diff --git a/apps/docs/content/docs/agents/mcp.mdx b/apps/docs/content/docs/agents/mcp.mdx index 9b4583ca502..b5f5adea4a4 100644 --- a/apps/docs/content/docs/agents/mcp.mdx +++ b/apps/docs/content/docs/agents/mcp.mdx @@ -117,16 +117,16 @@ Once MCP servers are configured, their tools become available within your agent /> -4. Select individual tools, or choose **Use all N tools** to add every tool from that server +4. Select individual tools, or choose **Configure operations access** for a dynamic server attachment 5. The agent can now access these tools during execution If you haven't configured a server yet, click **Add MCP Server** at the top of the dropdown to open the setup modal without leaving the block. -## Standalone MCP Tool Block +## Standalone MCP Block -For more granular control, you can use the dedicated MCP Tool block to execute specific MCP tools: +Use the MCP block to discover operations or run one operation with explicit inputs:
-The MCP Tool block runs one configured tool with parameters you set explicitly, and its output is readable by later blocks like any other. +Choose an **Action**: + +- **List operations** discovers authorized operation names, descriptions, and input schemas without executing provider operations. Filter by name or description, set a page size from 1 to 100, and pass `nextCursor` into the next request while `hasMore` is true. An authorized list can be empty. +- **Run operation** executes one exact operation name. Configured operations keep their generated argument fields. For an operation name resolved at runtime, supply a JSON arguments object; Sim validates it against the operation's discovered schema before execution. + +**MCP Server** takes one shared-server ID or managed-connection ID. Sim resolves a managed connection's parent server internally and verifies workspace and credential access. Both actions accept the same ID; List operations returns that ID as `serverId`, alongside the discovered `operations` and pagination metadata. + +The standalone block's Basic fields select a configured connection and discovered operation. Advanced fields accept literal IDs/names or upstream references. A runtime server reference requires JSON arguments. Listing hides the operation and argument fields. + +### Operations access + +The **MCP Server (Advanced)** Agent attachment takes a server/connection ID or upstream reference in a plain input. Its **Tool IDs** field accepts exact MCP tool names, such as `search_docs`, entered directly. These are the names returned by List operations, without a Sim server prefix. Neither field uses a server or operation catalog picker. + +Agent attachments have three access modes: + +| Mode | Behavior | +| --- | --- | +| **Only selected** | Allows only selected exact operation names. An empty selection allows nothing. Newly discovered names stay excluded. | +| **All except selected** | Denies selected exact names. An empty selection allows everything otherwise permitted. Denied names stay saved if they temporarily disappear. | +| **All permitted** | Allows every operation available to the authorized credential. | + +New restricted configurations start with an empty explicit selection. Existing saved workflows retain their prior access through normalization, while current organization and credential authorization still apply. + +Operation restrictions are saved in workflow state on the Agent attachment. Tool IDs are literal configuration, not upstream references or model arguments. An operation must be available to the resolved, authorized connection and permitted by the saved restriction. The standalone block runs its explicitly specified operation and has no separate access policy. + +Discovery filters the tools exposed to the Agent. Execution checks the actual server, connection, and saved restriction again before calling the provider. Missing or forbidden operations, unverifiable schemas, malformed arguments, and incorrect connection scopes fail the call. An Agent attachment with no permitted operations fails clearly. + +Policies match exact, case-sensitive MCP tool names on whichever authorized connection resolves at runtime. They do not inspect operation arguments: allowing a generic `execute_sql` operation does not limit which SQL it can execute. ## When to Use MCP Tool vs Agent diff --git a/apps/docs/content/docs/agents/skills.mdx b/apps/docs/content/docs/agents/skills.mdx index 5723fcf2d68..5690219d80a 100644 --- a/apps/docs/content/docs/agents/skills.mdx +++ b/apps/docs/content/docs/agents/skills.mdx @@ -4,6 +4,7 @@ description: Reusable instruction packages your agents load on demand, managed f --- import { Callout } from 'fumadocs-ui/components/callout' +import { Image } from '@/components/ui/image' Agent Skills are reusable packages of instructions that give your AI agents specialized capabilities. Based on the open [Agent Skills](https://agentskills.io) format, skills let you capture domain expertise, workflows, and best practices that agents can load on demand. @@ -93,7 +94,7 @@ Tagging a skill loads its full instructions into the conversation, so Sim follow Open any **Agent** block and find the **Skills** dropdown below the tools section. Select the skills you want the agent to have access to. -![Add Skill](/static/skills/add-skill.png) +Add Skill Selected skills appear as cards that you can click to edit or remove. diff --git a/apps/docs/content/docs/api-reference/(generated)/workspace-sync/meta.json b/apps/docs/content/docs/api-reference/(generated)/workspace-sync/meta.json new file mode 100644 index 00000000000..8cc588facc8 --- /dev/null +++ b/apps/docs/content/docs/api-reference/(generated)/workspace-sync/meta.json @@ -0,0 +1,24 @@ +{ + "pages": [ + "previewWorkflowImport", + "getWorkspaceForkAvailability", + "getWorkspaceForkLineage", + "listWorkspaceForkChildren", + "listWorkspaceForkResources", + "previewWorkspaceFork", + "forkWorkspace", + "getWorkspaceForkMappings", + "updateWorkspaceForkMappings", + "previewWorkspacePush", + "pushWorkspace", + "previewWorkspacePull", + "pullWorkspace", + "rollbackWorkspaceFork", + "unlinkWorkspaceFork", + "updateWorkspaceForkExclusions", + "listSelector", + "getSelector", + "getWorkspaceOperation", + "listWorkspaceOperations" + ] +} diff --git a/apps/docs/content/docs/api-reference/meta.json b/apps/docs/content/docs/api-reference/meta.json index 3b8d0a052bf..d6d47878d24 100644 --- a/apps/docs/content/docs/api-reference/meta.json +++ b/apps/docs/content/docs/api-reference/meta.json @@ -5,6 +5,7 @@ "---Getting Started---", "getting-started", "authentication", + "workflow-sync", "---SDKs---", "python", "typescript", @@ -16,6 +17,7 @@ "(generated)/files", "(generated)/knowledge-bases", "(generated)/workspaces", + "(generated)/workspace-sync", "(generated)/mcp-servers", "(generated)/skills", "(generated)/custom-tools", diff --git a/apps/docs/content/docs/api-reference/workflow-sync.mdx b/apps/docs/content/docs/api-reference/workflow-sync.mdx new file mode 100644 index 00000000000..dd0fe5249e0 --- /dev/null +++ b/apps/docs/content/docs/api-reference/workflow-sync.mdx @@ -0,0 +1,238 @@ +--- +title: Workflow imports and workspace sync +description: Use the v2 preview, mapping, apply, and operation-status protocol from an API or CLI client +--- + +An automated client exports or inspects the source, previews destination choices, applies the reviewed request, and polls its operation. The [CLI workflow guide](/cli/workflow-sync) follows this same v2 protocol; the server owns authorization, remapping, atomic writes, and durable completion for both surfaces. + +## CLI and HTTP interfaces + +Paths below are relative to `/api/v2`. `{workspaceId}` is the explicitly selected current workspace. + +| CLI command | HTTP request | +| --- | --- | +| `workflows export --include-references` | `GET /workflows/{workflowId}/export?includeReferences=true` | +| `workflows import-preview` / `workflows import` | `POST /workflows/import/preview` / `POST /workflows/import` | +| `workspaces fork-availability` / `lineage` | `GET /workspaces/{workspaceId}/fork/availability` / `.../fork/lineage` | +| `workspaces children` / `fork-resources` | `GET /workspaces/{workspaceId}/fork/children` / `.../fork/resources?kind=tables` | +| `workspaces fork-preview` / `fork` | `POST /workspaces/{workspaceId}/fork/preview` / `.../fork` | +| `workspaces push-preview` / `push --yes` | `POST /workspaces/{workspaceId}/fork/push/preview` / `.../fork/push` | +| `workspaces pull-preview` / `pull --yes` | `POST /workspaces/{workspaceId}/fork/pull/preview` / `.../fork/pull` | +| `workspaces mappings get` / `mappings update` | `GET` / `PUT /workspaces/{workspaceId}/fork/mappings` | +| `selectors list` / `selectors get` | `POST /selectors/list` / `POST /selectors/get` | +| `workspaces operations get ` / `operations wait ` | `GET /workspaces/{workspaceId}/operations/{operationId}`; wait polls this endpoint | +| `workspaces operations list` | `GET /workspaces/{workspaceId}/operations` | + +The generated [sync preview reference](/api-reference/workspace-sync/previewWorkspacePull) documents every field; its sidebar includes rollback, unlink, and exclusion endpoints. Children, resource discovery, mappings, and operations are paginated; follow `nextCursor` without changing the query's scope or filters. + +## Permissions + +Send a personal API key in `X-API-Key`, or an OAuth access token in `Authorization: Bearer …`. Existing workspace policies, credential access, permission groups, Enterprise/self-hosting gates, and workspace creation limits still apply. + +| Operation | Required access | +| --- | --- | +| Export | Workflow read access | +| Import preview and apply | Destination write access; binding credentials also requires an acting user with credential access | +| Fork discovery, preview, and creation | Source workspace admin | +| Sync preview/apply and mapping read/update | Admin on both workspaces on a direct fork edge | +| Rollback / unlink | Target admin / acting-side admin, respectively | +| Selector discovery | Read access in the discovery workspace and access to the selected credential | +| Operation get/list | Read access in the receipt workspace | + +Workspace API keys can import where existing authoring policy allows, but cannot bind credentials, administer forks, or execute selectors. Do not substitute a key owner for an acting user. OAuth import/fork/sync previews require `api:write`, even though preview does not commit changes. Export, fork discovery, mapping reads, selectors, and operation reads use `api:read`. + +## Portable imports + +Default exports remain sanitized. `includeReferences=true` adds a versioned manifest of registered resource IDs and source occurrences, including nested tools. It does not include secret values. Imported provenance and source IDs are labels; they never grant access to an alleged source workspace. + +Set `BASE_URL` to your deployment, and use an authorized key for each workspace. Raw HTTP returns `{ "data": ... }`; the CLI unwraps single-resource results. Extract the export payload before saving it: + +```sh +curl -sS --fail-with-body \ + -H "X-API-Key: $SOURCE_API_KEY" \ + "$BASE_URL/api/v2/workflows/$WORKFLOW_ID/export?includeReferences=true" \ + | jq '.data' > workflow.json +``` + +Save destination mappings as `import-mappings.json`, replacing the example IDs with manifest and destination resource IDs: + +```json +[ + { "kind": "credential", "sourceId": "source-connection", "targetId": "destination-connection" }, + { "kind": "sandbox", "sourceId": "source-sandbox", "targetId": "destination-sandbox" } +] +``` + +`mappings` applies to every occurrence of a resource's `kind` and `sourceId`. For older exports, `bindings` can address individual registered occurrences. Its entries are flat objects, without `sourceId` or an `occurrence` wrapper: + +```json +[ + { + "kind": "credential", + "blockId": "source-agent", + "subBlockKey": "tools", + "valuePath": [0, "params", "oauthCredential"], + "encoding": "scalar", + "targetId": "destination-connection" + } +] +``` + +A top-level field uses `valuePath: []`. Other registered encodings are `array`, `csv`, `files`, and `environment`; multi-value occurrences can include `positions`. Use the actual registered occurrence, rather than inventing paths. Conflicting instructions for one occurrence are rejected. `targetId: null` requests clearing; it cannot satisfy a required binding. Destination type, provider, and parent-child compatibility are validated. + +Build one request file and preview it: + +```sh +jq -n --arg workspaceId "$DESTINATION_WORKSPACE" \ + --slurpfile workflow workflow.json --slurpfile mappings import-mappings.json \ + '{workspaceId: $workspaceId, workflow: $workflow[0], mappings: $mappings[0]}' \ + > import-request.json + +curl -sS --fail-with-body -H "X-API-Key: $DESTINATION_API_KEY" \ + -H 'Content-Type: application/json' --data @import-request.json \ + "$BASE_URL/api/v2/workflows/import/preview" > import-preview.json +``` + +Inspect `data.unresolvedBindings`, `data.configuration`, `data.unresolvedConfiguration`, and `data.discovery`. Dependent choices use a different shape from bindings: + +```json +[ + { "blockId": "source-agent", "subBlockKey": "tools[0].folder", "value": "destination-label" } +] +``` + +Add these as `dependentValues` to `import-request.json` and preview again. For a field with `multiSelect: true`, `value` is a comma-separated string of selected IDs. For credential-backed selectors, discover options in the import's destination workspace using the returned `selectorKey` and `context`: + +```sh +curl -sS --fail-with-body -H "X-API-Key: $DESTINATION_API_KEY" \ + -H 'Content-Type: application/json' \ + --data "$(jq -n --arg workspaceId "$DESTINATION_WORKSPACE" \ + '{workspaceId: $workspaceId, selectorKey: "gmail.labels", context: {oauthCredential: "destination-connection"}, limit: 50}')" \ + "$BASE_URL/api/v2/selectors/list" +``` + +Selector lists return `{ "data": [...], "nextCursor": null, "truncated": false }`; check both pagination and truncation. Detail uses `/selectors/get` with the same scope/context and an `id`. MCP tools use `mcp.tools` with `mcpServerId`. A missing OAuth connection can require human authorization; changing a resource ID cannot create that connection. + +When `data.ready` is true, save a stable request ID and apply the exact reviewed choices: + +```sh +jq --arg requestId "$IMPORT_REQUEST_ID" \ + --arg fingerprint "$(jq -er '.data.previewFingerprint' import-preview.json)" \ + '. + {requestId: $requestId, previewFingerprint: $fingerprint}' \ + import-request.json > import-apply.json + +curl -sS --fail-with-body -H "X-API-Key: $DESTINATION_API_KEY" \ + -H 'Content-Type: application/json' --data @import-apply.json \ + "$BASE_URL/api/v2/workflows/import" > import-result.json +``` + +Mapped import creates a draft, its graph, variables, required inline custom tools, and receipt atomically. Remapping precedes graph ID regeneration; the result includes `idMap`. Plain imports without mapping options retain their earlier behavior and return no operation receipt. Supplying mapping options, even empty arrays, requires `requestId` and `previewFingerprint`. + +## Fork and sync + +Fork preview and apply share `{ "name": "Review environment", "copy": { "tables": ["source-table"] } }`. Apply adds `requestId` and the preview's fingerprint. Eligible deployed source workflows become child drafts; resource copies must be selected explicitly. + +Push sends deployed workflows from the current workspace to `otherWorkspaceId`. Pull sends them from `otherWorkspaceId` to the current workspace. Either endpoint works from either side of the direct parent/child edge. + +For example, save this as `sync-request.json` for a pull into the workspace in the URL: + +```json +{ + "otherWorkspaceId": "source-workspace", + "mappings": [ + { "resourceType": "oauth_credential", "sourceId": "source-connection", "targetId": "destination-connection" } + ], + "dependentValues": [ + { "sourceWorkflowId": "source-workflow", "sourceBlockId": "source-agent", "subBlockKey": "tools[0].folder", "value": "destination-label" } + ], + "copyResources": { "tables": ["source-table"] } +} +``` + +Sync mappings use edge `resourceType` names, such as `oauth_credential`, `service_account_credential`, `knowledge_base`, `custom_tool`, or `sandbox`; imports use `kind`, such as `credential`, `knowledge-base`, or `custom-tool`. Sync workflow identity is system-managed. Pick a knowledge document through its parent KB's dependent selector instead of writing a `knowledge_document` edge mapping. + +Mapping inspection requires `otherWorkspaceId` and `direction` in its query. Read rows also contain a storage `id`; write requests accept only `resourceType`, `sourceId`, and `targetId`. Project a page with `jq '[.data[] | {resourceType, sourceId, targetId}]'` before reusing its mappings, and follow `nextCursor` to collect further pages. + +Preview never saves inline mappings. Apply persists them with the sync transaction. Dependent overrides use source workflow/block/field identities, including original nested tool indices. Omission reuses saved choices; providing `dependentValues` replaces those choices for affected workflows, and `[]` clears them. Target draft values alone are not saved sync configuration. + +```sh +curl -sS --fail-with-body -H "X-API-Key: $SIM_API_KEY" \ + -H 'Content-Type: application/json' --data @sync-request.json \ + "$BASE_URL/api/v2/workspaces/$CURRENT_WORKSPACE/fork/pull/preview" > sync-preview.json + +jq --arg requestId "$SYNC_REQUEST_ID" \ + --arg fingerprint "$(jq -er '.data.previewFingerprint' sync-preview.json)" \ + '. + {requestId: $requestId, previewFingerprint: $fingerprint, confirm: true}' \ + sync-request.json > sync-apply.json + +curl -sS --fail-with-body -H "X-API-Key: $SIM_API_KEY" \ + -H 'Content-Type: application/json' --data @sync-apply.json \ + "$BASE_URL/api/v2/workspaces/$CURRENT_WORKSPACE/fork/pull" > sync-result.json +``` + +Review `unresolvedBindings`, `configuration`, planned workflow actions, and retiring trigger URLs before apply. Use each configuration field's `discoveryWorkspaceId` for `/selectors/list` or `/selectors/get`: source when its parent will be copied, destination when mapped. Pass its returned context and re-preview any changed choices. A ready preview is not a deployment-readiness report. + +`triggerSlots` lists stable `sourceWorkflowId` and `sourceBlockId` identities, `ownPath`, `adoptablePaths`, and `defaultAdoptPath`. A slot with `ownPath` preserves that URL and accepts no override. For an arriving trigger, `triggerMappings` can select an offered retiring path or `null` to request a new URL. Include the same choices on preview and apply: + +```json +[ + { "sourceWorkflowId": "source-workflow", "sourceBlockId": "source-trigger", "adoptPath": "retiring-trigger-path" } +] +``` + +Unknown source identities, duplicate choices, and paths outside that slot's candidates are rejected. Adoption candidates stay within the same target workflow and provider; do not construct them from a different workflow's URL. + +Copy selections use `copy` for fork creation and `copyResources` for sync. Fork `copy.files` contains workspace file IDs; sync `copyResources.files` contains storage keys. Credentials and secret values are not copied. `dropReferences` only acknowledges references deleted in the source; it cannot discard live source resources. + +Sync replaces eligible target workflows and schedules deployment of the admitted snapshots. Exclusions remain in effect. A deleted source can archive its mapped target; an undeployed source does not. Rollback restores the latest target sync's prior deployed versions, with no promise to restore arbitrary drafts or undo all resource copies. + +## Receipts, polling, and retries + +Apply returns a single-resource envelope. This is an example completed sync report: + +```json +{ + "data": { + "operationId": "operation-id", + "requestId": "release-2026-09-09", + "workspaceId": "current-workspace", + "kind": "workspace_pull", + "applied": true, + "status": "completed", + "resourceIds": ["destination-workflow"], + "issues": [], + "deployments": [ + { + "operationId": "deployment-id", + "workflowId": "destination-workflow", + "version": 2, + "status": "active", + "ready": true, + "pendingComponents": [] + } + ] + } +} +``` + +Always poll under the returned `workspaceId`. Import receipts belong to the destination. Fork-creation receipts belong to the source on which `/fork` ran. Push/pull receipts belong to the current workspace in the request URL, including a push that changes the other workspace. + +```sh +OPERATION_ID=$(jq -er '.data.operationId' sync-result.json) +OPERATION_WORKSPACE=$(jq -er '.data.workspaceId' sync-result.json) + +curl -sS --fail-with-body -H "X-API-Key: $SIM_API_KEY" \ + "$BASE_URL/api/v2/workspaces/$OPERATION_WORKSPACE/operations/$OPERATION_ID" +``` + +Poll with a delay while `status` is `processing`. Terminal outcomes are `completed`, `completed_with_warnings`, `requires_configuration`, and `failed`. Inspect `issues`, `copyProgress`, `deployments[].ready`, `pendingComponents`, and `triggerUrlChanges`. `applied: true` remains true after a follow-up failure: the transaction committed. Completed imports and forks remain drafts; a completed sync requires checking its admitted deployment results before treating the destination as ready. + +Request IDs deduplicate within the receipt workspace. Retain the complete apply request: identical authorized retries return the original operation before checking preview freshness. A changed payload under the same ID, a stale preview, or blocked apply returns HTTP `409` with `{ "error": { "code": "CONFLICT", "message": "...", "details": {} } }`. Other invalid inputs can return `400`; follow-up failures are reported on a committed operation. + +After an uncertain response, retry the identical request with the original ID, or query `GET /workspaces/{workspaceId}/operations?requestId=...`. Lists return `{ "data": [...], "nextCursor": ... }`; use operation get for refreshed completion status. If a stale preview is refused before commit, obtain a new preview and use a new request ID for the revised request. Never replace a lost-response request with a fresh ID merely to retry. + +## What the test harness covers + +Run `bun run test:workflow-sync` from the Sim repository with Bun, dependencies, and Docker available. It creates disposable PostgreSQL 17, exercises actual authorization, API-key authentication, v2 HTTP adapters, CLI subprocesses, graph/receipt transactions, locks, and deployment outbox workers, then removes the database. Tests cover concurrent retries, stale previews, atomic refusal, immutable deployment snapshots, exclusions, pagination, and copy-worker recovery. + +External provider options and failures use controlled fixtures; the separate realtime process uses an authenticated loopback fixture. This validates the platform protocol rather than proving every provider account is connected. Verify provider authorization and destination deployment readiness in the environment you intend to use. Migration safety is checked separately from this fresh-schema harness. diff --git a/apps/docs/content/docs/cli/commands.mdx b/apps/docs/content/docs/cli/commands.mdx index ac51f72acda..d5454bfcd66 100644 --- a/apps/docs/content/docs/cli/commands.mdx +++ b/apps/docs/content/docs/cli/commands.mdx @@ -45,6 +45,7 @@ These apply to every command, and may be written before or after it. | [`sim meta`](/cli/meta) | Manage meta | | [`sim sandboxes`](/cli/sandboxes) | Manage sandboxes | | [`sim secrets`](/cli/secrets) | Manage secrets | +| [`sim selectors`](/cli/selectors) | Manage selectors | | [`sim skills`](/cli/skills) | Manage skills | | [`sim tables`](/cli/tables) | Manage tables | | [`sim tools`](/cli/tools) | Manage tools | diff --git a/apps/docs/content/docs/cli/meta.json b/apps/docs/content/docs/cli/meta.json index 3b9bb713014..9a0b7bfd3b4 100644 --- a/apps/docs/content/docs/cli/meta.json +++ b/apps/docs/content/docs/cli/meta.json @@ -8,6 +8,7 @@ "configuration", "output", "scripting", + "workflow-sync", "troubleshooting", "---Commands---", "commands", @@ -26,6 +27,7 @@ "meta", "sandboxes", "secrets", + "selectors", "skills", "tables", "tools", diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 19cdae57d7f..22c00ff4e27 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -2992,6 +2992,50 @@ sim secrets set [options] +## sim selectors + +### sim selectors get + +Get Selector Option (OAuth login or personal API key required) + +```bash +sim selectors get [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). | +| `--id ` | Yes | Resource identifier. | + + + +### sim selectors list + +List Selector Options (OAuth login or personal API key required) + +```bash +sim selectors list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). | +| `--search ` | No | Provider option search text. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + + ## sim skills Also spelled `sim skill`. @@ -5430,7 +5474,7 @@ sim workflows run [options] Print a workflow as a portable JSON document ```bash -sim workflows export +sim workflows export [options] ``` **Arguments** @@ -5443,6 +5487,16 @@ sim workflows export +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--include-references` | No | Include non-secret resource identities for mapped import. | + + + ### sim workflows get Get Workflow @@ -5656,6 +5710,13 @@ sim workflows import [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--name ` | No | Override for the imported workflow name. | | `--description ` | No | Override for the imported workflow description. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--bindings ` | No | Resolved and unresolved source occurrences with their destination selections. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--request-id ` | No | Stable client request ID for reconciliation and identical retries. | +| `--preview-fingerprint ` | No | Fingerprint of the reviewed preview and its choices. | +| `--wait` | No | Wait for the committed operation to finish; missing configuration and failure exit nonzero. | +| `--wait-timeout ` | No | Maximum operation wait in seconds (default 3600; 0 waits indefinitely). | @@ -5703,6 +5764,30 @@ sim workflows move [options] +### sim workflows import-preview + +Preview Workflow Import + +```bash +sim workflows import-preview [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow ` | Yes | Workflow export object, bare workflow state, or JSON string containing either form. (JSON, or @path / @- to read a file or stdin). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `--name ` | No | Override for the imported workflow name. | +| `--description ` | No | Override for the imported workflow description. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--bindings ` | No | Resolved and unresolved source occurrences with their destination selections. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | + + + ### sim workflows restore Restore an archived workflow @@ -5907,6 +5992,29 @@ sim workflows mkdir Also spelled `sim workspace`. +### sim workspaces fork + +Fork Workspace (OAuth login or personal API key required) + +```bash +sim workspaces fork [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | Display name of the workflow or workspace. | +| `--copy ` | No | Explicit resource selections to copy into the new fork; omitted resource kinds are not copied. (JSON, or @path / @- to read a file or stdin). | +| `--request-id ` | Yes | Stable client request ID for reconciliation and identical retries. | +| `--preview-fingerprint ` | Yes | Fingerprint of the reviewed preview and its choices. | +| `--wait` | No | Wait for the committed operation to finish; missing configuration and failure exit nonzero. | +| `--wait-timeout ` | No | Maximum operation wait in seconds (default 3600; 0 waits indefinitely). | + + + ### sim workspaces get Get Workspace @@ -5915,6 +6023,174 @@ Get Workspace sim workspaces get ``` +### sim workspaces fork-availability + +Get Workspace Fork Availability (OAuth login or personal API key required) + +```bash +sim workspaces fork-availability +``` + +### sim workspaces lineage + +Get Workspace Fork Lineage (OAuth login or personal API key required) + +```bash +sim workspaces lineage +``` + +### sim workspaces mappings get + +Get Workspace Fork Mappings (OAuth login or personal API key required) + +```bash +sim workspaces mappings get [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--direction ` | Yes | Push means current to other; pull means other to current, independent of parent/child orientation. Accepted values: `push`, `pull`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Supported stable sort key for this collection. Accepted values: `id`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`. | + + + +### sim workspaces mappings update + +Update Workspace Fork Mappings (OAuth login or personal API key required) + +```bash +sim workspaces mappings update [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--direction ` | Yes | Push means current to other; pull means other to current, independent of parent/child orientation. Accepted values: `push`, `pull`. | +| `--mappings ` | Yes | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | + + + +### sim workspaces operations get + +Get Workspace Operation + +```bash +sim workspaces operations get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `operationId` | Yes | Durable operation identifier to use for polling. | + + + +### sim workspaces operations list + +List Workspace Operations + +```bash +sim workspaces operations list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--request-id ` | No | Stable client request ID for reconciliation and identical retries. | + + + +### sim workspaces operations wait + +Wait for copy and deployment readiness; exit 3 for configuration, 1 for failure, or 4 for timeout + +```bash +sim workspaces operations wait [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `operationId` | Yes | Operation ID returned by import, fork, push, or pull | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--wait-timeout ` | No | Maximum total wait (default 3600; 0 waits indefinitely). | + + + +### sim workspaces children + +List Workspace Fork Children (OAuth login or personal API key required) + +```bash +sim workspaces children [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Supported stable sort key for this collection. Accepted values: `createdAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `desc`. | + + + +### sim workspaces fork-resources + +List Workspace Fork Resources (OAuth login or personal API key required) + +```bash +sim workspaces fork-resources [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--kind ` | Yes | Resource or operation kind. Accepted values: `files`, `tables`, `knowledgeBases`, `customTools`, `skills`, `mcpServers`, `workflowMcpServers`. | +| `--sort-by ` | No | Supported stable sort key for this collection. Accepted values: `id`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`. | + + + ### sim workspaces members List workspace members @@ -5952,3 +6228,181 @@ sim workspaces list [options] | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | + +### sim workspaces fork-preview + +Preview Workspace Fork (OAuth login or personal API key required) + +```bash +sim workspaces fork-preview [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | Display name of the workflow or workspace. | +| `--copy ` | No | Explicit resource selections to copy into the new fork; omitted resource kinds are not copied. (JSON, or @path / @- to read a file or stdin). | + + + +### sim workspaces pull-preview + +Preview Workspace Pull (OAuth login or personal API key required) + +```bash +sim workspaces pull-preview [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--copy-resources ` | No | Explicit source resources to copy before syncing the workflows. (JSON, or @path / @- to read a file or stdin). | +| `--drop-references ` | No | Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped. (JSON, or @path / @- to read a file or stdin). | +| `--trigger-mappings ` | No | Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected. (JSON, or @path / @- to read a file or stdin). | + + + +### sim workspaces push-preview + +Preview Workspace Push (OAuth login or personal API key required) + +```bash +sim workspaces push-preview [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--copy-resources ` | No | Explicit source resources to copy before syncing the workflows. (JSON, or @path / @- to read a file or stdin). | +| `--drop-references ` | No | Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped. (JSON, or @path / @- to read a file or stdin). | +| `--trigger-mappings ` | No | Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected. (JSON, or @path / @- to read a file or stdin). | + + + +### sim workspaces pull + +Pull Workspace (OAuth login or personal API key required) + +```bash +sim workspaces pull [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--copy-resources ` | No | Explicit source resources to copy before syncing the workflows. (JSON, or @path / @- to read a file or stdin). | +| `--drop-references ` | No | Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped. (JSON, or @path / @- to read a file or stdin). | +| `--trigger-mappings ` | No | Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected. (JSON, or @path / @- to read a file or stdin). | +| `--request-id ` | Yes | Stable client request ID for reconciliation and identical retries. | +| `--preview-fingerprint ` | Yes | Fingerprint of the reviewed preview and its choices. | +| `--wait` | No | Wait for the committed operation to finish; missing configuration and failure exit nonzero. | +| `--wait-timeout ` | No | Maximum operation wait in seconds (default 3600; 0 waits indefinitely). | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim workspaces push + +Push Workspace (OAuth login or personal API key required) + +```bash +sim workspaces push [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--copy-resources ` | No | Explicit source resources to copy before syncing the workflows. (JSON, or @path / @- to read a file or stdin). | +| `--drop-references ` | No | Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped. (JSON, or @path / @- to read a file or stdin). | +| `--trigger-mappings ` | No | Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected. (JSON, or @path / @- to read a file or stdin). | +| `--request-id ` | Yes | Stable client request ID for reconciliation and identical retries. | +| `--preview-fingerprint ` | Yes | Fingerprint of the reviewed preview and its choices. | +| `--wait` | No | Wait for the committed operation to finish; missing configuration and failure exit nonzero. | +| `--wait-timeout ` | No | Maximum operation wait in seconds (default 3600; 0 waits indefinitely). | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim workspaces fork-rollback + +Rollback Workspace Fork (OAuth login or personal API key required) + +```bash +sim workspaces fork-rollback [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim workspaces unlink + +Unlink Workspace Fork (OAuth login or personal API key required) + +```bash +sim workspaces unlink [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim workspaces sync-exclusions + +Update Workspace Fork Exclusions (OAuth login or personal API key required) + +```bash +sim workspaces sync-exclusions [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow ` | Yes | Workflow identifiers in the current workspace. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--fork-sync-excluded ` | Yes | Whether the named workflows should be skipped as sync sources and targets. Accepted values: `true`, `false`. | + + diff --git a/apps/docs/content/docs/cli/selectors.mdx b/apps/docs/content/docs/cli/selectors.mdx new file mode 100644 index 00000000000..4a9dda8556d --- /dev/null +++ b/apps/docs/content/docs/cli/selectors.mdx @@ -0,0 +1,50 @@ +--- +title: Selectors +description: Manage selectors — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +Every command below also accepts the [global options](/cli/commands#global-options). + +## Get selector option + +```bash +sim selectors get [options] +``` + +Get Selector Option (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). | +| `--id ` | Yes | Resource identifier. | + + + +## List selector options + +```bash +sim selectors list [options] +``` + +List Selector Options (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--selector-key ` | Yes | Registered selector key for discovering this field’s destination options. Accepted values: `airtable.bases`, `airtable.tables`, `asana.workspaces`, `attio.lists`, `attio.objects`, `bigquery.datasets`, `bigquery.tables`, `bitbucket.workspaces`, `bitbucket.repositories`, `calcom.eventTypes`, `calcom.schedules`, `clickup.workspaces`, `clickup.spaces`, `clickup.folders`, `clickup.lists`, `confluence.spaces`, `confluence.spacesById`, `confluence.pages`, `google.tasks.lists`, `gmail.labels`, `google.calendar`, `google.drive`, `google.sheets`, `harmonic.savedSearches`, `hubspot.lists`, `hubspot.owners`, `hubspot.pipelines`, `hubspot.pipelineStages`, `hubspot.properties`, `jsm.requestTypes`, `jsm.serviceDesks`, `microsoft.planner.plans`, `notion.databases`, `notion.pages`, `netsuite.recordTypes`, `netsuite.asyncTasks`, `pipedrive.pipelines`, `sharepoint.lists`, `trello.boards`, `zoho_desk.organizations`, `zoho_desk.departments`, `zoho_desk.agents`, `zoom.meetings`, `slack.channels`, `snowflake.databases`, `snowflake.schemas`, `snowflake.tables`, `snowflake.warehouses`, `snowflake.roles`, `snowflake.fileFormats`, `snowflake.procedures`, `slack.users`, `outlook.folders`, `outlook.calendars`, `microsoft.teams`, `microsoft.chats`, `microsoft.channels`, `microsoft.planner`, `onedrive.files`, `onedrive.folders`, `sharepoint.sites`, `microsoft.excel`, `microsoft.excel.drives`, `microsoft.excel.sheets`, `microsoft.word`, `wealthbox.contacts`, `jira.issues`, `jira.projects`, `linear.projects`, `linear.teams`, `monday.boards`, `monday.groups`, `webflow.sites`, `webflow.collections`, `webflow.items`, `cloudwatch.logGroups`, `cloudwatch.logStreams`, `imap.mailboxes`, `mcp.tools`, `managedAgent.agents`, `managedAgent.environments`, `managedAgent.vaults`, `managedAgent.memoryStores`, `knowledge.documents`, `sim.workflows`, `table.columns`, `table.outputColumns`, `workspace.secretNames`, `workspace.sandboxes`, `providers.ollamaEmbeddingModels`, `providers.openrouterEmbeddingModels`. | +| `--context ` | No | Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization. (JSON, or @path / @- to read a file or stdin). | +| `--search ` | No | Provider option search text. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | + + diff --git a/apps/docs/content/docs/cli/workflow-sync.mdx b/apps/docs/content/docs/cli/workflow-sync.mdx new file mode 100644 index 00000000000..076494631f3 --- /dev/null +++ b/apps/docs/content/docs/cli/workflow-sync.mdx @@ -0,0 +1,162 @@ +--- +title: Workflow imports and workspace sync +description: Preview resource bindings, apply reviewed changes, and verify readiness from an LLM or CI client +--- + +Use an explicit profile and workspace for every environment. Fork administration requires a personal API key or OAuth login; workspace API keys cannot administer fork edges. Fork creation requires source admin access. Sync and mapping changes require admin access on both workspaces. + +These commands use the same v2 operations as a direct API client. See the [API workflow guide](/api-reference/workflow-sync) for HTTP requests, response envelopes, and the command-to-endpoint mapping. CLI JSON output unwraps single-resource responses, so read `.previewFingerprint`; raw HTTP clients read `.data.previewFingerprint`. + +## Import a workflow with destination bindings + +Portable export is opt-in. It adds a versioned reference manifest to the sanitized graph; credentials and secret values stay in their original workspace. + +```sh +sim --profile source --workspace "$SOURCE_WORKSPACE" --output json \ + workflows export "$WORKFLOW_ID" --include-references > workflow.json + +sim --profile destination --workspace "$DESTINATION_WORKSPACE" --output json \ + workflows import-preview --workflow @workflow.json > preview.json +``` + +Read `unresolvedBindings` and `unresolvedConfiguration`. Resource mappings select a destination by resource kind and source ID. For example, `mappings.json` can contain: + +```json +[ + { "kind": "credential", "sourceId": "source-connection", "targetId": "destination-connection" }, + { "kind": "sandbox", "sourceId": "source-sandbox", "targetId": "destination-sandbox" } +] +``` + +Use the existing credentials, tables, files, sandboxes, and other resource commands to discover or create destination resources. An older export without reference metadata can use `--bindings @bindings.json` to address individual source fields. For example, a nested Agent tool's credential binding is: + +```json +[ + { + "kind": "credential", + "blockId": "source-agent", + "subBlockKey": "tools", + "valuePath": [0, "params", "oauthCredential"], + "encoding": "scalar", + "targetId": "destination-connection" + } +] +``` + +Use the field path and encoding registered for the actual export. A top-level field uses its own `subBlockKey` and `valuePath: []`. Field bindings have no `sourceId`; they address an occurrence directly. Conflicting resource mappings and field bindings are rejected. Include `--bindings` on both preview and apply when using them. + +Dependent choices use the selector key and destination context returned by preview. For example: + +```sh +sim --profile destination --workspace "$DESTINATION_WORKSPACE" --output json \ + selectors list --selector-key gmail.labels \ + --context '{"oauthCredential":"destination-connection"}' +``` + +MCP tool discovery uses `mcp.tools` with `mcpServerId`. OAuth connections may require a human to authorize the provider before discovery can succeed. + +Import dependent values use source block IDs and field keys: + +```json +[{ "blockId": "source-agent", "subBlockKey": "tools[0].folder", "value": "destination-label" }] +``` + +Preview again with the exact choices, then apply with that fingerprint and a request ID saved by your client: + +```sh +sim --profile destination --workspace "$DESTINATION_WORKSPACE" --output json \ + workflows import-preview --workflow @workflow.json \ + --mappings @mappings.json --dependent-values @values.json > preview.json + +sim --profile destination --workspace "$DESTINATION_WORKSPACE" --output json \ + workflows import --workflow @workflow.json \ + --mappings @mappings.json --dependent-values @values.json \ + --preview-fingerprint "$(jq -r .previewFingerprint preview.json)" \ + --request-id "$REQUEST_ID" --wait +``` + +Mapped import creates a draft atomically after required bindings and configuration are resolved. The receipt includes the imported IDs and `idMap`. Imports without mapping options retain the existing behavior; they do not return a durable operation receipt. Supplying mapping options, even an empty `--mappings '[]'`, requires both `--request-id` and `--preview-fingerprint`. JSON flags accept `@file` and `@-` for stdin. + +## Fork, push, and pull + +Inspect `workspaces fork-availability`, `lineage`, and `fork-resources`, then use `fork-preview` and `fork` with identical name and copy selections. A fork creates child drafts; resource copying must be explicitly selected. + +```sh +sim --profile source --workspace "$SOURCE_WORKSPACE" --output json \ + workspaces fork-resources --kind tables --limit 50 + +sim --profile source --workspace "$SOURCE_WORKSPACE" --output json \ + workspaces fork-preview --name "Review environment" \ + --copy '{"tables":["source-table"]}' > fork-preview.json + +sim --profile source --workspace "$SOURCE_WORKSPACE" --output json \ + workspaces fork --name "Review environment" \ + --copy '{"tables":["source-table"]}' \ + --preview-fingerprint "$(jq -r .previewFingerprint fork-preview.json)" \ + --request-id "$FORK_REQUEST_ID" --wait +``` + +Push means the current workspace sends its deployed workflows to `--other-workspace-id`. Pull means the other workspace sends them to the current workspace. These meanings are the same on either side of the edge. + +```sh +sim --profile destination --workspace "$DESTINATION_WORKSPACE" --output json \ + workspaces pull-preview --other-workspace-id "$SOURCE_WORKSPACE" > sync-preview.json + +sim --profile destination --workspace "$DESTINATION_WORKSPACE" --output json \ + workspaces pull --other-workspace-id "$SOURCE_WORKSPACE" \ + --preview-fingerprint "$(jq -r .previewFingerprint sync-preview.json)" \ + --request-id "$SYNC_REQUEST_ID" --yes --wait +``` + +Include the same inline mappings and dependent values on preview and apply. Sync values identify `sourceWorkflowId`, `sourceBlockId`, and `subBlockKey`; preview target IDs are not stable public override identities. Sync mapping entries use `resourceType`, `sourceId`, and `targetId` as returned by mapping inspection; import entries use `kind`. Inline sync mappings persist on the fork edge in the same transaction as the sync. + +`workspaces mappings get` also returns a storage `id` on each row. Strip it before reusing a result as input: `jq '[.data[] | {resourceType, sourceId, targetId}]'`. Use `oauth_credential` or `service_account_credential` for sync credential mappings, according to the credential type. + +For example, `--mappings` and `--dependent-values` take these respective arrays: + +```json +[{ "resourceType": "oauth_credential", "sourceId": "source-connection", "targetId": "destination-connection" }] +``` + +```json +[{ "sourceWorkflowId": "source-workflow", "sourceBlockId": "source-agent", "subBlockKey": "tools[0].folder", "value": "destination-label" }] +``` + +Use `--copy-resources` for sync copies, such as `'{"tables":["source-table"]}'`; fork creation uses `--copy`. Fork `copy.files` takes workspace file IDs; sync `copyResources.files` takes storage keys. Neither copies credentials or secret values. + +For each sync configuration field, run selector discovery in its `discoveryWorkspaceId` with the returned context. This is the source workspace when the parent resource is being copied, and the destination workspace for an existing mapping. Use the returned field key unchanged, including nested tool indices. When `multiSelect` is true, send selected IDs as one comma-separated string. + +Saved sync choices apply when `--dependent-values` is omitted. Supplying the flag replaces saved choices for the affected workflows; an explicit `[]` clears them. Values that exist only in a target draft are not saved sync choices. + +Sync preview's `ready` describes whether the change can commit; it does not report deployment readiness. Sync replaces eligible target workflows and deploys the admitted snapshots. It preserves exclusions. Deleting a source can archive its mapped target; merely undeploying it does not. Rollback restores the latest target sync using prior deployed versions, and does not restore arbitrary drafts or undo every resource copy. + +Inspect preview's `triggerSlots` before replacing webhook triggers. Slots with `ownPath` keep it. For other slots, `--trigger-mappings` accepts entries with `sourceWorkflowId`, `sourceBlockId`, and `adoptPath` selected from `adoptablePaths`, or `null` for a new URL. Pass the identical choices to preview and apply; unknown, duplicate, and unavailable choices are rejected. + +## Reconcile completion and retries + +A successful apply returns `operationId`, `requestId`, `applied`, `status`, resource IDs, and structured issues. `applied: true` means the transaction committed, even if later copying or deployment fails. + +Poll in the receipt's `workspaceId`, which may differ from the workspace receiving workflows: + +| Operation | Receipt workspace | +| --- | --- | +| Import | Destination workspace | +| Fork creation | Source workspace on which `fork` ran | +| Push or pull | Current `--workspace` on which the command ran | + +```sh +sim --profile automation --workspace "$OPERATION_WORKSPACE_ID" --output json \ + workspaces operations wait "$OPERATION_ID" --wait-timeout 300 +``` + +If the response was lost, use `workspaces operations list --request-id "$REQUEST_ID"` in that same workspace, or retry the identical mutation. List results retain `{ "data": [...], "nextCursor": ... }`; single operation and preview results are unwrapped. Operation lists contain stored snapshots; use `operations get` or `operations wait` to refresh completion status. + +Verify deployment readiness before treating a synced environment as ready. Inspect trigger URL changes and configuration issues in the report. Completion with warnings exits successfully; required configuration exits 3, failed completion exits 1, and wait timeout exits 4. Timeout and uncertain-mutation diagnostics retain reconciliation IDs. + +After a lost response, retry the exact mutation with the original request ID. Identical retries return the same operation. Changing inputs under that ID returns 409. If a preview is stale and nothing committed, request a new preview and use a new request ID for the revised request. The CLI never invents a fresh ID to retry an uncertain mutation. + +## Validation boundary + +The repository's `bun run test:workflow-sync` harness starts a disposable PostgreSQL database and exercises real authorization, v2 HTTP adapters, CLI subprocesses, transactions, receipts, and deployment workers. External provider responses and the separate realtime process use controlled fixtures. This covers platform behavior and failure recovery; a provider connection still needs validation in the destination environment. + +Completed imports and forks are drafts. For sync, check the report's deployment readiness and copied-resource progress before using the environment. diff --git a/apps/docs/content/docs/cli/workflows.mdx b/apps/docs/content/docs/cli/workflows.mdx index b1340cc251c..7dbfc39efc2 100644 --- a/apps/docs/content/docs/cli/workflows.mdx +++ b/apps/docs/content/docs/cli/workflows.mdx @@ -552,7 +552,7 @@ sim workflows run [options] ## Print a workflow as a portable JSON document ```bash -sim workflows export +sim workflows export [options] ``` **Arguments** @@ -565,6 +565,16 @@ sim workflows export +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--include-references` | No | Include non-secret resource identities for mapped import. | + + + ## Get workflow ```bash @@ -764,6 +774,13 @@ sim workflows import [options] | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | | `--name ` | No | Override for the imported workflow name. | | `--description ` | No | Override for the imported workflow description. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--bindings ` | No | Resolved and unresolved source occurrences with their destination selections. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--request-id ` | No | Stable client request ID for reconciliation and identical retries. | +| `--preview-fingerprint ` | No | Fingerprint of the reviewed preview and its choices. | +| `--wait` | No | Wait for the committed operation to finish; missing configuration and failure exit nonzero. | +| `--wait-timeout ` | No | Maximum operation wait in seconds (default 3600; 0 waits indefinitely). | @@ -807,6 +824,28 @@ sim workflows move [options] +## Preview workflow import + +```bash +sim workflows import-preview [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow ` | Yes | Workflow export object, bare workflow state, or JSON string containing either form. (JSON, or @path / @- to read a file or stdin). | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `--name ` | No | Override for the imported workflow name. | +| `--description ` | No | Override for the imported workflow description. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--bindings ` | No | Resolved and unresolved source occurrences with their destination selections. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | + + + ## Restore an archived workflow ```bash diff --git a/apps/docs/content/docs/cli/workspaces.mdx b/apps/docs/content/docs/cli/workspaces.mdx index d92c8eb3d26..b64bdbc3e72 100644 --- a/apps/docs/content/docs/cli/workspaces.mdx +++ b/apps/docs/content/docs/cli/workspaces.mdx @@ -9,12 +9,197 @@ import { CommandTable } from '@/components/ui/command-table' Every command below also accepts the [global options](/cli/commands#global-options). +## Fork workspace + +```bash +sim workspaces fork [options] +``` + +Fork Workspace (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | Display name of the workflow or workspace. | +| `--copy ` | No | Explicit resource selections to copy into the new fork; omitted resource kinds are not copied. (JSON, or @path / @- to read a file or stdin). | +| `--request-id ` | Yes | Stable client request ID for reconciliation and identical retries. | +| `--preview-fingerprint ` | Yes | Fingerprint of the reviewed preview and its choices. | +| `--wait` | No | Wait for the committed operation to finish; missing configuration and failure exit nonzero. | +| `--wait-timeout ` | No | Maximum operation wait in seconds (default 3600; 0 waits indefinitely). | + + + ## Get workspace ```bash sim workspaces get ``` +## Get workspace fork availability + +```bash +sim workspaces fork-availability +``` + +Get Workspace Fork Availability (OAuth login or personal API key required) + +## Get workspace fork lineage + +```bash +sim workspaces lineage +``` + +Get Workspace Fork Lineage (OAuth login or personal API key required) + +## Get workspace fork mappings + +```bash +sim workspaces mappings get [options] +``` + +Get Workspace Fork Mappings (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--direction ` | Yes | Push means current to other; pull means other to current, independent of parent/child orientation. Accepted values: `push`, `pull`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Supported stable sort key for this collection. Accepted values: `id`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`. | + + + +## Update workspace fork mappings + +```bash +sim workspaces mappings update [options] +``` + +Update Workspace Fork Mappings (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--direction ` | Yes | Push means current to other; pull means other to current, independent of parent/child orientation. Accepted values: `push`, `pull`. | +| `--mappings ` | Yes | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | + + + +## Get workspace operation + +```bash +sim workspaces operations get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `operationId` | Yes | Durable operation identifier to use for polling. | + + + +## List workspace operations + +```bash +sim workspaces operations list [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--request-id ` | No | Stable client request ID for reconciliation and identical retries. | + + + +## Wait for copy and deployment readiness; exit 3 for configuration, 1 for failure, or 4 for timeout + +```bash +sim workspaces operations wait [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `operationId` | Yes | Operation ID returned by import, fork, push, or pull | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--wait-timeout ` | No | Maximum total wait (default 3600; 0 waits indefinitely). | + + + +## List workspace fork children + +```bash +sim workspaces children [options] +``` + +List Workspace Fork Children (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--sort-by ` | No | Supported stable sort key for this collection. Accepted values: `createdAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `desc`. | + + + +## List workspace fork resources + +```bash +sim workspaces fork-resources [options] +``` + +List Workspace Fork Resources (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | +| `--kind ` | Yes | Resource or operation kind. Accepted values: `files`, `tables`, `knowledgeBases`, `customTools`, `skills`, `mcpServers`, `workflowMcpServers`. | +| `--sort-by ` | No | Supported stable sort key for this collection. Accepted values: `id`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`. | + + + ## List workspace members ```bash @@ -48,3 +233,181 @@ sim workspaces list [options] | `--limit ` | No | Maximum items to return (0 for everything). Defaults to `0`. | + +## Preview workspace fork + +```bash +sim workspaces fork-preview [options] +``` + +Preview Workspace Fork (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | Display name of the workflow or workspace. | +| `--copy ` | No | Explicit resource selections to copy into the new fork; omitted resource kinds are not copied. (JSON, or @path / @- to read a file or stdin). | + + + +## Preview workspace pull + +```bash +sim workspaces pull-preview [options] +``` + +Preview Workspace Pull (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--copy-resources ` | No | Explicit source resources to copy before syncing the workflows. (JSON, or @path / @- to read a file or stdin). | +| `--drop-references ` | No | Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped. (JSON, or @path / @- to read a file or stdin). | +| `--trigger-mappings ` | No | Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected. (JSON, or @path / @- to read a file or stdin). | + + + +## Preview workspace push + +```bash +sim workspaces push-preview [options] +``` + +Preview Workspace Push (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--copy-resources ` | No | Explicit source resources to copy before syncing the workflows. (JSON, or @path / @- to read a file or stdin). | +| `--drop-references ` | No | Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped. (JSON, or @path / @- to read a file or stdin). | +| `--trigger-mappings ` | No | Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected. (JSON, or @path / @- to read a file or stdin). | + + + +## Pull workspace + +```bash +sim workspaces pull [options] +``` + +Pull Workspace (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--copy-resources ` | No | Explicit source resources to copy before syncing the workflows. (JSON, or @path / @- to read a file or stdin). | +| `--drop-references ` | No | Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped. (JSON, or @path / @- to read a file or stdin). | +| `--trigger-mappings ` | No | Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected. (JSON, or @path / @- to read a file or stdin). | +| `--request-id ` | Yes | Stable client request ID for reconciliation and identical retries. | +| `--preview-fingerprint ` | Yes | Fingerprint of the reviewed preview and its choices. | +| `--wait` | No | Wait for the committed operation to finish; missing configuration and failure exit nonzero. | +| `--wait-timeout ` | No | Maximum operation wait in seconds (default 3600; 0 waits indefinitely). | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Push workspace + +```bash +sim workspaces push [options] +``` + +Push Workspace (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `--mappings ` | No | Mappings keyed by resource type and source identifier. (JSON, or @path / @- to read a file or stdin). | +| `--dependent-values ` | No | Destination-dependent choices keyed by source workflow, block, and field identities. (JSON, or @path / @- to read a file or stdin). | +| `--copy-resources ` | No | Explicit source resources to copy before syncing the workflows. (JSON, or @path / @- to read a file or stdin). | +| `--drop-references ` | No | Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped. (JSON, or @path / @- to read a file or stdin). | +| `--trigger-mappings ` | No | Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected. (JSON, or @path / @- to read a file or stdin). | +| `--request-id ` | Yes | Stable client request ID for reconciliation and identical retries. | +| `--preview-fingerprint ` | Yes | Fingerprint of the reviewed preview and its choices. | +| `--wait` | No | Wait for the committed operation to finish; missing configuration and failure exit nonzero. | +| `--wait-timeout ` | No | Maximum operation wait in seconds (default 3600; 0 waits indefinitely). | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Rollback workspace fork + +```bash +sim workspaces fork-rollback [options] +``` + +Rollback Workspace Fork (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Unlink workspace fork + +```bash +sim workspaces unlink [options] +``` + +Unlink Workspace Fork (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--other-workspace-id ` | Yes | Workspace on the other side of the direct fork edge. | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Update workspace fork exclusions + +```bash +sim workspaces sync-exclusions [options] +``` + +Update Workspace Fork Exclusions (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow ` | Yes | Workflow identifiers in the current workspace. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--fork-sync-excluded ` | Yes | Whether the named workflows should be skipped as sync sources and targets. Accepted values: `true`, `false`. | + + diff --git a/apps/docs/content/docs/integrations/atlassian-service-account.mdx b/apps/docs/content/docs/integrations/atlassian-service-account.mdx index 4beb807c500..b0dbd6d5b3c 100644 --- a/apps/docs/content/docs/integrations/atlassian-service-account.mdx +++ b/apps/docs/content/docs/integrations/atlassian-service-account.mdx @@ -1,177 +1,101 @@ --- title: Atlassian Service Accounts -description: Set up an Atlassian service account with a scoped API token to use Jira, Jira Service Management, and Confluence in Sim workflows +description: Connect Jira, Jira Service Management, and Confluence workflows with a scoped service-account API token --- import { Callout } from 'fumadocs-ui/components/callout' -import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' -import { FAQ } from '@/components/ui/faq' - -Use an Atlassian service account with a scoped API token to connect Jira, Jira Service Management, and Confluence. Grant the account access to the products, projects, and spaces your workflows need. +Use an Atlassian service account for Jira, Jira Service Management, and Confluence workflows. One credential can serve all three products on the same site when its account access and token scopes cover each product. + +Setting up Search? Follow the [Confluence Search service-account guide](/search/confluence#using-a-service-account) for its content and permission scopes. [Jira Search](/search/jira) uses each teammate's OAuth account; a workflow service account does not replace that connection. + -One service account covers all three products. You add it once, and it appears as a connected credential on the Jira, Jira Service Management, and Confluence integration pages alike — there is no separate credential to create per product. +## Create the account and token -## Prerequisites +An **Atlassian organization admin** completes these steps: -You need an Atlassian organization admin to create the service account. Service accounts are an Atlassian organization-level feature — they cannot be created from a regular user account. +1. Open [Atlassian Administration](https://admin.atlassian.com/), select the organization, then **Directory → Service accounts → Create service account**. +2. Give the account access to the intended site's Jira and/or Confluence apps. Grant the project and space permissions its workflows need, including access to restricted content. +3. Select the service account, then **Create credentials → API token → Next**. +4. Name the token, set an expiry between 1 and 365 days, and select **Next**. +5. Select the scopes below for the products and operations you need. Review and create the token, then copy it. Atlassian shows it only once. -## Setting Up the Service Account +See Atlassian's [service-account setup](https://support.atlassian.com/user-management/docs/manage-your-service-accounts/) and [token instructions](https://support.atlassian.com/user-management/docs/manage-api-tokens-for-service-accounts/). -### 1. Create the Service Account +Atlassian Administration credential selector with API token selected - - - Open [admin.atlassian.com](https://admin.atlassian.com/) and go to **Directory** → **Service accounts** +## Choose scopes - {/* TODO(screenshot): admin.atlassian.com directory page with the "Service accounts" tab highlighted */} - - - Click **Create service account**, give it a name (e.g. `sim-jira-bot`), and finish creation - - - Grant the service account access to the Atlassian sites and products it needs. Open the service account, go to **Product access**, and add Jira and/or Confluence on the relevant site +Start with the connection and read scopes for each product you will use. These cover Sim's account validation, pickers, and common read operations; individual operations may need additional scopes. - {/* TODO(screenshot): service account "Product access" tab showing Jira granted on a site */} - - +### Jira and Jira Service Management - -The service account inherits permissions from the project/space roles you grant it — exactly like a human user. If a workflow needs to write to a specific Jira project, give the service account write access to that project in Jira's project settings. - +```text +read:jira-user +read:jira-work +``` -### 2. Create a Scoped API Token - - - - From the service account's page in admin.atlassian.com, open the **API tokens** tab and click **Create API token** - - {/* TODO(screenshot): service account API tokens tab with "Create API token" button */} - - - Choose **API token** as the authentication type (not OAuth 2.0 — Sim uses the API token flow) - -
- Atlassian admin — Choose authentication type with API token selected -
-
- - Select the scopes the token needs. The minimum set Sim's Jira and Confluence blocks expect is: - - **Jira (classic):** - ``` - read:jira-user - read:jira-work - write:jira-work - ``` - - **Jira Service Management (classic):** - ``` - read:servicedesk-request - write:servicedesk-request - manage:servicedesk-customer - ``` - - **Confluence (classic and granular):** - ``` - read:confluence-content.all - read:confluence-space.summary - write:confluence-content - read:page:confluence - write:page:confluence - ``` - - Add more scopes only if you need the corresponding operations (delete, manage webhooks, etc.). The full list of scopes Sim's blocks may use is documented in [Atlassian's developer reference](https://developer.atlassian.com/cloud/jira/platform/scopes-for-oauth-2-3LO-and-forge-apps/). - - - Prefer the classic scopes above over granular equivalents. Atlassian enforces an endpoint's granular scope list as all-or-nothing, so a token built from a partial granular set fails with `Unauthorized; scope does not match` even though each individual scope was granted. The classic scopes each cover their product's endpoints on their own. If your organization only permits granular scopes, include every scope listed for each endpoint in Atlassian's reference — Jira Service Management request operations also require `read:user:jira`. - - -
- Atlassian token scope picker filtered to App: Jira and Scope type: Classic -
- - - Use the **App** and **Scope type** filters to narrow the list to the scopes you need. Filter by `App: Jira` (or `Confluence`) and `Scope type: Classic` to find the three core Jira scopes; switch to **Granular** if your org doesn't expose Classic. - -
- - Copy the token when it is shown and record its expiration date in Atlassian Administration. Create a replacement before it expires; Sim does not refresh a pasted API token. - -
- - -The API token is bearer credentials for the service account. Treat it like a password — do not commit it to source control or share it publicly. Sim encrypts the token at rest. - +`read:jira-user` covers the [current-user check](https://developer.atlassian.com/cloud/jira/platform/rest/v3/api-group-myself/#api-rest-api-3-myself-get) when you add the credential, including through Jira Service Management. Add `read:servicedesk-request` for Service Management requests. -### 3. Find Your Site Domain +### Confluence -Enter only the host from your Jira or Confluence URL, such as `your-team.atlassian.net`; omit `https://` and any path. +```text +read:confluence-user +read:confluence-content.all +read:confluence-space.summary +read:space:confluence +read:page:confluence +``` -## Adding the Service Account to Sim +`read:confluence-user` covers the [current-user check](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-users/#api-wiki-rest-api-user-current-get). The [space picker](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space/#api-spaces-get) needs `read:space:confluence`; page reads and the page picker need `read:page:confluence`. - - - Open **Integrations** in your workspace sidebar - - - Open **Jira**, **Jira Service Management**, or **Confluence** — any of the three works, since they share one service account +### Workflow actions - {/* TODO(screenshot): Integrations page with Jira in the list */} - - - Click **Add to Sim** and choose **Add service account** +Add scopes for the actions your workflow performs: - {/* TODO(screenshot): Jira integration page with the "Add to Sim" dropdown open */} - - - Paste the API token, enter the site domain (e.g. `your-team.atlassian.net`), and optionally set a display name and description +| Actions | Scopes to add | +| --- | --- | +| Create or update Jira issues | `write:jira-work` | +| Create or update Service Management requests | `write:servicedesk-request` | +| Manage Service Management customers | `manage:servicedesk-customer` | +| Create or update Confluence content | `write:confluence-content`, `write:page:confluence` | +Delete, webhook, Assets, and other operations can require additional scopes. Check the specific endpoint in the [Jira](https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/), [Jira Service Management](https://developer.atlassian.com/cloud/jira/service-desk/rest/intro/), or [Confluence](https://developer.atlassian.com/cloud/confluence/rest/v2/intro/) API reference. Include the complete scope set for that endpoint; a classic scope does not cover every API in its product. - - - Click **Add service account**. Sim resolves the site and checks the token against the selected product's identity endpoint. Review any connection error before continuing. - - +Use the **App** and **Scope type** filters to find both classic and granular scopes. Scopes and account permissions are separate: the account must also have access to the project, space, or content. -The token, domain, and discovered cloudId are encrypted before being stored. +Atlassian scope picker filtered to Jira classic scopes -Once added, the credential is listed under **Connected** on all three Atlassian integration pages. It is named after the service account's own Atlassian display name, so several service accounts on the same site stay easy to tell apart. +## Add the credential to Sim -## Using the Service Account in Workflows +1. Open **Integrations** in your workspace sidebar, then **Jira**, **Jira Service Management**, or **Confluence**. +2. Select **Add to Sim → Add service account**. If only service accounts are available, select **Add service account** directly. +3. Paste the **API token** and enter **Site domain**, such as `your-team.atlassian.net`. Omit `https://` and any path. Optionally add a display name and description. +4. Select **Add service account**. Sim checks the token against the selected product's current-user endpoint; resolve any error before continuing. +5. In your workflow's Jira, Jira Service Management, or Confluence block, select the credential and configure the operation. -Add a Jira, Jira Service Management, or Confluence block to your workflow. In the credential dropdown, your Atlassian service account appears alongside any OAuth credentials. Select it and configure the block as you normally would. +The credential appears on all three integration pages, but adding it only validates the selected product. Check account access and scopes before using another product. Sim encrypts the token at rest and calls Atlassian as the service account. -
- Jira block in a workflow with the Atlassian service account selected as the credential -
+## Troubleshooting and rotation -The block calls Atlassian's API gateway (`api.atlassian.com/ex/jira/{cloudId}/...`) using the service account's token. There's no impersonation step — the service account acts as itself, with whatever permissions you granted it in admin.atlassian.com. +| Problem | What to check | +| --- | --- | +| Cannot add the credential | Use a scoped API token from **Directory → Service accounts**, verify its expiry and site, and include the selected product's current-user scope above. | +| Empty or failed picker | Include `read:jira-work` for Jira projects or `read:space:confluence` for Confluence spaces, and grant the account access to the selected site and content. | +| A workflow returns a scope or permission error | Check the operation's full scope list and the account's project/space permissions. Successful connection does not validate every operation. | +| Token expires or needs different scopes | Create a replacement token, add it as a new Sim service-account credential, and select it in the affected workflows. Test them before revoking the old token. Sim does not refresh pasted API tokens. | +| Data Center or Server host | This credential supports Atlassian Cloud only. | - +For a Search source, follow [Confluence's indexing-account replacement steps](/search/confluence#using-a-service-account). diff --git a/apps/docs/content/docs/knowledgebase/connectors.mdx b/apps/docs/content/docs/knowledgebase/connectors.mdx index 9d03f0be246..52da0ef7f74 100644 --- a/apps/docs/content/docs/knowledgebase/connectors.mdx +++ b/apps/docs/content/docs/knowledgebase/connectors.mdx @@ -14,7 +14,7 @@ Connectors continuously sync documents from external services into your knowledg ## Available Connectors -The current Connect Source picker showing searchable connectors including Airtable, Asana, Ashby, Azure DevOps, Bitbucket, Box, and ClickUp +The current Connect Source picker showing searchable connectors including Airtable, Asana, Ashby, Azure DevOps, Bitbucket, Box, and ClickUp Sim ships with 64 built-in connectors: @@ -125,7 +125,7 @@ Click **Connect & Sync** to save the connector and trigger the first sync. Docum Open **Connected Sources** from the knowledge base to see all active connectors. Each card shows the connector's status, the last sync time and document count, and the next scheduled sync: -Connected Sources panel showing a Google Docs connector with Active status, last sync details, and a sync history log with dated entries +Connected Sources panel showing a Google Docs connector with Active status, last sync details, and a sync history log with dated entries The action buttons on each connector card: @@ -159,7 +159,7 @@ The log retains the most recent 10 sync runs. Sometimes a connector syncs documents you don't want in your knowledge base — drafts, templates, confidential pages, and so on. You can exclude them individually. -Edit Google Docs modal showing the Documents tab with Active (37) and Excluded (0) filter buttons and a 'No excluded documents' message +Edit Google Docs modal showing the Documents tab with Active (37) and Excluded (0) filter buttons and a 'No excluded documents' message To exclude a document, open the connector's settings modal, go to the **Documents** tab, and click **Exclude** next to any document. Excluded documents are skipped on every subsequent sync even if the source content changes. diff --git a/apps/docs/content/docs/knowledgebase/tags.mdx b/apps/docs/content/docs/knowledgebase/tags.mdx index e563295787e..1d84bb9835c 100644 --- a/apps/docs/content/docs/knowledgebase/tags.mdx +++ b/apps/docs/content/docs/knowledgebase/tags.mdx @@ -36,11 +36,11 @@ The type dropdown in the creation form shows current slot usage for each type (e Tag definitions live at the knowledge base level. To manage them, click the knowledge base name in the header to open the context menu and select **Tags**: -Knowledge base header showing the dropdown menu with Rename, Tags, and Delete options +Knowledge base header showing the dropdown menu with Rename, Tags, and Delete options This opens the Tags modal, which lists all defined tags and shows how many documents each one is assigned to. Click **Add Tag** to define a new one: -Tags modal showing 0 defined tags, a Tag Name input field, and a Type dropdown set to Text (0/7), with Cancel and Create Tag buttons +Tags modal showing 0 defined tags, a Tag Name input field, and a Type dropdown set to Text (0/7), with Cancel and Create Tag buttons Enter a **Tag Name** and pick a **Type**, then click **Create Tag**. The name must be unique within the knowledge base. The type dropdown only shows types that still have available slots. Press Enter to submit or Escape to cancel. diff --git a/apps/docs/content/docs/logs-debugging/index.mdx b/apps/docs/content/docs/logs-debugging/index.mdx index 6ca2d2a973e..14edf8f61a9 100644 --- a/apps/docs/content/docs/logs-debugging/index.mdx +++ b/apps/docs/content/docs/logs-debugging/index.mdx @@ -35,6 +35,7 @@ Open a run and **Log Details** shows the trace. The **Trace** tab lists each blo alt="The current Log Details Trace tab showing workflow, agent, model, and tool spans with their durations" width={518} height={540} + className="mx-auto h-auto w-full max-w-md" /> This is the level you debug at, because a run fails when one of its blocks fails, and a block usually fails because of the input it received. In this run, one glance at the spans shows where the time went: Agent 1 took 7.84s of the 7.86s total. diff --git a/apps/docs/content/docs/logs-debugging/logging.mdx b/apps/docs/content/docs/logs-debugging/logging.mdx index 68dd52ed6b6..d974e26782a 100644 --- a/apps/docs/content/docs/logs-debugging/logging.mdx +++ b/apps/docs/content/docs/logs-debugging/logging.mdx @@ -47,7 +47,7 @@ Click any entry to open its sidebar: the run's timeline (start/end, total durati alt="The current Log Details sidebar with run metadata, output, and credit total" width={518} height={760} - className="my-6" + className="my-6 mx-auto h-auto w-full max-w-sm" /> @@ -70,7 +70,7 @@ Click any entry to open its sidebar: the run's timeline (start/end, total durati alt="Workflow Snapshot" width={600} height={628} - className="my-6" + className="my-6 mx-auto h-auto w-full max-w-md" /> diff --git a/apps/docs/content/docs/platform/credentials.mdx b/apps/docs/content/docs/platform/credentials.mdx index a80862d3e2b..742fbe1f48d 100644 --- a/apps/docs/content/docs/platform/credentials.mdx +++ b/apps/docs/content/docs/platform/credentials.mdx @@ -18,6 +18,7 @@ To manage secrets, open your workspace **Settings** and navigate to the **Secret alt="Secrets tab showing Workspace and Personal sections with inline key-value rows" width={700} height={479} + className="mx-auto h-auto w-full max-w-xl" /> Secrets are organized into two sections: @@ -54,6 +55,7 @@ To reference a secret in any input field, type `{{` to open the variable dropdow alt="Typing {{ in an input opens a dropdown showing available secrets" width={400} height={165} + className="mx-auto h-auto w-full max-w-[400px]" /> Select the secret you want to use. The reference appears highlighted in blue and is resolved to its actual value at runtime. @@ -63,6 +65,7 @@ Select the secret you want to use. The reference appears highlighted in blue and alt="A resolved secret reference shown as {{OPENAI_API_KEY}}" width={400} height={166} + className="mx-auto h-auto w-full max-w-[400px]" /> ### Execution log protection @@ -114,6 +117,7 @@ Click **Details** on any secret row to open its detail view. alt="Secret details view showing Key, Value, Description, and Members sections" width={700} height={351} + className="mx-auto h-auto w-full max-w-xl" /> From here you can: diff --git a/apps/docs/content/docs/platform/enterprise/access-control.mdx b/apps/docs/content/docs/platform/enterprise/access-control.mdx index fe907d92411..d5ccb0d225c 100644 --- a/apps/docs/content/docs/platform/enterprise/access-control.mdx +++ b/apps/docs/content/docs/platform/enterprise/access-control.mdx @@ -37,7 +37,7 @@ When a user runs a workflow or uses Chat, Sim reads the resolved group's configu Go to **Settings → Organization → Permission groups** from any workspace in your organization. Permission groups are defined once at the organization level and apply to every workspace under it. Only organization owners and admins can manage them. -Access Control settings showing a list of permission groups: Contractors, Sales, Engineering, and Marketing, each with Details and Delete actions +Access Control settings showing a list of permission groups: Contractors, Sales, Engineering, and Marketing, each with Details and Delete actions ### 2. Create a permission group @@ -59,7 +59,7 @@ A workspace-scoped group with **no members** applies to everyone in its workspac Controls which AI model providers members of this group can use. -Model Providers tab showing a grid of AI providers including Ollama, vLLM, OpenAI, Anthropic, Google, Azure OpenAI, and others with checkboxes to allow or restrict access +Model Providers tab showing a grid of AI providers including Ollama, vLLM, OpenAI, Anthropic, Google, Azure OpenAI, and others with checkboxes to allow or restrict access The list shows all providers available in Sim. @@ -89,7 +89,7 @@ Expand an integration block to reach its **tool denylist**. Clearing individual Controls the modules, actions, and credentials available to group members. Every row refuses at the API, not only in the UI — clearing a box revokes the access; it does not merely hide a tab. -Platform tab showing feature toggles grouped by category +Platform tab showing feature toggles grouped by category **Modules** diff --git a/apps/docs/content/docs/platform/enterprise/audit-logs.mdx b/apps/docs/content/docs/platform/enterprise/audit-logs.mdx index e5df0940922..089d8b46190 100644 --- a/apps/docs/content/docs/platform/enterprise/audit-logs.mdx +++ b/apps/docs/content/docs/platform/enterprise/audit-logs.mdx @@ -16,7 +16,7 @@ Audit logs record configuration and security events across your organization, in Go to **Settings → Organization → Audit logs** in your workspace. Logs are displayed in a table with the following columns: -Audit Logs settings showing a table of events with columns for Timestamp, Event, Description, and Actor, along with search and filter controls +Audit Logs settings showing a table of events with columns for Timestamp, Event, Description, and Actor, along with search and filter controls | Column | Description | |--------|-------------| diff --git a/apps/docs/content/docs/platform/enterprise/custom-blocks.mdx b/apps/docs/content/docs/platform/enterprise/custom-blocks.mdx index 45761df87d3..7184c315202 100644 --- a/apps/docs/content/docs/platform/enterprise/custom-blocks.mdx +++ b/apps/docs/content/docs/platform/enterprise/custom-blocks.mdx @@ -92,7 +92,7 @@ Click **Save changes**. The block is published immediately and becomes available In the workflow editor, open the block toolbar. Published custom blocks appear under a **Custom blocks** section. Drag one into your workflow like any other block, fill in its inputs (using the placeholders as a guide), and reference its outputs in downstream blocks. -Workflow editor block toolbar with a Custom Blocks section listing two published blocks below Core Blocks +Workflow editor block toolbar with a Custom Blocks section listing two published blocks below Core Blocks Consumers don't need any access to the source workflow. The block runs on its own, using only the inputs provided, and returns only the outputs you exposed. Its internal steps, models, and intermediate values stay hidden unless the block's publisher turned on **Trace runs in consumer logs**, in which case they appear under the block in the run's trace. diff --git a/apps/docs/content/docs/platform/enterprise/data-drains.mdx b/apps/docs/content/docs/platform/enterprise/data-drains.mdx index 4305d250c87..44bb8d617a7 100644 --- a/apps/docs/content/docs/platform/enterprise/data-drains.mdx +++ b/apps/docs/content/docs/platform/enterprise/data-drains.mdx @@ -16,9 +16,9 @@ Drains are independent of [Data Retention](/platform/enterprise/data-retention) Go to **Settings → Organization → Data drains** in your workspace, then click **New drain**. -Data Drains settings page showing two configured drains — one exporting workflow logs to Amazon S3 daily, another exporting Chat conversations to an HTTPS webhook hourly +Data Drains settings page showing two configured drains — one exporting workflow logs to Amazon S3 daily, another exporting Chat conversations to an HTTPS webhook hourly -New data drain dialog with fields for name, source, cadence, destination, and S3 credentials +New data drain dialog with fields for name, source, cadence, destination, and S3 credentials Each drain has four pieces: diff --git a/apps/docs/content/docs/platform/enterprise/forks.mdx b/apps/docs/content/docs/platform/enterprise/forks.mdx index cfe4879dbe5..4c0851c53a0 100644 --- a/apps/docs/content/docs/platform/enterprise/forks.mdx +++ b/apps/docs/content/docs/platform/enterprise/forks.mdx @@ -44,7 +44,7 @@ You will see: Click **Create fork**. Name the child (defaults to `{workspace} (fork)`), then review **Copy resources**. -Fork workspace modal with name field and Copy resources list fully selected +Fork workspace modal with name field and Copy resources list fully selected Everything under **Copy resources** starts **selected**. That is usually what you want: tables, knowledge bases, files, custom tools, skills, and MCP servers the child will need. @@ -52,7 +52,7 @@ Everything under **Copy resources** starts **selected**. That is usually what yo If you deselect a resource, references to it in the forked workflows are **cleared** in the child. You will see a warning before you confirm. -Fork workspace modal showing a warning that deselected resources will clear references in the fork +Fork workspace modal showing a warning that deselected resources will clear references in the fork Click **Fork**. The child workspace is created immediately. Deployed workflows land as **drafts** in the child. Large content (table rows, knowledge base files, file blobs) may finish copying in the background — watch **Activity** on the source workspace. @@ -94,11 +94,11 @@ On the sync page you will see direction (**Push** / **Pull**), deployed workflow Both are force operations. Confirm carefully. -Copy resources section showing resources used by workflows and a Not used by any workflow group +Copy resources section showing resources used by workflows and a Not used by any workflow group Resources referenced by the workflows in the sync default to selected for copy. Unused ones sit under **Not used by any workflow** (off by default). If you **map** a resource, it leaves the copy list — maps win. -Dependent field reconfigure card under a mapped resource with a required field marked +Dependent field reconfigure card under a mapped resource with a required field marked **Sync** stays disabled until: @@ -111,7 +111,7 @@ Resources referenced by the workflows in the sync default to selected for copy. Click **Sync**. You will get an overwrite confirmation. -Overwrite target workspace confirmation dialog warning that syncing overwrites changes on the target +Overwrite target workspace confirmation dialog warning that syncing overwrites changes on the target On success you will see a toast such as **Pushed to "…"** or **Pulled from "…"**. If some workflows fail to redeploy, you get a warning to open and redeploy them manually. @@ -134,7 +134,7 @@ The setting belongs to **this workspace's copy** only. Excluding a workflow here **See activity** (or the Activity view from the Forks header) lists forks, pushes, pulls, and rollbacks that involve this workspace — including events recorded on the other side of the edge. -Activity view showing Fork and Push events with expandable detail rows +Activity view showing Fork and Push events with expandable detail rows Expand a row for names of workflows and resources that were created, updated, or archived, and any warnings (for example failed background copies or deploy failures). A push or pull that copies resources fills their content in the background, and that progress and outcome show in the same row. diff --git a/apps/docs/content/docs/platform/enterprise/scim/entra.mdx b/apps/docs/content/docs/platform/enterprise/scim/entra.mdx index dac483eec86..8009198537f 100644 --- a/apps/docs/content/docs/platform/enterprise/scim/entra.mdx +++ b/apps/docs/content/docs/platform/enterprise/scim/entra.mdx @@ -5,6 +5,7 @@ description: Connect Microsoft Entra ID to Sim and verify user and group provisi import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' Use a non-gallery enterprise application in Microsoft Entra ID to create, update, and deactivate Sim members. Configure [single sign-on](/platform/enterprise/sso) separately for authentication. @@ -48,7 +49,7 @@ In the new application's **Provisioning** page, select **New configuration**. Ch Select **Test connection**, then **Create** after the test succeeds. Entra adds the bearer prefix itself. A successful connection test verifies connectivity and authentication; continue with a test assignment to verify provisioning. -![Entra provisioning connection settings with the deployment URL redacted and secret token masked](/static/enterprise/entra/connection.png) +Entra provisioning connection settings with the deployment URL redacted and secret token masked These steps use Entra's current provisioning experience. In the legacy experience, choose **Automatic** provisioning, enter the same credentials under **Admin Credentials**, test the connection, and save. See [Microsoft's SCIM configuration guide](https://learn.microsoft.com/en-us/entra/identity/app-provisioning/use-scim-to-provision-users-and-groups). @@ -67,7 +68,7 @@ Under **Attribute mapping**, review the user mappings. The defaults map `userPri If **Mail** is empty, Entra omits the work email and Sim uses `userName` as the account email. That UPN must be a valid email address in a verified Sim domain. -![Entra provisioning properties restricted to assigned users and groups](/static/enterprise/entra/scope.png) +Entra provisioning properties restricted to assigned users and groups @@ -76,7 +77,7 @@ If **Mail** is empty, Entra omits the work email and Sim uses `userName` as the Open **Provision on demand**, search for the assigned test user, and select **Provision**. Review the import, scope, matching, and action results. In Sim, confirm that the member appears under **Organization → Members** and that **Single sign-on → Provisioning → Activity** shows successful requests. -![Successful on-demand user provisioning in Entra with account details redacted](/static/enterprise/entra/provision-user.png) +Successful on-demand user provisioning in Entra with account details redacted Change the test user's display name in Entra and provision them again. Confirm the new name in Sim. Repeating provisioning without changes should report that the source and target already match. diff --git a/apps/docs/content/docs/platform/enterprise/scim/index.mdx b/apps/docs/content/docs/platform/enterprise/scim/index.mdx index fe540d080cb..c75d13bbaf5 100644 --- a/apps/docs/content/docs/platform/enterprise/scim/index.mdx +++ b/apps/docs/content/docs/platform/enterprise/scim/index.mdx @@ -141,7 +141,7 @@ Mapping a permission group to a directory group switches that permission group t -Provisioning tab with an active SCIM connection, tokens, provisioning rules, group mappings, and recent activity +Provisioning tab with an active SCIM connection, tokens, provisioning rules, group mappings, and recent activity ## How access is withdrawn diff --git a/apps/docs/content/docs/platform/enterprise/scim/okta.mdx b/apps/docs/content/docs/platform/enterprise/scim/okta.mdx index 1bbba4218cb..7fcd660cbd3 100644 --- a/apps/docs/content/docs/platform/enterprise/scim/okta.mdx +++ b/apps/docs/content/docs/platform/enterprise/scim/okta.mdx @@ -5,6 +5,7 @@ description: Connect a private Okta SCIM integration to Sim and verify user and import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' +import { Image } from '@/components/ui/image' Use an Okta SCIM integration to create, update, and deactivate Sim members. This guide covers provisioning; configure [single sign-on](/platform/enterprise/sso) separately for authentication. @@ -53,7 +54,7 @@ Select **Test API Credentials** and save after the test succeeds. A successful c The Header Auth template sends this field as the complete `Authorization` header. Entering only the token produces **A bearer token is required**. -![Okta API integration with a successful credential test; the test deployment URL is redacted](/static/enterprise/okta/api-integration.png) +Okta API integration with a successful credential test; the test deployment URL is redacted @@ -63,7 +64,7 @@ Under **Provisioning → To App**, select **Edit**, enable **Create Users**, **U Use an email address from a verified Sim domain for the application username and primary email. Review the attribute mappings if your Okta usernames differ from users' email addresses. -![Okta provisioning actions with Create Users, Update User Attributes, and Deactivate Users enabled, and Sync Password disabled](/static/enterprise/okta/provisioning-actions.png) +Okta provisioning actions with Create Users, Update User Attributes, and Deactivate Users enabled, and Sync Password disabled @@ -82,7 +83,7 @@ Okta deactivates users over SCIM; it does not send a SCIM DELETE. Sim suspends a Use separate groups for app assignment and Group Push. Okta does not support using the same group for both purposes. Assign the users to the app first. Under **Push Groups → Find groups by name**, select the group, leave **Push group memberships immediately** enabled, and save with **Create Group** selected for a new downstream group. -![Okta Group Push selecting an engineering group and creating its downstream group in Sim](/static/enterprise/okta/group-push.png) +Okta Group Push selecting an engineering group and creating its downstream group in Sim Once the group appears under **Single sign-on → Provisioning → Group mappings** in Sim, map it to a workspace, permission group, or the organization admin role. Workspace access requires a workspace mapping. If name matching is enabled, Sim can automatically map matching permission groups. diff --git a/apps/docs/content/docs/platform/enterprise/sso.mdx b/apps/docs/content/docs/platform/enterprise/sso.mdx index d2396800960..828f2c8d47b 100644 --- a/apps/docs/content/docs/platform/enterprise/sso.mdx +++ b/apps/docs/content/docs/platform/enterprise/sso.mdx @@ -49,7 +49,7 @@ An organization can run several identity providers at once, each serving a diffe ### 3. Fill in the form -Sign-in tab showing the OIDC configuration form with advanced options collapsed +Sign-in tab showing the OIDC configuration form with advanced options collapsed **Fields required for both protocols:** diff --git a/apps/docs/content/docs/platform/enterprise/usage-tracking.mdx b/apps/docs/content/docs/platform/enterprise/usage-tracking.mdx index ccdd786d662..09e62e9b3af 100644 --- a/apps/docs/content/docs/platform/enterprise/usage-tracking.mdx +++ b/apps/docs/content/docs/platform/enterprise/usage-tracking.mdx @@ -17,7 +17,7 @@ All figures are in **credits** (1 credit = $0.005). See [cost calculation](/plat Go to **Settings → Organization → Usage tracking** in your workspace. -Usage tracking Overview tab showing the period selector, credits used against the organization limit, a daily usage chart, and a Sources section pairing a ranked list of sources with a radar chart of the same mix +Usage tracking Overview tab showing the period selector, credits used against the organization limit, a daily usage chart, and a Sources section pairing a ranked list of sources with a radar chart of the same mix The period selector applies to every tab: @@ -43,7 +43,7 @@ The period selector applies to every tab: Selecting a workspace opens its detail view, which splits that workspace's usage into **Sources** (what kind of work) and **Workflows** (the individual workflow runs). **Open logs** jumps to [audit logs](/platform/enterprise/audit-logs) filtered to that workspace. -A workspace's detail view with a Sources section listing Sim Chat and Workflow, and a Workflows section ranking individual workflows by credits +A workspace's detail view with a Sources section listing Sim Chat and Workflow, and a Workflows section ranking individual workflows by credits **Sources** adds up to the workspace's total, while **Workflows** covers only the workflow-run part of it. In the example above, Sources totals 4,435 credits but the workflows list only accounts for the 161 credits under Workflow — the other 4,274 came from Chat, which no workflow produced. diff --git a/apps/docs/content/docs/platform/enterprise/verified-domains.mdx b/apps/docs/content/docs/platform/enterprise/verified-domains.mdx index 68df8d9618c..377a7e359ce 100644 --- a/apps/docs/content/docs/platform/enterprise/verified-domains.mdx +++ b/apps/docs/content/docs/platform/enterprise/verified-domains.mdx @@ -19,7 +19,7 @@ Verified Domains let organization owners and admins on Enterprise plans prove th Go to **Settings → Organization → Single sign-on → Domains**. The **Verified domains** section is shared by sign-in and directory provisioning. -Domains tab showing a verified domain and the DNS record for a pending domain +Domains tab showing a verified domain and the DNS record for a pending domain 1. Enter the domain, for example `acme.com`, and click **Add domain**. 2. Sim shows a DNS **TXT record** to publish — a host (`_sim-challenge.acme.com`) and a unique value (`sim-domain-verification=…`). diff --git a/apps/docs/content/docs/platform/enterprise/whitelabeling.mdx b/apps/docs/content/docs/platform/enterprise/whitelabeling.mdx index 9d9c1d2905f..82f26eb4380 100644 --- a/apps/docs/content/docs/platform/enterprise/whitelabeling.mdx +++ b/apps/docs/content/docs/platform/enterprise/whitelabeling.mdx @@ -16,7 +16,7 @@ White-labeling lets you replace Sim's default branding — logo, colors, and sup Go to **Settings → Organization → White-labeling** in your workspace. -Whitelabeling settings showing brand identity fields (Logo, Wordmark, Brand name), color pickers for primary and accent colors, and link fields for support email and documentation URL +Whitelabeling settings showing brand identity fields (Logo, Wordmark, Brand name), color pickers for primary and accent colors, and link fields for support email and documentation URL ### 2. Configure brand identity diff --git a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx index 6842f52e618..c57b18f4991 100644 --- a/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx +++ b/apps/docs/content/docs/platform/self-hosting/integrations-oauth.mdx @@ -6,6 +6,7 @@ description: Register OAuth apps so your users can connect Slack, Google, Jira, import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' import { FAQ } from '@/components/ui/faq' +import { Image } from '@/components/ui/image' **OAuth integrations need your own provider application on a self-hosted deployment.** Configure the OAuth services your team uses; API-key integrations can instead use keys supplied in their blocks. Users will see the connector in the UI, click "Connect", and get an error from the provider until the corresponding `*_CLIENT_ID` and `*_CLIENT_SECRET` are set. @@ -116,15 +117,104 @@ The same variables also power "Sign in with Microsoft". ### GitHub Search -Register a [GitHub App](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) with repository **Contents: read-only**, **Metadata: read-only**, and account **Email addresses: read-only** permissions. Keep user access token expiration enabled so Sim can rotate access and refresh tokens. +Self-hosted GitHub Search uses a GitHub App for account connections and organization installation indexing. Register your own App and configure the server variables below. Sim Cloud users use the [GitHub Search setup flow](/search/github#add-a-repository) directly. -| Environment variables | Provider ID | + + + +#### Register the App + +For a team, open **Your organizations → Settings** for the organization that will own the App. For a personal App, open your account's **Settings**. Then choose **Developer settings → GitHub Apps → New GitHub App**. + +Give the App a unique, recognizable name, such as **Your Company Sim Search**, and set **Homepage URL** to your Sim URL. + +Under **Identifying and authorizing users → Redirect URI (callback URL)**, enter: + +```text +/api/auth/oauth2/callback/github-repositories +``` + +Replace `` with your configured public origin, such as `https://sim.example.com`, without a trailing slash. The scheme, hostname, port, and path must match exactly; `www` and non-`www` hosts are different. See GitHub's [callback matching rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/about-the-user-authorization-callback-url). + +| GitHub setting | Value for Sim Search | |---|---| -| `GITHUB_APP_CLIENT_ID`
`GITHUB_APP_CLIENT_SECRET` | `github-repositories` | +| Allow wildcard matching | Disabled | +| Expire user authorization tokens | Enabled | +| Request user authorization (OAuth) during installation | Disabled | +| Enable Device Flow | Disabled | +| Post installation → Setup URL | Empty | +| Webhook → Active | Disabled | + +Authorization starts from Sim so the callback can finish the pending connection. The connector polls GitHub's API and does not need a webhook. + +GitHub App registration with the Redirect URI, expiring tokens enabled, and installation authorization, Device Flow, and webhooks disabled + +*Example registration. Replace `sim.example.com` with your Sim domain.* + +
+ + +#### Set read permissions + +Expand **Permissions → Repository permissions**. Set **Contents → Access: Read-only**; leave the mandatory **Metadata** permission at **Read-only**. + +GitHub repository permissions with Contents set to Read-only and Metadata shown as mandatory Read-only + +Expand **Account permissions** and set **Email addresses → Access: Read-only**. + +GitHub account permissions with only Email addresses selected for Read-only access + +| Permission area | Permission | Access | +|---|---|---| +| Repository | Contents | Read-only | +| Repository | Metadata | Read-only | +| Account | Email addresses | Read-only | + +Leave every other permission at **No access**. Sim does not need issue, pull-request, administration, or write permissions. GitHub's [registration guide](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) explains these settings. + +GitHub App user tokens use these permissions rather than OAuth scopes. An empty `scope` value in the token response is expected; see GitHub's [user token reference](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app). + +Set **Where can this GitHub App be installed? → Any account** to support connections from accounts outside the App owner. This lets any GitHub account install and authorize the App, subject to that account's organization policies. Making the App public does not make repositories public or grant anyone Search access. See GitHub's [App visibility rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/making-a-github-app-public-or-private). + +Select **Create GitHub App**. + + + + +#### Configure Sim + +On the App's **General** settings page, copy its numeric **App ID** and **Client ID**, select **Generate a new client secret**, and generate a **Private key**. The App slug is the final part of its public URL: `https://github.com/apps/`. + +Set all five variables using values from your GitHub App, then restart Sim: + +```text +GITHUB_APP_ID= +GITHUB_APP_SLUG= +GITHUB_APP_CLIENT_ID= +GITHUB_APP_CLIENT_SECRET= +GITHUB_APP_PRIVATE_KEY= +``` + +The private key must include its PEM header, footer, and contents. Sim accepts actual newlines or escaped `\n` sequences. When storing it in a JSON secret, use the plaintext JSON editor and encode each line break as `\n` inside the string. Single-line key/value fields can remove line breaks; replacing them with spaces makes the PEM invalid. Restart Sim after saving configuration changes. + +Keep the private key and client secret in the deployment's server configuration; organization admins select installations in Sim without entering these secrets. + +If you run Search indexing on Trigger.dev, configure the same five variables in its matching environment. The app server and indexing worker both need the App credentials; updating the app's secret store alone does not update a separately configured worker. + +The **Client ID** is different from the numeric **App ID**. Use credentials from **Developer settings → GitHub Apps**. `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` belong to the separate GitHub sign-in integration and remain unchanged. Search does not read `GITHUB_REPO_CLIENT_ID` or `GITHUB_REPO_CLIENT_SECRET`. + +Keep **Expire user authorization tokens** enabled so Sim receives the refresh token it needs to [renew personal connections](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens). + +Complete the installation through [the GitHub Search source setup](/search/github#add-a-repository). + +If you replace a deployment's GitHub App, an organization admin selects **Settings → Sources → More → Refresh connection settings**. When Search is disabled, use **Connected accounts → Providers → Update configurations** in organization settings. This applies the deployment's current App configuration to all existing providers while preserving their option IDs. Accounts whose App configuration changed must reconnect. Then reconnect personal GitHub accounts and connect an installation of the new App. Reconnecting alone cannot update the organization's saved App configuration. + + +
-Register `https:///api/auth/oauth2/callback/github-repositories` as the callback. These App OAuth client credentials are separate from `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` used for Sim sign-in. Sim does not require an App private key. +If GitHub rejects `redirect_uri`, compare the App's registered callback with `NEXT_PUBLIC_APP_URL` followed by `/api/auth/oauth2/callback/github-repositories`. Keep wildcard matching disabled. If installation indexing is unavailable, confirm all five `GITHUB_APP_*` variables belong to the same App and include a complete RSA private key. -A repository or organization administrator installs the App on the repositories to search. Each member connects their own GitHub account, with a verified email matching their Sim account. Search indexes repository files that both the member and the installed App can access. GitHub workflow blocks and existing knowledge-base token connections continue to use personal access tokens. +GitHub workflow blocks and knowledge-base token connections continue to use personal access tokens. ### Everything else diff --git a/apps/docs/content/docs/search/confluence.mdx b/apps/docs/content/docs/search/confluence.mdx index 70276ed311f..308ca638a00 100644 --- a/apps/docs/content/docs/search/confluence.mdx +++ b/apps/docs/content/docs/search/confluence.mdx @@ -7,31 +7,20 @@ import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' -Search pages and blog posts from selected Confluence Cloud spaces. A Sim organization admin configures the source, and each teammate connects their Confluence account. +Search pages and blog posts from selected Confluence Cloud spaces. A Sim organization admin approves the provider; **each teammate connects their own Confluence account**, including when a service account supplies the content. -Search indexes each page's own text, including supported local callouts and code blocks. It does not expand Include Page, Excerpt Include, or third-party macros into that page. Referenced pages can be indexed separately with their own access rules. - -Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. - -## Choose a setup - -| Method | Who supplies the content? | What teammates do | -| --- | --- | --- | -| **Central account** | One account syncs content, space permissions, page restrictions, and group membership. | Connect their own Confluence account so Sim can match their Atlassian identity to those permissions. | -| **Member accounts** | Sim syncs content separately through connected members' accounts. | Connect their own Confluence account to establish which pages they can access. | - -Selecting **Add source** on Confluence's integration page opens central account setup. Use this when one account can read the intended spaces and their permissions. For a member source, start **Connect account** from **Integrations** in the main sidebar after an admin allows Confluence. Available methods depend on your organization's enabled features. - -**Everyone still connects in both methods.** With a central account, teammates supply their identity; they do not configure another central crawl or choose spaces again. +| Method | How it works | +| --- | --- | +| **Service account** | One account indexes content and permissions. Each teammate connects to match their Atlassian identity to those permissions. | +| **Member accounts** | Sim indexes the pages each connected teammate can access, using that person's account. | ## Before you start -- Be a **Sim organization admin** to add the source. -- Use a Confluence Cloud site such as `your-team.atlassian.net`. This connector does not connect to Server or Data Center. +- Use **Confluence Cloud** and a site hostname such as `your-team.atlassian.net`. Server and Data Center are not supported. - Each teammate needs a verified Sim email matching their active Atlassian account's email. -- For a central crawl, grant its account access to Confluence, the chosen spaces, and any restricted pages you want indexed. Admin status alone does not bypass page restrictions. It also needs permission to read space permissions and the user/group directory. +- For a central source, an Atlassian organization admin creates a service account with Confluence access. Grant it access to the chosen spaces and restricted pages, plus permission to read space permissions and the user/group directory. Admin status alone does not bypass page restrictions. -On hosted Sim, personal connections authorize the existing Sim app. Teammates do not create OAuth apps or service-account tokens. Self-hosted deployments need the [shared OAuth configuration](#self-hosted-operator-setup) even when a service account supplies the content. +On hosted Sim, teammates authorize Sim's existing OAuth app. Self-hosted deployments must configure the [shared OAuth app](#self-hosted-operator-setup), including when a service account supplies content. ## Set up a central source @@ -40,84 +29,51 @@ On hosted Sim, personal connections authorize the existing Sim app. Teammates do ### Choose Confluence -Open **Settings → Sources** and turn on **Confluence** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. **Setup guide** opens this guide from the source form. - - - - -### Select an account - -Under **Indexing account**, select an existing account, choose **Connect Confluence account** for OAuth, or add a service account using the [steps below](#using-a-service-account). The account must be able to read the content and its permissions. +Open your organization's **Settings → Sources**, turn on **Confluence**, then select **Set up** (or **Manage**) → **Add source**. This opens service-account setup. -### Select the spaces +### Choose the account and spaces -Enter **Confluence Domain**, then choose one or more **Spaces**. The picker shows spaces accessible to the selected account. Use the switch beside the field to enter comma-separated **Space Keys**, such as `ENG, PRODUCT`. - -Open **More options** to change **Content Type**, **Filter by Label**, or **Metadata tags**. The default includes pages; choose **All content** for pages and blog posts. Leave the label filter empty unless you want a smaller scope. +Under **Indexing account**, select a service account or [add one](#using-a-service-account). Enter the same **Confluence Domain** as the credential, then choose **Spaces**. To enter comma-separated keys such as `ENG, PRODUCT`, use the switch beside the Spaces field. Confluence central source setup with an indexing account, domain, and spaces +Open **More options** for content type, labels, and metadata tags. The default is **Pages only**; choose **All content** to include blog posts. + -### Save and connect your identity +### Sync and connect your identity -Click **Connect & Sync**. Then open **Integrations** in the main sidebar, click **Connect account** on the Confluence source, and finish the connection in the new tab. Sign in using the Atlassian email that matches your verified Sim email, and authorize the configured site. +Select **Connect & Sync**. Setup samples permission access in one selected space. Each document's access is verified during sync. Then open **Integrations** in the main sidebar and select **Connect** on the Confluence source. In the new tab, authorize the configured site using the Atlassian email matching your verified Sim email. -Each teammate completes this last step. A previously authorized account may already be connected. Return to Integrations to see indexing status and your searchable document count. +Each teammate completes this identity connection. An existing authorized account may already be connected. Return to Integrations to check indexing status and your searchable document count. +For workspace Search, start from **Search → Add source**. Available methods depend on the enabled features. + ## Connect member accounts -After an admin allows Confluence, open **Integrations** in the main sidebar and select **Connect account**. If there is no source yet, enter **Confluence Domain** and **Space Keys**, then select **Connect** and authorize your account. For another site or space scope, use **Add source** beside **Add another Confluence source**. +After an admin allows Confluence, open **Integrations → Connect**. If no source exists, enter **Confluence Domain** and **Space Keys**, then connect your account. To use another site or space scope, choose the row labeled **Connect a different site or content scope**. -An admin can open **Settings → Sources**, select **Manage** beside **Confluence**, and open the source's **Settings** tab to adjust its filters. **Account for browsing** helps populate the space picker; it does not connect that account for Search. Manual space keys work without a browsing account. +Admins manage these sources under **Settings → Sources → Confluence → Manage**. An **Account for browsing** populates the space picker; it does not enroll that account for Search. Manual space keys work without a browsing account. ## Using a service account -Sim's Atlassian service account form accepts a **scoped API token** and **site domain**. - - - - -### Give the service account Confluence access - -Have an Atlassian organization admin create a service account under **Directory → Service accounts** in [Atlassian Administration](https://admin.atlassian.com/). Give it Confluence access on the intended site. A space admin must also grant access to the chosen spaces and any restricted pages the source should index. See [Atlassian's service-account setup](https://support.atlassian.com/user-management/docs/manage-your-service-accounts/). - - - - -### Choose API token authentication - -Select the service account, then **Create credentials → API token → Next**. This is the credential type accepted by Sim's service-account form. - -Atlassian Administration authentication selector with API token selected - -Atlassian Administration's credential selector. See the [current Atlassian instructions](https://support.atlassian.com/user-management/docs/manage-api-tokens-for-service-accounts/). - - - - -### Select Confluence scopes - -Name the token and choose an expiry between 1 and 365 days. In the scope picker, choose **Confluence** and add the scopes below; the list includes both classic and granular scopes. Review and create the token, then copy it for the next step. Atlassian only reveals the token once. +Use a **scoped API token** from an Atlassian service account: -Use these scopes for Confluence Search content and permission reads: +1. In [Atlassian Administration](https://admin.atlassian.com/), open **Directory → Service accounts**. Create or select the account and grant the Confluence access described above. +2. Select **Create credentials → API token → Next**. Name the token and choose an expiry between 1 and 365 days. +3. Add **all scopes below**. Use the **App: Confluence** and **Scope type** filters to find both classic and granular scopes. ```text read:confluence-content.all @@ -134,68 +90,64 @@ read:user:confluence read:group:confluence ``` - - - -### Add the token to Sim +These scopes cover Search's account check, pickers, content, permissions, and directory reads. `read:confluence-user` is needed for the [current-user check](https://developer.atlassian.com/cloud/confluence/rest/v1/api-group-users/#api-wiki-rest-api-user-current-get); `read:space:confluence` is needed for the [space picker](https://developer.atlassian.com/cloud/confluence/rest/v2/api-group-space/#api-spaces-get). Workflow write scopes are not needed for central Search. -Under **Indexing account**, choose the service-account connection action. Paste the **API token** and enter **Site domain**. Optionally add a display name and description, then click **Add service account**. Continue in the original source modal, using the same domain in both forms. +4. Review and create the token, then copy it. Atlassian shows it only once. +5. In Sim's source form, use **Indexing account** to add a service account. Paste the **API token**, enter **Site domain** (hostname only), and select **Add service account**. Continue in the source form with the same domain. - - +Atlassian Administration credential selector with API token selected -Scopes do not grant access to spaces or pages by themselves. Keep the account's Confluence permissions and its token scopes aligned. When a token expires or needs different scopes, create a replacement in Atlassian. Add the replacement service account in the source's **Settings**, then use **Change indexing account** to apply it. +See Atlassian's [account setup](https://support.atlassian.com/user-management/docs/manage-your-service-accounts/) and [token instructions](https://support.atlassian.com/user-management/docs/manage-api-tokens-for-service-accounts/). Scopes do not grant space or page access themselves. Before expiry, or when scopes must change, create a replacement token and add it as a new credential in the source's **Settings**. Select **Change indexing account**, verify a sync, then revoke the old token. -Personal OAuth uses Sim's shared Confluence integration and requests a broader set of permissions, including writes. Search reads content and permissions; it does not edit your Confluence pages. Older OAuth connections need to reconnect to grant the group-read permission used by central permission syncing. +Personal OAuth connections use Sim's shared Confluence app, which also requests permissions for workflow actions, including writes. Search reads content and permissions; it does not edit pages. -## Configuration +## Configuration and indexed content | Setting | What it controls | | --- | --- | -| **Confluence Domain** | The Cloud hostname, such as `your-team.atlassian.net`. Do not paste a page URL or `/wiki` path. | -| **Spaces / Space Keys** | Required spaces to index. The picker and manual key input are two ways to set the same scope. | -| **Content Type** | **Pages only** by default. **All content** means pages and blog posts; it does not include comments or attachment contents. | -| **Filter by Label** | Optional comma-separated labels. Content can match any listed label. | -| **Metadata tags** | Optional labels, version, and last-modified tags. In the add-source form, these are under **More options**. | - -Search manages the schedule and hides item limits. Published/current content is indexed; archived and trashed content is excluded. +| **Confluence Domain** | Cloud hostname only, such as `your-team.atlassian.net`; omit page URLs and `/wiki`. | +| **Spaces / Space Keys** | Required spaces. The picker and manual input set the same scope. | +| **Content Type** | **Pages only** by default; **All content** includes pages and blog posts. | +| **Filter by Label** | Optional comma-separated labels; content can match any listed label. | +| **Metadata tags** | Labels, version, and last-modified tags. | -## Teammates and ongoing sync +Search manages the schedule and hides item limits. It indexes published/current content and each page's own text, including supported local callouts and code blocks. Archived content, comments, attachment contents, and expanded Include Page, Excerpt Include, or third-party macro output are excluded. Referenced pages can be indexed separately with their own permissions. -Existing organization members see the configured Confluence source and their own **Connect account** or **Reconnect** action. Add new teammates through your Sim organization invitation or SSO onboarding, then have them connect Confluence from Integrations. Connecting a Confluence account does not add someone to the Sim organization. +## Manage access and sync -With a central account, Sim applies space access together with the page's restrictions and inherited ancestor restrictions. Group membership is refreshed in the background. With member accounts, each person's provider listing determines the pages available to them. A Sim organization admin does not automatically receive access to every Confluence document. +Central sources combine space permissions, page and ancestor restrictions, and group membership. Member sources use each person's provider listing. Sim admin status does not grant access to all pages, and permission changes take effect after syncing and processing. -New content and permission changes require a sync and processing before Search reflects them. Open **Settings → Sources**, select **Manage** beside **Confluence**, then open the source to inspect **Documents**, edit **Settings**, or review **Sync history**. If your own account needs authorization again, use **Reconnect** in the main Integrations page. Where available, the provider's **Accounts → Request connections** sends account connection requests; these do not invite people to the Sim organization. +Open **Settings → Sources → Confluence → Manage**, then a source's **Documents**, **Settings**, or **Sync history**. Teammates use **Integrations** to connect or reconnect. Invite new people to the Sim organization through Members settings or SSO first; **Accounts → Request connections** requests a provider connection, not organization membership. ## Troubleshooting -| What you see | What to check | +| Problem | What to check | | --- | --- | -| **Connect & Sync** is disabled | Select a central account, enter the domain, and choose at least one space. | -| Space picker is empty | Connect an account, enter the correct domain, and verify its space access. You can also switch to manual space keys. | -| Service-account validation fails | Check the token's expiry, site, Confluence app access, and scopes. Use a scoped API token from an Atlassian service account. | -| Content syncs but central search returns nothing | Connect your personal Confluence identity. Ask the admin to check directory/permission sync errors and group-read scopes. | -| A restricted page is missing | Ensure the crawling account can view that page and its ancestors, and that your own account has the required access. | -| Included or embedded content is missing | Add the referenced page's space to the source if appropriate. Search indexes pages separately; remote macro output, comments, and attachment contents are excluded. | -| **Reconnect** or an email mismatch | Reauthorize with the Atlassian account matching your verified Sim email and grant all requested permissions. | - -### Check access in Confluence - -Open a missing page in Confluence with the affected teammate's account. On the page, **Share → General access** shows whether access comes from the space, a parent, or an explicit restriction. A space admin can inspect restricted pages under **Space settings → Content → Restricted**. Check both the teammate and central crawling account when using a central source. See Atlassian's [content access guide](https://support.atlassian.com/confluence-cloud/docs/add-or-remove-page-restrictions/). +| **Connect & Sync** is disabled | Select a service account, enter its site domain, and choose at least one space. | +| Space picker is empty or fails | Check the domain, account's space access, and `read:space:confluence` scope. Manual space keys are also supported. | +| Service-account validation fails | Check token expiry, site, Confluence app access, and the full scope list above, including `read:confluence-user`. | +| Content syncs but Search is empty | Connect your personal Confluence identity. Check permission/directory sync errors and group-read scopes. | +| A restricted page is missing | Both your account and the crawling account need access to the page and its ancestors. | +| Embedded content is missing | Index the referenced page separately; remote macro output is excluded. | +| **Reconnect** or email mismatch | Authorize with the Atlassian account matching your verified Sim email and grant all requested permissions. | -On Confluence Premium, **Inspect permissions** can show where a user's access is denied across the page, its ancestors, the space, and the product. Check **Can view**, resolve the relevant permission, then run a sync in Sim. See [Atlassian's permission inspection guide](https://support.atlassian.com/confluence-cloud/docs/inspect-a-users-permissions/). +Open a missing page as the affected teammate. **Share → General access** shows its restrictions; a space admin can also inspect **Space settings → Content → Restricted**. On Premium, **Inspect permissions → Can view** helps locate denied access. Resolve the restriction, then sync again. See Atlassian's [content access](https://support.atlassian.com/confluence-cloud/docs/add-or-remove-page-restrictions/) and [permission inspection](https://support.atlassian.com/confluence-cloud/docs/inspect-a-users-permissions/) guides. ## Self-hosted operator setup -Configure one shared Confluence OAuth integration for your deployment. This powers personal identity connections in both Search methods and the optional central OAuth account. +Configure one shared Confluence OAuth app for teammates' connections: -1. In the [Atlassian developer console](https://developer.atlassian.com/console/myapps/), select or create your deployment's **OAuth 2.0 integration**. -2. Under **Authorization → OAuth 2.0 (3LO)**, add `https:///api/auth/oauth2/callback/confluence` to **Callback URLs**, keep existing callbacks used by the deployment, and save. -3. Under **Permissions**, add the Confluence API and configure the full `confluence` scope list for your release in [Sim's OAuth configuration](https://github.com/simstudioai/sim/blob/staging/apps/sim/lib/oauth/oauth.ts), including `read:group:confluence`. Also add **User Identity API** with `read:me`. Sim requests `offline_access` for refresh tokens. The service-account read scopes above do not replace the broader shared OAuth scope set. +1. In the [Atlassian developer console](https://developer.atlassian.com/console/myapps/), select or create the deployment's **OAuth 2.0 integration**. +2. Under **Authorization → OAuth 2.0 (3LO)**, save `https:///api/auth/oauth2/callback/confluence` as a callback, preserving callbacks used by other deployments. +3. Under **Permissions**, add the Confluence API and its full `confluence` scope list from [Sim's OAuth configuration](https://github.com/simstudioai/sim/blob/staging/apps/sim/lib/oauth/oauth.ts), including `read:group:confluence`. Add **User Identity API → read:me**. Sim requests `offline_access` for refresh tokens; the service-account list above does not replace this shared OAuth scope set. 4. Enable sharing under **Distribution**. Set `CONFLUENCE_CLIENT_ID` and `CONFLUENCE_CLIENT_SECRET` from the app's **Settings**, verify `NEXT_PUBLIC_APP_URL`, and restart Sim. -5. Start authorization from **Integrations** and select the configured site. Reconnect old accounts after adding scopes so the new permission grant takes effect. +5. Connect from **Integrations** and select the configured site. After changing the OAuth client or requested scopes, use **Settings → Sources → More → Refresh connection settings**, then have affected teammates reconnect. -A callback mismatch needs a corrected callback URL; a connection that works only for the app owner needs sharing enabled. See Atlassian's [OAuth configuration guide](https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/) and Sim's [deployment reference](/platform/self-hosting/integrations-oauth). +The callback must exactly match Sim's URL, including scheme, hostname, port, and path. For `http://localhost:3000`, register `http://localhost:3000/api/auth/oauth2/callback/confluence`. If only the app owner can connect, check **Distribution**. See the [Atlassian OAuth guide](https://developer.atlassian.com/cloud/confluence/oauth-2-3lo-apps/) and [Sim deployment reference](/platform/self-hosting/integrations-oauth). diff --git a/apps/docs/content/docs/search/connect-your-account.mdx b/apps/docs/content/docs/search/connect-your-account.mdx index 26508b493a7..7b031e1cf82 100644 --- a/apps/docs/content/docs/search/connect-your-account.mdx +++ b/apps/docs/content/docs/search/connect-your-account.mdx @@ -7,7 +7,7 @@ import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' -Your admin allows the integration and can configure shared source filters. You connect your own account so Sim can establish what you are allowed to search. Admins connect their own accounts through the same flow. +Your admin allows the integration and configures its connection method. For member sources, connect your own account so Sim can establish what you are allowed to search. Admins use the same flow. For central Google sources, join Sim with your matching verified Google Workspace email; no personal Google connection is needed. @@ -16,28 +16,27 @@ Your admin allows the integration and can configure shared source filters. You c Accept your Sim organization invitation or sign in through your organization's SSO. Use a verified Sim email that matches your account at the source. Organization Search does not require workspace access. +For a central Google source, use your primary Workspace email and open **Search** or **Home** after joining. The authorization steps below apply to sources that require a personal connection. + ## Open Integrations -Open **Integrations** in the main sidebar, find the provider or source, and select **Connect account**. Your first connection may ask for a GitHub repository, Confluence domain and space keys, or Jira domain and project keys. Enter the required fields and select **Connect**. If the provider is missing, ask an organization admin to turn it on under **Settings → Sources → Allowed in Sim Search**. - -Use **Add source** beside **Add another [provider] source** when you need another supported repository, site, or project scope. Connecting an existing source does not ask you to configure it again. +Open **Integrations** in the main sidebar, find the provider or source, and select **Connect**. Your first connection may ask for a GitHub repository, Confluence domain and space keys, or Jira domain and project keys. Enter the required fields and select **Connect**. If the provider is missing, ask an organization admin to turn it on under **Settings → Sources**. -Organization Integrations showing approved providers and Connect account actions +To connect another supported repository, site, or project scope, find the provider row labeled **Connect a different site or content scope** and select **Connect**. Connecting an existing source does not ask you to configure it again. +Organization Integrations with search, personal connections, and Connect actions ## Authorize your account -In the new tab, select **Connect** and complete the provider's authorization. Choose the account associated with your verified Sim email. The provider may require your organization's SSO or app approval. - -Return to Integrations when the connection completes, or select **Return to Search** to open your organization’s Search page. Your account is saved when authorization completes; there is no separate submit step. If the popup was blocked or closed, allow popups and select **Connect account** again. While authorization is pending, use **Open again**. +Complete the provider's authorization in the new tab. Choose the account associated with your verified Sim email. The provider may require your organization's SSO or app approval. -Gmail account connection with Connect and Return to Search actions +The authorization tab closes when the connection completes and Integrations updates. If the tab stays open, return to Integrations. Your account is saved when authorization completes; there is no separate submit step. If the popup was blocked or closed, allow popups and select **Connect** again. While authorization is pending, use **Open again**. @@ -49,43 +48,46 @@ The source row shows indexing status and how many documents are available to you -For a source configured inside a workspace, join that workspace and use its **Search** page instead. Organization and workspace sources are separate. +For a source configured inside a workspace, join that workspace and connect through its **Search** page. To find documents, open **Home** and select **Search** in the composer. Organization and workspace sources are separate. ## Do I always need to connect? | Source setup | Your next step | | --- | --- | | Member accounts | Connect your own account, including when you are the admin. | -| Confluence admin/service account | Connect Confluence to verify your identity; the administrator's account handles the crawl. | -| Google Drive delegated service account | No personal connection is needed for that source. Your verified Sim email is matched to Drive permissions. | -| GitLab instance administrator | No personal connection is needed. Your verified Sim email must match a confirmed GitLab email. | +| GitHub App installation | Connect GitHub once for this Sim organization. The App handles indexing; your account establishes which repositories you may search. | +| Confluence service account | Connect Confluence to verify your identity; the service account handles the crawl. | +| Google Workspace service account (Gmail, Calendar, Drive) | No personal connection is needed for that source. Your verified Sim email identifies your mailbox and calendar view, or is matched to Drive permissions. | +| GitLab instance administrator | No personal connection is needed. Your verified Sim email must match the confirmed primary GitLab email. | -Connecting one Google service does not connect all of them. Gmail, Calendar, and Drive each have their own Search connection. +Gmail, Calendar, and Drive are separate Search sources. Connecting one Google service does not connect all of them. A central source can be searchable without a personal connection row in **Integrations**. ## If you received a connection request -Open the link from your admin and sign in to Sim with the invited email. A provider-specific request opens that provider's connection directly. Verify your Sim email if prompted, then reopen the original link and authorize the account. +Open the link from your admin and sign in to Sim with the invited email. A provider-specific request opens a Sim connection page for that provider; select **Connect** to start authorization. Verify your Sim email if prompted. Sim returns you to the connection page to authorize your account; if you are not redirected, reopen the original link. + +An account connection request does not invite you into the Sim organization. You can contribute an account without organization membership, but you need membership and enabled Search access to search the organization's documents. A provider-specific request offers **Return to Search** when you have that access; otherwise it offers **Open Sim**. A request covering several providers lists their connection options and a **Submit** button. Each account is saved as soon as its authorization completes. -An account connection request does not invite you into the Sim organization. You can contribute an account without organization membership, but you need membership and enabled Search access to search the organization's documents. The connection page offers **Return to Search** when you have that access; otherwise it offers **Your connected accounts**. +Gmail account connection with Connect and Return to Search actions ## Manage your connected accounts -Select **Your accounts** on the main Integrations page to open your personal **Connected accounts** settings. Use **Reconnect** to renew an organization account connection or **Disconnect** to withdraw it. Disconnecting stops that account from being used for organization indexing and workflows, and removes Search access that depends on it. +On the main **Integrations** page, use **Reconnect** beside an expired connection to renew it. To withdraw an account, open its row's actions menu and select **Disconnect**, then confirm. If several accounts are connected, choose the account to disconnect. Disconnecting stops that account from being used for organization indexing and workflows, and removes Search access that depends on it. -Admins can select **Manage sources** to open organization setup, then select **Manage** beside an integration to open its **Sources** and **Accounts** tabs. This does not grant the admin access to every document. +Admins manage setup from **Settings → Sources**. Select **Manage** beside the integration. Providers with personal connections have **Accounts** and a source list under **Advanced** (Google) or **Sources**. Without personal connections, the source list opens directly. This does not grant the admin access to every document. ## If you get stuck | Status | What to do | | --- | --- | -| **Connect account** | Complete the connection in the new tab. | +| **Connect** | Complete the connection in the new tab. | | **Reconnect** | Authorize the same source account again. | | **Finish connecting in the other tab** | Finish authorization, or use **Open again**. Allow popups for Sim. | | No results | Check the source's filters and sync status with your admin. Confirm you can open the document at the source. | -| **Verify email** | Verify your Sim email, then reopen the connection link. | +| **Verify email** | Verify your Sim email to return to the connection page. Reopen the original link if you are not redirected. | | Expired or cancelled authorization | Return to the original connection page and start again. If the invitation itself expired, ask the admin for a new request. | | Access revoked | Ask the organization admin to restore your account contribution access before reconnecting. | -| Needs admin attention | Ask your admin to open **Settings → Sources**, select **Manage** beside the integration, and open the source to inspect its error. | +| Needs admin attention | Ask your admin to open **Settings → Sources**, select **Manage** beside the integration, and open its source or sync configuration to inspect the error. | Your Sim role does not override document access at the source. Connecting a different account or receiving a Search link does not share someone else's mailbox, private calendar, or restricted documents with you. diff --git a/apps/docs/content/docs/search/github.mdx b/apps/docs/content/docs/search/github.mdx index 1bcc37159b6..9a69063e165 100644 --- a/apps/docs/content/docs/search/github.mdx +++ b/apps/docs/content/docs/search/github.mdx @@ -1,117 +1,65 @@ --- title: GitHub -description: Search repository files through each member's GitHub account +description: Index repository files with a GitHub App while preserving each person's access --- import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' -GitHub Search indexes text files from a repository on `github.com`. An organization admin chooses the repository, then each person connects their GitHub account. Installing the GitHub App alone does not connect your teammates. +GitHub Search indexes text files from repositories on `github.com`. An organization admin can install the GitHub App once and use it to index selected repositories. Each person connects their own GitHub account once to search the repositories they can access. Installing the App does not connect teammates or give them the installer's permissions. -Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. +Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. Installation indexing is available for organization Search. For workspace Search, use **Search → Add source** with member accounts or a dedicated user account; **Create & Invite** is the workspace equivalent of **Add source**. ## Before you start -Your Sim deployment needs a GitHub App configured as described below. The repository must contain at least one commit; initialize an empty repository with a README before adding it. For private repositories, an owner or administrator must install that App on them. Each person needs a verified GitHub email address matching their Sim account; the address can be private or secondary. +The repository must contain at least one commit. Each person needs a verified GitHub email address matching their Sim account; the address can be private or secondary. -## Configure the GitHub App +On Sim Cloud, connect through the Sim Search GitHub App in the setup flow below. If you self-host Sim, a deployment administrator must first [configure GitHub Search](/platform/self-hosting/integrations-oauth#github-search). -This step belongs to the Sim deployment administrator. If the App is already configured, continue to [Add a repository](#add-a-repository). +To connect an installation for central indexing, you must be a Sim organization admin and either own the GitHub personal account or be an owner of the GitHub organization where the App is installed. You must also be able to read the repository you add. - - - -### Register the App - -For a team, open **Your organizations → Settings** for the organization that will own the App. For a personal App, open your account's **Settings**. Then choose **Developer settings → GitHub Apps → New GitHub App**. - -Give the App a unique, recognizable name, such as **Your Company Sim Search**, and set **Homepage URL** to your Sim URL. - -Under **Identifying and authorizing users → Redirect URI (callback URL)**, enter: - -```text -https:///api/auth/oauth2/callback/github-repositories -``` - -| GitHub setting | Value for Sim Search | -|---|---| -| Allow wildcard matching | Disabled | -| Expire user authorization tokens | Enabled | -| Request user authorization (OAuth) during installation | Disabled | -| Enable Device Flow | Disabled | -| Post installation → Setup URL | Empty | -| Webhook → Active | Disabled | - -Authorization starts from Sim so the callback can finish the pending connection. The connector polls GitHub's API and does not need a webhook. - -GitHub App registration with the Redirect URI, expiring tokens enabled, and installation authorization, Device Flow, and webhooks disabled - -*Example registration. Replace `sim.example.com` with your Sim domain.* +## Add a repository - + -### Set read permissions - -Expand **Permissions → Repository permissions**. Set **Contents → Access: Read-only**; leave the mandatory **Metadata** permission at **Read-only**. - -GitHub repository permissions with Contents set to Read-only and Metadata shown as mandatory Read-only - -Expand **Account permissions** and set **Email addresses → Access: Read-only**. - -GitHub account permissions with only Email addresses selected for Read-only access - -| Permission area | Permission | Access | -|---|---|---| -| Repository | Contents | Read-only | -| Repository | Metadata | Read-only | -| Account | Email addresses | Read-only | - -Leave every other permission at **No access**. Sim does not need issue, pull-request, administration, or write permissions. GitHub's [registration guide](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/registering-a-github-app) explains these settings. - -GitHub App user tokens use these permissions rather than OAuth scopes. An empty `scope` value in the token response is expected; see GitHub's [user token reference](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-user-access-token-for-a-github-app). - -Under **Where can this GitHub App be installed?**, choose **Only on this account** for an organization-owned App used only by members of that organization. Choose **Any account** when teammates or repository owners are outside that organization, or the App is owned by your personal account. A private App owned by a personal account can only be authorized by its owner; see GitHub's [App visibility rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/making-a-github-app-public-or-private). +### Open GitHub setup -Select **Create GitHub App**. +Open **Settings → Sources** and turn on **GitHub**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. -### Configure Sim and install the App +### Choose how to index -On the App's **General** settings page, copy its **Client ID**, then select **Generate a new client secret**. Configure these deployment variables and restart Sim: +In **Sync documents with**, choose **Connect GitHub App** to index through an installation: -```text -GITHUB_APP_CLIENT_ID= -GITHUB_APP_CLIENT_SECRET= -``` +GitHub source setup with indexing choices for connected members, a GitHub account, or a GitHub App -Use the **Client ID** and **client secret** from **Developer settings → GitHub Apps**. OAuth App credentials used for GitHub sign-in are not compatible. The numeric **App ID** and downloaded private key are not used by this connector. Keep expiring user tokens enabled so Sim can [refresh them](https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/refreshing-user-access-tokens). +1. Select **Connect your GitHub account** if prompted. Finish authorization in the new tab, then return and select **Refresh**. -In the App's sidebar, choose **Install App**, select the target account, and grant access to the repositories you want to search. Return to Sim and select **Connect account**. Every teammate must authorize from Sim too. +Connect GitHub App dialog requiring a personal GitHub connection to verify manageable installations - - +2. Select **Install GitHub App**. Choose your GitHub account or organization and the repositories to include. If it is already installed, check its repository selection. +3. Return to Sim and select **Refresh**. Choose the installation and select **Use installation**. Only installations on your own account or organizations you own are available. -## Add a repository +GitHub App installation chooser with Install GitHub App, Refresh, and Use installation controls; account name redacted - - +The installation is now selected under **Sync documents with**. You can reuse it when adding another repository source in the same Sim organization. -### Open GitHub setup - -Open **Settings → Sources** and turn on **GitHub** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. +Alternatively, leave **Connected members** selected to use members' accounts for indexing. To use a dedicated indexing account, select it under **Sync documents with**, or choose **Connect GitHub account** to add one. Each method still requires teammates to connect their own accounts for Search access. ### Choose what to index -Enter **Repository** as `owner/repo`. Open **More options** only if you need a different branch, path or extension filters, metadata tags, or a dedicated indexing account. **Sync documents with** defaults to **Connected members**. +Enter **Repository** as `owner/repo`. For installation indexing, it must belong to the installation's account and be included in the repositories granted to the App. Add one source per repository; installing on all repositories does not automatically create sources for them. + +Open **More options** if you need a different branch, path or extension filters, or metadata tags. -GitHub source setup with a required Repository field and More options +GitHub repository configuration with branch, path filter, file extensions, and metadata tags | Field | What to enter | |---|---| @@ -122,33 +70,44 @@ Enter **Repository** as `owner/repo`. Open **More options** only if you need a d **Metadata tags** controls the metadata stored with results. Its defaults are suitable for most sources. Select **Add source** to save the source. -You can instead select an existing account under **Sync documents with** to supply file contents centrally. Teammates still connect their own accounts to establish which files they may find. - ### Connect your account -Open **Integrations** in the main sidebar, select **Connect account** on the GitHub source, and authorize the App. Teammates repeat this step after joining the Sim organization. For private repositories, both the person's account and the App installation must have access. GitHub also permits App user tokens to read public repositories without an installation; see [GitHub's permission rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app). +Open **Integrations** in the main sidebar, select **Connect** on the GitHub source, and authorize the App. Teammates repeat this step after joining the Sim organization. For private repositories, both the person's account and the App installation must have access. GitHub also permits App user tokens to read public repositories without an installation; see [GitHub's permission rules](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/choosing-permissions-for-a-github-app). + +An App installation or dedicated indexing account can start syncing after the source is saved. With **Connected members**, indexing begins after someone connects. Each teammate still connects before searching. An existing GitHub connection in the same Sim organization is reused across its GitHub sources. -With **Connected members**, indexing begins after someone connects. A dedicated indexing account can start syncing immediately; each teammate still connects before searching. Open **Settings → Sources**, select **Manage** beside **GitHub**, then open the source to inspect **Documents**, edit **Settings**, or review **Sync history**. +Open **Settings → Sources**, select **Manage** beside **GitHub**, then open the source to inspect **Documents**, edit **Settings**, or review **Sync history**. -Use **GitHub → Accounts → Request connections** to send provider-specific connection requests. These requests do not grant organization membership. For another repository, add another source; members can also use **Add another GitHub source** in the main Integrations page. +Use **GitHub → Accounts → Request connections** to send provider-specific connection requests. These requests do not grant organization membership. For another repository, add another source; members can also select **Connect** beside the GitHub row labeled **Connect a different site or content scope** in Integrations. +## How access is enforced + +GitHub App installation access supplies file contents for indexing. Each reader's own connected GitHub account determines which repository's indexed content they can search. Sim organization admins follow the same rule as other readers. + +For installation-indexed sources, Sim checks the installation's current status and verifies repository content access with the reader's GitHub account before returning results or opening indexed content. If GitHub cannot confirm access, that repository's content is withheld. Removing a person's repository access, disconnecting their account, or removing the repository from the App's access prevents subsequent reads once GitHub reflects the change. File edits still appear after background indexing. + +This is an installation plus personal authorization flow. GitHub Search does not impersonate everyone in an email domain. [Google Drive delegation and GitLab administrator indexing](/search#choose-the-right-connection-method) use different supported identity and permission models. + ## Troubleshooting | Problem | Next step | |---|---| -| GitHub is unavailable in Search | Ask the deployment admin to configure the App client credentials and enable member connections. | -| GitHub rejects `redirect_uri` | Register the exact callback on the GitHub App whose Client ID Sim uses: `http://localhost:3000/api/auth/oauth2/callback/github-repositories` for the default local server, or your production Sim origin followed by `/api/auth/oauth2/callback/github-repositories`. The scheme, host, port, and path must match; keep wildcard matching disabled. See [callback matching](https://docs.github.com/en/apps/creating-github-apps/registering-a-github-app/about-the-user-authorization-callback-url). | -| Wrong app credentials | Copy the Client ID and client secret from **GitHub Apps**, not **OAuth Apps**. Set `GITHUB_APP_CLIENT_ID` and `GITHUB_APP_CLIENT_SECRET`, then restart Sim. A numeric App ID or private-key file cannot replace them. | +| GitHub is unavailable in Search | Ask your Sim organization admin to enable GitHub under **Settings → Sources**. For self-hosted Sim, also check the [GitHub App configuration](/platform/self-hosting/integrations-oauth#github-search). | | Repository cannot be read | Confirm the App is installed on that repository and your GitHub account has access. For SAML organizations, establish your GitHub SSO session before reconnecting. | -| A teammate cannot authorize the App | Check **Where can this GitHub App be installed?** and the App owner. A private organization App accepts only organization members; a private personal App accepts only its owner. | +| A teammate cannot authorize the App | Check the GitHub organization's App policies and required approvals. Self-hosted deployments must allow the teammate's account in their App visibility settings. | +| No eligible installations found | Finish connecting your own GitHub account, install the configured App on your account or an organization you own, then select **Refresh**. An installation of a different App or one you only have repository access to cannot be selected. | +| Repository is not accepted for an installation | Check `owner/repo`, the installation's account and repository selection, and your own access. Update the source's Repository field after a rename. After a transfer, add a source using an installation for the new owner. | | Identity verification fails | Verify the email used by your Sim account in GitHub's email settings, then reconnect. A public profile email alone is insufficient. | -| Authorization fails after installation | Return to Sim and start **Connect account** there. Do not enable authorization during installation. | +| Authorization fails after installation | Return to Sim and start **Connect** there. Do not enable authorization during installation. | +| Account authorization did not complete | Start the connection again from Sim. If it repeats, contact your organization admin or Sim support. For self-hosted Sim, check the [App callback and credentials](/platform/self-hosting/integrations-oauth#github-search). | +| GitHub asks for a provider configuration update | An organization admin selects **Settings → Sources → More → Refresh connection settings**, then affected users reconnect GitHub. | +| Indexed files no longer appear | Confirm your own repository access, App repository selection, and connection status. Installation-indexed content is also withheld when GitHub cannot verify current access; retry once GitHub is available. | | Sync is incomplete | Review the source status. Very large Git trees, file size limits, and unreadable files can limit indexing. | | Empty repository returns an error | Add an initial commit, then sync again. GitHub does not return a file tree for an uninitialized repository. | diff --git a/apps/docs/content/docs/search/gitlab.mdx b/apps/docs/content/docs/search/gitlab.mdx index 69789c73e36..39fb464de65 100644 --- a/apps/docs/content/docs/search/gitlab.mdx +++ b/apps/docs/content/docs/search/gitlab.mdx @@ -7,7 +7,7 @@ import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' -GitLab Search uses an administrator connection to sync a project's content and permissions. Teammates do not connect individual GitLab accounts. They sign in to the Sim organization with a verified email matching their confirmed GitLab email. +GitLab Search uses an administrator connection to sync a project's content and permissions. Teammates do not connect individual GitLab accounts. They sign in to the Sim organization with a verified email matching their confirmed primary GitLab email. GitLab source setup in Sim Search @@ -49,7 +49,7 @@ The token must read the project, users, inherited project membership, instance s ### Configure the source in Sim -Open **Settings → Sources** and turn on **GitLab** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. Paste the **Personal Access Token**, enter your **Host** and **Project**, and choose the content to index. +Open **Settings → Sources** and turn on **GitLab**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. Paste the **Personal Access Token**, enter your **Host** and **Project**, and choose the content to index. | Field | What to enter | |---|---| @@ -59,7 +59,7 @@ Open **Settings → Sources** and turn on **GitLab** under **Allowed in Sim Sear | Branch | Optional branch or tag for repository files; blank uses the project's default branch. | | Path Filter / File Extensions | Optional limits for repository files. | | Issue State / Labels / Milestone | Optional filters for issues. | -| Max Items | Optional positive limit. Leave blank for all matching items. | +| Max Items | Optional positive whole-number limit. Leave blank for all matching items. | **More options** contains the repository and issue filters, **Max Items**, and **Metadata tags**. Unlike member-account sources, GitLab retains this optional item limit. @@ -72,7 +72,7 @@ Select **Connect & Sync**. Sim validates the token and source policy, then start ### Let teammates search -Invite teammates to the Sim organization using their verified work email. Sim matches that email against the GitLab directory and applies project, feature, and confidential-issue permissions. No GitLab **Connect account** step is required. +Invite teammates to the Sim organization using their verified work email. Sim matches that email against their confirmed primary GitLab email and applies project, feature, and confidential-issue permissions. Confirmed secondary addresses are not matched. No GitLab **Connect** step is required. Admins open **Settings → Sources**, select **Manage** beside **GitLab**, then open the source to inspect **Documents**, edit **Settings**, or review **Sync history**. GitLab has no personal **Accounts** tab. Permission and membership changes are picked up during background refreshes. @@ -90,7 +90,7 @@ The connector supports text repository files, wiki pages, issues, merge requests | Administrator token required | Use an active instance administrator's PAT with `read_api`, plus `admin_mode` when required. A project or group token cannot replace it. | | Source permissions cannot be mirrored | Read the reported policy. Sim rejects unsupported external authorization, IP restrictions, download-ban policies, or session-specific step-up requirements. | | Project not found | Check the host, project path or ID, and token access. | -| A teammate sees no results | Confirm both accounts' verified/confirmed email addresses match and the user has the required GitLab project or feature access. | +| A teammate sees no results | Confirm their verified Sim email matches their confirmed primary GitLab email and the user has the required GitLab project or feature access. | | Token expired | Remove and add the source again with a new token. This connector does not support replacing its token in place or refreshing PATs automatically. | Custom GitLab roles may grant more access than Sim's conservative role mapping recognizes. A source requiring unsupported policies must remain unavailable until its access model can be represented accurately. diff --git a/apps/docs/content/docs/search/gmail.mdx b/apps/docs/content/docs/search/gmail.mdx index 1af4d9a514d..8945c941261 100644 --- a/apps/docs/content/docs/search/gmail.mdx +++ b/apps/docs/content/docs/search/gmail.mdx @@ -1,64 +1,129 @@ --- title: Gmail -description: Connect each teammate's Gmail account to search their email in Sim +description: Connect personal Gmail accounts or index Workspace mailboxes with a delegated service account --- import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' -Search email threads from your own Gmail account. An organization admin enables the source; each teammate connects their own account. An admin's connection does not make their mailbox available to the team. +Search email threads from your Gmail account. Members can connect their own accounts, or an administrator can index Google Workspace mailboxes with a service account. In either case, each mailbox stays private to its owner. Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. -## Set up the source +## Choose your setup -These steps require a Sim organization admin. +| Method | Use it when | What teammates do | +| --- | --- | --- | +| **Member accounts** | Each person should authorize their own Gmail account. | Connect the Google account matching their verified Sim email. | +| **Service account** | A Google Workspace administrator can authorize a central mailbox crawl. | Join the Sim organization with their matching verified primary Workspace email; no personal Gmail connection is needed. | + +These are alternative setup paths. When only a central Gmail source is configured, Integrations does not offer a personal Gmail **Connect** action. Existing member-account sources keep their connection actions. + +Central indexing does not make email searchable by the administrator, other recipients, or the rest of the organization. Mailbox delegation and shared-mailbox access are not mirrored; only the mailbox owner's verified email grants Search access. + +## Connect member accounts -### Set up Gmail +### Allow Gmail -Open **Settings → Sources** and turn on **Gmail** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. Gmail uses member accounts; there is no domain-wide or service-account crawl in Search. +Open **Settings → Sources** and turn on **Gmail**. This allows personal connections; it does not connect anyone's account. -### Choose what to include +### Connect your account -Keep the defaults to search all dates and labels, excluding Promotions, Social, Spam, and Trash. **Labels** and **Date Range** are shown first. Open **More options** for category exclusions, **Search Filter**, and **Metadata tags**. +Open **Integrations** and select **Connect** beside Gmail. Authorize the Google account matching your verified Sim email. The first connection creates the default sync configuration: the last 6 months across all labels, excluding Promotions, Social, Spam, and Trash. -### Create the source +### Adjust filters if needed -Click **Add source**. Gmail appears in the provider's **Sources** list. Each person, including the admin, then connects their own account from **Integrations** in the main sidebar. To send a Gmail connection request, open **Gmail → Accounts → Request connections**; this does not invite the recipient to the Sim organization. +An admin selects **Manage** beside Gmail in **Settings → Sources**, opens **Advanced**, then selects the configuration's **Settings** tab. Change **Labels**, **Date Range**, or other filters and save. + +One member-account configuration is usually enough. Its filters apply to all active Gmail connections, including accounts connected later. It does not assign different filters to selected people or let teammates search each other's mail. **Add sync configuration** opens the [central service-account setup](#set-up-a-central-service-account); it does not edit this member configuration. + +Configurations are additive: a narrower one does not restrict an existing broader one, and overlapping configurations can index the same thread more than once. For one organization-wide policy, edit the existing configuration. -Gmail Search source configuration +## Set up a central service account + +Open **Settings → Sources**, enable **Gmail**, and select **Manage → Advanced → Add sync configuration**. If personal connections are disabled for your organization, select **Add source** from the provider page instead. + +This requires Google Workspace and a Workspace super administrator to authorize delegation. Consumer Gmail accounts cannot use this path. Each selected user must have Gmail enabled. + + + + +### Create the Google service account + +In [Google Cloud Console](https://console.cloud.google.com/), select your project and enable **Gmail API** and **Admin SDK API** under **APIs & Services → Library**. Open **IAM & Admin → Service Accounts → Create service account**, name it, and finish creation. Google Cloud project roles do not grant access to Workspace mailboxes and are not required for this crawl. + +Open its **Keys** tab and select **Add key → Create new key → JSON → Create**. Store the downloaded key securely. See [Google's key creation guide](https://docs.cloud.google.com/iam/docs/keys-create-delete#creating). + + + + +### Authorize domain-wide delegation + +Open the service account's **Details → Advanced settings** and copy its numeric **Client ID**. In the [Workspace Admin Console](https://admin.google.com/ac/owl/domainwidedelegation), sign in as a super administrator and open **Security → Access and data control → API controls → Manage Domain Wide Delegation → Add new**. + +Enter the Client ID and these exact scopes, separated by a comma: + +```text +https://www.googleapis.com/auth/gmail.readonly,https://www.googleapis.com/auth/admin.directory.user.readonly +``` + +Select **Authorize**, then **View details** to confirm both scopes were saved. If the same client also indexes Drive or Calendar, retain those services' required scopes. These Gmail crawl scopes do not allow sending or modifying mail. + +If your organization requires multi-party approval, another super administrator must approve the request. Delegation can take up to 24 hours to propagate. See Google's [delegation guide](https://knowledge.workspace.google.com/admin/apps/control-api-access-with-domain-wide-delegation). + + + + +### Configure Gmail in Sim + +Under **Indexing account**, select **Add service account** and paste the JSON key into **Add Google Service Account**, or select an existing service account. Set **Directory administrator email** to an active Workspace administrator who can read users in the Directory API. This account supplies directory access; each mailbox is read using that mailbox owner's delegated identity. + +Keep the default **Date Range** of **Last 6 months**, or adjust it and **Labels**. Use label names or system IDs such as `INBOX`; custom `Label_…` IDs are mailbox-specific. A label that does not exist in one mailbox simply matches no threads there. + +Under **More options → Users**, enter up to 100 primary Workspace email addresses, or leave blank for all active users in the same Workspace customer, including secondary domains. Suspended, archived, and guest users are excluded. Select **Connect & Sync**. Sim validates the directory administrator and selected users, then checks Gmail access for a sample user before saving. + +Teammates join the Sim organization with their matching verified primary email. They do not need to connect personal Google accounts for this source. + + + ## Connect your account -1. Join the Sim organization and verify your Sim email address. Open **Integrations** and click **Connect account** beside Gmail. +These steps apply to **Member accounts**. A central service-account source does not require a personal Gmail connection. + +1. Join the Sim organization and verify your Sim email address. Open **Integrations** and click **Connect** beside Gmail. 2. Complete the connection in the tab that opens. Choose the Google account whose verified email matches your Sim email, and grant the requested permissions. 3. Return to Integrations. The source shows its indexing status and the number of documents you can search. -Teammates follow these same steps after joining the organization. Once an admin approves Gmail, the first connection can create its source with default filters. Admins can configure shared filters beforehand. +Teammates follow these same steps after joining the organization. Once an admin allows Gmail, the first connection can create its source with default filters. Admins can edit those filters afterward or request connections from **Manage → Accounts → Request connections**. A connection request does not invite the recipient to the Sim organization. ## Source options -An admin opens **Settings → Sources**, selects **Manage** beside **Gmail**, and opens the source's **Settings** tab to change these options. Filters apply separately to each connected mailbox. **Documents** shows indexed threads and **Sync history** shows recent runs. +An admin opens **Settings → Sources** and selects **Manage** beside **Gmail** to open its configuration list. Each row shows **Member accounts** or **Service account** beside its sync status. Open a configuration's **Settings** tab to edit its filters, then select **Save**. Filters apply separately to each mailbox in the source. **Documents** shows indexed threads and **Sync history** shows recent runs. + +**Sync using** identifies the configuration's fixed connection method. To replace a central credential, choose another **Indexing account** and select **Change indexing account**. | Option | Behavior | | --- | --- | -| Labels | Optional comma-separated names or system IDs, such as `Engineering, INBOX`. A thread matching any listed label is included. Leave empty for all labels. Custom IDs such as `Label_7` belong to one mailbox and cannot be used for member setup. | -| Date Range | All time by default. Choose the last 7, 30, or 90 days, 6 months, or year. | +| Labels | Optional comma-separated names or system IDs, such as `Engineering, INBOX`. A thread matching any listed label is included. Leave empty for all labels. Custom IDs such as `Label_7` belong to one mailbox and cannot be used for member or central setup. | +| Directory administrator email | Required for central indexing. An active Workspace administrator who can read Directory users; this does not limit the crawl to the administrator's mailbox. | +| Users | Central indexing only. Optional primary Workspace email addresses (up to 100); blank includes all active users in the customer. This selects which mailboxes to crawl. Each mailbox remains searchable only by its owner. | +| Date Range | Last 6 months by default for Search sources. Choose the last 7, 30, or 90 days, a year, or all time. A knowledge-base connector outside Search defaults to all time. | | Exclude Promotions / Exclude Social | Both enabled by default. Choose **No** to include either category. | -| Search Filter | Optional [Gmail query](https://developers.google.com/workspace/gmail/api/guides/filtering), such as `from:team@example.com subject:release`. This filters what is indexed; it is not a Sim Search query. | +| Search Filter | Optional [Gmail query](https://developers.google.com/workspace/gmail/api/guides/filtering), such as `from:team@example.com subject:release`. This filters what is indexed; it is not a Sim Search query. Member-account sources with a search filter relist the mailbox on every sync instead of using Gmail's change history. | In the add-source form, **More options** contains optional **Metadata tags**. Sync frequency and the general knowledge-base **Max Threads** setting are hidden in Search. @@ -68,7 +133,17 @@ Sim indexes the message text Gmail returns for each matching thread, plus subjec File attachments and image contents are not indexed. Thread discovery uses Gmail's default exclusion of Spam and Trash. A filter such as `has:attachment` selects the email thread; it does not index the attachment. Gmail API filtering also differs from Gmail's interface for aliases and thread-wide searches. See Google's [thread listing reference](https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.threads/list) and [filtering guide](https://developers.google.com/workspace/gmail/api/guides/filtering). -Search schedules syncs hourly. The first sync and large mailboxes can take longer; results appear as documents are indexed. Updates and removals are reconciled during background sync, rather than fetched live for each search. +Search schedules syncs hourly. The first sync lists every thread in scope and can take several runs for a large mailbox; results appear as documents are indexed. + +**Member accounts:** later syncs use each mailbox's Gmail change history, unless the configuration has a search filter. A full relisting runs about weekly, or sooner if Gmail no longer retains the saved history. + +**Service account:** each sync revisits the selected active mailboxes and resumes unfinished listings. It does not reuse one mailbox's history cursor across the company. Only new or changed threads need their bodies fetched. Failed mailbox reads leave the crawl incomplete; they are not treated as an empty mailbox for deletion reconciliation. + +Updates, removals, and access refresh in the background, rather than being checked live for each search. + +An empty mailbox or filters with no matching threads complete normally with zero documents. + +Threads that exceed indexing size limits are skipped and reconsidered when the thread changes. ## Troubleshooting @@ -76,18 +151,23 @@ Search schedules syncs hourly. The first sync and large mailboxes can take longe | --- | --- | | A different email is requested | Use the Google account matching your verified Sim email. A separate personal account or alias does not satisfy the match. | | No searchable documents | Check the source's labels, date range, category exclusions, and search filter. Allow the first sync to finish. | -| Finish connecting in the other tab | Complete the Google flow, or use **Open again** while authorization is pending. If the popup was blocked or closed, allow popups and select **Connect account** again. | +| Finish connecting in the other tab | Complete the Google flow, or use **Open again** while authorization is pending. If the popup was blocked or closed, allow popups and select **Connect** again. | | Reconnect | Click **Reconnect** and authorize the same account again. | | Unavailable or needs admin attention | Ask your Sim admin to check source status and the deployment's Google OAuth configuration. | +| Directory or delegation error | Check both central crawl scopes, the service-account key, and the Directory administrator's user-read privileges. A normal OAuth account cannot replace the central service account. | +| Gmail access fails for a selected user | Verify Gmail is enabled for that primary Workspace account and delegation is authorized. Narrow **Users** to accounts with Gmail enabled. Aliases and external accounts cannot be selected. | +| A central source indexes mail but a teammate sees no results | Confirm their verified Sim email is the mailbox's primary email and they belong to the Sim organization. Administrators do not receive other people's mailbox access. | ## Self-hosted operator setup -Users do not need to create Google Cloud credentials. The deployment operator configures one Google OAuth client for the instance: +For an External app in **Testing**, Google refresh tokens for these scopes expire after seven days. Before production use, configure the appropriate publishing status and complete any required verification; adding test users alone does not make a durable production connection. See [Google’s token expiration rules](https://developers.google.com/identity/protocols/oauth2#expiration). + +Member-account connections use the deployment's Google OAuth client below. Central service-account indexing uses the separate setup above and does not require each user to complete OAuth. 1. In [Google Cloud Console](https://console.cloud.google.com/), select your project. Open **APIs & Services → Library**, find **Gmail API**, and enable it. 2. Open **Google Auth platform → Branding**. Select **Get started** if needed, then enter the app name, support email, and contact email. Under **Audience**, use **Internal** only for an app limited to your Google Workspace organization; otherwise use **External** and add test users while testing. Review the app's permissions under **Data Access → Add or remove scopes**, using the current Sim scopes below. Follow Google's [consent and verification guidance](https://developers.google.com/workspace/guides/configure-oauth-consent) for your audience. 3. Open **Google Auth platform → Clients → Create client**. Choose **Web application**, give the client a name, and add the URI below under **Authorized redirect URIs**. If this instance already has a Google client, add this URI to that client instead. See [Google's credential setup](https://developers.google.com/workspace/guides/create-credentials#web-application). -4. Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). +4. Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). If you change an existing deployment's OAuth client or scopes, an organization admin selects **Settings → Sources → More → Refresh connection settings**, then affected teammates reconnect. ```text https:///api/auth/oauth2/callback/google-email @@ -97,7 +177,7 @@ https:///api/auth/oauth2/callback/google-email This Google Cloud example uses one client for all three services. Replace `https://sim.example.com` with your Sim origin and add only the callbacks for services you enable. -The current Sim Gmail connection uses these scopes: +The member-account OAuth connection uses these scopes: ```text openid @@ -109,5 +189,5 @@ https://www.googleapis.com/auth/gmail.labels ``` - Google's `gmail.readonly` scope is sufficient for Search's email reads. Sim currently shares its Gmail OAuth connection with workflow actions and requires the broader scope set above; do not substitute `gmail.readonly` in this setup. Search does not send or modify email. See [Google's scope descriptions](https://developers.google.com/workspace/gmail/api/auth/scopes). + Google's `gmail.readonly` scope is sufficient for Search's email reads and is used by the central service-account path. Member-account connections share their OAuth credentials with workflow actions and require the broader set above. Search does not send or modify email. See [Google's scope descriptions](https://developers.google.com/workspace/gmail/api/auth/scopes). diff --git a/apps/docs/content/docs/search/google-calendar.mdx b/apps/docs/content/docs/search/google-calendar.mdx index fecad2266eb..5435e41ebd8 100644 --- a/apps/docs/content/docs/search/google-calendar.mdx +++ b/apps/docs/content/docs/search/google-calendar.mdx @@ -1,55 +1,112 @@ --- title: Google Calendar -description: Search calendar events using each teammate's own Google access +description: Connect personal Calendar accounts or index your company with a delegated service account --- import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' -Search meetings and event details available to your Google account. An organization admin enables the source; every teammate connects their own account. Google controls which calendar and event details each person can read. +Search meetings and event details available to your Google account. Members can connect personal accounts, or a Google Workspace administrator can configure a central service-account crawl. Both paths keep each person's own view of events separate. Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. -## Set up the source +## Choose your setup -These steps require a Sim organization admin. +| Method | Use it when | What teammates do | +| --- | --- | --- | +| **Member accounts** | Each person should connect their own Calendar access. No Google Workspace administrator setup is needed. | Connect their own Google Calendar account. | +| **Service account** | A Workspace administrator can authorize a central crawl with domain-wide delegation. | Join the Sim organization with matching verified email addresses; no personal Calendar connection is needed for this source. | + +Central indexing reads calendars as each selected user. An organizer's private event details are never reused as an attendee's copy. Each indexed copy is searchable only by the corresponding user, including when several users can read the same shared calendar. + +These are alternative setup paths. When only a central Calendar source is configured, Integrations does not offer a personal Calendar **Connect** action. Existing member-account sources keep their connection actions. + +## Connect member accounts -### Set up Google Calendar +### Allow and connect Google Calendar -Open **Settings → Sources** and turn on **Google Calendar** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. Search uses member accounts; an admin or service account cannot connect on behalf of everyone. +An admin opens **Settings → Sources** and turns on **Google Calendar**. Then each person opens **Integrations**, selects **Connect** beside Google Calendar, and authorizes their matching Google account. The first connection creates the default member-account sync configuration. -### Choose the calendars +### Choose calendars if needed -Leave **Calendars** empty to search each person's primary calendar. To include specific shared calendars, select an **Account for browsing** and choose calendars, or switch to **Calendar IDs** and enter their IDs. +An admin selects **Manage** beside Google Calendar in **Settings → Sources**, opens **Advanced**, and selects the configuration's **Settings** tab. Leave **Calendars** empty to search each person's primary calendar. To include specific shared calendars, select an **Account for browsing** and choose calendars, or switch to **Calendar IDs** and enter their IDs. **Account for browsing** only helps you choose calendars. It does not connect your account for Search or grant teammates access. -### Create the source +### Save the configuration -Keep the default date range for the previous and next 30 days. **More options** contains **Search Query**, **Include Attendees**, and **Metadata tags**. Click **Add source**, then connect your own account from **Integrations** in the main sidebar. +The default date range covers the previous and next 30 days. Save any changes to the existing configuration. Its calendar and date filters apply separately to each connected member's access. **Add sync configuration** opens the [central service-account setup](#set-up-a-central-service-account); it does not edit this member configuration. -Google Calendar Search source configuration - - `primary` means the connected person's main calendar. A calendar selected from the list is a specific calendar ID, even when it is your main calendar. That same ID applies to every member, and only members with access to it can search its events. + `primary` means the connected or impersonated person's main calendar. A calendar selected from the list is a specific calendar ID, even when it is your main calendar. That same ID applies to every selected user, and only users with access to it can search its events. +## Set up a central service account + +Open **Settings → Sources** and turn on **Google Calendar**. Select **Manage → Advanced → Add sync configuration** to open the central service-account form. If personal connections are disabled for your organization, select **Add source** from the provider page instead. + +This requires a Google Workspace customer and a super administrator to authorize domain-wide delegation. Consumer Gmail accounts cannot use this path. + + + + +### Prepare the service account + +In [Google Cloud Console](https://console.cloud.google.com/), select your project and enable **Google Calendar API** and **Admin SDK API** under **APIs & Services → Library**. Open **IAM & Admin → Service Accounts → Create service account** and create the account. Google Cloud project roles do not grant Calendar access and are not required for this crawl. + +Open the service account's **Keys** tab, then select **Add key → Create new key → JSON → Create**. Keep the downloaded key secure; you will add it to Sim. See [Google's key creation guide](https://docs.cloud.google.com/iam/docs/keys-create-delete#creating). + + + + +### Authorize domain-wide delegation + +Copy the service account's numeric **Client ID** from **Details → Advanced settings**. As a Workspace super administrator, open **Security → Access and data control → API controls → Manage Domain Wide Delegation → Add new** in the [Admin Console](https://admin.google.com/ac/owl/domainwidedelegation). + +Enter the Client ID and these exact comma-separated **OAuth scopes**: + +```text +https://www.googleapis.com/auth/calendar.events.readonly,https://www.googleapis.com/auth/admin.directory.user.readonly +``` + +Select **Authorize** and verify both scopes under **View details**. If you reuse a Drive or Gmail service account, retain its existing delegated scopes and add any missing Calendar scopes. An existing Drive authorization alone does not grant Calendar access. Delegation can take up to 24 hours to propagate; organizations requiring multi-party approval need another super administrator to approve the change. See [Google's delegation guide](https://knowledge.workspace.google.com/admin/apps/control-api-access-with-domain-wide-delegation). + +The **Directory administrator email** must be an active Workspace administrator with permission to read users. A super administrator has this permission; a custom administrator role can supply it. This identity lists the directory. Sim obtains a separate read-only Calendar token for each selected user; it does not read everyone's events as the administrator. + + + + +### Add the credential and choose users + +Under **Indexing account**, select **Add service account** or an existing service account. In **Add Google Service Account**, give the credential a name and paste its JSON key. Back in the source form, enter the **Directory administrator email**. + +Leave **Calendar IDs** empty for each user's `primary` calendar. To include shared calendars, enter their IDs, optionally alongside `primary`. IDs apply to each selected user who can read that calendar; this does not share calendars or expand anyone's Google access. Central setup uses manual IDs because an administrator's calendar picker would not represent every user's calendars. + +Choose the **Date Range**. Under **More options**, leave **Users** blank to include all active users in the Workspace customer, including secondary domains, or enter up to 100 primary email addresses separated by commas. Suspended, archived, and guest accounts are excluded. Choose **Connect & Sync**. + +Sim verifies Directory access and selected users, then probes one selected user's primary calendar to check the delegated Calendar permission. Shared-calendar access is checked separately for each user during sync. Teammates join the Sim organization with their matching verified primary Workspace email; they do not connect personal accounts for this source. + + + + ## Connect your account -1. Join the Sim organization and verify your Sim email. Open **Integrations** and click **Connect account** beside Google Calendar. +These steps apply to **Member accounts**. When only a central Calendar source is configured, teammates use Search or Home directly and are not offered a personal Calendar connection for that source. + +1. Join the Sim organization and verify your Sim email. Open **Integrations** and click **Connect** beside Google Calendar. 2. In the connection tab, choose the Google account whose verified email matches your Sim email. Grant the requested permissions. 3. Return to Integrations to see indexing status and your searchable document count. @@ -57,11 +114,15 @@ Teammates repeat only these connection steps after joining the organization. The ## Source options -An admin opens **Settings → Sources**, selects **Manage** beside **Google Calendar**, and opens the source's **Settings** tab to change these options. **Documents** shows indexed events and **Sync history** shows recent runs. +An admin opens **Settings → Sources** and selects **Manage** beside **Google Calendar** to open its configuration list. Each row shows **Member accounts** or **Service account** beside its sync status. Open a configuration's **Settings** tab to edit its filters, then select **Save**. **Documents** shows indexed events and **Sync history** shows recent runs. + +**Sync using** identifies the configuration's fixed connection method. To replace a central credential, choose another **Indexing account** and select **Change indexing account**. | Option | Behavior | | --- | --- | | Calendars / Calendar IDs | Empty defaults to each member's `primary` calendar. Explicit IDs restrict the source to those calendars. Multiple IDs are comma-separated; combine `primary` with shared calendar IDs if needed. | +| Directory administrator email | Central indexing only. Required to enumerate Workspace users; it does not limit the source to the administrator's events. | +| Users | Central indexing only. Optional primary email addresses (up to 100); blank includes all active users in the Workspace customer. Each user's event copies remain private to that user. | | Date Range | Previous and next 30 days by default. Alternatives are the previous 30 days, next 30 days, or 90 days in each direction. The window moves forward on later syncs. | | Search Query | Optional text filter applied by Google to event titles, descriptions, locations, and organizer or attendee names and emails. Leave empty to include all matching events in the date range. | | Include Attendees | **Yes** by default. **No** omits organizer and attendee identity fields and keeps the attendee count. It does not redact names written into titles or descriptions. | @@ -70,11 +131,11 @@ In the add-source form, **More options** contains optional **Metadata tags**. Se ## What gets indexed -Sim indexes event titles, descriptions, times, locations, and the selected attendee information. All-day events and individual occurrences of recurring meetings are supported. Results link back to Google Calendar. +Sim indexes event titles, descriptions, times, locations, and the selected attendee information. All-day events and individual occurrences of recurring meetings are supported. An invitation you declined stays searchable and is marked `Response: declined`. Results link back to Google Calendar. -Cancelled events, attachment contents, meeting recordings, and transcripts are not indexed. Events outside the selected date window are excluded. Private event details that Google withholds are not available in Search; see [Google's calendar sharing rules](https://developers.google.com/workspace/calendar/api/concepts/sharing). +Cancelled events, attachment contents, meeting recordings, and transcripts are not indexed. Status entries such as working location, out of office, focus time, and birthdays, and automatically generated reservation events from Gmail are not indexed. A shared calendar where you can see only free or busy times contributes nothing, since those blocks have no title or description. Events outside the selected date window are excluded. Private event details that Google withholds are not available in Search; see [Google's calendar sharing rules](https://developers.google.com/workspace/calendar/api/concepts/sharing). -Search schedules syncs hourly. Event edits, cancellations, access changes, and events moving outside the date window are reconciled during background sync. The first sync may take longer, and results appear as indexing progresses. +Search schedules syncs hourly. Event edits, cancellations, access changes, inactive or removed users, and events moving outside the date window are reconciled during completed background syncs. Central crawls page through each selected user and resume unfinished work before removing documents no longer listed. Authorization, quota, and provider failures stop the sync rather than treating unread calendars as empty. The first sync may take longer, and results appear as indexing progresses; Search is not a live Calendar read. ## Troubleshooting @@ -86,15 +147,20 @@ Search schedules syncs hourly. Event edits, cancellations, access changes, and e | A different email is requested | Choose the Google account matching your verified Sim email. | | Reconnect | Click **Reconnect** and complete Google authorization again. Allow pop-ups if the connection tab does not open. | | Unavailable or needs admin attention | Ask your Sim admin to check source status and the deployment's Google OAuth configuration. | +| Service-account authorization or Directory error | Confirm both delegated scopes, enabled APIs, and the Directory administrator's user-read privilege. Check whether delegation still awaits approval or propagation. | +| User not found or inactive | Use an active primary email in the same Workspace customer. Aliases, external or guest accounts, suspended users, and archived users cannot be selected. | +| A central source has no results for a teammate | Confirm their primary Workspace email matches their verified Sim email, they belong to the Sim organization, and they are included in **Users**. Check calendar IDs and **Sync history**. | ## Self-hosted operator setup -The deployment operator configures Google OAuth once; teammates then use the normal connection flow. +For an External app in **Testing**, Google refresh tokens for these scopes expire after seven days. Before production use, configure the appropriate publishing status and complete any required verification; adding test users alone does not make a durable production connection. See [Google’s token expiration rules](https://developers.google.com/identity/protocols/oauth2#expiration). + +The deployment operator configures Google OAuth for member accounts and the member-mode calendar picker. Central service accounts use the separate delegation setup above. 1. In [Google Cloud Console](https://console.cloud.google.com/), select your project. Open **APIs & Services → Library**, find **Google Calendar API**, and enable it. 2. Open **Google Auth platform → Branding** and configure the app name and contact details. Under **Audience**, choose **Internal** for your Google Workspace organization only, or **External** for other users. Add test users while an external app is testing. Review **Data Access → Add or remove scopes** using the current Sim scopes below. See Google's [consent and verification guidance](https://developers.google.com/workspace/guides/configure-oauth-consent). 3. Open **Google Auth platform → Clients → Create client**, choose **Web application**, and add the URI below under **Authorized redirect URIs**. Add it to the existing Google client if the instance already uses one. See [Google's credential setup](https://developers.google.com/workspace/guides/create-credentials#web-application). -4. Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). +4. Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). If you change an existing deployment's OAuth client or scopes, an organization admin selects **Settings → Sources → More → Refresh connection settings**, then affected teammates reconnect. ```text https:///api/auth/oauth2/callback/google-calendar @@ -104,7 +170,7 @@ https:///api/auth/oauth2/callback/google-calendar This Google Cloud example uses one client for all three services. Replace `https://sim.example.com` with your Sim origin and add only the callbacks for services you enable. -The current Sim Calendar connection uses these scopes: +The member-account OAuth connection uses these scopes: ```text openid @@ -114,5 +180,5 @@ https://www.googleapis.com/auth/calendar ``` - Search's reads can use `calendar.events.readonly`, `calendar.calendarlist.readonly`, and `calendar.calendars.readonly` for events, the calendar list, and calendar details. Sim currently shares its Calendar OAuth connection with workflow actions and requires the broader `calendar` scope above. Do not replace it with read-only scopes in this setup. Search does not change calendars or events. See [Google's scope descriptions](https://developers.google.com/workspace/calendar/api/auth). + The central service account uses `calendar.events.readonly` for event reads and the separate Directory scope listed earlier. Member connections share their OAuth credentials with workflow actions and require the broader `calendar` scope above. Search does not change calendars or events. See [Google's scope descriptions](https://developers.google.com/workspace/calendar/api/auth). diff --git a/apps/docs/content/docs/search/google-drive.mdx b/apps/docs/content/docs/search/google-drive.mdx index 3f68cebe7c7..8cde905b3e9 100644 --- a/apps/docs/content/docs/search/google-drive.mdx +++ b/apps/docs/content/docs/search/google-drive.mdx @@ -1,6 +1,6 @@ --- title: Google Drive -description: Connect Drive files through member accounts or a delegated service account +description: Connect personal Drive accounts or index your company with a delegated service account --- import { Callout } from 'fumadocs-ui/components/callout' @@ -15,11 +15,13 @@ Admin setup uses your organization's **Settings → Sources** page. Teammates co | Method | Use it when | What teammates do | | --- | --- | --- | -| **Member accounts** | Each person should connect their own Drive access. No Google Workspace administrator setup is needed. | Connect their own Google Drive accounts after the source is created. | -| **Service account** | A Google Workspace administrator can configure delegation and directory access for a central crawl. | Sign in to Sim with matching verified email addresses; no personal Drive connection is needed for this source. | +| **Member accounts** | Each person should connect their own Drive access. No Google Workspace administrator setup is needed. | Connect their own Google Drive accounts. | +| **Service account** | A Google Workspace administrator can configure delegation and directory access for a central crawl. | Join the Sim organization with matching verified email addresses; no personal Drive connection is needed for this source. | + +These are alternative setup paths. When only a central Drive source is configured, Integrations does not offer a personal Drive **Connect** action. Teammates use Search or Home directly. Existing member-account sources keep their connection actions. - A central crawl indexes only files the configured **Crawl as** account can access. Domain-wide delegation does not make this connector crawl every employee's Drive. Share the intended content with the indexing account, or use member accounts for each person's accessible files. + A central crawl reads each selected employee's Drive through domain-wide delegation, including private My Drive files and shared-drive files they can access. Leave **Users** blank to include all active users in your Google Workspace customer, including secondary domains. Files keep their original user and group permissions; indexing a private file does not make it visible to other employees. ## Connect member accounts @@ -29,21 +31,21 @@ Admin setup uses your organization's **Settings → Sources** page. Teammates co ### Allow Google Drive -An organization admin opens **Settings → Sources** and turns on **Google Drive** under **Allowed in Sim Search**. This allows personal connections; it does not create a source or connect anyone's account. +An organization admin opens **Settings → Sources** and turns on **Google Drive**. This allows personal connections; it does not create a source or connect anyone's account. ### Connect your account -Open **Integrations** in the main sidebar and select **Connect account** beside Google Drive. Use the Google account matching your verified Sim email. The first personal connection can create a source with default filters. Teammates follow the same [connection steps](/search/connect-your-account). +Open **Integrations** in the main sidebar and select **Connect** beside Google Drive. Use the Google account matching your verified Sim email. The first personal connection can create a source with default filters. Teammates follow the same [connection steps](/search/connect-your-account). ### Adjust filters if needed -An admin opens **Settings → Sources**, selects **Manage** beside **Google Drive**, and opens the source's **Settings** tab. Leave **Folders** empty to include supported files each member can access, or narrow the source to folders. **Account for browsing** helps select folders; manual **Folder IDs** work without it. Browsing does not connect that account to Search. +An admin opens **Settings → Sources**, selects **Manage** beside **Google Drive**, opens **Advanced**, and selects the sync configuration's **Settings** tab. Leave **Folders** empty to include supported files each member can access, or narrow the source to folders. **Account for browsing** helps select folders; manual **Folder IDs** work without it. Browsing does not connect that account to Search. Keep **Sync documents with → Connected members** unless a dedicated account should fetch content. Members still connect to establish access. If the dedicated account is a delegated service account, **Crawl as** selects the Google Workspace user whose files it fetches. Save the source settings when finished. @@ -52,11 +54,11 @@ Keep **Sync documents with → Connected members** unless a dedicated account sh ## Set up a central service account -Open **Settings → Sources** and turn on **Google Drive** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. This opens central service-account setup. Teammates do not need a personal Drive connection for this source. +Open **Settings → Sources** and turn on **Google Drive**. Select **Manage → Advanced → Add sync configuration** to open the central service-account form directly. If personal connections are disabled for your organization, select **Add source** from the provider page instead. Teammates do not need a personal Drive connection for this source. This requires a Google Workspace domain and a Workspace super administrator to authorize domain-wide delegation. Consumer Gmail accounts cannot use this path. -Google Drive central source setup with an indexing account, sharing policy, and folder scope +Google Drive central source setup with a service account, Directory administrator email, sharing policy, and optional folders @@ -83,54 +85,70 @@ In the service account's **Details**, expand **Advanced settings** and copy its Paste that Client ID into **Client ID**, then enter these exact scopes as a comma-separated list under **OAuth scopes**: ```text -https://www.googleapis.com/auth/drive.readonly,https://www.googleapis.com/auth/admin.directory.group.readonly,https://www.googleapis.com/auth/admin.directory.domain.readonly +https://www.googleapis.com/auth/drive.readonly,https://www.googleapis.com/auth/admin.directory.user.readonly,https://www.googleapis.com/auth/admin.directory.group.readonly,https://www.googleapis.com/auth/admin.directory.domain.readonly ``` -Select **Authorize**, then **View details** to confirm all three scopes were saved. If your organization requires multi-party approval, another super administrator must approve the request. Delegation changes can take up to 24 hours to propagate. See Google's [Admin Console delegation guide](https://knowledge.workspace.google.com/admin/apps/control-api-access-with-domain-wide-delegation). +Select **Authorize**, then **View details** to confirm all four scopes were saved. If you reuse a Gmail or Calendar service account, retain those services' required scopes and add any missing Drive scopes. If your organization requires multi-party approval, another super administrator must approve the request. Delegation changes can take up to 24 hours to propagate. See Google's [Admin Console delegation guide](https://knowledge.workspace.google.com/admin/apps/control-api-access-with-domain-wide-delegation). These are Search's central crawl scopes. The general [Google service account guide](/integrations/google-service-account) includes broader scopes for workflow actions; do not copy those into this Search setup. +The **Directory administrator email** must belong to an active Workspace administrator with permission to read users, groups, group memberships, and domains. A super administrator has these privileges; a custom administrator role can supply them instead. This identity enumerates the directory. Sim obtains separate read-only Drive tokens for the selected users. + +Group permissions require groups and memberships that this administrator can read in this Google Workspace customer. External groups and unresolvable nested groups are not supported. Google Drive target-audience shares are not mapped; use explicit user, supported group, or domain permissions instead. + ### Add the credential in Sim -Under **Indexing account**, choose the service-account connection action, or select an existing service account. Paste the JSON key into **Add Google Service Account**, give it a name, and add it. Sim returns you to the source form with that credential selected. +Under **Indexing account**, choose **Add service account**, or select an existing service account. Paste the JSON key into **Add Google Service Account**, give it a name, and add it. Sim returns you to the source form with that credential selected. Add Google Service Account credential modal in Sim -### Choose the indexing identity +### Choose the directory administrator and users + +Set **Directory administrator email** to the Workspace administrator described above. Under **More options**, leave **Users** blank for everyone, or enter up to 100 primary Workspace email addresses separated by commas. Suspended, archived, and guest accounts are excluded. -Set **Crawl as** to a Google Workspace administrator who can read groups, memberships, and domains, and can access the content you want indexed. Select folders if needed, then choose **Connect & Sync**. Sim validates Drive and Directory access before accepting the source. +Leave **Folders** empty to include supported files each selected user can access. To narrow the source, select folders visible to the Directory administrator or enter **Folder IDs** manually. The same folder filter applies to each selected user and does not grant access. Choose **Connect & Sync**. Sim validates the administrator and any selected users, and probes Drive access for an active user, before accepting the source. + +The crawl pages through each user’s files and shared drives, including shared-drive files the user has never opened. It resumes unfinished work and indexes a shared file once even when several users can access it. A file must be downloadable by at least one selected user to be indexed; the same requirement applies to a shortcut’s target. Sim must also verify its permissions before showing it in Search. Google can let a reader download a file while refusing to list its permissions; include an owner or another user who can read those permissions. Externally owned files can remain hidden when no selected user can verify their permissions. + +Teammates join the Sim organization with their matching verified email; they do not connect personal Google accounts for this central source. ## Source options +An admin opens **Settings → Sources** and selects **Manage** beside **Google Drive** to open its configuration list. Each row shows **Member accounts** or **Service account** beside its sync status. Open a configuration's **Settings** tab to edit its filters, then select **Save**. **Documents** shows indexed files and **Sync history** shows recent runs. + +**Sync using** identifies the configuration's fixed connection method. To replace a central credential, choose another **Indexing account** and select **Change indexing account**. + | Option | Behavior | | --- | --- | | Folders / Folder IDs | Optional. Includes files in each selected folder and its accessible subfolders. A folder selection does not grant access. | | File Type | All supported files by default, or only Google Docs, Sheets, Slides, or text formats. **Plain text files only** also includes CSV, HTML, Markdown, JSON, and XML. | -| Crawl as | Required for the central service account. In Member accounts, it optionally supplies the impersonated user when a dedicated service account fetches content. It has no effect on ordinary OAuth accounts. | +| Directory administrator email | Required for central indexing. Supplies Directory access for user enumeration and permission groups; it does not limit the crawl to this administrator's files. | +| Users | Central indexing only. Optional primary email addresses (up to 100); blank includes all active users in this Workspace customer. This selects which users' Drives to crawl, not who may search the resulting files. | +| Crawl as | In Member accounts, optionally supplies the impersonated user when a dedicated service account fetches content. It has no effect on ordinary OAuth accounts. | | Openly shared files | Applies only to central crawls; it has no effect in Member accounts. **Keep out of search** by default. You can include discoverable domain shares or discoverable public shares. Link-only sharing does not grant Search access; named user and group permissions still apply. | -| Metadata tags | Optional owner, file type, modification date, and starred metadata. In the add-source form, these and **File Type** are under **More options**. | +| Metadata tags | Optional owner, file type, modification date, and starred metadata. In the add-source form, these, **Users**, and **File Type** are under **More options**. | Sim exports Docs and Slides as text and Sheets as XLSX spreadsheets. Supported uploaded files use the knowledge-base document pipeline, including PDF and Office formats. Unsupported files and oversized exports cannot be indexed; Google limits Workspace exports to 10 MB. See [Drive export formats](https://developers.google.com/workspace/drive/api/guides/ref-export-formats) and [download limits](https://developers.google.com/workspace/drive/api/guides/manage-downloads). -Search schedules syncs hourly. Content, deletions, and permissions refresh in the background; results are not a live read from Drive. Open **Settings → Sources**, select **Manage** beside **Google Drive**, then open the source to inspect **Documents**, edit **Settings**, or review **Sync history**. **Accounts** on the provider page shows personal account connections where configured; it does not list the central service-account credential. +Search schedules syncs hourly. Central crawls revisit the selected users' files and permissions, including unchanged files, so permission changes and a new employee's older files are included. Unfinished crawls resume before deletion reconciliation. Content, deletions, and permissions refresh in the background; results are not a live read from Drive. **Accounts** on the provider page shows personal account connections where configured; it does not list the central service-account credential. ## Troubleshooting | Problem | Next step | | --- | --- | -| Directory access failed | Check the delegated scopes and the **Crawl as** user's administrator privileges. A normal Google OAuth credential cannot supply this central Search path. | -| An existing central source uses a normal Google OAuth account | Open the source's **Settings** tab, select or add a delegated service account, and choose **Change indexing account**. If the source is **Paused** or **Disabled**, choose **Resume** after updating the account. | -| Missing files in a central crawl | Open them as the **Crawl as** user. Delegation does not grant that user access to all domain files. Check folder and file-type filters. | -| A teammate sees no results | Confirm their verified Sim email matches the Drive permission or group membership. For member accounts, finish their personal Drive connection too. | +| Directory access failed | Check all four delegated scopes and the **Directory administrator email** user's administrator privileges. A normal Google OAuth credential cannot supply this central Search path. | +| Missing files in a central crawl | Check **Users**, folder and file-type filters, and whether selected active Workspace users can download the file and read its permissions. Opening a file alone does not prove either. Check Sync history for errors. Files reachable only by excluded or inactive accounts are not crawled; files with unverified permissions stay hidden. | +| User not found or inactive | Use a primary email in the same Google Workspace customer. Aliases, external or guest accounts, suspended users, and archived users cannot be selected for crawling. | +| A teammate sees no results | Confirm they have joined the Sim organization and their verified Sim email matches the Drive permission or group membership. For member accounts, finish their personal Drive connection too. | | A public or shared-link file is missing | Check **Openly shared files**. Link-only sharing does not grant Search access. A named user or group permission can still make the file searchable. | | Reconnect or credential error | Reauthorize the member account, or replace the service-account credential and verify delegation, as applicable. | @@ -150,7 +168,7 @@ https:///api/auth/oauth2/callback/google-drive This Google Cloud example uses one client for all three services. Replace `https://sim.example.com` with your Sim origin and add only the callbacks for services you enable. -Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). +Save the client ID and secret as `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET`. Set `NEXT_PUBLIC_APP_URL` to the same Sim origin used in the callback, then restart Sim. See [Integrations & OAuth](/platform/self-hosting/integrations-oauth). If you change an existing deployment's OAuth client or scopes, an organization admin selects **Settings → Sources → More → Refresh connection settings**, then affected teammates reconnect. The current Sim Drive OAuth connection uses these scopes: @@ -163,5 +181,5 @@ https://www.googleapis.com/auth/drive.file ``` - Google's `drive.readonly` scope covers Search's file reads. Sim's existing OAuth connection also supports workflow actions and requires the broader scopes above; do not substitute read-only scopes for member OAuth. The central service account uses the separate read-only Drive and Directory scopes listed earlier. See [Google's Drive scope descriptions](https://developers.google.com/workspace/drive/api/guides/api-specific-auth). + Google's `drive.readonly` scope covers Search's file reads. Sim's existing OAuth connection also supports workflow actions and requires the broader scopes above; do not substitute read-only scopes for member OAuth. The central service account uses the four separate read-only Drive and Directory scopes listed earlier. Adding company-wide indexing requires the Directory user-read scope in the service account's domain-wide delegation; it does not change the member OAuth app scopes. See [Google's Drive scope descriptions](https://developers.google.com/workspace/drive/api/guides/api-specific-auth). diff --git a/apps/docs/content/docs/search/index.mdx b/apps/docs/content/docs/search/index.mdx index 3673675951a..b3b9aa797be 100644 --- a/apps/docs/content/docs/search/index.mdx +++ b/apps/docs/content/docs/search/index.mdx @@ -16,60 +16,64 @@ Search brings your connected sources into one place. An organization admin allow ### Allow an integration -As an organization admin, open **Settings → Sources** and turn on the integration under **Allowed in Sim Search**. It stays in the list; no setup page opens automatically. +As an organization admin, open **Settings → Sources** and turn on the integration. It stays in the list; no setup page opens automatically. -The switch permits the integration in your organization. It does not connect an account, grant document access, or start indexing. Teammates connect from **Integrations** in the main sidebar; only admins manage these switches. +The switch saves immediately and permits the integration in your organization. It does not connect an account, grant document access, or start indexing. Teammates connect from **Integrations** in the main sidebar; only admins manage these switches. -### Configure it once +### Connect or configure the source -Select **Set up** beside the integration, or **Manage** if it already has sources. On its page, select **Add source**. For Slack, complete **Set up Slack app** first. Follow its **Setup guide** to connect the indexing account or choose the folders, repositories, calendars, spaces, or channels to include. **More options** contains secondary filters and **Metadata tags**. +For personal Gmail, Calendar, or Drive, teammates can connect immediately from **Integrations**. The first account creates the default sync configuration. Admins can adjust it later under **Manage → Advanced** on the provider page. -Select **Connect & Sync** for an administrator connection, or **Add source** for member accounts. Creating a source does not invite people or authorize their accounts. +For other sources, select **Set up** or **Manage**, then **Add source**. For a central Gmail, Calendar, or Drive service account, use **Manage → Advanced → Add sync configuration**. For Slack, complete **Set up Slack app** first. Follow the provider's **Setup guide** to configure its connection and content. **More options** contains secondary filters and **Metadata tags**. + +In the source form, select **Connect & Sync** for a central account or **Add source** for member accounts. Creating a source does not invite people or authorize their accounts. ### Connect and search -Open **Integrations** in the main sidebar and select **Connect account** if prompted—even if you created the source. Complete authorization in the new tab. Open **Search** to find documents, or **Home** to ask the assistant about them. Documents become available as background indexing progresses. +For member-account sources, open **Integrations** in the main sidebar and select **Connect** if prompted—even if you created the source. Complete authorization in the new tab. Central Google sources need no personal connection: join with a matching verified Workspace email and open **Search** to find documents, or **Home** to ask the assistant about them. Documents become available as background indexing progresses. -Organization Sources settings with Allowed in Sim Search switches and separate Set up and Manage actions +Organization Sources settings with provider switches and Set up or Manage actions -Source availability depends on the deployment and organization policy. An unavailable source needs operator configuration before setup can continue. +Source availability depends on the deployment and organization policy. An unavailable source needs operator configuration before setup can continue. If an existing provider asks for a configuration update after a deployment change, an admin selects **More → Refresh connection settings** on this page, then affected teammates reconnect. ## Choose the right connection method -Most sources use member accounts. Google Drive and Confluence also support a central administrator connection; GitLab requires an administrator token for a self-managed instance. +Sources can use member accounts or a central connection. Gmail, Google Calendar, and Google Drive support Google Workspace service accounts with domain-wide delegation. Confluence supports a central service account with a personal identity connection for each reader. GitHub supports an App installation, and GitLab requires an administrator token for a self-managed instance. | Method | What the admin does | What teammates do | | --- | --- | --- | -| **Member accounts** | Sets the source's filters once. | Connect their own accounts. Sim lists documents using each member's access. | -| **Service account** (Drive) / **Admin or service account** (Confluence) | Connects an account that can read the content and the source's permissions or directory. | Join the organization with a matching verified identity. Confluence also requires each person to connect their account. | +| **Member accounts** | Allows the provider and adjusts shared filters when needed. | Connect their own accounts. Sim lists documents using each member's access. | +| **GitHub App installation** | Installs the App, selects it under **Sync documents with**, and adds repository sources. | Connect GitHub once. Sim checks each reader's current repository access before returning installation-indexed content. | +| **Google Workspace service account** | Configures domain-wide delegation, supplies a Directory administrator email, and selects users and content filters. | Join with a matching verified email. No personal Google connection is needed for that source. Gmail remains private to the mailbox owner; Calendar preserves each person's event view; Drive uses file permissions. | +| **Confluence service account** | Connects an account that can read the selected content and its permissions. | Join the organization and connect Confluence to establish identity. | | **Administrator token** (GitLab) | Connects a self-managed instance administrator token and selects projects to index. | Join the organization with a verified Sim email matching GitLab. No personal connection is needed. | -Adding a Google Drive or Confluence source from the admin page starts central setup. For personal connections, use **Integrations → Connect account** in the main sidebar. An approved provider can create its first member source there; required repository, site, or project fields are collected before authorization. Admins can edit that source's filters afterward in its **Settings** tab. +Adding a Google or Confluence source from the admin page opens its central setup form directly. Google providers use **Manage → Advanced → Add sync configuration**; Confluence uses **Set up/Manage → Add source**. For personal connections, use **Integrations → Connect** in the main sidebar. An approved provider can create its first member source there; required repository, site, or project fields are collected before authorization. Admins can edit that source's filters afterward in its **Settings** tab. When personal connections are disabled for the organization, Google providers use **Add source** on the provider page instead of **Advanced**. -Some member sources offer **More options → Sync documents with**. **Connected members** uses members' accounts for both content and access checks. Selecting a dedicated account uses it to fetch content; members still connect to establish which documents they may search. **Account for browsing** only helps an admin pick source options—it does not enroll that account for Search. +Some member sources offer **Sync documents with**, either directly in setup or under **More options**. **Connected members** uses members' accounts for both content and access checks. Selecting a dedicated account uses it to fetch content; members still connect to establish which documents they may search. For GitHub organization sources, choose **Connect GitHub App** in this field to [connect an installation](/search/github#add-a-repository). **Account for browsing** only helps an admin pick source options—it does not enroll that account for Search. - An administrator connection does not grant everyone access to everything. Search applies the source's supported permission rules. It also does not automatically discover every employee's data: the indexing account must be able to read the configured content. + An administrator connection does not grant everyone access to everything. Search applies the source's supported permission rules. ## Connector guides | Source | Content | Connection in Search | | --- | --- | --- | -| [Confluence](/search/confluence) | Pages and blog posts | Admin/service account or member accounts; each teammate connects | -| [GitHub](/search/github) | Repository text files | GitHub App installation plus each member's authorization | +| [Confluence](/search/confluence) | Pages and blog posts | Service account or member accounts; each teammate connects | +| [GitHub](/search/github) | Repository text files | App installation or member indexing; each teammate connects | | [GitLab](/search/gitlab) | Repository files, wikis, issues, merge requests | Self-managed instance administrator token; no member connection | -| [Gmail](/search/gmail) | Email thread text | Each member's Gmail account | -| [Google Calendar](/search/google-calendar) | Calendar events | Each member's Google Calendar account | +| [Gmail](/search/gmail) | Email thread text | Delegated service account or member accounts | +| [Google Calendar](/search/google-calendar) | Meetings | Delegated service account or member accounts | | [Google Drive](/search/google-drive) | Supported Drive files | Delegated service account or member accounts | | [Jira](/search/jira) | Issues | Each member's Jira account | | [Slack](/search/slack) | Channel messages and threads | Slack app installation plus each member's authorization | @@ -88,39 +92,31 @@ These requests are separate from organization invitations. They let recipients c ## Manage sources and documents -Open **Settings → Sources**, select **Manage** beside the integration, then select a source. Providers with personal connections have **Sources** and **Accounts** tabs; providers such as GitLab open directly to the source list. Multiple sources can have different folder, repository, space, or project scopes. +Open **Settings → Sources** and select **Manage** beside the integration. When personal connections are enabled, Gmail, Calendar, and Drive list their sync configurations under **Advanced** and connections under **Accounts**. Other providers with personal connections use **Sources** and **Accounts**. Providers without personal connections open directly to the source list. Select a source or sync configuration to manage it. Multiple sources can have different folder, repository, space, or project scopes. + +For Gmail, one configuration is usually enough. **Add sync configuration** creates another source; editing **Settings** updates the selected source. A member configuration applies to all connected Gmail accounts, including accounts connected later. A central configuration applies to the selected Workspace **Users**, or all active users when that field is blank. Its labels and filters are evaluated separately in each mailbox. -Integration detail with Sources and Accounts tabs and a nested source list +Gmail Advanced tab with a sync configuration, search, and Add sync configuration action | Source tab | What you can do | | --- | --- | | **Documents** | Find indexed documents, inspect processing status, retry failed indexing, or exclude and restore documents. | -| **Settings** | Edit the source's scope, filters, and supported connection settings. Save your changes before leaving. | -| **Sync history** | Review recent sync runs and errors. | +| **Settings** | Edit the source's scope, filters, and supported indexing credentials. Select **Save** to apply changes or **Discard** to undo them. | +| **Sync history** | Review run dates, document changes, and any sync or account errors. | -Use the source header to sync, pause, resume, or remove that source. The back link returns to its integration. To disable an entire integration, turn off its **Allowed in Sim Search** switch. If it has sources, confirm **Deactivate**. Its content becomes unavailable in Search, Assistant, and MCP; sources and connected accounts are preserved. Turn the switch back on to allow it again. +**Sync using** shows the method selected when the source was created. Create a new source to change that method. To replace a supported indexing credential, select its replacement and use **Change indexing account**. -Gmail source Settings tab with label, date range, and search filters +Use the source header to sync, pause, resume, or remove that source. The back link returns to its integration. To disable an entire integration, turn off its switch in **Settings → Sources**. If it has sources, confirm **Deactivate**. Its content becomes unavailable in Search, Assistant, and MCP; sources and connected accounts are preserved. Turn the switch back on to allow it again. -Source Sync history with no connected accounts or synced members +Gmail Settings with the read-only Sync using method and editable label, date range, and search filters + +Gmail Sync history showing run dates and document additions, deletions, or no changes ## Search, Assistant, and MCP **Search** in the organization sidebar finds documents directly. The assistant on **Home** can search and read the same sources to answer questions with citations. Conversations are private to their author, including when another organization member is an admin. -To search from Claude, Codex, Claude Code, or Cursor, open **Settings → Search MCP**, choose your app, and copy its URL, command, or configuration. Connect in that app, sign in to Sim, and approve read-only Search access. No API key is needed. In Claude Team or Enterprise, an owner adds the custom connector before members connect. - -For another client, choose **Other** and use the server URL with Streamable HTTP and OAuth. The client must support remote MCP authentication. When adding configuration to an existing file, keep your other MCP servers. - -Each person signs in with their own Sim account. MCP applies their current organization membership and document access; connecting an app does not add sources or grant new document permissions. To disconnect an app, open **Settings → General → Authorized apps** and revoke it. - -MCP provides three tools for your organization: - -- **search** finds indexed passages. Narrow results by source, modification date, or document. -- **read_document** opens an indexed document by ID or its original URL. Read around a matching passage or page through longer documents. Results include a citation link. -- **chat** asks the Sim Assistant for an answer with citations. Each call starts a new private conversation and can use the same search filters. - -Search MCP is available in organization settings. All three tools use the caller’s current document permissions. They do not browse the web or change connected sources. +To use these sources from Claude, Codex, Claude Code, Cursor, or another compatible app, open **Settings → Search MCP**. Each person signs in with their own Sim account. The server provides `search`, `read_document`, and `chat`; `chat` starts a new private Sim conversation. See [Search MCP](/search/mcp) for app setup, permissions, and limits. ## Existing workspace Search @@ -133,13 +129,13 @@ Workspace Search remains separate. Workspace admins add sources through **Search 3. Ask a teammate with different source access to repeat the search. Documents restricted to you should not appear for them. 4. Change or remove a test document's access in the source and check again after the next completed content and permission refresh. -Search runs background syncs on an hourly schedule. Large sources, provider limits, and indexing queues can delay completion. Results are indexed copies, so edits and access changes are not fetched live for every query. +Search runs background syncs on an hourly schedule. Large sources, provider limits, and indexing queues can delay completion. Results are indexed copies, so edits appear after syncing. Permission refresh behavior depends on the connector: GitHub sources indexed through an App installation also check the reader's current repository access before returning indexed content. ## If indexing needs attention -A completed sync means the source was checked; some documents may still be indexing. In the main **Integrations** page, each source row shows how many documents you can search and whether indexing failed for any documents you can access. +A completed sync means the source was checked; some documents may still be indexing. Integrations lists personal connections; a central Google source can be searchable without appearing there. In the main **Integrations** page, each connected source row shows how many documents you can search and whether indexing failed for any documents you can access. -As an admin, open **Settings → Sources**, select **Manage** beside the integration, then open the source. In **Documents**, select **Failed** from the status dropdown to inspect those files. Use the search field to find a document by name. Select **Retry indexing** beside a file to try again. **Exclude** removes a file from search; select **Excluded** and then **Restore** to include it again. Fix a disconnected account or source configuration before retrying a sync that needs attention. +As an admin, open **Settings → Sources**, select **Manage** beside the integration, then open its source or sync configuration. In **Documents**, select **Failed** from the status dropdown to inspect those files. Use the search field to find a document by name. Select **Retry indexing** beside a file to try again. **Exclude** removes a file from search; select **Excluded** and then **Restore** to include it again. Fix a disconnected account or source configuration before retrying a sync that needs attention. Empty source Documents tab with search and an Included status filter diff --git a/apps/docs/content/docs/search/jira.mdx b/apps/docs/content/docs/search/jira.mdx index 4c0b5fa4df6..e47e217091f 100644 --- a/apps/docs/content/docs/search/jira.mdx +++ b/apps/docs/content/docs/search/jira.mdx @@ -7,22 +7,20 @@ import { Callout } from 'fumadocs-ui/components/callout' import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' -Search issue titles, descriptions, and metadata from selected Jira Cloud projects. An organization admin sets up the source; each teammate connects their own Jira account to search the issues they can access. +Search issue titles, descriptions, and metadata from selected **Jira Cloud** projects. An organization admin approves Jira and defines the source; **each teammate connects their own Jira account** to search issues they can access. -This Search connector uses **Member accounts**. It does not offer a central admin crawl. Comments, attachment contents, dashboards, and saved filters are not indexed. - -Admin setup uses your organization's **Settings → Sources** page. Teammates connect from **Integrations** in the main sidebar. For workspace Search, use **Search → Add source** instead; **Create & Invite** is the workspace equivalent of **Add source**. +Jira Search uses **Member accounts**. Service accounts are supported for Jira workflows, but do not provide a central Jira Search crawl. Comments, attachment contents, dashboards, and saved filters are not indexed. ## Before you start -- A **Sim organization admin** must approve Jira. An admin can configure the source beforehand, or the first member connection can supply the required site and project settings. -- Use an Atlassian Cloud site such as `your-team.atlassian.net`. Jira Server and Data Center are not supported by this connector. -- Each person needs a verified Sim email matching the email on their active Atlassian account, plus access to the selected Jira site and projects. Jira's **Browse Projects** and issue security permissions still determine which issues they can search. +- A **Sim organization admin** must approve Jira. The admin can configure the source, or the first member connection can supply its site and projects. +- Use a Cloud site hostname such as `your-team.atlassian.net`. Server and Data Center are not supported. +- Each person needs a verified Sim email matching their active Atlassian account's email, plus access to the selected site and projects. Jira's Browse Projects and issue security permissions determine the issues they can search. -On hosted Sim, teammates authorize the existing Sim app. They do not create an Atlassian app or API token. Deployment owners running their own Sim instance configure the [shared OAuth app](#self-hosted-operator-setup) once. +On hosted Sim, teammates authorize Sim's existing app. Self-hosted deployments configure the [shared OAuth app](#self-hosted-operator-setup) once. -Sim uses its existing Jira OAuth integration. Search uses `read:jira-work` to read issues, `read:me` to identify the connected person, and `offline_access` to refresh the connection. The authorization screen also includes permissions for other Jira features, including writes. Review the requested permissions before authorizing. +Search uses `read:jira-work`, `read:me`, and `offline_access` for issues, identity, and refresh. Sim's shared Jira app also requests permissions for workflow actions, including writes and deletes. Review the consent screen before authorizing. ## Set up the source @@ -32,22 +30,20 @@ Sim uses its existing Jira OAuth integration. Search uses `read:jira-work` to re ### Choose Jira -Open **Settings → Sources** and turn on **Jira** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Add source**. Jira sources use member accounts. +Open your organization's **Settings → Sources**, turn on **Jira**, then select **Set up** (or **Manage**) → **Add source**. ### Choose the projects -Under **Account for browsing**, select an account or choose **Connect Jira account** and complete Atlassian authorization. Enter **Jira Domain**, then choose one or more **Projects**. - -If you already know the project keys, use the switch beside **Projects** to select manual input and enter keys such as `ENG, SUPPORT`. Manual input lets you configure the source without connecting a browsing account first. +Enter **Jira Domain**. Under **Account for browsing**, select an account or choose **Connect Jira account**, then select **Projects**. To enter keys such as `ENG, SUPPORT` manually, use the switch beside Projects; this works without a browsing account. -**Account for browsing** only populates the project picker. It does not enroll you or share that account's issue access with teammates. +**Account for browsing** only populates the project picker. You can use OAuth or **Add service account** for this step; neither enrolls that account for Search or enables central indexing. Each teammate still connects their own Jira account. Jira source setup with an account for browsing and required site and project fields @@ -55,89 +51,58 @@ If you already know the project keys, use the switch beside **Projects** to sele -### Create the source +### Save the source -Open **More options** for **JQL Filter** and **Metadata tags**. Leave JQL empty to include all accessible issues in the selected projects, or add a condition such as `status = "Done"`. +Under **More options**, optionally set a **JQL Filter**, such as `status = "Done"`, and choose **Metadata tags**. Leave JQL empty for all accessible issues in the selected projects. Enter conditions only; omit `ORDER BY` because Sim supplies sorting. -Click **Add source**. The source appears on Jira's **Sources** tab. Creating it does not authorize accounts or send invitations. +Select **Add source**. This saves the shared scope; it does not authorize accounts or send invitations. ### Connect your search account -Open **Integrations** in the main sidebar and click **Connect account** on the Jira source. Complete the connection in the new tab using the Atlassian email that matches your verified Sim email. Select the configured Atlassian site when asked and grant the requested permissions. +Open **Integrations** in the main sidebar and select **Connect** on the Jira source. In the new tab, authorize the configured site using the Atlassian email matching your verified Sim email. -Return to Integrations to see connection and indexing status. Each teammate follows this same step. A previously authorized account may already be connected. +Each teammate follows this step. An existing authorized account may already be connected. Return to Integrations to check indexing status and your searchable document count. -## Configuration - -| Setting | What to enter | -| --- | --- | -| **Jira Domain** | The Cloud site hostname, such as `your-team.atlassian.net`. Use the same site during authorization. | -| **Projects / Project Keys** | One or more projects. The picker shows projects available to the browsing account; manual input accepts comma-separated keys. | -| **JQL Filter** | Optional conditions that narrow the selected projects. Leave out `ORDER BY`; Sim supplies the sorting. | -| **Metadata tags** | Optional issue type, status, priority, labels, assignee, and last-updated tags. In the add-source form, these are under **More options**. | +For workspace Search, start from **Search → Add source**; its source-creation button is **Create & Invite**. Search manages the sync schedule and hides item limits. -Search manages the sync schedule. Item limits and sync frequency are not setup decisions on this page. +## Manage the source -## Teammates and ongoing sync +Admins open **Settings → Sources → Jira → Manage**, then a source's **Documents**, **Settings**, or **Sync history**. Settings include the site, projects, JQL filter, and optional issue type, status, priority, labels, assignee, and last-updated tags. -Existing organization members see the same source configuration and their own **Connect account**, **Reconnect**, or indexing status. They do not choose projects again. Invite new teammates to the Sim organization through its Members settings or SSO onboarding, then have them open Integrations and connect Jira. A Jira authorization does not grant Sim organization membership. +Teammates share the configured scope and do not choose projects again. For another site or scope, add another source, or use **Connect** beside **Connect a different site or content scope** in Integrations. -Sim checks Jira separately using each connected person's account. Issue content and tags become searchable as processing finishes; changes and lost issue access are picked up by later syncs. The main Integrations page reports documents searchable by the current viewer. Admins open **Settings → Sources**, select **Manage** beside **Jira**, then open the source for **Documents**, **Settings**, and **Sync history**. +Invite new teammates to the Sim organization through Members settings or SSO, then have them connect Jira. **Jira → Accounts → Request connections** requests a provider connection; it does not add people to the organization. -Use **Jira → Accounts → Request connections** to send Jira connection requests. These requests do not invite people into the Sim organization. For a different site or project scope, an admin can add another source; members can also use **Add another Jira source** in the main Integrations page. +Sim checks Jira separately for each connected person. Content becomes searchable as indexing finishes; issue changes and lost access are reflected after later syncs. ## Troubleshooting -| What you see | What to do | +| Problem | What to check | | --- | --- | -| No provider setup controls | Ask a Sim organization admin to approve and set up Jira. | -| Projects are empty or disabled | Enter the domain and connect a browsing account, or switch to manual project keys. Check that the account can browse those projects. | -| Connected, but no issues | Confirm the authorized site matches the configured domain. Check project access, issue security, and the JQL filter. An admin's Jira access does not grant access to other members. | -| Email mismatch | Sign in to Atlassian with the email shown by Sim's connection flow. | -| Atlassian says the callback URL is invalid | Ask the deployment operator to check the OAuth app identified by `JIRA_CLIENT_ID`. Its saved callback must exactly match the authorization request's `redirect_uri`, including scheme, hostname, port, and `/api/auth/oauth2/callback/jira` path. | -| **Reconnect** | Reauthorize the Jira account and grant all requested permissions. This is needed after a grant is revoked or its required permissions change. | -| Connection tab does not open | Allow pop-ups for Sim, then click **Connect account** again. | - -### Check access in Jira - -First, open a missing issue in Jira using the same account you connected to Sim. If you cannot open it there, ask a Jira admin to check its project permissions and issue security. - -For company-managed projects, an admin can open **Settings → System → Admin Helper → Permission Helper**, enter the affected user and issue key, and check **Browse Projects**. The result explains which permission condition failed. Fix access in Jira, then let the next Sim sync finish. See Atlassian's [Permission Helper instructions](https://support.atlassian.com/jira-cloud-administration/docs/check-a-users-access-from-a-work-item/) and [illustrated permissions tutorial](https://www.atlassian.com/software/jira/guides/permissions/tutorials). - -Atlassian illustration of Jira's Permission helper with User and Issue fields and Browse Projects selected +| No source setup controls | Ask a Sim organization admin to approve Jira. | +| Projects are empty or disabled | Enter the correct domain, connect a browsing account with project access, or switch to manual keys. | +| Connected, but no issues | Check the authorized site, project access, issue security, and JQL. An admin's Jira access does not grant access to teammates. | +| Email mismatch | Use the Atlassian email matching your verified Sim email. If switching accounts fails, sign out of Atlassian and retry **Connect**. | +| **Reconnect** | Reauthorize and grant all requested permissions. A revoked grant or changed scope list can require a new connection. | +| Connection tab does not open | Allow pop-ups for Sim and retry. | +| Invalid callback URL | Ask the operator to check the app identified by `JIRA_CLIENT_ID`; its callback must exactly match Sim's `redirect_uri`. See operator setup below. | -Atlassian illustration from its [permissions tutorial](https://www.atlassian.com/software/jira/guides/permissions/tutorials). UI labels may vary by Jira version. +Open a missing issue in Jira using the connected account. For company-managed projects, an admin can check **Settings → System → Admin Helper → Permission Helper → Browse Projects**, using the affected user and issue key. Resolve Jira access first, then sync again. See [Atlassian's Permission Helper instructions](https://support.atlassian.com/jira-cloud-administration/docs/check-a-users-access-from-a-work-item/). ## Self-hosted operator setup -The deployment operator configures one shared Jira OAuth integration. Teammates continue to start **Connect account** from Sim. - -1. Open the [Atlassian developer console](https://developer.atlassian.com/console/myapps/) and select your deployment's **OAuth 2.0 integration**, or create one for the deployment. -2. Under **Authorization**, configure **OAuth 2.0 (3LO)**. Add `https:///api/auth/oauth2/callback/jira` to **Callback URLs**, keeping any callbacks already used by your deployment, then save. - - Atlassian OAuth Authorization form with an example Sim Jira callback URL - - Example callback in Atlassian's developer console. Replace `sim.example.com` with your Sim domain. +Configure one shared Jira OAuth app for the deployment: -3. Under **Permissions**, add **Jira API**, then **Configure** its classic and granular scopes for Jira, Jira Service Management, and Assets. Separately add **User Identity API** with `read:me`. Sim requests `offline_access` in the authorization URL for refresh tokens. Configure the full `jira` scope list for your release in [Sim's OAuth configuration](https://github.com/simstudioai/sim/blob/staging/apps/sim/lib/oauth/oauth.ts); the Search read scopes above are only a subset of this shared integration's permissions. -4. Under **Distribution**, enable sharing so teammates can authorize the app. Copy the client ID and secret from **Settings** into `JIRA_CLIENT_ID` and `JIRA_CLIENT_SECRET`, set the correct `NEXT_PUBLIC_APP_URL`, and restart Sim. -5. Start a connection from **Integrations**. Confirm that Atlassian lists the intended site, then return to Sim. After changing requested scopes, reconnect previously authorized accounts. +1. In the [Atlassian developer console](https://developer.atlassian.com/console/myapps/), select or create an **OAuth 2.0 integration**. +2. Under **Authorization → OAuth 2.0 (3LO)**, save `https:///api/auth/oauth2/callback/jira` as a callback, preserving callbacks used by other deployments. +3. Under **Permissions**, add **Jira API** and configure the full `jira` scope list from [Sim's OAuth configuration](https://github.com/simstudioai/sim/blob/staging/apps/sim/lib/oauth/oauth.ts), including its Jira Service Management and Assets scopes. Add **User Identity API → read:me**. Sim requests `offline_access` for refresh tokens; Search's three scopes above are only a subset of this shared app's permissions. +4. Enable sharing under **Distribution**. Set `JIRA_CLIENT_ID` and `JIRA_CLIENT_SECRET` from the app's **Settings**, verify `NEXT_PUBLIC_APP_URL`, and restart Sim. +5. Connect from **Integrations** and select the configured site. After changing the OAuth client or requested scopes, use **Settings → Sources → More → Refresh connection settings**, then have affected teammates reconnect. -For a local instance using `NEXT_PUBLIC_APP_URL=http://localhost:3000`, register `http://localhost:3000/api/auth/oauth2/callback/jira`. Use a separate development OAuth app when production callbacks must remain unchanged. After updating local client credentials or the app URL, restart Sim and begin a new connection from **Integrations**. If only the app owner can connect, check **Distribution**. See Atlassian's [OAuth configuration and sharing guide](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/) and Sim's [deployment reference](/platform/self-hosting/integrations-oauth). +The callback must match exactly, including scheme, hostname, port, and path. For `http://localhost:3000`, register `http://localhost:3000/api/auth/oauth2/callback/jira`. Use a separate development app when production callbacks must stay unchanged. If only the app owner can connect, check **Distribution**. See the [Atlassian OAuth guide](https://developer.atlassian.com/cloud/jira/platform/oauth-2-3lo-apps/) and [Sim deployment reference](/platform/self-hosting/integrations-oauth). diff --git a/apps/docs/content/docs/search/mcp.mdx b/apps/docs/content/docs/search/mcp.mdx new file mode 100644 index 00000000000..bd2d3de0638 --- /dev/null +++ b/apps/docs/content/docs/search/mcp.mdx @@ -0,0 +1,52 @@ +--- +title: Search MCP +description: Search your organization's sources from Claude, Codex, Cursor, and other MCP apps +--- + +Use Sim Search from another app to find and read your organization's indexed documents, or ask the Sim Assistant for cited answers. Your Sim permissions apply. + +## Connect an app + +1. Confirm you can find a document in the organization's **Search**. [Connect your source account](/search/connect-your-account) first if required. +2. Open **Settings → Search MCP** in the organization view. Select your **App** and copy the displayed URL, command, or configuration. +3. Follow the steps for your app below, sign in to Sim, and approve Search access. Each teammate signs in separately; no API key is needed for this flow. + +| App | Finish setup | +| --- | --- | +| **Claude** | Add the copied **Server URL** as a custom connector, then connect it. For Team or Enterprise, an owner first adds it under **Organization settings → Connectors**; members then connect individually. See [Claude's custom connector instructions](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp). | +| **Codex** | Run the copied **Terminal command** and complete browser sign-in. If authentication is needed again, run `codex mcp login sim-search`. | +| **Claude Code** | Run the copied **Terminal command**, then open `/mcp` in Claude Code and authenticate. See [Claude Code's MCP instructions](https://code.claude.com/docs/en/mcp). | +| **Cursor** | Add the copied `sim-search` entry to `mcpServers` in `~/.cursor/mcp.json`, preserving your other entries. Enable the server in Cursor and sign in to Sim. See [Cursor's MCP instructions](https://cursor.com/docs/mcp). | +| **Other** | Add the **Server URL** to a client that supports remote MCP with OAuth. Choose **Streamable HTTP** if asked. | + +For Claude's hosted custom connectors, your Sim deployment must be reachable from Claude's servers; a localhost URL will not work. See [Claude's network requirements](https://support.claude.com/en/articles/11175166-get-started-with-custom-connectors-using-remote-mcp). + +## Use the tools + +Ask your app: “Use Sim Search to find our launch checklist, read the relevant document, and cite the source.” The app can use three tools: + +| Tool | Behavior | +| --- | --- | +| `search` | Finds matching passages. Filter by `source` (such as `jira`), `modifiedAfter` (an ISO timestamp), or `documentIds`. Use the returned `citationUrl` when citing a result. | +| `read_document` | Reads an indexed document using either its returned `documentId` or original URL. Use `aroundChunkIndex` for context around a search hit, or `offset` to page through it. It does not fetch arbitrary web pages. | +| `chat` | Asks the Sim Assistant a question using your accessible sources and returns an answer with citations. Each call creates a new private Sim conversation. It accepts the same filters as `search`. | + +The tools do not edit source content. Assistant policy and usage limits apply to `chat`. + +## Access and limits + +The connection applies to the organization whose URL you copied. Sim checks your current membership and source access on each request. Connecting MCP does not add sources, connect provider accounts, or grant additional document permissions. Source edits and permission changes follow the same sync behavior as regular Search. + +Search returns 10 passages by default, with `topK` up to 50. Document reads return 20 chunks by default, with `limit` up to 50. When `pagination.hasMore` is true, continue at `pagination.offset + pagination.limit`; use either `offset` or `aroundChunkIndex`, not both. Documents still indexing return metadata only. + +Queries allow up to 8,192 characters; `documentIds` accepts up to 20 IDs. Responses are limited to 1 MiB. Request fewer passages or smaller document pages if a result is too large. API rate limits also apply; follow the retry delay returned by the tool. + +## Reconnect or revoke access + +If sign-in expires or access is revoked, reconnect in your app. To withdraw its Sim authorization, open **Settings → General → Authorized apps**, find the app, and revoke it. This stops future requests; it does not remove content already returned to that app. + +If Search MCP is unavailable, ask an organization admin to check the organization's Search availability and MCP policy. If results are missing, check the same query in Sim Search, your provider connection, and source sync status first. + +## Workspace MCP is separate + +Organization Search MCP searches organization sources. [Workspace MCP tools](/agents/mcp) connect external servers to Sim agents; [MCP deployment](/workflows/deployment/mcp) exposes workflows as tools. Neither setup automatically adds workspace content to organization Search. diff --git a/apps/docs/content/docs/search/meta.json b/apps/docs/content/docs/search/meta.json index f3f4dffbd2c..473704f2d45 100644 --- a/apps/docs/content/docs/search/meta.json +++ b/apps/docs/content/docs/search/meta.json @@ -2,6 +2,7 @@ "title": "Search", "pages": [ "connect-your-account", + "mcp", "confluence", "github", "gitlab", diff --git a/apps/docs/content/docs/search/slack.mdx b/apps/docs/content/docs/search/slack.mdx index 81d8c0cb91f..42917bc7438 100644 --- a/apps/docs/content/docs/search/slack.mdx +++ b/apps/docs/content/docs/search/slack.mdx @@ -6,15 +6,13 @@ description: Set up a workspace Slack app and connect members for channel search import { Step, Steps } from 'fumadocs-ui/components/steps' import { Image } from '@/components/ui/image' -Slack Search indexes channel messages and threads. A Sim organization admin configures your Slack app once, then each teammate authorizes their own Slack account. Their results are limited to the selected public channels and private channels they can access. DMs and group DMs are not indexed. - -Slack app setup for Sim Search with application and client credential fields +Slack Search indexes messages and threads each connected member can access. Public and private channels are included by default; one-to-one and group DMs are opt-in. A Sim organization admin installs the organization's Slack app, then each teammate authorizes their own account for indexing. ## Before you start You need a Sim organization admin and permission to create and install an app in the target Slack workspace. Ask a Slack workspace admin for approval when app installation is restricted. Use the same email address for Slack and your verified Sim account. -This guide covers **indexing Slack messages for Search and MCP**. It does not install a Sim assistant that answers inside Slack. +The same app supports **Sim Search in Slack** and **indexing Slack messages**. Installing the bot lets it answer questions about connected sources. Indexing Slack content additionally requires a source and each member’s authorization. ## Set up the organization's Slack app @@ -25,54 +23,57 @@ Skip to **Connect the source** if the organization already has a verified Slack ### Open provider settings -Open **Settings → Sources** and turn on **Slack** under **Allowed in Sim Search**. Select **Set up** (or **Manage** if sources already exist), then **Set up Slack app**. Allowing Slack does not configure the app or start indexing. +Open **Settings → Sources** and turn on **Slack**. Select **Set up** (or **Manage** if sources already exist), then **Set up Slack app**. Allowing Slack does not configure the app or start indexing. ### Configure an app in Slack -On the [Slack Apps page](https://api.slack.com/apps), create an app for the target workspace, or use an app dedicated to your Sim organization. Under **OAuth & Permissions**, add the user scopes and both redirect URLs listed below. Keep **Token Rotation** disabled. +Select **Install Sim Search** to open the three-step setup. In step 1, select **Create app in Slack** and choose the target workspace. Sim supplies a manifest with the required scopes, redirects, events, and interactivity URL. Keep **Token Rotation** disabled. + +You can also open this wizard from **Settings → Sim Search in Slack → Set up**. Slack app settings showing Basic Information and App Credentials *Official Slack example: [Basic Information](https://docs.slack.dev/tools/bolt-python/creating-an-app/#create-a-new-app). Use your own app's credentials.* -In Sim, enter these four fields: +Return to Sim and select **Continue**. In step 2, copy **Client ID**, **Client Secret**, and **Signing Secret** from the new app’s **Basic Information → App Credentials**: -| Sim field | Where to find it | -|---|---| -| Slack App ID | Slack app **Basic Information → App Credentials** (`A…`). | -| Slack workspace ID | The workspace segment of the Slack web URL, `app.slack.com/client/T…/…`. | -| Client ID | The same app's **Basic Information → App Credentials**. | -| Client Secret | The same app's **Basic Information → App Credentials**. | +Sim Search in Slack setup asking for Client ID, Client Secret, and Signing Secret -Organization setup uses personal user authorization. It does not ask for a bot token or signing secret. +Select **Continue**, then **Install in Slack** in step 3. Approve the installation in Slack. Sim saves the bot connection and opens **Settings → Sim Search in Slack**. Complete any required Slack administrator approval before continuing. ### Verify and continue -Select **Verify and add**, then authorize the app in the Slack popup. Sim verifies the app, workspace, client credentials, and required scopes. Allow popups if the window does not open. +Return to **Settings → Sources → Slack → Set up Slack app**. Select **Verify and add** and authorize member access for the installed app. Allow popups if the authorization window does not open. -After verification, Sim returns to the provider page. Open **Sources → Add source** to choose what to index. If you opened app setup from an unfinished source form, Sim returns to that form instead. If Slack requires administrator approval, complete that approval before continuing. +Member connections use one configured Slack app and workspace per organization. Installing another bot does not change that configuration. Changing the verified app requires members to reconnect. + +After verification, open **Sources → Add source** on Slack’s provider page to choose what to index. A connected bot alone does not mean Slack messages have been indexed. ## Connect the source -Open **Sources → Add source** on Slack's provider page. **Channels** and **Earliest Message Date** appear first. Open **More options** for exclusions, archived channels, metadata tags, and **Sync documents with**. Keep **Connected members** for the usual setup. +Open **Sources → Add source** on Slack's provider page. Choose **Channel Messages**, **Direct Messages**, **Channels**, and **Earliest Message Date**. Open **More options** for exclusions, archived channels, metadata tags, and **Sync documents with**. Keep **Connected members** for the usual setup. | Field | Behavior | |---|---| +| Channel Messages | Included by default. Turn off for a DM-only source. | +| Direct Messages | Excluded by default. Include to index one-to-one and group DMs the connected member can access. | | Channels | Leave blank for all accessible public and private channels, or choose channel names/IDs. | | Excluded Channels | Names or IDs to omit; exclusions override included channels. | -| Archived Channels | Included by default. | +| Archived Channels | Included by default. The picker lists active channels; use manual names/IDs to select archived channels. | | Earliest Message Date | Optional UTC date (`YYYY-MM-DD`). Applies to the thread's first message; replies are included with that thread. | -Select **Add source**. Each person opens **Integrations** in the main sidebar, selects **Connect account** on the Slack source, and approves the configured app. Creating the source or verifying the Slack app does not authorize teammates automatically. +Select **Add source**. Each person opens **Integrations** in the main sidebar, selects **Connect** on the Slack source, and approves the configured app. Creating the source or verifying the Slack app does not authorize teammates automatically. + +The Slack app's **Home → Connect sources** opens this same Integrations page. To send a Slack connection request, open **Slack → Accounts → Request connections**. This requests an external account connection; it does not invite the recipient to the Sim organization. @@ -82,27 +83,28 @@ Admins open **Settings → Sources**, select **Manage** beside **Slack**, then o ## Permissions reference -The organization account pool supports Search and workspace workflow tools. Its current authorization requests the following **User Token Scopes**, including write permissions. Search itself only indexes channel messages and threads; workspace use is controlled separately in the organization account settings. +New Search member connections request these read-only **User Token Scopes**. They are separate from the bot scopes used to answer messages in Slack. | Purpose | User scopes | |---|---| -| Public channels | `channels:read`, `channels:history`, `channels:write` | -| Private channels | `groups:read`, `groups:history`, `groups:write` | -| Messages and conversations | `chat:write`, `im:read`, `im:history`, `im:write`, `mpim:read`, `mpim:history`, `mpim:write` | -| Files and canvases | `files:read`, `files:write`, `canvases:read`, `canvases:write` | -| Reactions | `reactions:read`, `reactions:write` | -| Identity and profile | `users:read`, `users:read.email`, `users.profile:read`, `users.profile:write` | +| Public channels | `channels:read`, `channels:history` | +| Private channels | `groups:read`, `groups:history` | +| Direct messages | `im:read`, `im:history`, `mpim:read`, `mpim:history` | +| Identity | `users:read`, `users:read.email` | -Add both redirect URLs under **OAuth & Permissions → Redirect URLs**, using your Sim origin: +The generated manifest includes these redirect URLs under **OAuth & Permissions → Redirect URLs**, using your Sim origin: ```text +https:///api/knowledge/slack/oauth/callback https:///api/credential-groups/slack-managed-users/callback https:///api/credential-groups/oauth/slack/callback ``` Compare **OAuth & Permissions → Scopes → User Token Scopes** with the table above. If scopes change, update the Slack app and have members reconnect. Do not change a shared production app's credentials to configure a separate test installation. -For existing **workspace** Search, use **Search → Add source → Slack**. That flow uses the custom-bot wizard and **Connected accounts → Access → Search documents**, which requests the six read-only channel and identity scopes instead. Its bot installation is separate from the organization setup described here. +Existing workflow account pools retain their configured permissions; preserve those scopes when updating the shared app. + +For existing **workspace** Search, use **Search → Add source → Slack**. That flow uses the custom-bot wizard and **Connected accounts → Access → Search documents**, which requests read-only channel, DM, and identity scopes. Its bot installation is separate from the organization setup described here. See Slack's [app manifest reference](https://docs.slack.dev/reference/app-manifest/) and [user token access model](https://docs.slack.dev/authentication/tokens/). @@ -111,7 +113,7 @@ See Slack's [app manifest reference](https://docs.slack.dev/reference/app-manife | Problem | Next step | |---|---| | Setup keeps asking for a Slack app | Finish **Verify and add** in the Slack setup; approval alone is insufficient. | -| Redirect mismatch | Check both redirect URLs above against your Sim origin. | +| Redirect mismatch | Check all three redirect URLs above against your Sim origin. | | App or workspace mismatch | Use the App ID and client credentials from the same app, and the ID of the workspace being authorized. | | Missing scopes | Compare User Token Scopes with the table above, update the Slack app, reinstall as Slack requires, and reconnect. In workspace setup, select **Search documents** in both setup screens. | | Missing private-channel results | Confirm the member is in the channel and it is within the source filters. With an indexing account, confirm that account can read it too. | diff --git a/apps/docs/content/docs/tables/workflow-columns.mdx b/apps/docs/content/docs/tables/workflow-columns.mdx index daf9f76e629..86b9c8c44f4 100644 --- a/apps/docs/content/docs/tables/workflow-columns.mdx +++ b/apps/docs/content/docs/tables/workflow-columns.mdx @@ -42,6 +42,7 @@ The unit you configure is a **group**: something that runs once per row, fed by alt="The New column menu: Enrichments at the top, the plain column types, and Workflow at the bottom" width={340} height={351} + className="mx-auto h-auto w-full max-w-[340px]" /> ### Enrichments @@ -57,6 +58,7 @@ A **workflow group** runs one of your own [workflows](/workflows) per row — us alt="The Configure workflow panel for Lead Score Enrichment: a preview of the LeadScorer workflow, the workflow picker, three output columns selected, Auto-run on, and six Run after dependencies" width={400} height={634} + className="mx-auto h-auto w-full max-w-[360px]" /> - **Workflow** picks which workflow runs per row; here, *Lead Score Enrichment*. @@ -75,6 +77,7 @@ Everything from here applies to both kinds. A group's configuration is a set of alt="A group's bindings: the required Company domain input bound to the domain column, and the employee count and description outputs bound to their column names" width={380} height={499} + className="mx-auto h-auto w-full max-w-[380px]" /> When a group runs a row, the bound column values become its inputs; it only sees the columns you mapped, the rest of the row is untouched, and inputs are read-only during the run. Every output you selected is written to its column, and outputs you didn't select are discarded. @@ -126,6 +129,7 @@ Every value in a workflow column comes from a real workflow run, and each one is alt="A lead_score cell's menu: View execution, Re-run cell, insert and duplicate row actions, and Delete row" width={800} height={369} + className="mx-auto h-auto w-full max-w-xl" /> **View execution** opens the run's trace: each block with its status, timing, and credit cost, the same view as the [Logs](/logs-debugging) page. Here, the row's score came from a 1.86s run of the LeadScorer workflow: @@ -135,6 +139,7 @@ Every value in a workflow column comes from a real workflow run, and each one is alt="The Log Details trace for one row's run: Workflow Execution at 1.86s with Start and LeadScorer spans, marked Success" width={650} height={386} + className="mx-auto h-auto w-full max-w-xl" /> **Re-run cell** runs the group again for just that row, replacing its values when the run finishes. diff --git a/apps/docs/content/docs/workflows/deployment/api.mdx b/apps/docs/content/docs/workflows/deployment/api.mdx index aaa272807a1..2eaf1b5e36b 100644 --- a/apps/docs/content/docs/workflows/deployment/api.mdx +++ b/apps/docs/content/docs/workflows/deployment/api.mdx @@ -13,7 +13,7 @@ Deploy your workflow as a REST API endpoint that any application can call direct Open your workflow and click **Deploy**. The **General** tab opens first and shows you the current deployment state: -General tab of the Workflow Deployment modal showing a live workflow preview, a Versions table with v2 (live) and v1, and Undeploy / Update buttons +General tab of the Workflow Deployment modal showing a live workflow preview, a Versions table with v2 (live) and v1, and Undeploy / Update buttons The **General** tab contains: @@ -37,7 +37,7 @@ POST https://sim.ai/api/v2/workflows/{workflow-id}/execute When you modify the workflow canvas after deploying, an **Update deployment** badge appears at the bottom of the screen as a reminder that your live version is out of date: -Canvas toolbar showing the Update and Run buttons with an Update deployment tooltip +Canvas toolbar showing the Update and Run buttons with an Update deployment tooltip You can click the **Update** button directly from the canvas toolbar — you don't need to open the Deploy modal every time. @@ -45,7 +45,7 @@ You can click the **Update** button directly from the canvas toolbar — you don Every time you deploy or update, a new version is recorded in the Versions table. You can manage past versions using the context menu (⋮) next to any row: -Versions table showing v2 (live) and v1 with a context menu open offering Rename, Add description, Promote to live, and Load deployment options +Versions table showing v2 (live) and v1 with a context menu open offering Rename, Add description, Promote to live, and Load deployment options | Action | Description | |--------|-------------| @@ -82,7 +82,7 @@ Rollback re-activates an existing deployment version — the same operation as * Switch to the **API** tab in the Deploy modal to see ready-to-use code for all three execution modes: -API tab showing cURL, Python, JavaScript, and TypeScript language options, with Run workflow, Run workflow (stream response), and Run workflow (async) code sections +API tab showing cURL, Python, JavaScript, and TypeScript language options, with Run workflow, Run workflow (stream response), and Run workflow (async) code sections The language selector at the top lets you switch between **cURL**, **Python**, **JavaScript**, and **TypeScript**. Each mode — synchronous, streaming, and async — has its own code block that you can copy directly. The code is pre-filled with your workflow ID and a masked version of your API key. @@ -106,7 +106,7 @@ curl -X POST https://sim.ai/api/v2/workflows/{workflow-id}/execute \ Click **Edit API Info** to add a description and change the access mode: -Edit API Info modal with a Description textarea and an Access section toggling between API Key and Public modes +Edit API Info modal with a Description textarea and an Access section toggling between API Key and Public modes | Access Mode | Description | |-------------|-------------| @@ -170,7 +170,7 @@ Stream the response token-by-token as it is generated. Add `"stream": true` to y Use the **Select outputs** dropdown in the API tab to choose which fields to stream: -Select outputs dropdown open showing Agent 1 block with selectable output fields: content, model, tokens, toolCalls, providerTiming, cost +Select outputs dropdown open showing Agent 1 block with selectable output fields: content, model, tokens, toolCalls, providerTiming, cost The dropdown groups available outputs by block. The most common choice is `content` from an Agent block, which streams the generated text. You can select fields from multiple blocks simultaneously. diff --git a/apps/docs/content/docs/workflows/deployment/chat.mdx b/apps/docs/content/docs/workflows/deployment/chat.mdx index 404dfc509da..3238706fce6 100644 --- a/apps/docs/content/docs/workflows/deployment/chat.mdx +++ b/apps/docs/content/docs/workflows/deployment/chat.mdx @@ -21,7 +21,7 @@ Chat executions run against your workflow's active deployment snapshot. Publish Open your workflow, click **Deploy**, and select the **Chat** tab. You'll see the chat configuration panel: -Chat deployment configuration panel showing URL, Title, Output, Access control, and Welcome message fields +Chat deployment configuration panel showing URL, Title, Output, Access control, and Welcome message fields Configure the following fields, then click **Launch Chat**: @@ -37,13 +37,13 @@ Configure the following fields, then click **Launch Chat**: ### Output Selection -Output dropdown showing Agent 1 block with selectable fields: content, model, tokens, toolCalls, providerTiming, cost +Output dropdown showing Agent 1 block with selectable fields: content, model, tokens, toolCalls, providerTiming, cost The output dropdown groups available fields by block. For an Agent block, you can choose from `content`, `model`, `tokens`, `toolCalls`, `providerTiming`, and `cost`. In most cases, selecting `content` from the final Agent block is all you need — it streams the agent's text response directly to the user. ## Access Control -Access control section with Email tab selected, showing an Allowed emails field with @sim.ai domain added +Access control section with Email tab selected, showing an Allowed emails field with @sim.ai domain added | Mode | Description | |------|-------------| diff --git a/apps/docs/content/docs/workflows/deployment/index.mdx b/apps/docs/content/docs/workflows/deployment/index.mdx index 332f55a515f..d97801d67bd 100644 --- a/apps/docs/content/docs/workflows/deployment/index.mdx +++ b/apps/docs/content/docs/workflows/deployment/index.mdx @@ -18,7 +18,7 @@ A **snapshot** is an immutable copy of your workflow taken at the moment you dep Each snapshot is recorded as a numbered **version** (v1, v2, and so on) in the Versions table, tagged with who deployed it and when. Versions are independent: one is live at a time, marked with a green dot, and the rest stay available to rename, describe, load back to the canvas, or promote. You publish v1 with **Deploy** and every later **Update** adds the next version. -Versions table with v2 live and the v1 menu open on Promote to live +Versions table with v2 live and the v1 menu open on Promote to live ### Live version diff --git a/apps/docs/content/docs/workflows/deployment/mcp.mdx b/apps/docs/content/docs/workflows/deployment/mcp.mdx index 9da8a9c5820..45429ab3c20 100644 --- a/apps/docs/content/docs/workflows/deployment/mcp.mdx +++ b/apps/docs/content/docs/workflows/deployment/mcp.mdx @@ -40,7 +40,7 @@ MCP servers group your workflow tools together. Create and manage them in worksp alt="Add New MCP Server modal" width={550} height={371} - className="my-6" + className="my-6 mx-auto h-auto w-full max-w-md" /> @@ -53,7 +53,7 @@ MCP servers group your workflow tools together. Create and manage them in worksp alt="MCP Server details view" width={700} height={491} - className="my-6" + className="my-6 mx-auto h-auto w-full max-w-xl" /> @@ -120,7 +120,7 @@ Sim generates a ready-to-paste configuration for every supported client. To get alt="MCP client configuration panel" width={700} height={517} - className="my-6" + className="my-6 mx-auto h-auto w-full max-w-xl" /> diff --git a/apps/docs/lib/openapi-download.test.ts b/apps/docs/lib/openapi-download.test.ts index 2bdfb47bf3c..7a2bcdb26f9 100644 --- a/apps/docs/lib/openapi-download.test.ts +++ b/apps/docs/lib/openapi-download.test.ts @@ -33,8 +33,9 @@ describe('OpenAPI download', () => { const tags = document.tags as Array<{ name: string }> expect(document.openapi).toBe('3.1.0') - expect(Object.keys(paths)).toHaveLength(133) + expect(Object.keys(paths)).toHaveLength(152) expect(tags.map((tag) => tag.name)).toEqual([ + 'Workspace Sync', 'Workflows', 'Workflow Runs', 'Logs', diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 956ffba38ed..66a916f5767 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -745,7 +745,7 @@ "get": { "operationId": "readFileText", "summary": "Read File Text", - "description": "Extract text without changing the file. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, including some legacy `.doc` and `.ppt` results; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.", + "description": "Extract text without changing the file. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.", "x-sim-operation": "files.read_content", "x-oauth-scope": "api:read", "tags": ["Files"], diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index 2a79bb7be6b..d25aaf9460e 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -21,6 +21,10 @@ } ], "tags": [ + { + "name": "Workspace Sync", + "description": "Portable workflow configuration, workspace forks, push and pull, and durable operation status." + }, { "name": "Workflows", "description": "Manage and execute workflow definitions, folders, deployment versions, and portable imports and exports." @@ -1994,7 +1998,7 @@ "get": { "operationId": "exportWorkflow", "summary": "Export Workflow", - "description": "Export a portable, secret-sanitized workflow; workspace-scoped bindings must be selected again after import. Exporting records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "description": "Export a portable, secret-sanitized workflow; Set includeReferences=true to include non-secret source reference identities for mapped import; default exports keep their existing sanitized shape. Exporting records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", "x-sim-operation": "workflows.export", "x-oauth-scope": "api:read", "tags": ["Workflows"], @@ -2010,6 +2014,16 @@ "description": "Unique workflow identifier.", "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] } + }, + { + "name": "includeReferences", + "in": "query", + "required": false, + "description": "Include non-secret resource identifiers and source field occurrences for mapped imports.", + "schema": { + "description": "Include non-secret resource identifiers and source field occurrences for mapped imports.", + "type": "boolean" + } } ], "responses": { @@ -2065,17 +2079,17 @@ "post": { "operationId": "importWorkflow", "summary": "Import Workflow", - "description": "Create a workflow from a portable export object, bare state, or JSON string. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", + "description": "Create an undeployed workflow from a portable export object, bare state, or JSON string. Mapping options require a preview fingerprint and stable request ID; unresolved required configuration creates nothing. Mapped imports return source-to-imported block IDs and an operation receipt. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:write`.", "x-sim-operation": "workflows.import", "x-oauth-scope": "api:write", "tags": ["Workflows"], "requestBody": { "required": true, - "description": "Portable workflow data and destination metadata for an import.", + "description": "Workflow document, destination, and optional reviewed mappings.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/ImportWorkflowRequest" + "$ref": "#/components/schemas/ImportWorkflowBody" } } } @@ -3638,1996 +3652,6025 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." - }, - "oauthBearer": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "OAuth 2.0 access token", - "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." - } - }, - "headers": { - "Content-Type": { - "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable.", - "schema": { - "type": "string", - "title": "Content type", - "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable." - } - }, - "Content-Disposition": { - "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames.", - "schema": { - "type": "string", - "title": "Content disposition", - "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames." - } - }, - "Content-Length": { - "description": "File size in bytes.", - "schema": { - "type": "string", - "pattern": "^(0|[1-9]\\d*)$", - "title": "Content length", - "description": "File size in bytes." - } - }, - "X-RateLimit-Limit": { - "description": "Maximum requests allowed in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit", - "description": "Maximum requests allowed in the current window." - } - }, - "X-RateLimit-Remaining": { - "description": "Requests remaining in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit remaining", - "description": "Requests remaining in the current window." - } - }, - "X-RateLimit-Reset": { - "description": "ISO 8601 timestamp when the current rate-limit window resets.", - "schema": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "title": "Rate limit reset", - "description": "ISO 8601 timestamp when the current rate-limit window resets." - } - }, - "Retry-After": { - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Retry after", - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." - } - }, - "X-Run-Id": { - "description": "Identifier assigned to the workflow run.", - "schema": { - "type": "string", - "minLength": 1, - "title": "Run identifier", - "description": "Identifier assigned to the workflow run." - } - } }, - "responses": { - "BadRequest": { - "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "BAD_REQUEST", - "message": "Invalid request" - } - } - } - } - }, - "Unauthorized": { - "description": "The API credential is missing or invalid.", - "content": { - "application/json": { + "/api/v2/workspaces/{workspaceId}/fork/preview": { + "post": { + "operationId": "previewWorkspaceFork", + "summary": "Preview Workspace Fork", + "description": "Preview the deployed workflows and explicitly selected resources that a new workspace fork would copy. The result is read-only and supplies the fingerprint required by Fork Workspace. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workspaces.fork.preview", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "UNAUTHORIZED", - "message": "Authentication required" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." } } - } - }, - "UsageLimitExceeded": { - "description": "The workspace has exceeded its usage or billing limits.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "USAGE_LIMIT_EXCEEDED", - "message": "Usage limit exceeded. Please upgrade your plan to continue." + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewWorkspaceForkBody" } } } - } - }, - "Forbidden": { - "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "FORBIDDEN", - "message": "Insufficient workspace permissions", - "details": { - "code": "INSUFFICIENT_WORKSPACE_ROLE" + }, + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewWorkspaceForkResponse" } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "NotFound": { - "description": "The requested resource was not found.", - "content": { - "application/json": { + } + }, + "/api/v2/workspaces/{workspaceId}/fork": { + "post": { + "operationId": "forkWorkspace", + "summary": "Fork Workspace", + "description": "Create a child workspace with undeployed workflow drafts. Requires the reviewed preview fingerprint and a stable request ID. Identical retries return the same operation; reuse with different inputs returns 409. Poll Get Workspace Operation until selected resource copies complete. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workspaces.fork.create", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "NOT_FOUND", - "message": "Not found" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." } } - } - }, - "Conflict": { - "description": "The request conflicts with current resource state.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "Webhook path already in use" + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForkWorkspaceBody" } } } - } - }, - "RunIdConflict": { - "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", - "headers": { - "X-Run-Id": { - "$ref": "#/components/headers/X-Run-Id" - } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "Run ID has already been used", - "details": { - "code": "RUN_ID_CONFLICT", - "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ForkWorkspaceResponse" } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "PAYLOAD_TOO_LARGE", - "message": "Request body is too large" - } - } - } - } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { + } + }, + "/api/v2/workspaces/{workspaceId}/fork/push/preview": { + "post": { + "operationId": "previewWorkspacePush", + "summary": "Preview Workspace Push", + "description": "Preview deployed source workflows replacing mapped targets along a direct fork edge. Push sends the current workspace to the other; pull brings the other into the current workspace. Proposed mappings are not saved. Dependent choices use source workflow, block, and field identities. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workspaces.fork.sync.preview", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "UNSUPPORTED_MEDIA_TYPE", - "message": "Request body must be sent as application/json" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." } } - } - }, - "Locked": { - "description": "The resource is temporarily locked or unavailable; retry the request.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "LOCKED", - "message": "Workflow is locked" + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewWorkspacePushBody" } } } - } - }, - "RateLimited": { - "description": "The caller exceeded the request rate limit.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" - } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "RATE_LIMITED", - "message": "API rate limit exceeded", - "details": { - "retryAfter": "2026-01-01T00:00:30.000Z" + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewWorkspacePushResponse" } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "ClientClosedRequest": { - "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", - "content": { - "application/json": { + } + }, + "/api/v2/workspaces/{workspaceId}/fork/push": { + "post": { + "operationId": "pushWorkspace", + "summary": "Push Workspace", + "description": "Apply a reviewed push or pull with inline mappings in one transaction. Requires confirmation, the preview fingerprint, and a stable request ID. Unresolved or changed plans return 409 without applying. The receipt distinguishes committed changes from copy and deployment readiness; poll Get Workspace Operation before treating the target as ready. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workspaces.fork.sync", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CLIENT_CLOSED_REQUEST", - "message": "Client cancelled request", - "details": { - "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" - } - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." } } - } - }, - "InternalError": { - "description": "An unexpected server error occurred.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "INTERNAL_ERROR", - "message": "Internal server error" + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PushWorkspaceBody" } } } - } - }, - "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" - } }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "SERVICE_UNAVAILABLE", - "message": "Service temporarily unavailable" + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PushWorkspaceResponse" + } } } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } } } }, - "schemas": { - "V2ActionableForbiddenDetails": { - "type": "object", - "properties": { - "code": { - "$ref": "#/components/schemas/V2ForbiddenDetailCode" + "/api/v2/workspaces/{workspaceId}/fork/pull/preview": { + "post": { + "operationId": "previewWorkspacePull", + "summary": "Preview Workspace Pull", + "description": "Preview deployed source workflows replacing mapped targets along a direct fork edge. Push sends the current workspace to the other; pull brings the other into the current workspace. Proposed mappings are not saved. Dependent choices use source workflow, block, and field identities. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workspaces.fork.sync.preview", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } } - }, - "required": ["code"], - "additionalProperties": { - "description": "Additional context for this refusal." - }, - "title": "Actionable forbidden details", - "description": "Machine-readable cause and optional context for an actionable `403` response." - }, - "V2ForbiddenDetailCode": { - "type": "string", - "enum": [ - "INSUFFICIENT_WORKSPACE_ROLE", - "PERSONAL_API_KEYS_DISABLED", - "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", - "PRINCIPAL_KIND_NOT_PERMITTED", - "ORGANIZATION_MEMBERSHIP_REQUIRED", - "ORGANIZATION_ADMIN_REQUIRED", - "ENTERPRISE_PLAN_REQUIRED", - "ORGANIZATION_PLAN_REQUIRED", - "AUDIT_LOGS_DISABLED", - "SKILL_EDITOR_ACCESS_REQUIRED", - "SECRET_ADMIN_ACCESS_REQUIRED", - "WORKSPACE_RESOURCE_LIMIT_REACHED", - "PUBLIC_SHARING_NOT_ALLOWED", - "CREDENTIAL_ADMIN_ACCESS_REQUIRED", - "MCP_SERVER_URL_NOT_ALLOWED", - "WORKSPACE_PLAN_CAPABILITY_REQUIRED", - "CHAT_AUTH_MODE_NOT_PERMITTED", - "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", - "PERMISSION_GROUP_CAPABILITY_BLOCKED", - "INTEGRATION_NOT_ALLOWED", - "INSUFFICIENT_SCOPE", - "SCIM_MANAGED_MEMBERSHIP" ], - "title": "Forbidden detail code", - "description": "Stable cause code for an actionable `403` response." - }, - "V2Error": { - "type": "object", - "properties": { - "error": { - "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Stable machine-readable error code." - }, - "message": { - "type": "string", - "description": "Human-readable explanation of the error." - }, - "details": { - "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", - "anyOf": [ - { - "$ref": "#/components/schemas/V2ActionableForbiddenDetails" - }, - { - "description": "Other structured context defined by the specific error." - } - ] + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewWorkspacePullBody" } - }, - "required": ["code", "message"], - "additionalProperties": false, - "description": "Canonical error details." + } } }, - "required": ["error"], - "additionalProperties": false, - "title": "v2 error response", - "description": "Canonical error envelope returned by the public v2 API.", - "examples": [ - { - "error": { - "code": "BAD_REQUEST", - "message": "The request is invalid." + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewWorkspacePullResponse" + } + } } - } - ] - }, - "FolderPathInput": { - "title": "Folder path input", - "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", - "maxLength": 4096, - "type": "string" - }, - "WorkflowListItem": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] }, - "webUrl": { - "type": "string", - "format": "uri", - "description": "Canonical absolute URL for opening this resource in the Sim web application." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "name": { - "type": "string", - "description": "Workflow name.", - "examples": ["Customer support triage"] + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Workflow description, or null when none is set." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "folderPath": { - "type": "string", - "title": "Folder path", - "description": "Canonical containing-folder path; `/` is the workspace root.", - "maxLength": 4096, - "examples": ["/Operations"] + "404": { + "$ref": "#/components/responses/NotFound" }, - "workspaceId": { - "type": "string", - "description": "Workspace that owns the workflow." + "409": { + "$ref": "#/components/responses/Conflict" }, - "isDeployed": { - "type": "boolean", - "description": "Whether the workflow has an active deployment." + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "deployedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 activation timestamp, or null when not deployed.", - "format": "date-time" + "429": { + "$ref": "#/components/responses/RateLimited" }, - "runCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." + "500": { + "$ref": "#/components/responses/InternalError" }, - "lastRunAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/pull": { + "post": { + "operationId": "pullWorkspace", + "summary": "Pull Workspace", + "description": "Apply a reviewed push or pull with inline mappings in one transaction. Requires confirmation, the preview fingerprint, and a stable request ID. Unresolved or changed plans return 409 without applying. The receipt distinguishes committed changes from copy and deployment readiness; poll Get Workspace Operation before treating the target as ready. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workspaces.fork.sync", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } + } + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PullWorkspaceBody" } - ], - "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", - "format": "date-time" - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was created.", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was last updated.", - "format": "date-time" + } } }, - "required": [ - "id", - "webUrl", - "name", - "description", - "folderPath", - "workspaceId", - "isDeployed", - "deployedAt", - "runCount", - "lastRunAt", - "createdAt", - "updatedAt" - ], - "additionalProperties": false, - "title": "Workflow summary", - "description": "Summary of a workflow and its deployment and run state." - }, - "WorkflowListResponse": { - "type": "object", - "properties": { - "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowListItem" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PullWorkspaceResponse" + } } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "Workflow list response", - "description": "A cursor-paginated page of workflow summaries.", - "examples": [ + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/availability": { + "get": { + "operationId": "getWorkspaceForkAvailability", + "summary": "Get Workspace Fork Availability", + "description": "Inspect workspace fork information and copyable resources. Lineage does not grant access to the other workspace; fork creation requires source admin and sync requires admin on both sides. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workspaces.fork.discover", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "parameters": [ { - "data": [ - { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "folderPath": "/Operations", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 42, - "lastRunAt": "2026-08-09T18:04:11.000Z", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z" - } - ], - "nextCursor": null + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } } - ] - }, - "SeededWorkflowBlock": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Block identifier." + ], + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetWorkspaceForkAvailabilityResponse" + } + } + } }, - "type": { - "type": "string", - "description": "Registered block type." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "name": { - "type": "string", - "description": "Block display name." - } - }, - "required": ["id", "type", "name"], - "additionalProperties": false, - "title": "Seeded workflow block", - "description": "A block the platform placed in a newly created workflow." - }, - "CreateWorkflowResult": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "webUrl": { - "type": "string", - "format": "uri", - "description": "Canonical absolute URL for opening this resource in the Sim web application." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "name": { - "type": "string", - "description": "Workflow name.", - "examples": ["Customer support triage"] + "404": { + "$ref": "#/components/responses/NotFound" }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Workflow description, or null when none is set." + "409": { + "$ref": "#/components/responses/Conflict" }, - "folderPath": { - "type": "string", - "title": "Folder path", - "description": "Canonical containing-folder path; `/` is the workspace root.", - "maxLength": 4096, - "examples": ["/Operations"] + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "workspaceId": { - "type": "string", - "description": "Workspace that owns the workflow." + "429": { + "$ref": "#/components/responses/RateLimited" }, - "isDeployed": { - "type": "boolean", - "description": "Whether the workflow has an active deployment." + "500": { + "$ref": "#/components/responses/InternalError" }, - "deployedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/lineage": { + "get": { + "operationId": "getWorkspaceForkLineage", + "summary": "Get Workspace Fork Lineage", + "description": "Inspect workspace fork information and copyable resources. Lineage does not grant access to the other workspace; fork creation requires source admin and sync requires admin on both sides. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workspaces.fork.discover", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } + } + ], + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetWorkspaceForkLineageResponse" + } } - ], - "description": "ISO 8601 activation timestamp, or null when not deployed.", - "format": "date-time" + } }, - "runCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "lastRunAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", - "format": "date-time" + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was created.", - "format": "date-time" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was last updated.", - "format": "date-time" + "404": { + "$ref": "#/components/responses/NotFound" }, - "blocks": { - "type": "array", - "items": { - "$ref": "#/components/schemas/SeededWorkflowBlock" - }, - "description": "Blocks seeded into the new workflow. Contains the start block; attach edges to its `id`." + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": [ - "id", - "webUrl", - "name", - "description", - "folderPath", - "workspaceId", - "isDeployed", - "deployedAt", - "runCount", - "lastRunAt", - "createdAt", - "updatedAt", - "blocks" - ], - "additionalProperties": false, - "title": "Create workflow result", - "description": "The created workflow and the blocks it was seeded with." - }, - "CreateWorkflowResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/CreateWorkflowResult" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Create workflow response", - "description": "The created workflow and the blocks it was seeded with.", - "examples": [ + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/children": { + "get": { + "operationId": "listWorkspaceForkChildren", + "summary": "List Workspace Fork Children", + "description": "Inspect workspace fork information and copyable resources. Lineage does not grant access to the other workspace; fork creation requires source admin and sync requires admin on both sides. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workspaces.fork.discover", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "parameters": [ { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "folderPath": "/Operations", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": false, - "deployedAt": null, - "runCount": 0, - "lastRunAt": null, - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z", - "blocks": [ - { - "id": "start-1", - "type": "starter", - "name": "Start" - } - ] + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." } - } - ] - }, - "CreateWorkflowRequest": { - "type": "object", - "properties": { - "workspaceId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to create the workflow." }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Workflow name." + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } }, - "description": { - "description": "Optional workflow description.", - "anyOf": [ - { - "type": "string", - "maxLength": 50000 - }, - { - "type": "null" - } - ] + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } }, - "folderPath": { - "$ref": "#/components/schemas/FolderPathInput" - } - }, - "required": ["workspaceId", "name"], - "additionalProperties": false, - "title": "Create workflow request", - "description": "Name, description, workspace, and optional folder for a new workflow." - }, - "WorkflowBlock": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Block identifier, unique within the workflow." + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Supported stable sort key for this collection.", + "schema": { + "default": "createdAt", + "description": "Supported stable sort key for this collection.", + "type": "string", + "enum": ["createdAt"] + } }, - "type": { - "type": "string", - "description": "Registered block type." + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["desc"] + } + } + ], + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListWorkspaceForkChildrenResponse" + } + } + } }, - "name": { - "type": "string", - "description": "Block display name; must be unique within the workflow." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "position": { - "type": "object", - "properties": { - "x": { - "type": "number", - "description": "Canvas x coordinate." - }, - "y": { - "type": "number", - "description": "Canvas y coordinate." - } - }, - "required": ["x", "y"], - "additionalProperties": false, - "description": "Canvas coordinates of a block." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "subBlocks": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Sub-block identifier." - }, - "type": { - "type": "string", - "minLength": 1, - "description": "Sub-block input type." - }, - "value": { - "description": "Configured value; shape depends on the sub-block type." - } - }, - "required": ["id", "type", "value"], - "additionalProperties": false, - "description": "One configurable input on a block." - }, - "description": "Configured inputs keyed by sub-block id." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "outputs": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Declared shape of one output; depends on the block type." - }, - "description": "Declared output shape keyed by output name." + "404": { + "$ref": "#/components/responses/NotFound" }, - "enabled": { - "type": "boolean", - "description": "Whether the block runs." + "409": { + "$ref": "#/components/responses/Conflict" }, - "horizontalHandles": { - "description": "Whether edge handles render horizontally.", - "type": "boolean" + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "height": { - "description": "Rendered block height.", - "type": "number" + "429": { + "$ref": "#/components/responses/RateLimited" }, - "advancedMode": { - "description": "Whether the block is edited in advanced mode.", - "type": "boolean" + "500": { + "$ref": "#/components/responses/InternalError" }, - "errorEnabled": { - "description": "Whether the block exposes an error branch.", - "type": "boolean" + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/resources": { + "get": { + "operationId": "listWorkspaceForkResources", + "summary": "List Workspace Fork Resources", + "description": "Inspect workspace fork information and copyable resources. Lineage does not grant access to the other workspace; fork creation requires source admin and sync requires admin on both sides. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workspaces.fork.discover", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } }, - "retry": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Whether the block retries on failure." - }, - "maxTries": { - "type": "integer", - "minimum": 2, - "maximum": 5, - "description": "Total attempts, including the first." - }, - "waitBetweenTriesMs": { - "type": "integer", - "minimum": 0, - "maximum": 5000, - "description": "Delay between attempts, in milliseconds." - } - }, - "required": ["enabled", "maxTries", "waitBetweenTriesMs"], - "additionalProperties": false, - "description": "Per-block retry configuration." + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } }, - "triggerMode": { - "description": "Whether the block acts as the workflow trigger.", - "type": "boolean" + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } }, - "data": { - "type": "object", - "properties": { - "parentId": { - "description": "Identifier of the containing loop or parallel.", - "type": "string" - }, - "extent": { - "description": "Constrains the block to its parent bounds.", - "type": "string", - "const": "parent" - }, - "width": { - "description": "Rendered container width.", - "type": "number" - }, - "height": { - "description": "Rendered container height.", - "type": "number" - }, - "collection": { - "description": "Items a forEach loop or collection parallel iterates." - }, - "count": { - "description": "Iteration count for a `for` loop or count parallel.", - "type": "number" - }, - "loopType": { - "description": "Loop container kind.", - "type": "string", - "enum": ["for", "forEach", "while", "doWhile"] - }, - "whileCondition": { - "description": "Condition expression for a `while` loop.", - "type": "string" - }, - "doWhileCondition": { - "description": "Condition expression for a `doWhile` loop.", - "type": "string" - }, - "parallelType": { - "description": "Parallel container kind.", - "type": "string", - "enum": ["collection", "count"] - }, - "batchSize": { - "description": "Maximum concurrent branches of a parallel.", - "type": "number" - }, - "type": { - "description": "Container subtype.", - "type": "string" - }, - "canonicalModes": { - "description": "Per-field editing mode, keyed by canonical parameter id.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": ["basic", "advanced"] + { + "name": "kind", + "in": "query", + "required": true, + "description": "Resource or operation kind.", + "schema": { + "type": "string", + "enum": [ + "files", + "tables", + "knowledgeBases", + "customTools", + "skills", + "mcpServers", + "workflowMcpServers" + ], + "description": "Resource or operation kind." + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Supported stable sort key for this collection.", + "schema": { + "default": "id", + "description": "Supported stable sort key for this collection.", + "type": "string", + "enum": ["id"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc"] + } + } + ], + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListWorkspaceForkResourcesResponse" } } - }, - "additionalProperties": false, - "description": "Container and layout metadata carried by a block." + } }, - "locked": { - "description": "Whether the block is locked against edits.", - "type": "boolean" - } - }, - "required": ["id", "type", "name", "position", "subBlocks", "outputs", "enabled"], - "additionalProperties": false, - "title": "Workflow block", - "description": "One node of a workflow graph and its configuration." - }, - "WorkflowEdge": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Edge identifier, unique within the workflow." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "source": { - "type": "string", - "minLength": 1, - "description": "Source block id." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "target": { - "type": "string", - "minLength": 1, - "description": "Target block id." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "sourceHandle": { - "description": "Source port, or null for the block default.", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "404": { + "$ref": "#/components/responses/NotFound" }, - "targetHandle": { - "description": "Target port, or null for the block default.", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] + "409": { + "$ref": "#/components/responses/Conflict" }, - "type": { - "description": "Edge renderer type.", - "type": "string" - } - }, - "required": ["id", "source", "target"], - "additionalProperties": false, - "title": "Workflow edge", - "description": "A directed connection between two blocks." - }, - "WorkflowLoop": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Loop container identifier; equal to the loop block id." + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "nodes": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Block ids inside the loop." + "429": { + "$ref": "#/components/responses/RateLimited" }, - "iterations": { - "type": "number", - "description": "Resolved iteration count." + "500": { + "$ref": "#/components/responses/InternalError" }, - "loopType": { - "type": "string", - "enum": ["for", "forEach", "while", "doWhile"], - "description": "Loop kind." + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/mappings": { + "get": { + "operationId": "getWorkspaceForkMappings", + "summary": "Get Workspace Fork Mappings", + "description": "Read persisted mappings in the requested source-to-target direction. Candidate discovery uses the destination resource and selector listing operations. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workspaces.fork.mappings.read", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } }, - "forEachItems": { - "description": "Items a forEach loop iterates, or the expression producing them.", - "anyOf": [ - { - "type": "array", - "items": { - "description": "One item the loop iterates." - } - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One item the loop iterates." - } - }, - { - "type": "string" - } - ] - }, - "whileCondition": { - "description": "Condition expression for a `while` loop.", - "type": "string" + { + "name": "otherWorkspaceId", + "in": "query", + "required": true, + "description": "Workspace on the other side of the direct fork edge.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace on the other side of the direct fork edge." + } }, - "doWhileCondition": { - "description": "Condition expression for a `doWhile` loop.", - "type": "string" + { + "name": "direction", + "in": "query", + "required": true, + "description": "Push means current to other; pull means other to current, independent of parent/child orientation.", + "schema": { + "type": "string", + "enum": ["push", "pull"], + "description": "Push means current to other; pull means other to current, independent of parent/child orientation." + } }, - "enabled": { - "description": "Whether the loop runs.", - "type": "boolean" + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } }, - "locked": { - "description": "Whether the loop is locked against edits.", - "type": "boolean" - } - }, - "required": ["id", "nodes", "iterations", "loopType"], - "additionalProperties": false, - "title": "Workflow loop", - "description": "A loop container derived from the workflow blocks." - }, - "WorkflowParallel": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Parallel container identifier; equal to the parallel block id." + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } }, - "nodes": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Block ids inside the parallel." + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Supported stable sort key for this collection.", + "schema": { + "default": "id", + "description": "Supported stable sort key for this collection.", + "type": "string", + "enum": ["id"] + } }, - "distribution": { - "description": "Items distributed across branches, or the expression producing them.", - "anyOf": [ - { - "type": "array", - "items": { - "description": "One item distributed to a branch." - } - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One item distributed to a branch." + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc"] + } + } + ], + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetWorkspaceForkMappingsResponse" } - }, - { - "type": "string" } - ] + } }, - "count": { - "description": "Fixed branch count.", - "type": "number" + "400": { + "$ref": "#/components/responses/BadRequest" }, - "parallelType": { - "description": "Parallel kind.", - "type": "string", - "enum": ["count", "collection"] + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "batchSize": { - "description": "Maximum concurrent branches.", - "type": "number" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "enabled": { - "description": "Whether the parallel runs.", - "type": "boolean" + "404": { + "$ref": "#/components/responses/NotFound" }, - "locked": { - "description": "Whether the parallel is locked against edits.", - "type": "boolean" - } - }, - "required": ["id", "nodes"], - "additionalProperties": false, - "title": "Workflow parallel", - "description": "A parallel container derived from the workflow blocks." - }, - "WorkflowVariable": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Variable identifier." + "409": { + "$ref": "#/components/responses/Conflict" }, - "name": { - "type": "string", - "description": "Variable name, referenced from block inputs." + "413": { + "$ref": "#/components/responses/PayloadTooLarge" }, - "type": { - "default": "string", - "description": "Declared variable type.", - "type": "string", - "enum": ["string", "number", "boolean", "object", "array", "plain"] + "429": { + "$ref": "#/components/responses/RateLimited" }, - "value": { - "description": "Variable value; free-form and validated per `type` at use time." + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["id", "name", "type", "value"], - "additionalProperties": false, - "title": "Workflow variable", - "description": "A workflow-scoped variable." + } }, - "WorkflowGraph": { - "type": "object", - "properties": { - "blocks": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/WorkflowBlock" - }, - "description": "Blocks keyed by block id." + "put": { + "operationId": "updateWorkspaceForkMappings", + "summary": "Update Workspace Fork Mappings", + "description": "Update edge mappings after validating destination resource membership and credential provider compatibility. Push addresses current-to-other mappings; pull addresses other-to-current mappings. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workspaces.fork.mappings.update", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } + } + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkspaceForkMappingsBody" + } + } + } + }, + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkspaceForkMappingsResponse" + } + } + } }, - "edges": { - "maxItems": 10000, - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowEdge" - }, - "description": "Directed connections between blocks." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "loops": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/WorkflowLoop" - }, - "description": "Loop containers keyed by container id; always present, `{}` when there are none." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "parallels": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/WorkflowParallel" - }, - "description": "Parallel containers keyed by container id; always present, `{}` when there are none." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "variables": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/WorkflowVariable" - }, - "description": "Workflow variables keyed by variable id; always present, `{}` when there are none." + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" } - }, - "required": ["blocks", "edges", "loops", "parallels", "variables"], - "additionalProperties": false, - "title": "Workflow graph", - "description": "The editable draft graph of a workflow: blocks, edges, derived loop and parallel containers, and variables." - }, - "WorkflowStateResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowGraph" + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/rollback": { + "post": { + "operationId": "rollbackWorkspaceFork", + "summary": "Rollback Workspace Fork", + "description": "Restore the latest sync into this workspace using its prior deployed versions. Requires target admin. It does not restore arbitrary drafts or remove every copied resource. Pending activations are reported. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workspaces.fork.rollback", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } + } + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RollbackWorkspaceForkBody" + } + } } }, - "required": ["data"], - "additionalProperties": false, - "title": "Workflow state response", - "description": "The editable draft graph of a workflow.", - "examples": [ + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RollbackWorkspaceForkResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/unlink": { + "post": { + "operationId": "unlinkWorkspaceFork", + "summary": "Unlink Workspace Fork", + "description": "Remove the direct fork relationship and its mappings. Requires admin on the acting workspace. Existing workflow and resource content remains available. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workspaces.fork.unlink", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ { - "data": { - "blocks": {}, - "edges": [], - "loops": {}, - "parallels": {}, - "variables": {} + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." } } - ] - }, - "WorkflowLintReport": { - "type": "object", - "properties": { - "sources": { - "type": "array", - "items": { - "type": "object", - "properties": { - "blockId": { - "type": "string", - "description": "Block the finding is about." - }, - "blockName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Display name of the block, when it has one." - }, - "blockType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Registered type of the block, when it has one." + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnlinkWorkspaceForkBody" + } + } + } + }, + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnlinkWorkspaceForkResponse" } - }, - "required": ["blockId", "blockName", "blockType"], - "additionalProperties": false - }, - "description": "Blocks with no incoming edge. A trigger block is naturally a source; anything else here is unreachable." + } + } }, - "sinks": { - "type": "array", - "items": { - "type": "object", - "properties": { - "blockId": { - "type": "string", - "description": "Block the finding is about." - }, - "blockName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Display name of the block, when it has one." - }, - "blockType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Registered type of the block, when it has one." - } - }, - "required": ["blockId", "blockName", "blockType"], - "additionalProperties": false - }, - "description": "Blocks with no outgoing edge." + "400": { + "$ref": "#/components/responses/BadRequest" }, - "orphanBlocks": { - "type": "array", - "items": { - "type": "object", - "properties": { - "blockId": { - "type": "string", - "description": "Block the finding is about." - }, - "blockName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Display name of the block, when it has one." - }, - "blockType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Registered type of the block, when it has one." - } - }, - "required": ["blockId", "blockName", "blockType"], - "additionalProperties": false - }, - "description": "Blocks with neither an incoming nor an outgoing edge." + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "emptyOutgoingPorts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "blockId": { - "type": "string", - "description": "Block the finding is about." - }, - "blockName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Display name of the block, when it has one." - }, - "blockType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Registered type of the block, when it has one." - }, - "handle": { - "type": "string", - "description": "Source handle with nothing connected to it." - }, - "label": { - "type": "string", - "description": "Human-readable name of the port." - } - }, - "required": ["blockId", "blockName", "blockType", "handle", "label"], - "additionalProperties": false - }, - "description": "Branch and container ports that lead nowhere." + "403": { + "$ref": "#/components/responses/Forbidden" }, - "invalidBranchPorts": { - "type": "array", - "items": { - "type": "object", - "properties": { - "blockId": { - "type": "string", - "description": "Block the finding is about." - }, - "blockName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Display name of the block, when it has one." - }, - "blockType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Registered type of the block, when it has one." - }, - "sourceHandle": { - "type": "string", - "description": "Source handle that does not match the block." - }, - "reason": { - "type": "string", - "description": "Why the handle is not valid for this block." + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/fork/exclusions": { + "put": { + "operationId": "updateWorkspaceForkExclusions", + "summary": "Update Workspace Fork Exclusions", + "description": "Include or exclude selected workflows from fork sync. Excluded workflows are skipped as sources and targets. Missing, archived, and unchanged workflow IDs are skipped. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workspaces.fork.exclusions", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } + } + ], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkspaceForkExclusionsBody" + } + } + } + }, + "responses": { + "200": { + "description": "The workspace operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateWorkspaceForkExclusionsResponse" } - }, - "required": ["blockId", "blockName", "blockType", "sourceHandle", "reason"], - "additionalProperties": false - }, - "description": "Condition and router edges whose source handle names no real branch." + } + } }, - "invalidConnectionTargets": { - "type": "array", - "items": { - "type": "object", - "properties": { - "sourceBlockId": { - "type": "string", - "description": "Block the edge leaves." - }, - "sourceBlockName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Display name of the source block." - }, - "sourceHandle": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Handle the edge leaves from." - }, - "targetBlockId": { - "type": "string", - "description": "Block the edge points at." - }, - "reason": { - "type": "string", - "description": "Why the target is not a legal destination." + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workflows/import/preview": { + "post": { + "operationId": "previewWorkflowImport", + "summary": "Preview Workflow Import", + "description": "Validate destination mappings and dependent choices without creating a workflow. Returns unresolved fields, discovery instructions, and a fingerprint required by mapped import. No source workspace is queried from imported provenance.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "workflows.import.preview", + "x-oauth-scope": "api:write", + "tags": ["Workspace Sync"], + "requestBody": { + "required": true, + "description": "Portable workflow data and destination metadata for an import.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImportWorkflowRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PreviewWorkflowImportResponse" } - }, - "required": [ - "sourceBlockId", - "sourceBlockName", - "sourceHandle", - "targetBlockId", - "reason" - ], - "additionalProperties": false - }, - "description": "Edges pointing at a block that cannot legally receive them." + } + } }, - "fieldIssues": { - "type": "array", - "items": { - "type": "object", - "properties": { - "blockId": { - "type": "string", - "description": "Block the finding is about." - }, - "blockName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Display name of the block, when it has one." - }, - "blockType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Registered type of the block, when it has one." - }, - "missingRequiredFields": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Required sub-block fields that resolve empty in the active mode." - }, - "inactiveModeValues": { - "type": "array", - "items": { - "type": "object", - "properties": { - "canonicalId": { - "type": "string", - "description": "Canonical parameter the two sub-block modes share." - }, - "activeMemberId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Sub-block the runtime reads, where the value should live." - }, - "inactiveMemberId": { - "type": "string", - "description": "Sub-block holding the stranded value, which the runtime ignores." - }, - "kind": { - "type": "string", - "enum": ["credential", "resource", "other"], - "description": "What kind of value is stranded." - } - }, - "required": ["canonicalId", "activeMemberId", "inactiveMemberId", "kind"], - "additionalProperties": false - }, - "description": "Values stranded on the inactive member of a canonical pair." + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/selectors/list": { + "post": { + "operationId": "listSelector", + "summary": "List Selector Options", + "description": "List workspace-scoped configuration choices using the selector key and dependencies from an import or sync preview. Missing OAuth connections require human authorization before provider choices can be discovered. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "selectors.execute", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSelectorBody" + } + } + } + }, + "responses": { + "200": { + "description": "The operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListSelectorResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/selectors/get": { + "post": { + "operationId": "getSelector", + "summary": "Get Selector Option", + "description": "Resolve a workspace configuration option by its provider identifier and declared dependencies. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "selectors.execute", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "requestBody": { + "required": true, + "description": "The body for this operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSelectorBody" + } + } + } + }, + "responses": { + "200": { + "description": "The operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetSelectorResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/operations/{operationId}": { + "get": { + "operationId": "getWorkspaceOperation", + "summary": "Get Workspace Operation", + "description": "Read a committed operation, copy progress, exact deployment readiness, and structured issues. A failed follow-up does not mean the business transaction was rolled back.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workspaces.operations.read", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } + }, + { + "name": "operationId", + "in": "path", + "required": true, + "description": "Durable operation identifier to use for polling.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Durable operation identifier to use for polling." + } + } + ], + "responses": { + "200": { + "description": "The operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetWorkspaceOperationResponse" } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/workspaces/{workspaceId}/operations": { + "get": { + "operationId": "listWorkspaceOperations", + "summary": "List Workspace Operations", + "description": "Page committed operations newest first. Filter by the original request ID to reconcile an uncertain mutation response.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "workspaces.operations.read", + "x-oauth-scope": "api:read", + "tags": ["Workspace Sync"], + "parameters": [ + { + "name": "workspaceId", + "in": "path", + "required": true, + "description": "Explicit current workspace scope.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum items to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "requestId", + "in": "query", + "required": false, + "description": "Stable client request ID for reconciliation and identical retries.", + "schema": { + "description": "Stable client request ID for reconciliation and identical retries.", + "type": "string", + "minLength": 1, + "maxLength": 128 + } + } + ], + "responses": { + "200": { + "description": "The operation result.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListWorkspaceOperationsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." + } + }, + "headers": { + "Content-Type": { + "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable.", + "schema": { + "type": "string", + "title": "Content type", + "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable." + } + }, + "Content-Disposition": { + "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames.", + "schema": { + "type": "string", + "title": "Content disposition", + "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames." + } + }, + "Content-Length": { + "description": "File size in bytes.", + "schema": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)$", + "title": "Content length", + "description": "File size in bytes." + } + }, + "X-RateLimit-Limit": { + "description": "Maximum requests allowed in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit", + "description": "Maximum requests allowed in the current window." + } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit remaining", + "description": "Requests remaining in the current window." + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp when the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "title": "Rate limit reset", + "description": "ISO 8601 timestamp when the current rate-limit window resets." + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Retry after", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." + } + }, + "X-Run-Id": { + "description": "Identifier assigned to the workflow run.", + "schema": { + "type": "string", + "minLength": 1, + "title": "Run identifier", + "description": "Identifier assigned to the workflow run." + } + } + }, + "responses": { + "BadRequest": { + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } + } + } + } + }, + "Unauthorized": { + "description": "The API credential is missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Authentication required" + } + } + } + } + }, + "UsageLimitExceeded": { + "description": "The workspace has exceeded its usage or billing limits.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "USAGE_LIMIT_EXCEEDED", + "message": "Usage limit exceeded. Please upgrade your plan to continue." + } + } + } + } + }, + "Forbidden": { + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with current resource state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Webhook path already in use" + } + } + } + } + }, + "RunIdConflict": { + "description": "The run cannot be started, for one of two causes named by `error.details.code`: `RUN_ID_CONFLICT` when the supplied `X-Run-Id` is already claimed, and `CALL_CHAIN_DEPTH_EXCEEDED` when the incoming `X-Sim-Via` chain has reached the maximum workflow-to-workflow call depth.", + "headers": { + "X-Run-Id": { + "$ref": "#/components/headers/X-Run-Id" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "Run ID has already been used", + "details": { + "code": "RUN_ID_CONFLICT", + "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" + } + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } + } + } + }, + "UnsupportedMediaType": { + "description": "The request uses an unsupported media type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Request body must be sent as application/json" + } + } + } + } + }, + "Locked": { + "description": "The resource is temporarily locked or unavailable; retry the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "Workflow is locked" + } + } + } + } + }, + "RateLimited": { + "description": "The caller exceeded the request rate limit.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } + } + } + } + }, + "ClientClosedRequest": { + "description": "The client closed the connection before the response was produced. An abort can leave the run going, so `error.details.runId` carries the run id — reconcile against the runs resource rather than starting another run.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CLIENT_CLOSED_REQUEST", + "message": "Client cancelled request", + "details": { + "runId": "0f7c1a2e-9b3d-4c58-8a21-6d4e5f7a9b01" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + }, + "ServiceUnavailable": { + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", + "message": "Service temporarily unavailable" + } + } + } + } + } + }, + "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, + "V2Error": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Stable machine-readable error code." + }, + "message": { + "type": "string", + "description": "Human-readable explanation of the error." + }, + "details": { + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] + } + }, + "required": ["code", "message"], + "additionalProperties": false, + "description": "Canonical error details." + } + }, + "required": ["error"], + "additionalProperties": false, + "title": "v2 error response", + "description": "Canonical error envelope returned by the public v2 API.", + "examples": [ + { + "error": { + "code": "BAD_REQUEST", + "message": "The request is invalid." + } + } + ] + }, + "FolderPathInput": { + "title": "Folder path input", + "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, + "WorkflowListItem": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + }, + "webUrl": { + "type": "string", + "format": "uri", + "description": "Canonical absolute URL for opening this resource in the Sim web application." + }, + "name": { + "type": "string", + "description": "Workflow name.", + "examples": ["Customer support triage"] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Workflow description, or null when none is set." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096, + "examples": ["/Operations"] + }, + "workspaceId": { + "type": "string", + "description": "Workspace that owns the workflow." + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow has an active deployment." + }, + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 activation timestamp, or null when not deployed.", + "format": "date-time" + }, + "runCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." + }, + "lastRunAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was last updated.", + "format": "date-time" + } + }, + "required": [ + "id", + "webUrl", + "name", + "description", + "folderPath", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Workflow summary", + "description": "Summary of a workflow and its deployment and run state." + }, + "WorkflowListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowListItem" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Workflow list response", + "description": "A cursor-paginated page of workflow summaries.", + "examples": [ + { + "data": [ + { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 42, + "lastRunAt": "2026-08-09T18:04:11.000Z", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z" + } + ], + "nextCursor": null + } + ] + }, + "SeededWorkflowBlock": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Block identifier." + }, + "type": { + "type": "string", + "description": "Registered block type." + }, + "name": { + "type": "string", + "description": "Block display name." + } + }, + "required": ["id", "type", "name"], + "additionalProperties": false, + "title": "Seeded workflow block", + "description": "A block the platform placed in a newly created workflow." + }, + "CreateWorkflowResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + }, + "webUrl": { + "type": "string", + "format": "uri", + "description": "Canonical absolute URL for opening this resource in the Sim web application." + }, + "name": { + "type": "string", + "description": "Workflow name.", + "examples": ["Customer support triage"] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Workflow description, or null when none is set." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096, + "examples": ["/Operations"] + }, + "workspaceId": { + "type": "string", + "description": "Workspace that owns the workflow." + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow has an active deployment." + }, + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 activation timestamp, or null when not deployed.", + "format": "date-time" + }, + "runCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." + }, + "lastRunAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was last updated.", + "format": "date-time" + }, + "blocks": { + "type": "array", + "items": { + "$ref": "#/components/schemas/SeededWorkflowBlock" + }, + "description": "Blocks seeded into the new workflow. Contains the start block; attach edges to its `id`." + } + }, + "required": [ + "id", + "webUrl", + "name", + "description", + "folderPath", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "createdAt", + "updatedAt", + "blocks" + ], + "additionalProperties": false, + "title": "Create workflow result", + "description": "The created workflow and the blocks it was seeded with." + }, + "CreateWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/CreateWorkflowResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create workflow response", + "description": "The created workflow and the blocks it was seeded with.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": false, + "deployedAt": null, + "runCount": 0, + "lastRunAt": null, + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z", + "blocks": [ + { + "id": "start-1", + "type": "starter", + "name": "Start" + } + ] + } + } + ] + }, + "CreateWorkflowRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the workflow." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Workflow name." + }, + "description": { + "description": "Optional workflow description.", + "anyOf": [ + { + "type": "string", + "maxLength": 50000 + }, + { + "type": "null" + } + ] + }, + "folderPath": { + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "required": ["workspaceId", "name"], + "additionalProperties": false, + "title": "Create workflow request", + "description": "Name, description, workspace, and optional folder for a new workflow." + }, + "WorkflowBlock": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Block identifier, unique within the workflow." + }, + "type": { + "type": "string", + "description": "Registered block type." + }, + "name": { + "type": "string", + "description": "Block display name; must be unique within the workflow." + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "description": "Canvas x coordinate." + }, + "y": { + "type": "number", + "description": "Canvas y coordinate." + } + }, + "required": ["x", "y"], + "additionalProperties": false, + "description": "Canvas coordinates of a block." + }, + "subBlocks": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Sub-block identifier." + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Sub-block input type." + }, + "value": { + "description": "Configured value; shape depends on the sub-block type." + } + }, + "required": ["id", "type", "value"], + "additionalProperties": false, + "description": "One configurable input on a block." + }, + "description": "Configured inputs keyed by sub-block id." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Declared shape of one output; depends on the block type." + }, + "description": "Declared output shape keyed by output name." + }, + "enabled": { + "type": "boolean", + "description": "Whether the block runs." + }, + "horizontalHandles": { + "description": "Whether edge handles render horizontally.", + "type": "boolean" + }, + "height": { + "description": "Rendered block height.", + "type": "number" + }, + "advancedMode": { + "description": "Whether the block is edited in advanced mode.", + "type": "boolean" + }, + "errorEnabled": { + "description": "Whether the block exposes an error branch.", + "type": "boolean" + }, + "retry": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the block retries on failure." + }, + "maxTries": { + "type": "integer", + "minimum": 2, + "maximum": 5, + "description": "Total attempts, including the first." + }, + "waitBetweenTriesMs": { + "type": "integer", + "minimum": 0, + "maximum": 5000, + "description": "Delay between attempts, in milliseconds." + } + }, + "required": ["enabled", "maxTries", "waitBetweenTriesMs"], + "additionalProperties": false, + "description": "Per-block retry configuration." + }, + "triggerMode": { + "description": "Whether the block acts as the workflow trigger.", + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "parentId": { + "description": "Identifier of the containing loop or parallel.", + "type": "string" + }, + "extent": { + "description": "Constrains the block to its parent bounds.", + "type": "string", + "const": "parent" + }, + "width": { + "description": "Rendered container width.", + "type": "number" + }, + "height": { + "description": "Rendered container height.", + "type": "number" + }, + "collection": { + "description": "Items a forEach loop or collection parallel iterates." + }, + "count": { + "description": "Iteration count for a `for` loop or count parallel.", + "type": "number" + }, + "loopType": { + "description": "Loop container kind.", + "type": "string", + "enum": ["for", "forEach", "while", "doWhile"] + }, + "whileCondition": { + "description": "Condition expression for a `while` loop.", + "type": "string" + }, + "doWhileCondition": { + "description": "Condition expression for a `doWhile` loop.", + "type": "string" + }, + "parallelType": { + "description": "Parallel container kind.", + "type": "string", + "enum": ["collection", "count"] + }, + "batchSize": { + "description": "Maximum concurrent branches of a parallel.", + "type": "number" + }, + "type": { + "description": "Container subtype.", + "type": "string" + }, + "canonicalModes": { + "description": "Per-field editing mode, keyed by canonical parameter id.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": ["basic", "advanced"] + } + } + }, + "additionalProperties": false, + "description": "Container and layout metadata carried by a block." + }, + "locked": { + "description": "Whether the block is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "type", "name", "position", "subBlocks", "outputs", "enabled"], + "additionalProperties": false, + "title": "Workflow block", + "description": "One node of a workflow graph and its configuration." + }, + "WorkflowEdge": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Edge identifier, unique within the workflow." + }, + "source": { + "type": "string", + "minLength": 1, + "description": "Source block id." + }, + "target": { + "type": "string", + "minLength": 1, + "description": "Target block id." + }, + "sourceHandle": { + "description": "Source port, or null for the block default.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "targetHandle": { + "description": "Target port, or null for the block default.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": { + "description": "Edge renderer type.", + "type": "string" + } + }, + "required": ["id", "source", "target"], + "additionalProperties": false, + "title": "Workflow edge", + "description": "A directed connection between two blocks." + }, + "WorkflowLoop": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Loop container identifier; equal to the loop block id." + }, + "nodes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Block ids inside the loop." + }, + "iterations": { + "type": "number", + "description": "Resolved iteration count." + }, + "loopType": { + "type": "string", + "enum": ["for", "forEach", "while", "doWhile"], + "description": "Loop kind." + }, + "forEachItems": { + "description": "Items a forEach loop iterates, or the expression producing them.", + "anyOf": [ + { + "type": "array", + "items": { + "description": "One item the loop iterates." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One item the loop iterates." + } + }, + { + "type": "string" + } + ] + }, + "whileCondition": { + "description": "Condition expression for a `while` loop.", + "type": "string" + }, + "doWhileCondition": { + "description": "Condition expression for a `doWhile` loop.", + "type": "string" + }, + "enabled": { + "description": "Whether the loop runs.", + "type": "boolean" + }, + "locked": { + "description": "Whether the loop is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "nodes", "iterations", "loopType"], + "additionalProperties": false, + "title": "Workflow loop", + "description": "A loop container derived from the workflow blocks." + }, + "WorkflowParallel": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Parallel container identifier; equal to the parallel block id." + }, + "nodes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Block ids inside the parallel." + }, + "distribution": { + "description": "Items distributed across branches, or the expression producing them.", + "anyOf": [ + { + "type": "array", + "items": { + "description": "One item distributed to a branch." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One item distributed to a branch." + } + }, + { + "type": "string" + } + ] + }, + "count": { + "description": "Fixed branch count.", + "type": "number" + }, + "parallelType": { + "description": "Parallel kind.", + "type": "string", + "enum": ["count", "collection"] + }, + "batchSize": { + "description": "Maximum concurrent branches.", + "type": "number" + }, + "enabled": { + "description": "Whether the parallel runs.", + "type": "boolean" + }, + "locked": { + "description": "Whether the parallel is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "nodes"], + "additionalProperties": false, + "title": "Workflow parallel", + "description": "A parallel container derived from the workflow blocks." + }, + "WorkflowVariable": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Variable identifier." + }, + "name": { + "type": "string", + "description": "Variable name, referenced from block inputs." + }, + "type": { + "default": "string", + "description": "Declared variable type.", + "type": "string", + "enum": ["string", "number", "boolean", "object", "array", "plain"] + }, + "value": { + "description": "Variable value; free-form and validated per `type` at use time." + } + }, + "required": ["id", "name", "type", "value"], + "additionalProperties": false, + "title": "Workflow variable", + "description": "A workflow-scoped variable." + }, + "WorkflowGraph": { + "type": "object", + "properties": { + "blocks": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowBlock" + }, + "description": "Blocks keyed by block id." + }, + "edges": { + "maxItems": 10000, + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowEdge" + }, + "description": "Directed connections between blocks." + }, + "loops": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowLoop" + }, + "description": "Loop containers keyed by container id; always present, `{}` when there are none." + }, + "parallels": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowParallel" + }, + "description": "Parallel containers keyed by container id; always present, `{}` when there are none." + }, + "variables": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowVariable" + }, + "description": "Workflow variables keyed by variable id; always present, `{}` when there are none." + } + }, + "required": ["blocks", "edges", "loops", "parallels", "variables"], + "additionalProperties": false, + "title": "Workflow graph", + "description": "The editable draft graph of a workflow: blocks, edges, derived loop and parallel containers, and variables." + }, + "WorkflowStateResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowGraph" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Workflow state response", + "description": "The editable draft graph of a workflow.", + "examples": [ + { + "data": { + "blocks": {}, + "edges": [], + "loops": {}, + "parallels": {}, + "variables": {} + } + } + ] + }, + "WorkflowLintReport": { + "type": "object", + "properties": { + "sources": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + } + }, + "required": ["blockId", "blockName", "blockType"], + "additionalProperties": false + }, + "description": "Blocks with no incoming edge. A trigger block is naturally a source; anything else here is unreachable." + }, + "sinks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + } + }, + "required": ["blockId", "blockName", "blockType"], + "additionalProperties": false + }, + "description": "Blocks with no outgoing edge." + }, + "orphanBlocks": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + } + }, + "required": ["blockId", "blockName", "blockType"], + "additionalProperties": false + }, + "description": "Blocks with neither an incoming nor an outgoing edge." + }, + "emptyOutgoingPorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + }, + "handle": { + "type": "string", + "description": "Source handle with nothing connected to it." + }, + "label": { + "type": "string", + "description": "Human-readable name of the port." + } + }, + "required": ["blockId", "blockName", "blockType", "handle", "label"], + "additionalProperties": false + }, + "description": "Branch and container ports that lead nowhere." + }, + "invalidBranchPorts": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + }, + "sourceHandle": { + "type": "string", + "description": "Source handle that does not match the block." + }, + "reason": { + "type": "string", + "description": "Why the handle is not valid for this block." + } + }, + "required": ["blockId", "blockName", "blockType", "sourceHandle", "reason"], + "additionalProperties": false + }, + "description": "Condition and router edges whose source handle names no real branch." + }, + "invalidConnectionTargets": { + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceBlockId": { + "type": "string", + "description": "Block the edge leaves." + }, + "sourceBlockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the source block." + }, + "sourceHandle": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Handle the edge leaves from." + }, + "targetBlockId": { + "type": "string", + "description": "Block the edge points at." + }, + "reason": { + "type": "string", + "description": "Why the target is not a legal destination." + } + }, + "required": [ + "sourceBlockId", + "sourceBlockName", + "sourceHandle", + "targetBlockId", + "reason" + ], + "additionalProperties": false + }, + "description": "Edges pointing at a block that cannot legally receive them." + }, + "fieldIssues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + }, + "missingRequiredFields": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Required sub-block fields that resolve empty in the active mode." + }, + "inactiveModeValues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "canonicalId": { + "type": "string", + "description": "Canonical parameter the two sub-block modes share." + }, + "activeMemberId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Sub-block the runtime reads, where the value should live." + }, + "inactiveMemberId": { + "type": "string", + "description": "Sub-block holding the stranded value, which the runtime ignores." + }, + "kind": { + "type": "string", + "enum": ["credential", "resource", "other"], + "description": "What kind of value is stranded." + } + }, + "required": ["canonicalId", "activeMemberId", "inactiveMemberId", "kind"], + "additionalProperties": false + }, + "description": "Values stranded on the inactive member of a canonical pair." + } + }, + "required": [ + "blockId", + "blockName", + "blockType", + "missingRequiredFields", + "inactiveModeValues" + ], + "additionalProperties": false + }, + "description": "Per-block configuration problems. The most actionable part of the report for a headless graph builder: a block missing a required field will fail at run time." + }, + "unresolvedReferences": { + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block the finding is about." + }, + "blockName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Display name of the block, when it has one." + }, + "blockType": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Registered type of the block, when it has one." + }, + "field": { + "type": "string", + "description": "Sub-block field holding the reference." + }, + "value": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "array", + "items": { + "type": "string" + } + } + ], + "description": "The reference, or references, that did not resolve." + }, + "kind": { + "type": "string", + "enum": ["credential", "resource", "custom-tool", "mcp-tool", "skill"], + "description": "What kind of entity the reference was expected to name." + }, + "reason": { + "type": "string", + "description": "Why the reference does not resolve." + } + }, + "required": ["blockId", "blockName", "blockType", "field", "value", "kind", "reason"], + "additionalProperties": false + }, + "description": "Credential, resource, tool, and skill references that do not resolve. These values are still persisted; they are reported, not dropped." + }, + "notes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Advisory notes about the report itself." + } + }, + "required": [ + "sources", + "sinks", + "orphanBlocks", + "emptyOutgoingPorts", + "invalidBranchPorts", + "invalidConnectionTargets", + "fieldIssues", + "unresolvedReferences", + "notes" + ], + "additionalProperties": false, + "title": "Workflow lint report", + "description": "Advisory findings about the saved graph. Findings never block the write; they tell a caller what will misbehave at run time." + }, + "ReplaceWorkflowStateResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the workflow whose draft graph was written." + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal notes about blocks and edges that were normalized or dropped before persistence. Empty when there was nothing to report." + }, + "needsRedeployment": { + "type": "boolean", + "description": "Whether the live deployment now differs from the draft. A graph write never changes what the deployed endpoint serves; deploy to publish it." + }, + "lint": { + "$ref": "#/components/schemas/WorkflowLintReport" + }, + "dryRun": { + "type": "boolean", + "description": "Whether this request only validated. `true` means nothing was persisted; the findings describe what a committed write of the same body would produce." + } + }, + "required": ["id", "warnings", "needsRedeployment", "lint", "dryRun"], + "additionalProperties": false, + "title": "Replace workflow state result", + "description": "Outcome of replacing a workflow draft graph, with its advisory findings." + }, + "ReplaceWorkflowStateResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/ReplaceWorkflowStateResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Replace workflow state response", + "description": "Outcome of replacing a workflow draft graph.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "warnings": [], + "needsRedeployment": true, + "dryRun": false, + "lint": { + "sources": [], + "sinks": [], + "orphanBlocks": [], + "emptyOutgoingPorts": [], + "invalidBranchPorts": [], + "invalidConnectionTargets": [], + "fieldIssues": [], + "unresolvedReferences": [], + "notes": [] + } + } + } + ] + }, + "WorkflowBlockInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Block identifier, unique within the workflow." + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Registered block type." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Block display name; must be unique within the workflow." + }, + "position": { + "type": "object", + "properties": { + "x": { + "type": "number", + "description": "Canvas x coordinate." + }, + "y": { + "type": "number", + "description": "Canvas y coordinate." + } + }, + "required": ["x", "y"], + "description": "Canvas coordinates of a block." + }, + "subBlocks": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Sub-block identifier." + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Sub-block input type." + }, + "value": { + "description": "Configured value; shape depends on the sub-block type." + } + }, + "required": ["id", "type", "value"], + "description": "One configurable input on a block." + }, + "description": "Configured inputs keyed by sub-block id." + }, + "outputs": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Declared shape of one output; depends on the block type." + }, + "description": "Declared output shape keyed by output name." + }, + "enabled": { + "type": "boolean", + "description": "Whether the block runs." + }, + "horizontalHandles": { + "description": "Whether edge handles render horizontally.", + "type": "boolean" + }, + "height": { + "description": "Rendered block height.", + "type": "number" + }, + "advancedMode": { + "description": "Whether the block is edited in advanced mode.", + "type": "boolean" + }, + "errorEnabled": { + "description": "Whether the block exposes an error branch.", + "type": "boolean" + }, + "retry": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Whether the block retries on failure." + }, + "maxTries": { + "type": "integer", + "minimum": 2, + "maximum": 5, + "description": "Total attempts, including the first." + }, + "waitBetweenTriesMs": { + "type": "integer", + "minimum": 0, + "maximum": 5000, + "description": "Delay between attempts, in milliseconds." + } + }, + "required": ["enabled", "maxTries", "waitBetweenTriesMs"], + "description": "Per-block retry configuration." + }, + "triggerMode": { + "description": "Whether the block acts as the workflow trigger.", + "type": "boolean" + }, + "data": { + "type": "object", + "properties": { + "parentId": { + "description": "Identifier of the containing loop or parallel.", + "type": "string" + }, + "extent": { + "description": "Constrains the block to its parent bounds.", + "type": "string", + "const": "parent" + }, + "width": { + "description": "Rendered container width.", + "type": "number" + }, + "height": { + "description": "Rendered container height.", + "type": "number" + }, + "collection": { + "description": "Items a forEach loop or collection parallel iterates." + }, + "count": { + "description": "Iteration count for a `for` loop or count parallel.", + "type": "number" + }, + "loopType": { + "description": "Loop container kind.", + "type": "string", + "enum": ["for", "forEach", "while", "doWhile"] + }, + "whileCondition": { + "description": "Condition expression for a `while` loop.", + "type": "string" + }, + "doWhileCondition": { + "description": "Condition expression for a `doWhile` loop.", + "type": "string" + }, + "parallelType": { + "description": "Parallel container kind.", + "type": "string", + "enum": ["collection", "count"] + }, + "batchSize": { + "description": "Maximum concurrent branches of a parallel.", + "type": "number" + }, + "type": { + "description": "Container subtype.", + "type": "string" + }, + "canonicalModes": { + "description": "Per-field editing mode, keyed by canonical parameter id.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": ["basic", "advanced"] + } + } + }, + "description": "Container and layout metadata carried by a block." + }, + "locked": { + "description": "Whether the block is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "type", "name", "position", "subBlocks", "outputs", "enabled"], + "title": "Workflow block", + "description": "One node of a workflow graph and its configuration." + }, + "WorkflowEdgeInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Edge identifier, unique within the workflow." + }, + "source": { + "type": "string", + "minLength": 1, + "description": "Source block id." + }, + "target": { + "type": "string", + "minLength": 1, + "description": "Target block id." + }, + "sourceHandle": { + "description": "Source port, or null for the block default.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "targetHandle": { + "description": "Target port, or null for the block default.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "type": { + "description": "Edge renderer type.", + "type": "string" + } + }, + "required": ["id", "source", "target"], + "title": "Workflow edge", + "description": "A directed connection between two blocks." + }, + "WorkflowLoopInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Loop container identifier; equal to the loop block id." + }, + "nodes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Block ids inside the loop." + }, + "iterations": { + "type": "number", + "description": "Resolved iteration count." + }, + "loopType": { + "type": "string", + "enum": ["for", "forEach", "while", "doWhile"], + "description": "Loop kind." + }, + "forEachItems": { + "description": "Items a forEach loop iterates, or the expression producing them.", + "anyOf": [ + { + "type": "array", + "items": { + "description": "One item the loop iterates." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One item the loop iterates." + } + }, + { + "type": "string" + } + ] + }, + "whileCondition": { + "description": "Condition expression for a `while` loop.", + "type": "string" + }, + "doWhileCondition": { + "description": "Condition expression for a `doWhile` loop.", + "type": "string" + }, + "enabled": { + "description": "Whether the loop runs.", + "type": "boolean" + }, + "locked": { + "description": "Whether the loop is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "nodes", "iterations", "loopType"], + "title": "Workflow loop", + "description": "A loop container derived from the workflow blocks." + }, + "WorkflowParallelInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Parallel container identifier; equal to the parallel block id." + }, + "nodes": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Block ids inside the parallel." + }, + "distribution": { + "description": "Items distributed across branches, or the expression producing them.", + "anyOf": [ + { + "type": "array", + "items": { + "description": "One item distributed to a branch." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One item distributed to a branch." + } + }, + { + "type": "string" + } + ] + }, + "count": { + "description": "Fixed branch count.", + "type": "number" + }, + "parallelType": { + "description": "Parallel kind.", + "type": "string", + "enum": ["count", "collection"] + }, + "batchSize": { + "description": "Maximum concurrent branches.", + "type": "number" + }, + "enabled": { + "description": "Whether the parallel runs.", + "type": "boolean" + }, + "locked": { + "description": "Whether the parallel is locked against edits.", + "type": "boolean" + } + }, + "required": ["id", "nodes"], + "title": "Workflow parallel", + "description": "A parallel container derived from the workflow blocks." + }, + "WorkflowVariableInput": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Variable identifier." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Variable name, referenced from block inputs." + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "object", "array", "plain"], + "description": "Declared variable type." + }, + "value": { + "description": "Variable value; free-form and validated per `type` at use time." + } + }, + "required": ["id", "name", "type", "value"], + "title": "Workflow variable", + "description": "A workflow-scoped variable." + }, + "ReplaceWorkflowStateRequest": { + "type": "object", + "properties": { + "blocks": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowBlockInput" + }, + "description": "Blocks keyed by block id." + }, + "edges": { + "maxItems": 10000, + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowEdgeInput" + }, + "description": "Directed connections between blocks." + }, + "loops": { + "description": "Ignored on write: loop containers are recomputed from `blocks`.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowLoopInput" + } + }, + "parallels": { + "description": "Ignored on write: parallel containers are recomputed from `blocks`.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowParallelInput" + } + }, + "variables": { + "description": "Replacement variable set. Omit to leave the stored variables untouched.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/WorkflowVariableInput" + } + } + }, + "required": ["blocks", "edges"], + "additionalProperties": false, + "title": "Replace workflow state request", + "description": "A complete replacement draft graph for a workflow.", + "examples": [ + { + "blocks": {}, + "edges": [] + } + ] + }, + "WorkflowSkippedItem": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "block_not_found", + "invalid_block_type", + "block_not_allowed", + "model_not_allowed", + "block_locked", + "tool_not_allowed", + "invalid_edge_target", + "invalid_edge_source", + "invalid_edge_scope", + "invalid_source_handle", + "invalid_target_handle", + "invalid_subblock_field", + "missing_required_params", + "invalid_subflow_parent", + "nested_subflow_not_allowed", + "duplicate_block_name", + "reserved_block_name", + "retry_not_supported", + "duplicate_trigger", + "duplicate_single_instance_block", + "disabled_ancestor" + ], + "description": "Machine-readable reason the engine declined an operation." + }, + "operationType": { + "type": "string", + "description": "The `operation_type` that was declined." + }, + "blockId": { + "type": "string", + "description": "Block the declined operation targeted." + }, + "reason": { + "type": "string", + "description": "Human-readable explanation." + }, + "details": { + "description": "Additional context for the reason; keys depend on `type`.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One piece of engine-supplied context for the reason." + } + } + }, + "required": ["type", "operationType", "blockId", "reason"], + "additionalProperties": false, + "title": "Workflow skipped item", + "description": "One operation the edit engine did not apply." + }, + "WorkflowInputValidationError": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "description": "Block whose input was rejected." + }, + "blockType": { + "type": "string", + "description": "Type of the block whose input was rejected." + }, + "field": { + "type": "string", + "description": "Sub-block field that was rejected." + }, + "error": { + "type": "string", + "description": "Why the value was rejected." + } + }, + "required": ["blockId", "blockType", "field", "error"], + "additionalProperties": false, + "title": "Workflow input validation error", + "description": "One block input that was dropped rather than persisted." + }, + "ApplyWorkflowOperationsResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the workflow whose draft graph was written." + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal notes about blocks and edges that were normalized or dropped before persistence. Empty when there was nothing to report." + }, + "needsRedeployment": { + "type": "boolean", + "description": "Whether the live deployment now differs from the draft. A graph write never changes what the deployed endpoint serves; deploy to publish it." + }, + "applied": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Operations the engine applied." + }, + "skipped": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowSkippedItem" + }, + "description": "Operations the engine declined. Empty when everything applied." + }, + "deferred": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowSkippedItem" + }, + "description": "Edges waiting for target blocks. They apply automatically when their targets exist, in this batch or a later one. Do not resubmit them." + }, + "inputValidationErrors": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowInputValidationError" + }, + "description": "Block inputs that were dropped rather than persisted, and only those. The rest of the operation still applied. References that merely fail to resolve stay persisted and are reported in `lint.unresolvedReferences` instead." + }, + "mintedBlockIds": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "description": "The id the block was actually given." + }, + "description": "Minted block ids keyed by requested `block_id`, present only when they differ. References within this batch are remapped automatically; later requests must use the minted id. Supply a UUID when the requested id must survive unchanged." + }, + "lint": { + "$ref": "#/components/schemas/WorkflowLintReport" + }, + "dryRun": { + "type": "boolean", + "description": "Whether this request only evaluated. `true` means nothing was persisted; the outcome describes what a committed apply of the same body would produce." + } + }, + "required": [ + "id", + "warnings", + "needsRedeployment", + "applied", + "skipped", + "deferred", + "inputValidationErrors", + "mintedBlockIds", + "lint", + "dryRun" + ], + "additionalProperties": false, + "title": "Apply workflow operations result", + "description": "Outcome of a batch of semantic edits against a workflow graph." + }, + "ApplyWorkflowOperationsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/ApplyWorkflowOperationsResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Apply workflow operations response", + "description": "Outcome of a batch of semantic edits.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "applied": 1, + "skipped": [], + "deferred": [], + "inputValidationErrors": [], + "mintedBlockIds": { + "triage": "a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77" + }, + "lint": { + "sources": [], + "sinks": [], + "orphanBlocks": [], + "emptyOutgoingPorts": [], + "invalidBranchPorts": [], + "invalidConnectionTargets": [], + "fieldIssues": [ + { + "blockId": "agent-1", + "blockName": "Triage", + "blockType": "agent", + "missingRequiredFields": ["systemPrompt"], + "inactiveModeValues": [] + } + ], + "unresolvedReferences": [], + "notes": [] + }, + "warnings": [], + "needsRedeployment": true, + "dryRun": false + } + } + ] + }, + "WorkflowEditOperation": { + "oneOf": [ + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "add", + "description": "Create a new block." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "params": { + "type": "object", + "properties": { + "type": { + "type": "string", + "minLength": 1, + "description": "Registered block type." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Block display name." + }, + "inputs": { + "allOf": [ + { + "type": "object", + "properties": { + "tools": { + "description": "Agent tools configuration. Applies to a `tool-input` field; other block inputs remain catalog-defined.", + "$ref": "#/components/schemas/AgentToolInput" + } + }, + "additionalProperties": { + "description": "One block-specific input whose accepted shape is published by the block catalog." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One block-specific input whose accepted shape is published by the block catalog." + } + } + ], + "description": "Block configuration keyed by sub-block id." + } + }, + "required": ["type", "name"], + "additionalProperties": { + "description": "One block-specific input or connection descriptor." + }, + "description": "Block `type`, `name`, and optional `inputs`, `connections`, `retry`, `triggerMode`, or `advancedMode`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`." + } + }, + "required": ["operation_type", "block_id", "params"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "edit", + "description": "Change an existing block: its inputs, name, or connections." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "params": { + "allOf": [ + { + "type": "object", + "properties": { + "inputs": { + "allOf": [ + { + "type": "object", + "properties": { + "tools": { + "description": "Agent tools configuration. Applies to a `tool-input` field; other block inputs remain catalog-defined.", + "$ref": "#/components/schemas/AgentToolInput" + } + }, + "additionalProperties": { + "description": "One block-specific input whose accepted shape is published by the block catalog." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One block-specific input whose accepted shape is published by the block catalog." + } + } + ], + "description": "Block configuration keyed by sub-block id." + } + }, + "additionalProperties": { + "description": "One operation parameter; see the description for the accepted keys." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One operation parameter; see the description for the accepted keys." + } + } + ], + "description": "Patch only supplied fields: `inputs`, `name`, `connections`, `removeEdges`, `nestedNodes`, `retry`, `triggerMode`, and `advancedMode`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`. Re-sending `connections` replaces outgoing edges; use `removeEdges` to delete selected edges." + } + }, + "required": ["operation_type", "block_id", "params"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "delete", + "description": "Remove a block and every edge touching it." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + } + }, + "required": ["operation_type", "block_id"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "insert_into_subflow", + "description": "Create a block inside a loop or parallel container." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "params": { + "type": "object", + "properties": { + "subflowId": { + "type": "string", + "minLength": 1, + "description": "Loop or parallel container to insert the block into." + }, + "type": { + "type": "string", + "minLength": 1, + "description": "Registered block type." + }, + "name": { + "type": "string", + "minLength": 1, + "description": "Block display name." + }, + "inputs": { + "allOf": [ + { + "type": "object", + "properties": { + "tools": { + "description": "Agent tools configuration. Applies to a `tool-input` field; other block inputs remain catalog-defined.", + "$ref": "#/components/schemas/AgentToolInput" + } + }, + "additionalProperties": { + "description": "One block-specific input whose accepted shape is published by the block catalog." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One block-specific input whose accepted shape is published by the block catalog." + } + } + ], + "description": "Block configuration keyed by sub-block id." + } + }, + "required": ["subflowId", "type", "name"], + "additionalProperties": { + "description": "One block-specific input or connection descriptor." + }, + "description": "Container, block `type`, `name`, and the same optional fields as `add`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`." + } + }, + "required": ["operation_type", "block_id", "params"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation_type": { + "type": "string", + "const": "extract_from_subflow", + "description": "Move a block out of its loop or parallel container." + }, + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "params": { + "type": "object", + "properties": { + "subflowId": { + "type": "string", + "minLength": 1, + "description": "Loop or parallel container the block moves into or out of." + } + }, + "required": ["subflowId"], + "additionalProperties": { + "description": "One block-specific input." + }, + "description": "Container identifier, plus any block-specific inputs." + } + }, + "required": ["operation_type", "block_id", "params"], + "additionalProperties": false + } + ], + "title": "Workflow edit operation", + "description": "One semantic edit against a workflow graph." + }, + "AgentToolInput": { + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/components/schemas/AgentTool" + }, + "description": "The complete value stored in an Agent block’s `tools` input.", + "title": "Agent tools input" + }, + "AgentTool": { + "oneOf": [ + { + "$ref": "#/components/schemas/AgentIntegrationTool" + }, + { + "$ref": "#/components/schemas/AgentCustomTool" + }, + { + "$ref": "#/components/schemas/AgentMcpTool" + }, + { + "$ref": "#/components/schemas/AgentMcpServerAdvanced" + } + ], + "title": "Agent tool", + "description": "A catalog integration operation, workspace custom tool, or MCP tool available to an Agent." + }, + "AgentIntegrationTool": { + "type": "object", + "properties": { + "type": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "pattern": "^(?!(?:custom-tool|mcp|mcp-server-advanced)$).+$", + "description": "Catalog block id, such as `cloudwatch` or `slack`. Use the block id, never an underlying tool id." + }, + "operation": { + "description": "Operation ID from Get Block. Required when the block exposes multiple operations; it may differ from the tool ID.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "usageControl": { + "type": "string", + "enum": ["auto", "force", "none"], + "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + }, + "params": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One tool parameter value." + }, + "description": "Parameters fixed by the workflow author. Parameters left out remain available for the model to supply when the tool declares them." + } + }, + "required": ["type"], + "additionalProperties": { + "description": "Forward-compatible integration tool metadata preserved by the workflow editor." + }, + "title": "Agent integration tool", + "description": "A catalog integration operation the Agent may call. Resolve valid block and operation ids through the block catalog.", + "examples": [ + { + "type": "cloudwatch", + "operation": "describe_alarm_history", + "usageControl": "auto", + "params": {} + } + ] + }, + "AgentCustomTool": { + "anyOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "custom-tool", + "description": "Custom-tool discriminator." + }, + "customToolId": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Custom tool ID from List Custom Tools." + }, + "usageControl": { + "type": "string", + "enum": ["auto", "force", "none"], + "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + } + }, + "required": ["type", "customToolId"], + "additionalProperties": { + "description": "Forward-compatible custom tool metadata preserved by the workflow editor." + } + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "custom-tool", + "description": "Custom-tool discriminator." + }, + "schema": { + "type": "object", + "properties": { + "type": { + "description": "Function declaration discriminator.", + "type": "string", + "const": "function" + }, + "function": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 64, + "description": "Function name presented to the model." + }, + "description": { + "description": "What the inline custom tool does.", + "type": "string" + }, + "parameters": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One JSON Schema keyword on the function parameters." + }, + "description": "JSON Schema describing the function arguments." + } + }, + "required": ["name", "parameters"], + "additionalProperties": { + "description": "Additional function declaration metadata." + }, + "description": "OpenAI-style function definition." + } + }, + "required": ["function"], + "additionalProperties": { + "description": "Additional custom tool declaration metadata." + }, + "description": "Inline OpenAI-style function declaration." + }, + "code": { + "type": "string", + "description": "Inline tool implementation executed by the Function runtime." + }, + "usageControl": { + "type": "string", + "enum": ["auto", "force", "none"], + "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + } + }, + "required": ["type", "schema", "code"], + "additionalProperties": { + "description": "Forward-compatible custom tool metadata preserved by the workflow editor." + } + } + ], + "title": "Agent custom tool", + "description": "A workspace custom tool. Prefer `customToolId`; inline declarations are also accepted.", + "examples": [ + { + "type": "custom-tool", + "customToolId": "cst_01J9X2ABCDEF", + "usageControl": "auto" + } + ] + }, + "AgentMcpTool": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "mcp", + "description": "MCP-tool discriminator." + }, + "params": { + "allOf": [ + { + "type": "object", + "properties": { + "serverId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "MCP server id returned by `GET /api/v2/mcp-servers`." + }, + "toolName": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Tool name returned by the MCP server’s tools endpoint." + } + }, + "required": ["serverId", "toolName"], + "additionalProperties": { + "description": "One parameter fixed by the workflow author." + } + }, + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "One parameter fixed by the workflow author." + } + } + ], + "description": "MCP server and tool identity plus any tool arguments fixed by the workflow author." + }, + "usageControl": { + "type": "string", + "enum": ["auto", "force", "none"], + "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + } + }, + "required": ["type", "params"], + "additionalProperties": { + "description": "Forward-compatible MCP tool metadata preserved by the workflow editor." + }, + "title": "Agent MCP tool", + "description": "One tool discovered from a workspace MCP server.", + "examples": [ + { + "type": "mcp", + "params": { + "serverId": "mcp_01J9X2ABCDEF", + "toolName": "search_docs" + }, + "usageControl": "auto" + } + ] + }, + "AgentMcpServerAdvanced": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "mcp-server-advanced", + "description": "Server-wide MCP binding discriminator." + }, + "operationPolicy": { + "oneOf": [ + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "all", + "description": "Allow all operations available to the authorized credential." + } + }, + "required": ["mode"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "allow", + "description": "Allow only the selected exact operations." + }, + "operations": { + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Exact MCP tool name on the resolved connection, without a Sim server prefix." + }, + "description": "Allowed exact MCP tool names; an empty list grants no access." + } + }, + "required": ["mode", "operations"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "mode": { + "type": "string", + "const": "deny", + "description": "Exclude the selected exact operations." + }, + "operations": { + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Exact MCP tool name on the resolved connection, without a Sim server prefix." + }, + "description": "Denied exact MCP tool names; an empty list allows otherwise permitted tools." + } + }, + "required": ["mode", "operations"], + "additionalProperties": false + } + ], + "description": "Saved workflow operation restrictions that can only narrow authorized credential access." + }, + "params": { + "type": "object", + "properties": { + "serverId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace MCP server ID or explicit credential-group managed MCP connection ID." + } + }, + "required": ["serverId"], + "additionalProperties": false, + "description": "Executable server or connection identity for authorized operation discovery and execution." + }, + "usageControl": { + "type": "string", + "enum": ["auto", "force", "none"], + "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + } + }, + "required": ["type", "params"], + "additionalProperties": { + "description": "Forward-compatible MCP server metadata preserved by the workflow editor." + }, + "title": "Agent MCP server (advanced)", + "description": "Dynamically discovered operations permitted by the authorized credential and saved block policy.", + "examples": [ + { + "type": "mcp-server-advanced", + "params": { + "serverId": "mcp_01J9X2ABCDEF" + }, + "usageControl": "auto" + } + ] + }, + "ApplyWorkflowOperationsRequest": { + "type": "object", + "properties": { + "operations": { + "minItems": 1, + "maxItems": 200, + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowEditOperation" + }, + "description": "Edits to apply, in a single batch." + }, + "atomic": { + "default": false, + "description": "Fail the whole batch when any operation is declined or any block input would be dropped. The default applies what it can and reports the rest in `skipped` and `inputValidationErrors`; `true` writes nothing and answers `409` instead.", + "type": "boolean" + }, + "layout": { + "default": "targeted", + "description": "Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied.", + "type": "string", + "enum": ["targeted", "none"] + }, + "setBlockEnabled": { + "description": "Blocks to enable or disable, applied after `operations`. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined.", + "maxItems": 200, + "type": "array", + "items": { + "type": "object", + "properties": { + "block_id": { + "type": "string", + "minLength": 1, + "description": "Block the operation targets. For `add`, the id the new block will be given." + }, + "enabled": { + "type": "boolean", + "description": "Whether the block should run." + } + }, + "required": ["block_id", "enabled"], + "additionalProperties": false + } + } + }, + "required": ["operations"], + "additionalProperties": false, + "title": "Apply workflow operations request", + "description": "A batch of semantic edits against a workflow graph.", + "examples": [ + { + "operations": [ + { + "operation_type": "add", + "block_id": "agent-1", + "params": { + "type": "agent", + "name": "Triage", + "inputs": { + "tools": [ + { + "type": "cloudwatch", + "operation": "describe_alarm_history", + "usageControl": "auto", + "params": {} + } + ] + } + } + } + ] + } + ] + }, + "ApplyWorkflowVariablesResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the workflow whose variables were updated." + }, + "variableCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Variables the workflow now holds." + }, + "changed": { + "type": "boolean", + "description": "Whether anything actually changed. A no-op batch answers `200` with `false`." + } + }, + "required": ["id", "variableCount", "changed"], + "additionalProperties": false, + "title": "Apply workflow variables result", + "description": "Outcome of a workflow variable update." + }, + "ApplyWorkflowVariablesResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/ApplyWorkflowVariablesResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Apply workflow variables response", + "description": "Outcome of a workflow variable update.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "variableCount": 3, + "changed": true + } + } + ] + }, + "ApplyWorkflowVariablesRequest": { + "type": "object", + "properties": { + "operations": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "oneOf": [ + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "const": "add", + "description": "Create a variable with this name." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Variable name." + }, + "type": { + "type": "string", + "enum": ["string", "number", "boolean", "object", "array", "plain"], + "description": "Declared variable type." + }, + "value": { + "description": "Variable value, coerced to `type`." + } + }, + "required": ["operation", "name", "type", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "const": "edit", + "description": "Replace the value, and optionally the type, of an existing variable." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name of the variable to update." + }, + "type": { + "description": "Replacement type; the stored type is kept when omitted.", + "type": "string", + "enum": ["string", "number", "boolean", "object", "array", "plain"] + }, + "value": { + "description": "Replacement value, coerced to the effective type." + } + }, + "required": ["operation", "name", "value"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "operation": { + "type": "string", + "const": "delete", + "description": "Remove the variable with this name." + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Name of the variable to remove." + } + }, + "required": ["operation", "name"], + "additionalProperties": false + } + ], + "description": "One variable change." + }, + "description": "Variable changes to apply, in order." + } + }, + "required": ["operations"], + "additionalProperties": false, + "title": "Apply workflow variables request", + "description": "Additions, edits, and deletions against a workflow’s variables." + }, + "DuplicateWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowListItem" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Duplicate workflow response", + "description": "The created copy.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage (copy)", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": false, + "deployedAt": null, + "runCount": 0, + "lastRunAt": null, + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z" + } + } + ] + }, + "DuplicateWorkflowRequest": { + "type": "object", + "properties": { + "name": { + "description": "Name for the copy. Defaults to the source name, deduplicated within the folder.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "folderPath": { + "description": "Destination folder path. Defaults to the source workflow's folder.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "additionalProperties": false, + "title": "Duplicate workflow request", + "description": "Optional name and destination folder for the copy." + }, + "RestoreWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowListItem" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Restore workflow response", + "description": "The restored workflow.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 42, + "lastRunAt": "2026-08-09T18:04:11.000Z", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z" + } + } + ] + }, + "MoveWorkflowsResult": { + "type": "object", + "properties": { + "moved": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workflows that were relocated." + }, + "failed": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Workflows that were not relocated — absent from the workspace, archived, or locked. Best-effort by design: the rest of the batch still moved." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical destination folder path.", + "maxLength": 4096 + } + }, + "required": ["moved", "failed", "folderPath"], + "additionalProperties": false, + "title": "Move workflows result", + "description": "Which workflows moved and which did not." + }, + "MoveWorkflowsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/MoveWorkflowsResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Move workflows response", + "description": "Which workflows moved and which did not.", + "examples": [ + { + "data": { + "moved": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"], + "failed": [], + "folderPath": "/Operations" + } + } + ] + }, + "MoveWorkflowsRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace holding every workflow in the batch." + }, + "workflowIds": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Workflows to move. Duplicates are collapsed." + }, + "folderPath": { + "description": "Destination folder path; `/` moves the workflows to the workspace root.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "required": ["workspaceId", "workflowIds", "folderPath"], + "additionalProperties": false, + "title": "Move workflows request", + "description": "Workflows to relocate and the folder to relocate them into." + }, + "WorkflowInputField": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "Input field name." + }, + "type": { + "type": "string", + "description": "Input field type." + }, + "description": { + "description": "Optional input field description.", + "type": "string" + } + }, + "required": ["name", "type"], + "additionalProperties": false, + "title": "Workflow input field", + "description": "A deployed API trigger input exposed by a workflow." + }, + "WorkflowDetail": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + }, + "webUrl": { + "type": "string", + "format": "uri", + "description": "Canonical absolute URL for opening this resource in the Sim web application." + }, + "name": { + "type": "string", + "description": "Workflow name.", + "examples": ["Customer support triage"] + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Workflow description, or null when none is set." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096, + "examples": ["/Operations"] + }, + "workspaceId": { + "type": "string", + "description": "Workspace that owns the workflow." + }, + "isDeployed": { + "type": "boolean", + "description": "Whether the workflow has an active deployment." + }, + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 activation timestamp, or null when not deployed.", + "format": "date-time" + }, + "runCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." + }, + "lastRunAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", + "format": "date-time" + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was last updated.", + "format": "date-time" + }, + "variables": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Structured workflow variable value." + }, + "description": "Workflow-scoped variables keyed by variable identifier." + }, + "inputs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowInputField" + }, + "description": "Input fields exposed by the workflow API trigger." + } + }, + "required": [ + "id", + "webUrl", + "name", + "description", + "folderPath", + "workspaceId", + "isDeployed", + "deployedAt", + "runCount", + "lastRunAt", + "createdAt", + "updatedAt", + "variables", + "inputs" + ], + "additionalProperties": false, + "title": "Workflow detail", + "description": "Full workflow summary with variables and API-trigger input fields." + }, + "WorkflowDetailResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowDetail" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Workflow detail response", + "description": "Detailed workflow metadata, variables, and trigger inputs.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 42, + "lastRunAt": "2026-08-09T18:04:11.000Z", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z", + "variables": {}, + "inputs": [] + } + } + ] + }, + "UpdateWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowListItem" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update workflow response", + "description": "The updated workflow summary.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "folderPath": "/Operations", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isDeployed": true, + "deployedAt": "2026-06-12T10:30:00.000Z", + "runCount": 42, + "lastRunAt": "2026-08-09T18:04:11.000Z", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z" + } + } + ] + }, + "UpdateWorkflowRequest": { + "type": "object", + "properties": { + "name": { + "description": "Replacement workflow name.", + "type": "string", + "minLength": 1, + "maxLength": 255 + }, + "description": { + "description": "Replacement workflow description; null clears it.", + "anyOf": [ + { + "type": "string", + "maxLength": 50000 + }, + { + "type": "null" + } + ] + }, + "folderPath": { + "description": "Destination folder path; `/` moves the workflow to the workspace root.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "additionalProperties": false, + "title": "Update workflow request", + "description": "Fields to update on an existing workflow." + }, + "DeleteWorkflowResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the archived workflow." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Confirms that the workflow is no longer live." + }, + "archived": { + "type": "boolean", + "const": true, + "description": "Whether the workflow was archived. Restore Workflow recovers it and the schedules, webhooks, MCP tools, and chats archived with it." + } + }, + "required": ["id", "deleted", "archived"], + "additionalProperties": false, + "title": "Delete workflow result", + "description": "Confirmation that a workflow was archived." + }, + "DeleteWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/DeleteWorkflowResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete workflow response", + "description": "Confirmation that the workflow was archived.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "deleted": true, + "archived": true + } + } + ] + }, + "WorkflowVersion": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique deployment-version identifier." + }, + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Monotonically increasing deployment version number." + }, + "name": { + "description": "Optional deployment-version label.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "description": { + "description": "Optional deployment-version release note.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "isActive": { + "type": "boolean", + "description": "Whether this version is currently serving executions." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when this version was created.", + "format": "date-time" + }, + "deployedBy": { + "description": "Display name of the user who created the deployment, when available.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "latestOperationStatus": { + "description": "Latest lifecycle-operation status for this version.", + "anyOf": [ + { + "type": "string", + "enum": ["preparing", "activating", "active", "failed", "superseded"] + }, + { + "type": "null" + } + ] + } + }, + "required": ["id", "version", "isActive", "createdAt"], + "additionalProperties": false, + "title": "Workflow version", + "description": "A saved deployment version of a workflow." + }, + "WorkflowVersionListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowVersion" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Workflow version list response", + "description": "A cursor-paginated page of deployment versions.", + "examples": [ + { + "data": [ + { + "id": "version_3", + "version": 3, + "name": "Escalation routing", + "description": "Adds the priority escalation branch.", + "isActive": true, + "createdAt": "2026-06-12T10:30:00.000Z", + "deployedBy": "Jane Smith", + "latestOperationStatus": "active" + } + ], + "nextCursor": null + } + ] + }, + "DeployedWorkflowState": { + "title": "Deployed workflow state", + "description": "Workflow graph snapshot pinned by a deployment version.", + "type": "object", + "additionalProperties": true + }, + "WorkflowVersionDetail": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique deployment-version identifier." + }, + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Monotonically increasing deployment version number." + }, + "name": { + "anyOf": [ + { + "type": "string" }, - "required": [ - "blockId", - "blockName", - "blockType", - "missingRequiredFields", - "inactiveModeValues" - ], - "additionalProperties": false - }, - "description": "Per-block configuration problems. The most actionable part of the report for a headless graph builder: a block missing a required field will fail at run time." + { + "type": "null" + } + ], + "description": "Version label, or null when unset." }, - "unresolvedReferences": { - "type": "array", - "items": { - "type": "object", - "properties": { - "blockId": { - "type": "string", - "description": "Block the finding is about." - }, - "blockName": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Display name of the block, when it has one." - }, - "blockType": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Registered type of the block, when it has one." - }, - "field": { - "type": "string", - "description": "Sub-block field holding the reference." - }, - "value": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "array", - "items": { - "type": "string" - } - } - ], - "description": "The reference, or references, that did not resolve." - }, - "kind": { - "type": "string", - "enum": ["credential", "resource", "custom-tool", "mcp-tool", "skill"], - "description": "What kind of entity the reference was expected to name." - }, - "reason": { - "type": "string", - "description": "Why the reference does not resolve." - } + "description": { + "anyOf": [ + { + "type": "string" }, - "required": ["blockId", "blockName", "blockType", "field", "value", "kind", "reason"], - "additionalProperties": false - }, - "description": "Credential, resource, tool, and skill references that do not resolve. These values are still persisted; they are reported, not dropped." + { + "type": "null" + } + ], + "description": "Version release note, or null when unset." + }, + "isActive": { + "type": "boolean", + "description": "Whether this version is currently serving executions." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when this version was created.", + "format": "date-time" + }, + "state": { + "description": "Workflow graph saved with this deployment version. Sensitive values are redacted to null.", + "$ref": "#/components/schemas/DeployedWorkflowState" + } + }, + "required": ["id", "version", "name", "description", "isActive", "createdAt", "state"], + "additionalProperties": false, + "title": "Workflow version detail", + "description": "A deployment version together with the workflow state it pins." + }, + "WorkflowVersionDetailResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowVersionDetail" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Workflow version detail response", + "description": "The deployment version and its pinned workflow graph.", + "examples": [ + { + "data": { + "id": "version_3", + "version": 3, + "name": "Escalation routing", + "description": "Adds the priority escalation branch.", + "isActive": true, + "createdAt": "2026-06-12T10:30:00.000Z", + "state": { + "blocks": {}, + "edges": [] + } + } + } + ] + }, + "WorkflowVersionMetadata": { + "type": "object", + "properties": { + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Monotonically increasing deployment version number." + }, + "name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Version label, or null when unset." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Version release note, or null when unset." + } + }, + "required": ["version", "name", "description"], + "additionalProperties": false, + "title": "Workflow version metadata", + "description": "Mutable label and release note of a deployment version." + }, + "UpdateWorkflowVersionResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowVersionMetadata" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update workflow version response", + "description": "The deployment version metadata after the update.", + "examples": [ + { + "data": { + "version": 3, + "name": "Escalation routing", + "description": "Adds the priority escalation branch." + } + } + ] + }, + "UpdateWorkflowVersionRequest": { + "type": "object", + "properties": { + "name": { + "description": "New label for the deployment version.", + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "description": { + "description": "New release note for the deployment version, or null to clear it.", + "anyOf": [ + { + "type": "string", + "maxLength": 50000 + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "title": "Update workflow version request", + "description": "Merge-patch body for the mutable metadata of a deployment version.", + "examples": [ + { + "name": "Escalation routing", + "description": "Adds the priority escalation branch." + } + ] + }, + "ActiveDeploymentSummary": { + "type": "object", + "properties": { + "deploymentVersionId": { + "type": "string", + "description": "Identifier of the active deployment version." }, - "notes": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Advisory notes about the report itself." + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Numeric active deployment version." + }, + "deployedAt": { + "type": "string", + "description": "ISO 8601 timestamp when this version became active.", + "format": "date-time" } }, - "required": [ - "sources", - "sinks", - "orphanBlocks", - "emptyOutgoingPorts", - "invalidBranchPorts", - "invalidConnectionTargets", - "fieldIssues", - "unresolvedReferences", - "notes" - ], + "required": ["deploymentVersionId", "version", "deployedAt"], "additionalProperties": false, - "title": "Workflow lint report", - "description": "Advisory findings about the saved graph. Findings never block the write; they tell a caller what will misbehave at run time." + "title": "Active deployment", + "description": "Summary of the workflow version currently serving API executions." }, - "ReplaceWorkflowStateResult": { + "DeploymentOperationSummary": { "type": "object", "properties": { "id": { "type": "string", - "description": "Identifier of the workflow whose draft graph was written." + "description": "Unique deployment operation identifier." }, - "warnings": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Non-fatal notes about blocks and edges that were normalized or dropped before persistence. Empty when there was nothing to report." + "deploymentVersionId": { + "type": "string", + "description": "Deployment version targeted by this operation." }, - "needsRedeployment": { - "type": "boolean", - "description": "Whether the live deployment now differs from the draft. A graph write never changes what the deployed endpoint serves; deploy to publish it." + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Numeric deployment version." }, - "lint": { - "$ref": "#/components/schemas/WorkflowLintReport" + "action": { + "type": "string", + "enum": ["deploy", "activate"], + "description": "Operation being performed on the deployment version." }, - "dryRun": { - "type": "boolean", - "description": "Whether this request only validated. `true` means nothing was persisted; the findings describe what a committed write of the same body would produce." + "status": { + "type": "string", + "enum": ["preparing", "activating", "active", "failed", "superseded"], + "description": "Current deployment lifecycle status." + }, + "isCurrent": { + "default": true, + "description": "Whether this operation still describes the current deployment attempt.", + "type": "boolean" + }, + "readiness": { + "$ref": "#/components/schemas/DeploymentReadiness" + }, + "requestedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the deployment operation was requested.", + "format": "date-time" + }, + "activatedAt": { + "description": "ISO 8601 activation timestamp, or null before activation completes.", + "format": "date-time", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error": { + "description": "Deployment failure details, or null when no failure occurred.", + "anyOf": [ + { + "$ref": "#/components/schemas/DeploymentOperationError" + }, + { + "type": "null" + } + ] } }, - "required": ["id", "warnings", "needsRedeployment", "lint", "dryRun"], + "required": [ + "id", + "deploymentVersionId", + "version", + "action", + "status", + "isCurrent", + "readiness", + "requestedAt" + ], "additionalProperties": false, - "title": "Replace workflow state result", - "description": "Outcome of replacing a workflow draft graph, with its advisory findings." + "title": "Deployment operation", + "description": "Lifecycle state of a deployment or version-activation attempt." }, - "ReplaceWorkflowStateResponse": { + "DeploymentReadiness": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/ReplaceWorkflowStateResult" + "webhooks": { + "type": "string", + "enum": ["pending", "ready", "not_applicable"], + "description": "Webhook synchronization readiness." + }, + "schedules": { + "type": "string", + "enum": ["pending", "ready", "not_applicable"], + "description": "Schedule synchronization readiness." + }, + "mcp": { + "type": "string", + "enum": ["pending", "ready", "not_applicable"], + "description": "MCP synchronization readiness." } }, - "required": ["data"], + "required": ["webhooks", "schedules", "mcp"], "additionalProperties": false, - "title": "Replace workflow state response", - "description": "Outcome of replacing a workflow draft graph.", - "examples": [ - { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "warnings": [], - "needsRedeployment": true, - "dryRun": false, - "lint": { - "sources": [], - "sinks": [], - "orphanBlocks": [], - "emptyOutgoingPorts": [], - "invalidBranchPorts": [], - "invalidConnectionTargets": [], - "fieldIssues": [], - "unresolvedReferences": [], - "notes": [] - } - } - } - ] + "title": "Deployment readiness", + "description": "Readiness of the side effects required to activate a deployment." }, - "WorkflowBlockInput": { + "DeploymentOperationError": { "type": "object", "properties": { - "id": { + "code": { "type": "string", - "minLength": 1, - "description": "Block identifier, unique within the workflow." + "description": "Stable deployment failure code." }, - "type": { + "message": { "type": "string", - "minLength": 1, - "description": "Registered block type." + "description": "Human-readable deployment failure message." }, - "name": { + "retryable": { + "type": "boolean", + "description": "Whether retrying the deployment may succeed." + } + }, + "required": ["code", "message", "retryable"], + "additionalProperties": false, + "title": "Deployment operation error", + "description": "Failure details for a deployment lifecycle operation." + }, + "VersionActivationResult": { + "title": "Version activation result", + "description": "Activation attempt accepted for processing. Activation is asynchronous; inspect `isDeployed` and `latestDeploymentAttempt` for current state.", + "$ref": "#/components/schemas/RollbackResult" + }, + "RollbackResult": { + "type": "object", + "properties": { + "id": { "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Block display name; must be unique within the workflow." - }, - "position": { - "type": "object", - "properties": { - "x": { - "type": "number", - "description": "Canvas x coordinate." - }, - "y": { - "type": "number", - "description": "Canvas y coordinate." - } - }, - "required": ["x", "y"], - "description": "Canvas coordinates of a block." - }, - "subBlocks": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "object", - "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Sub-block identifier." - }, - "type": { - "type": "string", - "minLength": 1, - "description": "Sub-block input type." - }, - "value": { - "description": "Configured value; shape depends on the sub-block type." - } - }, - "required": ["id", "type", "value"], - "description": "One configurable input on a block." - }, - "description": "Configured inputs keyed by sub-block id." - }, - "outputs": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Declared shape of one output; depends on the block type." - }, - "description": "Declared output shape keyed by output name." + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] }, - "enabled": { + "isDeployed": { "type": "boolean", - "description": "Whether the block runs." - }, - "horizontalHandles": { - "description": "Whether edge handles render horizontally.", - "type": "boolean" - }, - "height": { - "description": "Rendered block height.", - "type": "number" + "description": "Whether a workflow version is currently live and available for API execution." }, - "advancedMode": { - "description": "Whether the block is edited in advanced mode.", - "type": "boolean" + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", + "format": "date-time", + "examples": ["2026-06-12T10:30:00.000Z"] }, - "errorEnabled": { - "description": "Whether the block exposes an error branch.", - "type": "boolean" + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." }, - "retry": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Whether the block retries on failure." - }, - "maxTries": { - "type": "integer", - "minimum": 2, - "maximum": 5, - "description": "Total attempts, including the first." + "activeDeployment": { + "anyOf": [ + { + "$ref": "#/components/schemas/ActiveDeploymentSummary" }, - "waitBetweenTriesMs": { - "type": "integer", - "minimum": 0, - "maximum": 5000, - "description": "Delay between attempts, in milliseconds." + { + "type": "null" } - }, - "required": ["enabled", "maxTries", "waitBetweenTriesMs"], - "description": "Per-block retry configuration." + ], + "description": "Currently live deployment version, or null while no version is active." }, - "triggerMode": { - "description": "Whether the block acts as the workflow trigger.", - "type": "boolean" + "latestDeploymentAttempt": { + "anyOf": [ + { + "$ref": "#/components/schemas/DeploymentOperationSummary" + }, + { + "type": "null" + } + ], + "description": "Most recent deployment lifecycle attempt, or null when none is available." }, + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Deployment version selected for re-activation." + } + }, + "required": [ + "id", + "isDeployed", + "deployedAt", + "warnings", + "activeDeployment", + "latestDeploymentAttempt", + "version" + ], + "additionalProperties": false, + "title": "Rollback result", + "description": "Rollback attempt accepted for processing. Activation is asynchronous; inspect `isDeployed` and `latestDeploymentAttempt` for current state." + }, + "ActivateWorkflowVersionResponse": { + "type": "object", + "properties": { "data": { "type": "object", "properties": { - "parentId": { - "description": "Identifier of the containing loop or parallel.", - "type": "string" - }, - "extent": { - "description": "Constrains the block to its parent bounds.", + "id": { "type": "string", - "const": "parent" - }, - "width": { - "description": "Rendered container width.", - "type": "number" - }, - "height": { - "description": "Rendered container height.", - "type": "number" - }, - "collection": { - "description": "Items a forEach loop or collection parallel iterates." + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] }, - "count": { - "description": "Iteration count for a `for` loop or count parallel.", - "type": "number" + "isDeployed": { + "type": "boolean", + "description": "Whether a workflow version is currently live and available for API execution." }, - "loopType": { - "description": "Loop container kind.", - "type": "string", - "enum": ["for", "forEach", "while", "doWhile"] + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", + "format": "date-time", + "examples": ["2026-06-12T10:30:00.000Z"] }, - "whileCondition": { - "description": "Condition expression for a `while` loop.", - "type": "string" + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." }, - "doWhileCondition": { - "description": "Condition expression for a `doWhile` loop.", - "type": "string" + "activeDeployment": { + "anyOf": [ + { + "$ref": "#/components/schemas/ActiveDeploymentSummary" + }, + { + "type": "null" + } + ], + "description": "Currently live deployment version, or null while no version is active." }, - "parallelType": { - "description": "Parallel container kind.", - "type": "string", - "enum": ["collection", "count"] + "latestDeploymentAttempt": { + "anyOf": [ + { + "$ref": "#/components/schemas/DeploymentOperationSummary" + }, + { + "type": "null" + } + ], + "description": "Most recent deployment lifecycle attempt, or null when none is available." }, - "batchSize": { - "description": "Maximum concurrent branches of a parallel.", - "type": "number" + "version": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Deployment version selected for re-activation." + } + }, + "required": [ + "id", + "isDeployed", + "deployedAt", + "warnings", + "activeDeployment", + "latestDeploymentAttempt", + "version" + ], + "additionalProperties": false, + "description": "Response data.", + "$ref": "#/components/schemas/VersionActivationResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Activate workflow version response", + "description": "Current deployment state after accepting the activation attempt.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": false, + "deployedAt": null, + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": { + "id": "depop_01J8ZK4RX5N7Y3S0U8D6E1W2", + "deploymentVersionId": "depver_01J8ZK4RX5N7Y3S0U8D6E1W3", + "version": 3, + "action": "activate", + "status": "activating", + "isCurrent": true, + "readiness": { + "webhooks": "ready", + "schedules": "ready", + "mcp": "not_applicable" + }, + "requestedAt": "2026-06-12T10:30:00.000Z", + "activatedAt": null, + "error": null }, - "type": { - "description": "Container subtype.", - "type": "string" + "version": 3 + } + } + ] + }, + "ActivateWorkflowVersionRequest": { + "default": {}, + "title": "Activate workflow version request", + "description": "No body. The version to promote is named by the request path.", + "examples": [{}], + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "RevertWorkflowVersionResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier." + }, + "version": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 }, - "canonicalModes": { - "description": "Per-field editing mode, keyed by canonical parameter id.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "enum": ["basic", "advanced"] - } + { + "type": "string", + "const": "active" } - }, - "description": "Container and layout metadata carried by a block." + ], + "description": "Deployment version loaded into the draft, or `active` for the live version." }, - "locked": { - "description": "Whether the block is locked against edits.", - "type": "boolean" + "lastSaved": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Epoch milliseconds at which the overwritten draft was saved." } }, - "required": ["id", "type", "name", "position", "subBlocks", "outputs", "enabled"], - "title": "Workflow block", - "description": "One node of a workflow graph and its configuration." + "required": ["id", "version", "lastSaved"], + "additionalProperties": false, + "title": "Revert workflow version result", + "description": "The draft after it was overwritten by a deployment version." }, - "WorkflowEdgeInput": { + "RevertWorkflowVersionResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/RevertWorkflowVersionResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Revert workflow version response", + "description": "The draft after it was overwritten by the deployment version.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "version": 3, + "lastSaved": 1765535400000 + } + } + ] + }, + "RevertWorkflowVersionRequest": { + "default": {}, + "title": "Revert workflow version request", + "description": "No body. The version to load into the draft is named by the request path.", + "examples": [{}], + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "WorkflowDeployment": { "type": "object", "properties": { "id": { "type": "string", - "minLength": 1, - "description": "Edge identifier, unique within the workflow." - }, - "source": { - "type": "string", - "minLength": 1, - "description": "Source block id." + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] }, - "target": { - "type": "string", - "minLength": 1, - "description": "Target block id." + "isDeployed": { + "type": "boolean", + "description": "Whether a workflow version is currently live and available for API execution." }, - "sourceHandle": { - "description": "Source port, or null for the block default.", + "deployedAt": { "anyOf": [ { "type": "string" @@ -5635,1800 +9678,2111 @@ { "type": "null" } - ] + ], + "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", + "format": "date-time", + "examples": ["2026-06-12T10:30:00.000Z"] }, - "targetHandle": { - "description": "Target port, or null for the block default.", + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." + }, + "activeDeployment": { "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/ActiveDeploymentSummary" }, { "type": "null" } - ] + ], + "description": "Currently live deployment version, or null while no version is active." }, - "type": { - "description": "Edge renderer type.", - "type": "string" + "latestDeploymentAttempt": { + "anyOf": [ + { + "$ref": "#/components/schemas/DeploymentOperationSummary" + }, + { + "type": "null" + } + ], + "description": "Most recent deployment lifecycle attempt, or null when none is available." + }, + "needsRedeployment": { + "type": "boolean", + "description": "Whether the editable draft has diverged from the live deployment version. False while a deployment attempt is still preparing or activating, and false when nothing is deployed." + }, + "isPublicApi": { + "type": "boolean", + "description": "Whether anyone with the execution URL can run the deployed workflow and consume billed usage without an API key. Change this with Update Workflow Public API Access." } }, - "required": ["id", "source", "target"], - "title": "Workflow edge", - "description": "A directed connection between two blocks." + "required": [ + "id", + "isDeployed", + "deployedAt", + "warnings", + "activeDeployment", + "latestDeploymentAttempt", + "needsRedeployment", + "isPublicApi" + ], + "additionalProperties": false, + "title": "Workflow deployment", + "description": "Current deployment state of a workflow, including draft-versus-live drift and the most recent deployment attempt." }, - "WorkflowLoopInput": { + "WorkflowDeploymentResponse": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Loop container identifier; equal to the loop block id." - }, - "nodes": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Block ids inside the loop." - }, - "iterations": { - "type": "number", - "description": "Resolved iteration count." - }, - "loopType": { - "type": "string", - "enum": ["for", "forEach", "while", "doWhile"], - "description": "Loop kind." - }, - "forEachItems": { - "description": "Items a forEach loop iterates, or the expression producing them.", - "anyOf": [ - { - "type": "array", - "items": { - "description": "One item the loop iterates." - } + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowDeployment" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Workflow deployment response", + "description": "Current deployment state, including draft-versus-live drift and whether the deployment is publicly executable.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": true, + "needsRedeployment": true, + "isPublicApi": false, + "deployedAt": "2026-06-12T10:30:00.000Z", + "warnings": [], + "activeDeployment": { + "deploymentVersionId": "depver_01J8ZK3QW4M6X2R9T7B5C0V2", + "version": 3, + "deployedAt": "2026-06-12T10:30:00.000Z" }, - { - "type": "object", - "propertyNames": { - "type": "string" + "latestDeploymentAttempt": { + "id": "depop_01J8ZK3QW4M6X2R9T7B5C0V1", + "deploymentVersionId": "depver_01J8ZK3QW4M6X2R9T7B5C0V2", + "version": 3, + "action": "deploy", + "status": "active", + "isCurrent": true, + "readiness": { + "webhooks": "ready", + "schedules": "ready", + "mcp": "not_applicable" }, - "additionalProperties": { - "description": "One item the loop iterates." - } - }, - { - "type": "string" + "requestedAt": "2026-06-12T10:29:58.000Z", + "activatedAt": "2026-06-12T10:30:00.000Z", + "error": null } - ] - }, - "whileCondition": { - "description": "Condition expression for a `while` loop.", - "type": "string" - }, - "doWhileCondition": { - "description": "Condition expression for a `doWhile` loop.", - "type": "string" - }, - "enabled": { - "description": "Whether the loop runs.", - "type": "boolean" + } + } + ] + }, + "WorkflowPublicApiSettings": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique workflow identifier." }, - "locked": { - "description": "Whether the loop is locked against edits.", - "type": "boolean" + "isPublicApi": { + "type": "boolean", + "description": "Whether the deployed workflow accepts unauthenticated public API execution." + } + }, + "required": ["id", "isPublicApi"], + "additionalProperties": false, + "title": "Workflow public API settings", + "description": "Whether a deployed workflow is executable without an API key." + }, + "UpdateWorkflowPublicApiResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowPublicApiSettings" } }, - "required": ["id", "nodes", "iterations", "loopType"], - "title": "Workflow loop", - "description": "A loop container derived from the workflow blocks." + "required": ["data"], + "additionalProperties": false, + "title": "Update workflow public API response", + "description": "Public API access after the update.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isPublicApi": true + } + } + ] }, - "WorkflowParallelInput": { + "UpdateWorkflowPublicApiRequest": { + "type": "object", + "properties": { + "isPublicApi": { + "type": "boolean", + "description": "Whether the deployed workflow should accept unauthenticated public API execution." + } + }, + "required": ["isPublicApi"], + "additionalProperties": false, + "title": "Update workflow public API request", + "description": "Enable or disable unauthenticated public execution of the deployed workflow.", + "examples": [ + { + "isPublicApi": true + } + ] + }, + "DeployResult": { "type": "object", "properties": { "id": { "type": "string", - "description": "Parallel container identifier; equal to the parallel block id." + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] }, - "nodes": { + "isDeployed": { + "type": "boolean", + "description": "Whether a workflow version is currently live and available for API execution." + }, + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", + "format": "date-time", + "examples": ["2026-06-12T10:30:00.000Z"] + }, + "warnings": { "type": "array", "items": { "type": "string" }, - "description": "Block ids inside the parallel." + "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." }, - "distribution": { - "description": "Items distributed across branches, or the expression producing them.", + "activeDeployment": { "anyOf": [ { - "type": "array", - "items": { - "description": "One item distributed to a branch." - } + "$ref": "#/components/schemas/ActiveDeploymentSummary" }, { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One item distributed to a branch." - } + "type": "null" + } + ], + "description": "Currently live deployment version, or null while no version is active." + }, + "latestDeploymentAttempt": { + "anyOf": [ + { + "$ref": "#/components/schemas/DeploymentOperationSummary" }, { - "type": "string" + "type": "null" } - ] - }, - "count": { - "description": "Fixed branch count.", - "type": "number" - }, - "parallelType": { - "description": "Parallel kind.", - "type": "string", - "enum": ["count", "collection"] - }, - "batchSize": { - "description": "Maximum concurrent branches.", - "type": "number" - }, - "enabled": { - "description": "Whether the parallel runs.", - "type": "boolean" + ], + "description": "Most recent deployment lifecycle attempt, or null when none is available." }, - "locked": { - "description": "Whether the parallel is locked against edits.", - "type": "boolean" + "version": { + "description": "Deployment version created for this attempt, when available.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, - "required": ["id", "nodes"], - "title": "Workflow parallel", - "description": "A parallel container derived from the workflow blocks." + "required": [ + "id", + "isDeployed", + "deployedAt", + "warnings", + "activeDeployment", + "latestDeploymentAttempt" + ], + "additionalProperties": false, + "title": "Deploy result", + "description": "Deployment attempt accepted for asynchronous activation. `latestDeploymentAttempt` identifies the attempt. Poll Get Workflow Deployment for `isDeployed` and `deployedAt`, or List Workflow Versions for `isActive`." }, - "WorkflowVariableInput": { + "DeployWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/DeployResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Deploy workflow response", + "description": "Current deployment state after accepting the attempt.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": false, + "deployedAt": null, + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": { + "id": "depop_01J8ZK3QW4M6X2R9T7B5C0V1", + "deploymentVersionId": "depver_01J8ZK3QW4M6X2R9T7B5C0V2", + "version": 3, + "action": "deploy", + "status": "preparing", + "isCurrent": true, + "readiness": { + "webhooks": "pending", + "schedules": "ready", + "mcp": "not_applicable" + }, + "requestedAt": "2026-06-12T10:30:00.000Z", + "activatedAt": null, + "error": null + }, + "version": 3 + } + } + ] + }, + "DeployWorkflowRequest": { + "default": {}, + "title": "Deploy workflow request", + "description": "Optional metadata for the new deployment version.", + "examples": [ + { + "name": "Escalation routing", + "description": "Adds the priority escalation branch." + } + ], "type": "object", "properties": { - "id": { - "type": "string", - "minLength": 1, - "description": "Variable identifier." - }, "name": { + "description": "Optional label for the deployment version.", "type": "string", "minLength": 1, - "maxLength": 255, - "description": "Variable name, referenced from block inputs." - }, - "type": { - "type": "string", - "enum": ["string", "number", "boolean", "object", "array", "plain"], - "description": "Declared variable type." + "maxLength": 100 }, - "value": { - "description": "Variable value; free-form and validated per `type` at use time." + "description": { + "description": "Optional release note for the deployment version.", + "anyOf": [ + { + "type": "string", + "maxLength": 50000 + }, + { + "type": "null" + } + ] } }, - "required": ["id", "name", "type", "value"], - "title": "Workflow variable", - "description": "A workflow-scoped variable." + "additionalProperties": false }, - "ReplaceWorkflowStateRequest": { + "UndeployResult": { "type": "object", "properties": { - "blocks": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/WorkflowBlockInput" - }, - "description": "Blocks keyed by block id." + "id": { + "type": "string", + "description": "Unique workflow identifier.", + "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] }, - "edges": { - "maxItems": 10000, - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowEdgeInput" - }, - "description": "Directed connections between blocks." + "isDeployed": { + "type": "boolean", + "description": "Whether a workflow version is currently live and available for API execution." + }, + "deployedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", + "format": "date-time", + "examples": ["2026-06-12T10:30:00.000Z"] }, - "loops": { - "description": "Ignored on write: loop containers are recomputed from `blocks`.", - "type": "object", - "propertyNames": { + "warnings": { + "type": "array", + "items": { "type": "string" }, - "additionalProperties": { - "$ref": "#/components/schemas/WorkflowLoopInput" - } + "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." }, - "parallels": { - "description": "Ignored on write: parallel containers are recomputed from `blocks`.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/WorkflowParallelInput" - } + "activeDeployment": { + "anyOf": [ + { + "$ref": "#/components/schemas/ActiveDeploymentSummary" + }, + { + "type": "null" + } + ], + "description": "Currently live deployment version, or null while no version is active." }, - "variables": { - "description": "Replacement variable set. Omit to leave the stored variables untouched.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "$ref": "#/components/schemas/WorkflowVariableInput" - } + "latestDeploymentAttempt": { + "anyOf": [ + { + "$ref": "#/components/schemas/DeploymentOperationSummary" + }, + { + "type": "null" + } + ], + "description": "Most recent deployment lifecycle attempt, or null when none is available." } }, - "required": ["blocks", "edges"], + "required": [ + "id", + "isDeployed", + "deployedAt", + "warnings", + "activeDeployment", + "latestDeploymentAttempt" + ], "additionalProperties": false, - "title": "Replace workflow state request", - "description": "A complete replacement draft graph for a workflow.", + "title": "Undeploy result", + "description": "Deployment state after a successful undeploy. `isDeployed` is false and no workflow version is active." + }, + "UndeployWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/UndeployResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Undeploy workflow response", + "description": "Deployment state after deactivating the active version.", "examples": [ { - "blocks": {}, - "edges": [] + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": false, + "deployedAt": null, + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": null + } } ] }, - "WorkflowSkippedItem": { + "RollbackWorkflowResponse": { "type": "object", "properties": { - "type": { - "type": "string", - "enum": [ - "block_not_found", - "invalid_block_type", - "block_not_allowed", - "model_not_allowed", - "block_locked", - "tool_not_allowed", - "invalid_edge_target", - "invalid_edge_source", - "invalid_edge_scope", - "invalid_source_handle", - "invalid_target_handle", - "invalid_subblock_field", - "missing_required_params", - "invalid_subflow_parent", - "nested_subflow_not_allowed", - "duplicate_block_name", - "reserved_block_name", - "retry_not_supported", - "duplicate_trigger", - "duplicate_single_instance_block", - "disabled_ancestor" - ], - "description": "Machine-readable reason the engine declined an operation." - }, - "operationType": { - "type": "string", - "description": "The `operation_type` that was declined." - }, - "blockId": { - "type": "string", - "description": "Block the declined operation targeted." - }, - "reason": { - "type": "string", - "description": "Human-readable explanation." - }, - "details": { - "description": "Additional context for the reason; keys depend on `type`.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One piece of engine-supplied context for the reason." - } + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/RollbackResult" } }, - "required": ["type", "operationType", "blockId", "reason"], + "required": ["data"], "additionalProperties": false, - "title": "Workflow skipped item", - "description": "One operation the edit engine did not apply." + "title": "Rollback workflow response", + "description": "Current deployment state after accepting the rollback attempt.", + "examples": [ + { + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "isDeployed": false, + "deployedAt": null, + "warnings": [], + "activeDeployment": null, + "latestDeploymentAttempt": { + "id": "depop_01J8ZK4RX5N7Y3S0U8D6E1W2", + "deploymentVersionId": "depver_01J8ZK4RX5N7Y3S0U8D6E1W3", + "version": 2, + "action": "activate", + "status": "activating", + "isCurrent": true, + "readiness": { + "webhooks": "ready", + "schedules": "ready", + "mcp": "not_applicable" + }, + "requestedAt": "2026-06-12T10:30:00.000Z", + "activatedAt": null, + "error": null + }, + "version": 2 + } + } + ] }, - "WorkflowInputValidationError": { + "RollbackWorkflowRequest": { + "default": {}, + "title": "Rollback workflow request", + "description": "Optional deployment version to reactivate.", + "examples": [ + { + "version": 2 + } + ], "type": "object", "properties": { - "blockId": { - "type": "string", - "description": "Block whose input was rejected." - }, - "blockType": { - "type": "string", - "description": "Type of the block whose input was rejected." - }, - "field": { - "type": "string", - "description": "Sub-block field that was rejected." - }, - "error": { - "type": "string", - "description": "Why the value was rejected." + "version": { + "description": "Deployment version to reactivate. Omit to select the previous active version.", + "type": "integer", + "minimum": 1, + "maximum": 2147483647 } }, - "required": ["blockId", "blockType", "field", "error"], - "additionalProperties": false, - "title": "Workflow input validation error", - "description": "One block input that was dropped rather than persisted." + "additionalProperties": false }, - "ApplyWorkflowOperationsResult": { + "WorkflowExportPayload": { "type": "object", "properties": { - "id": { + "version": { "type": "string", - "description": "Identifier of the workflow whose draft graph was written." - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Non-fatal notes about blocks and edges that were normalized or dropped before persistence. Empty when there was nothing to report." - }, - "needsRedeployment": { - "type": "boolean", - "description": "Whether the live deployment now differs from the draft. A graph write never changes what the deployed endpoint serves; deploy to publish it." - }, - "applied": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Operations the engine applied." + "const": "1.0", + "description": "Workflow export format version." }, - "skipped": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowSkippedItem" - }, - "description": "Operations the engine declined. Empty when everything applied." + "exportedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the export was created.", + "format": "date-time" }, - "deferred": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowSkippedItem" + "workflow": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the source workflow." + }, + "name": { + "type": "string", + "description": "Name of the exported workflow." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Description of the exported workflow, or null when unset." + }, + "workspaceId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Identifier of the source workspace, or null for legacy exports." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path; `/` is the workspace root.", + "maxLength": 4096 + } }, - "description": "Edges waiting for target blocks. They apply automatically when their targets exist, in this batch or a later one. Do not resubmit them." + "required": ["id", "name", "description", "workspaceId", "folderPath"], + "additionalProperties": false, + "description": "Source workflow metadata." }, - "inputValidationErrors": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowInputValidationError" - }, - "description": "Block inputs that were dropped rather than persisted, and only those. The rest of the operation still applied. References that merely fail to resolve stay persisted and are reported in `lint.unresolvedReferences` instead." + "state": { + "type": "object", + "additionalProperties": true, + "description": "Secret-sanitized workflow graph, edges, loops, parallels, metadata, and variables." }, - "mintedBlockIds": { + "referenceManifest": { + "description": "Versioned non-secret identifiers and registered source field occurrences for mapped import.", "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string", - "description": "The id the block was actually given." + "properties": { + "version": { + "type": "number", + "const": 1, + "description": "Reference format or deployment version number." + }, + "references": { + "maxItems": 10000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox", + "workflow" + ], + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Untrusted source reference label; imports never use it to authorize or query a source workspace." + }, + "required": { + "type": "boolean", + "description": "Whether the reference or configuration is required for this operation." + }, + "occurrences": { + "minItems": 1, + "maxItems": 10000, + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "valuePath": { + "maxItems": 8, + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + ] + }, + "description": "Path within the field value; strings address properties and numbers address array entries." + }, + "positions": { + "description": "Positions occupied by this identifier in a multi-value field.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + }, + "encoding": { + "type": "string", + "enum": ["scalar", "array", "csv", "files", "environment"], + "description": "Registered encoding used to discover and rewrite the reference." + } + }, + "required": ["blockId", "subBlockKey", "valuePath", "encoding"], + "additionalProperties": false + }, + "description": "Every registered source block and field occurrence of this reference." + } + }, + "required": ["kind", "sourceId", "required", "occurrences"], + "additionalProperties": false + }, + "description": "Non-secret resource identifiers and their registered occurrences." + } }, - "description": "Minted block ids keyed by requested `block_id`, present only when they differ. References within this batch are remapped automatically; later requests must use the minted id. Supply a UUID when the requested id must survive unchanged." - }, - "lint": { - "$ref": "#/components/schemas/WorkflowLintReport" - }, - "dryRun": { - "type": "boolean", - "description": "Whether this request only evaluated. `true` means nothing was persisted; the outcome describes what a committed apply of the same body would produce." + "required": ["version", "references"], + "additionalProperties": false } }, - "required": [ - "id", - "warnings", - "needsRedeployment", - "applied", - "skipped", - "deferred", - "inputValidationErrors", - "mintedBlockIds", - "lint", - "dryRun" - ], + "required": ["version", "exportedAt", "workflow", "state"], "additionalProperties": false, - "title": "Apply workflow operations result", - "description": "Outcome of a batch of semantic edits against a workflow graph." + "title": "Workflow export payload", + "description": "Portable, secret-sanitized workflow export. Workspace-scoped bindings must be selected again after import." }, - "ApplyWorkflowOperationsResponse": { + "ExportWorkflowResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/ApplyWorkflowOperationsResult" + "$ref": "#/components/schemas/WorkflowExportPayload" } }, "required": ["data"], "additionalProperties": false, - "title": "Apply workflow operations response", - "description": "Outcome of a batch of semantic edits.", + "title": "Export workflow response", + "description": "Portable, secret-sanitized workflow data.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "applied": 1, - "skipped": [], - "deferred": [], - "inputValidationErrors": [], - "mintedBlockIds": { - "triage": "a3f1c0b2-7a44-4c1d-9d3a-2b8e5f0a1c77" - }, - "lint": { - "sources": [], - "sinks": [], - "orphanBlocks": [], - "emptyOutgoingPorts": [], - "invalidBranchPorts": [], - "invalidConnectionTargets": [], - "fieldIssues": [ - { - "blockId": "agent-1", - "blockName": "Triage", - "blockType": "agent", - "missingRequiredFields": ["systemPrompt"], - "inactiveModeValues": [] - } - ], - "unresolvedReferences": [], - "notes": [] + "version": "1.0", + "exportedAt": "2026-08-09T18:04:11.000Z", + "workflow": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "folderPath": "/Operations" }, - "warnings": [], - "needsRedeployment": true, - "dryRun": false + "state": { + "blocks": {}, + "edges": [] + } } } ] }, - "WorkflowEditOperation": { - "oneOf": [ - { - "type": "object", - "properties": { - "operation_type": { - "type": "string", - "const": "add", - "description": "Create a new block." - }, - "block_id": { - "type": "string", - "minLength": 1, - "description": "Block the operation targets. For `add`, the id the new block will be given." + "ImportedWorkflow": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Resource identifier." + }, + "name": { + "type": "string", + "description": "Display name of the workflow or workspace." + }, + "description": { + "anyOf": [ + { + "type": "string" }, - "params": { - "type": "object", - "properties": { - "type": { - "type": "string", - "minLength": 1, - "description": "Registered block type." - }, - "name": { - "type": "string", - "minLength": 1, - "description": "Block display name." - }, - "inputs": { - "allOf": [ - { - "type": "object", - "properties": { - "tools": { - "description": "Agent tools configuration. Applies to a `tool-input` field; other block inputs remain catalog-defined.", - "$ref": "#/components/schemas/AgentToolInput" - } - }, - "additionalProperties": { - "description": "One block-specific input whose accepted shape is published by the block catalog." - } - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One block-specific input whose accepted shape is published by the block catalog." - } - } - ], - "description": "Block configuration keyed by sub-block id." - } - }, - "required": ["type", "name"], - "additionalProperties": { - "description": "One block-specific input or connection descriptor." - }, - "description": "Block `type`, `name`, and optional `inputs`, `connections`, `retry`, `triggerMode`, or `advancedMode`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`." + { + "type": "null" } + ], + "description": "Imported workflow description." + }, + "workspaceId": { + "type": "string", + "description": "Explicit current workspace scope." + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path.", + "maxLength": 4096 + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was imported.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the workflow was last updated.", + "format": "date-time" + }, + "operationId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Durable operation identifier to use for polling." + }, + "requestId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Stable client request ID for reconciliation and identical retries." + }, + "kind": { + "type": "string", + "enum": ["workflow_import", "workspace_fork", "workspace_push", "workspace_pull"], + "description": "Resource or operation kind." + }, + "applied": { + "type": "boolean", + "const": true, + "description": "The business transaction committed, including when follow-up work fails." + }, + "status": { + "type": "string", + "enum": [ + "processing", + "completed", + "completed_with_warnings", + "requires_configuration", + "failed" + ], + "description": "Current operation or deployment outcome." + }, + "resourceIds": { + "maxItems": 5000, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 256 }, - "required": ["operation_type", "block_id", "params"], - "additionalProperties": false + "description": "Identifiers of resources created or changed by the committed operation." }, - { - "type": "object", - "properties": { - "operation_type": { - "type": "string", - "const": "edit", - "description": "Change an existing block: its inputs, name, or connections." - }, - "block_id": { - "type": "string", - "minLength": 1, - "description": "Block the operation targets. For `add`, the id the new block will be given." + "issues": { + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Stable machine-readable issue code." + }, + "message": { + "type": "string", + "maxLength": 2048, + "description": "Human-readable explanation of the issue." + }, + "workflowId": { + "description": "Workflow affected by this issue or deployment attempt.", + "type": "string", + "maxLength": 256 + }, + "blockId": { + "description": "Source block identifier before graph ID regeneration.", + "type": "string", + "maxLength": 256 + }, + "subBlockKey": { + "description": "Registered source field key, including the tool index for nested Agent fields.", + "type": "string", + "maxLength": 256 + } }, - "params": { - "allOf": [ - { - "type": "object", - "properties": { - "inputs": { - "allOf": [ - { - "type": "object", - "properties": { - "tools": { - "description": "Agent tools configuration. Applies to a `tool-input` field; other block inputs remain catalog-defined.", - "$ref": "#/components/schemas/AgentToolInput" - } - }, - "additionalProperties": { - "description": "One block-specific input whose accepted shape is published by the block catalog." - } - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One block-specific input whose accepted shape is published by the block catalog." - } - } - ], - "description": "Block configuration keyed by sub-block id." - } - }, - "additionalProperties": { - "description": "One operation parameter; see the description for the accepted keys." - } - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One operation parameter; see the description for the accepted keys." - } - } - ], - "description": "Patch only supplied fields: `inputs`, `name`, `connections`, `removeEdges`, `nestedNodes`, `retry`, `triggerMode`, and `advancedMode`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`. Re-sending `connections` replaces outgoing edges; use `removeEdges` to delete selected edges." - } + "required": ["code", "message"], + "additionalProperties": false }, - "required": ["operation_type", "block_id", "params"], - "additionalProperties": false + "description": "Structured warnings, missing configuration, and follow-up failures." }, - { + "idMap": { + "description": "Source graph identifiers mapped to the imported identifiers.", "type": "object", - "properties": { - "operation_type": { - "type": "string", - "const": "delete", - "description": "Remove a block and every edge touching it." - }, - "block_id": { - "type": "string", - "minLength": 1, - "description": "Block the operation targets. For `add`, the id the new block will be given." - } + "propertyNames": { + "type": "string", + "maxLength": 256 }, - "required": ["operation_type", "block_id"], - "additionalProperties": false + "additionalProperties": { + "type": "string", + "maxLength": 256 + } }, - { - "type": "object", - "properties": { - "operation_type": { - "type": "string", - "const": "insert_into_subflow", - "description": "Create a block inside a loop or parallel container." - }, - "block_id": { - "type": "string", - "minLength": 1, - "description": "Block the operation targets. For `add`, the id the new block will be given." - }, - "params": { - "type": "object", - "properties": { - "subflowId": { - "type": "string", - "minLength": 1, - "description": "Loop or parallel container to insert the block into." - }, - "type": { - "type": "string", - "minLength": 1, - "description": "Registered block type." - }, - "name": { + "deploymentOperationIds": { + "description": "Exact deployment attempts admitted by the workspace operation.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "maxLength": 256 + } + }, + "deployments": { + "description": "Readiness of the exact admitted deployment attempts.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "object", + "properties": { + "operationId": { + "type": "string", + "maxLength": 256, + "description": "Durable operation identifier to use for polling." + }, + "workflowId": { + "type": "string", + "maxLength": 256, + "description": "Workflow affected by this issue or deployment attempt." + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "Reference format or deployment version number." + }, + "status": { + "type": "string", + "enum": ["preparing", "activating", "active", "failed", "superseded"], + "description": "Current operation or deployment outcome." + }, + "ready": { + "type": "boolean", + "description": "Whether the operation passes its current apply or deployment readiness checks." + }, + "pendingComponents": { + "maxItems": 32, + "type": "array", + "items": { "type": "string", - "minLength": 1, - "description": "Block display name." + "maxLength": 128 }, - "inputs": { - "allOf": [ - { - "type": "object", - "properties": { - "tools": { - "description": "Agent tools configuration. Applies to a `tool-input` field; other block inputs remain catalog-defined.", - "$ref": "#/components/schemas/AgentToolInput" - } - }, - "additionalProperties": { - "description": "One block-specific input whose accepted shape is published by the block catalog." - } - }, - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One block-specific input whose accepted shape is published by the block catalog." - } - } - ], - "description": "Block configuration keyed by sub-block id." - } - }, - "required": ["subflowId", "type", "name"], - "additionalProperties": { - "description": "One block-specific input or connection descriptor." + "description": "Deployment components that have not finished becoming ready." + } + }, + "required": [ + "operationId", + "workflowId", + "version", + "status", + "ready", + "pendingComponents" + ], + "additionalProperties": false + } + }, + "triggerUrlChanges": { + "description": "Public trigger paths changed by this sync, with the affected workflow names.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "object", + "properties": { + "workflowName": { + "type": "string", + "maxLength": 1024, + "description": "Name of the workflow whose public trigger path stops serving." }, - "description": "Container, block `type`, `name`, and the same optional fields as `add`. `inputs` maps sub-block ids directly to values, never through `subBlocks`. Keep `retry`, `triggerMode`, and `advancedMode` beside `inputs`. `connections` maps source handles to target ids, `{ block, handle }`, or arrays; `success` aliases `source`." - } - }, - "required": ["operation_type", "block_id", "params"], - "additionalProperties": false + "path": { + "type": "string", + "maxLength": 4096, + "description": "Public trigger path that stops serving after this sync." + } + }, + "required": ["workflowName", "path"], + "additionalProperties": false + } }, - { + "backgroundWorkId": { + "description": "Workspace activity identifier for resource-copy progress.", + "type": "string", + "maxLength": 256 + }, + "copyProgress": { + "description": "Completion status and counts for explicitly selected resource copies.", "type": "object", "properties": { - "operation_type": { + "status": { "type": "string", - "const": "extract_from_subflow", - "description": "Move a block out of its loop or parallel container." - }, - "block_id": { - "type": "string", - "minLength": 1, - "description": "Block the operation targets. For `add`, the id the new block will be given." + "enum": ["pending", "completed", "failed"], + "description": "Current operation or deployment outcome." }, - "params": { - "type": "object", - "properties": { - "subflowId": { - "type": "string", - "minLength": 1, - "description": "Loop or parallel container the block moves into or out of." - } - }, - "required": ["subflowId"], - "additionalProperties": { - "description": "One block-specific input." - }, - "description": "Container identifier, plus any block-specific inputs." + "copied": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of resources copied successfully." + }, + "failed": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of resources that failed to copy." } }, - "required": ["operation_type", "block_id", "params"], + "required": ["status", "copied", "failed"], "additionalProperties": false } - ], - "title": "Workflow edit operation", - "description": "One semantic edit against a workflow graph." - }, - "AgentToolInput": { - "maxItems": 100, - "type": "array", - "items": { - "$ref": "#/components/schemas/AgentTool" }, - "description": "The complete value stored in an Agent block’s `tools` input.", - "title": "Agent tools input" - }, - "AgentTool": { - "oneOf": [ - { - "$ref": "#/components/schemas/AgentIntegrationTool" - }, - { - "$ref": "#/components/schemas/AgentCustomTool" - }, - { - "$ref": "#/components/schemas/AgentMcpTool" - }, - { - "$ref": "#/components/schemas/AgentMcpServerAdvanced" - } + "required": [ + "id", + "name", + "description", + "workspaceId", + "folderPath", + "createdAt", + "updatedAt" ], - "title": "Agent tool", - "description": "A catalog integration operation, workspace custom tool, or MCP tool available to an Agent." + "additionalProperties": false, + "title": "Imported workflow", + "description": "Workflow created by an import operation." }, - "AgentIntegrationTool": { + "ImportWorkflowResponse": { "type": "object", "properties": { - "type": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "pattern": "^(?!(?:custom-tool|mcp|mcp-server-advanced)$).+$", - "description": "Catalog block id, such as `cloudwatch` or `slack`. Use the block id, never an underlying tool id." - }, - "operation": { - "description": "Operation ID from Get Block. Required when the block exposes multiple operations; it may differ from the tool ID.", - "type": "string", - "minLength": 1, - "maxLength": 255 - }, - "usageControl": { - "type": "string", - "enum": ["auto", "force", "none"], - "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." - }, - "params": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One tool parameter value." - }, - "description": "Parameters fixed by the workflow author. Parameters left out remain available for the model to supply when the tool declares them." + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/ImportedWorkflow" } }, - "required": ["type"], - "additionalProperties": { - "description": "Forward-compatible integration tool metadata preserved by the workflow editor." - }, - "title": "Agent integration tool", - "description": "A catalog integration operation the Agent may call. Resolve valid block and operation ids through the block catalog.", + "required": ["data"], + "additionalProperties": false, + "title": "Import workflow response", + "description": "The workflow created by the import.", "examples": [ { - "type": "cloudwatch", - "operation": "describe_alarm_history", - "usageControl": "auto", - "params": {} + "data": { + "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "name": "Customer support triage", + "description": "Routes incoming support requests to the right team.", + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "folderPath": "/Operations", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-08-09T18:04:11.000Z" + } } ] }, - "AgentCustomTool": { - "anyOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "custom-tool", - "description": "Custom-tool discriminator." - }, - "customToolId": { + "ImportWorkflowBody": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to import the workflow." + }, + "workflow": { + "anyOf": [ + { "type": "string", "minLength": 1, - "maxLength": 255, - "description": "Custom tool ID from List Custom Tools." + "description": "JSON string containing a workflow export object or bare workflow state." }, - "usageControl": { - "type": "string", - "enum": ["auto", "force", "none"], - "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + { + "type": "object", + "additionalProperties": true, + "description": "Workflow export object or bare workflow state." } - }, - "required": ["type", "customToolId"], - "additionalProperties": { - "description": "Forward-compatible custom tool metadata preserved by the workflow editor." - } + ], + "description": "Workflow export object, bare workflow state, or JSON string containing either form." }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "custom-tool", - "description": "Custom-tool discriminator." - }, - "schema": { - "type": "object", - "properties": { - "type": { - "description": "Function declaration discriminator.", - "type": "string", - "const": "function" - }, - "function": { - "type": "object", - "properties": { - "name": { - "type": "string", - "minLength": 1, - "maxLength": 64, - "description": "Function name presented to the model." - }, - "description": { - "description": "What the inline custom tool does.", - "type": "string" - }, - "parameters": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "One JSON Schema keyword on the function parameters." - }, - "description": "JSON Schema describing the function arguments." - } - }, - "required": ["name", "parameters"], - "additionalProperties": { - "description": "Additional function declaration metadata." - }, - "description": "OpenAI-style function definition." - } + "folderPath": { + "description": "Destination folder path; omit for the workspace root.", + "$ref": "#/components/schemas/FolderPathInput" + }, + "name": { + "description": "Override for the imported workflow name.", + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + "description": { + "description": "Override for the imported workflow description.", + "type": "string", + "maxLength": 2000 + }, + "mappings": { + "description": "Mappings keyed by resource type and source identifier.", + "maxItems": 5000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox", + "workflow" + ], + "description": "Resource or operation kind." }, - "required": ["function"], - "additionalProperties": { - "description": "Additional custom tool declaration metadata." + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Untrusted source reference label; imports never use it to authorize or query a source workspace." }, - "description": "Inline OpenAI-style function declaration." - }, - "code": { - "type": "string", - "description": "Inline tool implementation executed by the Function runtime." + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + } }, - "usageControl": { - "type": "string", - "enum": ["auto", "force", "none"], - "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." - } - }, - "required": ["type", "schema", "code"], - "additionalProperties": { - "description": "Forward-compatible custom tool metadata preserved by the workflow editor." + "required": ["kind", "sourceId", "targetId"], + "additionalProperties": false } - } - ], - "title": "Agent custom tool", - "description": "A workspace custom tool. Prefer `customToolId`; inline declarations are also accepted.", - "examples": [ - { - "type": "custom-tool", - "customToolId": "cst_01J9X2ABCDEF", - "usageControl": "auto" - } - ] - }, - "AgentMcpTool": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "mcp", - "description": "MCP-tool discriminator." }, - "params": { - "allOf": [ - { - "type": "object", - "properties": { - "serverId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "MCP server id returned by `GET /api/v2/mcp-servers`." + "bindings": { + "description": "Resolved and unresolved source occurrences with their destination selections.", + "maxItems": 5000, + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "valuePath": { + "default": [], + "maxItems": 8, + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + ] }, - "toolName": { - "type": "string", - "minLength": 1, - "maxLength": 256, - "description": "Tool name returned by the MCP server’s tools endpoint." + "description": "Path within the field value; strings address properties and numbers address array entries." + }, + "positions": { + "description": "Positions occupied by this identifier in a multi-value field.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 2000 } }, - "required": ["serverId", "toolName"], - "additionalProperties": { - "description": "One parameter fixed by the workflow author." + "encoding": { + "default": "scalar", + "type": "string", + "enum": ["scalar", "array", "csv", "files", "environment"], + "description": "Registered encoding used to discover and rewrite the reference." + }, + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox", + "workflow" + ], + "description": "Resource or operation kind." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." } }, - { - "type": "object", - "propertyNames": { - "type": "string" + "required": ["blockId", "subBlockKey", "kind", "targetId"], + "additionalProperties": false + } + }, + "dependentValues": { + "description": "Destination-dependent choices keyed by source workflow, block, and field identities.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." }, - "additionalProperties": { - "description": "One parameter fixed by the workflow author." + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "value": { + "type": "string", + "maxLength": 16384, + "description": "Destination value for the registered dependent field." } - } - ], - "description": "MCP server and tool identity plus any tool arguments fixed by the workflow author." + }, + "required": ["blockId", "subBlockKey", "value"], + "additionalProperties": false + } }, - "usageControl": { + "requestId": { + "description": "Stable client request ID for reconciliation and identical retries.", "type": "string", - "enum": ["auto", "force", "none"], - "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$" + }, + "previewFingerprint": { + "description": "Fingerprint of the reviewed preview and its choices.", + "type": "string", + "pattern": "^[a-f0-9]{64}$" } }, - "required": ["type", "params"], - "additionalProperties": { - "description": "Forward-compatible MCP tool metadata preserved by the workflow editor." - }, - "title": "Agent MCP tool", - "description": "One tool discovered from a workspace MCP server.", - "examples": [ - { - "type": "mcp", - "params": { - "serverId": "mcp_01J9X2ABCDEF", - "toolName": "search_docs" - }, - "usageControl": "auto" - } - ] + "required": ["workspaceId", "workflow"], + "additionalProperties": false, + "title": "Import workflow input", + "description": "Workflow document, destination, and optional reviewed mappings." }, - "AgentMcpServerAdvanced": { + "ChatDeploymentListItem": { "type": "object", "properties": { - "type": { + "id": { "type": "string", - "const": "mcp-server-advanced", - "description": "Server-wide MCP binding discriminator." + "description": "Unique chat deployment identifier." }, - "params": { - "type": "object", - "properties": { - "serverId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace MCP server ID or explicit credential-group managed MCP connection ID." - } + "workflowId": { + "type": "string", + "description": "Workflow this deployment publishes." + }, + "workspaceId": { + "type": "string", + "description": "Workspace the deployment belongs to, derived from its workflow." + }, + "identifier": { + "type": "string", + "description": "URL slug the deployed chat answers on. Unique across live deployments." + }, + "url": { + "type": "string", + "description": "Public URL of the deployed chat. There is no chat subdomain — the identifier is a path segment.", + "examples": ["https://sim.ai/chat/support"] + }, + "title": { + "type": "string", + "description": "Title shown to visitors." + }, + "description": { + "type": "string", + "description": "Description shown to visitors. Empty when unset." + }, + "isActive": { + "type": "boolean", + "description": "Whether the deployment answers requests." + }, + "authType": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "How visitors are gated: `public` (no gate), `password`, `email`, or `sso`." + }, + "outputConfigs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoredChatDeploymentOutputConfig" }, - "required": ["serverId"], - "additionalProperties": false, - "description": "Server identity for discovering and invoking every available MCP tool." + "description": "Block outputs surfaced to visitors." }, - "usageControl": { + "includeThinking": { + "type": "boolean", + "description": "Whether visitors may receive provider thinking events. They must also opt into the streaming protocol." + }, + "includeToolCalls": { + "type": "boolean", + "description": "Whether visitors may receive tool lifecycle events. They must also opt into the streaming protocol." + }, + "createdAt": { "type": "string", - "enum": ["auto", "force", "none"], - "description": "When the Agent may call the tool: `auto` lets the model decide, `force` requires a call, and `none` disables it. Omitted means `auto`." + "description": "ISO 8601 timestamp when the deployment was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the deployment was last modified.", + "format": "date-time" } }, - "required": ["type", "params"], - "additionalProperties": { - "description": "Forward-compatible MCP server metadata preserved by the workflow editor." - }, - "title": "Agent MCP server (advanced)", - "description": "All tools available to the executing subject from one MCP server.", - "examples": [ - { - "type": "mcp-server-advanced", - "params": { - "serverId": "mcp_01J9X2ABCDEF" - }, - "usageControl": "auto" - } - ] + "required": [ + "id", + "workflowId", + "workspaceId", + "identifier", + "url", + "title", + "description", + "isActive", + "authType", + "outputConfigs", + "includeThinking", + "includeToolCalls", + "createdAt", + "updatedAt" + ], + "additionalProperties": false, + "title": "Chat deployment list entry", + "description": "A workflow published as a hosted chat, without the fields the detail read gates." }, - "ApplyWorkflowOperationsRequest": { + "StoredChatDeploymentOutputConfig": { "type": "object", "properties": { - "operations": { - "minItems": 1, - "maxItems": 200, - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowEditOperation" - }, - "description": "Edits to apply, in a single batch." - }, - "atomic": { - "default": false, - "description": "Fail the whole batch when any operation is declined or any block input would be dropped. The default applies what it can and reports the rest in `skipped` and `inputValidationErrors`; `true` writes nothing and answers `409` instead.", - "type": "boolean" + "workflowId": { + "description": "Child workflow containing the selected block. Omitted for the deployed workflow.", + "type": "string" }, - "layout": { - "default": "targeted", - "description": "Whether to reposition blocks the batch touched. `targeted` (default) nudges only the affected subgraph; `none` leaves every position exactly as supplied.", + "blockId": { "type": "string", - "enum": ["targeted", "none"] + "description": "Block whose output the chat streams." }, - "setBlockEnabled": { - "description": "Blocks to enable or disable, applied after `operations`. Disabling a loop or parallel cascades to its unlocked descendants; enabling a block whose container is disabled is declined.", - "maxItems": 200, + "path": { + "type": "string", + "description": "Path within that block output. Empty means the whole output." + } + }, + "required": ["blockId", "path"], + "additionalProperties": false, + "title": "Stored chat deployment output config", + "description": "One block output currently surfaced to chat visitors." + }, + "ChatDeploymentListResponse": { + "type": "object", + "properties": { + "data": { "type": "array", "items": { - "type": "object", - "properties": { - "block_id": { - "type": "string", - "minLength": 1, - "description": "Block the operation targets. For `add`, the id the new block will be given." - }, - "enabled": { - "type": "boolean", - "description": "Whether the block should run." - } + "$ref": "#/components/schemas/ChatDeploymentListItem" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" }, - "required": ["block_id", "enabled"], - "additionalProperties": false - } + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, - "required": ["operations"], + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Apply workflow operations request", - "description": "A batch of semantic edits against a workflow graph.", + "title": "Chat deployment list response", + "description": "A cursor-paginated page of chat deployments.", "examples": [ { - "operations": [ + "data": [ { - "operation_type": "add", - "block_id": "agent-1", - "params": { - "type": "agent", - "name": "Triage", - "inputs": { - "tools": [ - { - "type": "cloudwatch", - "operation": "describe_alarm_history", - "usageControl": "auto", - "params": {} - } - ] + "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", + "identifier": "support", + "url": "https://sim.ai/chat/support", + "title": "Support chat", + "description": "Ask about billing, onboarding, or outages.", + "isActive": true, + "authType": "public", + "outputConfigs": [ + { + "blockId": "block_01J8ZK3QW4M6X2R9T7B5C0V4", + "path": "content" } - } + ], + "includeThinking": false, + "includeToolCalls": false, + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" } - ] + ], + "nextCursor": null } ] }, - "ApplyWorkflowVariablesResult": { + "StoredChatDeploymentCustomizations": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Identifier of the workflow whose variables were updated." + "primaryColor": { + "description": "CSS color used for the chat accent.", + "type": "string" }, - "variableCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Variables the workflow now holds." + "welcomeMessage": { + "description": "First message shown to a visitor.", + "type": "string" }, - "changed": { - "type": "boolean", - "description": "Whether anything actually changed. A no-op batch answers `200` with `false`." - } - }, - "required": ["id", "variableCount", "changed"], - "additionalProperties": false, - "title": "Apply workflow variables result", - "description": "Outcome of a workflow variable update." - }, - "ApplyWorkflowVariablesResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/ApplyWorkflowVariablesResult" + "imageUrl": { + "description": "Avatar image shown beside assistant messages.", + "type": "string" } }, - "required": ["data"], "additionalProperties": false, - "title": "Apply workflow variables response", - "description": "Outcome of a workflow variable update.", - "examples": [ - { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "variableCount": 3, - "changed": true - } - } - ] + "title": "Stored chat deployment customizations", + "description": "Presentation overrides currently stored on the deployed chat." }, - "ApplyWorkflowVariablesRequest": { + "ChatDeployment": { "type": "object", "properties": { - "operations": { - "minItems": 1, - "maxItems": 100, + "id": { + "type": "string", + "description": "Unique chat deployment identifier." + }, + "workflowId": { + "type": "string", + "description": "Workflow this deployment publishes." + }, + "workspaceId": { + "type": "string", + "description": "Workspace the deployment belongs to, derived from its workflow." + }, + "identifier": { + "type": "string", + "description": "URL slug the deployed chat answers on. Unique across live deployments." + }, + "url": { + "type": "string", + "description": "Public URL of the deployed chat. There is no chat subdomain — the identifier is a path segment.", + "examples": ["https://sim.ai/chat/support"] + }, + "title": { + "type": "string", + "description": "Title shown to visitors." + }, + "description": { + "type": "string", + "description": "Description shown to visitors. Empty when unset." + }, + "isActive": { + "type": "boolean", + "description": "Whether the deployment answers requests." + }, + "authType": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "How visitors are gated: `public` (no gate), `password`, `email`, or `sso`." + }, + "hasPassword": { + "type": "boolean", + "description": "Whether a password is stored. The password itself is never readable." + }, + "allowedEmails": { "type": "array", "items": { - "oneOf": [ - { - "type": "object", - "properties": { - "operation": { - "type": "string", - "const": "add", - "description": "Create a variable with this name." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Variable name." - }, - "type": { - "type": "string", - "enum": ["string", "number", "boolean", "object", "array", "plain"], - "description": "Declared variable type." - }, - "value": { - "description": "Variable value, coerced to `type`." - } - }, - "required": ["operation", "name", "type", "value"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "operation": { - "type": "string", - "const": "edit", - "description": "Replace the value, and optionally the type, of an existing variable." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Name of the variable to update." - }, - "type": { - "description": "Replacement type; the stored type is kept when omitted.", - "type": "string", - "enum": ["string", "number", "boolean", "object", "array", "plain"] - }, - "value": { - "description": "Replacement value, coerced to the effective type." - } - }, - "required": ["operation", "name", "value"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "operation": { - "type": "string", - "const": "delete", - "description": "Remove the variable with this name." - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Name of the variable to remove." - } - }, - "required": ["operation", "name"], - "additionalProperties": false - } - ], - "description": "One variable change." + "type": "string" + }, + "description": "Email addresses or domains admitted under `email` and `sso` gating. Empty otherwise." + }, + "customizations": { + "description": "Presentation overrides. Unset fields fall back to platform defaults.", + "$ref": "#/components/schemas/StoredChatDeploymentCustomizations" + }, + "outputConfigs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/StoredChatDeploymentOutputConfig" }, - "description": "Variable changes to apply, in order." + "description": "Block outputs surfaced to visitors." + }, + "includeThinking": { + "type": "boolean", + "description": "Whether visitors may receive provider thinking events. They must also opt into the streaming protocol." + }, + "includeToolCalls": { + "type": "boolean", + "description": "Whether visitors may receive tool lifecycle events. They must also opt into the streaming protocol." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the deployment was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the deployment was last modified.", + "format": "date-time" } }, - "required": ["operations"], + "required": [ + "id", + "workflowId", + "workspaceId", + "identifier", + "url", + "title", + "description", + "isActive", + "authType", + "hasPassword", + "allowedEmails", + "customizations", + "outputConfigs", + "includeThinking", + "includeToolCalls", + "createdAt", + "updatedAt" + ], "additionalProperties": false, - "title": "Apply workflow variables request", - "description": "Additions, edits, and deletions against a workflow’s variables." + "title": "Chat deployment", + "description": "A workflow published as a hosted chat." }, - "DuplicateWorkflowResponse": { + "GetWorkflowChatDeploymentResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowListItem" + "$ref": "#/components/schemas/ChatDeployment" } }, "required": ["data"], "additionalProperties": false, - "title": "Duplicate workflow response", - "description": "The created copy.", + "title": "Get workflow chat deployment response", + "description": "The workflow's chat deployment.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage (copy)", - "description": "Routes incoming support requests to the right team.", - "folderPath": "/Operations", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": false, - "deployedAt": null, - "runCount": 0, - "lastRunAt": null, - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z" + "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", + "identifier": "support", + "url": "https://sim.ai/chat/support", + "title": "Support chat", + "description": "Ask about billing, onboarding, or outages.", + "isActive": true, + "authType": "public", + "hasPassword": false, + "allowedEmails": [], + "customizations": { + "primaryColor": "#6F3DFA", + "welcomeMessage": "Hi there! How can I help?" + }, + "outputConfigs": [ + { + "blockId": "block_01J8ZK3QW4M6X2R9T7B5C0V4", + "path": "content" + } + ], + "includeThinking": false, + "includeToolCalls": false, + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" } } ] }, - "DuplicateWorkflowRequest": { + "ReplaceWorkflowChatDeploymentResponse": { "type": "object", "properties": { - "name": { - "description": "Name for the copy. Defaults to the source name, deduplicated within the folder.", + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/ChatDeployment" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Replace workflow chat deployment response", + "description": "The chat deployment as stored after the replace.", + "examples": [ + { + "data": { + "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", + "identifier": "support", + "url": "https://sim.ai/chat/support", + "title": "Support chat", + "description": "Ask about billing, onboarding, or outages.", + "isActive": true, + "authType": "public", + "hasPassword": false, + "allowedEmails": [], + "customizations": { + "primaryColor": "#6F3DFA", + "welcomeMessage": "Hi there! How can I help?" + }, + "outputConfigs": [ + { + "blockId": "block_01J8ZK3QW4M6X2R9T7B5C0V4", + "path": "content" + } + ], + "includeThinking": false, + "includeToolCalls": false, + "createdAt": "2026-06-12T10:30:00.000Z", + "updatedAt": "2026-06-12T10:30:00.000Z" + } + } + ] + }, + "ChatDeploymentCustomizations": { + "type": "object", + "properties": { + "primaryColor": { + "description": "CSS color used for the chat accent.", "type": "string", "minLength": 1, - "maxLength": 255 + "maxLength": 64 }, - "folderPath": { - "description": "Destination folder path. Defaults to the source workflow's folder.", - "$ref": "#/components/schemas/FolderPathInput" + "welcomeMessage": { + "description": "First message shown to a visitor.", + "type": "string", + "maxLength": 2000 + }, + "imageUrl": { + "description": "Avatar image shown beside assistant messages.", + "type": "string", + "maxLength": 2048 } }, "additionalProperties": false, - "title": "Duplicate workflow request", - "description": "Optional name and destination folder for the copy." + "title": "Chat deployment customizations", + "description": "Presentation overrides for the deployed chat." }, - "RestoreWorkflowResponse": { + "ChatDeploymentOutputConfig": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowListItem" + "workflowId": { + "description": "Child workflow containing the selected block. Omit for the deployed workflow.", + "type": "string", + "minLength": 1 + }, + "blockId": { + "type": "string", + "minLength": 1, + "description": "Block whose output the chat streams." + }, + "path": { + "type": "string", + "minLength": 1, + "description": "Path within that block output." } }, - "required": ["data"], + "required": ["blockId", "path"], "additionalProperties": false, - "title": "Restore workflow response", - "description": "The restored workflow.", - "examples": [ - { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "folderPath": "/Operations", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 42, - "lastRunAt": "2026-08-09T18:04:11.000Z", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z" + "title": "Chat deployment output config", + "description": "One block output surfaced to chat visitors." + }, + "ReplaceChatDeploymentRequest": { + "type": "object", + "properties": { + "identifier": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[a-z0-9-]+$", + "description": "URL slug the deployed chat answers on. Must be free across live deployments." + }, + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Title shown to visitors." + }, + "description": { + "description": "Description shown to visitors. Omitted clears it.", + "type": "string", + "maxLength": 2000 + }, + "customizations": { + "description": "Presentation overrides. Omitted fields take platform defaults.", + "$ref": "#/components/schemas/ChatDeploymentCustomizations" + }, + "authType": { + "description": "How visitors are gated. `public` leaves the chat open to anyone holding the URL.", + "default": "public", + "type": "string", + "enum": ["public", "password", "email", "sso"] + }, + "password": { + "description": "Write-only password. Required whenever `authType` is `password`, and rejected otherwise. Never readable back.", + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "allowedEmails": { + "description": "Email addresses or domains admitted under `email` and `sso` gating. At least one is required for those modes.", + "maxItems": 500, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "outputConfigs": { + "description": "Block outputs to surface to visitors. Omitted surfaces none.", + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/components/schemas/ChatDeploymentOutputConfig" } + }, + "includeThinking": { + "description": "Allow visitors to receive provider thinking events.", + "default": false, + "type": "boolean" + }, + "includeToolCalls": { + "description": "Allow visitors to receive tool lifecycle events.", + "default": false, + "type": "boolean" + } + }, + "required": ["identifier", "title"], + "additionalProperties": false, + "title": "Replace chat deployment request", + "description": "The complete desired state of a workflow's chat.", + "examples": [ + { + "identifier": "support", + "title": "Support chat" } ] }, - "MoveWorkflowsResult": { + "DeleteChatDeploymentResult": { "type": "object", "properties": { - "moved": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Workflows that were relocated." - }, - "failed": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Workflows that were not relocated — absent from the workspace, archived, or locked. Best-effort by design: the rest of the batch still moved." - }, - "folderPath": { + "id": { "type": "string", - "title": "Folder path", - "description": "Canonical destination folder path.", - "maxLength": 4096 + "description": "Identifier of the removed chat deployment." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Whether the deployment was removed." } }, - "required": ["moved", "failed", "folderPath"], + "required": ["id", "deleted"], "additionalProperties": false, - "title": "Move workflows result", - "description": "Which workflows moved and which did not." + "title": "Delete chat deployment result", + "description": "Chat deployment removal acknowledgement." }, - "MoveWorkflowsResponse": { + "DeleteWorkflowChatDeploymentResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/MoveWorkflowsResult" + "$ref": "#/components/schemas/DeleteChatDeploymentResult" } }, "required": ["data"], "additionalProperties": false, - "title": "Move workflows response", - "description": "Which workflows moved and which did not.", + "title": "Delete workflow chat deployment response", + "description": "Acknowledgement that the chat deployment was removed.", "examples": [ { "data": { - "moved": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"], - "failed": [], - "folderPath": "/Operations" + "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", + "deleted": true } } ] }, - "MoveWorkflowsRequest": { + "ExecutionError": { "type": "object", "properties": { - "workspaceId": { + "message": { "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace holding every workflow in the batch." - }, - "workflowIds": { - "minItems": 1, - "maxItems": 100, - "type": "array", - "items": { - "type": "string", - "minLength": 1 - }, - "description": "Workflows to move. Duplicates are collapsed." + "description": "Human-readable workflow execution failure message." }, - "folderPath": { - "description": "Destination folder path; `/` moves the workflows to the workspace root.", - "$ref": "#/components/schemas/FolderPathInput" - } - }, - "required": ["workspaceId", "workflowIds", "folderPath"], - "additionalProperties": false, - "title": "Move workflows request", - "description": "Workflows to relocate and the folder to relocate them into." - }, - "WorkflowInputField": { - "type": "object", - "properties": { - "name": { + "code": { "type": "string", - "description": "Input field name." + "enum": [ + "TIMEOUT", + "CANCELLED", + "USAGE_LIMIT_EXCEEDED", + "INVALID_INPUT", + "BLOCK_EXECUTION_FAILED", + "CHILD_WORKFLOW_FAILED", + "EXECUTION_FAILED" + ], + "description": "Stable machine-readable execution failure code. `BLOCK_EXECUTION_FAILED` and `CHILD_WORKFLOW_FAILED` are reported only where block attribution is available; elsewhere a block-level failure is reported as `EXECUTION_FAILED`." }, - "type": { - "type": "string", - "description": "Input field type." + "blockId": { + "description": "Identifier of the failing block. Present on the synchronous execute response only; the polled run resource and the resume response cannot attribute a block.", + "type": "string" }, - "description": { - "description": "Optional input field description.", + "blockName": { + "description": "Display name of the failing block. Present on the synchronous execute response only.", + "type": "string" + }, + "blockType": { + "description": "Integration or block type that failed. Present on the synchronous execute response only.", "type": "string" } }, - "required": ["name", "type"], + "required": ["message", "code"], "additionalProperties": false, - "title": "Workflow input field", - "description": "A deployed API trigger input exposed by a workflow." + "title": "Execution error", + "description": "Structured in-band failure details for a workflow run." }, - "WorkflowDetail": { + "WorkflowRunResult": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] - }, - "webUrl": { - "type": "string", - "format": "uri", - "description": "Canonical absolute URL for opening this resource in the Sim web application." - }, - "name": { + "runId": { "type": "string", - "description": "Workflow name.", - "examples": ["Customer support triage"] - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Workflow description, or null when none is set." + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] }, - "folderPath": { + "workflowId": { "type": "string", - "title": "Folder path", - "description": "Canonical containing-folder path; `/` is the workspace root.", - "maxLength": 4096, - "examples": ["/Operations"] + "description": "Workflow that produced the run." }, - "workspaceId": { + "status": { "type": "string", - "description": "Workspace that owns the workflow." - }, - "isDeployed": { - "type": "boolean", - "description": "Whether the workflow has an active deployment." - }, - "deployedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 activation timestamp, or null when not deployed.", - "format": "date-time" + "enum": ["completed", "failed", "paused", "cancelled"], + "description": "Terminal or paused run status." }, - "runCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Lifetime count of successful runs, excluding failed, canceled, and paused runs. Log retention does not reduce this count; it may differ from the number returned by List Workflow Runs." + "output": { + "description": "Workflow output, including partial output on failure." }, - "lastRunAt": { + "error": { "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/ExecutionError" }, { "type": "null" } ], - "description": "ISO 8601 timestamp of the latest run counted by `runCount`, or null when none has been. Stamped by the same successful-run path, so a workflow whose only runs failed reports null here.", - "format": "date-time" - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was created.", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was last updated.", - "format": "date-time" - }, - "variables": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Structured workflow variable value." - }, - "description": "Workflow-scoped variables keyed by variable identifier." + "description": "Structured execution failure, or null when none occurred." }, - "inputs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowInputField" - }, - "description": "Input fields exposed by the workflow API trigger." - } - }, - "required": [ - "id", - "webUrl", - "name", - "description", - "folderPath", - "workspaceId", - "isDeployed", - "deployedAt", - "runCount", - "lastRunAt", - "createdAt", - "updatedAt", - "variables", - "inputs" - ], - "additionalProperties": false, - "title": "Workflow detail", - "description": "Full workflow summary with variables and API-trigger input fields." - }, - "WorkflowDetailResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowDetail" + "startedAt": { + "description": "ISO 8601 timestamp when execution started.", + "format": "date-time", + "type": "string" + }, + "endedAt": { + "description": "ISO 8601 timestamp when execution ended.", + "format": "date-time", + "type": "string" + }, + "durationMs": { + "description": "Execution duration in milliseconds.", + "type": "number", + "minimum": 0 } }, - "required": ["data"], + "required": ["runId", "workflowId", "status", "output", "error"], "additionalProperties": false, - "title": "Workflow detail response", - "description": "Detailed workflow metadata, variables, and trigger inputs.", - "examples": [ - { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "folderPath": "/Operations", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 42, - "lastRunAt": "2026-08-09T18:04:11.000Z", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z", - "variables": {}, - "inputs": [] - } - } - ] + "title": "Workflow run result", + "description": "Synchronous workflow run output and in-band execution status. Run failures are reported in band, not as HTTP errors — a run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"`, so branch on `status`." }, - "UpdateWorkflowResponse": { + "ExecuteWorkflowSyncResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowListItem" + "$ref": "#/components/schemas/WorkflowRunResult" } }, "required": ["data"], "additionalProperties": false, - "title": "Update workflow response", - "description": "The updated workflow summary.", + "title": "Synchronous workflow execution response", + "description": "Completed, failed, paused, or cancelled synchronous workflow run.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/w/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "folderPath": "/Operations", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "isDeployed": true, - "deployedAt": "2026-06-12T10:30:00.000Z", - "runCount": 42, - "lastRunAt": "2026-08-09T18:04:11.000Z", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z" + "runId": "run_8f14e45f-ceea-467f-a", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "status": "completed", + "output": { + "result": "Ticket routed to Support" + }, + "error": null, + "startedAt": "2026-08-09T18:04:10.000Z", + "endedAt": "2026-08-09T18:04:11.000Z", + "durationMs": 1000 } } ] }, - "UpdateWorkflowRequest": { + "QueuedWorkflowRun": { "type": "object", "properties": { - "name": { - "description": "Replacement workflow name.", + "runId": { "type": "string", "minLength": 1, - "maxLength": 255 - }, - "description": { - "description": "Replacement workflow description; null clears it.", - "anyOf": [ - { - "type": "string", - "maxLength": 50000 - }, - { - "type": "null" - } - ] + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] }, - "folderPath": { - "description": "Destination folder path; `/` moves the workflow to the workspace root.", - "$ref": "#/components/schemas/FolderPathInput" - } - }, - "additionalProperties": false, - "title": "Update workflow request", - "description": "Fields to update on an existing workflow." - }, - "DeleteWorkflowResult": { - "type": "object", - "properties": { - "id": { + "statusUrl": { "type": "string", - "description": "Identifier of the archived workflow." - }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Confirms that the workflow is no longer live." - }, - "archived": { - "type": "boolean", - "const": true, - "description": "Whether the workflow was archived. Restore Workflow recovers it and the schedules, webhooks, MCP tools, and chats archived with it." + "format": "uri", + "description": "Absolute URL of the workflow run resource." } }, - "required": ["id", "deleted", "archived"], + "required": ["runId", "statusUrl"], "additionalProperties": false, - "title": "Delete workflow result", - "description": "Confirmation that a workflow was archived." + "title": "Queued workflow run", + "description": "Receipt returned when a workflow run is queued." }, - "DeleteWorkflowResponse": { + "ExecuteWorkflowQueuedResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/DeleteWorkflowResult" + "$ref": "#/components/schemas/QueuedWorkflowRun" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete workflow response", - "description": "Confirmation that the workflow was archived.", + "title": "Queued workflow execution response", + "description": "Receipt returned for an asynchronous workflow run.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "deleted": true, - "archived": true + "runId": "run_8f14e45f-ceea-467f-a", + "statusUrl": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/run_8f14e45f-ceea-467f-a" } } ] }, - "WorkflowVersion": { + "ExecuteWorkflowRequest": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Unique deployment-version identifier." - }, - "version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Monotonically increasing deployment version number." - }, - "name": { - "description": "Optional deployment-version label.", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "description": { - "description": "Optional deployment-version release note.", - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ] - }, - "isActive": { - "type": "boolean", - "description": "Whether this version is currently serving executions." - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when this version was created.", - "format": "date-time" + "input": { + "description": "Workflow input keyed by the selected trigger input-field name.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Value supplied for one workflow input field." + } }, - "deployedBy": { - "description": "Display name of the user who created the deployment, when available.", - "anyOf": [ + "run": { + "description": "Workflow state and entry point to execute. Omit for the active deployment. Manual execution requires OAuth or personal-key write access and supports synchronous or streamed runs only.", + "oneOf": [ { - "type": "string" + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "deployment", + "description": "Execute the active deployed workflow state." + } + }, + "required": ["source"], + "additionalProperties": false }, { - "type": "null" + "type": "object", + "properties": { + "source": { + "type": "string", + "const": "manual", + "description": "Execute the current saved workflow state manually." + }, + "entry": { + "description": "Manual entry mode. Omit to enter through the workflow trigger; a block entry requires an exact source run.", + "oneOf": [ + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "trigger", + "description": "Enter the manual run through a runnable trigger." + }, + "blockId": { + "description": "Runnable trigger block to enter through. Omit only when the saved workflow has exactly one runnable trigger.", + "type": "string", + "minLength": 1 + }, + "useMockPayload": { + "description": "Use the selected trigger's server-derived mock payload. Cannot be combined with `input`.", + "type": "boolean" + } + }, + "required": ["type"], + "additionalProperties": false + }, + { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "block", + "description": "Resume manual execution at a block using persisted upstream state." + }, + "blockId": { + "type": "string", + "minLength": 1, + "description": "Saved workflow block at which manual execution should resume." + }, + "sourceRunId": { + "type": "string", + "minLength": 1, + "description": "Run ID supplying upstream block results when starting from a selected block." + } + }, + "required": ["type", "blockId", "sourceRunId"], + "additionalProperties": false + } + ] + } + }, + "required": ["source"], + "additionalProperties": false } ] }, - "latestOperationStatus": { - "description": "Latest lifecycle-operation status for this version.", - "anyOf": [ - { - "type": "string", - "enum": ["preparing", "activating", "active", "failed", "superseded"] - }, - { - "type": "null" - } - ] - } - }, - "required": ["id", "version", "isActive", "createdAt"], - "additionalProperties": false, - "title": "Workflow version", - "description": "A saved deployment version of a workflow." - }, - "WorkflowVersionListResponse": { - "type": "object", - "properties": { - "data": { + "async": { + "default": false, + "description": "Queue the run and return a 202 receipt when true. Requires an OAuth access token or API key, cannot be combined with `stream`, and rejects all streaming and output-shaping options (`selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, `base64MaxBytes`).", + "type": "boolean" + }, + "executionTimeoutSeconds": { + "description": "Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`.", + "type": "integer", + "minimum": 1, + "maximum": 604800 + }, + "stream": { + "default": false, + "description": "Return Server-Sent Events instead of JSON when true. Cannot be combined with `async`.", + "type": "boolean" + }, + "selectedOutputs": { + "description": "Output references for streaming: `.` or `..`, using normalized block names. Child references apply to every invocation. Requires `stream: true` and rejects synchronous or async requests. Use `selectedOutputs` with Get Workflow Run to narrow an existing run.", + "maxItems": 100, "type": "array", "items": { - "$ref": "#/components/schemas/WorkflowVersion" - }, - "description": "Items in the current page." + "type": "string", + "minLength": 1 + } }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "includeThinking": { + "default": false, + "description": "Include model reasoning events in an agent-event stream. Requires `stream: true` and the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.", + "type": "boolean" + }, + "includeToolCalls": { + "default": false, + "description": "Include tool-call events in an agent-event stream. Requires `stream: true` and the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.", + "type": "boolean" + }, + "includeFileBase64": { + "description": "Inline eligible output files as base64 content. Rejected when `async` is true.", + "type": "boolean" + }, + "base64MaxBytes": { + "description": "Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 16777216 } }, - "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Workflow version list response", - "description": "A cursor-paginated page of deployment versions.", + "title": "Execute workflow request", + "description": "Input, workflow-state selection, and execution-mode options. Input descriptions specify compatible modes; invalid combinations return `400`.", "examples": [ { - "data": [ - { - "id": "version_3", - "version": 3, - "name": "Escalation routing", - "description": "Adds the priority escalation branch.", - "isActive": true, - "createdAt": "2026-06-12T10:30:00.000Z", - "deployedBy": "Jane Smith", - "latestOperationStatus": "active" + "input": { + "ticketId": "ticket_123" + } + }, + { + "input": { + "ticketId": "ticket_123" + }, + "async": true + }, + { + "input": { + "ticketId": "ticket_123" + }, + "stream": true + }, + { + "run": { + "source": "manual" + } + }, + { + "run": { + "source": "manual", + "entry": { + "type": "block", + "blockId": "block_123", + "sourceRunId": "run_123" } - ], - "nextCursor": null + } } ] }, - "DeployedWorkflowState": { - "title": "Deployed workflow state", - "description": "Workflow graph snapshot pinned by a deployment version.", - "type": "object", - "additionalProperties": true - }, - "WorkflowVersionDetail": { + "WorkflowRunListItem": { "type": "object", "properties": { - "id": { + "runId": { "type": "string", - "description": "Unique deployment-version identifier." + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] }, - "version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Monotonically increasing deployment version number." + "workflowId": { + "type": "string", + "description": "Workflow that produced the run." }, - "name": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } + "status": { + "type": "string", + "enum": [ + "pending", + "running", + "paused", + "redacting", + "completed", + "failed", + "cancelled" ], - "description": "Version label, or null when unset." + "description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting to be resumed — either held at a human-in-the-loop pause point, or left paused by a resume attempt that did not complete. Only the single-run response distinguishes the two, through `paused.automaticResumeWaitingReason`." }, - "description": { + "trigger": { + "type": "string", + "description": "Trigger type that started the run." + }, + "startedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the run started.", + "format": "date-time" + }, + "endedAt": { "anyOf": [ { "type": "string" @@ -7437,214 +11791,126 @@ "type": "null" } ], - "description": "Version release note, or null when unset." - }, - "isActive": { - "type": "boolean", - "description": "Whether this version is currently serving executions." - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when this version was created.", + "description": "ISO 8601 timestamp when the run ended, or null while active.", "format": "date-time" }, - "state": { - "description": "Workflow graph saved with this deployment version. Sensitive values are redacted to null.", - "$ref": "#/components/schemas/DeployedWorkflowState" - } - }, - "required": ["id", "version", "name", "description", "isActive", "createdAt", "state"], - "additionalProperties": false, - "title": "Workflow version detail", - "description": "A deployment version together with the workflow state it pins." - }, - "WorkflowVersionDetailResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowVersionDetail" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Workflow version detail response", - "description": "The deployment version and its pinned workflow graph.", - "examples": [ - { - "data": { - "id": "version_3", - "version": 3, - "name": "Escalation routing", - "description": "Adds the priority escalation branch.", - "isActive": true, - "createdAt": "2026-06-12T10:30:00.000Z", - "state": { - "blocks": {}, - "edges": [] - } - } - } - ] - }, - "WorkflowVersionMetadata": { - "type": "object", - "properties": { - "version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Monotonically increasing deployment version number." - }, - "name": { + "durationMs": { "anyOf": [ { - "type": "string" + "type": "number" }, { "type": "null" } ], - "description": "Version label, or null when unset." + "description": "Run duration in milliseconds, or null while active." }, - "description": { + "cost": { "anyOf": [ { - "type": "string" + "type": "object", + "properties": { + "total": { + "type": "number", + "description": "Total credits consumed by the run." + } + }, + "required": ["total"], + "additionalProperties": false }, { "type": "null" } ], - "description": "Version release note, or null when unset." + "description": "Credit cost, or null when unavailable." } }, - "required": ["version", "name", "description"], + "required": [ + "runId", + "workflowId", + "status", + "trigger", + "startedAt", + "endedAt", + "durationMs", + "cost" + ], "additionalProperties": false, - "title": "Workflow version metadata", - "description": "Mutable label and release note of a deployment version." + "title": "Workflow run summary", + "description": "Summary of a recorded workflow run." }, - "UpdateWorkflowVersionResponse": { + "WorkflowRunListResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowVersionMetadata" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Update workflow version response", - "description": "The deployment version metadata after the update.", - "examples": [ - { - "data": { - "version": 3, - "name": "Escalation routing", - "description": "Adds the priority escalation branch." - } - } - ] - }, - "UpdateWorkflowVersionRequest": { - "type": "object", - "properties": { - "name": { - "description": "New label for the deployment version.", - "type": "string", - "minLength": 1, - "maxLength": 100 + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkflowRunListItem" + }, + "description": "Items in the current page." }, - "description": { - "description": "New release note for the deployment version, or null to clear it.", + "nextCursor": { "anyOf": [ { - "type": "string", - "maxLength": 50000 + "type": "string" }, { "type": "null" } - ] + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, + "required": ["data", "nextCursor"], "additionalProperties": false, - "title": "Update workflow version request", - "description": "Merge-patch body for the mutable metadata of a deployment version.", + "title": "Workflow run list response", + "description": "A cursor-paginated page of workflow run summaries.", "examples": [ { - "name": "Escalation routing", - "description": "Adds the priority escalation branch." + "data": [ + { + "runId": "run_8f14e45f-ceea-467f-a", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "status": "completed", + "trigger": "api", + "startedAt": "2026-08-09T18:04:10.000Z", + "endedAt": "2026-08-09T18:04:11.000Z", + "durationMs": 1000, + "cost": { + "total": 12 + } + } + ], + "nextCursor": null } ] }, - "ActiveDeploymentSummary": { - "type": "object", - "properties": { - "deploymentVersionId": { - "type": "string", - "description": "Identifier of the active deployment version." - }, - "version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Numeric active deployment version." - }, - "deployedAt": { - "type": "string", - "description": "ISO 8601 timestamp when this version became active.", - "format": "date-time" - } - }, - "required": ["deploymentVersionId", "version", "deployedAt"], - "additionalProperties": false, - "title": "Active deployment", - "description": "Summary of the workflow version currently serving API executions." - }, - "DeploymentOperationSummary": { + "V2RunFile": { "type": "object", "properties": { "id": { "type": "string", - "description": "Unique deployment operation identifier." + "description": "Identifier to address this file by on the download endpoint." }, - "deploymentVersionId": { + "name": { "type": "string", - "description": "Deployment version targeted by this operation." + "description": "File name, including its extension." }, - "version": { + "size": { "type": "integer", - "exclusiveMinimum": 0, + "minimum": 0, "maximum": 9007199254740991, - "description": "Numeric deployment version." - }, - "action": { - "type": "string", - "enum": ["deploy", "activate"], - "description": "Operation being performed on the deployment version." + "description": "File size in bytes." }, - "status": { + "type": { "type": "string", - "enum": ["preparing", "activating", "active", "failed", "superseded"], - "description": "Current deployment lifecycle status." - }, - "isCurrent": { - "default": true, - "description": "Whether this operation still describes the current deployment attempt.", - "type": "boolean" - }, - "readiness": { - "$ref": "#/components/schemas/DeploymentReadiness" + "description": "MIME type recorded for the file." }, - "requestedAt": { + "downloadPath": { "type": "string", - "description": "ISO 8601 timestamp when the deployment operation was requested.", - "format": "date-time" + "description": "Path to fetch this file's bytes from, relative to the API host." }, - "activatedAt": { - "description": "ISO 8601 activation timestamp, or null before activation completes.", - "format": "date-time", + "base64": { "anyOf": [ { "type": "string" @@ -7652,97 +11918,56 @@ { "type": "null" } - ] - }, - "error": { - "description": "Deployment failure details, or null when no failure occurred.", - "anyOf": [ - { - "$ref": "#/components/schemas/DeploymentOperationError" - }, - { - "type": "null" - } - ] - } - }, - "required": [ - "id", - "deploymentVersionId", - "version", - "action", - "status", - "isCurrent", - "readiness", - "requestedAt" - ], - "additionalProperties": false, - "title": "Deployment operation", - "description": "Lifecycle state of a deployment or version-activation attempt." - }, - "DeploymentReadiness": { - "type": "object", - "properties": { - "webhooks": { - "type": "string", - "enum": ["pending", "ready", "not_applicable"], - "description": "Webhook synchronization readiness." - }, - "schedules": { - "type": "string", - "enum": ["pending", "ready", "not_applicable"], - "description": "Schedule synchronization readiness." - }, - "mcp": { - "type": "string", - "enum": ["pending", "ready", "not_applicable"], - "description": "MCP synchronization readiness." + ], + "description": "Base64-encoded contents when `includeFileBase64` was requested and the file fits the inline ceiling, otherwise null." } }, - "required": ["webhooks", "schedules", "mcp"], + "required": ["id", "name", "size", "type", "downloadPath", "base64"], "additionalProperties": false, - "title": "Deployment readiness", - "description": "Readiness of the side effects required to activate a deployment." + "title": "Workflow run file", + "description": "A file produced by a workflow run." }, - "DeploymentOperationError": { + "WorkflowRunStatus": { "type": "object", "properties": { - "code": { + "runId": { "type": "string", - "description": "Stable deployment failure code." + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] }, - "message": { + "workflowId": { "type": "string", - "description": "Human-readable deployment failure message." + "description": "Workflow that produced the run." }, - "retryable": { - "type": "boolean", - "description": "Whether retrying the deployment may succeed." - } - }, - "required": ["code", "message", "retryable"], - "additionalProperties": false, - "title": "Deployment operation error", - "description": "Failure details for a deployment lifecycle operation." - }, - "VersionActivationResult": { - "title": "Version activation result", - "description": "Activation attempt accepted for processing. Activation is asynchronous; inspect `isDeployed` and `latestDeploymentAttempt` for current state.", - "$ref": "#/components/schemas/RollbackResult" - }, - "RollbackResult": { - "type": "object", - "properties": { - "id": { + "status": { "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + "enum": [ + "pending", + "running", + "paused", + "redacting", + "completed", + "failed", + "cancelled", + "queued" + ], + "description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting to be resumed — either held at a human-in-the-loop pause point, or left paused by a resume attempt that did not complete. Only the single-run response distinguishes the two, through `paused.automaticResumeWaitingReason`." }, - "isDeployed": { - "type": "boolean", - "description": "Whether a workflow version is currently live and available for API execution." + "trigger": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Trigger type that started the run. Backfilled as `api` for a run that is still queued, so it is populated from the first poll." }, - "deployedAt": { + "startedAt": { "anyOf": [ { "type": "string" @@ -7751,1993 +11976,2664 @@ "type": "null" } ], - "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", - "format": "date-time", - "examples": ["2026-06-12T10:30:00.000Z"] - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." + "description": "ISO 8601 start timestamp. A queued run reports the time it was enqueued, so it is populated from the first poll.", + "format": "date-time" }, - "activeDeployment": { + "endedAt": { "anyOf": [ { - "$ref": "#/components/schemas/ActiveDeploymentSummary" + "type": "string" }, { "type": "null" } ], - "description": "Currently live deployment version, or null while no version is active." + "description": "ISO 8601 end timestamp, or null while nonterminal.", + "format": "date-time" }, - "latestDeploymentAttempt": { + "durationMs": { "anyOf": [ { - "$ref": "#/components/schemas/DeploymentOperationSummary" + "type": "number" }, { "type": "null" } ], - "description": "Most recent deployment lifecycle attempt, or null when none is available." + "description": "Run duration in milliseconds, or null while active." }, - "version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Deployment version selected for re-activation." - } - }, - "required": [ - "id", - "isDeployed", - "deployedAt", - "warnings", - "activeDeployment", - "latestDeploymentAttempt", - "version" - ], - "additionalProperties": false, - "title": "Rollback result", - "description": "Rollback attempt accepted for processing. Activation is asynchronous; inspect `isDeployed` and `latestDeploymentAttempt` for current state." - }, - "ActivateWorkflowVersionResponse": { - "type": "object", - "properties": { - "data": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] - }, - "isDeployed": { - "type": "boolean", - "description": "Whether a workflow version is currently live and available for API execution." - }, - "deployedAt": { - "anyOf": [ - { - "type": "string" + "paused": { + "anyOf": [ + { + "type": "object", + "properties": { + "contextId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Resume context identifier, or null while every pause point is mid-resume." }, - { - "type": "null" - } - ], - "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", - "format": "date-time", - "examples": ["2026-06-12T10:30:00.000Z"] - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." - }, - "activeDeployment": { - "anyOf": [ - { - "$ref": "#/components/schemas/ActiveDeploymentSummary" + "pausedAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 timestamp when the execution entered the paused state." }, - { - "type": "null" - } - ], - "description": "Currently live deployment version, or null while no version is active." - }, - "latestDeploymentAttempt": { - "anyOf": [ - { - "$ref": "#/components/schemas/DeploymentOperationSummary" + "resumeAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 scheduled automatic-resume timestamp, or null when no resume time is set." }, - { - "type": "null" + "pauseKind": { + "anyOf": [ + { + "type": "string", + "enum": ["time", "human"] + }, + { + "type": "null" + } + ], + "description": "Whether the pause waits for time or human input, or null when unspecified." + }, + "blockedOnBlockId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Workflow block awaiting resume, or null when no block is identified." + }, + "automaticResumeWaitingReason": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Why automatic resume is waiting, or null when it is not — on a paused run, null means it is waiting on human input. Recorded whenever a resume attempt fails and cleared once one succeeds. A non-retryable or exhausted failure is prefixed `Automatic resume requires manual intervention: `." + }, + "pausePointCount": { + "type": "number", + "description": "Number of pause points tracked for the execution." + }, + "resumedCount": { + "type": "number", + "description": "Number of pause points that have resumed." } + }, + "required": [ + "contextId", + "pausedAt", + "resumeAt", + "pauseKind", + "blockedOnBlockId", + "automaticResumeWaitingReason", + "pausePointCount", + "resumedCount" ], - "description": "Most recent deployment lifecycle attempt, or null when none is available." + "additionalProperties": false }, - "version": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991, - "description": "Deployment version selected for re-activation." + { + "type": "null" } - }, - "required": [ - "id", - "isDeployed", - "deployedAt", - "warnings", - "activeDeployment", - "latestDeploymentAttempt", - "version" ], - "additionalProperties": false, - "description": "Response data.", - "$ref": "#/components/schemas/VersionActivationResult" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Activate workflow version response", - "description": "Current deployment state after accepting the activation attempt.", - "examples": [ - { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "isDeployed": false, - "deployedAt": null, - "warnings": [], - "activeDeployment": null, - "latestDeploymentAttempt": { - "id": "depop_01J8ZK4RX5N7Y3S0U8D6E1W2", - "deploymentVersionId": "depver_01J8ZK4RX5N7Y3S0U8D6E1W3", - "version": 3, - "action": "activate", - "status": "activating", - "isCurrent": true, - "readiness": { - "webhooks": "ready", - "schedules": "ready", - "mcp": "not_applicable" - }, - "requestedAt": "2026-06-12T10:30:00.000Z", - "activatedAt": null, - "error": null - }, - "version": 3 - } - } - ] - }, - "ActivateWorkflowVersionRequest": { - "default": {}, - "title": "Activate workflow version request", - "description": "No body. The version to promote is named by the request path.", - "examples": [{}], - "type": "object", - "properties": {}, - "additionalProperties": false - }, - "RevertWorkflowVersionResult": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier." + "description": "Current pause details, or null when the run is not paused." }, - "version": { + "cost": { "anyOf": [ { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "type": "object", + "properties": { + "total": { + "type": "number", + "description": "Total credits consumed by the run." + } + }, + "required": ["total"], + "additionalProperties": false }, { - "type": "string", - "const": "active" + "type": "null" } ], - "description": "Deployment version loaded into the draft, or `active` for the live version." - }, - "lastSaved": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Epoch milliseconds at which the overwritten draft was saved." - } - }, - "required": ["id", "version", "lastSaved"], - "additionalProperties": false, - "title": "Revert workflow version result", - "description": "The draft after it was overwritten by a deployment version." - }, - "RevertWorkflowVersionResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/RevertWorkflowVersionResult" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Revert workflow version response", - "description": "The draft after it was overwritten by the deployment version.", - "examples": [ - { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "version": 3, - "lastSaved": 1765535400000 - } - } - ] - }, - "RevertWorkflowVersionRequest": { - "default": {}, - "title": "Revert workflow version request", - "description": "No body. The version to load into the draft is named by the request path.", - "examples": [{}], - "type": "object", - "properties": {}, - "additionalProperties": false - }, - "WorkflowDeployment": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] - }, - "isDeployed": { - "type": "boolean", - "description": "Whether a workflow version is currently live and available for API execution." + "description": "Credit cost, or null when unavailable." }, - "deployedAt": { + "error": { "anyOf": [ { - "type": "string" + "$ref": "#/components/schemas/ExecutionError" }, { "type": "null" } ], - "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", - "format": "date-time", - "examples": ["2026-06-12T10:30:00.000Z"] - }, - "warnings": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." + "description": "Structured execution failure, or null when none occurred. Reclassified from the persisted error message, so `blockId`/`blockName`/`blockType` are absent and a block-level failure reports `EXECUTION_FAILED` here even when the same run reported `BLOCK_EXECUTION_FAILED` on its synchronous execute response." }, - "activeDeployment": { + "output": { "anyOf": [ { - "$ref": "#/components/schemas/ActiveDeploymentSummary" + "description": "Final workflow output value." }, { "type": "null" } ], - "description": "Currently live deployment version, or null while no version is active." + "description": "Final workflow output when requested, otherwise null." }, - "latestDeploymentAttempt": { + "blockOutputs": { "anyOf": [ { - "$ref": "#/components/schemas/DeploymentOperationSummary" + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "Output value produced by one workflow block." + } }, { "type": "null" } ], - "description": "Most recent deployment lifecycle attempt, or null when none is available." - }, - "needsRedeployment": { - "type": "boolean", - "description": "Whether the editable draft has diverged from the live deployment version. False while a deployment attempt is still preparing or activating, and false when nothing is deployed." + "description": "Outputs of the blocks named by `selectedOutputs`, or null when none were requested. Gated by `selectedOutputs` alone — `includeOutput` governs `output` only." }, - "isPublicApi": { - "type": "boolean", - "description": "Whether anyone with the execution URL can run the deployed workflow and consume billed usage without an API key. Change this with Update Workflow Public API Access." + "files": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2RunFile" + } + }, + { + "type": "null" + } + ], + "description": "Files this run produced, or null when `includeOutput` is false. Matches the nullability of `output`." } }, "required": [ - "id", - "isDeployed", - "deployedAt", - "warnings", - "activeDeployment", - "latestDeploymentAttempt", - "needsRedeployment", - "isPublicApi" + "runId", + "workflowId", + "status", + "trigger", + "startedAt", + "endedAt", + "durationMs", + "paused", + "cost", + "error", + "output", + "blockOutputs", + "files" ], "additionalProperties": false, - "title": "Workflow deployment", - "description": "Current deployment state of a workflow, including draft-versus-live drift and the most recent deployment attempt." + "title": "Workflow run status", + "description": "Detailed current state of a workflow run." }, - "WorkflowDeploymentResponse": { + "WorkflowRunStatusResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowDeployment" + "$ref": "#/components/schemas/WorkflowRunStatus" } }, "required": ["data"], "additionalProperties": false, - "title": "Workflow deployment response", - "description": "Current deployment state, including draft-versus-live drift and whether the deployment is publicly executable.", + "title": "Workflow run status response", + "description": "Detailed current state of a workflow run.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "isDeployed": true, - "needsRedeployment": true, - "isPublicApi": false, - "deployedAt": "2026-06-12T10:30:00.000Z", - "warnings": [], - "activeDeployment": { - "deploymentVersionId": "depver_01J8ZK3QW4M6X2R9T7B5C0V2", - "version": 3, - "deployedAt": "2026-06-12T10:30:00.000Z" + "runId": "run_8f14e45f-ceea-467f-a", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "status": "completed", + "trigger": "api", + "startedAt": "2026-08-09T18:04:10.000Z", + "endedAt": "2026-08-09T18:04:11.000Z", + "durationMs": 1000, + "paused": null, + "cost": { + "total": 12 }, - "latestDeploymentAttempt": { - "id": "depop_01J8ZK3QW4M6X2R9T7B5C0V1", - "deploymentVersionId": "depver_01J8ZK3QW4M6X2R9T7B5C0V2", - "version": 3, - "action": "deploy", - "status": "active", - "isCurrent": true, - "readiness": { - "webhooks": "ready", - "schedules": "ready", - "mcp": "not_applicable" - }, - "requestedAt": "2026-06-12T10:29:58.000Z", - "activatedAt": "2026-06-12T10:30:00.000Z", - "error": null - } + "error": null, + "output": { + "result": "Ticket routed to Support" + }, + "blockOutputs": null, + "files": [ + { + "id": "file_1a2b3c", + "name": "summary.pdf", + "size": 20480, + "type": "application/pdf", + "downloadPath": "/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/run_8f14e45f-ceea-467f-a/files/file_1a2b3c", + "base64": null + } + ] } } ] }, - "WorkflowPublicApiSettings": { + "ResumeWorkflowSyncResponse": { "type": "object", "properties": { - "id": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkflowRunResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Synchronous workflow resume response", + "description": "Completed, failed, paused, or cancelled resumed workflow run.", + "examples": [ + { + "data": { + "runId": "run_8f14e45f-ceea-467f-a", + "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", + "status": "completed", + "output": { + "result": "Ticket routed to Support" + }, + "error": null, + "startedAt": "2026-08-09T18:04:10.000Z", + "endedAt": "2026-08-09T18:04:11.000Z", + "durationMs": 1000 + } + } + ] + }, + "QueuedWorkflowResume": { + "type": "object", + "properties": { + "runId": { "type": "string", - "description": "Unique workflow identifier." + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] + }, + "statusUrl": { + "type": "string", + "format": "uri", + "description": "Absolute URL of the workflow run resource." }, - "isPublicApi": { - "type": "boolean", - "description": "Whether the deployed workflow accepts unauthenticated public API execution." + "queuePosition": { + "description": "Current queue position, when available.", + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 } }, - "required": ["id", "isPublicApi"], + "required": ["runId", "statusUrl"], "additionalProperties": false, - "title": "Workflow public API settings", - "description": "Whether a deployed workflow is executable without an API key." + "title": "Queued workflow resume", + "description": "Receipt returned when a resumed workflow attempt is queued." }, - "UpdateWorkflowPublicApiResponse": { + "ResumeWorkflowQueuedResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowPublicApiSettings" + "$ref": "#/components/schemas/QueuedWorkflowResume" } }, "required": ["data"], "additionalProperties": false, - "title": "Update workflow public API response", - "description": "Public API access after the update.", + "title": "Queued workflow resume response", + "description": "Receipt returned when a resumed workflow attempt is queued.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "isPublicApi": true + "runId": "run_8f14e45f-ceea-467f-a", + "statusUrl": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/run_8f14e45f-ceea-467f-a" } } ] }, - "UpdateWorkflowPublicApiRequest": { + "ResumeWorkflowRequest": { "type": "object", "properties": { - "isPublicApi": { - "type": "boolean", - "description": "Whether the deployed workflow should accept unauthenticated public API execution." + "contextId": { + "type": "string", + "minLength": 1, + "description": "Human-in-the-loop pause-context identifier." + }, + "input": { + "description": "Input supplied to the paused workflow block." } }, - "required": ["isPublicApi"], + "required": ["contextId"], "additionalProperties": false, - "title": "Update workflow public API request", - "description": "Enable or disable unauthenticated public execution of the deployed workflow.", + "title": "Resume workflow request", + "description": "Pause context and optional input used to resume a workflow run.", "examples": [ { - "isPublicApi": true + "contextId": "ctx_123", + "input": { + "approved": true + } } ] }, - "DeployResult": { + "CancelWorkflowRunResult": { "type": "object", "properties": { - "id": { + "success": { + "type": "boolean", + "description": "Whether cancellation was accepted." + }, + "runId": { "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9._:-]+$", + "description": "Unique workflow run identifier.", + "examples": ["run_8f14e45f-ceea-467f-a"] }, - "isDeployed": { + "redisAvailable": { "type": "boolean", - "description": "Whether a workflow version is currently live and available for API execution." - }, - "deployedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", - "format": "date-time", - "examples": ["2026-06-12T10:30:00.000Z"] + "description": "Whether the distributed cancellation channel was available." }, - "warnings": { - "type": "array", - "items": { - "type": "string" - }, - "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." + "durablyRecorded": { + "type": "boolean", + "description": "Whether this request durably recorded a cancellation. Always false for a run that was already terminal, where the request is satisfied but nothing was written." }, - "activeDeployment": { - "anyOf": [ - { - "$ref": "#/components/schemas/ActiveDeploymentSummary" - }, - { - "type": "null" - } - ], - "description": "Currently live deployment version, or null while no version is active." + "locallyAborted": { + "type": "boolean", + "description": "Whether an in-process execution was aborted." }, - "latestDeploymentAttempt": { - "anyOf": [ - { - "$ref": "#/components/schemas/DeploymentOperationSummary" - }, - { - "type": "null" - } - ], - "description": "Most recent deployment lifecycle attempt, or null when none is available." + "pausedCancelled": { + "type": "boolean", + "description": "Whether a paused execution was cancelled." }, - "version": { - "description": "Deployment version created for this attempt, when available.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "reason": { + "description": "Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` and `queue_cancelled` are successful cancellation values. `already_cancelled`, `already_completed`, and `already_failed` mean the run had already reached that terminal state, so nothing was cancelled and `durablyRecorded` is false. The remaining values identify a degraded or incomplete cancellation step.", + "type": "string", + "enum": [ + "recorded", + "already_cancelled", + "already_completed", + "already_failed", + "redis_unavailable", + "redis_write_failed", + "paused_event_publish_failed", + "paused_database_cancel_failed", + "queue_cancelled", + "active_resume_signal_failed", + "cancellation_not_finalized" + ] } }, "required": [ - "id", - "isDeployed", - "deployedAt", - "warnings", - "activeDeployment", - "latestDeploymentAttempt" + "success", + "runId", + "redisAvailable", + "durablyRecorded", + "locallyAborted", + "pausedCancelled" ], "additionalProperties": false, - "title": "Deploy result", - "description": "Deployment attempt accepted for asynchronous activation. `latestDeploymentAttempt` identifies the attempt. Poll Get Workflow Deployment for `isDeployed` and `deployedAt`, or List Workflow Versions for `isActive`." + "title": "Cancel workflow run result", + "description": "Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state succeeds with no effect, reported as `durablyRecorded: false` with an `already_*` reason naming the state observed." }, - "DeployWorkflowResponse": { + "CancelWorkflowRunResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/DeployResult" + "$ref": "#/components/schemas/CancelWorkflowRunResult" } }, "required": ["data"], "additionalProperties": false, - "title": "Deploy workflow response", - "description": "Current deployment state after accepting the attempt.", + "title": "Cancel workflow run response", + "description": "Outcome of the cancellation request.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "isDeployed": false, - "deployedAt": null, - "warnings": [], - "activeDeployment": null, - "latestDeploymentAttempt": { - "id": "depop_01J8ZK3QW4M6X2R9T7B5C0V1", - "deploymentVersionId": "depver_01J8ZK3QW4M6X2R9T7B5C0V2", - "version": 3, - "action": "deploy", - "status": "preparing", - "isCurrent": true, - "readiness": { - "webhooks": "pending", - "schedules": "ready", - "mcp": "not_applicable" - }, - "requestedAt": "2026-06-12T10:30:00.000Z", - "activatedAt": null, - "error": null - }, - "version": 3 + "success": true, + "runId": "run_8f14e45f-ceea-467f-a", + "redisAvailable": true, + "durablyRecorded": true, + "locallyAborted": true, + "pausedCancelled": false, + "reason": "recorded" } } ] }, - "DeployWorkflowRequest": { - "default": {}, - "title": "Deploy workflow request", - "description": "Optional metadata for the new deployment version.", - "examples": [ - { - "name": "Escalation routing", - "description": "Adds the priority escalation branch." - } - ], + "WorkflowFolder": { "type": "object", "properties": { "name": { - "description": "Optional label for the deployment version.", "type": "string", - "minLength": 1, - "maxLength": 100 + "description": "Folder name." }, - "description": { - "description": "Optional release note for the deployment version.", - "anyOf": [ - { - "type": "string", - "maxLength": 50000 - }, - { - "type": "null" - } - ] - } - }, - "additionalProperties": false - }, - "UndeployResult": { - "type": "object", - "properties": { - "id": { + "path": { "type": "string", - "description": "Unique workflow identifier.", - "examples": ["3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36"] + "title": "Non-root folder path", + "description": "Canonical folder path used as the public folder identifier.", + "maxLength": 4096 }, - "isDeployed": { - "type": "boolean", - "description": "Whether a workflow version is currently live and available for API execution." + "parentPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical parent path; `/` is the root.", + "maxLength": 4096 }, - "deployedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 timestamp associated with the deployment, or null when unavailable.", - "format": "date-time", - "examples": ["2026-06-12T10:30:00.000Z"] + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when the folder was created.", + "format": "date-time" + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the folder was last updated.", + "format": "date-time" }, - "warnings": { + "locked": { + "type": "boolean", + "description": "Whether the folder is currently locked for mutation." + } + }, + "required": ["name", "path", "parentPath", "createdAt", "updatedAt", "locked"], + "additionalProperties": false, + "title": "Workflow folder", + "description": "A canonical workflow folder and its mutation lock state." + }, + "WorkflowFolderListResponse": { + "type": "object", + "properties": { + "data": { "type": "array", "items": { - "type": "string" + "$ref": "#/components/schemas/WorkflowFolder" }, - "description": "Non-fatal synchronization warnings. Empty when there is nothing to report." + "description": "Items in the current page." }, - "activeDeployment": { + "nextCursor": { "anyOf": [ { - "$ref": "#/components/schemas/ActiveDeploymentSummary" + "type": "string" }, { "type": "null" } ], - "description": "Currently live deployment version, or null while no version is active." - }, - "latestDeploymentAttempt": { - "anyOf": [ - { - "$ref": "#/components/schemas/DeploymentOperationSummary" - }, + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Workflow folder list response", + "description": "A list of canonical workflow folders.", + "examples": [ + { + "data": [ { - "type": "null" + "name": "Operations", + "path": "/Operations", + "parentPath": "/", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-05-01T09:00:00.000Z", + "locked": false } ], - "description": "Most recent deployment lifecycle attempt, or null when none is available." + "nextCursor": null } - }, - "required": [ - "id", - "isDeployed", - "deployedAt", - "warnings", - "activeDeployment", - "latestDeploymentAttempt" - ], - "additionalProperties": false, - "title": "Undeploy result", - "description": "Deployment state after a successful undeploy. `isDeployed` is false and no workflow version is active." + ] }, - "UndeployWorkflowResponse": { + "CreateWorkflowFolderResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/UndeployResult" + "$ref": "#/components/schemas/WorkflowFolder" } }, "required": ["data"], "additionalProperties": false, - "title": "Undeploy workflow response", - "description": "Deployment state after deactivating the active version.", + "title": "Create workflow folder response", + "description": "The created workflow folder.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "isDeployed": false, - "deployedAt": null, - "warnings": [], - "activeDeployment": null, - "latestDeploymentAttempt": null + "name": "Operations", + "path": "/Operations", + "parentPath": "/", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-05-01T09:00:00.000Z", + "locked": false } } ] }, - "RollbackWorkflowResponse": { + "NonRootFolderPathInput": { + "title": "Non-root folder path input", + "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", + "maxLength": 4096, + "type": "string" + }, + "CreateWorkflowFolderRequest": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace in which to create the folder." + }, + "path": { + "description": "Path of the folder to create.", + "$ref": "#/components/schemas/NonRootFolderPathInput" + } + }, + "required": ["workspaceId", "path"], + "additionalProperties": false, + "title": "Create workflow folder request", + "description": "Workspace and canonical path for a new workflow folder." + }, + "RelocateWorkflowFolderResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/RollbackResult" + "$ref": "#/components/schemas/WorkflowFolder" } }, "required": ["data"], "additionalProperties": false, - "title": "Rollback workflow response", - "description": "Current deployment state after accepting the rollback attempt.", + "title": "Relocate workflow folder response", + "description": "The relocated workflow folder.", "examples": [ { "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "isDeployed": false, - "deployedAt": null, - "warnings": [], - "activeDeployment": null, - "latestDeploymentAttempt": { - "id": "depop_01J8ZK4RX5N7Y3S0U8D6E1W2", - "deploymentVersionId": "depver_01J8ZK4RX5N7Y3S0U8D6E1W3", - "version": 2, - "action": "activate", - "status": "activating", - "isCurrent": true, - "readiness": { - "webhooks": "ready", - "schedules": "ready", - "mcp": "not_applicable" - }, - "requestedAt": "2026-06-12T10:30:00.000Z", - "activatedAt": null, - "error": null - }, - "version": 2 + "name": "Support", + "path": "/Support", + "parentPath": "/", + "createdAt": "2026-05-01T09:00:00.000Z", + "updatedAt": "2026-05-01T09:00:00.000Z", + "locked": false } } ] }, - "RollbackWorkflowRequest": { - "default": {}, - "title": "Rollback workflow request", - "description": "Optional deployment version to reactivate.", - "examples": [ - { - "version": 2 - } - ], + "RelocateWorkflowFolderRequest": { "type": "object", "properties": { - "version": { - "description": "Deployment version to reactivate. Omit to select the previous active version.", - "type": "integer", - "minimum": 1, - "maximum": 2147483647 + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace containing the folder." + }, + "path": { + "description": "Current folder path.", + "$ref": "#/components/schemas/NonRootFolderPathInput" + }, + "destinationPath": { + "description": "New full path for the folder and its descendants.", + "$ref": "#/components/schemas/NonRootFolderPathInput" } }, - "additionalProperties": false + "required": ["workspaceId", "path", "destinationPath"], + "additionalProperties": false, + "title": "Relocate workflow folder request", + "description": "Current and destination paths for a workflow folder." }, - "WorkflowExportPayload": { + "DeleteWorkflowFolderResult": { "type": "object", "properties": { - "version": { + "path": { "type": "string", - "const": "1.0", - "description": "Workflow export format version." + "title": "Folder path", + "description": "Path of the deleted workflow folder.", + "maxLength": 4096 }, - "exportedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the export was created.", - "format": "date-time" + "deleted": { + "type": "boolean", + "const": true, + "description": "Confirms that the folder was deleted." }, - "workflow": { + "deletedItems": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Identifier of the source workflow." - }, - "name": { - "type": "string", - "description": "Name of the exported workflow." - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Description of the exported workflow, or null when unset." - }, - "workspaceId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Identifier of the source workspace, or null for legacy exports." + "folders": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of folders deleted." }, - "folderPath": { - "type": "string", - "title": "Folder path", - "description": "Canonical containing-folder path; `/` is the workspace root.", - "maxLength": 4096 + "workflows": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of workflows deleted." } }, - "required": ["id", "name", "description", "workspaceId", "folderPath"], + "required": ["folders", "workflows"], "additionalProperties": false, - "description": "Source workflow metadata." - }, - "state": { - "type": "object", - "additionalProperties": true, - "description": "Secret-sanitized workflow graph, edges, loops, parallels, metadata, and variables." + "description": "Resources removed by the deletion." } }, - "required": ["version", "exportedAt", "workflow", "state"], + "required": ["path", "deleted", "deletedItems"], "additionalProperties": false, - "title": "Workflow export payload", - "description": "Portable, secret-sanitized workflow export. Workspace-scoped bindings must be selected again after import." + "title": "Delete workflow folder result", + "description": "Confirmation and deletion counts for a workflow folder." }, - "ExportWorkflowResponse": { + "DeleteWorkflowFolderResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowExportPayload" + "$ref": "#/components/schemas/DeleteWorkflowFolderResult" } }, "required": ["data"], "additionalProperties": false, - "title": "Export workflow response", - "description": "Portable, secret-sanitized workflow data.", + "title": "Delete workflow folder response", + "description": "Confirmation and counts for the deleted folder.", "examples": [ { "data": { - "version": "1.0", - "exportedAt": "2026-08-09T18:04:11.000Z", - "workflow": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "folderPath": "/Operations" - }, - "state": { - "blocks": {}, - "edges": [] + "path": "/Operations", + "deleted": true, + "deletedItems": { + "folders": 1, + "workflows": 0 } } } ] }, - "ImportedWorkflow": { + "WorkspaceForkPreview": { "type": "object", "properties": { - "id": { + "previewFingerprint": { "type": "string", - "description": "Identifier of the imported workflow." + "pattern": "^[a-f0-9]{64}$", + "description": "Fingerprint of the reviewed preview and its choices." }, - "name": { + "sourceWorkspaceId": { "type": "string", - "description": "Imported workflow name." + "minLength": 1, + "maxLength": 128, + "description": "Canonical workspace the workflows and resources are copied from." }, - "description": { - "anyOf": [ - { - "type": "string" + "workflows": { + "maxItems": 1000, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "name": { + "type": "string", + "maxLength": 1024, + "description": "Display name of the workflow or workspace." + } }, - { - "type": "null" - } - ], - "description": "Imported workflow description." - }, - "workspaceId": { - "type": "string", - "description": "Workspace that owns the imported workflow." - }, - "folderPath": { - "type": "string", - "title": "Folder path", - "description": "Canonical containing-folder path.", - "maxLength": 4096 + "required": ["sourceWorkflowId", "name"], + "additionalProperties": false + }, + "description": "Eligible workflows and their planned actions." }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was imported.", - "format": "date-time" + "selectedResourceCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of resources explicitly selected for copying." }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the workflow was last updated.", - "format": "date-time" + "draftOnly": { + "type": "boolean", + "const": true, + "description": "True because fork creation produces undeployed drafts." } }, "required": [ - "id", - "name", - "description", - "workspaceId", - "folderPath", - "createdAt", - "updatedAt" + "previewFingerprint", + "sourceWorkspaceId", + "workflows", + "selectedResourceCount", + "draftOnly" ], "additionalProperties": false, - "title": "Imported workflow", - "description": "Workflow created by an import operation." + "title": "WorkspaceForkPreview", + "description": "The WorkspaceForkPreview result." }, - "ImportWorkflowResponse": { + "PreviewWorkspaceForkResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/ImportedWorkflow" + "$ref": "#/components/schemas/WorkspaceForkPreview" } }, "required": ["data"], "additionalProperties": false, - "title": "Import workflow response", - "description": "The workflow created by the import.", - "examples": [ - { - "data": { - "id": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "name": "Customer support triage", - "description": "Routes incoming support requests to the right team.", - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "folderPath": "/Operations", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-08-09T18:04:11.000Z" - } - } - ] + "title": "PreviewWorkspaceFork response", + "description": "The response for this operation." }, - "ImportWorkflowRequest": { + "PreviewWorkspaceForkBody": { "type": "object", "properties": { - "workspaceId": { + "name": { + "description": "Display name of the workflow or workspace.", "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace in which to import the workflow." + "maxLength": 100 }, - "workflow": { - "anyOf": [ - { - "type": "string", - "minLength": 1, - "description": "JSON string containing a workflow export object or bare workflow state." + "copy": { + "description": "Explicit resource selections to copy into the new fork; omitted resource kinds are not copied.", + "type": "object", + "properties": { + "files": { + "description": "Workspace file IDs to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } }, - { - "type": "object", - "additionalProperties": true, - "description": "Workflow export object or bare workflow state." + "tables": { + "description": "Source table identifiers whose schemas and rows are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "knowledgeBases": { + "description": "Source knowledge base identifiers whose documents and content are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "customTools": { + "description": "Source custom tool identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "skills": { + "description": "Source skill identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "mcpServers": { + "description": "External MCP server identifiers to copy; OAuth connections require authorization in the destination.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "workflowMcpServers": { + "description": "Workflow-publishing MCP server identifiers to copy as empty configuration shells.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } } - ], - "description": "Workflow export object, bare workflow state, or JSON string containing either form." - }, - "folderPath": { - "description": "Destination folder path; omit for the workspace root.", - "$ref": "#/components/schemas/FolderPathInput" - }, - "name": { - "description": "Override for the imported workflow name.", - "type": "string", - "minLength": 1, - "maxLength": 200 - }, - "description": { - "description": "Override for the imported workflow description.", - "type": "string", - "maxLength": 2000 - } - }, - "required": ["workspaceId", "workflow"], - "additionalProperties": false, - "title": "Import workflow request", - "description": "Portable workflow data and destination metadata for an import." - }, - "ChatDeploymentListItem": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique chat deployment identifier." - }, - "workflowId": { - "type": "string", - "description": "Workflow this deployment publishes." - }, - "workspaceId": { - "type": "string", - "description": "Workspace the deployment belongs to, derived from its workflow." - }, - "identifier": { - "type": "string", - "description": "URL slug the deployed chat answers on. Unique across live deployments." - }, - "url": { - "type": "string", - "description": "Public URL of the deployed chat. There is no chat subdomain — the identifier is a path segment.", - "examples": ["https://sim.ai/chat/support"] - }, - "title": { - "type": "string", - "description": "Title shown to visitors." - }, - "description": { - "type": "string", - "description": "Description shown to visitors. Empty when unset." - }, - "isActive": { - "type": "boolean", - "description": "Whether the deployment answers requests." - }, - "authType": { - "type": "string", - "enum": ["public", "password", "email", "sso"], - "description": "How visitors are gated: `public` (no gate), `password`, `email`, or `sso`." - }, - "outputConfigs": { - "type": "array", - "items": { - "$ref": "#/components/schemas/StoredChatDeploymentOutputConfig" }, - "description": "Block outputs surfaced to visitors." - }, - "includeThinking": { - "type": "boolean", - "description": "Whether visitors may receive provider thinking events. They must also opt into the streaming protocol." - }, - "includeToolCalls": { - "type": "boolean", - "description": "Whether visitors may receive tool lifecycle events. They must also opt into the streaming protocol." - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the deployment was created.", - "format": "date-time" - }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the deployment was last modified.", - "format": "date-time" - } - }, - "required": [ - "id", - "workflowId", - "workspaceId", - "identifier", - "url", - "title", - "description", - "isActive", - "authType", - "outputConfigs", - "includeThinking", - "includeToolCalls", - "createdAt", - "updatedAt" - ], + "additionalProperties": false + } + }, "additionalProperties": false, - "title": "Chat deployment list entry", - "description": "A workflow published as a hosted chat, without the fields the detail read gates." + "title": "PreviewWorkspaceFork body", + "description": "The body for this operation." }, - "StoredChatDeploymentOutputConfig": { + "WorkspaceOperationReport": { "type": "object", "properties": { - "workflowId": { - "description": "Child workflow containing the selected block. Omitted for the deployed workflow.", - "type": "string" + "operationId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Durable operation identifier to use for polling." }, - "blockId": { + "requestId": { "type": "string", - "description": "Block whose output the chat streams." + "minLength": 1, + "maxLength": 128, + "description": "Stable client request ID for reconciliation and identical retries." }, - "path": { + "workspaceId": { "type": "string", - "description": "Path within that block output. Empty means the whole output." - } - }, - "required": ["blockId", "path"], - "additionalProperties": false, - "title": "Stored chat deployment output config", - "description": "One block output currently surfaced to chat visitors." - }, - "ChatDeploymentListResponse": { - "type": "object", - "properties": { - "data": { + "minLength": 1, + "maxLength": 128, + "description": "Explicit current workspace scope." + }, + "kind": { + "type": "string", + "enum": ["workflow_import", "workspace_fork", "workspace_push", "workspace_pull"], + "description": "Resource or operation kind." + }, + "applied": { + "type": "boolean", + "const": true, + "description": "The business transaction committed, including when follow-up work fails." + }, + "status": { + "type": "string", + "enum": [ + "processing", + "completed", + "completed_with_warnings", + "requires_configuration", + "failed" + ], + "description": "Current operation or deployment outcome." + }, + "resourceIds": { + "maxItems": 5000, "type": "array", "items": { - "$ref": "#/components/schemas/ChatDeploymentListItem" + "type": "string", + "minLength": 1, + "maxLength": 256 }, - "description": "Items in the current page." + "description": "Identifiers of resources created or changed by the committed operation." }, - "nextCursor": { - "anyOf": [ - { - "type": "string" + "issues": { + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "code": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Stable machine-readable issue code." + }, + "message": { + "type": "string", + "maxLength": 2048, + "description": "Human-readable explanation of the issue." + }, + "workflowId": { + "description": "Workflow affected by this issue or deployment attempt.", + "type": "string", + "maxLength": 256 + }, + "blockId": { + "description": "Source block identifier before graph ID regeneration.", + "type": "string", + "maxLength": 256 + }, + "subBlockKey": { + "description": "Registered source field key, including the tool index for nested Agent fields.", + "type": "string", + "maxLength": 256 + } }, - { - "type": "null" + "required": ["code", "message"], + "additionalProperties": false + }, + "description": "Structured warnings, missing configuration, and follow-up failures." + }, + "idMap": { + "description": "Source graph identifiers mapped to the imported identifiers.", + "type": "object", + "propertyNames": { + "type": "string", + "maxLength": 256 + }, + "additionalProperties": { + "type": "string", + "maxLength": 256 + } + }, + "deploymentOperationIds": { + "description": "Exact deployment attempts admitted by the workspace operation.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "maxLength": 256 + } + }, + "deployments": { + "description": "Readiness of the exact admitted deployment attempts.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "object", + "properties": { + "operationId": { + "type": "string", + "maxLength": 256, + "description": "Durable operation identifier to use for polling." + }, + "workflowId": { + "type": "string", + "maxLength": 256, + "description": "Workflow affected by this issue or deployment attempt." + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "Reference format or deployment version number." + }, + "status": { + "type": "string", + "enum": ["preparing", "activating", "active", "failed", "superseded"], + "description": "Current operation or deployment outcome." + }, + "ready": { + "type": "boolean", + "description": "Whether the operation passes its current apply or deployment readiness checks." + }, + "pendingComponents": { + "maxItems": 32, + "type": "array", + "items": { + "type": "string", + "maxLength": 128 + }, + "description": "Deployment components that have not finished becoming ready." + } + }, + "required": [ + "operationId", + "workflowId", + "version", + "status", + "ready", + "pendingComponents" + ], + "additionalProperties": false + } + }, + "triggerUrlChanges": { + "description": "Public trigger paths changed by this sync, with the affected workflow names.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "object", + "properties": { + "workflowName": { + "type": "string", + "maxLength": 1024, + "description": "Name of the workflow whose public trigger path stops serving." + }, + "path": { + "type": "string", + "maxLength": 4096, + "description": "Public trigger path that stops serving after this sync." + } + }, + "required": ["workflowName", "path"], + "additionalProperties": false + } + }, + "backgroundWorkId": { + "description": "Workspace activity identifier for resource-copy progress.", + "type": "string", + "maxLength": 256 + }, + "copyProgress": { + "description": "Completion status and counts for explicitly selected resource copies.", + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["pending", "completed", "failed"], + "description": "Current operation or deployment outcome." + }, + "copied": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of resources copied successfully." + }, + "failed": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of resources that failed to copy." } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + }, + "required": ["status", "copied", "failed"], + "additionalProperties": false } }, - "required": ["data", "nextCursor"], + "required": [ + "operationId", + "requestId", + "workspaceId", + "kind", + "applied", + "status", + "resourceIds", + "issues" + ], "additionalProperties": false, - "title": "Chat deployment list response", - "description": "A cursor-paginated page of chat deployments.", - "examples": [ - { - "data": [ - { - "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", - "identifier": "support", - "url": "https://sim.ai/chat/support", - "title": "Support chat", - "description": "Ask about billing, onboarding, or outages.", - "isActive": true, - "authType": "public", - "outputConfigs": [ - { - "blockId": "block_01J8ZK3QW4M6X2R9T7B5C0V4", - "path": "content" - } - ], - "includeThinking": false, - "includeToolCalls": false, - "createdAt": "2026-06-12T10:30:00.000Z", - "updatedAt": "2026-06-12T10:30:00.000Z" - } - ], - "nextCursor": null - } - ] + "title": "WorkspaceOperationReport", + "description": "The WorkspaceOperationReport result." }, - "StoredChatDeploymentCustomizations": { + "ForkWorkspaceResponse": { "type": "object", "properties": { - "primaryColor": { - "description": "CSS color used for the chat accent.", - "type": "string" - }, - "welcomeMessage": { - "description": "First message shown to a visitor.", - "type": "string" - }, - "imageUrl": { - "description": "Avatar image shown beside assistant messages.", - "type": "string" + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkspaceOperationReport" } }, + "required": ["data"], "additionalProperties": false, - "title": "Stored chat deployment customizations", - "description": "Presentation overrides currently stored on the deployed chat." + "title": "ForkWorkspace response", + "description": "The response for this operation." }, - "ChatDeployment": { + "ForkWorkspaceBody": { "type": "object", "properties": { - "id": { - "type": "string", - "description": "Unique chat deployment identifier." - }, - "workflowId": { + "name": { + "description": "Display name of the workflow or workspace.", "type": "string", - "description": "Workflow this deployment publishes." + "minLength": 1, + "maxLength": 100 }, - "workspaceId": { - "type": "string", - "description": "Workspace the deployment belongs to, derived from its workflow." + "copy": { + "description": "Explicit resource selections to copy into the new fork; omitted resource kinds are not copied.", + "type": "object", + "properties": { + "files": { + "description": "Workspace file IDs to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "tables": { + "description": "Source table identifiers whose schemas and rows are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "knowledgeBases": { + "description": "Source knowledge base identifiers whose documents and content are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "customTools": { + "description": "Source custom tool identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "skills": { + "description": "Source skill identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "mcpServers": { + "description": "External MCP server identifiers to copy; OAuth connections require authorization in the destination.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "workflowMcpServers": { + "description": "Workflow-publishing MCP server identifiers to copy as empty configuration shells.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false }, - "identifier": { + "requestId": { "type": "string", - "description": "URL slug the deployed chat answers on. Unique across live deployments." + "minLength": 1, + "maxLength": 128, + "description": "Stable client request ID for reconciliation and identical retries." }, - "url": { + "previewFingerprint": { "type": "string", - "description": "Public URL of the deployed chat. There is no chat subdomain — the identifier is a path segment.", - "examples": ["https://sim.ai/chat/support"] - }, - "title": { + "pattern": "^[a-f0-9]{64}$", + "description": "Fingerprint of the reviewed preview and its choices." + } + }, + "required": ["requestId", "previewFingerprint"], + "additionalProperties": false, + "title": "ForkWorkspace body", + "description": "The body for this operation." + }, + "WorkspaceSyncPreview": { + "type": "object", + "properties": { + "previewFingerprint": { "type": "string", - "description": "Title shown to visitors." + "pattern": "^[a-f0-9]{64}$", + "description": "Fingerprint of the reviewed preview and its choices." }, - "description": { + "sourceWorkspaceId": { "type": "string", - "description": "Description shown to visitors. Empty when unset." - }, - "isActive": { - "type": "boolean", - "description": "Whether the deployment answers requests." + "minLength": 1, + "maxLength": 128, + "description": "Canonical workspace the workflows and resources are copied from." }, - "authType": { + "targetWorkspaceId": { "type": "string", - "enum": ["public", "password", "email", "sso"], - "description": "How visitors are gated: `public` (no gate), `password`, `email`, or `sso`." + "minLength": 1, + "maxLength": 128, + "description": "Canonical workspace receiving the changes." }, - "hasPassword": { + "ready": { "type": "boolean", - "description": "Whether a password is stored. The password itself is never readable." + "description": "Whether the operation passes its current apply or deployment readiness checks." }, - "allowedEmails": { + "workflows": { + "maxItems": 2000, "type": "array", "items": { - "type": "string" + "type": "object", + "properties": { + "action": { + "type": "string", + "enum": ["create", "replace", "archive"], + "description": "Planned workflow creation, replacement, or archival." + }, + "sourceWorkflowId": { + "description": "Workflow identifier in the source workspace.", + "type": "string", + "minLength": 1 + }, + "targetWorkflowId": { + "description": "Existing target workflow identifier; absent when apply will create a new target.", + "type": "string", + "minLength": 1 + }, + "name": { + "type": "string", + "maxLength": 1024, + "description": "Display name of the workflow or workspace." + } + }, + "required": ["action", "name"], + "additionalProperties": false }, - "description": "Email addresses or domains admitted under `email` and `sso` gating. Empty otherwise." - }, - "customizations": { - "description": "Presentation overrides. Unset fields fall back to platform defaults.", - "$ref": "#/components/schemas/StoredChatDeploymentCustomizations" + "description": "Eligible workflows and their planned actions." }, - "outputConfigs": { + "unresolvedBindings": { + "maxItems": 10000, "type": "array", "items": { - "$ref": "#/components/schemas/StoredChatDeploymentOutputConfig" + "type": "object", + "properties": { + "kind": { + "type": "string", + "maxLength": 256, + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + }, + "blockName": { + "description": "Display name of the affected source block.", + "type": "string", + "maxLength": 1024 + }, + "reason": { + "description": "Structured explanation of the unresolved binding.", + "type": "string", + "maxLength": 256 + } + }, + "required": ["kind", "sourceId"], + "additionalProperties": false }, - "description": "Block outputs surfaced to visitors." - }, - "includeThinking": { - "type": "boolean", - "description": "Whether visitors may receive provider thinking events. They must also opt into the streaming protocol." - }, - "includeToolCalls": { - "type": "boolean", - "description": "Whether visitors may receive tool lifecycle events. They must also opt into the streaming protocol." - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when the deployment was created.", - "format": "date-time" + "description": "Source references that still require destination mappings or explicit copy choices." }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp when the deployment was last modified.", - "format": "date-time" - } - }, - "required": [ - "id", - "workflowId", - "workspaceId", - "identifier", - "url", - "title", - "description", - "isActive", - "authType", - "hasPassword", - "allowedEmails", - "customizations", - "outputConfigs", - "includeThinking", - "includeToolCalls", - "createdAt", - "updatedAt" - ], - "additionalProperties": false, - "title": "Chat deployment", - "description": "A workflow published as a hosted chat." - }, - "GetWorkflowChatDeploymentResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/ChatDeployment" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Get workflow chat deployment response", - "description": "The workflow's chat deployment.", - "examples": [ - { - "data": { - "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", - "identifier": "support", - "url": "https://sim.ai/chat/support", - "title": "Support chat", - "description": "Ask about billing, onboarding, or outages.", - "isActive": true, - "authType": "public", - "hasPassword": false, - "allowedEmails": [], - "customizations": { - "primaryColor": "#6F3DFA", - "welcomeMessage": "Hi there! How can I help?" - }, - "outputConfigs": [ - { - "blockId": "block_01J8ZK3QW4M6X2R9T7B5C0V4", - "path": "content" + "configuration": { + "maxItems": 10000, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "maxLength": 256, + "description": "Block identifier in the source workflow." + }, + "subBlockKey": { + "type": "string", + "maxLength": 1024, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "title": { + "type": "string", + "maxLength": 1024, + "description": "Human-readable configuration field label." + }, + "required": { + "type": "boolean", + "description": "Whether the reference or configuration is required for this operation." + }, + "currentValue": { + "type": "string", + "maxLength": 65536, + "description": "Persisted sync override or proposed override; empty when neither is configured." + }, + "multiSelect": { + "description": "Whether the field accepts comma-separated selections.", + "type": "boolean" + }, + "selectorKey": { + "description": "Registered selector key for discovering this field’s options.", + "type": "string", + "maxLength": 256 + }, + "discoveryWorkspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace scope for selector discovery: source for a parent being copied, otherwise destination." + }, + "context": { + "type": "object", + "propertyNames": { + "type": "string", + "maxLength": 256 + }, + "additionalProperties": { + "type": "string", + "maxLength": 4096 + }, + "description": "Allowlisted selector dependencies scoped to discoveryWorkspaceId." + }, + "parentKind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox" + ], + "description": "Resource kind that owns this dependent configuration." + }, + "parentSourceId": { + "type": "string", + "maxLength": 4096, + "description": "Source identifier of the parent resource being mapped." + }, + "parentContextKey": { + "description": "Selector context key supplied by the mapped parent resource.", + "type": "string", + "maxLength": 256 } + }, + "required": [ + "sourceWorkflowId", + "sourceBlockId", + "subBlockKey", + "title", + "required", + "currentValue", + "discoveryWorkspaceId", + "context", + "parentKind", + "parentSourceId" ], - "includeThinking": false, - "includeToolCalls": false, - "createdAt": "2026-06-12T10:30:00.000Z", - "updatedAt": "2026-06-12T10:30:00.000Z" - } - } - ] - }, - "ReplaceWorkflowChatDeploymentResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/ChatDeployment" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Replace workflow chat deployment response", - "description": "The chat deployment as stored after the replace.", - "examples": [ - { - "data": { - "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "workspaceId": "9f4c2a10-3b7e-4d58-8f6a-2c1d0e5b7a94", - "identifier": "support", - "url": "https://sim.ai/chat/support", - "title": "Support chat", - "description": "Ask about billing, onboarding, or outages.", - "isActive": true, - "authType": "public", - "hasPassword": false, - "allowedEmails": [], - "customizations": { - "primaryColor": "#6F3DFA", - "welcomeMessage": "Hi there! How can I help?" + "additionalProperties": false + }, + "description": "Dependent fields that may need destination-specific values." + }, + "excludedTargets": { + "maxItems": 1000, + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Resource identifier." + }, + "name": { + "type": "string", + "maxLength": 1024, + "description": "Display name of the workflow or workspace." + } }, - "outputConfigs": [ - { - "blockId": "block_01J8ZK3QW4M6X2R9T7B5C0V4", - "path": "content" + "required": ["id", "name"], + "additionalProperties": false + }, + "description": "Target workflows explicitly excluded from sync." + }, + "triggerSlots": { + "maxItems": 10000, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Stable source trigger block identifier to use in trigger mappings." + }, + "blockName": { + "type": "string", + "maxLength": 1024, + "description": "Display name of the source trigger block." + }, + "workflowName": { + "type": "string", + "maxLength": 1024, + "description": "Display name of the source workflow." + }, + "ownPath": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Existing target trigger path, preserved automatically and not configurable." + }, + "adoptablePaths": { + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + "description": "Retiring paths in the same target workflow with a compatible trigger provider." + }, + "defaultAdoptPath": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Default adoption when ownPath is null; null then allocates a new path." } + }, + "required": [ + "sourceWorkflowId", + "sourceBlockId", + "blockName", + "workflowName", + "ownPath", + "adoptablePaths", + "defaultAdoptPath" ], - "includeThinking": false, - "includeToolCalls": false, - "createdAt": "2026-06-12T10:30:00.000Z", - "updatedAt": "2026-06-12T10:30:00.000Z" - } - } - ] - }, - "ChatDeploymentCustomizations": { - "type": "object", - "properties": { - "primaryColor": { - "description": "CSS color used for the chat accent.", - "type": "string", - "minLength": 1, - "maxLength": 64 - }, - "welcomeMessage": { - "description": "First message shown to a visitor.", - "type": "string", - "maxLength": 2000 + "additionalProperties": false + }, + "description": "Source triggers and the target paths available for explicit adoption choices." }, - "imageUrl": { - "description": "Avatar image shown beside assistant messages.", - "type": "string", - "maxLength": 2048 + "triggerUrlChanges": { + "maxItems": 1000, + "type": "array", + "items": { + "type": "object", + "properties": { + "workflowName": { + "type": "string", + "maxLength": 1024, + "description": "Name of the affected workflow." + }, + "path": { + "type": "string", + "maxLength": 4096, + "description": "Public trigger path that stops serving after this sync." + } + }, + "required": ["workflowName", "path"], + "additionalProperties": false + }, + "description": "Retiring target trigger URLs no arriving trigger adopts." } }, + "required": [ + "previewFingerprint", + "sourceWorkspaceId", + "targetWorkspaceId", + "ready", + "workflows", + "unresolvedBindings", + "configuration", + "excludedTargets", + "triggerSlots", + "triggerUrlChanges" + ], "additionalProperties": false, - "title": "Chat deployment customizations", - "description": "Presentation overrides for the deployed chat." + "title": "WorkspaceSyncPreview", + "description": "The WorkspaceSyncPreview result." }, - "ChatDeploymentOutputConfig": { + "PreviewWorkspacePushResponse": { "type": "object", "properties": { - "workflowId": { - "description": "Child workflow containing the selected block. Omit for the deployed workflow.", - "type": "string", - "minLength": 1 - }, - "blockId": { - "type": "string", - "minLength": 1, - "description": "Block whose output the chat streams." - }, - "path": { - "type": "string", - "minLength": 1, - "description": "Path within that block output." + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkspaceSyncPreview" } }, - "required": ["blockId", "path"], + "required": ["data"], "additionalProperties": false, - "title": "Chat deployment output config", - "description": "One block output surfaced to chat visitors." + "title": "PreviewWorkspacePush response", + "description": "The response for this operation." }, - "ReplaceChatDeploymentRequest": { + "PreviewWorkspacePushBody": { "type": "object", "properties": { - "identifier": { + "otherWorkspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "pattern": "^[a-z0-9-]+$", - "description": "URL slug the deployed chat answers on. Must be free across live deployments." - }, - "title": { - "type": "string", - "minLength": 1, - "maxLength": 200, - "description": "Title shown to visitors." - }, - "description": { - "description": "Description shown to visitors. Omitted clears it.", - "type": "string", - "maxLength": 2000 - }, - "customizations": { - "description": "Presentation overrides. Omitted fields take platform defaults.", - "$ref": "#/components/schemas/ChatDeploymentCustomizations" - }, - "authType": { - "description": "How visitors are gated. `public` leaves the chat open to anyone holding the URL.", - "default": "public", - "type": "string", - "enum": ["public", "password", "email", "sso"] - }, - "password": { - "description": "Write-only password. Required whenever `authType` is `password`, and rejected otherwise. Never readable back.", - "type": "string", - "minLength": 1, - "maxLength": 1024 + "description": "Workspace on the other side of the direct fork edge." }, - "allowedEmails": { - "description": "Email addresses or domains admitted under `email` and `sso` gating. At least one is required for those modes.", - "maxItems": 500, + "mappings": { + "description": "Mappings keyed by resource type and source identifier.", + "maxItems": 5000, "type": "array", "items": { - "type": "string", - "minLength": 1 + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "enum": [ + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "file", + "file_folder", + "mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ], + "description": "Resource type stored on the canonical parent/child edge." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + } + }, + "required": ["resourceType", "sourceId", "targetId"], + "additionalProperties": false } }, - "outputConfigs": { - "description": "Block outputs to surface to visitors. Omitted surfaces none.", - "maxItems": 100, + "dependentValues": { + "description": "Destination-dependent choices keyed by source workflow, block, and field identities.", + "maxItems": 2000, "type": "array", "items": { - "$ref": "#/components/schemas/ChatDeploymentOutputConfig" + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Block identifier in the source workflow." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "value": { + "type": "string", + "maxLength": 65536, + "description": "Destination value for the registered dependent field." + } + }, + "required": ["sourceWorkflowId", "sourceBlockId", "subBlockKey", "value"], + "additionalProperties": false } }, - "includeThinking": { - "description": "Allow visitors to receive provider thinking events.", - "default": false, - "type": "boolean" + "copyResources": { + "description": "Explicit source resources to copy before syncing the workflows.", + "type": "object", + "properties": { + "knowledgeBases": { + "description": "Source knowledge base identifiers whose documents and content are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "tables": { + "description": "Source table identifiers whose schemas and rows are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "customTools": { + "description": "Source custom tool identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "skills": { + "description": "Source skill identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "files": { + "description": "Workspace file storage keys to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "mcpServers": { + "description": "External MCP server identifiers to copy; OAuth connections require authorization in the destination.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false }, - "includeToolCalls": { - "description": "Allow visitors to receive tool lifecycle events.", - "default": false, - "type": "boolean" - } - }, - "required": ["identifier", "title"], - "additionalProperties": false, - "title": "Replace chat deployment request", - "description": "The complete desired state of a workflow's chat.", - "examples": [ - { - "identifier": "support", - "title": "Support chat" - } - ] - }, - "DeleteChatDeploymentResult": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Identifier of the removed chat deployment." + "dropReferences": { + "description": "Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox" + ], + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + } + }, + "required": ["kind", "sourceId"], + "additionalProperties": false + } }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Whether the deployment was removed." + "triggerMappings": { + "description": "Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected.", + "maxItems": 500, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source trigger block identifier from the sync preview." + }, + "adoptPath": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "An adoptable path offered for this trigger, or null to allocate a new path." + } + }, + "required": ["sourceWorkflowId", "sourceBlockId", "adoptPath"], + "additionalProperties": false + } } }, - "required": ["id", "deleted"], + "required": ["otherWorkspaceId"], "additionalProperties": false, - "title": "Delete chat deployment result", - "description": "Chat deployment removal acknowledgement." + "title": "PreviewWorkspacePush body", + "description": "The body for this operation." }, - "DeleteWorkflowChatDeploymentResponse": { + "PushWorkspaceResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/DeleteChatDeploymentResult" + "$ref": "#/components/schemas/WorkspaceOperationReport" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete workflow chat deployment response", - "description": "Acknowledgement that the chat deployment was removed.", - "examples": [ - { - "data": { - "id": "chat_01J8ZK3QW4M6X2R9T7B5C0V2", - "deleted": true - } - } - ] - }, - "ExecutionError": { - "type": "object", - "properties": { - "message": { - "type": "string", - "description": "Human-readable workflow execution failure message." - }, - "code": { - "type": "string", - "enum": [ - "TIMEOUT", - "CANCELLED", - "USAGE_LIMIT_EXCEEDED", - "INVALID_INPUT", - "BLOCK_EXECUTION_FAILED", - "CHILD_WORKFLOW_FAILED", - "EXECUTION_FAILED" - ], - "description": "Stable machine-readable execution failure code. `BLOCK_EXECUTION_FAILED` and `CHILD_WORKFLOW_FAILED` are reported only where block attribution is available; elsewhere a block-level failure is reported as `EXECUTION_FAILED`." - }, - "blockId": { - "description": "Identifier of the failing block. Present on the synchronous execute response only; the polled run resource and the resume response cannot attribute a block.", - "type": "string" - }, - "blockName": { - "description": "Display name of the failing block. Present on the synchronous execute response only.", - "type": "string" - }, - "blockType": { - "description": "Integration or block type that failed. Present on the synchronous execute response only.", - "type": "string" - } - }, - "required": ["message", "code"], - "additionalProperties": false, - "title": "Execution error", - "description": "Structured in-band failure details for a workflow run." + "title": "PushWorkspace response", + "description": "The response for this operation." }, - "WorkflowRunResult": { + "PushWorkspaceBody": { "type": "object", "properties": { - "runId": { + "otherWorkspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] - }, - "workflowId": { - "type": "string", - "description": "Workflow that produced the run." + "description": "Workspace on the other side of the direct fork edge." }, - "status": { - "type": "string", - "enum": ["completed", "failed", "paused", "cancelled"], - "description": "Terminal or paused run status." + "mappings": { + "description": "Mappings keyed by resource type and source identifier.", + "maxItems": 5000, + "type": "array", + "items": { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "enum": [ + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "file", + "file_folder", + "mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ], + "description": "Resource type stored on the canonical parent/child edge." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + } + }, + "required": ["resourceType", "sourceId", "targetId"], + "additionalProperties": false + } }, - "output": { - "description": "Workflow output, including partial output on failure." + "dependentValues": { + "description": "Destination-dependent choices keyed by source workflow, block, and field identities.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Block identifier in the source workflow." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "value": { + "type": "string", + "maxLength": 65536, + "description": "Destination value for the registered dependent field." + } + }, + "required": ["sourceWorkflowId", "sourceBlockId", "subBlockKey", "value"], + "additionalProperties": false + } }, - "error": { - "anyOf": [ - { - "$ref": "#/components/schemas/ExecutionError" + "copyResources": { + "description": "Explicit source resources to copy before syncing the workflows.", + "type": "object", + "properties": { + "knowledgeBases": { + "description": "Source knowledge base identifiers whose documents and content are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } }, - { - "type": "null" + "tables": { + "description": "Source table identifiers whose schemas and rows are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "customTools": { + "description": "Source custom tool identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "skills": { + "description": "Source skill identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "files": { + "description": "Workspace file storage keys to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "mcpServers": { + "description": "External MCP server identifiers to copy; OAuth connections require authorization in the destination.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } } - ], - "description": "Structured execution failure, or null when none occurred." - }, - "startedAt": { - "description": "ISO 8601 timestamp when execution started.", - "format": "date-time", - "type": "string" + }, + "additionalProperties": false }, - "endedAt": { - "description": "ISO 8601 timestamp when execution ended.", - "format": "date-time", - "type": "string" + "dropReferences": { + "description": "Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox" + ], + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + } + }, + "required": ["kind", "sourceId"], + "additionalProperties": false + } }, - "durationMs": { - "description": "Execution duration in milliseconds.", - "type": "number", - "minimum": 0 - } - }, - "required": ["runId", "workflowId", "status", "output", "error"], - "additionalProperties": false, - "title": "Workflow run result", - "description": "Synchronous workflow run output and in-band execution status. Run failures are reported in band, not as HTTP errors — a run that exceeds its execution timeout returns HTTP 200 with `status: \"failed\"` and `error.code: \"TIMEOUT\"`, so branch on `status`." - }, - "ExecuteWorkflowSyncResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowRunResult" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "Synchronous workflow execution response", - "description": "Completed, failed, paused, or cancelled synchronous workflow run.", - "examples": [ - { - "data": { - "runId": "run_8f14e45f-ceea-467f-a", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "status": "completed", - "output": { - "result": "Ticket routed to Support" + "triggerMappings": { + "description": "Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected.", + "maxItems": 500, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source trigger block identifier from the sync preview." + }, + "adoptPath": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "An adoptable path offered for this trigger, or null to allocate a new path." + } }, - "error": null, - "startedAt": "2026-08-09T18:04:10.000Z", - "endedAt": "2026-08-09T18:04:11.000Z", - "durationMs": 1000 + "required": ["sourceWorkflowId", "sourceBlockId", "adoptPath"], + "additionalProperties": false } - } - ] - }, - "QueuedWorkflowRun": { - "type": "object", - "properties": { - "runId": { + }, + "requestId": { "type": "string", "minLength": 1, "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] + "description": "Stable client request ID for reconciliation and identical retries." }, - "statusUrl": { + "previewFingerprint": { "type": "string", - "format": "uri", - "description": "Absolute URL of the workflow run resource." + "pattern": "^[a-f0-9]{64}$", + "description": "Fingerprint of the reviewed preview and its choices." + }, + "confirm": { + "type": "boolean", + "const": true, + "description": "Explicit acknowledgement that sync replaces target workflows." } }, - "required": ["runId", "statusUrl"], + "required": ["otherWorkspaceId", "requestId", "previewFingerprint", "confirm"], "additionalProperties": false, - "title": "Queued workflow run", - "description": "Receipt returned when a workflow run is queued." + "title": "PushWorkspace body", + "description": "The body for this operation." }, - "ExecuteWorkflowQueuedResponse": { + "PreviewWorkspacePullResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/QueuedWorkflowRun" + "$ref": "#/components/schemas/WorkspaceSyncPreview" } }, "required": ["data"], "additionalProperties": false, - "title": "Queued workflow execution response", - "description": "Receipt returned for an asynchronous workflow run.", - "examples": [ - { - "data": { - "runId": "run_8f14e45f-ceea-467f-a", - "statusUrl": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/run_8f14e45f-ceea-467f-a" - } - } - ] + "title": "PreviewWorkspacePull response", + "description": "The response for this operation." }, - "ExecuteWorkflowRequest": { + "PreviewWorkspacePullBody": { "type": "object", "properties": { - "input": { - "description": "Workflow input keyed by the selected trigger input-field name.", - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Value supplied for one workflow input field." - } + "otherWorkspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace on the other side of the direct fork edge." }, - "run": { - "description": "Workflow state and entry point to execute. Omit for the active deployment. Manual execution requires OAuth or personal-key write access and supports synchronous or streamed runs only.", - "oneOf": [ - { - "type": "object", - "properties": { - "source": { - "type": "string", - "const": "deployment", - "description": "Execute the active deployed workflow state." - } + "mappings": { + "description": "Mappings keyed by resource type and source identifier.", + "maxItems": 5000, + "type": "array", + "items": { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "enum": [ + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "file", + "file_folder", + "mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ], + "description": "Resource type stored on the canonical parent/child edge." }, - "required": ["source"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "source": { - "type": "string", - "const": "manual", - "description": "Execute the current saved workflow state manually." - }, - "entry": { - "description": "Manual entry mode. Omit to enter through the workflow trigger; a block entry requires an exact source run.", - "oneOf": [ - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "trigger", - "description": "Enter the manual run through a runnable trigger." - }, - "blockId": { - "description": "Runnable trigger block to enter through. Omit only when the saved workflow has exactly one runnable trigger.", - "type": "string", - "minLength": 1 - }, - "useMockPayload": { - "description": "Use the selected trigger's server-derived mock payload. Cannot be combined with `input`.", - "type": "boolean" - } - }, - "required": ["type"], - "additionalProperties": false - }, - { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "block", - "description": "Resume manual execution at a block using persisted upstream state." - }, - "blockId": { - "type": "string", - "minLength": 1, - "description": "Saved workflow block at which manual execution should resume." - }, - "sourceRunId": { - "type": "string", - "minLength": 1, - "description": "Run ID supplying upstream block results when starting from a selected block." - } - }, - "required": ["type", "blockId", "sourceRunId"], - "additionalProperties": false - } - ] - } + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." }, - "required": ["source"], - "additionalProperties": false - } - ] - }, - "async": { - "default": false, - "description": "Queue the run and return a 202 receipt when true. Requires an OAuth access token or API key, cannot be combined with `stream`, and rejects all streaming and output-shaping options (`selectedOutputs`, `includeThinking`, `includeToolCalls`, `includeFileBase64`, `base64MaxBytes`).", - "type": "boolean" - }, - "executionTimeoutSeconds": { - "description": "Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`.", - "type": "integer", - "minimum": 1, - "maximum": 604800 - }, - "stream": { - "default": false, - "description": "Return Server-Sent Events instead of JSON when true. Cannot be combined with `async`.", - "type": "boolean" + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + } + }, + "required": ["resourceType", "sourceId", "targetId"], + "additionalProperties": false + } }, - "selectedOutputs": { - "description": "Output references for streaming: `.` or `..`, using normalized block names. Child references apply to every invocation. Requires `stream: true` and rejects synchronous or async requests. Use `selectedOutputs` with Get Workflow Run to narrow an existing run.", - "maxItems": 100, + "dependentValues": { + "description": "Destination-dependent choices keyed by source workflow, block, and field identities.", + "maxItems": 2000, "type": "array", "items": { - "type": "string", - "minLength": 1 + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Block identifier in the source workflow." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "value": { + "type": "string", + "maxLength": 65536, + "description": "Destination value for the registered dependent field." + } + }, + "required": ["sourceWorkflowId", "sourceBlockId", "subBlockKey", "value"], + "additionalProperties": false } }, - "includeThinking": { - "default": false, - "description": "Include model reasoning events in an agent-event stream. Requires `stream: true` and the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.", - "type": "boolean" - }, - "includeToolCalls": { - "default": false, - "description": "Include tool-call events in an agent-event stream. Requires `stream: true` and the `X-Sim-Stream-Protocol: agent-events-v1` request header, and is rejected when `async` is true.", - "type": "boolean" + "copyResources": { + "description": "Explicit source resources to copy before syncing the workflows.", + "type": "object", + "properties": { + "knowledgeBases": { + "description": "Source knowledge base identifiers whose documents and content are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "tables": { + "description": "Source table identifiers whose schemas and rows are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "customTools": { + "description": "Source custom tool identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "skills": { + "description": "Source skill identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "files": { + "description": "Workspace file storage keys to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "mcpServers": { + "description": "External MCP server identifiers to copy; OAuth connections require authorization in the destination.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } + }, + "additionalProperties": false }, - "includeFileBase64": { - "description": "Inline eligible output files as base64 content. Rejected when `async` is true.", - "type": "boolean" + "dropReferences": { + "description": "Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox" + ], + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + } + }, + "required": ["kind", "sourceId"], + "additionalProperties": false + } }, - "base64MaxBytes": { - "description": "Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 16777216 + "triggerMappings": { + "description": "Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected.", + "maxItems": 500, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source trigger block identifier from the sync preview." + }, + "adoptPath": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "An adoptable path offered for this trigger, or null to allocate a new path." + } + }, + "required": ["sourceWorkflowId", "sourceBlockId", "adoptPath"], + "additionalProperties": false + } + } + }, + "required": ["otherWorkspaceId"], + "additionalProperties": false, + "title": "PreviewWorkspacePull body", + "description": "The body for this operation." + }, + "PullWorkspaceResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/WorkspaceOperationReport" } }, + "required": ["data"], "additionalProperties": false, - "title": "Execute workflow request", - "description": "Input, workflow-state selection, and execution-mode options. Input descriptions specify compatible modes; invalid combinations return `400`.", - "examples": [ - { - "input": { - "ticketId": "ticket_123" + "title": "PullWorkspace response", + "description": "The response for this operation." + }, + "PullWorkspaceBody": { + "type": "object", + "properties": { + "otherWorkspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace on the other side of the direct fork edge." + }, + "mappings": { + "description": "Mappings keyed by resource type and source identifier.", + "maxItems": 5000, + "type": "array", + "items": { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "enum": [ + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "file", + "file_folder", + "mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ], + "description": "Resource type stored on the canonical parent/child edge." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + } + }, + "required": ["resourceType", "sourceId", "targetId"], + "additionalProperties": false } }, - { - "input": { - "ticketId": "ticket_123" - }, - "async": true + "dependentValues": { + "description": "Destination-dependent choices keyed by source workflow, block, and field identities.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Block identifier in the source workflow." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "value": { + "type": "string", + "maxLength": 65536, + "description": "Destination value for the registered dependent field." + } + }, + "required": ["sourceWorkflowId", "sourceBlockId", "subBlockKey", "value"], + "additionalProperties": false + } }, - { - "input": { - "ticketId": "ticket_123" + "copyResources": { + "description": "Explicit source resources to copy before syncing the workflows.", + "type": "object", + "properties": { + "knowledgeBases": { + "description": "Source knowledge base identifiers whose documents and content are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "tables": { + "description": "Source table identifiers whose schemas and rows are copied.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "customTools": { + "description": "Source custom tool identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "skills": { + "description": "Source skill identifiers to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "files": { + "description": "Workspace file storage keys to copy.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + }, + "mcpServers": { + "description": "External MCP server identifiers to copy; OAuth connections require authorization in the destination.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + } + } }, - "stream": true + "additionalProperties": false }, - { - "run": { - "source": "manual" + "dropReferences": { + "description": "Source-deleted references explicitly acknowledged for removal; live source references cannot be dropped.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox" + ], + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + } + }, + "required": ["kind", "sourceId"], + "additionalProperties": false } }, - { - "run": { - "source": "manual", - "entry": { - "type": "block", - "blockId": "block_123", - "sourceRunId": "run_123" - } + "triggerMappings": { + "description": "Public trigger path choices from preview.triggerSlots, addressed by source workflow and block IDs. Duplicate or unavailable choices are rejected.", + "maxItems": 500, + "type": "array", + "items": { + "type": "object", + "properties": { + "sourceWorkflowId": { + "type": "string", + "minLength": 1, + "description": "Workflow identifier in the source workspace." + }, + "sourceBlockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source trigger block identifier from the sync preview." + }, + "adoptPath": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "An adoptable path offered for this trigger, or null to allocate a new path." + } + }, + "required": ["sourceWorkflowId", "sourceBlockId", "adoptPath"], + "additionalProperties": false } - } - ] - }, - "WorkflowRunListItem": { - "type": "object", - "properties": { - "runId": { + }, + "requestId": { "type": "string", "minLength": 1, "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] + "description": "Stable client request ID for reconciliation and identical retries." }, - "workflowId": { - "type": "string", - "description": "Workflow that produced the run." - }, - "status": { - "type": "string", - "enum": [ - "pending", - "running", - "paused", - "redacting", - "completed", - "failed", - "cancelled" - ], - "description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting to be resumed — either held at a human-in-the-loop pause point, or left paused by a resume attempt that did not complete. Only the single-run response distinguishes the two, through `paused.automaticResumeWaitingReason`." - }, - "trigger": { - "type": "string", - "description": "Trigger type that started the run." - }, - "startedAt": { + "previewFingerprint": { "type": "string", - "description": "ISO 8601 timestamp when the run started.", - "format": "date-time" + "pattern": "^[a-f0-9]{64}$", + "description": "Fingerprint of the reviewed preview and its choices." }, - "endedAt": { - "anyOf": [ - { - "type": "string" + "confirm": { + "type": "boolean", + "const": true, + "description": "Explicit acknowledgement that sync replaces target workflows." + } + }, + "required": ["otherWorkspaceId", "requestId", "previewFingerprint", "confirm"], + "additionalProperties": false, + "title": "PullWorkspace body", + "description": "The body for this operation." + }, + "GetWorkspaceForkAvailabilityResult": { + "type": "object", + "properties": { + "available": { + "type": "boolean", + "description": "Whether this deployment and workspace plan enable forking." + } + }, + "required": ["available"], + "additionalProperties": false, + "title": "GetWorkspaceForkAvailabilityResult", + "description": "The GetWorkspaceForkAvailabilityResult result." + }, + "GetWorkspaceForkAvailabilityResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/GetWorkspaceForkAvailabilityResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "GetWorkspaceForkAvailability response", + "description": "The response for this operation." + }, + "GetWorkspaceForkLineageResult": { + "type": "object", + "properties": { + "current": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Resource identifier." }, - { - "type": "null" - } - ], - "description": "ISO 8601 timestamp when the run ended, or null while active.", - "format": "date-time" - }, - "durationMs": { - "anyOf": [ - { - "type": "number" + "name": { + "type": "string", + "maxLength": 1024, + "description": "Display name of the workflow or workspace." }, - { - "type": "null" + "organizationId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Owning organization, or null for a personal workspace." } - ], - "description": "Run duration in milliseconds, or null while active." + }, + "required": ["id", "name", "organizationId"], + "additionalProperties": false, + "description": "The current workspace lineage node." }, - "cost": { + "parent": { "anyOf": [ { "type": "object", "properties": { - "total": { - "type": "number", - "description": "Total credits consumed by the run." + "id": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Resource identifier." + }, + "name": { + "type": "string", + "maxLength": 1024, + "description": "Display name of the workflow or workspace." + }, + "organizationId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Owning organization, or null for a personal workspace." } }, - "required": ["total"], + "required": ["id", "name", "organizationId"], "additionalProperties": false }, { "type": "null" } ], - "description": "Credit cost, or null when unavailable." + "description": "The live parent workspace, or null when this workspace is not a fork." } }, - "required": [ - "runId", - "workflowId", - "status", - "trigger", - "startedAt", - "endedAt", - "durationMs", - "cost" - ], + "required": ["current", "parent"], "additionalProperties": false, - "title": "Workflow run summary", - "description": "Summary of a recorded workflow run." + "title": "GetWorkspaceForkLineageResult", + "description": "The GetWorkspaceForkLineageResult result." }, - "WorkflowRunListResponse": { + "GetWorkspaceForkLineageResponse": { "type": "object", "properties": { "data": { - "type": "array", - "items": { - "$ref": "#/components/schemas/WorkflowRunListItem" - }, - "description": "Items in the current page." - }, - "nextCursor": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + "description": "Response data.", + "$ref": "#/components/schemas/GetWorkspaceForkLineageResult" } }, - "required": ["data", "nextCursor"], + "required": ["data"], "additionalProperties": false, - "title": "Workflow run list response", - "description": "A cursor-paginated page of workflow run summaries.", - "examples": [ - { - "data": [ - { - "runId": "run_8f14e45f-ceea-467f-a", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "status": "completed", - "trigger": "api", - "startedAt": "2026-08-09T18:04:10.000Z", - "endedAt": "2026-08-09T18:04:11.000Z", - "durationMs": 1000, - "cost": { - "total": 12 - } - } - ], - "nextCursor": null - } - ] + "title": "GetWorkspaceForkLineage response", + "description": "The response for this operation." }, - "V2RunFile": { + "ListWorkspaceForkChildrenResult": { "type": "object", "properties": { "id": { "type": "string", - "description": "Identifier to address this file by on the download endpoint." + "minLength": 1, + "maxLength": 128, + "description": "Resource identifier." }, "name": { "type": "string", - "description": "File name, including its extension." - }, - "size": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "File size in bytes." - }, - "type": { - "type": "string", - "description": "MIME type recorded for the file." - }, - "downloadPath": { - "type": "string", - "description": "Path to fetch this file's bytes from, relative to the API host." + "maxLength": 1024, + "description": "Display name of the workflow or workspace." }, - "base64": { + "organizationId": { "anyOf": [ { "type": "string" @@ -9746,755 +14642,1502 @@ "type": "null" } ], - "description": "Base64-encoded contents when `includeFileBase64` was requested and the file fits the inline ceiling, otherwise null." + "description": "Owning organization, or null for a personal workspace." + }, + "createdAt": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "ISO 8601 creation timestamp." } }, - "required": ["id", "name", "size", "type", "downloadPath", "base64"], + "required": ["id", "name", "organizationId", "createdAt"], "additionalProperties": false, - "title": "Workflow run file", - "description": "A file produced by a workflow run." + "title": "ListWorkspaceForkChildrenResult", + "description": "The ListWorkspaceForkChildrenResult result." }, - "WorkflowRunStatus": { + "ListWorkspaceForkChildrenResponse": { "type": "object", "properties": { - "runId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] - }, - "workflowId": { - "type": "string", - "description": "Workflow that produced the run." - }, - "status": { - "type": "string", - "enum": [ - "pending", - "running", - "paused", - "redacting", - "completed", - "failed", - "cancelled", - "queued" - ], - "description": "Current or terminal run status. `redacting` is transient, reported while a finished run's output is being scrubbed. `paused` means the run is waiting to be resumed — either held at a human-in-the-loop pause point, or left paused by a resume attempt that did not complete. Only the single-run response distinguishes the two, through `paused.automaticResumeWaitingReason`." - }, - "trigger": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Trigger type that started the run. Backfilled as `api` for a run that is still queued, so it is populated from the first poll." - }, - "startedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 start timestamp. A queued run reports the time it was enqueued, so it is populated from the first poll.", - "format": "date-time" - }, - "endedAt": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 end timestamp, or null while nonterminal.", - "format": "date-time" - }, - "durationMs": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "null" - } - ], - "description": "Run duration in milliseconds, or null while active." - }, - "paused": { - "anyOf": [ - { - "type": "object", - "properties": { - "contextId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Resume context identifier, or null while every pause point is mid-resume." - }, - "pausedAt": { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", - "description": "ISO 8601 timestamp when the execution entered the paused state." - }, - "resumeAt": { - "anyOf": [ - { - "type": "string", - "format": "date-time", - "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" - }, - { - "type": "null" - } - ], - "description": "ISO 8601 scheduled automatic-resume timestamp, or null when no resume time is set." - }, - "pauseKind": { - "anyOf": [ - { - "type": "string", - "enum": ["time", "human"] - }, - { - "type": "null" - } - ], - "description": "Whether the pause waits for time or human input, or null when unspecified." - }, - "blockedOnBlockId": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Workflow block awaiting resume, or null when no block is identified." - }, - "automaticResumeWaitingReason": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Why automatic resume is waiting, or null when it is not — on a paused run, null means it is waiting on human input. Recorded whenever a resume attempt fails and cleared once one succeeds. A non-retryable or exhausted failure is prefixed `Automatic resume requires manual intervention: `." - }, - "pausePointCount": { - "type": "number", - "description": "Number of pause points tracked for the execution." - }, - "resumedCount": { - "type": "number", - "description": "Number of pause points that have resumed." - } - }, - "required": [ - "contextId", - "pausedAt", - "resumeAt", - "pauseKind", - "blockedOnBlockId", - "automaticResumeWaitingReason", - "pausePointCount", - "resumedCount" - ], - "additionalProperties": false + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ListWorkspaceForkChildrenResult" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" }, { "type": "null" } ], - "description": "Current pause details, or null when the run is not paused." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "ListWorkspaceForkChildren response", + "description": "The response for this operation." + }, + "ListWorkspaceForkResourcesResult": { + "type": "object", + "properties": { + "id": { + "type": "string", + "maxLength": 4096, + "description": "Resource identifier." }, - "cost": { + "label": { + "type": "string", + "maxLength": 1024, + "description": "Human-readable resource label." + }, + "folderId": { + "description": "Containing folder identifier, or null at the workspace root.", "anyOf": [ { - "type": "object", - "properties": { - "total": { - "type": "number", - "description": "Total credits consumed by the run." - } - }, - "required": ["total"], - "additionalProperties": false + "type": "string" }, { "type": "null" } - ], - "description": "Credit cost, or null when unavailable." + ] }, - "error": { + "folderName": { + "description": "Containing folder name, or null at the workspace root.", "anyOf": [ { - "$ref": "#/components/schemas/ExecutionError" + "type": "string" }, { "type": "null" } - ], - "description": "Structured execution failure, or null when none occurred. Reclassified from the persisted error message, so `blockId`/`blockName`/`blockType` are absent and a block-level failure reports `EXECUTION_FAILED` here even when the same run reported `BLOCK_EXECUTION_FAILED` on its synchronous execute response." + ] + } + }, + "required": ["id", "label"], + "additionalProperties": false, + "title": "ListWorkspaceForkResourcesResult", + "description": "The ListWorkspaceForkResourcesResult result." + }, + "ListWorkspaceForkResourcesResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ListWorkspaceForkResourcesResult" + }, + "description": "Items in the current page." }, - "output": { + "nextCursor": { "anyOf": [ { - "description": "Final workflow output value." + "type": "string" }, { "type": "null" } ], - "description": "Final workflow output when requested, otherwise null." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "ListWorkspaceForkResources response", + "description": "The response for this operation." + }, + "GetWorkspaceForkMappingsResult": { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "enum": [ + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "file", + "file_folder", + "mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ], + "description": "Resource type stored on the canonical parent/child edge." }, - "blockOutputs": { + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + }, + "targetId": { "anyOf": [ { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "description": "Output value produced by one workflow block." - } + "type": "string", + "minLength": 1, + "maxLength": 4096 }, { "type": "null" } ], - "description": "Outputs of the blocks named by `selectedOutputs`, or null when none were requested. Gated by `selectedOutputs` alone — `includeOutput` governs `output` only." + "description": "Authorized destination identifier, or null to clear the mapping." }, - "files": { + "id": { + "type": "string", + "maxLength": 256, + "description": "Resource identifier." + } + }, + "required": ["resourceType", "sourceId", "targetId", "id"], + "additionalProperties": false, + "title": "GetWorkspaceForkMappingsResult", + "description": "The GetWorkspaceForkMappingsResult result." + }, + "GetWorkspaceForkMappingsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/GetWorkspaceForkMappingsResult" + }, + "description": "Items in the current page." + }, + "nextCursor": { "anyOf": [ { - "type": "array", - "items": { - "$ref": "#/components/schemas/V2RunFile" - } + "type": "string" }, { "type": "null" } ], - "description": "Files this run produced, or null when `includeOutput` is false. Matches the nullability of `output`." + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } }, - "required": [ - "runId", - "workflowId", - "status", - "trigger", - "startedAt", - "endedAt", - "durationMs", - "paused", - "cost", - "error", - "output", - "blockOutputs", - "files" - ], + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "GetWorkspaceForkMappings response", + "description": "The response for this operation." + }, + "UpdateWorkspaceForkMappingsResult": { + "type": "object", + "properties": { + "updated": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of records changed." + } + }, + "required": ["updated"], + "additionalProperties": false, + "title": "UpdateWorkspaceForkMappingsResult", + "description": "The UpdateWorkspaceForkMappingsResult result." + }, + "UpdateWorkspaceForkMappingsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/UpdateWorkspaceForkMappingsResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "UpdateWorkspaceForkMappings response", + "description": "The response for this operation." + }, + "UpdateWorkspaceForkMappingsBody": { + "type": "object", + "properties": { + "otherWorkspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace on the other side of the direct fork edge." + }, + "direction": { + "type": "string", + "enum": ["push", "pull"], + "description": "Push means current to other; pull means other to current, independent of parent/child orientation." + }, + "mappings": { + "maxItems": 5000, + "type": "array", + "items": { + "type": "object", + "properties": { + "resourceType": { + "type": "string", + "enum": [ + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "file", + "file_folder", + "mcp_server", + "custom_block", + "custom_tool", + "skill", + "sandbox" + ], + "description": "Resource type stored on the canonical parent/child edge." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Source resource identifier in the canonical source workspace." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + } + }, + "required": ["resourceType", "sourceId", "targetId"], + "additionalProperties": false + }, + "description": "Mappings keyed by resource type and source identifier." + } + }, + "required": ["otherWorkspaceId", "direction", "mappings"], + "additionalProperties": false, + "title": "UpdateWorkspaceForkMappings body", + "description": "The body for this operation." + }, + "RollbackWorkspaceForkResult": { + "type": "object", + "properties": { + "restored": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Workflows restored to their prior deployed version." + }, + "archived": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Workflows created by the sync and now archived." + }, + "unarchived": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Previously archived workflows restored by rollback." + }, + "skipped": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Snapshot workflows no longer available to restore." + }, + "pendingActivations": { + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "maxLength": 256 + }, + "description": "Workflows whose restored deployment is still activating." + } + }, + "required": ["restored", "archived", "unarchived", "skipped", "pendingActivations"], + "additionalProperties": false, + "title": "RollbackWorkspaceForkResult", + "description": "The RollbackWorkspaceForkResult result." + }, + "RollbackWorkspaceForkResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/RollbackWorkspaceForkResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "RollbackWorkspaceFork response", + "description": "The response for this operation." + }, + "RollbackWorkspaceForkBody": { + "type": "object", + "properties": { + "otherWorkspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace on the other side of the direct fork edge." + } + }, + "required": ["otherWorkspaceId"], + "additionalProperties": false, + "title": "RollbackWorkspaceFork body", + "description": "The body for this operation." + }, + "UnlinkWorkspaceForkResult": { + "type": "object", + "properties": { + "unlinked": { + "type": "boolean", + "description": "Whether the fork edge was removed." + } + }, + "required": ["unlinked"], + "additionalProperties": false, + "title": "UnlinkWorkspaceForkResult", + "description": "The UnlinkWorkspaceForkResult result." + }, + "UnlinkWorkspaceForkResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/UnlinkWorkspaceForkResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "UnlinkWorkspaceFork response", + "description": "The response for this operation." + }, + "UnlinkWorkspaceForkBody": { + "type": "object", + "properties": { + "otherWorkspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace on the other side of the direct fork edge." + } + }, + "required": ["otherWorkspaceId"], + "additionalProperties": false, + "title": "UnlinkWorkspaceFork body", + "description": "The body for this operation." + }, + "UpdateWorkspaceForkExclusionsResult": { + "type": "object", + "properties": { + "updated": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of records changed." + } + }, + "required": ["updated"], "additionalProperties": false, - "title": "Workflow run status", - "description": "Detailed current state of a workflow run." + "title": "UpdateWorkspaceForkExclusionsResult", + "description": "The UpdateWorkspaceForkExclusionsResult result." }, - "WorkflowRunStatusResponse": { + "UpdateWorkspaceForkExclusionsResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/WorkflowRunStatus" + "$ref": "#/components/schemas/UpdateWorkspaceForkExclusionsResult" } }, "required": ["data"], "additionalProperties": false, - "title": "Workflow run status response", - "description": "Detailed current state of a workflow run.", - "examples": [ - { - "data": { - "runId": "run_8f14e45f-ceea-467f-a", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "status": "completed", - "trigger": "api", - "startedAt": "2026-08-09T18:04:10.000Z", - "endedAt": "2026-08-09T18:04:11.000Z", - "durationMs": 1000, - "paused": null, - "cost": { - "total": 12 - }, - "error": null, - "output": { - "result": "Ticket routed to Support" - }, - "blockOutputs": null, - "files": [ - { - "id": "file_1a2b3c", - "name": "summary.pdf", - "size": 20480, - "type": "application/pdf", - "downloadPath": "/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/run_8f14e45f-ceea-467f-a/files/file_1a2b3c", - "base64": null - } - ] - } - } - ] + "title": "UpdateWorkspaceForkExclusions response", + "description": "The response for this operation." }, - "ResumeWorkflowSyncResponse": { + "UpdateWorkspaceForkExclusionsBody": { "type": "object", "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowRunResult" + "workflowIds": { + "minItems": 1, + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Workflow identifiers in the current workspace." + }, + "forkSyncExcluded": { + "type": "boolean", + "description": "Whether the named workflows should be skipped as sync sources and targets." } }, - "required": ["data"], + "required": ["workflowIds", "forkSyncExcluded"], "additionalProperties": false, - "title": "Synchronous workflow resume response", - "description": "Completed, failed, paused, or cancelled resumed workflow run.", - "examples": [ - { - "data": { - "runId": "run_8f14e45f-ceea-467f-a", - "workflowId": "3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36", - "status": "completed", - "output": { - "result": "Ticket routed to Support" - }, - "error": null, - "startedAt": "2026-08-09T18:04:10.000Z", - "endedAt": "2026-08-09T18:04:11.000Z", - "durationMs": 1000 - } - } - ] + "title": "UpdateWorkspaceForkExclusions body", + "description": "The body for this operation." }, - "QueuedWorkflowResume": { + "PreviewWorkflowImportResult": { "type": "object", "properties": { - "runId": { + "previewFingerprint": { "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] + "minLength": 64, + "maxLength": 64, + "description": "Fingerprint of the reviewed preview and its choices." }, - "statusUrl": { - "type": "string", - "format": "uri", - "description": "Absolute URL of the workflow run resource." + "ready": { + "type": "boolean", + "description": "Whether the operation passes its current apply or deployment readiness checks." + }, + "bindings": { + "maxItems": 10000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox", + "workflow" + ], + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "maxLength": 4096, + "description": "Untrusted source reference label; imports never use it to authorize or query a source workspace." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + }, + "required": { + "type": "boolean", + "description": "Whether the reference or configuration is required for this operation." + }, + "occurrence": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "valuePath": { + "maxItems": 8, + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + ] + }, + "description": "Path within the field value; strings address properties and numbers address array entries." + }, + "positions": { + "description": "Positions occupied by this identifier in a multi-value field.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + }, + "encoding": { + "type": "string", + "enum": ["scalar", "array", "csv", "files", "environment"], + "description": "Registered encoding used to discover and rewrite the reference." + } + }, + "required": ["blockId", "subBlockKey", "valuePath", "encoding"], + "additionalProperties": false, + "description": "Registered source field occurrence addressed by this binding." + } + }, + "required": ["kind", "sourceId", "targetId", "required", "occurrence"], + "additionalProperties": false + }, + "description": "Resolved and unresolved source occurrences with their destination selections." + }, + "unresolvedBindings": { + "maxItems": 10000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox", + "workflow" + ], + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "maxLength": 4096, + "description": "Untrusted source reference label; imports never use it to authorize or query a source workspace." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + }, + "required": { + "type": "boolean", + "description": "Whether the reference or configuration is required for this operation." + }, + "occurrence": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "valuePath": { + "maxItems": 8, + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + ] + }, + "description": "Path within the field value; strings address properties and numbers address array entries." + }, + "positions": { + "description": "Positions occupied by this identifier in a multi-value field.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + }, + "encoding": { + "type": "string", + "enum": ["scalar", "array", "csv", "files", "environment"], + "description": "Registered encoding used to discover and rewrite the reference." + } + }, + "required": ["blockId", "subBlockKey", "valuePath", "encoding"], + "additionalProperties": false, + "description": "Registered source field occurrence addressed by this binding." + } + }, + "required": ["kind", "sourceId", "targetId", "required", "occurrence"], + "additionalProperties": false + }, + "description": "Source references that still require destination mappings or explicit copy choices." + }, + "configuration": { + "maxItems": 10000, + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "title": { + "type": "string", + "maxLength": 1024, + "description": "Human-readable configuration field label." + }, + "required": { + "type": "boolean", + "description": "Whether the reference or configuration is required for this operation." + }, + "configured": { + "type": "boolean", + "description": "Whether this destination field currently has a nonempty value." + }, + "multiSelect": { + "description": "Whether the field accepts comma-separated selections.", + "type": "boolean" + }, + "selectorKey": { + "description": "Registered selector key for discovering this field’s destination options.", + "type": "string", + "maxLength": 256 + }, + "context": { + "type": "object", + "propertyNames": { + "type": "string", + "maxLength": 256 + }, + "additionalProperties": { + "type": "string", + "maxLength": 16384 + }, + "description": "Allowlisted selector dependencies scoped to the destination workspace." + }, + "requiresAuthentication": { + "type": "boolean", + "description": "Whether a human must connect the provider before choices can be discovered." + } + }, + "required": [ + "blockId", + "subBlockKey", + "title", + "required", + "configured", + "context", + "requiresAuthentication" + ], + "additionalProperties": false + }, + "description": "Dependent fields that may need destination-specific values." + }, + "unresolvedConfiguration": { + "maxItems": 10000, + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "title": { + "type": "string", + "maxLength": 1024, + "description": "Human-readable configuration field label." + }, + "required": { + "type": "boolean", + "description": "Whether the reference or configuration is required for this operation." + }, + "configured": { + "type": "boolean", + "description": "Whether this destination field currently has a nonempty value." + }, + "multiSelect": { + "description": "Whether the field accepts comma-separated selections.", + "type": "boolean" + }, + "selectorKey": { + "description": "Registered selector key for discovering this field’s destination options.", + "type": "string", + "maxLength": 256 + }, + "context": { + "type": "object", + "propertyNames": { + "type": "string", + "maxLength": 256 + }, + "additionalProperties": { + "type": "string", + "maxLength": 16384 + }, + "description": "Allowlisted selector dependencies scoped to the destination workspace." + }, + "requiresAuthentication": { + "type": "boolean", + "description": "Whether a human must connect the provider before choices can be discovered." + } + }, + "required": [ + "blockId", + "subBlockKey", + "title", + "required", + "configured", + "context", + "requiresAuthentication" + ], + "additionalProperties": false + }, + "description": "Required destination configuration that remains empty." }, - "queuePosition": { - "description": "Current queue position, when available.", - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 9007199254740991 + "discovery": { + "maxItems": 32, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "maxLength": 256, + "description": "Resource or operation kind." + }, + "command": { + "type": "string", + "maxLength": 1024, + "description": "CLI command to enumerate destination candidates." + }, + "humanAuthorizationMayBeRequired": { + "type": "boolean", + "description": "Whether discovery may require a human OAuth authorization step." + } + }, + "required": ["kind", "command", "humanAuthorizationMayBeRequired"], + "additionalProperties": false + }, + "description": "CLI operations for discovering suitable destination resources." } }, - "required": ["runId", "statusUrl"], + "required": [ + "previewFingerprint", + "ready", + "bindings", + "unresolvedBindings", + "configuration", + "unresolvedConfiguration", + "discovery" + ], "additionalProperties": false, - "title": "Queued workflow resume", - "description": "Receipt returned when a resumed workflow attempt is queued." + "title": "PreviewWorkflowImportResult", + "description": "The PreviewWorkflowImportResult result." }, - "ResumeWorkflowQueuedResponse": { + "PreviewWorkflowImportResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/QueuedWorkflowResume" + "$ref": "#/components/schemas/PreviewWorkflowImportResult" } }, "required": ["data"], "additionalProperties": false, - "title": "Queued workflow resume response", - "description": "Receipt returned when a resumed workflow attempt is queued.", - "examples": [ - { - "data": { - "runId": "run_8f14e45f-ceea-467f-a", - "statusUrl": "https://www.sim.ai/api/v2/workflows/3b1f7c92-8d4e-4a6b-9c0d-5e2f8a714b36/runs/run_8f14e45f-ceea-467f-a" - } - } - ] + "title": "PreviewWorkflowImport response", + "description": "The response for this operation." }, - "ResumeWorkflowRequest": { + "ImportWorkflowRequest": { "type": "object", "properties": { - "contextId": { + "workspaceId": { "type": "string", "minLength": 1, - "description": "Human-in-the-loop pause-context identifier." + "maxLength": 128, + "description": "Workspace in which to import the workflow." }, - "input": { - "description": "Input supplied to the paused workflow block." - } - }, - "required": ["contextId"], - "additionalProperties": false, - "title": "Resume workflow request", - "description": "Pause context and optional input used to resume a workflow run.", - "examples": [ - { - "contextId": "ctx_123", - "input": { - "approved": true - } - } - ] - }, - "CancelWorkflowRunResult": { - "type": "object", - "properties": { - "success": { - "type": "boolean", - "description": "Whether cancellation was accepted." + "workflow": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "description": "JSON string containing a workflow export object or bare workflow state." + }, + { + "type": "object", + "additionalProperties": true, + "description": "Workflow export object or bare workflow state." + } + ], + "description": "Workflow export object, bare workflow state, or JSON string containing either form." }, - "runId": { + "folderPath": { + "description": "Destination folder path; omit for the workspace root.", + "$ref": "#/components/schemas/FolderPathInput" + }, + "name": { + "description": "Override for the imported workflow name.", "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9._:-]+$", - "description": "Unique workflow run identifier.", - "examples": ["run_8f14e45f-ceea-467f-a"] - }, - "redisAvailable": { - "type": "boolean", - "description": "Whether the distributed cancellation channel was available." + "maxLength": 200 }, - "durablyRecorded": { - "type": "boolean", - "description": "Whether this request durably recorded a cancellation. Always false for a run that was already terminal, where the request is satisfied but nothing was written." + "description": { + "description": "Override for the imported workflow description.", + "type": "string", + "maxLength": 2000 }, - "locallyAborted": { - "type": "boolean", - "description": "Whether an in-process execution was aborted." + "mappings": { + "description": "Mappings keyed by resource type and source identifier.", + "maxItems": 5000, + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox", + "workflow" + ], + "description": "Resource or operation kind." + }, + "sourceId": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "Untrusted source reference label; imports never use it to authorize or query a source workspace." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + } + }, + "required": ["kind", "sourceId", "targetId"], + "additionalProperties": false + } }, - "pausedCancelled": { - "type": "boolean", - "description": "Whether a paused execution was cancelled." + "bindings": { + "description": "Resolved and unresolved source occurrences with their destination selections.", + "maxItems": 5000, + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "valuePath": { + "default": [], + "maxItems": 8, + "type": "array", + "items": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 256 + }, + { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + ] + }, + "description": "Path within the field value; strings address properties and numbers address array entries." + }, + "positions": { + "description": "Positions occupied by this identifier in a multi-value field.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "integer", + "minimum": 0, + "maximum": 2000 + } + }, + "encoding": { + "default": "scalar", + "type": "string", + "enum": ["scalar", "array", "csv", "files", "environment"], + "description": "Registered encoding used to discover and rewrite the reference." + }, + "kind": { + "type": "string", + "enum": [ + "credential", + "env-var", + "knowledge-base", + "knowledge-document", + "table", + "file", + "file-folder", + "mcp-server", + "custom-tool", + "custom-block", + "skill", + "sandbox", + "workflow" + ], + "description": "Resource or operation kind." + }, + "targetId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 4096 + }, + { + "type": "null" + } + ], + "description": "Authorized destination identifier, or null to clear the mapping." + } + }, + "required": ["blockId", "subBlockKey", "kind", "targetId"], + "additionalProperties": false + } }, - "reason": { - "description": "Machine-readable cancellation outcome, present on every cancellation including full successes. `recorded` and `queue_cancelled` are successful cancellation values. `already_cancelled`, `already_completed`, and `already_failed` mean the run had already reached that terminal state, so nothing was cancelled and `durablyRecorded` is false. The remaining values identify a degraded or incomplete cancellation step.", - "type": "string", - "enum": [ - "recorded", - "already_cancelled", - "already_completed", - "already_failed", - "redis_unavailable", - "redis_write_failed", - "paused_event_publish_failed", - "paused_database_cancel_failed", - "queue_cancelled", - "active_resume_signal_failed", - "cancellation_not_finalized" - ] - } - }, - "required": [ - "success", - "runId", - "redisAvailable", - "durablyRecorded", - "locallyAborted", - "pausedCancelled" - ], - "additionalProperties": false, - "title": "Cancel workflow run result", - "description": "Outcome of a workflow run cancellation request. Cancellation is best-effort: a run already in a terminal state succeeds with no effect, reported as `durablyRecorded: false` with an `already_*` reason naming the state observed." - }, - "CancelWorkflowRunResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/CancelWorkflowRunResult" + "dependentValues": { + "description": "Destination-dependent choices keyed by source workflow, block, and field identities.", + "maxItems": 2000, + "type": "array", + "items": { + "type": "object", + "properties": { + "blockId": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Source block identifier before graph ID regeneration." + }, + "subBlockKey": { + "type": "string", + "minLength": 1, + "maxLength": 256, + "description": "Registered source field key, including the tool index for nested Agent fields." + }, + "value": { + "type": "string", + "maxLength": 16384, + "description": "Destination value for the registered dependent field." + } + }, + "required": ["blockId", "subBlockKey", "value"], + "additionalProperties": false + } } }, - "required": ["data"], + "required": ["workspaceId", "workflow"], "additionalProperties": false, - "title": "Cancel workflow run response", - "description": "Outcome of the cancellation request.", - "examples": [ - { - "data": { - "success": true, - "runId": "run_8f14e45f-ceea-467f-a", - "redisAvailable": true, - "durablyRecorded": true, - "locallyAborted": true, - "pausedCancelled": false, - "reason": "recorded" - } - } - ] + "title": "Import workflow request", + "description": "Portable workflow data and destination metadata for an import." }, - "WorkflowFolder": { + "SelectorOption": { "type": "object", "properties": { - "name": { - "type": "string", - "description": "Folder name." - }, - "path": { - "type": "string", - "title": "Non-root folder path", - "description": "Canonical folder path used as the public folder identifier.", - "maxLength": 4096 - }, - "parentPath": { - "type": "string", - "title": "Folder path", - "description": "Canonical parent path; `/` is the root.", - "maxLength": 4096 - }, - "createdAt": { + "id": { "type": "string", - "description": "ISO 8601 timestamp when the folder was created.", - "format": "date-time" + "minLength": 1, + "maxLength": 16384, + "description": "Provider resource identifier." }, - "updatedAt": { + "label": { "type": "string", - "description": "ISO 8601 timestamp when the folder was last updated.", - "format": "date-time" + "minLength": 1, + "maxLength": 16384, + "description": "Human-readable provider resource name." }, - "locked": { - "type": "boolean", - "description": "Whether the folder is currently locked for mutation." + "meta": { + "description": "Safe scalar metadata for presenting or configuring this choice.", + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "additionalProperties": { + "anyOf": [ + { + "type": "string", + "maxLength": 16384 + }, + { + "type": "number" + }, + { + "type": "boolean" + }, + { + "type": "null" + } + ] + } } }, - "required": ["name", "path", "parentPath", "createdAt", "updatedAt", "locked"], + "required": ["id", "label"], "additionalProperties": false, - "title": "Workflow folder", - "description": "A canonical workflow folder and its mutation lock state." + "title": "SelectorOption", + "description": "The SelectorOption result." }, - "WorkflowFolderListResponse": { + "ListSelectorResponse": { "type": "object", "properties": { "data": { + "maxItems": 100, "type": "array", "items": { - "$ref": "#/components/schemas/WorkflowFolder" + "$ref": "#/components/schemas/SelectorOption" }, - "description": "Items in the current page." + "description": "Requested options or operation result." }, "nextCursor": { "anyOf": [ { - "type": "string" + "type": "string", + "maxLength": 32768 }, { "type": "null" } ], - "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." - } - }, - "required": ["data", "nextCursor"], - "additionalProperties": false, - "title": "Workflow folder list response", - "description": "A list of canonical workflow folders.", - "examples": [ - { - "data": [ - { - "name": "Operations", - "path": "/Operations", - "parentPath": "/", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-05-01T09:00:00.000Z", - "locked": false - } - ], - "nextCursor": null - } - ] - }, - "CreateWorkflowFolderResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowFolder" + "description": "Opaque cursor for the next page. Send it back as `cursor`; null means there is nothing further to fetch. Never construct one yourself." + }, + "truncated": { + "type": "boolean", + "description": "Whether the provider returned only a bounded subset of its options." } }, - "required": ["data"], + "required": ["data", "nextCursor", "truncated"], "additionalProperties": false, - "title": "Create workflow folder response", - "description": "The created workflow folder.", - "examples": [ - { - "data": { - "name": "Operations", - "path": "/Operations", - "parentPath": "/", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-05-01T09:00:00.000Z", - "locked": false - } - } - ] - }, - "NonRootFolderPathInput": { - "title": "Non-root folder path input", - "description": "Non-root folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", - "maxLength": 4096, - "type": "string" + "title": "ListSelector response", + "description": "The response for this operation." }, - "CreateWorkflowFolderRequest": { + "ListSelectorBody": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace in which to create the folder." + "description": "Explicit current workspace scope." }, - "path": { - "description": "Path of the folder to create.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "selectorKey": { + "type": "string", + "enum": [ + "airtable.bases", + "airtable.tables", + "asana.workspaces", + "attio.lists", + "attio.objects", + "bigquery.datasets", + "bigquery.tables", + "bitbucket.workspaces", + "bitbucket.repositories", + "calcom.eventTypes", + "calcom.schedules", + "clickup.workspaces", + "clickup.spaces", + "clickup.folders", + "clickup.lists", + "confluence.spaces", + "confluence.spacesById", + "confluence.pages", + "google.tasks.lists", + "gmail.labels", + "google.calendar", + "google.drive", + "google.sheets", + "harmonic.savedSearches", + "hubspot.lists", + "hubspot.owners", + "hubspot.pipelines", + "hubspot.pipelineStages", + "hubspot.properties", + "jsm.requestTypes", + "jsm.serviceDesks", + "microsoft.planner.plans", + "notion.databases", + "notion.pages", + "netsuite.recordTypes", + "netsuite.asyncTasks", + "pipedrive.pipelines", + "sharepoint.lists", + "trello.boards", + "zoho_desk.organizations", + "zoho_desk.departments", + "zoho_desk.agents", + "zoom.meetings", + "slack.channels", + "snowflake.databases", + "snowflake.schemas", + "snowflake.tables", + "snowflake.warehouses", + "snowflake.roles", + "snowflake.fileFormats", + "snowflake.procedures", + "slack.users", + "outlook.folders", + "outlook.calendars", + "microsoft.teams", + "microsoft.chats", + "microsoft.channels", + "microsoft.planner", + "onedrive.files", + "onedrive.folders", + "sharepoint.sites", + "microsoft.excel", + "microsoft.excel.drives", + "microsoft.excel.sheets", + "microsoft.word", + "wealthbox.contacts", + "jira.issues", + "jira.projects", + "linear.projects", + "linear.teams", + "monday.boards", + "monday.groups", + "webflow.sites", + "webflow.collections", + "webflow.items", + "cloudwatch.logGroups", + "cloudwatch.logStreams", + "imap.mailboxes", + "mcp.tools", + "managedAgent.agents", + "managedAgent.environments", + "managedAgent.vaults", + "managedAgent.memoryStores", + "knowledge.documents", + "sim.workflows", + "table.columns", + "table.outputColumns", + "workspace.secretNames", + "workspace.sandboxes", + "providers.ollamaEmbeddingModels", + "providers.openrouterEmbeddingModels" + ], + "description": "Registered selector key for discovering this field’s destination options." + }, + "context": { + "default": {}, + "description": "Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization.", + "type": "object", + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 64 + }, + "additionalProperties": { + "type": "string", + "maxLength": 16384 + } + }, + "search": { + "description": "Provider option search text.", + "type": "string", + "minLength": 1, + "maxLength": 1024 + }, + "cursor": { + "description": "Opaque continuation cursor returned by the preceding page.", + "type": "string", + "minLength": 1, + "maxLength": 32768 + }, + "limit": { + "default": 50, + "description": "Maximum number of items to return on one page.", + "type": "integer", + "minimum": 1, + "maximum": 100 } }, - "required": ["workspaceId", "path"], + "required": ["workspaceId", "selectorKey"], "additionalProperties": false, - "title": "Create workflow folder request", - "description": "Workspace and canonical path for a new workflow folder." + "title": "ListSelector body", + "description": "The body for this operation." }, - "RelocateWorkflowFolderResponse": { + "GetSelectorResponse": { "type": "object", "properties": { "data": { - "description": "Response data.", - "$ref": "#/components/schemas/WorkflowFolder" + "anyOf": [ + { + "$ref": "#/components/schemas/SelectorOption" + }, + { + "type": "null" + } + ], + "description": "Response data." } }, "required": ["data"], "additionalProperties": false, - "title": "Relocate workflow folder response", - "description": "The relocated workflow folder.", - "examples": [ - { - "data": { - "name": "Support", - "path": "/Support", - "parentPath": "/", - "createdAt": "2026-05-01T09:00:00.000Z", - "updatedAt": "2026-05-01T09:00:00.000Z", - "locked": false - } - } - ] + "title": "GetSelector response", + "description": "The response for this operation." }, - "RelocateWorkflowFolderRequest": { + "GetSelectorBody": { "type": "object", "properties": { "workspaceId": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace containing the folder." - }, - "path": { - "description": "Current folder path.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "description": "Explicit current workspace scope." }, - "destinationPath": { - "description": "New full path for the folder and its descendants.", - "$ref": "#/components/schemas/NonRootFolderPathInput" - } - }, - "required": ["workspaceId", "path", "destinationPath"], - "additionalProperties": false, - "title": "Relocate workflow folder request", - "description": "Current and destination paths for a workflow folder." - }, - "DeleteWorkflowFolderResult": { - "type": "object", - "properties": { - "path": { + "selectorKey": { "type": "string", - "title": "Folder path", - "description": "Path of the deleted workflow folder.", - "maxLength": 4096 - }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Confirms that the folder was deleted." + "enum": [ + "airtable.bases", + "airtable.tables", + "asana.workspaces", + "attio.lists", + "attio.objects", + "bigquery.datasets", + "bigquery.tables", + "bitbucket.workspaces", + "bitbucket.repositories", + "calcom.eventTypes", + "calcom.schedules", + "clickup.workspaces", + "clickup.spaces", + "clickup.folders", + "clickup.lists", + "confluence.spaces", + "confluence.spacesById", + "confluence.pages", + "google.tasks.lists", + "gmail.labels", + "google.calendar", + "google.drive", + "google.sheets", + "harmonic.savedSearches", + "hubspot.lists", + "hubspot.owners", + "hubspot.pipelines", + "hubspot.pipelineStages", + "hubspot.properties", + "jsm.requestTypes", + "jsm.serviceDesks", + "microsoft.planner.plans", + "notion.databases", + "notion.pages", + "netsuite.recordTypes", + "netsuite.asyncTasks", + "pipedrive.pipelines", + "sharepoint.lists", + "trello.boards", + "zoho_desk.organizations", + "zoho_desk.departments", + "zoho_desk.agents", + "zoom.meetings", + "slack.channels", + "snowflake.databases", + "snowflake.schemas", + "snowflake.tables", + "snowflake.warehouses", + "snowflake.roles", + "snowflake.fileFormats", + "snowflake.procedures", + "slack.users", + "outlook.folders", + "outlook.calendars", + "microsoft.teams", + "microsoft.chats", + "microsoft.channels", + "microsoft.planner", + "onedrive.files", + "onedrive.folders", + "sharepoint.sites", + "microsoft.excel", + "microsoft.excel.drives", + "microsoft.excel.sheets", + "microsoft.word", + "wealthbox.contacts", + "jira.issues", + "jira.projects", + "linear.projects", + "linear.teams", + "monday.boards", + "monday.groups", + "webflow.sites", + "webflow.collections", + "webflow.items", + "cloudwatch.logGroups", + "cloudwatch.logStreams", + "imap.mailboxes", + "mcp.tools", + "managedAgent.agents", + "managedAgent.environments", + "managedAgent.vaults", + "managedAgent.memoryStores", + "knowledge.documents", + "sim.workflows", + "table.columns", + "table.outputColumns", + "workspace.secretNames", + "workspace.sandboxes", + "providers.ollamaEmbeddingModels", + "providers.openrouterEmbeddingModels" + ], + "description": "Registered selector key for discovering this field’s destination options." }, - "deletedItems": { + "context": { + "default": {}, + "description": "Only the dependencies declared by the selector, such as oauthCredential and channelId. Missing OAuth connections require human authorization.", "type": "object", - "properties": { - "folders": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of folders deleted." - }, - "workflows": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of workflows deleted." - } + "propertyNames": { + "type": "string", + "minLength": 1, + "maxLength": 64 }, - "required": ["folders", "workflows"], - "additionalProperties": false, - "description": "Resources removed by the deletion." + "additionalProperties": { + "type": "string", + "maxLength": 16384 + } + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 16384, + "description": "Resource identifier." } }, - "required": ["path", "deleted", "deletedItems"], + "required": ["workspaceId", "selectorKey", "id"], "additionalProperties": false, - "title": "Delete workflow folder result", - "description": "Confirmation and deletion counts for a workflow folder." + "title": "GetSelector body", + "description": "The body for this operation." }, - "DeleteWorkflowFolderResponse": { + "GetWorkspaceOperationResponse": { "type": "object", "properties": { "data": { "description": "Response data.", - "$ref": "#/components/schemas/DeleteWorkflowFolderResult" + "$ref": "#/components/schemas/WorkspaceOperationReport" } }, "required": ["data"], "additionalProperties": false, - "title": "Delete workflow folder response", - "description": "Confirmation and counts for the deleted folder.", - "examples": [ - { - "data": { - "path": "/Operations", - "deleted": true, - "deletedItems": { - "folders": 1, - "workflows": 0 + "title": "GetWorkspaceOperation response", + "description": "The response for this operation." + }, + "ListWorkspaceOperationsResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WorkspaceOperationReport" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" } - } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." } - ] + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "ListWorkspaceOperations response", + "description": "The response for this operation." } } }, diff --git a/apps/docs/public/static/search/confluence-setup.jpg b/apps/docs/public/static/search/confluence-setup.jpg index 8ad1f1aed92..8efc63e456d 100644 Binary files a/apps/docs/public/static/search/confluence-setup.jpg and b/apps/docs/public/static/search/confluence-setup.jpg differ diff --git a/apps/docs/public/static/search/connect-account.png b/apps/docs/public/static/search/connect-account.png index 163bdd34312..d4ad8a4044a 100644 Binary files a/apps/docs/public/static/search/connect-account.png and b/apps/docs/public/static/search/connect-account.png differ diff --git a/apps/docs/public/static/search/github-indexing-options.jpg b/apps/docs/public/static/search/github-indexing-options.jpg new file mode 100644 index 00000000000..ff4a800f808 Binary files /dev/null and b/apps/docs/public/static/search/github-indexing-options.jpg differ diff --git a/apps/docs/public/static/search/github-installation-connect.jpg b/apps/docs/public/static/search/github-installation-connect.jpg new file mode 100644 index 00000000000..dacb637a791 Binary files /dev/null and b/apps/docs/public/static/search/github-installation-connect.jpg differ diff --git a/apps/docs/public/static/search/github-installation-select.jpg b/apps/docs/public/static/search/github-installation-select.jpg new file mode 100644 index 00000000000..a79f975dbff Binary files /dev/null and b/apps/docs/public/static/search/github-installation-select.jpg differ diff --git a/apps/docs/public/static/search/github-setup.jpg b/apps/docs/public/static/search/github-setup.jpg index 3cf512628e9..454bd18b10e 100644 Binary files a/apps/docs/public/static/search/github-setup.jpg and b/apps/docs/public/static/search/github-setup.jpg differ diff --git a/apps/docs/public/static/search/google-drive-setup.jpg b/apps/docs/public/static/search/google-drive-setup.jpg index b9f4368fc72..a61673d3495 100644 Binary files a/apps/docs/public/static/search/google-drive-setup.jpg and b/apps/docs/public/static/search/google-drive-setup.jpg differ diff --git a/apps/docs/public/static/search/integration-provider.jpg b/apps/docs/public/static/search/integration-provider.jpg index 96341327613..bf655343f51 100644 Binary files a/apps/docs/public/static/search/integration-provider.jpg and b/apps/docs/public/static/search/integration-provider.jpg differ diff --git a/apps/docs/public/static/search/integration-settings.jpg b/apps/docs/public/static/search/integration-settings.jpg index ab46bb0e984..f4e517c0656 100644 Binary files a/apps/docs/public/static/search/integration-settings.jpg and b/apps/docs/public/static/search/integration-settings.jpg differ diff --git a/apps/docs/public/static/search/slack-setup.jpg b/apps/docs/public/static/search/slack-setup.jpg index 92e06d28802..c9ee55a27df 100644 Binary files a/apps/docs/public/static/search/slack-setup.jpg and b/apps/docs/public/static/search/slack-setup.jpg differ diff --git a/apps/docs/public/static/search/source-settings.jpg b/apps/docs/public/static/search/source-settings.jpg index 1a1cb850924..d89c8062380 100644 Binary files a/apps/docs/public/static/search/source-settings.jpg and b/apps/docs/public/static/search/source-settings.jpg differ diff --git a/apps/docs/public/static/search/source-sync-history.jpg b/apps/docs/public/static/search/source-sync-history.jpg index 84151025f0d..59b7ef8abd6 100644 Binary files a/apps/docs/public/static/search/source-sync-history.jpg and b/apps/docs/public/static/search/source-sync-history.jpg differ diff --git a/apps/sim/.env.example b/apps/sim/.env.example index f00c68f8094..4f2b7a58cee 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -143,6 +143,17 @@ CRON_SECRET=your_cron_secret # Use `openssl rand -hex 32` to generate. Authentic # S3_ENDPOINT= # Custom endpoint for S3-compatible storage (Cloudflare R2, MinIO, Backblaze B2). Leave unset for AWS S3 # S3_FORCE_PATH_STYLE=true # Required for MinIO/Ceph RGW. Leave unset for AWS S3 and R2 +# GitHub Search (Optional - credentials from a GitHub App with expiring user tokens) +# Use a separate app for production and staging. Allow installation by any account for a public app. +# Grant repository Contents: read and Metadata: read, plus user Email addresses: read. +# Callback: /api/auth/oauth2/callback/github-repositories +# GITHUB_APP_CLIENT_ID= # GitHub App client ID; distinct from sign-in OAuth credentials +# GITHUB_APP_CLIENT_SECRET= +# Optional organization indexing through an app installation; readers still connect their own GitHub account. +# GITHUB_APP_ID= # Numeric GitHub App ID +# GITHUB_APP_SLUG= # App slug from https://github.com/apps/ +# GITHUB_APP_PRIVATE_KEY= # RSA PEM private key; literal \\n sequences are accepted + # Instagram OAuth (Optional - Instagram App ID/Secret from Meta App Dashboard > Instagram > API setup with Instagram login) # INSTAGRAM_CLIENT_ID= # INSTAGRAM_CLIENT_SECRET= diff --git a/apps/sim/app/(auth)/verify/use-verification.test.tsx b/apps/sim/app/(auth)/verify/use-verification.test.tsx new file mode 100644 index 00000000000..bb09a6d84ea --- /dev/null +++ b/apps/sim/app/(auth)/verify/use-verification.test.tsx @@ -0,0 +1,102 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + session: vi.fn(), + refetch: vi.fn(), + verify: vi.fn(), + resend: vi.fn(), +})) + +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: () => ({ data: mocks.session(), refetch: mocks.refetch }), + client: { emailOtp: { verifyEmail: mocks.verify, sendVerificationOtp: mocks.resend } }, +})) +vi.mock('next/navigation', () => ({ useSearchParams: () => new URLSearchParams() })) + +import { useVerification } from '@/app/(auth)/verify/use-verification' + +function useTestVerification() { + return useVerification({ + hasEmailService: true, + isProduction: true, + isEmailVerificationEnabled: true, + }) +} + +let root: Root +function renderVerification() { + const result = { current: undefined as ReturnType | undefined } + function Harness() { + result.current = useTestVerification() + return null + } + act(() => root.render()) + return { + get current() { + return result.current! + }, + } +} + +beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + const container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + sessionStorage.clear() + mocks.session.mockReturnValue({ user: { email: 'member@example.com', emailVerified: false } }) + mocks.verify.mockResolvedValue({}) + mocks.resend.mockResolvedValue({}) +}) + +afterEach(() => { + act(() => root.unmount()) + document.body.innerHTML = '' + vi.clearAllTimers() + vi.useRealTimers() + vi.unstubAllGlobals() + sessionStorage.clear() +}) + +describe('verification after opening an enrollment in a new tab', () => { + it('resends and verifies for the signed-in user without signup storage', async () => { + const result = renderVerification() + expect(result.current.email).toBe('member@example.com') + await act(async () => result.current.resendCode()) + expect(mocks.resend).toHaveBeenCalledWith({ + email: 'member@example.com', + type: 'email-verification', + }) + act(() => result.current.handleOtpChange('123456')) + await act(async () => result.current.verifyCode()) + expect(mocks.verify).toHaveBeenCalledWith({ email: 'member@example.com', otp: '123456' }) + expect(result.current.status).toBe('verified') + expect(mocks.refetch).toHaveBeenCalled() + }) + + it('uses the current account over a previous signup address in the tab', async () => { + sessionStorage.setItem('verificationEmail', 'previous@example.com') + const result = renderVerification() + await act(async () => result.current.resendCode()) + expect(mocks.resend).toHaveBeenCalledWith({ + email: 'member@example.com', + type: 'email-verification', + }) + }) + + it('preserves signup verification before a session exists', async () => { + mocks.session.mockReturnValue(null) + sessionStorage.setItem('verificationEmail', 'signup@example.com') + const result = renderVerification() + await act(async () => result.current.resendCode()) + expect(mocks.resend).toHaveBeenCalledWith({ + email: 'signup@example.com', + type: 'email-verification', + }) + }) +}) diff --git a/apps/sim/app/(auth)/verify/use-verification.ts b/apps/sim/app/(auth)/verify/use-verification.ts index 5927438998b..b2009abdbbc 100644 --- a/apps/sim/app/(auth)/verify/use-verification.ts +++ b/apps/sim/app/(auth)/verify/use-verification.ts @@ -75,18 +75,19 @@ export function useVerification({ isEmailVerificationEnabled, }: UseVerificationParams): UseVerificationReturn { const searchParams = useSearchParams() - const { refetch: refetchSession } = useSession() + const { data: session, refetch: refetchSession } = useSession() const [otp, setOtp] = useState('') - const [email, setEmail] = useState('') + const [storedEmail, setStoredEmail] = useState('') const [status, setStatus] = useState('idle') const [isResending, setIsResending] = useState(false) const [errorMessage, setErrorMessage] = useState('') useEffect(() => { const storedEmail = sessionStorage.getItem('verificationEmail') - if (storedEmail) setEmail(storedEmail) + if (storedEmail) setStoredEmail(storedEmail) }, []) + const email = session?.user?.email || storedEmail const isOtpComplete = otp.length === 6 async function verifyCode() { diff --git a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx index a39fb279919..551079a0b80 100644 --- a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx +++ b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.test.tsx @@ -15,6 +15,8 @@ const CYCLE_MS = 17_100 let pending: FrameRequestCallback[] = [] let clock = 0 +let reducedMotion = false +let onMotionPreference: (() => void) | undefined let root: Root | null = null let host: HTMLDivElement | null = null @@ -40,15 +42,23 @@ beforeEach(() => { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true pending = [] clock = 0 + reducedMotion = false + onMotionPreference = undefined const stubs = { requestAnimationFrame: (cb: FrameRequestCallback) => pending.push(cb), cancelAnimationFrame: () => { pending = [] }, matchMedia: () => ({ - matches: false, - addEventListener: () => {}, - removeEventListener: () => {}, + get matches() { + return reducedMotion + }, + addEventListener: (_type: string, listener: () => void) => { + onMotionPreference = listener + }, + removeEventListener: () => { + onMotionPreference = undefined + }, }), } for (const [name, value] of Object.entries(stubs)) { @@ -79,7 +89,10 @@ describe('FooterWordmarkLoop', () => { expect(html).toContain('aria-hidden="true"') expect(html).toContain('data-stage="wm" opacity="1"') expect(html).toContain('data-stage="orb" opacity="0"') - expect(html).toContain('stdDeviation="0.55"') + expect(html).toContain('stdDeviation="0"') + expect(html).toMatch(/filter="url\(#fwl-goo-/) + expect(html).toContain('values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 1 0"') + expect(html).not.toContain(' { it('plays the master timeline: wordmark, orb, the seven shapes, orb, wordmark', () => { expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000') - expect(attr('[data-goo]', 'stdDeviation')).toBe('0.550') + expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) advanceTo(2700) expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000') expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000') expect(attr('[data-goo]', 'stdDeviation')).toBe('5.000') + expect(attr('[data-goo-matrix]', 'values')).toMatch(/40\.000 -19\.000$/) advanceTo(3900) expect(attr('[data-stage="metaballs"]', 'opacity')).toBe('1.0000') @@ -112,13 +127,53 @@ describe('FooterWordmarkLoop', () => { advanceTo(16000) expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000') expect(attr('[data-stage="thinking"]', 'opacity')).toBe('0.0000') - expect(attr('[data-goo]', 'stdDeviation')).toBe('0.550') + expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) advanceTo(CYCLE_MS + 2700) expect(attr('[data-stage="orb"]', 'opacity')).toBe('1.0000') expect(attr('[data-stage="wm"]', 'opacity')).toBe('0.0000') }) + it('returns to an identity filter when reduced motion is enabled mid-morph', () => { + advanceTo(2700) + expect(attr('[data-goo-matrix]', 'values')).toMatch(/40\.000 -19\.000$/) + + reducedMotion = true + act(() => onMotionPreference?.()) + + expect(pending).toHaveLength(0) + expect(attr('[data-stage="wm"]', 'opacity')).toBe('1.0000') + expect(attr('[data-stage="orb"]', 'opacity')).toBe('0.0000') + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) + expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') + }) + + it('eases the same filter to identity at both wordmark boundaries', () => { + advanceTo(1300) + expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) + + advanceTo(1301) + expect(Number(attr('[data-goo]', 'stdDeviation'))).toBeLessThan(0.001) + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) + + advanceTo(1800) + expect(attr('[data-goo-matrix]', 'values')).toMatch(/40\.000 -19\.000$/) + + advanceTo(2500) + expect(attr('[data-goo]', 'stdDeviation')).toBe('5.000') + + advanceTo(15199) + expect(Number(attr('[data-goo]', 'stdDeviation'))).toBeLessThan(0.001) + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) + + advanceTo(15200) + expect(attr('[data-goo]', 'stdDeviation')).toBe('0.000') + expect(attr('[data-goo-matrix]', 'values')).toMatch(/1\.000 -?0\.000$/) + expect(host?.querySelector('feComposite')).toBeNull() + }) + it('stops requesting frames on unmount', () => { advanceTo(500) act(() => root?.unmount()) diff --git a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx index f07058d2c1a..f8d2d9c6688 100644 --- a/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx +++ b/apps/sim/app/(landing)/components/footer/components/footer-wordmark-loop/footer-wordmark-loop.tsx @@ -43,11 +43,9 @@ const ORB_BEAT = 450 /** Closing hold on the wordmark before the loop wraps back to the opening hold. */ const HOLD_LOGO_END = 1700 const TAIL = 200 -/** Goo blur while liquid (through the cycle) and while crisp (the wordmark). */ +/** Blur range for the filtered portion of the morph. */ const GOO_HI = 5 const GOO_LO = 0.55 -/** Post-threshold blur, about half a device pixel at the mark's largest size. */ -const EDGE_SMOOTHING = 0.16 /** * Shapes that restart from compact when they appear and play exactly one pulse * of this many ms (just under a loop, so the dots reach the edge without @@ -356,13 +354,18 @@ interface StageNode { key: StageKey } +interface GooFilterNodes { + blur: SVGFEGaussianBlurElement + matrix: SVGFEColorMatrixElement +} + /** * Paints one frame of the choreography at `t` ms into the cycle by writing * SVG attributes directly - no React render per frame. */ function paintFrame( t: number, - blur: SVGFEGaussianBlurElement, + goo: GooFilterNodes, stages: StageNode[], anims: AnimatedNode[] ): void { @@ -411,8 +414,17 @@ function paintFrame( smooth(T_LOGO_HOLD_END, T_INTRO_END, t), 1 - smooth(T_OUTRO_START, T_OUTRO_END, t) ) - const deviation = round(GOO_LO + (GOO_HI - GOO_LO) * liquid) - if (blur.getAttribute('stdDeviation') !== deviation) blur.setAttribute('stdDeviation', deviation) + /** Ease the filter to identity at rest without overlaying the unfiltered shapes. */ + const strength = Math.min( + smooth(T_LOGO_HOLD_END, T_LOGO_HOLD_END + MORPH, t), + 1 - smooth(T_OUTRO_END - MORPH, T_OUTRO_END, t) + ) + const deviation = round((GOO_LO + (GOO_HI - GOO_LO) * liquid) * strength) + if (goo.blur.getAttribute('stdDeviation') !== deviation) { + goo.blur.setAttribute('stdDeviation', deviation) + } + const matrix = `1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 ${round(1 + 39 * strength)} ${round(-19 * strength)}` + if (goo.matrix.getAttribute('values') !== matrix) goo.matrix.setAttribute('values', matrix) } interface FooterWordmarkLoopProps { @@ -454,7 +466,9 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { const svg = svgRef.current if (!svg) return const blur = svg.querySelector('[data-goo]') - if (!blur) return + const matrix = svg.querySelector('[data-goo-matrix]') + if (!blur || !matrix) return + const goo: GooFilterNodes = { blur, matrix } const stages: StageNode[] = Array.from( svg.querySelectorAll('[data-stage]'), @@ -475,7 +489,7 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { const tick = (now: number) => { if (previous !== null) elapsed += Math.min(now - previous, MAX_FRAME_STEP) previous = now - paintFrame(elapsed % CYCLE_MS, blur, stages, anims) + paintFrame(elapsed % CYCLE_MS, goo, stages, anims) frame = requestAnimationFrame(tick) } const play = () => { @@ -492,7 +506,7 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { if (reducedMotion?.matches) { pause() elapsed = 0 - paintFrame(0, blur, stages, anims) + paintFrame(0, goo, stages, anims) } else { play() } @@ -536,20 +550,16 @@ export function FooterWordmarkLoop({ className }: FooterWordmarkLoopProps) { height='160%' colorInterpolationFilters='sRGB' > - + {/* A steep threshold: the melt between shapes keeps its liquid merges, but every edge resolves within a pixel, so the mark stays crisp at the cycle's full blur. */} - {/* The threshold discards the rasterizer's edge coverage, so at the - resting blur the wordmark's edge fell inside a device pixel and - stair-stepped at the largest size. A sub-pixel blur after it - restores ordinary anti-aliasing without touching the melt. */} - diff --git a/apps/sim/app/_shell/providers/theme-provider.test.tsx b/apps/sim/app/_shell/providers/theme-provider.test.tsx index c3f58ce2ae7..98d37b45e51 100644 --- a/apps/sim/app/_shell/providers/theme-provider.test.tsx +++ b/apps/sim/app/_shell/providers/theme-provider.test.tsx @@ -9,6 +9,7 @@ const { mockUsePathname } = vi.hoisted(() => ({ mockUsePathname: vi.fn() })) vi.mock('next/navigation', () => ({ usePathname: mockUsePathname })) +import { syncThemeToNextThemes } from '@/lib/core/utils/theme' import { ThemeProvider } from '@/app/_shell/providers/theme-provider' let root: Root @@ -37,6 +38,15 @@ function render(pathname: string) { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + /** The global storage mock is not a native jsdom Storage instance. */ + vi.stubGlobal( + 'StorageEvent', + class extends window.StorageEvent { + constructor(type: string, init: StorageEventInit) { + super(type, { ...init, storageArea: null }) + } + } + ) stubDarkOs() localStorage.clear() document.documentElement.className = '' @@ -74,4 +84,61 @@ describe('ThemeProvider theme stores', () => { localStorage.setItem('sim-landing-theme', 'dark') expect(render('/login')).toContain('light') }) + + it.each(['/', '/blog', '/customers/example'])( + 'keeps %s light when account settings resolve dark', + (pathname) => { + localStorage.setItem('sim-theme', 'dark') + const classes = render(pathname) + expect(classes).toContain('light') + + act(() => syncThemeToNextThemes('dark')) + + expect(classes).toContain('light') + expect(classes).not.toContain('dark') + } + ) + + it('preserves the landing footer choice when account settings change', () => { + localStorage.setItem('sim-landing-theme', 'dark') + const classes = render('/workflows') + + act(() => syncThemeToNextThemes('light')) + + expect(classes).toContain('dark') + expect(localStorage.getItem('sim-landing-theme')).toBe('dark') + expect(localStorage.getItem('sim-theme')).toBe('light') + }) + + it('preserves the forced auth theme when account settings resolve', () => { + const classes = render('/login') + + act(() => syncThemeToNextThemes('dark')) + + expect(classes).toContain('light') + expect(classes).not.toContain('dark') + }) + + it('updates the workspace theme when account settings resolve', () => { + localStorage.setItem('sim-theme', 'light') + const classes = render('/workspace/ws-1/home') + expect(classes).toContain('light') + + act(() => syncThemeToNextThemes('dark')) + + expect(classes).toContain('dark') + expect(classes).not.toContain('light') + expect(document.documentElement.style.colorScheme).toBe('dark') + }) + + it('resolves the workspace system theme through the active provider', () => { + localStorage.setItem('sim-theme', 'light') + const classes = render('/workspace/ws-1/home') + + act(() => syncThemeToNextThemes('system')) + + expect(classes).toContain('dark') + expect(document.documentElement.style.colorScheme).toBe('dark') + expect(localStorage.getItem('sim-theme')).toBe('system') + }) }) diff --git a/apps/sim/app/account/settings/[section]/page.test.tsx b/apps/sim/app/account/settings/[section]/page.test.tsx index 838297805e3..bd2226565d6 100644 --- a/apps/sim/app/account/settings/[section]/page.test.tsx +++ b/apps/sim/app/account/settings/[section]/page.test.tsx @@ -52,7 +52,10 @@ describe('account settings legacy links', () => { ) }) - it('still rejects unknown sections', async () => { - await expect(AccountSettingsSectionPage(pageProps('unknown'))).rejects.toThrow('NEXT_NOT_FOUND') - }) + it.each(['unknown', 'connected-accounts'])( + 'rejects unavailable sections: %s', + async (section) => { + await expect(AccountSettingsSectionPage(pageProps(section))).rejects.toThrow('NEXT_NOT_FOUND') + } + ) }) diff --git a/apps/sim/app/api/auth/oauth/credentials/route.test.ts b/apps/sim/app/api/auth/oauth/credentials/route.test.ts index 94f2cf1a7ab..1a0e2a60de4 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.test.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.test.ts @@ -38,6 +38,7 @@ vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) vi.mock('@/lib/permission-groups/config-scope.server', () => permissionGroupScopeMock) +import { getCanonicalScopesForProvider, getMissingRequiredScopes } from '@/lib/oauth/utils' import { DEFAULT_PERMISSION_GROUP_CONFIG } from '@/lib/permission-groups/fields' import { GET } from '@/app/api/auth/oauth/credentials/route' @@ -146,6 +147,100 @@ describe('OAuth Credentials API Route', () => { await expect(response.json()).resolves.toEqual({ credentials: [] }) }) + describe.each(['list', 'detail'] as const)('OAuth grant scopes in %s responses', (mode) => { + const workspaceId = '3f1c8a54-1c2e-4a1b-9d6e-2b7c5a9f0e11' + + beforeEach(() => { + hybridAuthMockFns.mockCheckSessionOrInternalAuth.mockReset().mockResolvedValue({ + success: true, + userId: 'user-123', + authType: 'session', + }) + permissionsMockFns.mockCheckWorkspaceAccess.mockResolvedValue({ + exists: true, + hasAccess: true, + canWrite: true, + canAdmin: true, + }) + permissionGroupScopeMockFns.mockResolvePermissionGroupConfig.mockResolvedValue( + DEFAULT_PERMISSION_GROUP_CONFIG + ) + }) + + async function requestCredential(providerId: string, scope: string | null) { + const row = { + id: 'credential-1', + workspaceId, + type: 'oauth', + displayName: 'Connected account', + providerId, + accountId: 'account-1', + scope, + updatedAt: new Date('2026-01-01T00:00:00Z'), + accountProviderId: providerId, + accountScope: scope, + accountUpdatedAt: new Date('2026-01-01T00:00:00Z'), + } + if (mode === 'detail') { + dbChainMockFns.limit.mockResolvedValueOnce([row]) + } else { + dbChainMockFns.where.mockResolvedValueOnce([row]).mockResolvedValueOnce([]) + } + const query = + mode === 'detail' + ? '?credentialId=credential-1' + : `?provider=${providerId}&workspaceId=${workspaceId}` + const response = await GET(createMockRequestWithQuery('GET', query)) + expect(response.status).toBe(200) + const data = await response.json() + expect(data.credentials).toHaveLength(1) + return data.credentials[0] + } + + it.each([null, '', ' \t\n '])( + 'does not synthesize a Confluence grant from missing scope metadata %j', + async (scope) => { + const credential = await requestCredential('confluence', scope) + + expect(credential.scopes).toEqual([]) + expect( + getMissingRequiredScopes(credential, getCanonicalScopesForProvider('confluence')) + ).toContain('read:group:confluence') + } + ) + + it('preserves the actual older Confluence grant and identifies missing group access', async () => { + const requiredScopes = getCanonicalScopesForProvider('confluence') + const previousGrant = requiredScopes.filter((scope) => scope !== 'read:group:confluence') + const credential = await requestCredential('confluence', previousGrant.join(',')) + + expect(credential.scopes).toEqual(previousGrant) + expect(getMissingRequiredScopes(credential, requiredScopes)).toEqual([ + 'read:group:confluence', + ]) + }) + + it('preserves a complete Confluence grant without requesting another update', async () => { + const grantedScopes = getCanonicalScopesForProvider('confluence') + const credential = await requestCredential('confluence', grantedScopes.join(' ')) + + expect(credential.scopes).toEqual(grantedScopes) + expect(getMissingRequiredScopes(credential, grantedScopes)).toEqual([]) + }) + + it.each([null, '', ' \t\n '])( + 'preserves the Box omitted-scope fallback for %j', + async (scope) => { + const credential = await requestCredential('box', scope) + const requiredScopes = getCanonicalScopesForProvider('box') + + expect(requiredScopes.length).toBeGreaterThan(0) + expect(credential.scopes).toEqual(requiredScopes) + expect(getMissingRequiredScopes(credential, requiredScopes)).toEqual([]) + } + ) + }) + /** The session/executor split documented on {@link integrationsWithheldFromSession} in the route. */ describe('integrations.manage', () => { const INTEGRATIONS_WITHHELD = { diff --git a/apps/sim/app/api/auth/oauth/credentials/route.ts b/apps/sim/app/api/auth/oauth/credentials/route.ts index 0625008f233..318e7c7ac98 100644 --- a/apps/sim/app/api/auth/oauth/credentials/route.ts +++ b/apps/sim/app/api/auth/oauth/credentials/route.ts @@ -35,13 +35,15 @@ function toCredentialResponse( credentialType: 'oauth' | 'service_account' = 'oauth' ) { const storedScope = scope?.trim() - // Some providers (e.g. Box) don't return scopes in their token response, - // so the DB column stays empty. Fall back to the configured scopes for - // the provider so the credential-selector doesn't show a false - // "Additional permissions required" banner. + /** + * Confluence reports granted scopes, so absent metadata must prompt reauthorization. + * Preserve the existing fallback for providers that omit scopes, such as Box. + */ const scopes = storedScope ? storedScope.split(/[\s,]+/).filter(Boolean) - : getCanonicalScopesForProvider(providerId) + : providerId === 'confluence' + ? [] + : getCanonicalScopesForProvider(providerId) const [_, featureType = 'default'] = providerId.split('-') return { diff --git a/apps/sim/app/api/credential-groups/enrollment-redirect.ts b/apps/sim/app/api/credential-groups/enrollment-redirect.ts index d2768f19ee7..755a68094b2 100644 --- a/apps/sim/app/api/credential-groups/enrollment-redirect.ts +++ b/apps/sim/app/api/credential-groups/enrollment-redirect.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion' const NO_STORE_REDIRECT_HEADERS = { 'Cache-Control': 'no-store', @@ -20,23 +21,17 @@ export function createCredentialGroupEnrollmentRedirect( }) } -export type CredentialGroupOAuthFailure = - | 'expired' - | 'denied' - | 'account_mismatch' - | 'permissions_required' - | 'configuration_changed' - | 'rate_limited' - | 'unavailable' - | 'failed' - export function createCredentialGroupCompletionRedirect( - oauth?: CredentialGroupOAuthFailure + oauth?: CredentialGroupOAuthFailure, + completionId?: string ): NextResponse { + const query = new URLSearchParams() + if (oauth) query.set('oauth', oauth) + if (completionId) query.set('completionId', completionId) return new NextResponse(null, { status: 303, headers: { - Location: `/credential-groups/complete${oauth ? `?oauth=${oauth}` : ''}`, + Location: `/credential-groups/complete${query.size ? `?${query}` : ''}`, ...NO_STORE_REDIRECT_HEADERS, }, }) diff --git a/apps/sim/app/api/credential-groups/oauth-callback.ts b/apps/sim/app/api/credential-groups/oauth-callback.ts index 399eb222982..aa565c8be62 100644 --- a/apps/sim/app/api/credential-groups/oauth-callback.ts +++ b/apps/sim/app/api/credential-groups/oauth-callback.ts @@ -5,6 +5,7 @@ import type { CredentialGroupOAuthCallbackQuery } from '@/lib/api/contracts/cred import { credentialGroupOAuthAttemptPrincipal } from '@/lib/credential-groups/application/enrollment-auth' import { completePublicCredentialGroupOAuth } from '@/lib/credential-groups/application/public-enrollment' import { CredentialGroupOAuthStateVersionError } from '@/lib/credential-groups/oauth-attempt-version' +import type { CredentialGroupOAuthFailure } from '@/lib/credential-groups/oauth-completion' import { consumeCredentialGroupOAuthAttempt } from '@/lib/credential-groups/oauth-state' import { CredentialGroupInvitationUnavailableError, @@ -12,7 +13,6 @@ import { } from '@/lib/credential-groups/provider-adapter' import type { CredentialGroupProvider } from '@/lib/credential-groups/providers' import { - type CredentialGroupOAuthFailure, createCredentialGroupCompletionRedirect, createCredentialGroupEnrollmentRedirect, } from '@/app/api/credential-groups/enrollment-redirect' @@ -54,7 +54,7 @@ export async function handleCredentialGroupOAuthCallback({ : {} const failureRedirect = (oauth: CredentialGroupOAuthFailure) => attempt.completionRedirect - ? createCredentialGroupCompletionRedirect(oauth) + ? createCredentialGroupCompletionRedirect(oauth, attempt.completionId) : createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { ...focus, oauth }) if (limited) { return failureRedirect('rate_limited') @@ -74,7 +74,7 @@ export async function handleCredentialGroupOAuthCallback({ request, }) return attempt.completionRedirect - ? createCredentialGroupCompletionRedirect() + ? createCredentialGroupCompletionRedirect(undefined, attempt.completionId) : createCredentialGroupEnrollmentRedirect(attempt.invitationToken, { ...focus, connected: attempt.optionId, diff --git a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts index 7c2b4a9edb9..508dc05267a 100644 --- a/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts +++ b/apps/sim/app/api/credential-groups/oauth/[provider]/callback/route.test.ts @@ -56,6 +56,22 @@ function request(query: string) { } describe('credential group OAuth callback', () => { + it.each([ + ['code=code-1', undefined], + ['error=access_denied', 'denied'], + ])( + 'correlates direct OAuth completion without returning to enrollment: %s', + async (query, failure) => { + const completionId = '550e8400-e29b-41d4-a716-446655440000' + mocks.consumeAttempt.mockResolvedValue({ ...attempt, completionRedirect: true, completionId }) + const response = await GET(request(`state=state-1&${query}`), context) + const location = new URL(response.headers.get('location')!, 'https://sim.test') + expect(response.status).toBe(303) + expect(location.pathname).toBe('/credential-groups/complete') + expect(location.searchParams.get('completionId')).toBe(completionId) + expect(location.searchParams.get('oauth')).toBe(failure ?? null) + } + ) beforeEach(() => { vi.clearAllMocks() mocks.rateLimit.mockResolvedValue(null) diff --git a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/enroll/route.ts b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/enroll/route.ts index da3cc91c176..ddedaec89c6 100644 --- a/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/enroll/route.ts +++ b/apps/sim/app/api/knowledge/[id]/connectors/[connectorId]/enroll/route.ts @@ -16,10 +16,11 @@ export const POST = defineInternalJsonRoute({ reason: 'A member connecting their own account by hand; each call only re-issues their own invitation', }), - errorPolicy: internalKnowledgeErrorPolicies.connectors, - mapInput: ({ params }) => ({ + errorPolicy: internalKnowledgeErrorPolicies.connectAccount, + mapInput: ({ params, query }) => ({ connectorId: params.connectorId, knowledgeBaseId: params.id, + oauthCompletionId: query.oauthCompletionId, }), useCase: startKnowledgeConnectorMemberEnrollment, present: ({ url }) => ({ success: true as const, data: { url } }), diff --git a/apps/sim/app/api/knowledge/github/installations/route.test.ts b/apps/sim/app/api/knowledge/github/installations/route.test.ts new file mode 100644 index 00000000000..1352bd78276 --- /dev/null +++ b/apps/sim/app/api/knowledge/github/installations/route.test.ts @@ -0,0 +1,223 @@ +/** + * @vitest-environment node + */ +import { authMockFns } from '@sim/testing' +import { NextRequest, NextResponse } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ list: vi.fn(), connect: vi.fn(), rateLimit: vi.fn() })) + +vi.mock('@/lib/core/rate-limiter', () => ({ + enforceUserRateLimit: mocks.rateLimit, + RateLimiter: class {}, +})) +vi.mock('@/lib/knowledge/application/github-installations', () => ({ + listGitHubSearchInstallations: { + operation: { id: 'knowledge.github.installations.list' }, + execute: mocks.list, + }, + connectGitHubSearchInstallation: { + operation: { id: 'knowledge.github.installations.connect' }, + execute: mocks.connect, + }, +})) +vi.mock('@/lib/oauth/github-installation', () => ({ + GitHubInstallationError: class extends Error { + constructor( + message: string, + readonly status?: number + ) { + super(message) + } + }, +})) +vi.mock('@/lib/credentials/managed-oauth', () => ({ + ManagedOAuthCredentialError: class extends Error { + constructor( + readonly code: string, + message: string, + readonly statusCode: number + ) { + super(message) + } + }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth' +import { GitHubInstallationError } from '@/lib/oauth/github-installation' +import { GET, POST } from '@/app/api/knowledge/github/installations/route' + +const URL = 'http://localhost/api/knowledge/github/installations' +const installation = { + installationId: '123', + accountId: '456', + accountLogin: 'acme', + accountType: 'Organization', +} + +beforeEach(() => { + vi.clearAllMocks() + authMockFns.mockGetSession.mockResolvedValue({ + user: { id: 'admin-1' }, + session: { id: 'session-1' }, + }) + mocks.rateLimit.mockResolvedValue(null) + mocks.list.mockResolvedValue({ + available: true, + installUrl: 'https://github.com/apps/sim-search/installations/new', + needsUserConnection: false, + installations: [installation], + }) + mocks.connect.mockResolvedValue({ credential: { id: 'cred-1', displayName: 'GitHub · acme' } }) +}) + +describe('GitHub installation route boundary', () => { + it.each(['GET', 'POST'] as const)( + 'authenticates %s before parsing or calling the use case', + async (method) => { + authMockFns.mockGetSession.mockResolvedValue(null) + const request = new NextRequest(URL, method === 'POST' ? { method, body: '{' } : undefined) + const json = vi.spyOn(request, 'json') + const response = await (method === 'GET' ? GET(request) : POST(request)) + expect(response.status).toBe(401) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(json).not.toHaveBeenCalled() + expect(mocks.rateLimit).not.toHaveBeenCalled() + expect(mocks.list).not.toHaveBeenCalled() + expect(mocks.connect).not.toHaveBeenCalled() + } + ) + + it('applies admission before parsing the POST body', async () => { + mocks.rateLimit.mockResolvedValue( + NextResponse.json({ error: 'Rate limit exceeded' }, { status: 429 }) + ) + const request = new NextRequest(URL, { method: 'POST', body: '{' }) + const json = vi.spyOn(request, 'json') + expect((await POST(request)).status).toBe(429) + expect(json).not.toHaveBeenCalled() + expect(mocks.connect).not.toHaveBeenCalled() + expect(mocks.rateLimit).toHaveBeenCalledWith( + 'github-search-installations', + 'admin-1', + undefined + ) + }) + + it.each(['0', '-1', '1.5', '123/path', ''])( + 'rejects invalid installation ID %s before the use case', + async (installationId) => { + const response = await POST( + new NextRequest(URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ organizationId: 'org-1', installationId }), + }) + ) + expect(response.status).toBe(400) + expect(mocks.connect).not.toHaveBeenCalled() + } + ) + + it('requires organization scope for GET', async () => { + expect((await GET(new NextRequest(URL))).status).toBe(400) + expect(mocks.list).not.toHaveBeenCalled() + }) + + it('forwards GET identity and cancellation and projects a private installation list', async () => { + const controller = new AbortController() + const request = new NextRequest(`${URL}?organizationId=org-1`, { signal: controller.signal }) + mocks.list.mockResolvedValue({ + available: true, + installUrl: 'https://github.com/apps/sim-search/installations/new', + needsUserConnection: false, + installations: [{ ...installation, accessToken: 'private' }], + privateKey: 'private', + }) + const response = await GET(request) + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(mocks.list).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' }, + input: { organizationId: 'org-1', signal: request.signal }, + }) + ) + expect(await response.json()).toEqual({ + success: true, + available: true, + installUrl: 'https://github.com/apps/sim-search/installations/new', + needsUserConnection: false, + installations: [installation], + }) + }) + + it('forwards POST cancellation and only returns the safe credential projection', async () => { + const request = new NextRequest(URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ organizationId: 'org-1', installationId: '123' }), + }) + mocks.connect.mockResolvedValue({ + credential: { + id: 'cred-1', + displayName: 'GitHub · acme', + encryptedServiceAccountKey: 'private', + }, + created: true, + }) + const response = await POST(request) + expect(response.status).toBe(200) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + expect(mocks.connect).toHaveBeenCalledWith( + expect.objectContaining({ + input: { organizationId: 'org-1', installationId: '123', signal: request.signal }, + }) + ) + expect(await response.json()).toEqual({ + success: true, + credential: { id: 'cred-1', displayName: 'GitHub · acme' }, + }) + }) + + it.each([ + [ + new OrchestrationError('forbidden', 'Organization administrator access is required'), + 403, + 'Organization administrator access is required', + ], + [ + new GitHubInstallationError('Installation permission denied', 403), + 403, + 'Installation permission denied', + ], + [ + new GitHubInstallationError('GitHub is temporarily unavailable', 503), + 502, + 'GitHub is temporarily unavailable', + ], + [ + new ManagedOAuthCredentialError( + 'MANAGED_CREDENTIAL_NEEDS_REAUTH', + 'private refresh details', + 401 + ), + 401, + 'Reconnect your GitHub account to continue installation setup', + ], + [new Error('private database details'), 500, 'Internal server error'], + ] as const)( + 'projects %s without successful installation data', + async (error, status, message) => { + mocks.list.mockRejectedValue(error) + const response = await GET(new NextRequest(`${URL}?organizationId=org-1`)) + expect(response.status).toBe(status) + expect(response.headers.get('Cache-Control')).toBe('private, no-store') + const body = await response.json() + expect(body.error).toBe(message) + expect(body).not.toHaveProperty('installations') + expect(body).not.toHaveProperty('credential') + } + ) +}) diff --git a/apps/sim/app/api/knowledge/github/installations/route.ts b/apps/sim/app/api/knowledge/github/installations/route.ts new file mode 100644 index 00000000000..1ec3700a125 --- /dev/null +++ b/apps/sim/app/api/knowledge/github/installations/route.ts @@ -0,0 +1,53 @@ +import { + connectGitHubSearchInstallationContract, + listGitHubSearchInstallationsContract, +} from '@/lib/api/contracts/knowledge/github-installations' +import { + defineInternalJsonRoute, + extendInternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { ManagedOAuthCredentialError } from '@/lib/credentials/managed-oauth' +import { + connectGitHubSearchInstallation, + listGitHubSearchInstallations, +} from '@/lib/knowledge/application/github-installations' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { GitHubInstallationError } from '@/lib/oauth/github-installation' + +const errorPolicy = extendInternalErrorPolicy(internalOrchestrationErrorPolicy, (error) => { + if (error instanceof GitHubInstallationError) + return internalErrorResponse(error.status === 403 ? 403 : 502, { error: error.message }) + if (error instanceof ManagedOAuthCredentialError) + return internalErrorResponse(error.statusCode, { + error: 'Reconnect your GitHub account to continue installation setup', + }) + return null +}) + +export const GET = defineInternalJsonRoute({ + contract: listGitHubSearchInstallationsContract, + auth: internalSessionAuth, + operation: knowledgeOperations.listGitHubInstallations, + rateLimit: internalRateLimits.user({ bucketName: 'github-search-installations' }), + errorPolicy, + mapInput: ({ query }, { request }) => ({ ...query, signal: request.signal }), + useCase: listGitHubSearchInstallations, + present: (result) => ({ success: true, ...result }), + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) + +export const POST = defineInternalJsonRoute({ + contract: connectGitHubSearchInstallationContract, + auth: internalSessionAuth, + operation: knowledgeOperations.connectGitHubInstallation, + rateLimit: internalRateLimits.user({ bucketName: 'github-search-installations' }), + errorPolicy, + mapInput: ({ body }, { request }) => ({ ...body, signal: request.signal }), + useCase: connectGitHubSearchInstallation, + present: ({ credential }) => ({ success: true, credential }), + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) diff --git a/apps/sim/app/api/knowledge/sim-search/connect/route.ts b/apps/sim/app/api/knowledge/sim-search/connect/route.ts index 9f2319ee6d3..521b6b570ef 100644 --- a/apps/sim/app/api/knowledge/sim-search/connect/route.ts +++ b/apps/sim/app/api/knowledge/sim-search/connect/route.ts @@ -13,7 +13,7 @@ export const POST = defineInternalJsonRoute({ auth: internalSessionAuth, operation: knowledgeOperations.simSearchConnect, rateLimit: internalRateLimits.none({ reason: 'One click per source; mints a single-use link' }), - errorPolicy: internalKnowledgeErrorPolicies.connectors, + errorPolicy: internalKnowledgeErrorPolicies.connectAccount, mapInput: ({ body }) => body, useCase: connectSimSearchConnector, present: (result) => ({ success: true as const, data: result }), diff --git a/apps/sim/app/api/knowledge/sim-search/personal-integrations/route.ts b/apps/sim/app/api/knowledge/sim-search/personal-integrations/route.ts new file mode 100644 index 00000000000..bdc9d9b6a25 --- /dev/null +++ b/apps/sim/app/api/knowledge/sim-search/personal-integrations/route.ts @@ -0,0 +1,37 @@ +import { + connectPersonalSearchIntegrationContract, + listPersonalSearchIntegrationsContract, +} from '@/lib/api/contracts/knowledge/personal-integrations' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalKnowledgeErrorPolicies } from '@/lib/knowledge/api/route-policies' +import { connectPersonalSearchIntegration } from '@/lib/knowledge/application/connect-personal-search-integration' +import { knowledgeOperations } from '@/lib/knowledge/application/operations' +import { listPersonalSearchIntegrations } from '@/lib/knowledge/application/personal-search-integrations' + +export const GET = defineInternalJsonRoute({ + contract: listPersonalSearchIntegrationsContract, + auth: internalSessionAuth, + operation: knowledgeOperations.listPersonalSearchIntegrations, + rateLimit: internalRateLimits.user({ bucketName: 'knowledge.search.personal-integrations.list' }), + errorPolicy: internalKnowledgeErrorPolicies.connectors, + mapInput: ({ query }) => query, + useCase: listPersonalSearchIntegrations, + present: (data) => ({ success: true as const, data }), +}) + +export const POST = defineInternalJsonRoute({ + contract: connectPersonalSearchIntegrationContract, + auth: internalSessionAuth, + operation: knowledgeOperations.connectPersonalSearchIntegration, + rateLimit: internalRateLimits.user({ + bucketName: 'knowledge.search.personal-integrations.connect', + }), + errorPolicy: internalKnowledgeErrorPolicies.connectAccount, + mapInput: ({ body }) => body, + useCase: connectPersonalSearchIntegration, + present: (data) => ({ success: true as const, data }), +}) diff --git a/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts b/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts index 43251e1748c..44ee57d8279 100644 --- a/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts +++ b/apps/sim/app/api/knowledge/sim-search/sources/route.test.ts @@ -1,6 +1,10 @@ /** @vitest-environment node */ import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { + OrganizationSearchProviderSummary, + SearchSourceSummary, +} from '@/lib/api/contracts/knowledge/connectors' const mocks = vi.hoisted(() => ({ execute: vi.fn(), @@ -58,9 +62,10 @@ const source = { viewerDocumentCount: 0, viewerFailedDocumentCount: 0, viewerEmailVerified: true, + viewerAccounts: [], connectionRequired: false, viewerMembership: null, -} +} satisfies SearchSourceSummary beforeEach(() => { vi.clearAllMocks() @@ -266,8 +271,9 @@ describe('organization administration overview boundary', () => { sourceCount: 1, approved: true, status: 'waiting_for_connections', + issue: null, isSyncing: false, - } + } satisfies OrganizationSearchProviderSummary mocks.adminOverview.mockResolvedValue({ providers: [{ ...provider, privateAccount: 'private' }], documentNames: ['private'], diff --git a/apps/sim/app/api/mothership/execute/route.test.ts b/apps/sim/app/api/mothership/execute/route.test.ts index 43893921a35..b243dd31b1d 100644 --- a/apps/sim/app/api/mothership/execute/route.test.ts +++ b/apps/sim/app/api/mothership/execute/route.test.ts @@ -34,6 +34,20 @@ const { mockRunHeadlessCopilotLifecycle: vi.fn(), })) +vi.mock('@/lib/auth/internal', () => ({ + verifyInternalDelegationToken: vi.fn().mockResolvedValue({ + workflowId: 'workflow-1', + executionId: 'execution-1', + mcpBlockId: 'block-1', + subjectUserId: 'user-1', + }), +})) +vi.mock('@/lib/internal/principals/executor', () => ({ + createExecutorPrincipalFromExecutionContext: vi + .fn() + .mockResolvedValue({ workspaceId: 'workspace-1' }), +})) + vi.mock('@/lib/core/security/encryption', () => ({ decryptSecret: mockDecryptSecret, })) @@ -112,6 +126,8 @@ describe('buildExecuteResponsePayload', () => { describe('mothership private trace provenance transport', () => { const requestBody = { + workflowId: 'workflow-1', + executionId: 'execution-1', messages: [{ role: 'user', content: 'hello' }], workspaceId: 'workspace-1', userId: 'user-1', @@ -177,7 +193,11 @@ describe('mothership private trace provenance transport', () => { createMockRequest( 'POST', requestBody, - { Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing' }, + { + 'X-Sim-Mcp-Delegation': 'signed-block', + Authorization: 'Bearer internal', + 'x-sim-billing-attribution': 'billing', + }, 'http://localhost:3000/api/mothership/execute' ) ) @@ -204,7 +224,11 @@ describe('mothership private trace provenance transport', () => { createMockRequest( 'POST', requestBody, - { Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing' }, + { + 'X-Sim-Mcp-Delegation': 'signed-block', + Authorization: 'Bearer internal', + 'x-sim-billing-attribution': 'billing', + }, 'http://localhost:3000/api/mothership/execute' ) ) @@ -226,7 +250,11 @@ describe('mothership private trace provenance transport', () => { messages: [{ role: 'user', content: 'secret-value __var_FOREIGN' }], contexts: [{ kind: 'docs', label: 'Docs' }], }, - { Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing' }, + { + 'X-Sim-Mcp-Delegation': 'signed-block', + Authorization: 'Bearer internal', + 'x-sim-billing-attribution': 'billing', + }, 'http://localhost:3000/api/mothership/execute' ) ) @@ -274,13 +302,22 @@ describe('mothership private trace provenance transport', () => { }, ], }, - { Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing' }, + { + 'X-Sim-Mcp-Delegation': 'signed-block', + Authorization: 'Bearer internal', + 'x-sim-billing-attribution': 'billing', + }, 'http://localhost:3000/api/mothership/execute' ) ) expect(response.status).toBe(200) - expect(mockBuildTaggedMcpToolSchemas).toHaveBeenCalledWith('user-1', 'workspace-1', ['123']) + expect(mockBuildTaggedMcpToolSchemas).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + ['123'], + expect.objectContaining({ mcpBlockId: 'block-1' }) + ) expect(mockProcessContextsServer).toHaveBeenCalledWith( [ { @@ -324,7 +361,11 @@ describe('mothership private trace provenance transport', () => { secretScope: 'selected', mountedSecrets: ['API_KEY'], }, - { Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing' }, + { + 'X-Sim-Mcp-Delegation': 'signed-block', + Authorization: 'Bearer internal', + 'x-sim-billing-attribution': 'billing', + }, 'http://localhost:3000/api/mothership/execute' ) ) @@ -355,6 +396,7 @@ describe('mothership private trace provenance transport', () => { 'POST', requestBody, { + 'X-Sim-Mcp-Delegation': 'signed-block', Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing', 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1', @@ -397,6 +439,7 @@ describe('mothership private trace provenance transport', () => { 'POST', requestBody, { + 'X-Sim-Mcp-Delegation': 'signed-block', Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing', 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1', @@ -432,6 +475,7 @@ describe('mothership private trace provenance transport', () => { 'POST', requestBody, { + 'X-Sim-Mcp-Delegation': 'signed-block', Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing', 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1', @@ -485,6 +529,7 @@ describe('mothership private trace provenance transport', () => { contexts: [{ kind: 'mcp', label: 'Docs', serverId: 'server-1' }], }, { + 'X-Sim-Mcp-Delegation': 'signed-block', Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing', 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1', @@ -524,6 +569,7 @@ describe('mothership private trace provenance transport', () => { 'POST', requestBody, { + 'X-Sim-Mcp-Delegation': 'signed-block', Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing', 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1', @@ -554,6 +600,7 @@ describe('mothership private trace provenance transport', () => { 'POST', requestBody, { + 'X-Sim-Mcp-Delegation': 'signed-block', Authorization: 'Bearer internal', 'x-sim-billing-attribution': 'billing', 'x-sim-request-private-tool-metadata': 'resolved-secret-provenance-v1', diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 2e1cd9fd38c..9a2331dacfc 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -5,6 +5,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { mothershipExecuteContract } from '@/lib/api/contracts/mothership-chats' import { parseRequest } from '@/lib/api/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { verifyInternalDelegationToken } from '@/lib/auth/internal' import { requireBillingAttributionHeader } from '@/lib/billing/core/billing-attribution' import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' import { processContextsServer } from '@/lib/copilot/chat/process-contents' @@ -33,6 +34,8 @@ import { RESOLVED_SECRET_PROVENANCE_METADATA_V1, requestsPrivateToolMetadata, } from '@/lib/execution/private-tool-metadata' +import { createExecutorPrincipalFromExecutionContext } from '@/lib/internal/principals/executor' +import { MCP_SERVER_DELEGATION_AUDIENCE } from '@/lib/mcp/application/authorization' import { assertActiveWorkspaceAccess, isWorkspaceAccessDeniedError, @@ -160,6 +163,29 @@ export const POST = withRouteHandler(async (req: NextRequest) => { secretScope, mountedSecrets, } = validation.data.body + const mcpDelegationToken = validation.data.headers['x-sim-mcp-delegation'] + if (!mcpDelegationToken) throw new Error('Mothership requires signed workflow provenance') + const delegation = await verifyInternalDelegationToken(mcpDelegationToken) + if (!delegation.mcpBlockId) throw new Error('Mothership requires signed block provenance') + if ( + workflowId !== (delegation.currentWorkflow?.workflowId ?? delegation.workflowId) || + executionId !== delegation.executionId + ) + throw new Error('Mothership workflow scope does not match signed provenance') + const mcpContext = { + userId: auth.userId, + workflowId: workflowId ?? delegation.workflowId, + workspaceId, + executionId, + executorDelegationOrigin: delegation, + mcpBlockId: delegation.mcpBlockId, + } + const mcpPrincipal = await createExecutorPrincipalFromExecutionContext({ + context: mcpContext, + audience: MCP_SERVER_DELEGATION_AUDIENCE, + }) + if (mcpPrincipal.workspaceId !== workspaceId) + throw new Error('MCP workspace scope does not match') const secretMountPolicy = normalizeSecretMountPolicy({ secretScope, mountedSecrets }) /** @@ -221,8 +247,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { const nonMcpAgentMentions = agentMentions?.filter((context) => context.kind !== 'mcp') const userPermission = workspaceAccess.permission const mothershipToolsPromise = Promise.allSettled([ - buildSelectedMcpToolSchemas(userId, workspaceId, mcpTools ?? []), - buildTaggedMcpToolSchemas(userId, workspaceId, taggedMcpServerIds), + buildSelectedMcpToolSchemas(userId, workspaceId, mcpTools ?? [], mcpContext), + buildTaggedMcpToolSchemas(userId, workspaceId, taggedMcpServerIds, mcpContext), ]).then((results) => { const groups = results.map((result) => { if (result.status === 'rejected') throw result.reason @@ -344,6 +370,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => { simRequestId: requestId, goRoute: '/api/mothership/execute', autoExecuteTools: true, + mcpBlockId: delegation.mcpBlockId, + executorDelegationOrigin: delegation, interactive: false, abortSignal: lifecycleAbortController.signal, billingAttribution, diff --git a/apps/sim/app/api/organization-credentials/oauth/route.test.ts b/apps/sim/app/api/organization-credentials/oauth/route.test.ts index a4f0499298a..f666a71e7fa 100644 --- a/apps/sim/app/api/organization-credentials/oauth/route.test.ts +++ b/apps/sim/app/api/organization-credentials/oauth/route.test.ts @@ -15,7 +15,7 @@ vi.mock('@/lib/credentials/application/organization-credentials', () => { } as const return { organizationCredentialOperations: { list: operation }, - listOrganizationCredentials: { operation, execute: mocks.execute }, + listOrganizationOAuthCredentials: { operation, execute: mocks.execute }, } }) @@ -45,23 +45,26 @@ describe('GET /api/organization-credentials/oauth', () => { credentials: [ { id: 'full-credential', - displayName: 'Full access', - providerId: 'google-drive', + type: 'oauth', + name: 'Full access', + provider: 'google-drive', scopes: [DRIVE_SCOPE, METADATA_SCOPE], accountId: 'private-account-full', encryptedValue: 'private-secret', }, { id: 'limited-credential', - displayName: 'Limited access', - providerId: 'google-drive', + type: 'oauth', + name: 'Limited access', + provider: 'google-drive', scopes: [METADATA_SCOPE], accountId: 'private-account-limited', }, { id: 'unknown-credential', - displayName: 'Unknown access', - providerId: 'google-drive', + type: 'oauth', + name: 'Unknown access', + provider: 'google-drive', scopes: [], }, ], @@ -110,6 +113,60 @@ describe('GET /api/organization-credentials/oauth', () => { ) }) + it('forwards browsing intent with the acting session and projects a managed choice safely', async () => { + mocks.execute.mockResolvedValue({ + credentials: [ + { + id: 'managed-1', + name: 'My Jira', + provider: 'jira', + type: 'managed_oauth', + scopes: ['read:jira-work'], + encryptedAccessToken: 'never-public', + }, + ], + }) + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/organization-credentials/oauth?organizationId=org-1&providerId=jira&purpose=browsing' + ) + ) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + credentials: [ + { + id: 'managed-1', + name: 'My Jira', + provider: 'jira', + type: 'managed_oauth', + scopes: ['read:jira-work'], + }, + ], + }) + expect(mocks.execute).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'admin-1', sessionId: 'session-1' }, + input: { organizationId: 'org-1', providerId: 'jira', type: 'oauth', purpose: 'browsing' }, + }) + ) + }) + + it('rejects an unsupported listing purpose before the use case', async () => { + const response = await GET( + createMockRequest( + 'GET', + undefined, + {}, + 'http://localhost:3000/api/organization-credentials/oauth?organizationId=org-1&purpose=all-members' + ) + ) + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + it('still returns an empty authorized list without fabricating a credential', async () => { mocks.execute.mockResolvedValue({ credentials: [] }) diff --git a/apps/sim/app/api/organization-credentials/oauth/route.ts b/apps/sim/app/api/organization-credentials/oauth/route.ts index e6e784b9a45..f354d397279 100644 --- a/apps/sim/app/api/organization-credentials/oauth/route.ts +++ b/apps/sim/app/api/organization-credentials/oauth/route.ts @@ -6,10 +6,9 @@ import { } from '@/lib/api/server/routes' import { internalCredentialErrorPolicy } from '@/lib/credentials/api/route-policies' import { - listOrganizationCredentials, + listOrganizationOAuthCredentials, organizationCredentialOperations, } from '@/lib/credentials/application/organization-credentials' -import type { OAuthProvider } from '@/lib/oauth/types' export const GET = defineInternalJsonRoute({ contract: listOrganizationOAuthCredentialsContract, @@ -18,14 +17,5 @@ export const GET = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Preserve OAuth credential listing behavior' }), errorPolicy: internalCredentialErrorPolicy, mapInput: ({ query }) => ({ ...query, type: 'oauth' as const }), - useCase: listOrganizationCredentials, - present: ({ credentials }) => ({ - credentials: credentials.map((row) => ({ - id: row.id, - name: row.displayName, - provider: row.providerId as OAuthProvider, - type: 'oauth' as const, - scopes: row.scopes, - })), - }), + useCase: listOrganizationOAuthCredentials, }) diff --git a/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts index 146a97011f1..087273cd537 100644 --- a/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts +++ b/apps/sim/app/api/organizations/[id]/connected-accounts/[groupId]/slack-managed-users/route.test.ts @@ -20,8 +20,6 @@ import { POST } from '@/app/api/organizations/[id]/connected-accounts/[groupId]/ const body = { appId: 'A123', teamId: 'T123', - clientId: 'fixture-client-id', - clientSecret: 'fixture-client-secret', } const context = { params: Promise.resolve({ id: 'org-a', groupId: 'group-a' }) } function request(input: unknown = body) { @@ -43,7 +41,7 @@ beforeEach(() => { }) describe('organization Slack setup route', () => { - it('authenticates before parsing setup secrets', async () => { + it('authenticates before parsing setup input', async () => { mocks.session.mockResolvedValue(null) const response = await POST(request({}), context) expect(response.status).toBe(401) @@ -67,6 +65,12 @@ describe('organization Slack setup route', () => { expect(mocks.execute).not.toHaveBeenCalled() }) + it.each(['clientId', 'clientSecret'])('rejects a client-supplied OAuth %s', async (field) => { + const response = await POST(request({ ...body, [field]: 'client-supplied-value' }), context) + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + it('preserves refusal when current organization authority is insufficient', async () => { mocks.execute.mockRejectedValue( new OrchestrationError('forbidden', 'Organization admin required') diff --git a/apps/sim/app/api/v2/selectors/get/route.ts b/apps/sim/app/api/v2/selectors/get/route.ts new file mode 100644 index 00000000000..4910299fb84 --- /dev/null +++ b/apps/sim/app/api/v2/selectors/get/route.ts @@ -0,0 +1,22 @@ +import { v2GetSelectorContract } from '@/lib/api/contracts/v2/selectors' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2SelectorErrorPolicy } from '@/lib/selectors/api/error-policy' +import { getSelectorOption } from '@/lib/selectors/application/get-selector-option' +import { selectorOperations } from '@/lib/selectors/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2GetSelectorContract, + auth: v2ApiKeyAuth, + operation: selectorOperations.execute, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2SelectorErrorPolicy, + parseOptions: { maxBodyBytes: 256 * 1024 }, + mapInput: ({ body }) => ({ + selectorKey: body.selectorKey, + context: body.context, + scope: { kind: 'workspace' as const, workspaceId: body.workspaceId }, + id: body.id, + }), + useCase: getSelectorOption, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/selectors/list/route.ts b/apps/sim/app/api/v2/selectors/list/route.ts new file mode 100644 index 00000000000..8cd0f0746ef --- /dev/null +++ b/apps/sim/app/api/v2/selectors/list/route.ts @@ -0,0 +1,17 @@ +import { v2ListSelectorContract } from '@/lib/api/contracts/v2/selectors' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2SelectorErrorPolicy } from '@/lib/selectors/api/error-policy' +import { selectorOperations } from '@/lib/selectors/application/operations' +import { listSelector } from '@/lib/selectors/application/paged-selector' + +export const POST = defineV2JsonRoute({ + contract: v2ListSelectorContract, + auth: v2ApiKeyAuth, + operation: selectorOperations.execute, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2SelectorErrorPolicy, + parseOptions: { maxBodyBytes: 256 * 1024 }, + mapInput: ({ body }) => body, + useCase: listSelector, + present: ({ items, nextCursor, truncated }) => ({ data: items, nextCursor, truncated }), +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/export/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/export/route.ts index 93b622d85bf..fda2ae9f317 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/export/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/export/route.ts @@ -19,7 +19,10 @@ export const GET = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, headSafe: false, errorPolicy: v2WorkflowErrorPolicies.concealWorkflowAuthorization, - mapInput: ({ params }) => ({ workflowId: params.workflowId }), + mapInput: ({ params, query }) => ({ + workflowId: params.workflowId, + includeReferences: query.includeReferences === true, + }), useCase: exportWorkflow, present: ({ payload, folderPath }) => ({ data: { diff --git a/apps/sim/app/api/v2/workflows/import/preview/route.ts b/apps/sim/app/api/v2/workflows/import/preview/route.ts new file mode 100644 index 00000000000..67eb12f7c03 --- /dev/null +++ b/apps/sim/app/api/v2/workflows/import/preview/route.ts @@ -0,0 +1,18 @@ +import { v2PreviewWorkflowImportContract } from '@/lib/api/contracts/v2/workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' +import { previewWorkflowImport } from '@/lib/workflows/application/mapped-import' +import { workflowOperations } from '@/lib/workflows/application/operations' +import { MAX_IMPORT_BODY_BYTES } from '@/lib/workflows/operations/import-workflow' + +export const POST = defineV2JsonRoute({ + contract: v2PreviewWorkflowImportContract, + auth: v2ApiKeyAuth, + operation: workflowOperations.importPreview, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2WorkflowErrorPolicies.import, + parseOptions: { maxBodyBytes: MAX_IMPORT_BODY_BYTES }, + mapInput: ({ body }) => body, + useCase: previewWorkflowImport, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workflows/import/route.ts b/apps/sim/app/api/v2/workflows/import/route.ts index c4a9a7ea310..1291bfaeae0 100644 --- a/apps/sim/app/api/v2/workflows/import/route.ts +++ b/apps/sim/app/api/v2/workflows/import/route.ts @@ -15,16 +15,11 @@ export const POST = defineV2JsonRoute({ rateLimit: v2RateLimits.publicApi, errorPolicy: v2WorkflowErrorPolicies.import, parseOptions: { maxBodyBytes: MAX_IMPORT_BODY_BYTES }, - mapInput: ({ body }) => ({ - workspaceId: body.workspaceId, - folderPath: body.folderPath, - name: body.name, - description: body.description, - workflow: body.workflow, - }), + mapInput: ({ body }) => body, useCase: importWorkflow, - present: ({ workflow, folderPath }) => ({ + present: ({ workflow, folderPath, operation }) => ({ data: { + ...operation, id: workflow.id, name: workflow.name, description: workflow.description, diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/availability/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/availability/route.ts new file mode 100644 index 00000000000..29c0c719c48 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/availability/route.ts @@ -0,0 +1,16 @@ +import { v2GetWorkspaceForkAvailabilityContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { getWorkspaceForkAvailability } from '@/ee/workspace-forking/application/discovery' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const GET = defineV2JsonRoute({ + contract: v2GetWorkspaceForkAvailabilityContract, + auth: v2ApiKeyAuth, + operation: forkOperations.discover, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + mapInput: ({ params }) => params, + useCase: getWorkspaceForkAvailability, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/children/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/children/route.ts new file mode 100644 index 00000000000..36214dec960 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/children/route.ts @@ -0,0 +1,16 @@ +import { v2ListWorkspaceForkChildrenContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { listWorkspaceForkChildren } from '@/ee/workspace-forking/application/discovery' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const GET = defineV2JsonRoute({ + contract: v2ListWorkspaceForkChildrenContract, + auth: v2ApiKeyAuth, + operation: forkOperations.discover, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + mapInput: ({ params, query }) => ({ ...params, ...query }), + useCase: listWorkspaceForkChildren, + present: ({ items, nextCursor }) => ({ data: items, nextCursor }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/exclusions/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/exclusions/route.ts new file mode 100644 index 00000000000..3cb1b423b0a --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/exclusions/route.ts @@ -0,0 +1,17 @@ +import { v2UpdateWorkspaceForkExclusionsContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { updateWorkspaceForkExclusions } from '@/ee/workspace-forking/application/recovery-and-mappings' + +export const PUT = defineV2JsonRoute({ + contract: v2UpdateWorkspaceForkExclusionsContract, + auth: v2ApiKeyAuth, + operation: forkOperations.exclusions, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: updateWorkspaceForkExclusions, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/lineage/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/lineage/route.ts new file mode 100644 index 00000000000..12380b787a9 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/lineage/route.ts @@ -0,0 +1,16 @@ +import { v2GetWorkspaceForkLineageContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { getWorkspaceForkLineage } from '@/ee/workspace-forking/application/discovery' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const GET = defineV2JsonRoute({ + contract: v2GetWorkspaceForkLineageContract, + auth: v2ApiKeyAuth, + operation: forkOperations.discover, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + mapInput: ({ params }) => params, + useCase: getWorkspaceForkLineage, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/mappings/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/mappings/route.ts new file mode 100644 index 00000000000..b989c578929 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/mappings/route.ts @@ -0,0 +1,32 @@ +import { + v2GetWorkspaceForkMappingsContract, + v2UpdateWorkspaceForkMappingsContract, +} from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { getWorkspaceForkMappings } from '@/ee/workspace-forking/application/discovery' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { updateWorkspaceForkMappings } from '@/ee/workspace-forking/application/recovery-and-mappings' + +export const GET = defineV2JsonRoute({ + contract: v2GetWorkspaceForkMappingsContract, + auth: v2ApiKeyAuth, + operation: forkOperations.mappingsRead, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + mapInput: ({ params, query }) => ({ ...params, ...query }), + useCase: getWorkspaceForkMappings, + present: ({ items, nextCursor }) => ({ data: items, nextCursor }), +}) + +export const PUT = defineV2JsonRoute({ + contract: v2UpdateWorkspaceForkMappingsContract, + auth: v2ApiKeyAuth, + operation: forkOperations.mappingsUpdate, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: updateWorkspaceForkMappings, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/preview/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/preview/route.ts new file mode 100644 index 00000000000..e6f52739352 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/preview/route.ts @@ -0,0 +1,17 @@ +import { v2PreviewWorkspaceForkContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { previewWorkspaceFork } from '@/ee/workspace-forking/application/create-and-sync' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2PreviewWorkspaceForkContract, + auth: v2ApiKeyAuth, + operation: forkOperations.preview, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: previewWorkspaceFork, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/pull/preview/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/pull/preview/route.ts new file mode 100644 index 00000000000..a7b10d802ee --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/pull/preview/route.ts @@ -0,0 +1,25 @@ +import { v2PreviewWorkspacePullContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { previewWorkspaceSync } from '@/ee/workspace-forking/application/create-and-sync' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2PreviewWorkspacePullContract, + auth: v2ApiKeyAuth, + operation: forkOperations.syncPreview, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => { + const { dependentValues, ...choices } = body + return { + ...params, + ...choices, + sourceDependentValues: dependentValues, + direction: 'pull' as const, + } + }, + useCase: previewWorkspaceSync, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/pull/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/pull/route.ts new file mode 100644 index 00000000000..a18cf6f5752 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/pull/route.ts @@ -0,0 +1,25 @@ +import { v2PullWorkspaceContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { syncWorkspace } from '@/ee/workspace-forking/application/create-and-sync' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2PullWorkspaceContract, + auth: v2ApiKeyAuth, + operation: forkOperations.sync, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => { + const { dependentValues, confirm: _confirm, ...choices } = body + return { + ...params, + ...choices, + sourceDependentValues: dependentValues, + direction: 'pull' as const, + } + }, + useCase: syncWorkspace, + present: (result) => ({ data: result.operation! }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/push/preview/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/push/preview/route.ts new file mode 100644 index 00000000000..5987da20835 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/push/preview/route.ts @@ -0,0 +1,25 @@ +import { v2PreviewWorkspacePushContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { previewWorkspaceSync } from '@/ee/workspace-forking/application/create-and-sync' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2PreviewWorkspacePushContract, + auth: v2ApiKeyAuth, + operation: forkOperations.syncPreview, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => { + const { dependentValues, ...choices } = body + return { + ...params, + ...choices, + sourceDependentValues: dependentValues, + direction: 'push' as const, + } + }, + useCase: previewWorkspaceSync, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/push/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/push/route.ts new file mode 100644 index 00000000000..a86f9a1dd8c --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/push/route.ts @@ -0,0 +1,25 @@ +import { v2PushWorkspaceContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { syncWorkspace } from '@/ee/workspace-forking/application/create-and-sync' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2PushWorkspaceContract, + auth: v2ApiKeyAuth, + operation: forkOperations.sync, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => { + const { dependentValues, confirm: _confirm, ...choices } = body + return { + ...params, + ...choices, + sourceDependentValues: dependentValues, + direction: 'push' as const, + } + }, + useCase: syncWorkspace, + present: (result) => ({ data: result.operation! }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/resources/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/resources/route.ts new file mode 100644 index 00000000000..0d065b9797b --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/resources/route.ts @@ -0,0 +1,16 @@ +import { v2ListWorkspaceForkResourcesContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { listWorkspaceForkResources } from '@/ee/workspace-forking/application/discovery' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const GET = defineV2JsonRoute({ + contract: v2ListWorkspaceForkResourcesContract, + auth: v2ApiKeyAuth, + operation: forkOperations.discover, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + mapInput: ({ params, query }) => ({ ...params, ...query }), + useCase: listWorkspaceForkResources, + present: ({ items, nextCursor }) => ({ data: items, nextCursor }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/rollback/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/rollback/route.ts new file mode 100644 index 00000000000..c3951477ff1 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/rollback/route.ts @@ -0,0 +1,17 @@ +import { v2RollbackWorkspaceForkContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { rollbackWorkspaceFork } from '@/ee/workspace-forking/application/recovery-and-mappings' + +export const POST = defineV2JsonRoute({ + contract: v2RollbackWorkspaceForkContract, + auth: v2ApiKeyAuth, + operation: forkOperations.rollback, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: rollbackWorkspaceFork, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/route.ts new file mode 100644 index 00000000000..07672b5939d --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/route.ts @@ -0,0 +1,17 @@ +import { v2ForkWorkspaceContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkWorkspace } from '@/ee/workspace-forking/application/create-and-sync' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2ForkWorkspaceContract, + auth: v2ApiKeyAuth, + operation: forkOperations.create, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: forkWorkspace, + present: (result) => ({ data: result.operation! }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/unlink/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/unlink/route.ts new file mode 100644 index 00000000000..2ffac025941 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/fork/unlink/route.ts @@ -0,0 +1,17 @@ +import { v2UnlinkWorkspaceForkContract } from '@/lib/api/contracts/v2/workspace-fork' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2ForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { unlinkWorkspaceFork } from '@/ee/workspace-forking/application/recovery-and-mappings' + +export const POST = defineV2JsonRoute({ + contract: v2UnlinkWorkspaceForkContract, + auth: v2ApiKeyAuth, + operation: forkOperations.unlink, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2ForkErrorPolicy, + parseOptions: { maxBodyBytes: 10 * 1024 * 1024 }, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: unlinkWorkspaceFork, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/operations/[operationId]/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/operations/[operationId]/route.ts new file mode 100644 index 00000000000..d9aa5cb8e06 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/operations/[operationId]/route.ts @@ -0,0 +1,16 @@ +import { v2GetWorkspaceOperationContract } from '@/lib/api/contracts/v2/workspace-operations' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes/resource-concealment' +import { getWorkspaceOperation } from '@/lib/workspaces/operations/application' +import { workspaceOperations } from '@/lib/workspaces/operations/operations' + +export const GET = defineV2JsonRoute({ + contract: v2GetWorkspaceOperationContract, + auth: v2ApiKeyAuth, + operation: workspaceOperations.read, + rateLimit: v2RateLimits.publicApi, + errorPolicy: createV2ResourceConcealmentPolicy({ notFoundMessage: 'Operation not found' }), + mapInput: ({ params }) => params, + useCase: getWorkspaceOperation, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/workspaces/[workspaceId]/operations/route.ts b/apps/sim/app/api/v2/workspaces/[workspaceId]/operations/route.ts new file mode 100644 index 00000000000..e78a8c78093 --- /dev/null +++ b/apps/sim/app/api/v2/workspaces/[workspaceId]/operations/route.ts @@ -0,0 +1,16 @@ +import { v2ListWorkspaceOperationsContract } from '@/lib/api/contracts/v2/workspace-operations' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { createV2ResourceConcealmentPolicy } from '@/lib/api/server/routes/resource-concealment' +import { listWorkspaceOperations } from '@/lib/workspaces/operations/application' +import { workspaceOperations } from '@/lib/workspaces/operations/operations' + +export const GET = defineV2JsonRoute({ + contract: v2ListWorkspaceOperationsContract, + auth: v2ApiKeyAuth, + operation: workspaceOperations.read, + rateLimit: v2RateLimits.publicApi, + errorPolicy: createV2ResourceConcealmentPolicy({ notFoundMessage: 'Operation not found' }), + mapInput: ({ params, query }) => ({ ...params, ...query }), + useCase: listWorkspaceOperations, + present: ({ operations, nextCursor }) => ({ data: operations, nextCursor }), +}) diff --git a/apps/sim/app/api/webhooks/outbox/process/route.ts b/apps/sim/app/api/webhooks/outbox/process/route.ts index d24f70fab6a..f79d06a4e28 100644 --- a/apps/sim/app/api/webhooks/outbox/process/route.ts +++ b/apps/sim/app/api/webhooks/outbox/process/route.ts @@ -23,6 +23,8 @@ import { workspaceFileLiveDocOutboxHandlers } from '@/lib/uploads/contexts/works import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox' import { invitationMigrationOutboxHandlers } from '@/lib/workspaces/admin-move' +import { workspaceOperationOutboxHandlers } from '@/lib/workspaces/operations/outbox' +import { forkContentOutboxHandlers } from '@/ee/workspace-forking/application/content-outbox' import { reapStaleBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store' const logger = createLogger('OutboxProcessorAPI') @@ -45,6 +47,8 @@ const handlers = { ...workspaceFileLiveDocOutboxHandlers, ...workspaceFileStorageCleanupOutboxHandlers, ...workflowDeploymentOutboxHandlers, + ...workspaceOperationOutboxHandlers, + ...forkContentOutboxHandlers, } as const export const GET = withRouteHandler(async (request: NextRequest) => { diff --git a/apps/sim/app/api/workspaces/[id]/fork/availability/route.ts b/apps/sim/app/api/workspaces/[id]/fork/availability/route.ts index f7ea1ec10bc..d37e9005765 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/availability/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/availability/route.ts @@ -1,36 +1,20 @@ -import { type NextRequest, NextResponse } from 'next/server' import { getForkAvailabilityContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' -import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { getWorkspaceForkAvailability } from '@/ee/workspace-forking/application/discovery' +import { forkOperations } from '@/ee/workspace-forking/application/operations' -/** - * Whether forking is available for this workspace based on deployment configuration - * and plan. Member-readable because it only reveals availability; the client uses it - * to show or hide Forks settings and context-menu entries. - */ -export const GET = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getForkAvailabilityContract, req, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - - const access = await checkWorkspaceAccess(id, session.user.id) - if (!access.exists || !access.workspace) { - return NextResponse.json({ error: 'Workspace not found' }, { status: 404 }) - } - - const available = await isForkingAvailableForWorkspace( - access.workspace.organizationId, - session.user.id - ) - return NextResponse.json({ available }) - } -) +export const GET = defineInternalJsonRoute({ + contract: getForkAvailabilityContract, + auth: internalSessionAuth, + operation: forkOperations.discover, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: getWorkspaceForkAvailability, + present: (result) => result, +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts index 4425c1a4675..59da9070885 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts @@ -1,288 +1,20 @@ -import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' -import { eq } from 'drizzle-orm' -import { type NextRequest, NextResponse } from 'next/server' import { getForkDiffContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { loadTargetDraftSubBlocks } from '@/ee/workspace-forking/lib/copy/copy-workflows' import { - listForkExcludedDeployedWorkflows, - loadSourceDeployedStates, - loadTargetWebhookPathsByBlock, -} from '@/ee/workspace-forking/lib/copy/deploy-bridge' -import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz' -import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store' -import { collectForkCustomBlockReconfigs } from '@/ee/workspace-forking/lib/mapping/custom-block-reconfigs' -import { - collectForkDependentReconfigs, - collectForkResourceUsages, -} from '@/ee/workspace-forking/lib/mapping/dependent-reconfigs' -import { - forkDependentValueKey, - loadForkDependentValues, -} from '@/ee/workspace-forking/lib/mapping/dependent-value-store' -import { listForkResourceCandidates } from '@/ee/workspace-forking/lib/mapping/resources' -import { - annotateForkClearedRefSourceLiveness, - collectForkClearedRefCandidates, -} from '@/ee/workspace-forking/lib/promote/cleared-refs' -import { computeForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan' -import { buildForkTriggerPlan } from '@/ee/workspace-forking/lib/promote/trigger-urls' -import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity' -import { readTargetDraftDependentValue } from '@/ee/workspace-forking/lib/remap/remap-references' - -export const GET = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getForkDiffContract, req, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - const { otherWorkspaceId, direction } = parsed.data.query - - const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id) - - const { deployedWorkflows, sourceStates } = await loadSourceDeployedStates( - auth.sourceWorkspaceId - ) - const plan = await computeForkPromotePlan({ - executor: db, - edge: auth.edge, - sourceWorkspaceId: auth.sourceWorkspaceId, - targetWorkspaceId: auth.targetWorkspaceId, - direction, - deployedSourceWorkflows: deployedWorkflows, - sourceStates, - }) - - // Resolve dependent-reconfig target block ids through the SAME persisted block map the - // sync will use, so a re-pick the modal keys by target block id lands on the block the - // promote actually writes (on push that's the parent's original id, not a derived one). - const sourceIsParent = auth.sourceWorkspaceId === auth.edge.parentWorkspaceId - const blockMap = await loadForkBlockMap(db, auth.edge.childWorkspaceId) - const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap) - - // Stored dependent values are the source of truth for what each selector is set to. Overlay - // them as each field's currentValue so the modal pre-fills what the user actually saved. - // Before the FIRST sync populates the store (fork-create seeds mappings but no dependent - // values), the fallback is the TARGET's own configured value (loaded from its draft) - never - // the source's, which would overwrite the target's selection. The stored read spans EVERY - // plan target: a create-mode (never-synced) workflow's deterministic target id is what the - // first sync will use, so values pre-configured for it in the mapping editor pre-fill here - // too. The draft read stays replace-scoped (creates have no target draft to fall back to). - const replaceTargetIds = plan.items - .filter((item) => item.mode === 'replace') - .map((item) => item.targetWorkflowId) - const allTargetIds = plan.items.map((item) => item.targetWorkflowId) - const [ - storedValues, - targetDraftByWorkflow, - sourceCandidates, - sourceWorkflowRows, - excludedSourceWorkflows, - ] = await Promise.all([ - loadForkDependentValues(db, auth.edge.childWorkspaceId, allTargetIds), - loadTargetDraftSubBlocks(db, replaceTargetIds), - // Source resource labels (per kind) + workflow names, for the cleared-ref list's display. - listForkResourceCandidates(db, auth.sourceWorkspaceId), - db - .select({ id: workflow.id, name: workflow.name }) - .from(workflow) - .where(eq(workflow.workspaceId, auth.sourceWorkspaceId)), - // Deployed-but-excluded source workflows, so the preview can show what a sync skips. - listForkExcludedDeployedWorkflows(db, auth.sourceWorkspaceId), - ]) - const storedByKey = new Map( - storedValues.map((entry) => [ - forkDependentValueKey(entry.targetWorkflowId, entry.targetBlockId, entry.subBlockKey), - entry.value, - ]) - ) - - // Source block subBlocks keyed by their resolved target identity, so the first-sync draft - // fallback can identity-check a nested tool against the SOURCE dependent tool it came from - - // an index alone may point at a different tool in the target draft, whose value isn't the - // dependent's. Read structurally (only each subblock's `value`), so the in-memory state's - // blocks pass without a cast. - const sourceBlocksByTarget = new Map>>() - for (const item of plan.items) { - if (item.mode !== 'replace') continue - const state = sourceStates.get(item.sourceWorkflowId) - if (!state) continue - const byBlock = new Map>() - for (const [sourceBlockId, block] of Object.entries(state.blocks)) { - byBlock.set(resolveBlockId(item.targetWorkflowId, sourceBlockId), block.subBlocks ?? {}) - } - sourceBlocksByTarget.set(item.targetWorkflowId, byBlock) - } - - // Replace-target fields pre-fill from the store, falling back to the TARGET's own draft - // value before the first sync populates the store (never the source's, which would - // overwrite the target's selection). Create-target fields (never-synced workflows) - // pre-fill from the store, falling back to the SOURCE value the collector emitted - - // that's exactly what the first sync copies verbatim, so the pre-fill is honest and - // configuring it ahead of the first sync is possible (the deterministic target ids - // already exist). - // Custom-block inputs join the same list: repointing a block makes every one of its - // inputs reconfigurable (see `collectForkCustomBlockReconfigs`), and they store, pre-fill, - // gate Sync, and apply through this identical channel. - const customBlockReconfigs = await collectForkCustomBlockReconfigs({ - items: plan.items, - sourceStates, - resolveTargetBlockId: resolveBlockId, - resolve: plan.resolver, - targetWorkspaceId: plan.targetWorkspaceId, - }) - - const dependentReconfigs = [ - ...customBlockReconfigs.map((field) => ({ - ...field, - currentValue: - storedByKey.get( - forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey) - ) ?? field.currentValue, - })), - ...collectForkDependentReconfigs(plan.items, sourceStates, resolveBlockId).map((field) => ({ - ...field, - currentValue: - storedByKey.get( - forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey) - ) ?? - readTargetDraftDependentValue( - targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId)?.subBlocks, - sourceBlocksByTarget.get(field.targetWorkflowId)?.get(field.targetBlockId), - field.subBlockKey - ), - })), - ...collectForkDependentReconfigs(plan.items, sourceStates, resolveBlockId, 'create').map( - (field) => ({ - ...field, - currentValue: - storedByKey.get( - forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey) - ) ?? field.currentValue, - }) - ), - ] - - // References this sync will blank in the target (per block/field), for the pre-sync cleared-ref - // list. Labels resolve from the source candidate lists + workflow names loaded above. - const sourceLabels = new Map() - for (const [kind, candidates] of Object.entries(sourceCandidates)) { - for (const candidate of candidates) - sourceLabels.set(`${kind}:${candidate.id}`, candidate.label) - } - const sourceWorkflowNames = new Map(sourceWorkflowRows.map((row) => [row.id, row.name])) - // Annotate each reference-cause entry's source liveness so the client can phrase the blocker - // reason (a deleted source can't be copied - it must be mapped to a live target resource). - const clearedRefs = await annotateForkClearedRefSourceLiveness( - db, - auth.sourceWorkspaceId, - collectForkClearedRefCandidates({ - items: plan.items, - sourceStates, - resolver: plan.resolver, - workflowIdMap: plan.workflowIdMap, - resolveBlockId, - sourceLabels, - sourceWorkflowNames, - }) - ) - - // Trigger URLs this sync decides in the target - the "we had to re-paste the Slack Request - // URL again" case, surfaced as an editable pairing before the overwrite instead of discovered - // after it. The preview reports the plan's DEFAULT resolution; the user's picks ride the - // promote call, where the same plan is rebuilt and validated against them. - const triggerPlan = buildForkTriggerPlan({ - items: plan.items, - sourceStates, - resolveBlockId, - targetWebhooks: await loadTargetWebhookPathsByBlock(db, allTargetIds), - }) - // The RAW retiring set, not the default resolution: the client derives which of these actually - // stop being served from the picks the user is making right now, so the heads-up and the - // overwrite confirm can never disagree with the Trigger URLs rows. - const retiringTriggerUrls = triggerPlan.retiring.map((row) => ({ - workflowName: row.workflowName, - path: row.path, - })) - // Every trigger that HAS a public URL, plus every one whose URL is up for decision - not just - // the decisions, so the section reads as a standing statement of each URL rather than an alert. - // - // A trigger with neither is deliberately absent: whether a block will serve a URL at all is - // only knowable from its webhook row, and a schedule / chat / manual / poller trigger never - // gets one. Claiming "gets a new URL" for those would be a straight lie, and no declarative - // flag separates them - `polling` is set on 10 of the trigger defs, while `webhook` is set on - // 345 including `slack_oauth`, which routes by `routingKey` with a NULL path. - const triggerMappings = triggerPlan.slots - .filter((slot) => slot.ownPath !== null || slot.adoptablePaths.length > 0) - .map((slot) => ({ - sourceBlockId: slot.sourceBlockId, - blockName: slot.blockName, - workflowName: slot.workflowName, - ownPath: slot.ownPath, - adoptablePaths: slot.adoptablePaths, - defaultAdoptPath: slot.defaultAdoptPath, - })) - - const toRef = (reference: (typeof plan.unmappedRequired)[number]) => ({ - kind: reference.kind, - sourceId: reference.sourceId, - required: reference.required, - blockName: reference.blockName, - }) - - // Orient the mapping around the workspace the modal is open in (`id`): show the - // caller's workflow name first, the sync partner's second, so renames are legible. - const currentIsSource = auth.sourceWorkspaceId === id - const workflows = [ - ...plan.items.map((item) => { - if (item.mode === 'create') { - // The target inherits the source's name, so both sides read the same. - return { - action: 'create' as const, - currentName: item.sourceMeta.name, - otherName: item.sourceMeta.name, - } - } - const targetName = item.targetName ?? item.sourceMeta.name - return { - action: 'update' as const, - currentName: currentIsSource ? item.sourceMeta.name : targetName, - otherName: currentIsSource ? targetName : item.sourceMeta.name, - } - }), - ...plan.archivedTargets.map((target) => ({ - action: 'archive' as const, - currentName: target.name, - otherName: target.name, - })), - ] - - return NextResponse.json({ - sourceWorkspaceId: auth.sourceWorkspaceId, - targetWorkspaceId: auth.targetWorkspaceId, - willUpdate: plan.willUpdate, - willCreate: plan.willCreate, - willArchive: plan.willArchive, - workflows, - excludedSourceWorkflows: excludedSourceWorkflows.map((w) => w.name), - excludedTargetWorkflows: plan.excludedTargets.map((t) => t.name), - unmappedRequired: plan.unmappedRequired.map(toRef), - unmappedOptional: plan.unmappedOptional.map(toRef), - mcpReauthServerIds: plan.mcpReauthServerIds, - inlineSecretSources: plan.inlineSecretSources, - dependentReconfigs, - resourceUsages: collectForkResourceUsages(plan.items, sourceStates), - copyableUnmapped: plan.copyableUnmapped, - clearedRefs, - retiringTriggerUrls, - triggerMappings, - }) - } -) + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { getWorkspaceSyncDetails } from '@/ee/workspace-forking/application/sync-details' + +export const GET = defineInternalJsonRoute({ + contract: getForkDiffContract, + auth: internalSessionAuth, + operation: forkOperations.syncPreview, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, query }) => ({ workspaceId: params.id, ...query }), + useCase: getWorkspaceSyncDetails, + present: (result) => result, +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts index f31acbbc923..f6064e7e6f5 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.test.ts @@ -11,13 +11,29 @@ import { } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAssertWorkspaceAdminAccess, mockCaptureServerEvent } = vi.hoisted(() => ({ - mockAssertWorkspaceAdminAccess: vi.fn(), - mockCaptureServerEvent: vi.fn(), -})) +const { mockAuthorizeWorkspaceOperation, mockCaptureServerEvent, mockAssertForkingEnabled } = + vi.hoisted(() => ({ + mockAuthorizeWorkspaceOperation: vi.fn(), + mockCaptureServerEvent: vi.fn(), + mockAssertForkingEnabled: vi.fn(), + })) vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ - assertWorkspaceAdminAccess: mockAssertWorkspaceAdminAccess, + assertForkingEnabled: mockAssertForkingEnabled, + ForkError: class extends Error {}, +})) + +vi.mock('@/lib/core/application/workspace-authorization', () => ({ + authorizeWorkspaceOperation: mockAuthorizeWorkspaceOperation, + requireAllowedWorkspacePrincipal: vi.fn(), +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: vi.fn(async (id: string) => ({ + id, + name: 'My Workspace', + organizationId: null, + allowPersonalApiKeys: true, + })), })) vi.mock('@sim/audit', () => auditMock) @@ -42,8 +58,8 @@ describe('fork excluded-workflows route', () => { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - mockGetSession.mockResolvedValue({ user: { id: ADMIN_ID } }) - mockAssertWorkspaceAdminAccess.mockResolvedValue({ id: WORKSPACE_ID, name: 'My Workspace' }) + mockGetSession.mockResolvedValue({ user: { id: ADMIN_ID }, session: { id: 'session-1' } }) + mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined) mockUpdateReturning([]) }) @@ -60,7 +76,7 @@ describe('fork excluded-workflows route', () => { ) expect(res.status).toBe(401) - expect(mockAssertWorkspaceAdminAccess).not.toHaveBeenCalled() + expect(mockAuthorizeWorkspaceOperation).not.toHaveBeenCalled() }) it('rejects an empty workflowIds batch', async () => { @@ -81,7 +97,16 @@ describe('fork excluded-workflows route', () => { routeContext ) - expect(mockAssertWorkspaceAdminAccess).toHaveBeenCalledWith(WORKSPACE_ID, ADMIN_ID) + expect(mockAuthorizeWorkspaceOperation).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session', userId: ADMIN_ID }), + expect.objectContaining({ id: 'workspaces.fork.exclusions', minimumRole: 'admin' }), + expect.objectContaining({ workspaceId: WORKSPACE_ID }), + {} + ) + expect(mockAssertForkingEnabled).toHaveBeenCalledWith(null) + expect(mockAssertForkingEnabled.mock.invocationCallOrder[0]).toBeLessThan( + dbChainMockFns.update.mock.invocationCallOrder[0] + ) }) it('updates the batch, reports the transition count, and records one audit entry', async () => { diff --git a/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.ts b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.ts index 842713be6d0..75fd2f0f8a8 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/excluded-workflows/route.ts @@ -1,94 +1,20 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { workflow } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, inArray, isNull, ne } from 'drizzle-orm' -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' import { updateForkExcludedWorkflowsContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { captureServerEvent } from '@/lib/posthog/server' -import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz' - -const logger = createLogger('ForkExcludedWorkflowsAPI') - -/** Workflow names carried on the audit entry - bounds the row for very large batches. */ -const AUDIT_NAME_LIMIT = 20 - -/** - * Toggle "Exclude from sync" for a batch of the workspace's workflows. An excluded - * workflow never leaves its workspace (promote in either direction, new-fork copies), - * is never overwritten or archived as a sync target, and keeps its identity mapping - * so re-including it resumes replace-mode. Admin-only, matching the sync operations - * the flag governs. Ids outside the workspace, archived workflows, and workflows - * already at the requested value are skipped, so `updated` counts real transitions. - */ -export const PUT = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(updateForkExcludedWorkflowsContract, req, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { workflowIds, forkSyncExcluded } = parsed.data.body - - const adminWorkspace = await assertWorkspaceAdminAccess(workspaceId, session.user.id) - - const updatedRows = await db - .update(workflow) - .set({ forkSyncExcluded, updatedAt: new Date() }) - .where( - and( - inArray(workflow.id, workflowIds), - eq(workflow.workspaceId, workspaceId), - isNull(workflow.archivedAt), - ne(workflow.forkSyncExcluded, forkSyncExcluded) - ) - ) - .returning({ id: workflow.id, name: workflow.name }) - - if (updatedRows.length > 0) { - recordAudit({ - workspaceId, - actorId: session.user.id, - action: forkSyncExcluded - ? AuditAction.WORKFLOW_FORK_SYNC_EXCLUDED - : AuditAction.WORKFLOW_FORK_SYNC_INCLUDED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: workspaceId, - resourceName: adminWorkspace.name, - description: `${forkSyncExcluded ? 'Excluded' : 'Included'} ${updatedRows.length} workflow(s) ${forkSyncExcluded ? 'from' : 'in'} fork sync`, - metadata: { - forkSyncExcluded, - workflowCount: updatedRows.length, - workflowNames: updatedRows.slice(0, AUDIT_NAME_LIMIT).map((row) => row.name), - }, - }) - - captureServerEvent( - session.user.id, - 'fork_excluded_workflows_updated', - { - workspace_id: workspaceId, - workflow_count: updatedRows.length, - fork_sync_excluded: forkSyncExcluded, - }, - { groups: { workspace: workspaceId } } - ) - } - - logger.info('Updated fork-sync exclusion', { - workspaceId, - requested: workflowIds.length, - updated: updatedRows.length, - forkSyncExcluded, - }) - - return NextResponse.json({ updated: updatedRows.length }) - } -) +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { updateWorkspaceForkExclusions } from '@/ee/workspace-forking/application/recovery-and-mappings' + +export const PUT = defineInternalJsonRoute({ + contract: updateForkExcludedWorkflowsContract, + auth: internalSessionAuth, + operation: forkOperations.exclusions, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }), + useCase: updateWorkspaceForkExclusions, + present: (result) => result, +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts index c3c265ba4c6..5bf0cbd759b 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts @@ -3,15 +3,16 @@ */ import { authMockFns, createMockRequest } from '@sim/testing' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' const { - mockAssertWorkspaceAdminAccess, + mockAuthorizeWorkspaceOperation, mockGetForkParent, mockGetForkChildren, mockGetUndoableRunForTarget, mockGetEffectiveWorkspacePermission, } = vi.hoisted(() => ({ - mockAssertWorkspaceAdminAccess: vi.fn(), + mockAuthorizeWorkspaceOperation: vi.fn(), mockGetForkParent: vi.fn(), mockGetForkChildren: vi.fn(), mockGetUndoableRunForTarget: vi.fn(), @@ -19,7 +20,8 @@ const { })) vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ - assertWorkspaceAdminAccess: mockAssertWorkspaceAdminAccess, + assertForkingEnabled: vi.fn(), + ForkError: class extends Error {}, })) vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({ @@ -31,7 +33,17 @@ vi.mock('@/ee/workspace-forking/lib/promote/promote-run-store', () => ({ getUndoableRunForTarget: mockGetUndoableRunForTarget, })) +vi.mock('@/lib/core/application/workspace-authorization', () => ({ + authorizeWorkspaceOperation: mockAuthorizeWorkspaceOperation, + requireAllowedWorkspacePrincipal: vi.fn(), +})) + vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: vi.fn(async (id: string) => ({ + id, + organizationId: null, + allowPersonalApiKeys: true, + })), getEffectiveWorkspacePermission: mockGetEffectiveWorkspacePermission, })) @@ -55,8 +67,8 @@ const childNode = (id: string, name: string) => ({ describe('fork lineage route', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: VIEWER_ID } }) - mockAssertWorkspaceAdminAccess.mockResolvedValue({ id: WORKSPACE_ID }) + mockGetSession.mockResolvedValue({ user: { id: VIEWER_ID }, session: { id: 'session-1' } }) + mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined) mockGetForkParent.mockResolvedValue(null) mockGetForkChildren.mockResolvedValue([]) mockGetUndoableRunForTarget.mockResolvedValue(null) @@ -69,13 +81,34 @@ describe('fork lineage route', () => { const res = await GET(createMockRequest('GET'), routeContext) expect(res.status).toBe(401) - expect(mockAssertWorkspaceAdminAccess).not.toHaveBeenCalled() + expect(mockAuthorizeWorkspaceOperation).not.toHaveBeenCalled() }) it('requires admin on the current workspace before loading lineage', async () => { await GET(createMockRequest('GET'), routeContext) - expect(mockAssertWorkspaceAdminAccess).toHaveBeenCalledWith(WORKSPACE_ID, VIEWER_ID) + expect(mockAuthorizeWorkspaceOperation).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session', userId: VIEWER_ID }), + expect.objectContaining({ id: 'workspaces.fork.discover', minimumRole: 'admin' }), + expect.objectContaining({ workspaceId: WORKSPACE_ID }), + {} + ) + expect(mockAuthorizeWorkspaceOperation.mock.invocationCallOrder[0]).toBeLessThan( + mockGetForkParent.mock.invocationCallOrder[0] + ) + }) + + it('does not read lineage when current workspace authorization is refused', async () => { + mockAuthorizeWorkspaceOperation.mockRejectedValue( + new OrchestrationError('forbidden', 'Admin access required') + ) + + const response = await GET(createMockRequest('GET'), routeContext) + + expect(response.status).toBe(403) + expect(mockGetForkParent).not.toHaveBeenCalled() + expect(mockGetForkChildren).not.toHaveBeenCalled() + expect(mockGetUndoableRunForTarget).not.toHaveBeenCalled() }) it('marks accessible and inaccessible nodes via the canonical permission resolver', async () => { diff --git a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts index 6e8b5b364e5..daf02b06675 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts @@ -1,82 +1,20 @@ -import { db } from '@sim/db' -import { workspace } from '@sim/db/schema' -import { eq } from 'drizzle-orm' -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' import { getForkLineageContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getEffectiveWorkspacePermission } from '@/lib/workspaces/permissions/utils' -import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz' -import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage' -import { getUndoableRunForTarget } from '@/ee/workspace-forking/lib/promote/promote-run-store' - -/** - * Annotates a lineage node with whether the viewer holds any access to it (explicit - * grant or org-admin derivation, via the canonical workspace-permission resolver). - * Lineage rows are visible to any admin of the CURRENT workspace, who may have no - * access to the other side of an edge; the flag drives per-action gating in the - * Forks UI. Resolved per node - lineage children lists are small and bounded. - */ -async function withViewerAccess( - node: T, - viewerId: string -): Promise { - const permission = await getEffectiveWorkspacePermission(viewerId, node) - return { ...node, viewerAccessible: permission !== null } -} - -export const GET = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getForkLineageContract, req, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - - await assertWorkspaceAdminAccess(workspaceId, session.user.id) - - const [rawParent, rawChildren, run] = await Promise.all([ - getForkParent(workspaceId), - getForkChildren(workspaceId), - getUndoableRunForTarget(db, workspaceId), - ]) - - const [parent, children] = await Promise.all([ - rawParent ? withViewerAccess(rawParent, session.user.id) : null, - Promise.all(rawChildren.map((child) => withViewerAccess(child, session.user.id))), - ]) - - let undoableRun: { - otherWorkspaceId: string - otherName: string - direction: 'push' | 'pull' - } | null = null - if (run) { - const [other] = await db - .select({ name: workspace.name }) - .from(workspace) - .where(eq(workspace.id, run.sourceWorkspaceId)) - .limit(1) - undoableRun = { - otherWorkspaceId: run.sourceWorkspaceId, - otherName: other?.name ?? 'workspace', - direction: run.direction, - } - } - - return NextResponse.json({ - workspaceId, - parent, - children: children.map((child) => ({ - ...child, - createdAt: child.createdAt.toISOString(), - })), - undoableRun, - }) - } -) +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { getWorkspaceForkLineageDetails } from '@/ee/workspace-forking/application/lineage-details' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: getForkLineageContract, + auth: internalSessionAuth, + operation: forkOperations.discover, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: getWorkspaceForkLineageDetails, + present: (result) => result, +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts b/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts index 63dc41e5e20..91a9f077cb3 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts @@ -1,104 +1,40 @@ -import { db } from '@sim/db' -import { type NextRequest, NextResponse } from 'next/server' import { getForkMappingContract, updateForkMappingContract, } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz' -import { acquireForkEdgeLock, setForkLockTimeout } from '@/ee/workspace-forking/lib/lineage/lineage' -import { reconcileForkDependentValues } from '@/ee/workspace-forking/lib/mapping/dependent-value-store' import { - applyForkMappingEntries, - getForkMappingView, - validateForkMappingTargets, -} from '@/ee/workspace-forking/lib/mapping/mapping-service' - -export const GET = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getForkMappingContract, req, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - const { otherWorkspaceId, direction } = parsed.data.query - - const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id) - - const { entries } = await getForkMappingView({ - edge: auth.edge, - sourceWorkspaceId: auth.sourceWorkspaceId, - targetWorkspaceId: auth.targetWorkspaceId, - }) - - return NextResponse.json({ - childWorkspaceId: auth.edge.childWorkspaceId, - parentWorkspaceId: auth.edge.parentWorkspaceId, - sourceWorkspaceId: auth.sourceWorkspaceId, - targetWorkspaceId: auth.targetWorkspaceId, - entries, - }) - } -) - -export const PUT = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(updateForkMappingContract, req, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - const { otherWorkspaceId, direction, entries, dependentValues } = parsed.data.body - - const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id) - - await validateForkMappingTargets(auth.sourceWorkspaceId, auth.targetWorkspaceId, entries) - - // Serialize concurrent mapping saves on this edge so a push (keyed child-side, deleted - // then re-upserted parent-side) can't leave duplicate rows for the same source. Same - // edge lock promote/rollback use, with a bounded wait. - const updated = await db.transaction(async (tx) => { - await setForkLockTimeout(tx) - await acquireForkEdgeLock(tx, auth.edge.childWorkspaceId) - const applied = await applyForkMappingEntries( - tx, - auth.edge, - session.user.id, - direction, - entries - ) - // Store dependent-field values with the mapping (each named workflow's stored set is - // replaced by exactly what was sent - promote's reconcile semantics, scoped to the - // payload's workflows since a mapping save has no promote plan). Omitted = untouched; - // rows for a workflow that never becomes a sync replace target are inert (promote - // loads the store scoped to its plan's targets). - if (dependentValues !== undefined) { - const targetWorkflowIds = Array.from( - new Set(dependentValues.map((entry) => entry.workflowId)) - ) - await reconcileForkDependentValues( - tx, - auth.edge.childWorkspaceId, - targetWorkflowIds, - dependentValues.map((entry) => ({ - targetWorkflowId: entry.workflowId, - targetBlockId: entry.blockId, - subBlockKey: entry.subBlockKey, - value: entry.value, - })) - ) - } - return applied - }) - - return NextResponse.json({ success: true as const, updated }) - } -) + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { getWorkspaceForkMappingDetails } from '@/ee/workspace-forking/application/mapping-details' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { updateWorkspaceForkMappings } from '@/ee/workspace-forking/application/recovery-and-mappings' + +export const GET = defineInternalJsonRoute({ + contract: getForkMappingContract, + auth: internalSessionAuth, + operation: forkOperations.mappingsRead, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, query }) => ({ workspaceId: params.id, ...query }), + useCase: getWorkspaceForkMappingDetails, + present: (result) => result, +}) +export const PUT = defineInternalJsonRoute({ + contract: updateForkMappingContract, + auth: internalSessionAuth, + operation: forkOperations.mappingsUpdate, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + otherWorkspaceId: body.otherWorkspaceId, + direction: body.direction, + mappings: body.entries, + dependentValues: body.dependentValues, + }), + useCase: updateWorkspaceForkMappings, + present: (result) => ({ success: true as const, ...result }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/promote/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/promote/route.test.ts index a634de5425e..d78b02188b9 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/promote/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/promote/route.test.ts @@ -8,11 +8,13 @@ * * @vitest-environment node */ +import { user } from '@sim/db/schema' import { auditMock, authMockFns, createMockRequest, type MockUser } from '@sim/testing' +import { queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' import { FolderCollectionFullError } from '@/lib/folders/errors' -const { mockLogger, mockPromoteFork, mockAssertCanPromote } = vi.hoisted(() => ({ +const { mockLogger, mockPromoteFork, mockAuthorizeWorkspaceOperation } = vi.hoisted(() => ({ mockLogger: { info: vi.fn(), warn: vi.fn(), @@ -23,7 +25,7 @@ const { mockLogger, mockPromoteFork, mockAssertCanPromote } = vi.hoisted(() => ( child: vi.fn(), }, mockPromoteFork: vi.fn(), - mockAssertCanPromote: vi.fn(), + mockAuthorizeWorkspaceOperation: vi.fn(), })) vi.mock('@sim/audit', () => auditMock) @@ -34,7 +36,27 @@ vi.mock('@sim/logger', () => ({ })) vi.mock('@/ee/workspace-forking/lib/promote/promote', () => ({ promoteFork: mockPromoteFork })) vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ - assertCanPromote: mockAssertCanPromote, + assertForkingEnabled: vi.fn(), + ForkError: class extends Error {}, +})) + +vi.mock('@/lib/core/application/workspace-authorization', () => ({ + authorizeWorkspaceOperation: mockAuthorizeWorkspaceOperation, + requireAllowedWorkspacePrincipal: vi.fn(), +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: vi.fn(async (id: string) => ({ + id, + name: id === 'ws-child' ? 'Child' : 'Parent', + organizationId: null, + allowPersonalApiKeys: true, + })), +})) +vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({ + resolveForkEdge: vi.fn(async () => ({ + childWorkspaceId: 'ws-child', + parentWorkspaceId: 'ws-parent', + })), })) import { POST } from '@/app/api/workspaces/[id]/fork/promote/route' @@ -58,19 +80,15 @@ function promoteRequest() { describe('POST /api/workspaces/[id]/fork/promote', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: TEST_USER }) - mockAssertCanPromote.mockResolvedValue({ - edge: { childWorkspaceId: WORKSPACE_ID }, - sourceWorkspaceId: WORKSPACE_ID, - targetWorkspaceId: 'ws-parent', - source: { name: 'Child' }, - target: { name: 'Parent' }, - }) + authMockFns.mockGetSession.mockResolvedValue({ user: TEST_USER, session: { id: 'session-1' } }) + resetDbChainMock() + queueTableRows(user, [{ name: TEST_USER.name }]) + mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined) }) /** - * The sync's Activity row is recorded by the use case, not here, so the route's job is to - * hand it the one thing only the route knows: the display name of the edge's other side. + * The shared application use case resolves the other side's name and the actor attribution + * before the manager records the sync activity. */ it('names the other side of the edge for promoteFork to record the sync', async () => { mockPromoteFork.mockResolvedValue({ @@ -80,6 +98,7 @@ describe('POST /api/workspaces/[id]/fork/promote', () => { archived: 0, redeployed: 1, deployFailed: 0, + deployWarnings: [], unmappedRequired: [], blockers: [], blocked: null, diff --git a/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts index af76a8d0e01..804270cfde2 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts @@ -1,119 +1,34 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { promoteForkContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz' -import { promoteFork } from '@/ee/workspace-forking/lib/promote/promote' - -const logger = createLogger('WorkspaceForkPromoteAPI') - -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(promoteForkContract, req, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - const { - otherWorkspaceId, - direction, - dependentValues, - copyResources, - dropReferences, - triggerMappings, - } = parsed.data.body - - const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id) - const otherName = - otherWorkspaceId === auth.sourceWorkspaceId ? auth.source.name : auth.target.name - - let result: Awaited> - try { - result = await promoteFork({ - edge: auth.edge, - sourceWorkspaceId: auth.sourceWorkspaceId, - targetWorkspaceId: auth.targetWorkspaceId, - direction, - userId: session.user.id, - actorName: session.user.name ?? undefined, - otherWorkspaceName: otherName, - dependentValues, - copyResources, - dropReferences, - triggerMappings, - requestId, - }) - } catch (error) { - /** - * `promoteFork` returns its deliberate refusals as a `blocked` result, but a - * classified failure raised deeper in the copy — the target workspace's folder - * ceiling being full, for one — throws instead. Without this branch it reaches - * `withRouteHandler`, which only understands `HttpError` and renders everything else - * as an opaque `Internal server error` 500. Unwrapped from the cause chain because - * drizzle re-wraps anything thrown inside a transaction callback. - */ - const classified = asOrchestrationError(error) - if (!classified) throw error - logger.warn(`[${requestId}] Fork sync refused: ${classified.message}`) - return NextResponse.json( - { error: classified.message }, - { status: statusForOrchestrationError(classified.code) } - ) - } - - const body = { - promoteRunId: result.promoteRunId, - updated: result.updated, - created: result.created, - archived: result.archived, - redeployed: result.redeployed, - deployFailed: result.deployFailed, - unmappedRequired: result.unmappedRequired, - blockers: result.blockers, - needsConfiguration: result.needsConfiguration, - clearedOptional: result.clearedOptional, - droppedReferences: result.droppedReferences, - triggerUrlChanges: result.triggerUrlChanges, - } - - if (result.blocked) { - logger.info(`[${requestId}] Promote blocked (${result.blocked})`, { - sourceWorkspaceId: auth.sourceWorkspaceId, - targetWorkspaceId: auth.targetWorkspaceId, - }) - return NextResponse.json(body) - } - - recordAudit({ - workspaceId: auth.targetWorkspaceId, - actorId: session.user.id, - action: AuditAction.WORKSPACE_FORK_PROMOTED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: auth.targetWorkspaceId, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: auth.target.name, - description: `Promoted workflows from "${auth.source.name}" to "${auth.target.name}"`, - metadata: { - direction, - sourceWorkspaceId: auth.sourceWorkspaceId, - updated: result.updated, - created: result.created, - archived: result.archived, - redeployed: result.redeployed, - }, - request: req, - }) - - return NextResponse.json(body) - } -) +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { syncWorkspace } from '@/ee/workspace-forking/application/create-and-sync' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: promoteForkContract, + auth: internalSessionAuth, + operation: forkOperations.sync, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }), + useCase: syncWorkspace, + present: (result) => ({ + promoteRunId: result.promoteRunId, + updated: result.updated, + created: result.created, + archived: result.archived, + redeployed: result.redeployed, + deployFailed: result.deployFailed, + deployWarnings: result.deployWarnings, + unmappedRequired: result.unmappedRequired, + blockers: result.blockers, + needsConfiguration: result.needsConfiguration, + clearedOptional: result.clearedOptional, + droppedReferences: result.droppedReferences, + triggerUrlChanges: result.triggerUrlChanges, + }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts b/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts index d639a56ed27..7d03aa2a6db 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts @@ -1,26 +1,20 @@ -import { db } from '@sim/db' -import { type NextRequest, NextResponse } from 'next/server' import { getForkResourcesContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz' -import { listForkCopyableResources } from '@/ee/workspace-forking/lib/mapping/resources' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { getWorkspaceForkResourceDetails } from '@/ee/workspace-forking/application/resource-details' -export const GET = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getForkResourcesContract, req, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - - await assertWorkspaceAdminAccess(id, session.user.id) - - const resources = await listForkCopyableResources(db, id) - return NextResponse.json(resources) - } -) +export const GET = defineInternalJsonRoute({ + contract: getForkResourcesContract, + auth: internalSessionAuth, + operation: forkOperations.discover, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id }), + useCase: getWorkspaceForkResourceDetails, + present: (result) => result, +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts b/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts index 68575860876..09a3b17ce60 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts @@ -1,92 +1,20 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { db } from '@sim/db' -import { workspace } 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 { rollbackForkContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { recordBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store' -import { assertCanRollback } from '@/ee/workspace-forking/lib/lineage/authz' -import { rollbackFork } from '@/ee/workspace-forking/lib/promote/rollback' - -const logger = createLogger('WorkspaceForkRollbackAPI') - -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(rollbackForkContract, req, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - const { otherWorkspaceId } = parsed.data.body - - const target = await assertCanRollback(id, session.user.id) - - const result = await rollbackFork({ - targetWorkspaceId: id, - otherWorkspaceId, - userId: session.user.id, - requestId, - }) - - recordAudit({ - workspaceId: id, - actorId: session.user.id, - action: AuditAction.WORKSPACE_FORK_ROLLED_BACK, - resourceType: AuditResourceType.WORKSPACE, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: target.name, - description: `Rolled back the last promote into "${target.name}"`, - metadata: { otherWorkspaceId, ...result }, - request: req, - }) - - // Durable audit entry scoped to this workspace so the undo shows in its Manage Forks - // → Activity log. Non-critical: a failure must not fail the (committed) rollback. - const [other] = await db - .select({ name: workspace.name }) - .from(workspace) - .where(eq(workspace.id, otherWorkspaceId)) - .limit(1) - const otherName = other?.name ?? 'the source workspace' - await recordBackgroundWork(db, { - workspaceId: id, - kind: 'fork_rollback', - status: - result.skipped > 0 || result.pendingActivations.length > 0 - ? 'completed_with_warnings' - : 'completed', - message: - result.pendingActivations.length > 0 - ? `Undid the last sync from "${otherName}" — ${result.pendingActivations.length} deployment(s) still activating` - : `Undid the last sync from "${otherName}"`, - metadata: { - actorName: session.user.name ?? undefined, - otherWorkspaceId, - otherWorkspaceName: otherName, - restored: result.restored, - removed: result.archived, - unarchived: result.unarchived, - skipped: result.skipped, - pendingActivations: result.pendingActivations.length, - }, - }).catch((error) => - logger.error(`[${requestId}] Failed to record rollback activity`, { - error: getErrorMessage(error), - }) - ) - - return NextResponse.json(result) - } -) +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { rollbackWorkspaceFork } from '@/ee/workspace-forking/application/recovery-and-mappings' + +export const POST = defineInternalJsonRoute({ + contract: rollbackForkContract, + auth: internalSessionAuth, + operation: forkOperations.rollback, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }), + useCase: rollbackWorkspaceFork, + present: (result) => result, +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/route.test.ts index c23e3a45ee9..cff037e493a 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/route.test.ts @@ -8,11 +8,13 @@ * * @vitest-environment node */ +import { user } from '@sim/db/schema' import { auditMock, authMockFns, createMockRequest, type MockUser } from '@sim/testing' +import { queueTableRows, resetDbChainMock } from '@sim/testing/mocks/database.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' import { FolderCollectionFullError } from '@/lib/folders/errors' -const { mockLogger, mockCreateFork, mockAssertCanFork } = vi.hoisted(() => ({ +const { mockLogger, mockCreateFork, mockAuthorizeWorkspaceOperation } = vi.hoisted(() => ({ mockLogger: { info: vi.fn(), warn: vi.fn(), @@ -23,7 +25,7 @@ const { mockLogger, mockCreateFork, mockAssertCanFork } = vi.hoisted(() => ({ child: vi.fn(), }, mockCreateFork: vi.fn(), - mockAssertCanFork: vi.fn(), + mockAuthorizeWorkspaceOperation: vi.fn(), })) vi.mock('@sim/audit', () => auditMock) @@ -33,7 +35,25 @@ vi.mock('@sim/logger', () => ({ getRequestContext: () => undefined, })) vi.mock('@/ee/workspace-forking/lib/create-fork', () => ({ createFork: mockCreateFork })) -vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ assertCanFork: mockAssertCanFork })) +vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({ + assertForkingEnabled: vi.fn(), + ForkError: class extends Error {}, +})) +vi.mock('@/lib/core/application/workspace-authorization', () => ({ + authorizeWorkspaceOperation: mockAuthorizeWorkspaceOperation, + requireAllowedWorkspacePrincipal: vi.fn(), +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getWorkspaceWithOwner: vi.fn(async (id: string) => ({ + id, + name: 'Source', + organizationId: null, + allowPersonalApiKeys: true, + })), +})) +vi.mock('@/lib/workspaces/policy', () => ({ + getWorkspaceCreationPolicy: vi.fn(async () => ({ canCreate: true })), +})) import { POST } from '@/app/api/workspaces/[id]/fork/route' @@ -56,11 +76,10 @@ function forkRequest() { describe('POST /api/workspaces/[id]/fork', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: TEST_USER }) - mockAssertCanFork.mockResolvedValue({ - source: { id: SOURCE_WORKSPACE_ID, name: 'Source' }, - policy: {}, - }) + authMockFns.mockGetSession.mockResolvedValue({ user: TEST_USER, session: { id: 'session-1' } }) + resetDbChainMock() + queueTableRows(user, [{ name: TEST_USER.name }]) + mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined) }) it('renders a full-folder-tree refusal as an actionable 409', async () => { @@ -100,12 +119,25 @@ describe('POST /api/workspaces/[id]/fork', () => { it('still returns the created fork when the copy succeeds', async () => { mockCreateFork.mockResolvedValue({ - workspace: { id: 'ws-child', name: 'Child' }, + workspace: { + id: 'ws-child', + name: 'Child', + ownerId: TEST_USER.id, + organizationId: null, + workspaceMode: 'personal', + }, workflowsCopied: 2, }) const response = await POST(forkRequest(), routeContext) expect(response.status).toBe(201) + expect(mockCreateFork).toHaveBeenCalledWith( + expect.objectContaining({ + source: expect.objectContaining({ id: SOURCE_WORKSPACE_ID }), + userId: TEST_USER.id, + actorName: TEST_USER.name, + }) + ) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/fork/route.ts b/apps/sim/app/api/workspaces/[id]/fork/route.ts index c4a90c30d6f..23c8826b7bc 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/route.ts @@ -1,88 +1,20 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { forkWorkspaceContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createFork } from '@/ee/workspace-forking/lib/create-fork' -import { assertCanFork } from '@/ee/workspace-forking/lib/lineage/authz' - -const logger = createLogger('WorkspaceForkAPI') - -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const { id: sourceWorkspaceId } = await context.params - const requestId = generateRequestId() - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const { source, policy } = await assertCanFork(sourceWorkspaceId, session.user.id) - - const parsed = await parseRequest(forkWorkspaceContract, req, context) - if (!parsed.success) return parsed.response - - const copy = parsed.data.body.copy - let result: Awaited> - try { - result = await createFork({ - source, - policy, - userId: session.user.id, - actorName: session.user.name ?? undefined, - name: parsed.data.body.name, - selection: { - files: copy?.files ?? [], - tables: copy?.tables ?? [], - knowledgeBases: copy?.knowledgeBases ?? [], - customTools: copy?.customTools ?? [], - skills: copy?.skills ?? [], - mcpServers: copy?.mcpServers ?? [], - workflowMcpServers: copy?.workflowMcpServers ?? [], - }, - requestId, - }) - } catch (error) { - /** - * The fork copy raises classified, caller-fixable refusals — the child workspace's - * folder ceiling being full, for one. Without this branch they reach - * `withRouteHandler`, which only understands `HttpError` and renders everything else - * as an opaque `Internal server error` 500, dropping the message that tells the user - * what to do. Unwrapped from the cause chain because drizzle re-wraps anything thrown - * inside a transaction callback in a `DrizzleQueryError`. - */ - const classified = asOrchestrationError(error) - if (!classified) throw error - logger.warn(`[${requestId}] Fork of ${sourceWorkspaceId} refused: ${classified.message}`) - return NextResponse.json( - { error: classified.message }, - { status: statusForOrchestrationError(classified.code) } - ) - } - - recordAudit({ - workspaceId: result.workspace.id, - actorId: session.user.id, - action: AuditAction.WORKSPACE_FORKED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: result.workspace.id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: result.workspace.name, - description: `Forked workspace from "${source.name}"`, - metadata: { - parentWorkspaceId: source.id, - workflowsCopied: result.workflowsCopied, - }, - request: req, - }) - - logger.info(`[${requestId}] Forked workspace ${sourceWorkspaceId} -> ${result.workspace.id}`) - return NextResponse.json(result, { status: 201 }) - } -) +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkWorkspace } from '@/ee/workspace-forking/application/create-and-sync' +import { forkOperations } from '@/ee/workspace-forking/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: forkWorkspaceContract, + auth: internalSessionAuth, + operation: forkOperations.create, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }), + useCase: forkWorkspace, + present: (result) => ({ workspace: result.workspace, workflowsCopied: result.workflowsCopied }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/fork/unlink/route.ts b/apps/sim/app/api/workspaces/[id]/fork/unlink/route.ts index d779547ef12..254c7b81975 100644 --- a/apps/sim/app/api/workspaces/[id]/fork/unlink/route.ts +++ b/apps/sim/app/api/workspaces/[id]/fork/unlink/route.ts @@ -1,49 +1,20 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { type NextRequest, NextResponse } from 'next/server' import { unlinkForkContract } from '@/lib/api/contracts/workspace-fork' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { assertCanUnlink } from '@/ee/workspace-forking/lib/lineage/authz' -import { unlinkForkEdge } from '@/ee/workspace-forking/lib/lineage/unlink' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies' +import { forkOperations } from '@/ee/workspace-forking/application/operations' +import { unlinkWorkspaceFork } from '@/ee/workspace-forking/application/recovery-and-mappings' -export const POST = withRouteHandler( - async (req: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(unlinkForkContract, req, context) - if (!parsed.success) return parsed.response - const { id } = parsed.data.params - const { otherWorkspaceId } = parsed.data.body - - const { edge, current } = await assertCanUnlink(id, otherWorkspaceId, session.user.id) - const result = await unlinkForkEdge(edge, requestId) - - if (result.unlinked) { - recordAudit({ - workspaceId: id, - actorId: session.user.id, - action: AuditAction.WORKSPACE_FORK_UNLINKED, - resourceType: AuditResourceType.WORKSPACE, - resourceId: id, - actorName: session.user.name ?? undefined, - actorEmail: session.user.email ?? undefined, - resourceName: current.name, - description: `Disconnected the fork relationship with workspace "${otherWorkspaceId}"`, - metadata: { - otherWorkspaceId, - childWorkspaceId: edge.childWorkspaceId, - parentWorkspaceId: edge.parentWorkspaceId, - }, - request: req, - }) - } - - return NextResponse.json(result) - } -) +export const POST = defineInternalJsonRoute({ + contract: unlinkForkContract, + auth: internalSessionAuth, + operation: forkOperations.unlink, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }), + errorPolicy: internalForkErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }), + useCase: unlinkWorkspaceFork, + present: (result) => result, +}) diff --git a/apps/sim/app/credential-groups/complete/completion-handoff.test.tsx b/apps/sim/app/credential-groups/complete/completion-handoff.test.tsx new file mode 100644 index 00000000000..f139057a076 --- /dev/null +++ b/apps/sim/app/credential-groups/complete/completion-handoff.test.tsx @@ -0,0 +1,49 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CredentialGroupCompletionHandoff } from '@/app/credential-groups/complete/completion-handoff' + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('credential group OAuth completion', () => { + it.each([undefined, 'denied', 'configuration_changed'] as const)( + 'publishes %s to only its initiating tab and closes', + (failure) => { + const postMessage = vi.fn() + const closeChannel = vi.fn() + const names: string[] = [] + vi.stubGlobal( + 'BroadcastChannel', + class { + postMessage = postMessage + close = closeChannel + constructor(name: string) { + names.push(name) + } + } + ) + const closeWindow = vi.spyOn(window, 'close').mockImplementation(() => {}) + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + const container = document.createElement('div') + const root = createRoot(container) + const completionId = '550e8400-e29b-41d4-a716-446655440000' + try { + act(() => + root.render( + + ) + ) + expect(names).toEqual([`sim:credential-group-oauth:${completionId}`]) + expect(postMessage).toHaveBeenCalledExactlyOnceWith(failure ?? 'connected') + expect(closeChannel).toHaveBeenCalledOnce() + expect(closeWindow).toHaveBeenCalledOnce() + } finally { + act(() => root.unmount()) + } + } + ) +}) diff --git a/apps/sim/app/credential-groups/complete/completion-handoff.tsx b/apps/sim/app/credential-groups/complete/completion-handoff.tsx new file mode 100644 index 00000000000..07f8d440126 --- /dev/null +++ b/apps/sim/app/credential-groups/complete/completion-handoff.tsx @@ -0,0 +1,26 @@ +'use client' + +import { useEffect } from 'react' +import { + type CredentialGroupOAuthFailure, + credentialGroupOAuthCompletionChannel, +} from '@/lib/credential-groups/oauth-completion' + +interface CredentialGroupCompletionHandoffProps { + completionId: string + failure?: CredentialGroupOAuthFailure +} + +/** Notifies the originating tab even when provider navigation has removed window.opener. */ +export function CredentialGroupCompletionHandoff({ + completionId, + failure, +}: CredentialGroupCompletionHandoffProps) { + useEffect(() => { + const channel = new BroadcastChannel(credentialGroupOAuthCompletionChannel(completionId)) + channel.postMessage(failure ?? 'connected') + channel.close() + window.close() + }, [completionId, failure]) + return null +} diff --git a/apps/sim/app/credential-groups/complete/page.tsx b/apps/sim/app/credential-groups/complete/page.tsx index 86dbcae5ee3..07edac4b434 100644 --- a/apps/sim/app/credential-groups/complete/page.tsx +++ b/apps/sim/app/credential-groups/complete/page.tsx @@ -1,36 +1,33 @@ import { ChipLink } from '@sim/emcn' +import { isValidUuid } from '@sim/utils/id' import type { Metadata } from 'next' +import { + CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES, + isCredentialGroupOAuthFailure, +} from '@/lib/credential-groups/oauth-completion' import { APP_ENTRY_PATH } from '@/lib/navigation/paths' import { AuthHeader, AuthShell } from '@/app/(auth)/components' +import { CredentialGroupCompletionHandoff } from '@/app/credential-groups/complete/completion-handoff' export const metadata: Metadata = { title: 'Accounts connected', robots: { index: false, follow: false }, } -const OAUTH_FAILURE_MESSAGES = { - expired: 'This connection attempt expired. Open Sim and start connecting your account again.', - denied: 'Authorization was canceled. Open Sim to try again.', - account_mismatch: 'Choose the account matching your Sim email address.', - permissions_required: 'All requested permissions are required to connect this account.', - configuration_changed: 'The connection settings changed. Open Sim to try again.', - rate_limited: 'Too many authorization attempts. Wait a few minutes and try again.', - unavailable: 'This connection is unavailable. Open Sim to try again.', - failed: 'Account authorization did not complete. Open Sim to try again.', -} as const - export default async function CredentialGroupCompletePage({ searchParams, }: { - searchParams: Promise<{ oauth?: string | string[] }> + searchParams: Promise<{ oauth?: string | string[]; completionId?: string | string[] }> }) { - const { oauth } = await searchParams - const error = - typeof oauth === 'string' && Object.hasOwn(OAUTH_FAILURE_MESSAGES, oauth) - ? OAUTH_FAILURE_MESSAGES[oauth as keyof typeof OAUTH_FAILURE_MESSAGES] - : undefined + const { oauth, completionId } = await searchParams + const failure = + oauth === undefined ? undefined : isCredentialGroupOAuthFailure(oauth) ? oauth : 'failed' + const error = failure ? CREDENTIAL_GROUP_OAUTH_FAILURE_MESSAGES[failure] : undefined return ( + {typeof completionId === 'string' && isValidUuid(completionId) && ( + + )} { expect(mocks.read).toHaveBeenCalledWith({ principal, input: {} }) }) - it('keeps account-settings reconnect focused and returns to account settings', async () => { + it('keeps existing account reconnect links focused and returns to Sim', async () => { await render({ returnTo: 'accounts', optionId: 'site-two' }) expect(oauthLinks().map((link) => link.getAttribute('href'))).toEqual([ '/api/credential-groups/enroll/invitation/oauth/site-two?returnTo=accounts', @@ -156,9 +156,9 @@ describe('focused Search enrollment', () => { expect(document.querySelector('form')).toBeNull() expect( Array.from(document.querySelectorAll('a')) - .find((link) => link.textContent === 'Your connected accounts') + .find((link) => link.textContent === 'Open Sim') ?.getAttribute('href') - ).toBe('/account/settings/connected-accounts') + ).toBe('/home') }) it('lets an account owner deliberately reconnect an active grant before reporting completion', async () => { @@ -189,12 +189,12 @@ describe('focused Search enrollment', () => { }) mocks.read.mockResolvedValue({ enrollment, canSearch }) await render({ returnTo: 'search', optionId: 'site-two' }) - const label = canSearch ? 'Return to Search' : 'Your connected accounts' + const label = canSearch ? 'Return to Search' : 'Open Sim' expect( Array.from(document.querySelectorAll('a')) .find((link) => link.textContent === label) ?.getAttribute('href') - ).toBe(canSearch ? '/o/canonical-org/search' : '/account/settings/connected-accounts') + ).toBe(canSearch ? '/o/canonical-org/search' : '/home') } ) @@ -204,7 +204,11 @@ describe('focused Search enrollment', () => { session: { id: 'session-1' }, }) await render({ returnTo: 'search', optionId: 'site-two' }) - expect(document.querySelector('a')?.getAttribute('href')).toBe('/verify') + const recovery = new URL(document.querySelector('a')!.getAttribute('href')!, 'https://sim.test') + expect(recovery.pathname).toBe('/verify') + expect(recovery.searchParams.get('redirectAfter')).toBe( + '/credential-groups/enroll/invitation?returnTo=search&optionId=site-two' + ) expect(mocks.read).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/credential-groups/enroll/[token]/page.tsx b/apps/sim/app/credential-groups/enroll/[token]/page.tsx index 57febd8e801..63423f76409 100644 --- a/apps/sim/app/credential-groups/enroll/[token]/page.tsx +++ b/apps/sim/app/credential-groups/enroll/[token]/page.tsx @@ -3,7 +3,6 @@ import { Chip, ChipLink } from '@sim/emcn' import type { Metadata } from 'next' import { headers } from 'next/headers' import { redirect } from 'next/navigation' -import { getAccountSettingsHref } from '@/components/settings/navigation' import { getSession } from '@/lib/auth' import { asOrchestrationError } from '@/lib/core/orchestration/types' import type { ResourceOwner } from '@/lib/core/resource-scope' @@ -139,21 +138,21 @@ export default async function CredentialGroupEnrollmentPage({ const { token } = await params if (!token || token.length > 128) return const resolvedSearchParams = await searchParams + const callback = new URLSearchParams() + for (const key of ['returnTo', 'optionId']) { + const value = getSearchParam(resolvedSearchParams, key) + if (value) callback.set(key, value) + } + const callbackUrl = `/credential-groups/enroll/${encodeURIComponent(token)}${callback.size ? `?${callback}` : ''}` const session = await getSession() if (!session?.user) { - const callback = new URLSearchParams() - for (const key of ['returnTo', 'optionId']) { - const value = getSearchParam(resolvedSearchParams, key) - if (value) callback.set(key, value) - } - const callbackUrl = `/credential-groups/enroll/${encodeURIComponent(token)}${callback.size ? `?${callback}` : ''}` redirect(`/login?callbackUrl=${encodeURIComponent(callbackUrl)}`) } if (!session.user.emailVerified) return ( ) @@ -184,10 +183,8 @@ export default async function CredentialGroupEnrollmentPage({ const canReturnToSearch = returnToSearch && ('canSearch' in enrollmentResult ? enrollmentResult.canSearch : !principal.organizationId) - const returnHref = canReturnToSearch - ? searchReturnPath(principal) - : getAccountSettingsHref('connected-accounts') - const returnLabel = canReturnToSearch ? 'Return to Search' : 'Your connected accounts' + const returnHref = canReturnToSearch ? searchReturnPath(principal) : APP_ENTRY_PATH + const returnLabel = canReturnToSearch ? 'Return to Search' : 'Open Sim' if (!enrollment) return diff --git a/apps/sim/app/home/page.test.tsx b/apps/sim/app/home/page.test.tsx index 90760f97c44..329be1004ea 100644 --- a/apps/sim/app/home/page.test.tsx +++ b/apps/sim/app/home/page.test.tsx @@ -28,10 +28,16 @@ describe('AppEntryPage', () => { vi.clearAllMocks() }) - it('sends a signed-out visitor to login without resolving an entry', async () => { + /** + * The proxy sends cookie-less requests to /login before this route renders, so a + * null session here is always a stale cookie. Redirecting to /login would be + * bounced back by the proxy's presence-only cookie check, looping forever. + */ + it('sends a stale-cookie viewer to the recovery surface, never back to login', async () => { mockGetSession.mockResolvedValue(null) - await expect(AppEntryPage()).rejects.toThrow('NEXT_REDIRECT:/login') + await expect(AppEntryPage()).rejects.toThrow('NEXT_REDIRECT:/workspace') + expect(mockRedirect).not.toHaveBeenCalledWith('/login') expect(mockResolveAppEntryPath).not.toHaveBeenCalled() }) diff --git a/apps/sim/app/home/page.tsx b/apps/sim/app/home/page.tsx index 1965f6894c2..420b2b99877 100644 --- a/apps/sim/app/home/page.tsx +++ b/apps/sim/app/home/page.tsx @@ -1,5 +1,6 @@ import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' +import { WORKSPACES_PATH } from '@/lib/navigation/paths' import { resolveAppEntryPath } from '@/lib/navigation/resolve-app-entry' /** @@ -10,8 +11,20 @@ import { resolveAppEntryPath } from '@/lib/navigation/resolve-app-entry' */ export default async function AppEntryPage() { const session = await getSession() + + /** + * A missing session here is never a signed-out visitor: the proxy treats `/home` + * as an app surface and sends cookie-less requests to `/login` before this + * renders, and auth-disabled deployments always resolve an anonymous session. So + * this branch means the cookie is present but its session is gone — and + * redirecting to `/login` would be bounced straight back by the proxy, which + * reads cookie presence rather than validity, looping until the browser gives up. + * Hand off to the workspace loader instead: it is the app's one identity-recovery + * surface, and it clears the stale cookies through `recoverFromStaleSession` + * before navigating to `/login`. + */ if (!session?.user) { - redirect('/login') + redirect(WORKSPACES_PATH) } redirect(await resolveAppEntryPath(session)) diff --git a/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx b/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx index f3f43caf421..7562bc5cd02 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-page/organization-page.tsx @@ -34,6 +34,9 @@ interface OrganizationPageProps { tabs?: readonly OrganizationPageTab[] /** The page's primary action, a chip. Omit for a page without one. */ action?: ReactNode + /** Keep the search field visible on pages whose main content is a searchable list. */ + searchMode?: 'collapsible' | 'expanded' + searchPlaceholder?: string children?: ReactNode } @@ -53,6 +56,8 @@ export function OrganizationPage({ description, tabs, action, + searchMode = 'collapsible', + searchPlaceholder = 'Search', children, }: OrganizationPageProps) { const scrollContainerRef = useRef(null) @@ -71,7 +76,7 @@ export function OrganizationPage({ * viewer opened and has not dismissed. */ const [searchOpened, setSearchOpened] = useState(false) - const searchOpen = searchOpened || search.length > 0 + const searchOpen = searchMode === 'expanded' || searchOpened || search.length > 0 const closeSearch = () => { setSearch('') @@ -99,7 +104,8 @@ export function OrganizationPage({ ref={tabsRef} className={cn( scrollFadeXClass, - 'flex min-w-0 flex-1 items-center gap-[1px] overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden' + 'flex min-w-0 flex-1 items-center gap-[1px] overflow-x-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden', + searchMode === 'expanded' && !tabs?.length && 'hidden' )} {...scrollFadeAttributes(tabEdges)} > @@ -119,32 +125,39 @@ export function OrganizationPage({ ) })} -
+
{searchOpen ? ( setSearch(event.target.value)} onKeyDown={(event) => { if (event.key === 'Escape') closeSearch() }} endAdornment={ - + (searchMode === 'collapsible' || search.length > 0) && ( + + ) } /> ) : ( diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx index e4b3856a5d3..e68e55f599c 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.test.tsx @@ -8,6 +8,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' const hoverState = vi.hoisted(() => ({ isOpen: false })) +const mockRequestJson = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/api/client/request', () => ({ requestJson: mockRequestJson })) vi.mock('next/link', () => ({ default: ({ @@ -25,7 +28,7 @@ vi.mock('next/link', () => ({ ), })) -vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/hooks', () => ({ +vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/hooks/use-hover-menu', () => ({ useHoverMenu: () => ({ isOpen: hoverState.isOpen, open: vi.fn(), @@ -37,6 +40,7 @@ vi.mock('@/app/workspace/[workspaceId]/w/components/sidebar/hooks', () => ({ })) import { ChatsSection } from '@/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section' +import { mothershipChatKeys } from '@/hooks/queries/mothership-chats' const CHATS: OrganizationChat[] = Array.from({ length: 8 }, (_, index) => ({ id: `chat-${index + 1}`, @@ -59,6 +63,8 @@ beforeEach(() => { disconnect() {} } ) + vi.clearAllMocks() + mockRequestJson.mockResolvedValue({ success: true }) hoverState.isOpen = false queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }) prefetchQuery = vi.spyOn(queryClient, 'prefetchQuery').mockResolvedValue() @@ -83,9 +89,7 @@ async function render(props: Partial[0]> = {}) { isLoading={false} isCollapsed={false} pathname={null} - menuOpenHref={null} - onContextMenu={() => {}} - onMoreClick={() => {}} + organizationId='org-1' {...props} /> @@ -94,33 +98,124 @@ async function render(props: Partial[0]> = {}) { } describe('ChatsSection', () => { - it('lists every chat with no paging control', async () => { + it('shows all chats without pagination controls', async () => { await render() - expect(container.querySelectorAll('a[href^="/o/org-1/chat/"]')).toHaveLength(8) expect(container.textContent).not.toContain('See more') + expect(container.textContent).not.toContain('See less') }) it('marks the chat on the current route active', async () => { - await render({ pathname: '/o/org-1/chat/chat-3' }) + await render({ pathname: '/o/org-1/chat/chat-8' }) - const current = container.querySelector('a[href="/o/org-1/chat/chat-3"]') + const current = container.querySelector('a[href="/o/org-1/chat/chat-8"]') const other = container.querySelector('a[href="/o/org-1/chat/chat-4"]') expect(current?.className).toContain('surface-active') expect(other?.className).not.toContain('surface-active') }) - it('reports the row href when its options button is pressed', async () => { - const onMoreClick = vi.fn() - await render({ onMoreClick }) + it.each([false, true])('renames via the options menu with collapsed=%s', async (isCollapsed) => { + hoverState.isOpen = isCollapsed + await render({ isCollapsed }) + const button = + document.body.querySelector( + 'a[href="/o/org-1/chat/chat-2"] button[aria-label="Chat options"]' + ) ?? document.body.querySelector('[aria-label="Chat options"]')! + await act(async () => button.click()) + const rename = Array.from( + document.body.querySelectorAll('[role="menuitem"]') + ).find((item) => item.textContent === 'Rename')! + expect(rename).toBeDefined() + await act(async () => rename.click()) + const input = document.body.querySelector('input[aria-label^="Rename chat"]')! + expect(input).not.toBeNull() + expect(input.value).toMatch(/^Chat /) + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call( + input, + 'Planning' + ) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + await act(async () => + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) + ) + expect(mockRequestJson).toHaveBeenCalledWith( + expect.objectContaining({ method: 'PATCH' }), + expect.objectContaining({ body: { title: 'Planning' } }) + ) + expect(document.body.querySelector('input[aria-label^="Rename chat"]')).toBeNull() + }) - const button = container.querySelector( - 'a[href="/o/org-1/chat/chat-2"] button[aria-label="Chat options"]' + it('rolls back only the organization list when rename fails', async () => { + const pending = Promise.withResolvers<{ success: boolean }>() + mockRequestJson.mockReturnValueOnce(pending.promise) + const key = mothershipChatKeys.organizationList('org-1') + queryClient.setQueryData(key, [{ id: 'chat-1', name: 'Chat 1' }]) + const workspaceKey = mothershipChatKeys.list('workspace-1') + queryClient.setQueryData(workspaceKey, [{ id: 'workspace-chat', name: 'Workspace chat' }]) + await render() + await act(async () => + container.querySelector('[aria-label="Chat options"]')!.click() + ) + const action = Array.from( + document.body.querySelectorAll('[role="menuitem"]') + ).find((item) => item.textContent === 'Rename')! + await act(async () => action.click()) + const input = document.body.querySelector('input[aria-label^="Rename chat"]')! + await act(async () => { + Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')!.set!.call( + input, + 'Pending title' + ) + input.dispatchEvent(new Event('input', { bubbles: true })) + }) + await act(async () => + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })) ) - await act(async () => button?.click()) + expect(queryClient.getQueryData(key)).toEqual([{ id: 'chat-1', name: 'Pending title' }]) + await act(async () => pending.reject(new Error('Rename rejected'))) + expect(queryClient.getQueryData(key)).toEqual([{ id: 'chat-1', name: 'Chat 1' }]) + expect(queryClient.getQueryData(workspaceKey)).toEqual([ + { id: 'workspace-chat', name: 'Workspace chat' }, + ]) + expect(input.value).toBe('Chat 1') + expect(input.disabled).toBe(false) + }) - expect(onMoreClick).toHaveBeenCalledWith(expect.anything(), '/o/org-1/chat/chat-2') - expect(prefetchQuery).not.toHaveBeenCalled() + it('cancels rename on Escape without a mutation', async () => { + await render() + await act(async () => + container.querySelector('[aria-label="Chat options"]')!.click() + ) + const rename = Array.from( + document.body.querySelectorAll('[role="menuitem"]') + ).find((item) => item.textContent === 'Rename')! + await act(async () => rename.click()) + const input = document.body.querySelector('input[aria-label^="Rename chat"]')! + await act(async () => + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + ) + expect(mockRequestJson).not.toHaveBeenCalled() + expect(document.body.querySelector('input[aria-label^="Rename chat"]')).toBeNull() + }) + + it.each([ + ['Pin', { pinned: true }], + ['Mark as unread', { isUnread: true }], + ])('offers %s for organization chats', async (label, body) => { + await render() + await act(async () => + container.querySelector('[aria-label="Chat options"]')!.click() + ) + const action = Array.from( + document.body.querySelectorAll('[role="menuitem"]') + ).find((item) => item.textContent === label)! + await act(async () => action.click()) + expect(mockRequestJson).toHaveBeenCalledWith(expect.objectContaining({ method: 'PATCH' }), { + params: { chatId: 'chat-1' }, + body, + }) }) it.each([false, true])( diff --git a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx index 351afadfeae..df2b36b4aa9 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-sidebar/components/chats-section/chats-section.tsx @@ -1,19 +1,28 @@ 'use client' -import { chipVariants, cn, DropdownMenuItem, Loader, OverflowText, Skeleton } from '@sim/emcn' +import { + ChipInput, + chipVariants, + cn, + DropdownMenuItem, + Loader, + OverflowText, + Skeleton, +} from '@sim/emcn' import { MoreHorizontal, Pin, Task } from '@sim/emcn/icons' import type { OrganizationChat } from '@/app/o/[organizationId]/components/organization-sidebar/hooks' -import { ConversationListItem } from '@/app/workspace/[workspaceId]/components' +import { useOrganizationChatActions } from '@/app/o/[organizationId]/components/organization-sidebar/hooks/use-organization-chat-actions' import { ChatNavigationLink, + CollapsedChatFlyoutItem, CollapsedSidebarMenu, SidebarSection, } from '@/app/workspace/[workspaceId]/w/components/sidebar/components' +import { ContextMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/components/workflow-list/components/context-menu/context-menu' import { SIDEBAR_ITEM_GAP_CLASS, SIDEBAR_SECTION_GAP_CLASS, } from '@/app/workspace/[workspaceId]/w/components/sidebar/constants' -import { useHoverMenu } from '@/app/workspace/[workspaceId]/w/components/sidebar/hooks' /** Stands in for a chip row while the list loads, so it carries no margin either. */ function ChatRowSkeleton() { @@ -28,11 +37,19 @@ interface ChatRowProps { chat: OrganizationChat isCurrentRoute: boolean isMenuOpen: boolean - onContextMenu: (e: React.MouseEvent, href: string) => void - onMoreClick: (e: React.MouseEvent, href: string) => void + onContextMenu: (e: React.MouseEvent, chatId: string) => void + onMorePointerDown: () => void + onMoreClick: (e: React.MouseEvent, chatId: string) => void } -function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick }: ChatRowProps) { +function ChatRow({ + chat, + isCurrentRoute, + isMenuOpen, + onContextMenu, + onMorePointerDown, + onMoreClick, +}: ChatRowProps) { /** * The trailing slot fits one glyph, and the dot wins over the pin: it reports * transient state (a run in progress, or an unread reply elsewhere), while pinning @@ -46,7 +63,7 @@ function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick chatId={chat.id} isCurrentRoute={isCurrentRoute} className={chipVariants({ active: isCurrentRoute || isMenuOpen, fullWidth: true })} - onContextMenu={(e) => onContextMenu(e, chat.href)} + onContextMenu={(e) => onContextMenu(e, chat.id)} >
@@ -55,7 +72,7 @@ function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick aria-hidden='true' className={cn( 'size-[6px] rounded-full transition-opacity', - isMenuOpen ? 'opacity-0' : 'group-hover:opacity-0' + isMenuOpen ? 'opacity-0' : 'group-focus-within:opacity-0 group-hover:opacity-0' )} style={{ backgroundColor: chat.isActive ? '#EAB308' : 'var(--brand-accent)' }} /> @@ -65,20 +82,21 @@ function ChatRow({ chat, isCurrentRoute, isMenuOpen, onContextMenu, onMoreClick aria-hidden='true' className={cn( 'absolute size-[12px] text-[var(--text-icon)] transition-opacity', - isMenuOpen ? 'opacity-0' : 'group-hover:opacity-0' + isMenuOpen ? 'opacity-0' : 'group-focus-within:opacity-0 group-hover:opacity-0' )} /> )}
diff --git a/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx b/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx index 9dd3e3fb56f..5d343df275e 100644 --- a/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx +++ b/apps/sim/app/o/[organizationId]/home/components/get-started/get-started.tsx @@ -1,14 +1,15 @@ 'use client' -import { useState } from 'react' +import { useEffect, useState } from 'react' import { cn, Expandable, ExpandableContent } from '@sim/emcn' import { ArrowRight, ChevronDown } from '@sim/emcn/icons' import Link from 'next/link' +import { OAUTH_SEARCH_READ_SCOPE, oauthScopeSatisfies } from '@/lib/auth/oauth-provider' import type { ResourceScope } from '@/lib/core/resource-scope' import { organizationRoutes } from '@/lib/navigation/paths' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' -import { useApiKeys } from '@/hooks/queries/api-keys' import { useSearchSourceOverview } from '@/hooks/queries/kb/connectors' +import { useAuthorizedApps } from '@/hooks/queries/oauth-provider' type StepId = 'connect-integration' | 'connect-sim-search' @@ -66,14 +67,24 @@ function StepMark({ complete }: { complete: boolean }) { * the workspace home's suggested actions: a hover-revealed disclosure header * over hairline-separated rows. Each step leads to the page that completes it, * and reads as done from the organization's real state: a source the viewer can - * search and a personal API key for the MCP server. + * search and an OAuth app authorized to use Search. */ export function GetStarted() { const { organization, viewer } = useOrganizationContext() const routes = organizationRoutes(organization.id) const scope: ResourceScope = { kind: 'organization', organizationId: organization.id } const { data: overview } = useSearchSourceOverview(scope) - const { data: apiKeys } = useApiKeys('', 'personal') + const { + data: authorizedApps, + fetchNextPage, + hasNextPage, + isFetching, + isError, + } = useAuthorizedApps('', { enabled: viewer.canUseSearchMcp }) + const hasSearchAuthorization = + authorizedApps?.pages.some((page) => + page.apps.some((app) => oauthScopeSatisfies(app.scopes, OAUTH_SEARCH_READ_SCOPE)) + ) ?? false const hrefs: Record = { 'connect-integration': viewer.isAdmin @@ -83,8 +94,9 @@ export function GetStarted() { } const completed: Record = { 'connect-integration': overview?.hasSearchableDocuments === true, - 'connect-sim-search': (apiKeys?.personalKeys.length ?? 0) > 0, + 'connect-sim-search': hasSearchAuthorization, } + const steps = STEPS.filter((step) => step.id !== 'connect-sim-search' || viewer.canUseSearchMcp) const [expanded, setExpanded] = useState(true) /** @@ -95,6 +107,25 @@ export function GetStarted() { */ const [animationsEnabled, setAnimationsEnabled] = useState(false) + useEffect(() => { + if ( + viewer.canUseSearchMcp && + !hasSearchAuthorization && + hasNextPage && + !isFetching && + !isError + ) { + void fetchNextPage() + } + }, [ + viewer.canUseSearchMcp, + hasSearchAuthorization, + hasNextPage, + isFetching, + isError, + fetchNextPage, + ]) + const handleToggleExpanded = () => { setAnimationsEnabled(true) setExpanded((prev) => !prev) @@ -135,7 +166,7 @@ export function GetStarted() { would hold its full value through the close and then vanish on unmount, snapping the content below up. */}
- {STEPS.map((step, i) => { + {steps.map((step, i) => { const complete = completed[step.id] return ( ({ context: vi.fn(), @@ -13,6 +14,8 @@ const mocks = vi.hoisted(() => ({ consume: vi.fn(), sources: vi.fn(), apiKeys: vi.fn(), + authorizedApps: vi.fn(), + fetchNextPage: vi.fn(), })) vi.mock('@/lib/auth/auth-client', () => ({ useSession: () => ({ data: { user: { id: 'reader' } } }), @@ -30,6 +33,7 @@ vi.mock('@/hooks/queries/mothership-chats', () => ({ vi.mock('@/app/o/[organizationId]/home/components/composer', () => ({ Composer: mocks.composer })) vi.mock('@/hooks/queries/kb/connectors', () => ({ useSearchSourceOverview: mocks.sources })) vi.mock('@/hooks/queries/api-keys', () => ({ useApiKeys: mocks.apiKeys })) +vi.mock('@/hooks/queries/oauth-provider', () => ({ useAuthorizedApps: mocks.authorizedApps })) vi.mock('@/app/workspace/[workspaceId]/home/components/mothership-chat', () => ({ MothershipChat: mocks.renderer, })) @@ -45,10 +49,11 @@ beforeEach(() => { mocks.context.mockReturnValue({ organization: { id: 'organization-a' }, searchAccess: { memberScoped: true }, - viewer: { isAdmin: false }, + viewer: { isAdmin: false, canUseSearchMcp: true }, }) mocks.sources.mockReturnValue({ data: { providers: [], hasSearchableDocuments: false } }) mocks.apiKeys.mockReturnValue({ data: { personalKeys: [] } }) + mockAuthorizedApps([{ apps: [], nextCursor: null }]) mocks.chat.mockReturnValue({ messages: [], isChatHistoryPending: true, sendMessage: mocks.send }) mocks.composer.mockReturnValue(
Question composer
) mocks.renderer.mockReturnValue(
Chat history
) @@ -65,6 +70,30 @@ function composerProps(): ComponentProps { return mocks.composer.mock.lastCall![0] } +function hasCompletedMcpStep() { + const link = container.querySelector('a[href="/o/organization-a/settings/search-mcp"]') + expect(link).not.toBeNull() + return link!.querySelector('span[aria-hidden="true"] svg') !== null +} + +function authorizedApp(scopes: string[], clientId = 'search-client'): AuthorizedApp { + return { clientId, name: clientId, scopes, authorizedAt: '2026-09-01T00:00:00.000Z' } +} + +function mockAuthorizedApps( + pages: AuthorizedAppsPage[], + state: { isFetching?: boolean; isError?: boolean } = {} +) { + mocks.authorizedApps.mockReturnValue({ + data: { pages }, + fetchNextPage: mocks.fetchNextPage, + hasNextPage: Boolean(pages.at(-1)?.nextCursor), + isFetching: false, + isError: false, + ...state, + }) +} + describe('organization home', () => { it.each([undefined, 'chat-a'])( 'does not mount Home or chat %s when Search is disabled', @@ -151,7 +180,7 @@ describe('organization home', () => { mocks.context.mockReturnValue({ organization: { id: 'organization-a' }, searchAccess: { memberScoped: true }, - viewer: { isAdmin }, + viewer: { isAdmin, canUseSearchMcp: true }, }) await act(async () => root.render()) expect( @@ -170,6 +199,79 @@ describe('organization home', () => { }) } ) + it('does not complete MCP onboarding for an unrelated personal API key', async () => { + mocks.apiKeys.mockReturnValue({ data: { personalKeys: [{ id: 'workflow-api-key' }] } }) + await act(async () => root.render()) + expect(hasCompletedMcpStep()).toBe(false) + expect(mocks.apiKeys).not.toHaveBeenCalled() + }) + + it('hides MCP onboarding and stops authorization paging when organization policy blocks access', async () => { + mocks.context.mockReturnValue({ + organization: { id: 'organization-a' }, + searchAccess: { memberScoped: true }, + viewer: { isAdmin: false, canUseSearchMcp: false }, + }) + mockAuthorizedApps([{ apps: [], nextCursor: 'older-apps' }]) + await act(async () => root.render()) + expect(container.textContent).not.toContain('Connect Sim Search MCP') + expect(container.textContent).toContain('Connect an integration') + expect(mocks.authorizedApps).toHaveBeenCalledWith('', { enabled: false }) + expect(mocks.fetchNextPage).not.toHaveBeenCalled() + }) + + it.each([ + { scopes: ['search:read'], completed: true }, + { scopes: ['api:read'], completed: true }, + { scopes: ['api:write'], completed: true }, + { scopes: ['offline_access'], completed: false }, + { scopes: [], completed: false }, + { scopes: ['unrecognized:read'], completed: false }, + ])('derives MCP completion from OAuth scopes $scopes', async ({ scopes, completed }) => { + mockAuthorizedApps([{ apps: [authorizedApp(scopes)], nextCursor: null }]) + await act(async () => root.render()) + expect(hasCompletedMcpStep()).toBe(completed) + }) + + it('finds a Search authorization after the first page and stops paging once found', async () => { + const firstPage = { + apps: [authorizedApp(['offline_access'], 'other-client')], + nextCursor: 'older-apps', + } + mockAuthorizedApps([firstPage]) + await act(async () => root.render()) + expect(hasCompletedMcpStep()).toBe(false) + expect(mocks.fetchNextPage).toHaveBeenCalledTimes(1) + + mockAuthorizedApps([ + firstPage, + { apps: [authorizedApp(['search:read'])], nextCursor: 'even-older-apps' }, + ]) + await act(async () => root.render()) + expect(hasCompletedMcpStep()).toBe(true) + expect(mocks.fetchNextPage).toHaveBeenCalledTimes(1) + }) + + it.each([{ isFetching: true }, { isError: true }])( + 'does not start another authorization page request while %j', + async (state) => { + mockAuthorizedApps([{ apps: [], nextCursor: 'older-apps' }], state) + await act(async () => root.render()) + expect(mocks.fetchNextPage).not.toHaveBeenCalled() + expect(hasCompletedMcpStep()).toBe(false) + } + ) + + it('clears MCP completion when the Search authorization is revoked', async () => { + mockAuthorizedApps([{ apps: [authorizedApp(['search:read'])], nextCursor: null }]) + await act(async () => root.render()) + expect(hasCompletedMcpStep()).toBe(true) + + mockAuthorizedApps([{ apps: [], nextCursor: null }]) + await act(async () => root.render()) + expect(hasCompletedMcpStep()).toBe(false) + }) + it('sends the member question as an assistant turn and clears the draft', async () => { await act(async () => root.render()) await act(async () => composerProps().onChange('Find our launch plan')) diff --git a/apps/sim/app/o/[organizationId]/home/organization-home.tsx b/apps/sim/app/o/[organizationId]/home/organization-home.tsx index 8d4ae29eb82..59d754b9117 100644 --- a/apps/sim/app/o/[organizationId]/home/organization-home.tsx +++ b/apps/sim/app/o/[organizationId]/home/organization-home.tsx @@ -6,6 +6,7 @@ import { MothershipHandoffStorage } from '@/lib/core/utils/browser-storage' import { Composer } from '@/app/o/[organizationId]/home/components/composer' import { GetStarted } from '@/app/o/[organizationId]/home/components/get-started' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection' import { MothershipChat } from '@/app/workspace/[workspaceId]/home/components/mothership-chat' import { useChat } from '@/app/workspace/[workspaceId]/home/hooks/use-chat' import { useMarkMothershipChatRead } from '@/hooks/queries/mothership-chats' @@ -79,6 +80,7 @@ function OrganizationHomeContent({ userName, chatId }: OrganizationHomeProps) {
{hasChat ? ( [ + searchSourceKeys.list({ kind: 'organization', organizationId: organization.id }), + organizationAccountsKeys.detail(organization.id), + ], + [organization.id] + ) + const connectedConnectorIds = useMemo( + () => + new Set( + sources.data + ?.filter((source) => source.viewerMembership === 'connected') + .map((source) => source.connectorId) + ), + [sources.data] + ) + const enrollment = useMemberEnrollment({ + membershipQueryKeys, + connectedConnectorIds, + directOAuth: true, + onConnectionError: toast.error, + }) + const visibleSources = + sources.data?.filter( + (source) => + source.connectionRequired && + source.enabled && + source.approved !== false && + source.availability === 'available' && + source.viewerMembership !== null && + source.viewerMembership !== 'needs_reauth' && + CONNECTABLE_MEMBERSHIPS.has(source.viewerMembership) && + searchAccess.memberScoped && + (source.accessMode === 'members' || searchAccess.sourceMirrored) + ) ?? [] + + const approvedTypes = new Set( + integrations.data + ?.filter((integration) => integration.approved) + .map((integration) => integration.connectorType) + ) + const configuredTypes = new Set( + overview.data?.providers.map((provider) => provider.connectorType) + ) + const sourceChoices = SEARCH_CONNECTORS.filter((connector) => { + if ( + connector.type === 'slack' || + !approvedTypes.has(connector.type) || + !connector.meta.name.toLowerCase().includes(search.toLowerCase()) || + (configuredTypes.has(connector.type) && connector.setupFields.length === 0) + ) + return false + return getConnectorAccessAvailability(connector.meta, availability.integrationAvailability, { + memberAccessAvailable: searchAccess.memberScoped, + mirroredAccessAvailable: searchAccess.sourceMirrored, + oauthServiceAvailability: availability.oauthServiceAvailability, + isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady, + }).members + }) + const integrationRows = [ + ...sourceChoices.map((connector) => ({ + kind: 'provider' as const, + connector, + name: connector.meta.name, + })), + ...visibleSources.map((source) => ({ + kind: 'source' as const, + source, + name: connectorDisplayName(source.connectorType), + })), + ].sort( + (a, b) => a.name.localeCompare(b.name) || (a.kind === b.kind ? 0 : a.kind === 'source' ? -1 : 1) + ) + const failedQuery = + sources.isError && !sources.isFetchNextPageError + ? sources + : overview.isError + ? overview + : integrations.isError + ? integrations + : null + + return ( + <> +
+ {failedQuery ? ( + void failedQuery.refetch()} + variant='inline' + /> + ) : availability.integrationAvailabilityError ? ( + void availability.refetchIntegrationAvailability()} + variant='inline' + /> + ) : sources.isPending || + overview.isPending || + integrations.isPending || + !availability.isIntegrationAvailabilityReady ? ( + Loading sources… + ) : visibleSources.length > 0 || sourceChoices.length > 0 || sources.hasNextPage ? ( + <> + {integrationRows.map((row) => { + if (row.kind === 'source') { + const { source } = row + return ( + enrollment.connect(source.knowledgeBaseId, source.connectorId)} + /> + ) + } + const { connector } = row + const { type, meta } = connector + const hasSources = configuredTypes.has(type) + return ( + } + title={meta.name} + description={ + hasSources + ? 'Connect a different site or content scope' + : 'Connect your account to search this source' + } + trailing={ + enrollment.connectSearchSource(scope, connector, undefined)} + > + Connect + + } + /> + ) + })} + + + ) : showEmpty ? ( + + {search ? 'No matching integrations.' : 'No integrations are available to connect.'} + + ) : null} +
+ {enrollment.setupConnector && ( + + enrollment.connectSource(scope, enrollment.setupConnector!.type, config) + } + /> + )} + + ) +} diff --git a/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx b/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx new file mode 100644 index 00000000000..279513d60a4 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.test.tsx @@ -0,0 +1,135 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ disconnect: vi.fn(), mutate: vi.fn(), reset: vi.fn() })) +vi.mock('@/hooks/queries/organization-accounts', () => ({ + useDisconnectPersonalOrganizationAccount: mocks.disconnect, +})) +vi.mock('@/app/workspace/[workspaceId]/integrations/components/integrations-showcase', () => ({ + IntegrationTile: () => null, +})) + +import type { SearchSourceSummary } from '@/lib/api/contracts/knowledge/connectors' +import { DisconnectAccountMenu } from '@/app/o/[organizationId]/integrations/disconnect-account-menu' +import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row' + +const accounts = [{ credentialId: 'my-gmail', displayName: 'me@example.test' }] +const source: SearchSourceSummary = { + knowledgeBaseId: 'kb', + connectorId: 'gmail', + connectorType: 'gmail', + sourceDescription: '', + accessMode: 'members', + availability: 'available', + enabled: true, + isSyncing: true, + lastSyncAt: null, + hasSyncError: false, + viewerDocumentCount: 0, + viewerFailedDocumentCount: 0, + viewerEmailVerified: true, + connectionRequired: true, + viewerMembership: 'connected', + viewerAccounts: accounts, +} + +describe('personal integration disconnect', () => { + let root: Root + let container: HTMLDivElement + beforeEach(() => { + vi.clearAllMocks() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + mocks.disconnect.mockReturnValue({ + mutate: mocks.mutate, + reset: mocks.reset, + isPending: false, + error: null, + }) + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + afterEach(async () => { + await act(async () => root.unmount()) + container.remove() + vi.unstubAllGlobals() + }) + async function render(overrides: Partial = {}) { + await act(async () => + root.render( + + } + /> + ) + ) + } + async function openDisconnect() { + const trigger = document.querySelector( + '[aria-label="Gmail account actions"]' + )! + await act(async () => + trigger.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + ) + const item = document.querySelector('[role="menuitem"]')! + expect(item.textContent).toBe('Disconnect') + expect(item.hasAttribute('data-disabled')).toBe(false) + await act(async () => item.click()) + } + function confirm() { + return Array.from(document.querySelectorAll('[role="dialog"] button')).find( + (button) => button.textContent === 'Disconnect' + )! + } + + it.each([ + ['indexing', {}], + ['failed', { hasSyncError: true }], + ['paused', { enabled: false }], + ['deactivated', { approved: false }], + ['reconnect', { viewerMembership: 'needs_reauth' }], + ['unavailable', { availability: 'unavailable', viewerMembership: null }], + ] as const)('allows disconnect while %s without requiring admin access', async (_, overrides) => { + await render(overrides) + await openDisconnect() + expect(document.body.textContent).toContain('Sim will stop using me@example.test for Search.') + expect(document.body.textContent).not.toContain('workflows') + expect(mocks.mutate).not.toHaveBeenCalled() + expect(confirm().disabled).toBe(false) + await act(async () => confirm().click()) + expect(mocks.mutate).toHaveBeenCalledExactlyOnceWith( + 'my-gmail', + expect.objectContaining({ onSuccess: expect.any(Function) }) + ) + }) + + it('shows a failure in the confirmation and keeps it retryable', async () => { + await render() + await openDisconnect() + mocks.disconnect.mockReturnValue({ + mutate: mocks.mutate, + reset: mocks.reset, + isPending: false, + error: new Error('Could not disconnect. Try again.'), + }) + await render() + expect(document.querySelector('[role="dialog"]')?.textContent).toContain( + 'Could not disconnect. Try again.' + ) + expect(confirm().disabled).toBe(false) + }) +}) diff --git a/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.tsx b/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.tsx new file mode 100644 index 00000000000..6d3166ff616 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.tsx @@ -0,0 +1,62 @@ +'use client' + +import { useState } from 'react' +import { ChipConfirmModal, ChipModalError } from '@sim/emcn' +import type { ViewerSearchSourceAccount } from '@/lib/api/contracts/knowledge/connectors' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' +import { useDisconnectPersonalOrganizationAccount } from '@/hooks/queries/organization-accounts' + +interface DisconnectAccountMenuProps { + organizationId: string + integrationName: string + accounts: ViewerSearchSourceAccount[] +} + +/** Disconnect is independent of provider availability, reconnect state, and indexing activity. */ +export function DisconnectAccountMenu({ + organizationId, + integrationName, + accounts, +}: DisconnectAccountMenuProps) { + const disconnect = useDisconnectPersonalOrganizationAccount(organizationId) + const [selectedId, setSelectedId] = useState(null) + const selected = accounts.find((account) => account.credentialId === selectedId) + if (!accounts.length) return null + + return ( + <> + ({ + label: accounts.length === 1 ? 'Disconnect' : `Disconnect ${account.displayName}`, + destructive: true, + disabled: disconnect.isPending, + onSelect: () => { + disconnect.reset() + setSelectedId(account.credentialId) + }, + }))} + /> + { + if (!open && !disconnect.isPending) setSelectedId(null) + }} + title={`Disconnect ${integrationName}`} + text={`Sim will stop using ${selected?.displayName ?? integrationName} for Search. You can reconnect later.`} + confirm={{ + label: 'Disconnect', + pendingLabel: 'Disconnecting…', + pending: disconnect.isPending, + disabled: disconnect.isPending, + onClick: () => { + if (!selected || disconnect.isPending) return + disconnect.mutate(selected.credentialId, { onSuccess: () => setSelectedId(null) }) + }, + }} + > + {disconnect.error?.message} + + + ) +} diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx index ecdfb02e5ba..5ffeeca2f52 100644 --- a/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/integrations.test.tsx @@ -1,8 +1,10 @@ /** @vitest-environment jsdom */ import { act, type ReactNode } from 'react' +import { toast } from '@sim/emcn' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { SearchSourceSummary } from '@/lib/api/contracts/knowledge/connectors' +import { SEARCH_CONNECTORS, type SearchConnector } from '@/lib/sim-search/connectors' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' const mocks = vi.hoisted(() => ({ @@ -15,6 +17,9 @@ const mocks = vi.hoisted(() => ({ connect: vi.fn(), availability: vi.fn(), refetchAvailability: vi.fn(), + enrollment: vi.fn(), + enrollmentError: null as string | null, + setupConnector: null as SearchConnector | null, })) vi.mock('@/app/o/[organizationId]/integrations/slack-search-actions', () => ({ @@ -57,20 +62,27 @@ vi.mock('@/hooks/queries/kb/connectors', () => ({ })) vi.mock('@/hooks/use-member-enrollment', () => ({ CONNECTABLE_MEMBERSHIPS: new Set(['invited', 'not_enrolled', 'needs_reauth']), - useMemberEnrollment: () => ({ - connect: mocks.connect, - connectSearchSource: mocks.connect, - isAwaiting: () => false, - isPending: false, - error: null, - }), + useMemberEnrollment: (options: unknown) => { + mocks.enrollment(options) + return { + connect: mocks.connect, + connectSearchSource: mocks.connect, + isAwaiting: () => false, + isPending: false, + error: mocks.enrollmentError, + setupConnector: mocks.setupConnector, + closeSetup: vi.fn(), + } + }, })) vi.mock('@/hooks/use-oauth-return', () => ({ useDesktopOAuthConnectListener: () => undefined, useOAuthReturnRouter: () => undefined, })) +import { ConnectAccountOptions } from '@/app/o/[organizationId]/integrations/connect-account-options' import { OrganizationIntegrations } from '@/app/o/[organizationId]/integrations/integrations' +import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' const scope = { kind: 'organization', organizationId: 'organization-a' } as const const memberSource: SearchSourceSummary = { @@ -106,6 +118,9 @@ describe('organization integrations role and source paths', () => { beforeEach(() => { vi.clearAllMocks() + vi.spyOn(toast, 'error').mockReturnValue('toast-id') + mocks.enrollmentError = null + mocks.setupConnector = null vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) mocks.context.mockReturnValue({ organization: { id: scope.organizationId }, @@ -115,7 +130,11 @@ describe('organization integrations role and source paths', () => { mocks.integrations.mockReturnValue({ data: [], isPending: false }) mocks.availability.mockReturnValue({ integrationAvailability: new Map(), - oauthServiceAvailability: new Map([['google-email', true]]), + oauthServiceAvailability: new Map([ + ['google-email', true], + ['confluence', true], + ['jira', true], + ]), isIntegrationAvailabilityReady: true, integrationAvailabilityError: null, isIntegrationAvailabilityFetching: false, @@ -141,12 +160,13 @@ describe('organization integrations role and source paths', () => { afterEach(async () => { await act(async () => root.unmount()) vi.useRealTimers() + vi.restoreAllMocks() container.remove() vi.unstubAllGlobals() }) async function render() { - await act(async () => root.render()) + await act(async () => root.render()) } function buttons(label: string) { @@ -155,18 +175,44 @@ describe('organization integrations role and source paths', () => { ) } + it.each([OrganizationIntegrations, ConnectAccountOptions])( + 'shows connection errors in a toast without adding inline error text in %s', + async (Component) => { + const message = 'Choose the account matching your Sim email address.' + mocks.enrollmentError = message + await act(async () => root.render()) + const options = mocks.enrollment.mock.calls[0][0] as { + onConnectionError: (message: string) => void + } + act(() => options.onConnectionError(message)) + expect(toast.error).toHaveBeenCalledExactlyOnceWith(message) + expect(container.textContent).not.toContain(message) + await act(async () => root.render()) + expect(toast.error).toHaveBeenCalledOnce() + } + ) + + it('keeps source setup fields after a failure without duplicating the toast inside the modal', async () => { + const message = 'Connection unavailable' + mocks.enrollmentError = message + mocks.setupConnector = SEARCH_CONNECTORS.find((connector) => connector.type === 'jira') ?? null + await render() + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + expect(document.body.textContent).not.toContain(message) + }) + it('uses the actual organization and only asks members to connect identity-dependent sources', async () => { await render() - expect(mocks.sources).toHaveBeenCalledWith(scope, { search: '', mine: false }) + expect(mocks.sources).toHaveBeenCalledWith(scope, { search: '' }) expect(buttons('Add source')).toHaveLength(0) expect(buttons('Manage')).toHaveLength(0) - expect(buttons('Connect account')).toHaveLength(1) - expect(document.body.textContent).toContain('4 searchable documents') - await act(async () => buttons('Connect account')[0].click()) + expect(buttons('Connect')).toHaveLength(1) + expect(document.body.textContent).not.toContain('Engineering') + await act(async () => buttons('Connect')[0].click()) expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'member-source') }) - it('keeps Slack return actions alongside the standard account and source actions', async () => { + it('keeps Slack return actions alongside personal connection controls', async () => { mocks.context.mockReturnValue({ organization: { id: scope.organizationId }, viewer: { isAdmin: true }, @@ -177,19 +223,32 @@ describe('organization integrations role and source paths', () => { ) ) - expect(document.body.textContent).toContain('Your accounts') - expect(document.body.textContent).toContain('Manage sources') + expect(document.body.textContent).not.toContain('Your accounts') + expect(document.body.textContent).not.toContain('Manage sources') expect(buttons('slack-return')).toHaveLength(1) }) - it('debounces server search while applying the selected tab immediately', async () => { + it('always requests personal connections even with an old All tab URL', async () => { vi.useFakeTimers() - await render() - mocks.filters.mockReturnValue({ tab: 'mine', search: ' drive ', setSearch: vi.fn() }) - await render() - expect(mocks.sources).toHaveBeenLastCalledWith(scope, { search: '', mine: true }) + await act(async () => root.render()) + mocks.filters.mockReturnValue({ tab: 'all', search: ' drive ', setSearch: vi.fn() }) + await act(async () => root.render()) + expect(mocks.sources).toHaveBeenCalledWith(scope, { search: '', mine: true }) await act(async () => vi.advanceTimersByTime(SEARCH_DEBOUNCE_MS)) - expect(mocks.sources).toHaveBeenLastCalledWith(scope, { search: 'drive', mine: true }) + expect(mocks.sources).toHaveBeenCalledWith(scope, { search: 'drive', mine: true }) + }) + + it('refreshes organization Accounts after either direct connection flow completes', async () => { + await act(async () => root.render()) + expect(mocks.enrollment.mock.calls.length).toBeGreaterThanOrEqual(2) + for (const [options] of mocks.enrollment.mock.calls) { + expect(options).toMatchObject({ + directOAuth: true, + membershipQueryKeys: expect.arrayContaining([ + organizationAccountsKeys.detail(scope.organizationId), + ]), + }) + } }) it('offers an approved integration before any source is configured', async () => { @@ -204,8 +263,8 @@ describe('organization integrations role and source paths', () => { }) await render() expect(document.body.textContent).toContain('Connect your account to search this source') - expect(buttons('Connect account')).toHaveLength(1) - await act(async () => buttons('Connect account')[0].click()) + expect(buttons('Connect')).toHaveLength(1) + await act(async () => buttons('Connect')[0].click()) expect(mocks.connect).toHaveBeenCalledWith( scope, expect.objectContaining({ type: 'gmail' }), @@ -231,8 +290,8 @@ describe('organization integrations role and source paths', () => { isIntegrationAvailabilityReady: true, }) await render() - expect(buttons('Add source')).toHaveLength(1) - await act(async () => buttons('Add source')[0].click()) + expect(buttons('Connect')).toHaveLength(2) + await act(async () => buttons('Connect')[1].click()) expect(mocks.connect).toHaveBeenCalledWith( scope, expect.objectContaining({ type: 'confluence' }), @@ -261,8 +320,8 @@ describe('organization integrations role and source paths', () => { isPending: false, }) await render() - expect(buttons('Connect account')).toHaveLength(0) - expect(document.body.textContent).toContain('Deactivated by an organization admin') + expect(buttons('Connect')).toHaveLength(0) + expect(document.body.textContent).not.toContain('Gmail') }) it('waits for availability before describing approved sources as needing admin setup', async () => { mocks.sources.mockReturnValue({ data: [], isPending: false }) @@ -278,7 +337,7 @@ describe('organization integrations role and source paths', () => { await render() expect(document.body.textContent).toContain('Loading sources') expect(document.body.textContent).not.toContain('An admin needs to finish source setup') - expect(buttons('Connect account')).toHaveLength(0) + expect(buttons('Connect')).toHaveLength(0) }) it('retries availability failures instead of asking an admin to finish setup', async () => { @@ -300,11 +359,11 @@ describe('organization integrations role and source paths', () => { await render() expect(document.body.textContent).toContain('Connection availability failed') expect(document.body.textContent).not.toContain('An admin needs to finish source setup') - expect(buttons('Connect account')).toHaveLength(0) + expect(buttons('Connect')).toHaveLength(0) await act(async () => buttons('Try again')[0].click()) expect(mocks.refetchAvailability).toHaveBeenCalledOnce() }) - it('asks an admin to configure Slack before members can connect an approved source', async () => { + it('hides Slack until its organization setup is ready', async () => { mocks.sources.mockReturnValue({ data: [], isPending: false }) mocks.overview.mockReturnValue({ data: { providers: [], hasSearchableDocuments: false }, @@ -315,10 +374,42 @@ describe('organization integrations role and source paths', () => { isPending: false, }) await render() - expect(buttons('Connect account')).toHaveLength(0) - expect(document.body.textContent).toContain('An admin needs to finish source setup') + expect(buttons('Connect')).toHaveLength(0) + expect(document.body.textContent).not.toContain('Slack') + expect(document.body.textContent).toContain('No integrations are available to connect.') + }) + + it('hides an approved provider when its OAuth configuration is missing', async () => { + mocks.sources.mockReturnValue({ data: [], isPending: false }) + mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) + mocks.integrations.mockReturnValue({ + data: [{ connectorType: 'gmail', approved: true }], + isPending: false, + }) + mocks.availability.mockReturnValue({ + integrationAvailability: new Map(), + oauthServiceAvailability: new Map([['google-email', false]]), + isIntegrationAvailabilityReady: true, + }) + await render() + expect(document.body.textContent).not.toContain('Gmail') + expect(buttons('Connect')).toHaveLength(0) + expect(document.body.textContent).toContain('No integrations are available to connect.') + }) + + it('offers personal Slack connection once source setup is complete', async () => { + mocks.sources.mockReturnValue({ + data: [{ ...memberSource, connectorType: 'slack', accessMode: 'admin' }], + isPending: false, + }) + await render() + expect(document.body.textContent).toContain('Slack') + expect(buttons('Connect')).toHaveLength(1) + expect(document.body.textContent).not.toContain('Finish Slack setup') + await act(async () => buttons('Connect')[0].click()) + expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'member-source') }) - it('takes admins directly to unfinished Slack indexing setup', async () => { + it('also hides unfinished Slack setup from admins on this personal surface', async () => { mocks.context.mockReturnValue({ organization: { id: scope.organizationId }, viewer: { isAdmin: true }, @@ -333,46 +424,73 @@ describe('organization integrations role and source paths', () => { await render() expect( document.querySelector('a[href="/o/organization-a/settings/integrations/providers/slack"]') - ).toHaveTextContent('Finish Slack setup') + ).toBeNull() expect(document.body.textContent).not.toContain('An admin needs to finish source setup') - expect(buttons('Connect account')).toHaveLength(0) + expect(buttons('Connect')).toHaveLength(0) }) - it('keeps personal rows consistent for admins and directs management through Sources', async () => { + it('keeps source administration off the personal page for admins', async () => { mocks.context.mockReturnValue({ organization: { id: scope.organizationId }, viewer: { isAdmin: true }, searchAccess: { memberScoped: true, sourceMirrored: true }, }) - await render() - expect( - document.querySelector( - 'a[href="/o/organization-a/settings/integrations/sources/member-source"]' - ) - ).toBeNull() - expect( - document.querySelector('a[href="/o/organization-a/settings/integrations"]') - ).toHaveTextContent('Manage sources') - expect(document.querySelector('a[href="/account/settings/connected-accounts"]')).not.toBeNull() - expect(buttons('Add source')).toHaveLength(0) - expect(buttons('Manage')).toHaveLength(0) + mocks.sources.mockReturnValue({ + data: [{ ...memberSource, viewerMembership: 'connected' }], + isPending: false, + }) + await act(async () => root.render()) + expect(document.body.textContent).not.toContain('Manage sources') + expect(document.querySelector('a[href="/account/settings/connected-accounts"]')).toBeNull() expect(document.querySelector('[aria-label$="source actions"]')).toBeNull() - expect(buttons('Connect account')).toHaveLength(1) + expect(buttons('Connect')).toHaveLength(0) }) - it('lists only the sources the viewer connected under Mine', async () => { - mocks.filters.mockReturnValue({ tab: 'mine', search: '', setSearch: vi.fn() }) + it('shows ready integrations inline and connects without an intermediate dialog', async () => { mocks.sources.mockReturnValue({ data: [], isPending: false }) - await render() + mocks.integrations.mockReturnValue({ + data: [{ connectorType: 'gmail', approved: true }], + isPending: false, + }) + mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) + await act(async () => root.render()) expect(mocks.sources).toHaveBeenCalledWith(scope, { search: '', mine: true }) - expect(document.body.textContent).toContain('You haven’t connected any sources yet.') - mocks.sources.mockReturnValue({ - data: [{ ...memberSource, viewerMembership: 'connected' }], + expect(document.body.textContent).toContain('Gmail') + expect(document.querySelector('[role="dialog"]')).toBeNull() + expect(buttons('Connect account')).toHaveLength(0) + await act(async () => buttons('Connect')[0].click()) + expect(mocks.connect).toHaveBeenCalledWith( + scope, + expect.objectContaining({ type: 'gmail' }), + undefined + ) + }) + + it('filters available providers using the same search as personal connections', async () => { + mocks.sources.mockReturnValue({ data: [], isPending: false }) + mocks.integrations.mockReturnValue({ + data: [ + { connectorType: 'gmail', approved: true }, + { connectorType: 'jira', approved: true }, + ], isPending: false, }) - await render() + mocks.overview.mockReturnValue({ data: { providers: [] }, isPending: false }) + await act(async () => root.render()) expect(document.body.textContent).toContain('Gmail') - expect(document.body.textContent).not.toContain('Engineering') + expect(document.body.textContent).not.toContain('Jira') + expect(mocks.sources).toHaveBeenCalledWith(scope, { search: 'gmail' }) + }) + + it('lets the viewer reconnect their own expired account from the main page', async () => { + mocks.sources.mockReturnValue({ + data: [{ ...memberSource, viewerMembership: 'needs_reauth' }], + isPending: false, + }) + await act(async () => root.render()) + expect(document.body.textContent).toContain('Your account needs to be reconnected') + await act(async () => buttons('Reconnect')[0].click()) + expect(mocks.connect).toHaveBeenCalledExactlyOnceWith('search-index', 'member-source') }) it('does not offer connection to an unavailable source or setup to a member with no sources', async () => { @@ -382,15 +500,15 @@ describe('organization integrations role and source paths', () => { searchAccess: { memberScoped: false, sourceMirrored: false }, }) await render() - expect(buttons('Connect account')).toHaveLength(0) - expect(document.body.textContent).toContain('Not available in this organization') + expect(buttons('Connect')).toHaveLength(0) + expect(document.body.textContent).not.toContain('Gmail') mocks.sources.mockReturnValue({ data: [], isPending: false }) mocks.overview.mockReturnValue({ data: { providers: [], hasSearchableDocuments: false }, isPending: false, }) await render() - expect(document.body.textContent).toContain('Ask an organization admin to get started') + expect(document.body.textContent).toContain('No integrations are available to connect.') expect(buttons('Add source')).toHaveLength(0) }) it('keeps sparse source pages navigable without claiming missing sources or duplicating configured providers', async () => { @@ -402,7 +520,7 @@ describe('organization integrations role and source paths', () => { }) await render() expect(buttons('Load more')).toHaveLength(1) - expect(buttons('Connect account')).toHaveLength(0) + expect(buttons('Connect')).toHaveLength(0) expect(document.body.textContent).not.toContain('hasn’t added any sources') await act(async () => buttons('Load more')[0].click()) expect(fetchNextPage).toHaveBeenCalledOnce() @@ -411,7 +529,7 @@ describe('organization integrations role and source paths', () => { it('retains loaded rows on a next-page failure and retries only that page', async () => { const fetchNextPage = vi.fn() mocks.sources.mockReturnValue({ - data: [centralSource], + data: [{ ...memberSource, sourceDescription: 'Engineering' }], isPending: false, isError: true, isFetchNextPageError: true, diff --git a/apps/sim/app/o/[organizationId]/integrations/integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx index 23c0d287e5c..da715657d45 100644 --- a/apps/sim/app/o/[organizationId]/integrations/integrations.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/integrations.tsx @@ -1,69 +1,51 @@ 'use client' import { useMemo } from 'react' -import { Chip, ChipLink } from '@sim/emcn' -import { getAccountSettingsHref } from '@/components/settings/navigation' +import { toast } from '@sim/emcn' import type { ResourceScope } from '@/lib/core/resource-scope' -import { organizationRoutes } from '@/lib/navigation/paths' -import { - connectorDisplayName, - getConnectorAccessAvailability, - SEARCH_CONNECTORS, - SEARCH_SOURCE_TYPES, -} from '@/lib/sim-search/connectors' +import type { SearchConnectionTarget } from '@/lib/knowledge/search/connection-target' +import { connectorDisplayName } from '@/lib/sim-search/connectors' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { OrganizationPage } from '@/app/o/[organizationId]/components/organization-page' import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page/use-organization-page-filters' +import { ConnectAccountOptions } from '@/app/o/[organizationId]/integrations/connect-account-options' +import { DisconnectAccountMenu } from '@/app/o/[organizationId]/integrations/disconnect-account-menu' import { SlackSearchActions } from '@/app/o/[organizationId]/integrations/slack-search-actions' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' -import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal' -import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' +import { SearchIntegrationConnection } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection' import { SearchSourcePagination } from '@/app/workspace/[workspaceId]/search/components/search-source-pagination' import { SearchSourceRow } from '@/app/workspace/[workspaceId]/search/components/search-source-row' -import { - SettingsEmptyState, - SettingsQueryErrorState, -} from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { - RESOURCE_LIST_STACK, - SettingsResourceRow, -} from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' -import { useSearchSourceOverview, useSearchSources } from '@/hooks/queries/kb/connectors' -import { useSearchIntegrations } from '@/hooks/queries/search-integrations' +import { SettingsQueryErrorState } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' +import { RESOURCE_LIST_STACK } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' +import { useSearchSources } from '@/hooks/queries/kb/connectors' +import { organizationAccountsKeys } from '@/hooks/queries/organization-accounts' import { searchSourceKeys } from '@/hooks/queries/utils/search-source-keys' import { useDebounce } from '@/hooks/use-debounce' import { useMemberEnrollment } from '@/hooks/use-member-enrollment' import { useDesktopOAuthConnectListener, useOAuthReturnRouter } from '@/hooks/use-oauth-return' -import { usePermissionConfig } from '@/hooks/use-permission-config' - -/** Every source the organization searches, or only the ones the viewer has connected. */ -const TABS = [ - { id: 'all', label: 'All' }, - { id: 'mine', label: 'Mine' }, -] as const interface OrganizationIntegrationsProps { + connectionRequest?: { target: SearchConnectionTarget; userId: string } slackOnboarding?: { token: string; userId: string } } -/** - * Personal connections and approved source scopes available to the organization. - * Administrators can open source management without leaving this journey. - */ -export function OrganizationIntegrations({ slackOnboarding }: OrganizationIntegrationsProps = {}) { +/** The viewer's Search connections and ready integrations they can connect personally. */ +export function OrganizationIntegrations({ + slackOnboarding, + connectionRequest, +}: OrganizationIntegrationsProps = {}) { useOAuthReturnRouter() useDesktopOAuthConnectListener() - const { organization, searchAccess, viewer } = useOrganizationContext() - const routes = organizationRoutes(organization.id) + const { organization, searchAccess } = useOrganizationContext() const scope: ResourceScope = { kind: 'organization', organizationId: organization.id } - const { tab, search } = useOrganizationPageFilters() + const { search } = useOrganizationPageFilters() const sourceSearch = useDebounce(search.trim(), SEARCH_DEBOUNCE_MS) - const sources = useSearchSources(scope, { search: sourceSearch, mine: tab === 'mine' }) - const overview = useSearchSourceOverview(scope) - const integrations = useSearchIntegrations(organization.id) - const availability = usePermissionConfig() + const sources = useSearchSources(scope, { search: sourceSearch, mine: true }) const membershipQueryKeys = useMemo( - () => [searchSourceKeys.list({ kind: 'organization', organizationId: organization.id })], + () => [ + searchSourceKeys.list({ kind: 'organization', organizationId: organization.id }), + organizationAccountsKeys.detail(organization.id), + ], [organization.id] ) const connectedConnectorIds = useMemo( @@ -75,190 +57,79 @@ export function OrganizationIntegrations({ slackOnboarding }: OrganizationIntegr ), [sources.data] ) - const enrollment = useMemberEnrollment({ membershipQueryKeys, connectedConnectorIds }) - const query = search.trim().toLowerCase() - const mineOnly = tab === 'mine' - const visibleSources = sources.data ?? [] - - const approvedTypes = new Set( - integrations.data - ?.filter((integration) => integration.approved) - .map((integration) => integration.connectorType) - ) - const configuredTypes = new Set( - overview.data?.providers.map((provider) => provider.connectorType) - ) - const sourceChoices = mineOnly - ? [] - : SEARCH_SOURCE_TYPES.filter( - ([type, meta]) => - approvedTypes.has(type) && - (!configuredTypes.has(type) || - SEARCH_CONNECTORS.some( - (connector) => connector.type === type && connector.setupFields.length > 0 - )) && - meta.name.toLowerCase().includes(query) - ) - const integrationRows = [ - ...sourceChoices.map(([type, meta]) => ({ - kind: 'provider' as const, - type, - meta, - name: meta.name, - })), - ...visibleSources.map((source) => ({ - kind: 'source' as const, - source, - name: connectorDisplayName(source.connectorType), - })), - ].sort( - (a, b) => a.name.localeCompare(b.name) || (a.kind === b.kind ? 0 : a.kind === 'source' ? -1 : 1) - ) - const failedQuery = - sources.isError && !sources.isFetchNextPageError - ? sources - : overview.isError - ? overview - : integrations.isError - ? integrations - : null + const enrollment = useMemberEnrollment({ + membershipQueryKeys, + connectedConnectorIds, + directOAuth: true, + onConnectionError: toast.error, + }) return ( - Your accounts - {viewer.isAdmin && ( - Manage sources - )} - {slackOnboarding && ( - - )} -
+ slackOnboarding && ( + + ) } > + {connectionRequest && ( + + )}
- {failedQuery ? ( + {sources.isError && !sources.isFetchNextPageError ? ( void failedQuery.refetch()} + error={sources.error} + fallback='Could not load your connections' + isRetrying={sources.isFetching} + onRetry={() => void sources.refetch()} variant='inline' /> - ) : availability.integrationAvailabilityError ? ( - void availability.refetchIntegrationAvailability()} - variant='inline' - /> - ) : sources.isPending || - overview.isPending || - integrations.isPending || - !availability.isIntegrationAvailabilityReady ? ( - Loading sources… - ) : visibleSources.length > 0 || sourceChoices.length > 0 || sources.hasNextPage ? ( + ) : !sources.isPending && (sources.data?.length || sources.hasNextPage) ? ( <> - {integrationRows.map((row) => { - if (row.kind === 'source') { - const { source } = row - return ( - enrollment.connect(source.knowledgeBaseId, source.connectorId)} - /> - ) - } - const { type, meta } = row - const connector = SEARCH_CONNECTORS.find((item) => item.type === type) - const access = getConnectorAccessAvailability( - meta, - availability.integrationAvailability, - { - memberAccessAvailable: searchAccess.memberScoped, - mirroredAccessAvailable: searchAccess.sourceMirrored, - oauthServiceAvailability: availability.oauthServiceAvailability, - isIntegrationAvailabilityReady: availability.isIntegrationAvailabilityReady, + {sources.data?.map((source) => ( + + ) : undefined } - ) - const canConnect = connector && type !== 'slack' && access.members - const hasSources = configuredTypes.has(type) - if (hasSources && !canConnect) return null - return ( - } - title={hasSources ? `Add another ${meta.name} source` : meta.name} - description={ - hasSources - ? 'Connect a different site or content scope' - : canConnect - ? 'Connect your account to search this source' - : type === 'slack' && viewer.isAdmin - ? 'Finish setting up Slack indexing to connect accounts' - : 'An admin needs to finish source setup' - } - trailing={ - canConnect ? ( - enrollment.connectSearchSource(scope, connector, undefined)} - > - {hasSources ? 'Add source' : 'Connect account'} - - ) : type === 'slack' && viewer.isAdmin ? ( - Finish Slack setup - ) : undefined - } - /> - ) - })} + available={ + source.accessMode === 'members' + ? searchAccess.memberScoped + : searchAccess.sourceMirrored && + (!source.connectionRequired || searchAccess.memberScoped) + } + waiting={enrollment.isAwaiting(source.connectorId)} + isPending={enrollment.isPending} + onConnect={() => enrollment.connect(source.knowledgeBaseId, source.connectorId)} + /> + ))} - ) : ( - - {query - ? 'No matching sources.' - : mineOnly - ? 'You haven’t connected any sources yet.' - : viewer.isAdmin - ? 'Your organization hasn’t added any sources yet. Open Manage sources to get started.' - : 'Your organization hasn’t added any sources yet. Ask an organization admin to get started.'} - - )} - {enrollment.error && ( -

{enrollment.error}

- )} + ) : null}
- {enrollment.setupConnector && ( - - enrollment.connectSource(scope, enrollment.setupConnector!.type, config) - } - /> - )} + ) } diff --git a/apps/sim/app/o/[organizationId]/integrations/page.test.tsx b/apps/sim/app/o/[organizationId]/integrations/page.test.tsx index 810ccffb7fe..ff1d143b490 100644 --- a/apps/sim/app/o/[organizationId]/integrations/page.test.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/page.test.tsx @@ -32,6 +32,41 @@ beforeEach(() => { }) describe('integrations page Slack context', () => { + it('preserves a requested connection across login and validates it in the existing organization page', async () => { + const selected = { + ...props, + searchParams: Promise.resolve({ + connectorType: 'gmail', + connectorId: 'source', + credentialId: 'account', + }), + } + const page = await OrganizationIntegrationsPage(selected) + expect(page.props.connectionRequest).toMatchObject({ + userId: 'viewer', + target: { + type: 'link', + connectorType: 'gmail', + connectorId: 'source', + credentialId: 'account', + }, + }) + authMockFns.mockGetSession.mockResolvedValue(null) + await expect(OrganizationIntegrationsPage(selected)).rejects.toThrow('Redirect') + expect(mocks.redirect).toHaveBeenCalledWith( + `/login?callbackUrl=${encodeURIComponent('/o/organization-a/integrations?connectorType=gmail&connectorId=source&credentialId=account')}` + ) + }) + it('rejects unknown providers and reconnects without a source', async () => { + for (const query of [ + { connectorType: 'invented' }, + { connectorType: 'gmail', credentialId: 'account' }, + ]) { + await expect( + OrganizationIntegrationsPage({ ...props, searchParams: Promise.resolve(query) }) + ).rejects.toThrow('Not found') + } + }) it('preserves the source page and Slack question context through login', async () => { authMockFns.mockGetSession.mockResolvedValue(null) await expect(OrganizationIntegrationsPage(props)).rejects.toThrow('Redirect') diff --git a/apps/sim/app/o/[organizationId]/integrations/page.tsx b/apps/sim/app/o/[organizationId]/integrations/page.tsx index 23d81a29386..e4bc2ddcf87 100644 --- a/apps/sim/app/o/[organizationId]/integrations/page.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/page.tsx @@ -2,11 +2,17 @@ import type { Metadata } from 'next' import { notFound, redirect } from 'next/navigation' import { slackSearchOnboardingInputSchema } from '@/lib/api/contracts/knowledge/slack' import { getSession } from '@/lib/auth' +import { + searchConnectionPath, + searchConnectionTargetSchema, +} from '@/lib/knowledge/search/connection-target' import { organizationRoutes } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' +import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors' import { slackSearchIntegrationsPath } from '@/lib/slack-search/onboarding' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' import { OrganizationIntegrations } from '@/app/o/[organizationId]/integrations/integrations' +import { loadIntegrationConnectionParams } from '@/app/o/[organizationId]/integrations/search-params' export const metadata: Metadata = { title: 'Integrations', @@ -15,7 +21,12 @@ export const metadata: Metadata = { interface OrganizationIntegrationsPageProps { params: Promise<{ organizationId: string }> - searchParams: Promise<{ slack?: string | string[] }> + searchParams: Promise<{ + slack?: string | string[] + connectorType?: string | string[] + connectorId?: string | string[] + credentialId?: string | string[] + }> } export default async function OrganizationIntegrationsPage({ @@ -23,7 +34,22 @@ export default async function OrganizationIntegrationsPage({ searchParams, }: OrganizationIntegrationsPageProps) { const { organizationId } = await params - const { slack } = await searchParams + const query = await searchParams + const { slack } = query + const selection = loadIntegrationConnectionParams(query) + const connector = SEARCH_CONNECTORS.find((entry) => entry.type === selection.connectorType) + const requested = + selection.connectorType || selection.connectorId || selection.credentialId + ? searchConnectionTargetSchema.safeParse({ + type: 'link', + provider: connector?.providerId, + connectorType: selection.connectorType, + ...(selection.connectorId ? { connectorId: selection.connectorId } : {}), + ...(selection.credentialId ? { credentialId: selection.credentialId } : {}), + }) + : undefined + if (requested && !requested.success) notFound() + const connectionTarget = requested?.data const context = slack === undefined ? undefined : slackSearchOnboardingInputSchema.safeParse({ token: slack }) if (context && !context.success) notFound() @@ -32,9 +58,11 @@ export default async function OrganizationIntegrationsPage({ if (!session?.user) redirect( buildAuthCrossLink('/login', { - callbackUrl: slackToken - ? slackSearchIntegrationsPath(organizationId, slackToken) - : organizationRoutes(organizationId).integrations, + callbackUrl: connectionTarget + ? searchConnectionPath(organizationId, connectionTarget) + : slackToken + ? slackSearchIntegrationsPath(organizationId, slackToken) + : organizationRoutes(organizationId).integrations, isInviteFlow: false, }) ) @@ -42,6 +70,9 @@ export default async function OrganizationIntegrationsPage({ if (!organizationContext?.searchAccess.memberScoped) notFound() return ( ) diff --git a/apps/sim/app/o/[organizationId]/integrations/search-params.ts b/apps/sim/app/o/[organizationId]/integrations/search-params.ts new file mode 100644 index 00000000000..55ccd0e2a41 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/integrations/search-params.ts @@ -0,0 +1,9 @@ +import { createLoader, parseAsString } from 'nuqs/server' + +export const integrationConnectionParams = { + connectorType: parseAsString.withDefault(''), + connectorId: parseAsString.withDefault(''), + credentialId: parseAsString.withDefault(''), +} + +export const loadIntegrationConnectionParams = createLoader(integrationConnectionParams) diff --git a/apps/sim/app/o/[organizationId]/layout.test.tsx b/apps/sim/app/o/[organizationId]/layout.test.tsx index 331473db5b0..bcb6145d12f 100644 --- a/apps/sim/app/o/[organizationId]/layout.test.tsx +++ b/apps/sim/app/o/[organizationId]/layout.test.tsx @@ -4,18 +4,19 @@ import type { ReactNode } from 'react' import { authMockFns } from '@sim/testing' +import { dehydrate } from '@tanstack/react-query' import { renderToStaticMarkup } from 'react-dom/server' import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetOrganizationSurfaceContext, mockWorkspaceChrome, - mockPrefetchUserProfile, + mockPrefetchOrganizationSidebar, mockUseSession, } = vi.hoisted(() => ({ mockGetOrganizationSurfaceContext: vi.fn(), mockWorkspaceChrome: vi.fn(({ children }: { children: ReactNode }) => children), - mockPrefetchUserProfile: vi.fn(async () => undefined), + mockPrefetchOrganizationSidebar: vi.fn(async () => undefined), mockUseSession: vi.fn(), })) @@ -30,15 +31,15 @@ vi.mock('@/lib/auth/stale-session-recovery', () => ({ vi.mock('@tanstack/react-query', () => ({ HydrationBoundary: ({ children }: { children: ReactNode }) => children, - dehydrate: () => ({}), + dehydrate: vi.fn(() => ({})), })) vi.mock('@/app/_shell/providers/get-query-client', () => ({ getQueryClient: () => ({}), })) -vi.mock('@/lib/users/prefetch-user-profile', () => ({ - prefetchUserProfile: mockPrefetchUserProfile, +vi.mock('@/app/o/[organizationId]/prefetch', () => ({ + prefetchOrganizationSidebar: mockPrefetchOrganizationSidebar, })) vi.mock('next/headers', () => ({ @@ -80,7 +81,10 @@ const SURFACE_CONTEXT = { describe('OrganizationLayout', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: 'viewer-1' } }) + mockGetSession.mockResolvedValue({ + user: { id: 'viewer-1' }, + session: { id: 'session-1', activeOrganizationId: 'active-org' }, + }) mockUseSession.mockReturnValue({ data: { user: { id: 'viewer-1' } }, isPending: false }) }) @@ -94,6 +98,7 @@ describe('OrganizationLayout', () => { }) ).rejects.toThrow('redirect:/login?callbackUrl=%2Fo%2Forg-1') expect(mockGetOrganizationSurfaceContext).not.toHaveBeenCalled() + expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled() }) it('renders the surface for a member and seeds the chrome from the collapse cookie', async () => { @@ -106,7 +111,12 @@ describe('OrganizationLayout', () => { const html = renderToStaticMarkup(element) expect(mockGetOrganizationSurfaceContext).toHaveBeenCalledWith('org-1', 'viewer-1') - expect(mockPrefetchUserProfile).toHaveBeenCalledWith({}, 'viewer-1') + expect(mockPrefetchOrganizationSidebar).toHaveBeenCalledWith( + {}, + 'org-1', + { kind: 'session', userId: 'viewer-1', sessionId: 'session-1' }, + 'active-org' + ) expect(html).toContain('Organization child') expect(html).not.toContain('Stop impersonating') expect(mockWorkspaceChrome).toHaveBeenCalledWith( @@ -118,7 +128,7 @@ describe('OrganizationLayout', () => { it('shows the shared impersonation banner above organization content', async () => { const session = { user: { id: 'viewer-1', name: 'QA Member', email: 'member@example.com' }, - session: { impersonatedBy: 'platform-admin' }, + session: { id: 'session-1', impersonatedBy: 'platform-admin' }, } mockGetSession.mockResolvedValue(session) mockUseSession.mockReturnValue({ data: session, isPending: false }) @@ -132,6 +142,12 @@ describe('OrganizationLayout', () => { ) expect(mockGetOrganizationSurfaceContext).toHaveBeenCalledWith('org-1', 'viewer-1') + expect(mockPrefetchOrganizationSidebar).toHaveBeenCalledWith( + {}, + 'org-1', + { kind: 'session', userId: 'viewer-1', sessionId: 'session-1' }, + null + ) expect(html).toContain('Impersonating QA Member (member@example.com)') expect(html).toContain('Stop impersonating') expect(html.indexOf('Stop impersonating')).toBeLessThan(html.indexOf('Organization child')) @@ -140,7 +156,7 @@ describe('OrganizationLayout', () => { it('does not use the impersonating admin to enter an organization outside the rollout', async () => { mockGetSession.mockResolvedValue({ user: { id: 'customer-member' }, - session: { impersonatedBy: 'platform-admin' }, + session: { id: 'session-1', impersonatedBy: 'platform-admin' }, }) mockGetOrganizationSurfaceContext.mockResolvedValue({ ...SURFACE_CONTEXT, @@ -158,6 +174,7 @@ describe('OrganizationLayout', () => { 'customer-member' ) expect(mockWorkspaceChrome).not.toHaveBeenCalled() + expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled() }) it('renders an explicit denial for a non-member without the surface', async () => { @@ -172,6 +189,7 @@ describe('OrganizationLayout', () => { expect(html).toContain('Organization access denied') expect(html).not.toContain('Secret organization child') expect(mockWorkspaceChrome).not.toHaveBeenCalled() + expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled() }) it.each(['owner', 'admin', 'member'])( @@ -190,6 +208,24 @@ describe('OrganizationLayout', () => { }) ).rejects.toThrow('redirect:/workspace?redirect=settings') expect(mockWorkspaceChrome).not.toHaveBeenCalled() + expect(mockPrefetchOrganizationSidebar).not.toHaveBeenCalled() } ) + + it('waits for sidebar reads before serializing hydration', async () => { + const ready = Promise.withResolvers() + mockGetOrganizationSurfaceContext.mockResolvedValue(SURFACE_CONTEXT) + mockPrefetchOrganizationSidebar.mockReturnValue(ready.promise) + const pending = OrganizationLayout({ + children: null, + params: Promise.resolve({ organizationId: 'org-1' }), + }) + await vi.waitFor(() => expect(mockPrefetchOrganizationSidebar).toHaveBeenCalledOnce(), { + interval: 1, + }) + expect(dehydrate).not.toHaveBeenCalled() + ready.resolve() + await pending + expect(dehydrate).toHaveBeenCalledOnce() + }) }) diff --git a/apps/sim/app/o/[organizationId]/layout.tsx b/apps/sim/app/o/[organizationId]/layout.tsx index 04f5b1129b3..fb4b66e85bd 100644 --- a/apps/sim/app/o/[organizationId]/layout.tsx +++ b/apps/sim/app/o/[organizationId]/layout.tsx @@ -2,13 +2,14 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { getSession } from '@/lib/auth' +import { getActiveOrganizationId } from '@/lib/auth/session-response' import { organizationRoutes, WORKSPACE_SETTINGS_PATH } from '@/lib/navigation/paths' import { getOrganizationSurfaceContext } from '@/lib/organizations/surface' -import { prefetchUserProfile } from '@/lib/users/prefetch-user-profile' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { buildAuthCrossLink } from '@/app/(auth)/auth-redirect' import { OrganizationAccessDenied } from '@/app/o/[organizationId]/components/organization-access-denied' import { OrganizationSidebar } from '@/app/o/[organizationId]/components/organization-sidebar' +import { prefetchOrganizationSidebar } from '@/app/o/[organizationId]/prefetch' import { OrganizationProvider } from '@/app/o/[organizationId]/providers/organization-provider' import { ImpersonationBanner } from '@/app/workspace/[workspaceId]/components/impersonation-banner' import { SessionExpired } from '@/app/workspace/[workspaceId]/components/session-expired' @@ -43,16 +44,19 @@ export default async function OrganizationLayout({ const [context, cookieStore] = await Promise.all([ getOrganizationSurfaceContext(organizationId, session.user.id), cookies(), - /* The rail's footer renders the viewer, so the profile is layout data: seeded - here it paints hydrated, and a page hydrating the same key beneath finds it - populated rather than an empty query it cannot fill during render. */ - prefetchUserProfile(queryClient, session.user.id), ]) if (!context) { return } if (!context.searchAccess.memberScoped) redirect(WORKSPACE_SETTINGS_PATH) + await prefetchOrganizationSidebar( + queryClient, + organizationId, + { kind: 'session', userId: session.user.id, sessionId: session.session.id }, + getActiveOrganizationId(session) + ) + const initialSidebarCollapsed = cookieStore.get('sidebar_collapsed')?.value === '1' return ( diff --git a/apps/sim/app/o/[organizationId]/prefetch.test.ts b/apps/sim/app/o/[organizationId]/prefetch.test.ts new file mode 100644 index 00000000000..679b8c88e24 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/prefetch.test.ts @@ -0,0 +1,180 @@ +/** + * @vitest-environment node + */ +import type { SessionPrincipal } from '@sim/auth/principal' +import { dehydrate, hydrate, QueryClient, QueryObserver } from '@tanstack/react-query' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockListOrganizationChats, mockListWorkspacesForViewer, mockGetUserProfile } = vi.hoisted( + () => ({ + mockListOrganizationChats: vi.fn(), + mockListWorkspacesForViewer: vi.fn(), + mockGetUserProfile: vi.fn(), + }) +) + +vi.mock('@/lib/copilot/chat/organization-chats', () => ({ + listOrganizationChats: { execute: mockListOrganizationChats }, +})) +vi.mock('@/lib/workspaces/list', () => ({ + listWorkspacesForViewer: mockListWorkspacesForViewer, +})) +vi.mock('@/lib/users/queries', () => ({ getUserProfile: mockGetUserProfile })) +vi.mock('@sim/emcn', () => ({ toast: { success: vi.fn(), error: vi.fn() } })) + +import { prefetchOrganizationSidebar } from '@/app/o/[organizationId]/prefetch' +import { userProfileKeys } from '@/hooks/queries/current-user-data' +import { + MOTHERSHIP_CHAT_LIST_STALE_TIME, + mothershipChatKeys, +} from '@/hooks/queries/mothership-chats' +import { workspaceKeys } from '@/hooks/queries/workspace' + +const PRINCIPAL: SessionPrincipal = { kind: 'session', userId: 'viewer', sessionId: 'session' } +const CHAT = { + id: 'chat', + title: 'Project notes', + updatedAt: '2026-01-02T00:00:00.000Z', + activeStreamId: null, + lastSeenAt: '2026-01-01T00:00:00.000Z', + pinned: true, + deletedAt: null, +} +const WORKSPACES = { + workspaces: [ + { + id: 'workspace', + name: 'Engineering', + ownerId: 'viewer', + organizationId: 'route-org', + workspaceMode: 'organization', + permissions: 'read', + }, + ], + lastActiveWorkspaceId: 'workspace', + pinnedWorkspaceIds: ['workspace'], + creationPolicy: null, +} +const CHAT_KEY = mothershipChatKeys.organizationList('route-org', 'active') + +function makeClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) +} + +function prefetch(client: QueryClient) { + return prefetchOrganizationSidebar(client, 'route-org', PRINCIPAL, 'active-org') +} + +describe('organization sidebar hydration', () => { + beforeEach(() => { + vi.clearAllMocks() + mockListOrganizationChats.mockResolvedValue([CHAT]) + mockListWorkspacesForViewer.mockResolvedValue(WORKSPACES) + mockGetUserProfile.mockResolvedValue({ id: 'viewer', name: 'Ada', email: 'ada@example.test' }) + }) + + it('hydrates the current viewer’s routed org chats and keeps workspace metadata intact', async () => { + const server = makeClient() + await prefetch(server) + const client = makeClient() + hydrate(client, dehydrate(server)) + + expect(mockListOrganizationChats).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { organizationId: 'route-org', scope: 'active' }, + }) + expect(mockListWorkspacesForViewer).toHaveBeenCalledWith({ + userId: 'viewer', + activeOrganizationId: 'active-org', + scope: 'active', + }) + expect(client.getQueryData(CHAT_KEY)).toEqual([ + { + id: 'chat', + name: 'Project notes', + updatedAt: new Date(CHAT.updatedAt), + isActive: false, + isUnread: true, + isPinned: true, + deletedAt: null, + }, + ]) + expect(client.getQueryData(workspaceKeys.list('active'))).toMatchObject(WORKSPACES) + expect(client.getQueryData(userProfileKeys.profile())).toMatchObject({ name: 'Ada' }) + expect(client.getQueryData(mothershipChatKeys.organizationList('active-org'))).toBeUndefined() + expect(client.getQueryData(mothershipChatKeys.list('workspace'))).toBeUndefined() + expect( + client.getQueryData(mothershipChatKeys.organizationList('route-org', 'archived')) + ).toBeUndefined() + }) + + it('starts the independent reads together and waits for all before dehydration', async () => { + const chats = Promise.withResolvers<(typeof CHAT)[]>() + const workspaces = Promise.withResolvers() + mockListOrganizationChats.mockReturnValue(chats.promise) + mockListWorkspacesForViewer.mockReturnValue(workspaces.promise) + const client = makeClient() + let finished = false + const pending = prefetch(client).then(() => { + finished = true + }) + + expect(mockListOrganizationChats).toHaveBeenCalledOnce() + expect(mockListWorkspacesForViewer).toHaveBeenCalledOnce() + expect(mockGetUserProfile).toHaveBeenCalledOnce() + expect(dehydrate(client).queries).toHaveLength(0) + expect(finished).toBe(false) + chats.resolve([CHAT]) + await chats.promise + expect(finished).toBe(false) + workspaces.resolve(WORKSPACES) + await pending + expect(dehydrate(client).queries).toHaveLength(3) + }) + + it('caches an empty chat list but leaves empty workspaces for the client creation path', async () => { + mockListOrganizationChats.mockResolvedValue([]) + mockListWorkspacesForViewer.mockResolvedValue({ ...WORKSPACES, workspaces: [] }) + const client = makeClient() + await prefetch(client) + expect(client.getQueryData(CHAT_KEY)).toEqual([]) + expect(client.getQueryState(workspaceKeys.list('active'))).toBeUndefined() + }) + + it('omits a denied chat read from hydration without losing successful sidebar reads', async () => { + mockListOrganizationChats.mockRejectedValue(new Error('Forbidden')) + const server = makeClient() + await expect(prefetch(server)).resolves.toBeUndefined() + const client = makeClient() + hydrate(client, dehydrate(server)) + expect(client.getQueryState(CHAT_KEY)).toBeUndefined() + expect(client.getQueryData(workspaceKeys.list('active'))).toMatchObject(WORKSPACES) + expect(mockListOrganizationChats).toHaveBeenCalledOnce() + }) + + it('does not suppress client recovery when the workspace read fails', async () => { + mockListWorkspacesForViewer.mockRejectedValue(new Error('Unavailable')) + const client = makeClient() + await expect(prefetch(client)).resolves.toBeUndefined() + expect(client.getQueryState(workspaceKeys.list('active'))).toBeUndefined() + expect(client.getQueryData(CHAT_KEY)).toHaveLength(1) + }) + + it('does not fetch chats again when a fresh hydrated observer mounts', async () => { + const server = makeClient() + await prefetch(server) + const client = makeClient() + hydrate(client, dehydrate(server)) + const fetchChats = vi.fn().mockResolvedValue([]) + const observer = new QueryObserver(client, { + queryKey: CHAT_KEY, + queryFn: fetchChats, + staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, + }) + const unsubscribe = observer.subscribe(() => {}) + expect(observer.getCurrentResult().isPending).toBe(false) + expect(observer.getCurrentResult().data).toHaveLength(1) + expect(fetchChats).not.toHaveBeenCalled() + unsubscribe() + }) +}) diff --git a/apps/sim/app/o/[organizationId]/prefetch.ts b/apps/sim/app/o/[organizationId]/prefetch.ts new file mode 100644 index 00000000000..0ed2be1f62f --- /dev/null +++ b/apps/sim/app/o/[organizationId]/prefetch.ts @@ -0,0 +1,38 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import type { QueryClient } from '@tanstack/react-query' +import { listOrganizationChats } from '@/lib/copilot/chat/organization-chats' +import { prefetchUserProfile } from '@/lib/users/prefetch-user-profile' +import { seedWorkspaceList } from '@/lib/workspaces/seed-workspace-list' +import { + MOTHERSHIP_CHAT_LIST_STALE_TIME, + mapChat, + mothershipChatKeys, +} from '@/hooks/queries/mothership-chats' + +/** + * Settles the org sidebar's reads before hydration, using the client keys and + * mappers. Chat access goes through the same authorized operation as the API; + * failed reads stay out of hydration so the client can retry them. + */ +export async function prefetchOrganizationSidebar( + queryClient: QueryClient, + organizationId: string, + principal: SessionPrincipal, + activeOrganizationId: string | null +): Promise { + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: mothershipChatKeys.organizationList(organizationId, 'active'), + queryFn: async () => { + const chats = await listOrganizationChats.execute({ + principal, + input: { organizationId, scope: 'active' }, + }) + return chats.map(mapChat) + }, + staleTime: MOTHERSHIP_CHAT_LIST_STALE_TIME, + }), + seedWorkspaceList(queryClient, principal.userId, activeOrganizationId), + prefetchUserProfile(queryClient, principal.userId), + ]) +} diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx index 9ea2460bd81..952880c34ee 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.test.tsx @@ -11,6 +11,10 @@ const mocks = vi.hoisted(() => ({ people: vi.fn(), invite: vi.fn(), refetch: vi.fn(), + update: vi.fn(), + updatePending: false, + updateError: null as Error | null, + resetUpdate: vi.fn(), })) vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ useOrganizationContext: mocks.context, @@ -21,6 +25,12 @@ vi.mock( ) vi.mock('@/hooks/queries/organization-accounts', () => ({ useOrganizationAccounts: mocks.accounts, + useUpdateOrganizationAccounts: () => ({ + mutate: mocks.update, + isPending: mocks.updatePending, + error: mocks.updateError, + reset: mocks.resetUpdate, + }), useOrganizationAccountPeople: mocks.people, useInviteOrganizationAccountPeople: () => ({ mutateAsync: mocks.invite, reset: vi.fn() }), useResendOrganizationAccountInvitation: () => ({}), @@ -37,10 +47,13 @@ describe('organization integration invitations', () => { beforeEach(() => { vi.clearAllMocks() vi.spyOn(toast, 'success').mockReturnValue('toast-id') + vi.spyOn(toast, 'error').mockReturnValue('toast-id') + mocks.updatePending = false + mocks.updateError = null vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) mocks.context.mockReturnValue({ organization: { id: 'org-a' }, viewer: { isAdmin: true } }) mocks.accounts.mockReturnValue({ - data: { credentialGroup: { id: 'group-a' } }, + data: { credentialGroup: { id: 'group-a', options: [] } }, error: null, refetch: mocks.refetch, }) @@ -80,7 +93,7 @@ describe('organization integration invitations', () => { function findButton(label: string) { const button = Array.from(document.querySelectorAll('button')).find( - (element) => element.textContent === label + (element) => element.textContent === label || element.getAttribute('aria-label') === label ) if (!button) throw new Error(`Missing ${label} button`) return button @@ -90,10 +103,23 @@ describe('organization integration invitations', () => { await act(async () => findButton(label).click()) } + async function openRefresh() { + expect(container.textContent).not.toContain('Update configurations') + await act(async () => + findButton('More source actions').dispatchEvent( + new MouseEvent('pointerdown', { bubbles: true, button: 0 }) + ) + ) + const item = document.querySelector('[role="menuitem"]') + expect(item?.textContent).toBe('Refresh connection settings') + await act(async () => item?.click()) + expect(document.body.textContent).toContain('Affected accounts will need to reconnect.') + } + it('keeps provider setup as the default and sends manual invitations from People to this org', async () => { await render() expect(container.textContent).toContain('Provider setup') - expect(mocks.accounts).toHaveBeenLastCalledWith(undefined) + expect(mocks.accounts).toHaveBeenLastCalledWith('org-a') expect(mocks.people).not.toHaveBeenCalled() await click('People') @@ -119,6 +145,87 @@ describe('organization integration invitations', () => { expect(document.querySelector('[role="dialog"]')).toBeNull() }) + it('refreshes saved provider identities only after choosing the maintenance action and confirming', async () => { + mocks.accounts.mockReturnValue({ + data: { + credentialGroup: { + id: 'group-a', + options: [ + { + id: 'github-option', + provider: 'github-repositories', + label: 'Engineering', + required: true, + }, + { + id: 'slack-option', + provider: 'slack', + label: 'Slack', + required: false, + slackBotCredentialId: 'slack-bot', + requiredScopes: ['search:read'], + }, + ], + }, + }, + error: null, + }) + mocks.update.mockImplementationOnce((_input, { onSuccess }) => onSuccess()) + await render() + await openRefresh() + await click('Refresh') + expect(mocks.update).toHaveBeenCalledWith( + { + organizationId: 'org-a', + groupId: 'group-a', + update: { + options: [ + { + id: 'github-option', + provider: 'github-repositories', + label: 'Engineering', + required: true, + }, + { + id: 'slack-option', + provider: 'slack', + label: 'Slack', + required: false, + slackBotCredentialId: 'slack-bot', + }, + ], + }, + }, + expect.any(Object) + ) + expect(toast.success).toHaveBeenCalledWith('Connection settings refreshed') + + expect(document.querySelector('[role="dialog"]')).toBeNull() + }) + + it('keeps failed refreshes open for retry and blocks duplicate submissions', async () => { + mocks.accounts.mockReturnValue({ + data: { credentialGroup: { id: 'group-a', options: [{ provider: 'gmail' }] } }, + error: null, + }) + await render() + await openRefresh() + await click('Refresh') + mocks.updateError = new Error('Update denied') + await render() + expect(document.body.textContent).toContain('Update denied') + expect(document.querySelector('[role="dialog"]')).not.toBeNull() + mocks.updatePending = true + await render() + expect(findButton('Refresh')).toBeDisabled() + expect(mocks.update).toHaveBeenCalledOnce() + }) + + it('does not offer maintenance without saved providers', async () => { + await render() + expect(container.querySelector('[aria-label="More source actions"]')).toBeNull() + }) + it('opens People directly from the saved URL', async () => { await render('?tab=people') expect(container.textContent).toContain('Request connections') @@ -137,7 +244,10 @@ describe('organization integration invitations', () => { await click('Request connections') expect(document.querySelector('[role="dialog"]')).toBeNull() - mocks.accounts.mockReturnValue({ data: { credentialGroup: { id: 'group-a' } }, error: null }) + mocks.accounts.mockReturnValue({ + data: { credentialGroup: { id: 'group-a', options: [] } }, + error: null, + }) await render('?tab=people') expect(container.textContent).not.toContain('Loading connected accounts') expect(findButton('Request connections')).not.toBeDisabled() diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx index 4634cb0e4af..5a88385585a 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-settings.tsx @@ -1,16 +1,22 @@ 'use client' -import { Chip, ChipSwitch } from '@sim/emcn' +import { useState } from 'react' +import { Chip, ChipConfirmModal, ChipModalError, ChipSwitch, toast } from '@sim/emcn' import { useQueryState } from 'nuqs' +import { getOrganizationAccountUpdateOptions } from '@/lib/credential-groups/organization-account-options' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { OrganizationIntegrationsSetup } from '@/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup' import { organizationIntegrationsTabParam } from '@/app/o/[organizationId]/settings/components/integrations/search-params' +import { RowActionsMenu } from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' import { SettingsEmptyState, SettingsQueryErrorState, } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' import { OrganizationAccountPeople } from '@/ee/credential-groups/components/organization-account-people' -import { useOrganizationAccounts } from '@/hooks/queries/organization-accounts' +import { + useOrganizationAccounts, + useUpdateOrganizationAccounts, +} from '@/hooks/queries/organization-accounts' export function OrganizationIntegrationsSettings() { const { organization, viewer } = useOrganizationContext() @@ -18,9 +24,26 @@ export function OrganizationIntegrationsSettings() { organizationIntegrationsTabParam.key, organizationIntegrationsTabParam.parser ) - const accounts = useOrganizationAccounts( - viewer.isAdmin && tab === 'people' ? organization.id : undefined - ) + const accounts = useOrganizationAccounts(viewer.isAdmin ? organization.id : undefined) + const update = useUpdateOrganizationAccounts() + const [refreshOpen, setRefreshOpen] = useState(false) + const group = accounts.data?.credentialGroup + const refreshConnections = () => { + if (!group || update.isPending) return + update.mutate( + { + organizationId: organization.id, + groupId: group.id, + update: { options: getOrganizationAccountUpdateOptions(group) }, + }, + { + onSuccess: () => { + setRefreshOpen(false) + toast.success('Connection settings refreshed') + }, + } + ) + } if (!viewer.isAdmin) return null return ( @@ -35,10 +58,33 @@ export function OrganizationIntegrationsSettings() { { value: 'people', label: 'People' }, ]} /> - {tab === 'providers' && ( - Allowed in Sim Search + {tab === 'providers' && !accounts.error && group && group.options.length > 0 && ( + { + update.reset() + setRefreshOpen(true) + }, + }, + ]} + /> )}
+ { + if (!update.isPending) setRefreshOpen(open) + }} + title='Refresh connection settings?' + text='Apply the latest sign-in settings to all integrations. Affected accounts will need to reconnect.' + confirm={{ label: 'Refresh', pending: update.isPending, onClick: refreshConnections }} + > + {update.error?.message} + {tab === 'providers' && } {tab === 'people' && ( { + it('uses Sources terminology in search and its empty state', async () => { + await render('?search=not-a-real-source') + expect(container.querySelector('input[placeholder="Search sources..."]')).toHaveValue( + 'not-a-real-source' + ) + expect(container.textContent).toContain('No matching sources') + expect(container.textContent).not.toContain('No matching integrations') + }) + it('offers Drive account management before anyone has connected', async () => { + mocks.overview.mockReturnValue({ + data: { + providers: [ + { + connectorType: 'google_drive', + approved: true, + sourceCount: 0, + status: 'waiting_for_connections', + issue: null, + isSyncing: false, + }, + ], + }, + isPending: false, + }) + await render() + expect(document.querySelector('a[aria-label="Manage Google Drive"]')).toHaveAttribute( + 'href', + '/o/org-one/settings/integrations/providers/google_drive' + ) + expect(document.querySelector('a[aria-label="Set up Google Drive"]')).toBeNull() + expect(container.textContent).toContain('Waiting for connections') + }) it('shows the stable catalog with switches and separate setup and management links', async () => { await render() - expect(document.querySelector('a[aria-label="Set up Gmail"]')).toHaveAttribute( + expect(document.querySelector('a[aria-label="Manage Gmail"]')).toHaveAttribute( 'href', '/o/org-one/settings/integrations/providers/gmail' ) @@ -137,8 +171,9 @@ describe('organization integration management entry', () => { 'href', '/o/org-one/settings/integrations/providers/google_drive' ) - expect(container.textContent).toContain('Needs setup') - expect(container.textContent).toContain('2 sources') + expect(container.textContent).toContain('Waiting for connections') + expect(container.textContent).not.toContain('Needs setup') + expect(container.textContent).toContain('Disabled') expect(container.textContent).toContain('Confluence') expect(container.textContent).not.toContain('Add integration') expect(document.querySelector('[aria-label="Allow Gmail in Sim Search"]')).toHaveAttribute( @@ -307,7 +342,7 @@ describe('organization integration management entry', () => { isPending: false, }) await render() - expect(container.textContent).toContain('Needs attention') + expect(container.textContent).toContain('Sync failed') expect(document.querySelector('a[aria-label="Manage Google Drive"]')).not.toBeNull() }) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx index a918636313c..a3de964ef15 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-integrations-setup.tsx @@ -4,8 +4,13 @@ import { useState } from 'react' import { ChipConfirmModal, ChipLink, ChipModalError, Switch, toast } from '@sim/emcn' import { SettingsPanel } from '@/components/settings/settings-panel' import { organizationRoutes } from '@/lib/navigation/paths' -import { getConnectorAccessAvailability, SEARCH_SOURCE_TYPES } from '@/lib/sim-search/connectors' +import { + canConnectWithDefaults, + getConnectorAccessAvailability, + SEARCH_SOURCE_TYPES, +} from '@/lib/sim-search/connectors' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' +import { organizationSearchStatusLabel } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-status' import { OrganizationSlackAccountSetup } from '@/app/o/[organizationId]/settings/components/integrations/slack-account-setup' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { SearchSourceSetup } from '@/app/workspace/[workspaceId]/search/components/search-source-setup' @@ -56,7 +61,7 @@ export function OrganizationIntegrationsSetup() { return ( {availability.integrationAvailabilityError && ( Loading sources… ) : visible.length === 0 ? ( - No matching integrations + No matching sources ) : ( visible.map(([type, meta]) => { const provider = providers.get(type) @@ -97,12 +102,8 @@ export function OrganizationIntegrationsSetup() { ) const available = access.admin || access.members const hasSources = sourceCount > 0 - let description = hasSources - ? `${sourceCount} ${sourceCount === 1 ? 'source' : 'sources'}` - : approved - ? 'Needs setup' - : undefined - if (approved && provider?.status === 'needs_attention') description = 'Needs attention' + const manage = hasSources || canConnectWithDefaults(meta) + let description = provider ? organizationSearchStatusLabel(provider) : undefined if (!hasSources && availability.isIntegrationAvailabilityReady && !available) description = 'Unavailable in this deployment' return ( @@ -117,10 +118,10 @@ export function OrganizationIntegrationsSetup() { {(hasSources || (approved && available)) && ( - {hasSources ? 'Manage' : 'Set up'} + {manage ? 'Manage' : 'Set up'} )} { + it('describes the next step instead of calling all empty integrations unconfigured', () => { + expect(organizationSearchStatusLabel(provider)).toBe('Waiting for connections') + expect(organizationSearchStatusLabel({ ...provider, status: 'needs_setup' })).toBe( + 'Source not configured' + ) + expect( + organizationSearchStatusLabel({ ...provider, status: 'needs_setup', sourceCount: 1 }) + ).toBe('Waiting for first sync') + expect(organizationSearchStatusLabel({ ...provider, status: 'active', sourceCount: 1 })).toBe( + 'Enabled' + ) + }) + it.each([ + ['sync_failed', 'Sync failed'], + ['account_sync_incomplete', 'Some accounts are not up to date'], + ['document_indexing_failed', 'Some documents failed to index'], + ] as const)('describes %s and keeps concurrent recovery visible', (issue, label) => { + expect(organizationSearchStatusLabel({ ...provider, status: 'needs_attention', issue })).toBe( + label + ) + expect( + organizationSearchStatusLabel({ + ...provider, + status: 'needs_attention', + issue, + isSyncing: true, + }) + ).toBe(`Indexing · ${label}`) + }) + it('shows deactivation ahead of a retained failure', () => { + expect( + organizationSearchStatusLabel({ + ...provider, + approved: false, + status: 'needs_attention', + issue: 'sync_failed', + }) + ).toBe('Disabled') + }) +}) diff --git a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts index 85a329f0467..547f0339812 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts +++ b/apps/sim/app/o/[organizationId]/settings/components/integrations/organization-search-status.ts @@ -1,15 +1,25 @@ import type { OrganizationSearchProviderSummary } from '@/lib/api/contracts/knowledge/connectors' const STATUS_LABELS: Record = { - needs_setup: 'Needs setup', - waiting_for_connections: 'Waiting for account connections', + needs_setup: 'Source not configured', + waiting_for_connections: 'Waiting for connections', indexing: 'Indexing', - needs_attention: 'Needs attention', + needs_attention: 'Sync failed', paused: 'Paused', - active: 'Syncing enabled', + active: 'Enabled', } export function organizationSearchStatusLabel(provider: OrganizationSearchProviderSummary): string { - if (!provider.approved) return 'Deactivated' + if (!provider.approved) return 'Disabled' + if (provider.status === 'needs_setup' && provider.sourceCount > 0) return 'Waiting for first sync' + if (provider.status === 'needs_attention') { + const error = + provider.issue === 'account_sync_incomplete' + ? 'Some accounts are not up to date' + : provider.issue === 'document_indexing_failed' + ? 'Some documents failed to index' + : 'Sync failed' + return provider.isSyncing ? `Indexing · ${error}` : error + } return STATUS_LABELS[provider.status] } diff --git a/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.test.tsx index de88160942d..636a3fc5080 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.test.tsx @@ -3,13 +3,13 @@ import { act } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const mocks = vi.hoisted(() => ({ organizationId: 'org-1' })) +const mocks = vi.hoisted(() => ({ organizationId: 'org-1', canUseSearchMcp: true })) vi.mock('@/lib/core/utils/urls', () => ({ getBaseUrl: () => 'https://sim.fixture.test' })) vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({ useOrganizationContext: () => ({ organization: { id: mocks.organizationId }, - viewer: { canUsePersonalApiKeys: false }, + viewer: { canUsePersonalApiKeys: false, canUseSearchMcp: mocks.canUseSearchMcp }, }), })) @@ -22,6 +22,7 @@ describe('Organization Search MCP', () => { beforeEach(() => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) mocks.organizationId = 'org-1' + mocks.canUseSearchMcp = true container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) @@ -33,7 +34,7 @@ describe('Organization Search MCP', () => { vi.unstubAllGlobals() }) - it('offers organization OAuth setup even when personal API keys are disabled', async () => { + it('offers OAuth setup when Search MCP is allowed without API key management', async () => { await act(async () => root.render()) expect(container.querySelector('input')?.value).toBe( 'https://sim.fixture.test/api/mcp/search/organizations/org-1' @@ -44,6 +45,16 @@ describe('Organization Search MCP', () => { expect(container.textContent).not.toContain('Authorization header') }) + it('explains the organization policy restriction without offering OAuth setup', async () => { + mocks.canUseSearchMcp = false + await act(async () => root.render()) + expect(container.textContent).toContain('organization’s policy disables Sim Search MCP access') + expect(container.textContent).toContain('Contact an organization admin') + expect(container.querySelector('input')).toBeNull() + expect(container.querySelector('[aria-label^="MCP app: "]')).toBeNull() + expect(container.textContent).not.toContain('sign in to Sim') + }) + it('replaces the connection scope when the organization changes', async () => { await act(async () => root.render()) mocks.organizationId = 'org-2' diff --git a/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.tsx b/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.tsx index a0235437d6d..7607f4c93ed 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/organization-search-mcp.tsx @@ -5,7 +5,15 @@ import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organ import { SearchMcpConnection } from '@/app/o/[organizationId]/settings/components/search-mcp-connection' export function OrganizationSearchMcp() { - const { organization } = useOrganizationContext() + const { organization, viewer } = useOrganizationContext() + if (!viewer.canUseSearchMcp) { + return ( +

+ Your organization’s policy disables Sim Search MCP access. Contact an organization admin to + enable it. +

+ ) + } const endpoint = getSearchMcpUrl(organization.id) return ( diff --git a/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.tsx b/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.tsx index c19fa601a15..1bcec157d2b 100644 --- a/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.tsx +++ b/apps/sim/app/o/[organizationId]/settings/components/search-mcp-connection.tsx @@ -3,8 +3,8 @@ import { useState } from 'react' import { Chip, - ChipDropdown, ChipModalField, + ChipSelect, Code, chipFieldSurfaceClass, useCopyToClipboard, @@ -38,18 +38,20 @@ export function SearchMcpConnection({ endpoint }: SearchMcpConnectionProps) { return ( <> - { - const option = CLIENTS.find((item) => item.value === value) - if (option) setClient(option.value) - }} - options={CLIENTS} - aria-label={`MCP app: ${CLIENTS.find((option) => option.value === client)?.label}`} - align='start' - matchTriggerWidth={false} - className='self-start' - /> +
+ { + const option = CLIENTS.find((item) => item.value === value) + if (option) setClient(option.value) + }} + options={[...CLIENTS]} + aria-label={`MCP app: ${CLIENTS.find((option) => option.value === client)?.label}`} + align='start' + fullWidth + dropdownWidth='trigger' + /> +
{client !== 'cursor' ? ( ({ })) vi.mock('@/lib/sim-search/connectors', () => ({ canConnectPersonally: () => mocks.personal, + canConnectWithDefaults: (meta: { name: string }) => + ['Gmail', 'Google Calendar', 'Google Drive'].includes(meta.name), getConnectorAccessAvailability: () => mocks.access, })) vi.mock('@/lib/oauth', () => ({ @@ -51,8 +53,18 @@ vi.mock('@/lib/credential-groups/providers', () => ({ })) vi.mock('@/connectors/registry', () => ({ CONNECTOR_META_REGISTRY: { - google_drive: { name: 'Google Drive', auth: { mode: 'oauth', provider: 'google-drive' } }, - gmail: { name: 'Gmail', auth: { mode: 'oauth', provider: 'google-email' } }, + google_drive: { + name: 'Google Drive', + auth: { mode: 'oauth', provider: 'google-drive', adminCredentialType: 'service_account' }, + }, + gmail: { + name: 'Gmail', + auth: { mode: 'oauth', provider: 'google-email', adminCredentialType: 'service_account' }, + }, + google_calendar: { + name: 'Google Calendar', + auth: { mode: 'oauth', provider: 'google-calendar', adminCredentialType: 'service_account' }, + }, slack: { name: 'Slack', auth: { mode: 'oauth', provider: 'slack' } }, gitlab: { name: 'GitLab', auth: { mode: 'apiKey' } }, }, @@ -105,11 +117,17 @@ vi.mock('@/app/o/[organizationId]/settings/components/integrations/slack-account import { SettingsHeaderProvider, SettingsHeaderShell } from '@/components/settings/settings-header' import { OrganizationProviderDetail } from '@/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail' -const provider = { connectorType: 'google_drive', approved: true, status: 'active' } +const provider = { + connectorType: 'google_drive', + approved: true, + status: 'active', + sourceCount: 0, +} const source = { connectorId: 'source-one', connectorType: 'google_drive', sourceDescription: 'Engineering handbook', + accessMode: 'admin', enabled: true, hasSyncError: false, isSyncing: false, @@ -202,6 +220,193 @@ describe('organization provider management', () => { }) } + it.each(['gmail', 'google_calendar', 'google_drive'])( + 'lets %s wait for connections without requiring source setup', + async (connectorType) => { + mocks.overview.mockReturnValue({ + data: { + providers: [ + { + connectorType, + approved: true, + status: 'waiting_for_connections', + sourceCount: 0, + issue: null, + isSyncing: false, + }, + ], + }, + }) + mocks.accounts.mockReturnValue({ data: { credentialGroup: null }, isPending: false }) + mocks.sources.mockReturnValue({ data: [], isPending: false }) + await render(connectorType, '?view=accounts') + expect(container.textContent).toContain('Waiting for connections') + expect(container.textContent).toContain( + 'Members connect their accounts from Integrations. Indexing starts automatically.' + ) + expect(container.textContent).not.toContain('Add source') + expect(container.textContent).not.toContain('Add sync configuration') + expect(container.querySelector('a[href="/o/org-one/integrations"]')).toBeNull() + expect(container.textContent).not.toContain('Open Integrations') + expect(mocks.sources).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ enabled: false }) + ) + await click('Advanced') + expect(container.textContent).toContain('No sync configurations yet.') + expect(container.textContent).toContain('Add sync configuration') + await click('Add sync configuration') + await vi.waitFor(() => { + expect(mocks.updateUrl).toHaveBeenLastCalledWith( + expect.objectContaining({ searchParams: expect.any(URLSearchParams) }) + ) + expect(mocks.updateUrl.mock.calls.at(-1)![0].searchParams.get('addConnector')).toBe( + connectorType + ) + }) + } + ) + + it.each(['gmail', 'google_calendar', 'google_drive'])( + 'does not ask for personal connections when %s already has a central source', + async (connectorType) => { + mocks.overview.mockReturnValue({ + data: { + providers: [{ ...provider, connectorType, sourceCount: 1 }], + }, + }) + mocks.accounts.mockReturnValue({ data: { credentialGroup: null }, isPending: false }) + mocks.access = { admin: true, members: true } + await render(connectorType) + + expect(container.textContent).toContain('Engineering handbook') + expect(container.textContent).not.toContain('No connected member accounts.') + expect(container.textContent).not.toContain( + 'Members connect their accounts from Integrations.' + ) + await click('Add sync configuration') + await vi.waitFor(() => { + const params = mocks.updateUrl.mock.calls.at(-1)![0].searchParams + expect(params.get('addConnector')).toBe(connectorType) + expect(params.get('source-access')).toBeNull() + }) + } + ) + + describe.each(['gmail', 'google_calendar', 'google_drive'])( + '%s default management view', + (connectorType) => { + function withSources(accessModes: string[]) { + mocks.overview.mockReturnValue({ + data: { providers: [{ ...provider, connectorType, sourceCount: accessModes.length }] }, + isPending: false, + }) + mocks.sources.mockReturnValue({ + data: accessModes.map((accessMode, index) => ({ + ...source, + connectorType, + accessMode, + connectorId: `source-${index}`, + })), + isPending: false, + }) + } + + it.each([ + { name: 'member-only', modes: ['members'] }, + { name: 'central-only', modes: ['admin'] }, + { name: 'mixed', modes: ['admin', 'members'] }, + { name: 'not configured', modes: [] }, + ])('opens configurations for the $name setup', async ({ modes }) => { + withSources(modes) + await render(connectorType) + expect(container.querySelector('[role="radio"][aria-checked="true"]')).toHaveTextContent( + 'Advanced' + ) + expect(mocks.sources).toHaveBeenLastCalledWith( + expect.any(Object), + expect.objectContaining({ enabled: true }) + ) + expect(mocks.accounts).toHaveBeenLastCalledWith(undefined) + expect(container.textContent).toContain('Add sync configuration') + if (modes.length === 0) + expect(container.textContent).toContain('No sync configurations yet.') + }) + + it.each(['accounts', 'sources'])('honors explicit %s links', async (view) => { + withSources(['admin']) + await render(connectorType, `?view=${view}`) + expect(mocks.sources).toHaveBeenLastCalledWith( + expect.any(Object), + expect.objectContaining({ enabled: view === 'sources' }) + ) + expect(container.querySelector('[role="radio"][aria-checked="true"]')).toHaveTextContent( + view === 'accounts' ? 'Accounts' : 'Advanced' + ) + }) + + it('loads the configuration list in parallel with its overview and retains the default', async () => { + mocks.overview.mockReturnValue({ isPending: true }) + await render(connectorType) + expect(container.textContent).toContain('Loading integration…') + expect(container.textContent).not.toContain('No connected member accounts.') + expect(mocks.accounts).toHaveBeenLastCalledWith(undefined) + expect(mocks.sources).toHaveBeenLastCalledWith( + expect.any(Object), + expect.objectContaining({ enabled: true }) + ) + withSources(['members']) + await render(connectorType) + expect(container.querySelector('[role="radio"][aria-checked="true"]')).toHaveTextContent( + 'Advanced' + ) + expect(container.textContent).toContain('Engineering handbook') + expect(mocks.sources).toHaveBeenLastCalledWith( + expect.any(Object), + expect.objectContaining({ enabled: true }) + ) + expect(mocks.accounts).toHaveBeenLastCalledWith(undefined) + }) + + it('preserves an explicit Accounts choice when the overview changes', async () => { + withSources(['members']) + await render(connectorType) + await click('Accounts') + await vi.waitFor(() => + expect(mocks.updateUrl.mock.calls.at(-1)?.[0].searchParams.get('view')).toBe('accounts') + ) + withSources(['admin']) + await render(connectorType) + expect(container.querySelector('[role="radio"][aria-checked="true"]')).toHaveTextContent( + 'Accounts' + ) + expect(mocks.sources).toHaveBeenLastCalledWith( + expect.any(Object), + expect.objectContaining({ enabled: false }) + ) + }) + + it.each([ + { accessMode: 'admin', method: 'Service account' }, + { accessMode: 'members', method: 'Member accounts' }, + ])( + 'identifies $method configurations without changing their title or destination', + async ({ accessMode, method }) => { + withSources([accessMode]) + mocks.sources.mockReturnValue({ + data: [{ ...source, connectorType, accessMode }], + isPending: false, + }) + await render(connectorType) + expect(container.textContent).toContain(`${method} · Last synced`) + expect( + container.querySelector('a[aria-label="Open Engineering handbook"]') + ).toHaveAttribute('href', '/o/org-one/settings/integrations/sources/source-one') + } + ) + } + ) + it.each(['active', 'disabled'])( 'removes only Slack account setup after confirmation, including a %s option', async (status) => { @@ -269,7 +474,7 @@ describe('organization provider management', () => { }) it('uses named source links even when the admin has not reconnected their own account', async () => { - await render() + await render('google_drive', '?view=sources') expect(mocks.sources).toHaveBeenCalledWith( { kind: 'organization', organizationId: 'org-one' }, { connectorType: 'google_drive', search: '', enabled: true } @@ -301,7 +506,7 @@ describe('organization provider management', () => { it('does not claim a provider is empty before paginated source discovery finishes', async () => { const fetchNextPage = vi.fn() mocks.sources.mockReturnValue({ data: [], isPending: false, hasNextPage: true, fetchNextPage }) - await render() + await render('google_drive', '?view=sources') expect(container.textContent).not.toContain('No sources yet') await click('Load more') expect(fetchNextPage).toHaveBeenCalledOnce() @@ -317,14 +522,14 @@ describe('organization provider management', () => { hasNextPage: true, fetchNextPage, }) - await render() + await render('google_drive', '?view=sources') expect(container.textContent).toContain('Engineering handbook') expect(container.textContent).toContain('More sources unavailable') await click('Try again') expect(fetchNextPage).toHaveBeenCalledOnce() }) - it.each(['', '?view=accounts'])( + it.each(['', '?view=accounts', '?view=sources'])( 'renders overview loading without presenting missing configuration at %s', async (params) => { mocks.overview.mockReturnValue({ isPending: true }) @@ -334,7 +539,7 @@ describe('organization provider management', () => { expect(mocks.people).not.toHaveBeenCalled() expect( container.querySelector( - `input[placeholder="${params ? 'Search people...' : 'Search sources...'}"]` + `input[placeholder="${params === '?view=accounts' ? 'Search people...' : 'Search sync configurations...'}"]` ) ).toBeEnabled() } @@ -388,8 +593,14 @@ describe('organization provider management', () => { it.each([ ['loading', 'Loading accounts…'], ['error', 'Accounts unavailable'], - ['missing group', 'Add a source to set up account connections.'], - ['missing provider option', 'Add a source to set up account connections.'], + [ + 'missing group', + 'Members connect their accounts from Integrations. Indexing starts automatically.', + ], + [ + 'missing provider option', + 'Members connect their accounts from Integrations. Indexing starts automatically.', + ], ])('preserves Accounts search while %s', async (state, message) => { const refetch = vi.fn() mocks.accounts.mockReturnValue( @@ -410,7 +621,7 @@ describe('organization provider management', () => { expect(container.textContent).toContain(message) expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') expect(container.querySelector('input[placeholder="Search people..."]')).toBeEnabled() - expect(container.querySelector('input[placeholder="Search sources..."]')).toBeNull() + expect(container.querySelector('input[placeholder="Search sync configurations..."]')).toBeNull() expect(mocks.people).not.toHaveBeenCalled() if (state === 'error') { await click('Try again') @@ -419,19 +630,19 @@ describe('organization provider management', () => { const sourcesTab = Array.from( container.querySelectorAll('[role="radio"]') - ).find((item) => item.textContent === 'Sources') + ).find((item) => item.textContent === 'Advanced') expect(sourcesTab).toBeDefined() await act(async () => sourcesTab!.click()) - expect(container.querySelector('input[placeholder="Search sources..."]')).toHaveValue( - 'handbook' - ) + expect( + container.querySelector('input[placeholder="Search sync configurations..."]') + ).toHaveValue('handbook') await click('Accounts') expect(container.querySelector('input[placeholder="Search people..."]')).toHaveValue('alex') }) it('preserves source navigation and retries connection availability failures', async () => { mocks.availabilityError = new Error('Connection availability could not be loaded') - await render() + await render('google_drive', '?view=sources') expect(container.textContent).toContain('Connection availability could not be loaded') expect(container.querySelector('a[aria-label="Open Engineering handbook"]')).toHaveAttribute( 'href', @@ -491,10 +702,14 @@ describe('organization provider management', () => { data: { providers: [{ ...provider, connectorType: type }] }, }) await render(type) - await click('Add source') - const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString) - expect(query.get('addConnector')).toBe(type) - expect(query.get('source-access')).toBe(memberParam ? 'members' : null) + await click('Advanced') + await click('Add sync configuration') + await vi.waitFor(() => { + expect(mocks.updateUrl).toHaveBeenCalled() + const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString) + expect(query.get('addConnector')).toBe(type) + expect(query.get('source-access')).toBe(memberParam ? 'members' : null) + }) } ) @@ -506,9 +721,12 @@ describe('organization provider management', () => { mocks.accounts.mockReturnValue({ data: { credentialGroup: null }, isPending: false }) await render('slack') await click('Set up Slack app') - const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString) - expect(query.get('connectedAccounts')).toBe('slack') - expect(query.has('addConnector')).toBe(false) + await vi.waitFor(() => { + expect(mocks.updateUrl).toHaveBeenCalled() + const query = new URLSearchParams(mocks.updateUrl.mock.calls.at(-1)![0].queryString) + expect(query.get('connectedAccounts')).toBe('slack') + expect(query.has('addConnector')).toBe(false) + }) }) it.each([ diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx index 8080d989f0a..cbf18f2f4e0 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/providers/[connectorType]/provider-detail.tsx @@ -11,7 +11,11 @@ import { SettingsPanel } from '@/components/settings/settings-panel' import { findCredentialGroupProviderFromProviderId } from '@/lib/credential-groups/providers' import { organizationRoutes } from '@/lib/navigation/paths' import { getServiceConfigByProviderId, getServiceConfigByServiceId } from '@/lib/oauth' -import { canConnectPersonally, getConnectorAccessAvailability } from '@/lib/sim-search/connectors' +import { + canConnectPersonally, + canConnectWithDefaults, + getConnectorAccessAvailability, +} from '@/lib/sim-search/connectors' import { SEARCH_DEBOUNCE_MS } from '@/lib/url-state' import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider' import { organizationSearchStatusLabel } from '@/app/o/[organizationId]/settings/components/integrations/organization-search-status' @@ -52,6 +56,8 @@ interface OrganizationProviderDetailProps { export function OrganizationProviderDetail({ connectorType }: OrganizationProviderDetailProps) { const { organization, viewer, searchAccess } = useOrganizationContext() const router = useRouter() + const meta = CONNECTOR_META_REGISTRY[connectorType] + const automaticSetup = Boolean(meta && canConnectWithDefaults(meta) && searchAccess.memberScoped) const [view, setView] = useQueryState( organizationProviderTabParam.key, organizationProviderTabParam.parser @@ -62,7 +68,6 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid const [deactivating, setDeactivating] = useState(false) const [removingSlackAccounts, setRemovingSlackAccounts] = useState(false) const scope = { kind: 'organization', organizationId: organization.id } as const - const meta = CONNECTOR_META_REGISTRY[connectorType] const personal = Boolean(meta && canConnectPersonally(meta) && searchAccess.memberScoped) const showAccounts = view === 'accounts' && personal const overview = useOrganizationSearchOverview(organization.id, { enabled: viewer.isAdmin }) @@ -100,7 +105,11 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid if (!viewer.isAdmin || !meta) return null const searchField = showAccounts ? { value: peopleSearch, onChange: setPeopleSearch, placeholder: 'Search people...' } - : { value: search, onChange: setSearch, placeholder: 'Search sources...' } + : { + value: search, + onChange: setSearch, + placeholder: automaticSetup ? 'Search sync configurations...' : 'Search sources...', + } const panel = { back, title: meta.name, @@ -156,10 +165,14 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid approval.mutate({ organizationId: organization.id, connectorType, approved: true }) const actions: SettingsAction[] = approved ? [ - ...(access.admin || access.members + ...((access.admin || access.members) && (!automaticSetup || !showAccounts) ? [ { - text: needsSlackSetup ? 'Set up Slack app' : 'Add source', + text: needsSlackSetup + ? 'Set up Slack app' + : automaticSetup + ? 'Add sync configuration' + : 'Add source', icon: Plus, variant: 'primary' as const, disabled: @@ -247,19 +260,29 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid 0 + ? `${source.viewerFailedDocumentCount} ${source.viewerFailedDocumentCount === 1 ? 'document' : 'documents'} failed to index` + : source.isSyncing + ? 'Indexing' + : source.lastSyncAt + ? `Last synced ${format(new Date(source.lastSyncAt), 'MMM d, h:mm a')}` + : 'Waiting for the first sync', + ].join(' · ')} href={organizationRoutes(organization.id).searchSource(source.connectorId)} clickLabel={`Open ${source.sourceDescription || meta.name}`} navigable @@ -271,7 +294,9 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid ? 'No matching sources' : !approved ? 'Activate this integration to set up sources.' - : 'No sources yet.'} + : automaticSetup + ? 'No sync configurations yet.' + : 'No sources yet.'} )} @@ -288,10 +313,17 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid aria-label={`${meta.name} settings`} value={view} onChange={(value) => void setView(value)} - options={[ - { value: 'sources', label: 'Sources' }, - { value: 'accounts', label: 'Accounts' }, - ]} + options={ + automaticSetup + ? [ + { value: 'accounts', label: 'Accounts' }, + { value: 'sources', label: 'Advanced' }, + ] + : [ + { value: 'sources', label: 'Sources' }, + { value: 'accounts', label: 'Accounts' }, + ] + } />
)} @@ -336,7 +368,11 @@ export function OrganizationProviderDetail({ connectorType }: OrganizationProvid {approved ? needsSlackSetup ? 'Set up the Slack app to connect accounts.' - : 'Add a source to set up account connections.' + : automaticSetup + ? provider && provider.sourceCount > 0 + ? 'No connected member accounts.' + : 'Members connect their accounts from Integrations. Indexing starts automatically.' + : 'Add a source to set up account connections.' : 'Activate this integration to set up account connections.'} diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx index d16dd95ea5b..fd1fc7944d9 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.test.tsx @@ -204,6 +204,23 @@ describe('organization source detail navigation', () => { '/o/org-one/settings/integrations/providers/google_drive' ) }) + + it.each(['members', 'admin'] as const)( + 'links member sources to personal Search connections: %s', + async (accessMode) => { + mocks.detail.mockReturnValue({ data: { ...connector, accessMode } }) + await render() + const link = container.querySelector('a[aria-label="Manage your Search accounts"]') + if (accessMode === 'members') { + expect(link).toHaveAttribute('href', '/o/org-one/integrations') + expect(container.textContent).toContain( + 'Each person connects from Integrations to sync content they can access.' + ) + } else { + expect(link).toBeNull() + } + } + ) it('restores document search and status from the shared URL', async () => { await render('?search=notes&document-filter=excluded') expect(mocks.documents).toHaveBeenLastCalledWith( diff --git a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx index 7670e68e7e6..1439c907041 100644 --- a/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx +++ b/apps/sim/app/o/[organizationId]/settings/integrations/sources/[connectorId]/source-detail.tsx @@ -192,7 +192,7 @@ function SourceDetailContent({ : effectiveStatus === 'disabled' ? 'Sync disabled' : effectiveStatus === 'error' - ? 'Sync needs attention' + ? 'Sync failed' : undefined const description = [title === meta?.name ? undefined : meta?.name, status].filter(Boolean).join(' · ') || undefined @@ -241,6 +241,15 @@ function SourceDetailContent({ description='Its content is unavailable in Search, Assistant, and MCP.' /> )} + {connector.accessMode === 'members' && ( + + )} ) if (view === 'settings') diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.test.ts index ea6108923db..17f2cf993fd 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.test.ts @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import * as XLSX from 'xlsx' import { readXlsxPreviewData, + readXlsxWorkbook, XLSX_MAX_COLUMNS, XLSX_MAX_ROWS, } from '@/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data' @@ -26,9 +27,11 @@ describe('readXlsxPreviewData', () => { const result = readXlsxPreviewData(XLSX, sheet) const options = toJson.mock.calls[0][1] as { range: { s: { r: number }; e: { r: number } } + raw?: boolean } expect(options.range.e.r - options.range.s.r).toBe(XLSX_MAX_ROWS) + expect(options.raw).toBe(false) expect(result.headers).toEqual(['header-a', 'header-b']) expect(result.rows).toHaveLength(XLSX_MAX_ROWS) expect(result.rows.slice(0, 2)).toEqual([ @@ -71,4 +74,37 @@ describe('readXlsxPreviewData', () => { expect(result.rowTruncated).toBe(false) expect(result.columnTruncated).toBe(true) }) + + /** + * Built through the viewer's own read path rather than by hand-setting `z`, + * so the assertions cover the read options as well as the conversion. + */ + function typedWorkbook(): ArrayBuffer { + const sheet = XLSX.utils.aoa_to_sheet([['Issued', 'Rate', 'Card', 'Elapsed']]) + sheet.A2 = { t: 'd', v: new Date(Date.UTC(2026, 2, 4)), z: 'm/d/yyyy' } + sheet.B2 = { t: 'n', v: 0.2, z: '0%' } + sheet.C2 = { t: 'n', v: 4111111111111111 } + sheet.D2 = { t: 'n', v: 1.25, z: '[h]:mm' } + sheet['!ref'] = 'A1:D2' + const book = XLSX.utils.book_new() + XLSX.utils.book_append_sheet(book, sheet, 'Ledger') + const bytes = XLSX.write(book, { type: 'buffer', bookType: 'xlsx' }) as Buffer + return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer + } + + it('shows display text rather than stored values', () => { + const workbook = readXlsxWorkbook(XLSX, typedWorkbook()) + + const result = readXlsxPreviewData(XLSX, workbook.Sheets.Ledger) + + expect(result.rows).toEqual([['2026-03-04', '20%', '4111111111111111', '30:00']]) + }) + + it('reads the workbook with the display-text options', () => { + const read = vi.fn(XLSX.read) + + readXlsxWorkbook({ read, utils: XLSX.utils }, typedWorkbook()) + + expect(read.mock.calls[0][1]).toMatchObject({ type: 'array', cellDates: true, cellNF: true }) + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.ts index a661d7f3cf7..14e8f30841f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data.ts @@ -1,10 +1,25 @@ -import type { WorkSheet } from 'xlsx' +import type { WorkBook, WorkSheet } from 'xlsx' +import { + normalizeSheetDisplayText, + SHEET_DISPLAY_READ_OPTIONS, +} from '@/lib/file-parsers/sheet-display-text' export const XLSX_MAX_ROWS = 1_000 export const XLSX_MAX_COLUMNS = 200 interface XlsxModule { - utils: Pick + read: typeof import('xlsx').read + utils: Pick +} + +/** + * Reads a workbook for preview with the options that make its cells carry + * display text: without `cellDates` a date arrives as a bare serial and + * without `cellNF` no cell has a format, so every rendered `w` would be + * overwritten as a General number. + */ +export function readXlsxWorkbook(XLSX: XlsxModule, data: ArrayBuffer): WorkBook { + return XLSX.read(new Uint8Array(data), { type: 'array', ...SHEET_DISPLAY_READ_OPTIONS }) } interface XlsxPreviewData { @@ -18,12 +33,21 @@ export function readXlsxPreviewData(XLSX: XlsxModule, sheet: WorkSheet): XlsxPre const declaredRange = XLSX.utils.decode_range(sheet['!ref'] || 'A1') const lastPreviewRow = Math.min(declaredRange.e.r, declaredRange.s.r + XLSX_MAX_ROWS) const lastPreviewColumn = Math.min(declaredRange.e.c, declaredRange.s.c + XLSX_MAX_COLUMNS - 1) + const previewRange = { + s: declaredRange.s, + e: { r: lastPreviewRow, c: lastPreviewColumn }, + } + + /** + * Shown as the text a user sees in Excel: `raw: false` emits each cell's + * formatted text, so a sheet read through {@link readXlsxWorkbook} shows a + * date as ISO text and `20%` rather than a serial and `0.2`. + */ + normalizeSheetDisplayText(sheet, previewRange, XLSX.utils) const previewRows = XLSX.utils.sheet_to_json(sheet, { header: 1, - range: { - s: declaredRange.s, - e: { r: lastPreviewRow, c: lastPreviewColumn }, - }, + raw: false, + range: previewRange, }) return { 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 431b5f213bc..11e4316c92e 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 @@ -10,6 +10,7 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { useHorizontalWheelScroll } from '@/app/workspace/[workspaceId]/files/components/file-viewer/use-horizontal-wheel-scroll' import { readXlsxPreviewData, + readXlsxWorkbook, XLSX_MAX_COLUMNS, XLSX_MAX_ROWS, } from '@/app/workspace/[workspaceId]/files/components/file-viewer/xlsx-preview-data' @@ -55,7 +56,7 @@ export const XlsxPreview = memo(function XlsxPreview({ setRenderError(null) await assertOoxmlPreviewWithinLimits(data) const XLSX = await import('xlsx') - const workbook = XLSX.read(new Uint8Array(data), { type: 'array' }) + const workbook = readXlsxWorkbook(XLSX, data) if (!cancelled) { workbookRef.current = workbook setSheetNames(workbook.SheetNames) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx index 6864df15943..f4d8cd5954e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx @@ -1,6 +1,7 @@ 'use client' import { + type ComponentType, createContext, type ReactNode, useCallback, @@ -10,6 +11,7 @@ import { useRef, } from 'react' import { noop } from '@sim/utils/helpers' +import type { SearchIntegrationConnectionProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection' import type { WorkspaceResourceRef } from '@/app/workspace/[workspaceId]/home/types' import type { ChatContext } from '@/stores/panel' @@ -20,6 +22,7 @@ import type { ChatContext } from '@/stores/panel' * consume them without relaying through every intermediate component. */ interface ChatSurfaceContextValue { + SearchConnectionComponent?: ComponentType /** Resolved id of the chat backing this surface, if one exists yet. */ chatId?: string /** Id of the user interacting with this surface. */ @@ -44,6 +47,7 @@ const ChatSurfaceContext = createContext({ }) interface ChatSurfaceProviderProps { + SearchConnectionComponent?: ComponentType chatId?: string userId?: string onContextAdd?: (context: ChatContext) => void @@ -59,6 +63,7 @@ interface ChatSurfaceProviderProps { * not re-render when a parent re-creates a handler. */ export function ChatSurfaceProvider({ + SearchConnectionComponent, chatId, userId, onContextAdd, @@ -88,13 +93,21 @@ export function ChatSurfaceProvider({ const value = useMemo( () => ({ + SearchConnectionComponent, chatId, userId, onContextAdd: stableOnContextAdd, onContextRemove: stableOnContextRemove, onWorkspaceResourceSelect: stableOnWorkspaceResourceSelect, }), - [chatId, userId, stableOnContextAdd, stableOnContextRemove, stableOnWorkspaceResourceSelect] + [ + SearchConnectionComponent, + chatId, + userId, + stableOnContextAdd, + stableOnContextRemove, + stableOnWorkspaceResourceSelect, + ] ) return {children} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card.tsx index 31ac390e5bf..9fe74690780 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card.tsx @@ -90,6 +90,8 @@ export const InteractionCardInputRow = forwardRef @@ -123,7 +128,7 @@ export function InteractionCardActionRow({ > {label} - + {trailing ?? } ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx index 97c9c683b3e..b339c1a91fc 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-card/source-card.tsx @@ -16,6 +16,7 @@ import { import { BRAND_ICON_BY_BASE_TYPE, sourceLabel, + sourceSiteName, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip' import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' import { BrandIcon } from '@/blocks/brand-icon' @@ -133,7 +134,7 @@ export function SourceCard({ source, query, onSummarize, dense = false }: Source : undefined const updatedAt = parseUpdatedAt(source.updatedAt) const meta = [ - sourceLabel(source), + sourceSiteName(source), source.author?.trim() || null, updatedAt ? formatDate(updatedAt) : null, ].filter((part): part is string => Boolean(part)) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/index.ts index 329fa2848eb..612c5e37cc5 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/index.ts @@ -1 +1,6 @@ -export { BRAND_ICON_BY_BASE_TYPE, SourceChip, sourceLabel } from './source-chip' +export { + BRAND_ICON_BY_BASE_TYPE, + SourceChip, + sourceLabel, + sourceSiteName, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip' diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.test.tsx new file mode 100644 index 00000000000..dcc94a58b90 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.test.tsx @@ -0,0 +1,130 @@ +/** @vitest-environment jsdom */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/browser-agent/open-in-panel', () => ({ + shouldOpenInBrowserPanel: () => false, + openInBrowserPanel: vi.fn(), +})) +vi.mock('@/lib/integrations', () => ({ blockTypeToIconMap: {} })) + +import { SourceCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-card' +import { SourceChip } from '@/app/workspace/[workspaceId]/home/components/message-content/components/source-chip' +import type { SourceTagData } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function mount(ui: React.ReactNode) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => root?.render(ui)) + return container +} + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + root = null + container = null +}) + +describe('citation labels', () => { + it('labels a Slack channel citation without losing its message title or permalink', () => { + const source = { + url: 'https://example.slack.com/archives/C123/p1789000000000000', + title: `#engineering: Release notes https://example.com/${'a'.repeat(300)}`, + connectorType: 'slack', + } + const view = mount() + const link = view.querySelector('a')! + expect(link.textContent).toBe('#engineering') + expect(link.getAttribute('href')).toBe(source.url) + + act(() => { + link.dispatchEvent( + new MouseEvent('pointerover', { bubbles: true, clientX: 200, clientY: 200 }) + ) + }) + const tooltip = document.querySelector('[role="tooltip"]')! + expect(tooltip.textContent).toContain(source.title) + expect(tooltip.textContent).toContain(source.url) + expect(link.getAttribute('aria-describedby')).toBe(tooltip.id) + }) + + it.each([ + { + url: 'https://mail.google.com/mail/u/0/#all/thread', + title: 'Launch checklist', + siteName: 'Sim Search', + connectorType: 'gmail', + }, + { + url: 'https://example.slack.com/archives/channel/message', + title: '#engineering — release handoff', + siteName: 'Slack', + connectorType: 'slack', + }, + { + url: 'https://example.slack.com/archives/D123/message', + title: 'Direct message: Release handoff', + connectorType: 'slack', + }, + { + url: 'https://example.slack.com/archives/G123/message', + title: 'Alice, Bob: Release handoff', + connectorType: 'slack', + }, + { + url: 'https://example.com/page', + title: '#engineering: Release handoff', + connectorType: 'confluence', + }, + { + url: 'https://docs.github.com/page', + title: 'Managing repositories', + siteName: 'GitHub Docs', + }, + ])('uses the retrieved title for $url', (source: SourceTagData) => { + const view = mount() + expect(view.querySelector('a')?.textContent).toBe(source.title) + expect(view.querySelector('a')?.getAttribute('href')).toBe(source.url) + }) + + it.each([ + [{ url: 'https://mail.google.com/thread', title: ' ', siteName: 'Gmail' }, 'Gmail'], + [{ url: 'https://www.example.com/page' }, 'example.com'], + ] as const)('keeps a readable fallback without a title', (source, expected) => { + expect(mount().querySelector('a')?.textContent).toBe(expected) + }) + + it('keeps the provider separate from the source card title', () => { + const view = mount( + + ) + expect(view.querySelector('[data-source-link]')?.textContent).toBe('Launch checklist') + expect(view.textContent?.match(/Launch checklist/g)).toHaveLength(1) + expect(view.textContent).toContain('Gmail') + }) + + it('keeps the full Slack message title in source cards', () => { + const source = { + url: 'https://example.slack.com/archives/C123/p1789000000000000', + title: '#engineering: Release handoff', + connectorType: 'slack', + } + const view = mount() + const link = view.querySelector('[data-source-link]')! + expect(link.textContent).toBe(source.title) + expect(link.getAttribute('href')).toBe(source.url) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx index 75a276e46a2..5c3c924fc6b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/source-chip/source-chip.tsx @@ -23,26 +23,34 @@ export const BRAND_ICON_BY_BASE_TYPE: ReadonlyMap = new M Object.entries(blockTypeToIconMap).map(([type, icon]) => [stripVersionSuffix(type), icon]) ) -/** Chip label: the site name the model supplied, else the URL's hostname without a `www.` prefix. */ -export function sourceLabel(source: SourceTagData): string { +/** The source's site or provider, separate from its document title. */ +export function sourceSiteName(source: SourceTagData): string { const siteName = source.siteName?.trim() if (siteName) return siteName return (externalLinkHostname(source.url) ?? source.url).replace(/^www\./, '') } +/** Citations identify the document; source metadata is the fallback when its title is unavailable. */ +export function sourceLabel(source: SourceTagData): string { + return source.title?.trim() || sourceSiteName(source) +} + interface SourceChipProps { source: SourceTagData } /** * A cited document as a small round pill — the connector's brand mark or the - * site favicon, then the site name — used inline at the citation point and + * site favicon, then the document title — used inline at the citation point and * again in the footer strip. Built on the chip fill and hover tokens at a 20px * height so it sits inside a line of prose; the 30px `Chip` is the wrong scale * for a citation. Opens the document like any external link in the reply. */ export function SourceChip({ source }: SourceChipProps) { const hostname = externalLinkHostname(source.url) + /** Slack's connector prefixes channel titles with `#channel: `; direct messages omit `#`. */ + const slackChannel = + source.connectorType === 'slack' ? source.title?.match(/^(#[^:\s]+): /)?.[1] : undefined const ConnectorIcon = source.connectorType ? BRAND_ICON_BY_BASE_TYPE.get(source.connectorType) : undefined @@ -71,17 +79,17 @@ export function SourceChip({ source }: SourceChipProps) { onError={hideBrokenFavicon} /> ) : null} - + - + {source.title ? ( - + {source.title} - {source.url} + {source.url} ) : ( - {source.url} + {source.url} )} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection.tsx new file mode 100644 index 00000000000..e1df655f9ce --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection.tsx @@ -0,0 +1,117 @@ +'use client' + +import { useState } from 'react' +import { Chip } from '@sim/emcn' +import { Check } from '@sim/emcn/icons' +import type { SearchConnectionTarget } from '@/lib/knowledge/search/connection-target' +import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors' +import { + InteractionCard, + InteractionCardActionRow, +} from '@/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card' +import { SourceSetupModal } from '@/app/workspace/[workspaceId]/home/components/search-sources/source-setup-modal' +import { BrandIcon } from '@/blocks/brand-icon' +import { useSearchIntegrationConnection } from '@/hooks/use-search-integration-connection' + +export interface SearchIntegrationConnectionProps { + organizationId: string + userId: string + target: SearchConnectionTarget + controlId: string + embedded?: boolean + divided?: boolean + onConnected?: () => void +} + +/** The ordinary credential card, with personal Search enrollment instead of workspace credentials. */ +export function SearchIntegrationConnection(props: SearchIntegrationConnectionProps) { + return ( + + ) +} + +function SearchIntegrationConnectionControl({ + organizationId, + userId, + target, + controlId, + embedded, + divided, + onConnected, +}: SearchIntegrationConnectionProps) { + const [setupOpen, setSetupOpen] = useState(false) + const connector = SEARCH_CONNECTORS.find((entry) => entry.type === target.connectorType) + const connection = useSearchIntegrationConnection({ + organizationId, + userId, + target, + controlId, + onConnected, + }) + const name = connector?.meta.name ?? target.provider + const action = target.credentialId ? 'Reconnect' : 'Connect' + const label = connection.connected + ? `Connected ${name}` + : connection.isLoading + ? `Checking ${name} connections…` + : connection.pending + ? `Waiting for ${name} connection…` + : !connection.available + ? `${name} connection is no longer available` + : `${action} ${name}` + const handleConnect = () => { + if (connector && !connection.connectorId && connector.setupFields.length && !connection.pending) + setSetupOpen(true) + else void connection.connect() + } + const content = ( + <> + + } + trailing={ + connection.connected ? ( + + ) : undefined + } + /> + {connection.pending && Cancel} + {connection.error && ( +
+ {connection.error}{' '} + void connection.retry() : handleConnect}> + Retry + +
+ )} + {setupOpen && connector && ( + setSetupOpen(false)} + isPending={connection.isStarting} + error={connection.error} + onConnect={(config) => { + void connection.connect(config).then((started) => { + if (started) setSetupOpen(false) + }) + }} + /> + )} + + ) + return embedded ? content : {content} +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx index daa8df37bf0..797686b7699 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.test.tsx @@ -7,6 +7,7 @@ import { createRoot, type Root } from 'react-dom/client' import { beforeEach, describe, expect, it, vi } from 'vitest' const { + mockParams, mockRefetchPersonalEnvironment, mockRefetchWorkspaceCredentials, mockIsBrowserAgentAvailable, @@ -18,6 +19,7 @@ const { mockUseWorkspaceCredential, mockUseWorkspaceCredentials, } = vi.hoisted(() => ({ + mockParams: vi.fn(() => ({ workspaceId: 'workspace-1' })), mockUpdateWorkspaceCredential: vi.fn(async () => undefined), mockRefetchPersonalEnvironment: vi.fn(async () => ({ data: {} })), mockRefetchWorkspaceCredentials: vi.fn(async () => ({ data: [] })), @@ -35,7 +37,20 @@ vi.mock('@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' })) vi.mock('next/navigation', () => ({ - useParams: () => ({ workspaceId: 'workspace-1' }), + useParams: mockParams, +})) + +vi.mock('@/lib/auth/auth-client', () => ({ + useSession: () => ({ data: { user: { id: 'person' } } }), +})) +vi.mock('@/app/workspace/[workspaceId]/home/components/chat-surface-context', () => ({ + useChatSurface: () => ({ + SearchConnectionComponent: ({ onConnected }: { onConnected?: () => void }) => ( + + ), + }), })) vi.mock('@/hooks/queries/credentials', () => ({ @@ -98,6 +113,7 @@ describe('CredentialDisplay link tag', () => { beforeEach(() => { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true vi.clearAllMocks() + mockParams.mockReturnValue({ workspaceId: 'workspace-1' }) window.localStorage.clear() window.history.replaceState({}, '', '/workspace/workspace-1/chat/chat-1') mockUseUserPermissionsContext.mockReturnValue({ canEdit: true }) @@ -110,6 +126,38 @@ describe('CredentialDisplay link tag', () => { mockIsBrowserAgentAvailable.mockReturnValue(false) }) + it('keeps organization Search connection completion behind Submit', async () => { + mockParams.mockReturnValue({ organizationId: 'org' } as never) + const container = document.createElement('div') + const root = createRoot(container) + const onContinue = vi.fn() + act(() => + root.render( + + ) + ) + const connect = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Test Search connection' + ) + act(() => connect?.click()) + expect(onContinue).not.toHaveBeenCalled() + const submit = Array.from(container.querySelectorAll('button')).find( + (button) => button.textContent === 'Submit' + ) + expect(submit).toBeDefined() + await act(async () => submit?.click()) + expect(onContinue).toHaveBeenCalledOnce() + expect(onContinue.mock.calls[0][0]).toContain('connected') + act(() => root.unmount()) + }) it('renders browser takeover through the shared question UI', () => { mockIsBrowserAgentAvailable.mockReturnValue(true) const { container, root } = renderCredentialLink({ diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx index 0330603d98c..73ab07b3539 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags.tsx @@ -19,6 +19,14 @@ import { resolveOAuthServiceForSlug, resolveServiceAccountIntegration, } from '@/lib/integrations/oauth-service' +import { + readSearchConnectionAttempt, + searchConnectionAttemptKey, +} from '@/lib/knowledge/search/connection-attempt' +import { + parseSearchConnectionBody, + searchConnectionTargetSchema, +} from '@/lib/knowledge/search/connection-target' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' import { getServiceConfigByProviderId } from '@/lib/oauth/utils' import { finishTerminalHandoff, isTerminalAvailable } from '@/lib/terminal/transport' @@ -150,6 +158,9 @@ export interface CredentialItemData { * rotate the secret on this credential; absent = create a new one. */ credentialId?: string + /** Canonical Search source requested by an organization connection control. */ + connectorType?: string + connectorId?: string } /** @@ -327,11 +338,11 @@ export interface WorkspaceResourceTagData { export interface SourceTagData { /** Canonical http(s) link to the referenced document. */ url: string - /** Document title, shown on hover. */ + /** Document title, used as the citation label and shown in full on hover. */ title?: string /** - * Short chip label — the site or product the document lives in ("GitHub - * Docs", "Confluence"). Falls back to the URL's hostname. + * The site or product the document lives in ("GitHub Docs", "Confluence"), + * used when its title is missing and as secondary metadata in source cards. */ siteName?: string /** @@ -520,6 +531,8 @@ function isCredentialItemData(value: unknown): value is CredentialItemData { } return typeof value.provider === 'string' && value.provider.trim().length > 0 } + if (value.type === 'link' && value.connectorType !== undefined) + return searchConnectionTargetSchema.safeParse(value).success if (value.type === 'link' && value.value === undefined) { return typeof value.provider === 'string' && value.provider.trim().length > 0 } @@ -538,6 +551,8 @@ export function parseCredentialTagBody(body: string): CredentialTagData | null { try { const parsed = JSON.parse(body) as unknown const items = Array.isArray(parsed) ? parsed : [parsed] + if (items.some((item) => isRecordLike(item) && item.connectorType !== undefined)) + return parseSearchConnectionBody(body) return items.length > 0 && items.every(isCredentialItemData) ? items : null } catch { return null @@ -2691,7 +2706,7 @@ export function credentialTagHasVisibleCard( function CredentialItemDisplay({ data, requestMode, - controlId, + controlId = 'credential-link', embedded = false, divided = false, secretValue, @@ -2699,6 +2714,9 @@ function CredentialItemDisplay({ onSaved, onConnected, }: CredentialControlProps) { + const { organizationId } = useParams<{ organizationId?: string }>() + const { SearchConnectionComponent } = useChatSurface() + const { data: session } = useSession() if ( requestMode === 'assistant' && data.type !== 'link' && @@ -2738,6 +2756,23 @@ function CredentialItemDisplay({ if (data.type === 'link') { if (requestMode === 'assistant') { + if (organizationId) { + const target = searchConnectionTargetSchema.safeParse(data) + if (!target.success || !session?.user?.id) return null + if (!SearchConnectionComponent) + throw new Error('Search connection controls require an organization chat surface') + return ( + + ) + } return ( void }) { - const { workspaceId } = useParams<{ workspaceId: string }>() + const { workspaceId, organizationId } = useParams<{ + workspaceId: string + organizationId?: string + }>() + const { data: session } = useSession() const { canEdit } = useUserPermissionsContext() const upsertWorkspace = useUpsertWorkspaceEnvironment() const savePersonal = useSavePersonalEnvironment() @@ -2829,6 +2868,19 @@ function CredentialInputCard({ if (item.type !== 'link' && item.type !== 'service_account') continue const index = restoreIndex++ if (item.type !== 'link') continue + if (requestMode === 'assistant' && organizationId && session?.user?.id) { + const target = searchConnectionTargetSchema.safeParse(item) + if (!target.success) continue + const attempt = readSearchConnectionAttempt( + searchConnectionAttemptKey( + organizationId, + session.user.id, + `${controlIdPrefix}:${dataIndex}:${JSON.stringify(target.data)}` + ) + ) + if (attempt?.status === 'connected') restored.add(index) + continue + } const { providerId, reconnectCredentialId } = requestMode === 'assistant' ? { @@ -2851,7 +2903,15 @@ function CredentialInputCard({ if (Array.from(restored).every((index) => current.has(index))) return current return new Set([...current, ...restored]) }) - }, [abandoned, controlIdPrefix, data, workspaceId, requestMode]) + }, [ + abandoned, + controlIdPrefix, + data, + workspaceId, + requestMode, + organizationId, + session?.user?.id, + ]) let integrationIndex = 0 let secretIndex = 0 diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts index f9cb6f66bbe..dc297b1df74 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/resolve-citations.test.ts @@ -67,6 +67,37 @@ describe('evidence-linked citations', () => { ).blocks[1].content ).toEqual(resolveMessageCitations(blocks(), '', true).blocks[1].content) }) + it('retains a retrieved provider label after persistence instead of the internal index name', () => { + const providerOutput = structuredClone(output) + Object.assign(providerOutput.data.results[0], { + knowledgeBaseName: 'Sim Search', + siteName: 'Gmail', + connectorType: 'gmail', + }) + for (const result of [ + providerOutput, + compactRetrievalCitations('search_workspace', providerOutput), + ]) { + const resolved = resolveMessageCitations(blocks(result), '', true).blocks[1].content + expect(resolved).toContain('"title":"Actual title"') + expect(resolved).toContain('"siteName":"Gmail"') + expect(resolved).not.toContain('Sim Search') + } + }) + + it('keeps the document title when a follow-up uses only read_document evidence', () => { + const readBlocks = blocks({ + success: true, + data: { + ...output.data.results[0], + chunks: [{ content: 'Retrieved passage', chunkIndex: 0 }], + }, + }) + readBlocks[0].toolCall!.name = 'read_document' + expect(resolveMessageCitations(readBlocks, '', true).blocks[1].content).toContain( + '"title":"Actual title"' + ) + }) it('resolves source tags split across streamed text chunks before rendering', () => { const split = blocks().slice(0, 1) split.push( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index d84c76b2685..6fc88b74a04 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -1,5 +1,6 @@ 'use client' +import type { ComponentType } from 'react' import { memo, type ReactNode, @@ -34,6 +35,7 @@ import { parseLastCredentialTag, parseLastQuestionTag, } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags' +import type { SearchIntegrationConnectionProps } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/search-integration-connection' import { prepareCopyableMarkdown, toCopyableMarkdown, @@ -62,6 +64,7 @@ import { MothershipChatSkeleton } from './components/mothership-chat-skeleton' import { shouldShowAssistantMessageActions } from './message-actions-visibility' interface MothershipChatProps { + SearchConnectionComponent?: ComponentType workspaceId?: string composer?: ReactNode messages: ChatMessage[] @@ -333,6 +336,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ }) export function MothershipChat({ + SearchConnectionComponent, workspaceId, composer, messages: messagesProp, @@ -781,6 +785,7 @@ export function MothershipChat({ return ( ({ create: vi.fn(), update: vi.fn() })) + +vi.mock('@sim/emcn', () => ({ + ChipModal: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalBody: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalHeader: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalError: ({ children }: { children: ReactNode }) =>
{children}
, + ChipModalField: ({ + type, + title, + value, + onChange, + }: { + type: string + title: string + value?: string + onChange: (value: string) => void + }) => + type === 'file' ? null : ( +