diff --git a/.claude/rules/sim-architecture.md b/.claude/rules/sim-architecture.md index bc52fd37001..a0cfbfcd050 100644 --- a/.claude/rules/sim-architecture.md +++ b/.claude/rules/sim-architecture.md @@ -38,6 +38,10 @@ packages/ # @sim/* — audit, auth, db, logger, realtime-protocol - `apps/* → packages/*` only. Packages never import from `apps/*`. - `apps/realtime` avoids Next.js, React, the block/tool registry, provider SDKs, and the executor; never add `@/lib/webhooks/providers/*`, `@/executor/*`, `@/blocks/*`, or `@/tools/*` imports to any package it consumes. CI enforces this via `scripts/check-monorepo-boundaries.ts` and `scripts/check-realtime-prune-graph.ts`. +## The `'use client'` server boundary + +Every export of a `'use client'` module becomes a *client reference* on the server — server-evaluated code (RSC pages/layouts, `prefetch.ts`, route handlers, block definitions, triggers) can only *render* it as a component or pass it as a prop, never *call* it (doing so throws at runtime, e.g. `tableKeys.list is not a function`; `next build` does not catch it). Keep server-importable query primitives (key factories, fetchers, mappers, constants) in non-`'use client'` modules — see `.claude/rules/sim-queries.md`. Enforced by `scripts/check-client-boundary-imports.ts`. + ## Feature Organization Features live under `app/workspace/[workspaceId]/`: diff --git a/.claude/rules/sim-queries.md b/.claude/rules/sim-queries.md index 25707c740af..d1db9437ff0 100644 --- a/.claude/rules/sim-queries.md +++ b/.claude/rules/sim-queries.md @@ -27,6 +27,17 @@ Never use inline query keys — always use the factory. **Every identifier the `queryFn` forwards into the fetch MUST appear in the `queryKey`.** (Query-machinery identifiers — `signal`, `pageParam` — are exempt; they aren't fetch-scoping args.) If the fetch is scoped by `workspaceId`, `cursor`, `limit`, an org id, etc., those values must be part of the key — otherwise distinct fetch args share one cache entry (a cross-tenant / per-param cache collision). The lone exception is a globally-unique id used as the key while a second fetch arg is only an authz scope that cannot collide; annotate those with `// rq-lint-allow: `. Enforced by the `key-fetch-arg-drift` check in `scripts/check-react-query-patterns.ts`. +## Server-importable query primitives must NOT live in a `'use client'` module + +Next.js rewrites **every** export of a `'use client'` module into a *client reference* in the server bundle. Server-evaluated code — RSC `page.tsx`/`layout.tsx`, `prefetch.ts`, route handlers, **block definitions**, triggers/workers — can only *render* such an export as a component or pass it as a prop; **calling** one throws at runtime (`Attempted to call X from the server but X is on the client` — for an object export it surfaces as `X.list is not a function`). `next build` does **not** catch this — only SSR/runtime does. + +So any **query-key factory, standalone `requestJson` fetcher, mapper, or constant** that a server module imports must live in a **non-`'use client'`** module: + +- key factories → `hooks/queries/utils/-keys.ts` (see `folder-keys.ts`, `table-keys.ts`, `credential-keys.ts`) +- standalone fetchers/mappers → `hooks/queries/utils/fetch-*.ts` / `*-list-query.ts` (see `fetch-workflow-envelope.ts`, `fetch-credential-set.ts`) + +The `'use client'` hook module then imports these back for its hooks. **Never** define a server-imported factory/fetcher directly in a `'use client'` hooks file — it crashes SSR (this caused the tables-page crash). Enforced for prefetch/route/trigger/block files by `scripts/check-client-boundary-imports.ts` (`bun run check:client-boundary`, run in CI). Escape hatch for a genuinely browser-only path: `// client-boundary-allow: ` on the line above the import. + ## File Structure ```typescript diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 40f83645146..c4ffd7449ea 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -122,6 +122,9 @@ jobs: - name: React Query pattern audit run: bun run check:react-query + - name: Client boundary import audit + run: bun run check:client-boundary + - name: Verify realtime prune graph run: bun run check:realtime-prune diff --git a/apps/docs/content/docs/en/integrations/gitlab.mdx b/apps/docs/content/docs/en/integrations/gitlab.mdx index 6d7340bac8e..eb37db7210b 100644 --- a/apps/docs/content/docs/en/integrations/gitlab.mdx +++ b/apps/docs/content/docs/en/integrations/gitlab.mdx @@ -41,6 +41,7 @@ List GitLab projects accessible to the authenticated user | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `owned` | boolean | No | Limit to projects owned by the current user | | `membership` | boolean | No | Limit to projects the current user is a member of | | `search` | string | No | Search projects by name | @@ -65,6 +66,7 @@ Get details of a specific GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path \(e.g., "namespace/project"\) | #### Output @@ -81,6 +83,7 @@ List issues in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path | | `state` | string | No | Filter by state \(opened, closed, all\) | | `labels` | string | No | Comma-separated list of label names | @@ -107,6 +110,7 @@ Get details of a specific GitLab issue | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path | | `issueIid` | number | Yes | Issue number within the project \(the # shown in GitLab UI\) | @@ -124,6 +128,7 @@ Create a new issue in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path | | `title` | string | Yes | Issue title | | `description` | string | No | Issue description \(Markdown supported\) | @@ -147,6 +152,7 @@ Update an existing issue in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path | | `issueIid` | number | Yes | Issue internal ID \(IID\) | | `title` | string | No | New issue title | @@ -172,6 +178,7 @@ Delete an issue from a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path | | `issueIid` | number | Yes | Issue internal ID \(IID\) | @@ -189,6 +196,7 @@ Add a comment to a GitLab issue | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path | | `issueIid` | number | Yes | Issue internal ID \(IID\) | | `body` | string | Yes | Comment body \(Markdown supported\) | @@ -207,6 +215,7 @@ List merge requests in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path | | `state` | string | No | Filter by state \(opened, closed, merged, all\) | | `labels` | string | No | Comma-separated list of label names | @@ -232,6 +241,7 @@ Get details of a specific GitLab merge request | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | @@ -249,6 +259,7 @@ Create a new merge request in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path | | `sourceBranch` | string | Yes | Source branch name | | `targetBranch` | string | Yes | Target branch name | @@ -275,6 +286,7 @@ Update an existing merge request in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | | `title` | string | No | New merge request title | @@ -302,6 +314,7 @@ Merge a merge request in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | | `mergeCommitMessage` | string | No | Custom merge commit message | @@ -324,6 +337,7 @@ Add a comment to a GitLab merge request | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path | | `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | | `body` | string | Yes | Comment body \(Markdown supported\) | @@ -342,6 +356,7 @@ List pipelines in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path | | `ref` | string | No | Filter by ref \(branch or tag\) | | `status` | string | No | Filter by status \(created, waiting_for_resource, preparing, pending, running, success, failed, canceled, skipped, manual, scheduled\) | @@ -365,6 +380,7 @@ Get details of a specific GitLab pipeline | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path | | `pipelineId` | number | Yes | Pipeline ID | @@ -382,6 +398,7 @@ Trigger a new pipeline in a GitLab project | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path | | `ref` | string | Yes | Branch or tag to run the pipeline on | | `variables` | array | No | Array of variables for the pipeline \(each with key, value, and optional variable_type\) | @@ -400,6 +417,7 @@ Retry a failed GitLab pipeline | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path | | `pipelineId` | number | Yes | Pipeline ID | @@ -417,6 +435,7 @@ Cancel a running GitLab pipeline | Parameter | Type | Required | Description | | --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | | `projectId` | string | Yes | Project ID or URL-encoded path | | `pipelineId` | number | Yes | Pipeline ID | @@ -426,4 +445,449 @@ Cancel a running GitLab pipeline | --------- | ---- | ----------- | | `pipeline` | object | The cancelled GitLab pipeline | +### `gitlab_list_repository_tree` + +List files and directories in a GitLab project repository + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `projectId` | string | Yes | Project ID or URL-encoded path | +| `path` | string | No | Path inside the repository to list | +| `ref` | string | No | Branch, tag, or commit SHA to list from | +| `recursive` | boolean | No | Whether to list files recursively | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `tree` | array | List of repository tree entries | +| `total` | number | Total number of tree entries | + +### `gitlab_get_file` + +Get the contents of a file from a GitLab project repository + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `projectId` | string | Yes | Project ID or URL-encoded path | +| `filePath` | string | Yes | Path to the file in the repository | +| `ref` | string | Yes | Branch, tag, or commit SHA | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `filePath` | string | The file path | +| `fileName` | string | The file name | +| `size` | number | The file size in bytes | +| `ref` | string | The branch, tag, or commit SHA | +| `blobId` | string | The blob ID | +| `lastCommitId` | string | The last commit ID that modified the file | +| `content` | string | The decoded file content | + +### `gitlab_create_file` + +Create a new file in a GitLab project repository + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `projectId` | string | Yes | Project ID or URL-encoded path | +| `filePath` | string | Yes | Path to the file in the repository | +| `branch` | string | Yes | Branch to commit the new file to | +| `content` | string | Yes | File content | +| `commitMessage` | string | Yes | Commit message | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `filePath` | string | The created file path | +| `branch` | string | The branch the file was committed to | + +### `gitlab_update_file` + +Update an existing file in a GitLab project repository + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `projectId` | string | Yes | Project ID or URL-encoded path | +| `filePath` | string | Yes | Path to the file in the repository | +| `branch` | string | Yes | Branch to commit the update to | +| `content` | string | Yes | New file content | +| `commitMessage` | string | Yes | Commit message | +| `lastCommitId` | string | No | Last known commit ID for the file \(optimistic locking\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `filePath` | string | The updated file path | +| `branch` | string | The branch the update was committed to | + +### `gitlab_create_branch` + +Create a new branch in a GitLab project repository + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `projectId` | string | Yes | Project ID or URL-encoded path | +| `branch` | string | Yes | Name of the new branch | +| `ref` | string | Yes | Source branch/tag/SHA | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `name` | string | The created branch name | +| `webUrl` | string | The web URL of the branch | +| `protected` | boolean | Whether the branch is protected | +| `commit` | object | The commit the branch points to | + +### `gitlab_list_branches` + +List branches in a GitLab project repository + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `projectId` | string | Yes | Project ID or URL-encoded path | +| `search` | string | No | Filter branches by name | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `branches` | array | List of branches | +| `total` | number | Total number of branches | + +### `gitlab_list_commits` + +List commits in a GitLab project repository + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `projectId` | string | Yes | Project ID or URL-encoded path | +| `refName` | string | No | Branch, tag, or revision range to list commits from | +| `since` | string | No | Only commits after this ISO 8601 date | +| `until` | string | No | Only commits before this ISO 8601 date | +| `path` | string | No | Only commits affecting this file path | +| `author` | string | No | Filter commits by author | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `commits` | array | List of commits | +| `total` | number | Total number of commits | + +### `gitlab_get_merge_request_changes` + +Get the file changes (diffs) of a GitLab merge request + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `projectId` | string | Yes | Project ID or URL-encoded path | +| `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `mergeRequestIid` | number | The merge request internal ID \(IID\) | +| `changes` | array | List of file changes \(diffs\) | +| `changesCount` | number | Number of changed files returned | + +### `gitlab_approve_merge_request` + +Approve a GitLab merge request + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `projectId` | string | Yes | Project ID or URL-encoded path | +| `mergeRequestIid` | number | Yes | Merge request internal ID \(IID\) | +| `sha` | string | No | HEAD SHA of the merge request to approve | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `approvalsRequired` | number | Number of approvals required | +| `approvalsLeft` | number | Number of approvals still needed | +| `approvedBy` | array | List of approvers | + +### `gitlab_list_pipeline_jobs` + +List jobs for a GitLab pipeline + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `projectId` | string | Yes | Project ID or URL-encoded path | +| `pipelineId` | number | Yes | Pipeline ID | +| `scope` | string | No | Filter jobs by scope \(e.g. created, running, success, failed\) | +| `includeRetried` | boolean | No | Whether to include retried jobs | +| `perPage` | number | No | Number of results per page \(default 20, max 100\) | +| `page` | number | No | Page number for pagination | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `jobs` | array | List of pipeline jobs | +| `total` | number | Total number of jobs | + +### `gitlab_get_job_log` + +Get the log (trace) of a GitLab job + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `projectId` | string | Yes | Project ID or URL-encoded path | +| `jobId` | number | Yes | Job ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `log` | string | The job log \(trace\) output | + +### `gitlab_play_job` + +Trigger (play) a manual GitLab job + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `host` | string | No | Self-managed GitLab host \(e.g. gitlab.example.com\). Defaults to gitlab.com. | +| `projectId` | string | Yes | Project ID or URL-encoded path | +| `jobId` | number | Yes | Job ID | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `id` | number | The job ID | +| `name` | string | The job name | +| `status` | string | The job status | +| `webUrl` | string | The web URL of the job | + + + +## Triggers + +A **Trigger** is a block that starts a workflow when an event happens in this service. + +### GitLab Comment + +Trigger workflow when a comment is added on a commit, merge request, or issue + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | Used to create the webhook in your project. Requires the Maintainer or Owner role. | +| `projectId` | string | Yes | The GitLab project to register the webhook on. | +| `host` | string | No | Self-managed GitLab host. Leave blank for gitlab.com. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `object_kind` | string | Event kind \(note\) | +| `event_type` | string | GitLab event type from the X-Gitlab-Event header | +| `object_attributes` | object | object_attributes output from the tool | +| ↳ `id` | number | Comment ID | +| ↳ `note` | string | Comment body | +| ↳ `noteable_type` | string | What the comment is on \(Commit, MergeRequest, Issue, Snippet\) | +| ↳ `action` | string | Action \(create, update\) | +| ↳ `url` | string | Comment URL | + + +--- + +### GitLab Event + +Trigger workflow from any GitLab webhook event + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | Used to create the webhook in your project. Requires the Maintainer or Owner role. | +| `projectId` | string | Yes | The GitLab project to register the webhook on. | +| `host` | string | No | Self-managed GitLab host. Leave blank for gitlab.com. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `object_kind` | string | Event kind \(push, merge_request, issue, etc.\) | +| `event_type` | string | GitLab event type from the X-Gitlab-Event header | +| `user` | json | Actor that triggered the event \(when present\) | +| `object_attributes` | json | Event-specific attributes \(varies by object_kind\) | + + +--- + +### GitLab Issue + +Trigger workflow when an issue is opened, updated, or closed in GitLab + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | Used to create the webhook in your project. Requires the Maintainer or Owner role. | +| `projectId` | string | Yes | The GitLab project to register the webhook on. | +| `host` | string | No | Self-managed GitLab host. Leave blank for gitlab.com. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `object_kind` | string | Event kind \(issue\) | +| `event_type` | string | GitLab event type from the X-Gitlab-Event header | +| `object_attributes` | object | object_attributes output from the tool | +| ↳ `id` | number | Global issue ID | +| ↳ `iid` | number | Project-scoped issue number | +| ↳ `title` | string | Issue title | +| ↳ `state` | string | State \(opened, closed\) | +| ↳ `action` | string | Action \(open, close, reopen, update\) | +| ↳ `description` | string | Issue description | +| ↳ `confidential` | boolean | Whether the issue is confidential | +| ↳ `url` | string | Issue URL | + + +--- + +### GitLab Merge Request + +Trigger workflow when a merge request is opened, updated, or merged in GitLab + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | Used to create the webhook in your project. Requires the Maintainer or Owner role. | +| `projectId` | string | Yes | The GitLab project to register the webhook on. | +| `host` | string | No | Self-managed GitLab host. Leave blank for gitlab.com. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `object_kind` | string | Event kind \(merge_request\) | +| `event_type` | string | GitLab event type from the X-Gitlab-Event header | +| `object_attributes` | object | object_attributes output from the tool | +| ↳ `id` | number | Global merge request ID | +| ↳ `iid` | number | Project-scoped merge request number | +| ↳ `title` | string | Merge request title | +| ↳ `state` | string | State \(opened, closed, merged, locked\) | +| ↳ `action` | string | Action \(open, close, reopen, update, merge, etc.\) | +| ↳ `source_branch` | string | Source branch | +| ↳ `target_branch` | string | Target branch | +| ↳ `merge_status` | string | Merge status | +| ↳ `draft` | boolean | Whether the merge request is a draft | +| ↳ `url` | string | Merge request URL | + + +--- + +### GitLab Pipeline + +Trigger workflow when a pipeline status changes in GitLab + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | Used to create the webhook in your project. Requires the Maintainer or Owner role. | +| `projectId` | string | Yes | The GitLab project to register the webhook on. | +| `host` | string | No | Self-managed GitLab host. Leave blank for gitlab.com. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `object_kind` | string | Event kind \(pipeline\) | +| `event_type` | string | GitLab event type from the X-Gitlab-Event header | +| `object_attributes` | object | object_attributes output from the tool | +| ↳ `id` | number | Pipeline ID | +| ↳ `status` | string | Pipeline status \(success, failed, running, etc.\) | +| ↳ `detailed_status` | string | Detailed pipeline status | +| ↳ `ref` | string | Ref the pipeline ran on | +| ↳ `sha` | string | Commit SHA | +| ↳ `source` | string | Pipeline source \(push, web, schedule, etc.\) | +| ↳ `duration` | number | Pipeline duration in seconds | +| ↳ `url` | string | Pipeline URL | + + +--- + +### GitLab Push + +Trigger workflow when commits are pushed to a GitLab project + +#### Configuration + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `accessToken` | string | Yes | Used to create the webhook in your project. Requires the Maintainer or Owner role. | +| `projectId` | string | Yes | The GitLab project to register the webhook on. | +| `host` | string | No | Self-managed GitLab host. Leave blank for gitlab.com. | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `object_kind` | string | Event kind \(push\) | +| `event_type` | string | GitLab event type from the X-Gitlab-Event header | +| `ref` | string | Git ref that was pushed \(e.g. refs/heads/main\) | +| `branch` | string | Branch name derived from ref | +| `before` | string | SHA before the push | +| `after` | string | SHA after the push | +| `checkout_sha` | string | SHA of the most recent commit | +| `user_username` | string | Username of the pusher | +| `user_name` | string | Display name of the pusher | +| `user_email` | string | Email of the pusher | +| `total_commits_count` | number | Number of commits in the push | +| `commits` | json | Array of commit objects included in this push | diff --git a/apps/docs/content/docs/en/integrations/salesforce.mdx b/apps/docs/content/docs/en/integrations/salesforce.mdx index 1125dc36cdd..76e5729536c 100644 --- a/apps/docs/content/docs/en/integrations/salesforce.mdx +++ b/apps/docs/content/docs/en/integrations/salesforce.mdx @@ -27,7 +27,7 @@ The Salesforce tool is ideal for workflows where your agents need to streamline ## Usage Instructions -Integrate Salesforce into your workflow. Manage accounts, contacts, leads, opportunities, cases, and tasks with powerful automation capabilities. +Integrate Salesforce into your workflow. Manage accounts, contacts, leads, opportunities, cases, and tasks, run reports and SOQL queries, and manage org schema by creating custom fields and objects via the Tooling API. @@ -717,7 +717,7 @@ Delete a task ### `salesforce_list_reports` -Get a list of reports accessible by the current user +Get a list of up to 200 recently viewed reports for the current user #### Input @@ -814,7 +814,7 @@ Get a list of available report types ### `salesforce_list_dashboards` -Get a list of dashboards accessible by the current user +Get a list of recently used dashboards for the current user #### Input @@ -1029,6 +1029,150 @@ Get a list of all available Salesforce objects | ↳ `totalReturned` | number | Number of objects returned | | ↳ `success` | boolean | Salesforce operation success | +### `salesforce_create_custom_field` + +Create a custom field on a Salesforce object (e.g., Account) using the Tooling API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `idToken` | string | No | No description | +| `instanceUrl` | string | No | No description | +| `objectName` | string | Yes | API name of the object to add the field to \(e.g., Account, Contact, Lead, MyObject__c\) | +| `fieldName` | string | Yes | API name of the new field; the __c suffix is added automatically \(e.g., Region\) | +| `label` | string | No | Display label shown in the UI \(defaults to the field name when omitted\) | +| `fieldType` | string | Yes | Field data type: Text, TextArea, LongTextArea, Html, Number, Currency, Percent, Checkbox, Date, DateTime, Time, Phone, Email, Url, Picklist, or MultiselectPicklist | +| `length` | number | No | Maximum length for Text \(1-255\), LongTextArea, Html, or MultiselectPicklist fields | +| `precision` | number | No | Total number of digits for Number, Currency, or Percent fields \(1-18\) | +| `scale` | number | No | Number of digits to the right of the decimal for numeric fields | +| `visibleLines` | number | No | Number of visible lines for LongTextArea, Html, or MultiselectPicklist fields | +| `required` | boolean | No | Whether the field is required on record create/edit | +| `unique` | boolean | No | Whether the field enforces unique values | +| `externalId` | boolean | No | Whether the field is an external ID \(for Text, Number, or Email fields\) | +| `defaultValue` | string | No | Default value; for Checkbox fields use true or false | +| `description` | string | No | Internal description of the field | +| `inlineHelpText` | string | No | Help text shown next to the field in the UI | +| `picklistValues` | string | No | Comma-separated values for Picklist or MultiselectPicklist fields | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `output` | object | Created custom field metadata | +| ↳ `id` | string | Tooling API Id of the newly created custom field | +| ↳ `fullName` | string | Full API name of the field, including object \(e.g., Account.Region__c\) | +| ↳ `success` | boolean | Whether the create operation was successful | +| ↳ `created` | boolean | Whether the field was created \(always true on success\) | + +### `salesforce_update_custom_field` + +Update an existing custom field on a Salesforce object using the Tooling API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `idToken` | string | No | No description | +| `instanceUrl` | string | No | No description | +| `fieldId` | string | Yes | Tooling API Id of the custom field to update \(find it via the Tooling Query tool\) | +| `label` | string | No | Display label shown in the UI | +| `length` | number | No | Maximum length for Text, LongTextArea, Html, or MultiselectPicklist fields | +| `precision` | number | No | Total number of digits for Number, Currency, or Percent fields | +| `scale` | number | No | Number of digits to the right of the decimal for numeric fields | +| `visibleLines` | number | No | Number of visible lines for LongTextArea, Html, or MultiselectPicklist fields | +| `required` | boolean | No | Whether the field is required on record create/edit | +| `unique` | boolean | No | Whether the field enforces unique values | +| `externalId` | boolean | No | Whether the field is an external ID | +| `defaultValue` | string | No | Default value; for Checkbox fields use true or false | +| `description` | string | No | Internal description of the field | +| `inlineHelpText` | string | No | Help text shown next to the field in the UI | +| `picklistValues` | string | No | Comma-separated values to add to a Picklist or MultiselectPicklist field \(existing values are kept\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `output` | object | Updated custom field metadata | +| ↳ `id` | string | Tooling API Id of the updated custom field | +| ↳ `updated` | boolean | Whether the field was updated \(always true on success\) | + +### `salesforce_delete_custom_field` + +Delete a custom field from a Salesforce object using the Tooling API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `idToken` | string | No | No description | +| `instanceUrl` | string | No | No description | +| `fieldId` | string | Yes | Tooling API Id of the custom field to delete \(find it via the Tooling Query tool\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `output` | object | Deleted custom field metadata | +| ↳ `id` | string | Tooling API Id of the deleted custom field | +| ↳ `deleted` | boolean | Whether the field was deleted \(always true on success\) | + +### `salesforce_create_custom_object` + +Create a custom object in Salesforce using the Tooling API + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `idToken` | string | No | No description | +| `instanceUrl` | string | No | No description | +| `objectName` | string | Yes | API name of the new object; the __c suffix is added automatically \(e.g., Project\) | +| `label` | string | Yes | Singular display label for the object \(e.g., Project\) | +| `pluralLabel` | string | Yes | Plural display label for the object \(e.g., Projects\) | +| `nameFieldLabel` | string | No | Label for the standard Name field \(defaults to "<label> Name"\) | +| `description` | string | No | Internal description of the object | +| `sharingModel` | string | No | Org-wide sharing model: ReadWrite, Read, Private, or ControlledByParent \(default ReadWrite\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `output` | object | Created custom object metadata | +| ↳ `id` | string | Tooling API Id of the newly created custom object | +| ↳ `fullName` | string | Full API name of the object \(e.g., Project__c\) | +| ↳ `success` | boolean | Whether the create operation was successful | +| ↳ `created` | boolean | Whether the object was created \(always true on success\) | + +### `salesforce_tooling_query` + +Execute a SOQL query against the Tooling API to inspect metadata objects + +#### Input + +| Parameter | Type | Required | Description | +| --------- | ---- | -------- | ----------- | +| `idToken` | string | No | No description | +| `instanceUrl` | string | No | No description | +| `query` | string | Yes | Tooling SOQL query \(e.g., SELECT Id, DeveloperName FROM CustomField WHERE TableEnumOrId = 'Account'\) | + +#### Output + +| Parameter | Type | Description | +| --------- | ---- | ----------- | +| `success` | boolean | Operation success status | +| `output` | object | Tooling query results | +| ↳ `records` | array | Array of Tooling API records matching the query | +| ↳ `query` | string | The executed Tooling SOQL query | +| ↳ `metadata` | object | Response metadata | +| ↳ `totalReturned` | number | Number of records returned in this response | +| ↳ `hasMore` | boolean | Whether more records exist \(inverse of done\) | +| ↳ `success` | boolean | Salesforce operation success | + ## Triggers diff --git a/apps/docs/content/docs/en/workflows/blocks/meta.json b/apps/docs/content/docs/en/workflows/blocks/meta.json index 567a0c3417f..8b5cd58012a 100644 --- a/apps/docs/content/docs/en/workflows/blocks/meta.json +++ b/apps/docs/content/docs/en/workflows/blocks/meta.json @@ -2,7 +2,6 @@ "title": "Core Blocks", "pages": [ "agent", - "pi", "api", "function", "condition", @@ -17,6 +16,7 @@ "human-in-the-loop", "variables", "wait", - "credential" + "credential", + "pi" ] } diff --git a/apps/docs/content/docs/en/workflows/blocks/pi.mdx b/apps/docs/content/docs/en/workflows/blocks/pi.mdx index f98095bdb1a..2bee2901742 100644 --- a/apps/docs/content/docs/en/workflows/blocks/pi.mdx +++ b/apps/docs/content/docs/en/workflows/blocks/pi.mdx @@ -25,7 +25,7 @@ Pick the mode with the **Mode** dropdown. The fields below it change to match. Cloud runs entirely inside a disposable sandbox, so it never touches your machine. It clones the repo, lets the agent work with full read/shell/edit/git, pushes a branch, and opens a PR you review and merge. - Requires sandbox execution to be enabled (the Cloud option only appears when it is). -- Requires **your own provider API key (BYOK)** — the model key is handed to the sandbox, so Sim never injects a hosted key there. +- Requires **your own provider API key (BYOK)** — the model key is handed to the sandbox. - Needs a **GitHub token** with permission to clone, push, and open a PR (see [Setup](#setup-cloud)). - The deliverable is a **pull request** — nothing is committed to your default branch directly. @@ -118,7 +118,7 @@ The one case neither layer can rescue is a *first* prompt that already exceeds t ## Setup -### Cloud +### Cloud [#setup-cloud] Cloud runs in a sandbox image with the Pi CLI and git baked in. @@ -128,7 +128,7 @@ Cloud runs in a sandbox image with the Pi CLI and git baked in. - *Fine-grained:* select the repo, then **Contents: Read and write** + **Pull requests: Read and write**. - *Classic:* the **`repo`** scope. For org repos, authorize the token for SSO. -### Local +### Local [#setup-local] 1. **Enable SSH** on the target machine (on macOS: System Settings → General → Sharing → Remote Login). 2. **Expose it on a public host.** Sim blocks `localhost`/LAN, so use a TCP tunnel — for example `ngrok tcp 22`, which gives a `host:port` to put in **Host** and **Port**. diff --git a/apps/realtime/package.json b/apps/realtime/package.json index 99867ef852d..e8b1e1607be 100644 --- a/apps/realtime/package.json +++ b/apps/realtime/package.json @@ -26,6 +26,7 @@ "@sim/logger": "workspace:*", "@sim/platform-authz": "workspace:*", "@sim/realtime-protocol": "workspace:*", + "@sim/runtime-secrets": "workspace:*", "@sim/security": "workspace:*", "@sim/utils": "workspace:*", "@sim/workflow-persistence": "workspace:*", diff --git a/apps/realtime/src/bootstrap.ts b/apps/realtime/src/bootstrap.ts new file mode 100644 index 00000000000..fe786372052 --- /dev/null +++ b/apps/realtime/src/bootstrap.ts @@ -0,0 +1,9 @@ +/** + * Container entrypoint. Hydrates `process.env` from the runtime secret before + * loading the Socket.IO server, whose modules (`@/env`, DB preflight) read env + * at import time. See `@sim/runtime-secrets`. + */ +import { loadRuntimeSecrets } from '@sim/runtime-secrets' + +await loadRuntimeSecrets() +await import('@/index') diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 18c8aafb563..26dc06abe0d 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -7,6 +7,7 @@ import { NextResponse } from 'next/server' import { fileExportContract } from '@/lib/api/contracts/storage-transfer' import { parseRequest } from '@/lib/api/server' import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { extractEmbeddedImageIds } from '@/lib/copilot/tools/server/files/embedded-image-refs' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import type { StorageContext } from '@/lib/uploads/config' import { USE_BLOB_STORAGE } from '@/lib/uploads/config' @@ -19,9 +20,6 @@ const logger = createLogger('FilesExportAPI') const MARKDOWN_MIME_TYPES = new Set(['text/markdown', 'text/x-markdown']) const MARKDOWN_EXTENSIONS = new Set(['md', 'markdown']) -const VIEW_URL_RE = - /\/api\/files\/view\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/gi -const MAX_EMBEDDED_IMAGES = 50 function isMarkdown(originalName: string, contentType: string): boolean { if (MARKDOWN_MIME_TYPES.has(contentType)) return true @@ -82,10 +80,7 @@ export const GET = withRouteHandler( }) let mdContent = mdBuffer.toString('utf-8') - const imageIds = [...new Set([...mdContent.matchAll(VIEW_URL_RE)].map((m) => m[1]))].slice( - 0, - MAX_EMBEDDED_IMAGES - ) + const imageIds = extractEmbeddedImageIds(mdContent) logger.info('Exporting markdown', { id, imageCount: imageIds.length }) @@ -139,10 +134,11 @@ export const GET = withRouteHandler( for (const [imageId, asset] of assetMap) { const escapedId = imageId.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') const replacement = `./assets/${asset.filename}` - mdContent = mdContent.replace( - new RegExp(`/api/files/view/${escapedId}`, 'g'), - () => replacement - ) + // Rewrite both embed spellings the extractor resolves to this id — the view URL and the in-app + // `/workspace//files/` path — so a bundled asset never leaves a broken link in the export. + mdContent = mdContent + .replace(new RegExp(`/api/files/view/${escapedId}`, 'g'), () => replacement) + .replace(new RegExp(`/workspace/[A-Za-z0-9-]+/files/${escapedId}`, 'g'), () => replacement) } const zip = new JSZip() diff --git a/apps/sim/app/api/files/public/[token]/inline/route.test.ts b/apps/sim/app/api/files/public/[token]/inline/route.test.ts new file mode 100644 index 00000000000..3f2b654bda0 --- /dev/null +++ b/apps/sim/app/api/files/public/[token]/inline/route.test.ts @@ -0,0 +1,116 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockResolveShare, mockRateLimit, mockValidateAuth, mockDownloadFile, mockResolveImage } = + vi.hoisted(() => ({ + mockResolveShare: vi.fn(), + mockRateLimit: vi.fn(), + mockValidateAuth: vi.fn(), + mockDownloadFile: vi.fn(), + mockResolveImage: vi.fn(), + })) + +vi.mock('@/lib/public-shares/share-manager', () => ({ + resolveActiveShareByToken: mockResolveShare, +})) +vi.mock('@/lib/public-shares/rate-limit', () => ({ enforcePublicFileRateLimit: mockRateLimit })) +vi.mock('@/lib/core/security/deployment-auth', () => ({ validateDeploymentAuth: mockValidateAuth })) +vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile })) +vi.mock('@/lib/uploads/server/inline-image', () => ({ + resolveWorkspaceInlineImage: mockResolveImage, +})) + +import { GET } from '@/app/api/files/public/[token]/inline/route' + +const TOKEN = 'tok_share_123456' +const DOC_KEY = 'workspace/ws-1/doc.md' +const IMG_KEY = 'workspace/ws-1/photo.png' +const FILE_ID = 'wf_YwDXi8eWOkTxn0sbgChlB' +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]) + +const params = { params: Promise.resolve({ token: TOKEN }) } +const req = (q: string) => new NextRequest(`http://localhost/api/files/public/${TOKEN}/inline?${q}`) + +const share = { + share: { id: 'sh_1', token: TOKEN, authType: 'public' }, + file: { id: 'wf_doc', key: DOC_KEY, workspaceId: 'ws-1', originalName: 'doc.md' }, + workspaceName: 'Acme', + ownerName: 'Jane', +} + +/** doc bytes embed the image via the view form; image bytes are a real PNG */ +function downloadByKey(docContent = `![a](/api/files/view/${FILE_ID})`) { + return ({ key }: { key: string }) => + Promise.resolve(key === DOC_KEY ? Buffer.from(docContent, 'utf-8') : PNG) +} + +describe('GET /api/files/public/[token]/inline', () => { + beforeEach(() => { + vi.clearAllMocks() + mockRateLimit.mockResolvedValue(null) + mockResolveShare.mockResolvedValue(share) + mockValidateAuth.mockResolvedValue({ authorized: true }) + mockResolveImage.mockResolvedValue({ + key: IMG_KEY, + contentType: 'image/png', + filename: 'photo.png', + }) + mockDownloadFile.mockImplementation(downloadByKey()) + }) + + it('serves a same-workspace image referenced by the doc, typed from its bytes', async () => { + const res = await GET(req(`fileId=${FILE_ID}`), params) + expect(res.status).toBe(200) + expect(res.headers.get('content-type')).toBe('image/png') + }) + + it('serves a key-referenced image', async () => { + mockDownloadFile.mockImplementation( + downloadByKey(`![a](/api/files/serve/${encodeURIComponent(IMG_KEY)}?context=workspace)`) + ) + const res = await GET(req(`key=${encodeURIComponent(IMG_KEY)}`), params) + expect(res.status).toBe(200) + }) + + it('404s when the reference is not embedded in the shared document', async () => { + mockDownloadFile.mockImplementation(downloadByKey('no images here')) + const res = await GET(req(`fileId=${FILE_ID}`), params) + expect(res.status).toBe(404) + expect(mockResolveImage).not.toHaveBeenCalled() + }) + + it('404s when the referenced file is not in the document workspace', async () => { + mockResolveImage.mockResolvedValue(null) + const res = await GET(req(`fileId=${FILE_ID}`), params) + expect(res.status).toBe(404) + }) + + it('404s when the bytes are not a renderable image', async () => { + mockDownloadFile.mockImplementation(({ key }: { key: string }) => + Promise.resolve( + key === DOC_KEY + ? Buffer.from(`![a](/api/files/view/${FILE_ID})`, 'utf-8') + : Buffer.from('', 'utf-8') + ) + ) + const res = await GET(req(`fileId=${FILE_ID}`), params) + expect(res.status).toBe(404) + }) + + it('401s and never reads storage when the share is unauthorized', async () => { + mockValidateAuth.mockResolvedValue({ authorized: false, error: 'auth_required_password' }) + const res = await GET(req(`fileId=${FILE_ID}`), params) + expect(res.status).toBe(401) + expect(mockDownloadFile).not.toHaveBeenCalled() + }) + + it('404s for an unknown or inactive token', async () => { + mockResolveShare.mockResolvedValue(null) + const res = await GET(req(`fileId=${FILE_ID}`), params) + expect(res.status).toBe(404) + expect(mockDownloadFile).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/files/public/[token]/inline/route.ts b/apps/sim/app/api/files/public/[token]/inline/route.ts new file mode 100644 index 00000000000..87c343a26a8 --- /dev/null +++ b/apps/sim/app/api/files/public/[token]/inline/route.ts @@ -0,0 +1,99 @@ +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getPublicInlineFileContract } from '@/lib/api/contracts/public-shares' +import { parseRequest } from '@/lib/api/server' +import { + extractEmbeddedImageIds, + extractEmbeddedImageKeys, +} from '@/lib/copilot/tools/server/files/embedded-image-refs' +import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' +import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image' +import { serveInlineImage } from '@/app/api/files/serve-inline-image' +import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('PublicInlineFileAPI') + +/** + * GET /api/files/public/[token]/inline?key=|fileId= + * + * Cascades a markdown document's public share to the images it embeds, so a logged-out viewer sees them + * instead of broken icons. The share grants the document bytes; this route extends that grant to the + * document's referenced images only, behind three gates that together hold the security boundary: + * + * 1. Referenced-by-doc — the requested key/id must appear in the shared document's current bytes. The + * token is a capability for the document and its embeds, never an arbitrary workspace file. + * 2. Same-workspace — the referenced file must be a `workspace` file in the document's own workspace + * ({@link resolveWorkspaceInlineImage}). This blocks any cross-workspace reference (which an author + * can write but must never resolve) from loading. + * 3. Content-truth — the served content type is sniffed from the bytes, not the client-declared type, + * and only genuine raster images are served. A file spoofing `image/png` while holding HTML/SVG is + * refused rather than rendered inline. + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { + const requestId = generateRequestId() + + try { + const limited = await enforcePublicFileRateLimit(request, 'content') + if (limited) return limited + + const parsed = await parseRequest(getPublicInlineFileContract, request, context) + if (!parsed.success) return parsed.response + const { token } = parsed.data.params + const ref = parsed.data.query + + const resolved = await resolveActiveShareByToken(token) + if (!resolved) { + throw new FileNotFoundError('Not found') + } + + const auth = await validateDeploymentAuth( + requestId, + resolved.share, + request, + undefined, + 'file' + ) + if (!auth.authorized) { + return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) + } + + const { file: doc } = resolved + if (!doc.workspaceId) { + throw new FileNotFoundError('Not found') + } + + // Referenced-by-doc gate: the share grants exactly the images the document embeds. + const docText = (await downloadFile({ key: doc.key, context: 'workspace' })).toString('utf-8') + const referenced = ref.fileId + ? extractEmbeddedImageIds(docText).includes(ref.fileId) + : extractEmbeddedImageKeys(docText).includes(ref.key as string) + if (!referenced) { + throw new FileNotFoundError('Not found') + } + + // Same-workspace gate: resolve scoped to the document's own workspace. + const image = await resolveWorkspaceInlineImage(doc.workspaceId, ref) + if (!image) { + throw new FileNotFoundError('Not found') + } + + // Content-truth gate (`sniff`): render only genuine raster image bytes. + return await serveInlineImage(image, { sniff: true }) + } catch (error) { + if (error instanceof FileNotFoundError) { + return createErrorResponse(error) + } + logger.error('Error serving public inline image:', error) + return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file')) + } + } +) diff --git a/apps/sim/app/api/files/serve-inline-image.ts b/apps/sim/app/api/files/serve-inline-image.ts new file mode 100644 index 00000000000..88c3383d961 --- /dev/null +++ b/apps/sim/app/api/files/serve-inline-image.ts @@ -0,0 +1,44 @@ +import { createLogger } from '@sim/logger' +import type { NextResponse } from 'next/server' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import type { ResolvedInlineImage } from '@/lib/uploads/server/inline-image' +import { sniffImageContentType } from '@/lib/uploads/utils/validation' +import { createFileResponse, FileNotFoundError } from '@/app/api/files/utils' + +const logger = createLogger('InlineImageServe') + +/** + * A shared/edited/deleted file must never serve stale bytes from its fixed inline URL, so every inline + * image revalidates on each request. + */ +const INLINE_CACHE_CONTROL = 'private, no-cache, must-revalidate' + +/** + * Download and respond with an already-workspace-scoped inline image — the single serving tail for both + * the in-app and public inline routes. When `sniff` is set (public shares, a less-trusted audience), the + * served content type is derived from the bytes and non-raster content is refused with 404; otherwise the + * stored content type is served, matching the in-app serve route. + */ +export async function serveInlineImage( + image: ResolvedInlineImage, + { sniff }: { sniff: boolean } +): Promise { + const buffer = await downloadFile({ key: image.key, context: 'workspace' }) + + let contentType = image.contentType + if (sniff) { + const sniffed = sniffImageContentType(buffer) + if (!sniffed) { + logger.warn('Embedded reference is not a renderable image', { key: image.key }) + throw new FileNotFoundError('Not found') + } + contentType = sniffed + } + + return createFileResponse({ + buffer, + contentType, + filename: image.filename, + cacheControl: INLINE_CACHE_CONTROL, + }) +} diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts new file mode 100644 index 00000000000..3bb2a8a06ba --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts @@ -0,0 +1,77 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockGetPerms, mockResolveImage, mockDownloadFile } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockGetPerms: vi.fn(), + mockResolveImage: vi.fn(), + mockDownloadFile: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ + auth: { api: { getSession: vi.fn() } }, + getSession: mockGetSession, +})) +vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetPerms })) +vi.mock('@/lib/uploads/server/inline-image', () => ({ + resolveWorkspaceInlineImage: mockResolveImage, +})) +vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile })) + +import { GET } from '@/app/api/workspaces/[id]/files/inline/route' + +const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]) +const params = { params: Promise.resolve({ id: 'ws-1' }) } +const req = (q: string) => new NextRequest(`http://localhost/api/workspaces/ws-1/files/inline?${q}`) + +describe('GET /api/workspaces/[id]/files/inline', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue({ user: { id: 'u1' } }) + mockGetPerms.mockResolvedValue('read') + mockResolveImage.mockResolvedValue({ + key: 'workspace/ws-1/x-photo.png', + contentType: 'image/png', + filename: 'photo.png', + }) + mockDownloadFile.mockResolvedValue(PNG) + }) + + it('serves a workspace-scoped image by fileId', async () => { + const res = await GET(req('fileId=wf_abc'), params) + expect(res.status).toBe(200) + expect(mockResolveImage).toHaveBeenCalledWith('ws-1', { fileId: 'wf_abc' }) + }) + + it('serves a workspace-scoped image by key', async () => { + const res = await GET(req(`key=${encodeURIComponent('workspace/ws-1/x-photo.png')}`), params) + expect(res.status).toBe(200) + }) + + it('404s when the reference does not resolve in the workspace (cross-workspace)', async () => { + mockResolveImage.mockResolvedValue(null) + const res = await GET(req('fileId=wf_other'), params) + expect(res.status).toBe(404) + }) + + it('404s without workspace membership, before resolving the file', async () => { + mockGetPerms.mockResolvedValue(null) + const res = await GET(req('fileId=wf_abc'), params) + expect(res.status).toBe(404) + expect(mockResolveImage).not.toHaveBeenCalled() + }) + + it('401s without a session', async () => { + mockGetSession.mockResolvedValue(null) + const res = await GET(req('fileId=wf_abc'), params) + expect(res.status).toBe(401) + }) + + it('400s when neither key nor fileId is provided', async () => { + const res = await GET(req(''), params) + expect(res.status).toBe(400) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts new file mode 100644 index 00000000000..245fb5731d8 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts @@ -0,0 +1,59 @@ +import { createLogger } from '@sim/logger' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { getInlineWorkspaceFileContract } from '@/lib/api/contracts/workspace-files' +import { parseRequest } from '@/lib/api/server' +import { getSession } from '@/lib/auth' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { serveInlineImage } from '@/app/api/files/serve-inline-image' +import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils' + +export const dynamic = 'force-dynamic' + +const logger = createLogger('WorkspaceInlineFileAPI') + +/** + * GET /api/workspaces/[id]/files/inline?key=|fileId= + * + * Serves an image embedded in a workspace markdown document, **scoped to the workspace in the path**. + * The markdown editor rewrites its embedded `/api/files/serve/` and `/api/files/view/` srcs to + * this route so a referenced file resolves only within the document's workspace — a cross-workspace + * reference returns 404 and does not render, even for a viewer who belongs to the other workspace. Read + * access to the workspace is required; disposition/content-type handling mirrors the serve route. + */ +export const GET = withRouteHandler( + async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { + try { + const parsed = await parseRequest(getInlineWorkspaceFileContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId } = parsed.data.params + const ref = parsed.data.query + + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } + + // Authorize before disclosing anything; deny with 404 so a non-member can't probe existence. + const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) + if (!permission) { + throw new FileNotFoundError('Not found') + } + + const image = await resolveWorkspaceInlineImage(workspaceId, ref) + if (!image) { + throw new FileNotFoundError('Not found') + } + + return await serveInlineImage(image, { sniff: false }) + } catch (error) { + if (error instanceof FileNotFoundError) { + return createErrorResponse(error) + } + logger.error('Error serving workspace inline image:', error) + return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file')) + } + } +) diff --git a/apps/sim/app/chat/hooks/use-chat-streaming.ts b/apps/sim/app/chat/hooks/use-chat-streaming.ts index f4c9fcb9b97..dd315dafe73 100644 --- a/apps/sim/app/chat/hooks/use-chat-streaming.ts +++ b/apps/sim/app/chat/hooks/use-chat-streaming.ts @@ -3,6 +3,7 @@ import { useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { readSSEEvents } from '@/lib/core/utils/sse' import { isUserFileWithMetadata } from '@/lib/core/utils/user-file' import type { ChatFile, ChatMessage } from '@/app/chat/components/message/message' import { CHAT_ERROR_MESSAGES } from '@/app/chat/constants' @@ -125,14 +126,12 @@ export function useChatStreaming() { streamingOptions?.voiceSettings?.autoPlayResponses && streamingOptions?.audioStreamHandler - const reader = response.body?.getReader() - if (!reader) { + if (!response.body) { setIsLoading(false) setIsStreamingResponse(false) return } - const decoder = new TextDecoder() let accumulatedText = '' let lastAudioPosition = 0 @@ -192,264 +191,252 @@ export function useChatStreaming() { setIsLoading(false) + let terminated = false + try { - while (true) { - // Check if aborted - if (abortControllerRef.current === null) { - break + await readSSEEvents<{ + blockId?: string + chunk?: string + event?: string + error?: string + data?: { + success: boolean + error?: string | { message?: string } + output?: Record> } - - const { done, value } = await reader.read() - - if (done) { - flushUI() - // Stream any remaining text for TTS - if ( - shouldPlayAudio && - streamingOptions?.audioStreamHandler && - accumulatedText.length > lastAudioPosition - ) { - const remainingText = accumulatedText.substring(lastAudioPosition).trim() - if (remainingText) { - try { - await streamingOptions.audioStreamHandler(remainingText) - } catch (error) { - logger.error('TTS error for remaining text:', error) - } - } + }>(response.body, { + signal: abortControllerRef.current.signal, + onParseError: (_data, parseError) => { + logger.error('Error parsing stream data:', parseError) + }, + onEvent: async (json) => { + const { blockId, chunk: contentChunk, event: eventType } = json + + if (eventType === 'error' || json.event === 'error') { + const errorMessage = json.error || CHAT_ERROR_MESSAGES.GENERIC_ERROR + setMessages((prev) => + prev.map((msg) => + msg.id === messageId + ? { + ...msg, + content: errorMessage, + isStreaming: false, + type: 'assistant' as const, + } + : msg + ) + ) + setIsLoading(false) + terminated = true + return true } - break - } - const chunk = decoder.decode(value, { stream: true }) - const lines = chunk.split('\n\n') + if (eventType === 'final' && json.data) { + flushUI() + const finalData = json.data - for (const line of lines) { - if (line.startsWith('data: ')) { - const data = line.substring(6) + const outputConfigs = streamingOptions?.outputConfigs + const formattedOutputs: string[] = [] + let extractedFiles: ChatFile[] = [] - if (data === '[DONE]') { - continue - } + const formatValue = (value: any): string | null => { + if (value === null || value === undefined) { + return null + } - try { - const json = JSON.parse(data) - const { blockId, chunk: contentChunk, event: eventType } = json - - if (eventType === 'error' || json.event === 'error') { - const errorMessage = json.error || CHAT_ERROR_MESSAGES.GENERIC_ERROR - setMessages((prev) => - prev.map((msg) => - msg.id === messageId - ? { - ...msg, - content: errorMessage, - isStreaming: false, - type: 'assistant' as const, - } - : msg - ) - ) - setIsLoading(false) - return + if (isUserFileWithMetadata(value)) { + return null } - if (eventType === 'final' && json.data) { - flushUI() - const finalData = json.data as { - success: boolean - error?: string | { message?: string } - output?: Record> + if (Array.isArray(value) && value.length === 0) { + return null + } + + if (typeof value === 'string') { + return value + } + + if (typeof value === 'object') { + try { + return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\`` + } catch { + return String(value) } + } - const outputConfigs = streamingOptions?.outputConfigs - const formattedOutputs: string[] = [] - let extractedFiles: ChatFile[] = [] + return String(value) + } - const formatValue = (value: any): string | null => { - if (value === null || value === undefined) { - return null - } + const getOutputValue = (blockOutputs: Record, path?: string) => { + if (!path || path === 'content') { + if (blockOutputs.content !== undefined) return blockOutputs.content + if (blockOutputs.result !== undefined) return blockOutputs.result + return blockOutputs + } - if (isUserFileWithMetadata(value)) { - return null - } + if (blockOutputs[path] !== undefined) { + return blockOutputs[path] + } - if (Array.isArray(value) && value.length === 0) { - return null + if (path.includes('.')) { + return path.split('.').reduce((current, segment) => { + if (current && typeof current === 'object' && segment in current) { + return current[segment] } + return undefined + }, blockOutputs) + } - if (typeof value === 'string') { - return value - } + return undefined + } - if (typeof value === 'object') { - try { - return `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\`` - } catch { - return String(value) - } - } + if (outputConfigs?.length && finalData.output) { + for (const config of outputConfigs) { + const blockOutputs = finalData.output[config.blockId] + if (!blockOutputs) continue + + const value = getOutputValue(blockOutputs, config.path) + + if (isUserFileWithMetadata(value)) { + extractedFiles.push({ + id: value.id, + name: value.name, + url: value.url, + key: value.key, + size: value.size, + type: value.type, + context: value.context, + }) + continue + } - return String(value) + const nestedFiles = extractFilesFromData(value) + if (nestedFiles.length > 0) { + extractedFiles = [...extractedFiles, ...nestedFiles] + continue } - const getOutputValue = (blockOutputs: Record, path?: string) => { - if (!path || path === 'content') { - if (blockOutputs.content !== undefined) return blockOutputs.content - if (blockOutputs.result !== undefined) return blockOutputs.result - return blockOutputs - } + const formatted = formatValue(value) + if (formatted) { + formattedOutputs.push(formatted) + } + } + } - if (blockOutputs[path] !== undefined) { - return blockOutputs[path] - } + let finalContent = accumulatedText - if (path.includes('.')) { - return path.split('.').reduce((current, segment) => { - if (current && typeof current === 'object' && segment in current) { - return current[segment] - } - return undefined - }, blockOutputs) - } + if (formattedOutputs.length > 0) { + const nonEmptyOutputs = formattedOutputs.filter((output) => output.trim()) + if (nonEmptyOutputs.length > 0) { + const combinedOutputs = nonEmptyOutputs.join('\n\n') + finalContent = finalContent + ? `${finalContent.trim()}\n\n${combinedOutputs}` + : combinedOutputs + } + } - return undefined + if (!finalContent && extractedFiles.length === 0) { + if (finalData.error) { + if (typeof finalData.error === 'string') { + finalContent = finalData.error + } else if (typeof finalData.error?.message === 'string') { + finalContent = finalData.error.message } + } else if (finalData.success && finalData.output) { + const fallbackOutput = Object.values(finalData.output) + .map((block) => formatValue(block)?.trim()) + .filter(Boolean)[0] + if (fallbackOutput) { + finalContent = fallbackOutput + } + } + } - if (outputConfigs?.length && finalData.output) { - for (const config of outputConfigs) { - const blockOutputs = finalData.output[config.blockId] - if (!blockOutputs) continue - - const value = getOutputValue(blockOutputs, config.path) - - if (isUserFileWithMetadata(value)) { - extractedFiles.push({ - id: value.id, - name: value.name, - url: value.url, - key: value.key, - size: value.size, - type: value.type, - context: value.context, - }) - continue - } - - const nestedFiles = extractFilesFromData(value) - if (nestedFiles.length > 0) { - extractedFiles = [...extractedFiles, ...nestedFiles] - continue + setMessages((prev) => + prev.map((msg) => + msg.id === messageId + ? { + ...msg, + isStreaming: false, + content: finalContent ?? msg.content, + files: extractedFiles.length > 0 ? extractedFiles : undefined, } + : msg + ) + ) - const formatted = formatValue(value) - if (formatted) { - formattedOutputs.push(formatted) - } - } - } + accumulatedTextRef.current = '' + lastStreamedPositionRef.current = 0 + lastDisplayedPositionRef.current = 0 + audioStreamingActiveRef.current = false - let finalContent = accumulatedText + terminated = true + return true + } - if (formattedOutputs.length > 0) { - const nonEmptyOutputs = formattedOutputs.filter((output) => output.trim()) - if (nonEmptyOutputs.length > 0) { - const combinedOutputs = nonEmptyOutputs.join('\n\n') - finalContent = finalContent - ? `${finalContent.trim()}\n\n${combinedOutputs}` - : combinedOutputs - } - } + if (blockId && contentChunk) { + if (!messageIdMap.has(blockId)) { + messageIdMap.set(blockId, messageId) + } - if (!finalContent && extractedFiles.length === 0) { - if (finalData.error) { - if (typeof finalData.error === 'string') { - finalContent = finalData.error - } else if (typeof finalData.error?.message === 'string') { - finalContent = finalData.error.message - } - } else if (finalData.success && finalData.output) { - const fallbackOutput = Object.values(finalData.output) - .map((block) => formatValue(block)?.trim()) - .filter(Boolean)[0] - if (fallbackOutput) { - finalContent = fallbackOutput - } - } + accumulatedText += contentChunk + accumulatedTextRef.current = accumulatedText + logger.debug('[useChatStreaming] Received chunk', { + blockId, + chunkLength: contentChunk.length, + totalLength: accumulatedText.length, + messageId, + chunk: contentChunk.substring(0, 20), + }) + uiDirty = true + scheduleUIFlush() + + if (shouldPlayAudio && streamingOptions?.audioStreamHandler) { + const newText = accumulatedText.substring(lastAudioPosition) + const sentenceEndings = ['. ', '! ', '? ', '.\n', '!\n', '?\n', '.', '!', '?'] + let sentenceEnd = -1 + + for (const ending of sentenceEndings) { + const index = newText.indexOf(ending) + if (index > 0) { + sentenceEnd = index + ending.length + break } - - setMessages((prev) => - prev.map((msg) => - msg.id === messageId - ? { - ...msg, - isStreaming: false, - content: finalContent ?? msg.content, - files: extractedFiles.length > 0 ? extractedFiles : undefined, - } - : msg - ) - ) - - accumulatedTextRef.current = '' - lastStreamedPositionRef.current = 0 - lastDisplayedPositionRef.current = 0 - audioStreamingActiveRef.current = false - - return } - if (blockId && contentChunk) { - if (!messageIdMap.has(blockId)) { - messageIdMap.set(blockId, messageId) - } - - accumulatedText += contentChunk - accumulatedTextRef.current = accumulatedText - logger.debug('[useChatStreaming] Received chunk', { - blockId, - chunkLength: contentChunk.length, - totalLength: accumulatedText.length, - messageId, - chunk: contentChunk.substring(0, 20), - }) - uiDirty = true - scheduleUIFlush() - - // Real-time TTS for voice mode - if (shouldPlayAudio && streamingOptions?.audioStreamHandler) { - const newText = accumulatedText.substring(lastAudioPosition) - const sentenceEndings = ['. ', '! ', '? ', '.\n', '!\n', '?\n', '.', '!', '?'] - let sentenceEnd = -1 - - for (const ending of sentenceEndings) { - const index = newText.indexOf(ending) - if (index > 0) { - sentenceEnd = index + ending.length - break - } - } - - if (sentenceEnd > 0) { - const sentence = newText.substring(0, sentenceEnd).trim() - if (sentence && sentence.length >= 3) { - try { - await streamingOptions.audioStreamHandler(sentence) - lastAudioPosition += sentenceEnd - } catch (error) { - logger.error('TTS error:', error) - } - } + if (sentenceEnd > 0) { + const sentence = newText.substring(0, sentenceEnd).trim() + if (sentence && sentence.length >= 3) { + try { + await streamingOptions.audioStreamHandler(sentence) + lastAudioPosition += sentenceEnd + } catch (error) { + logger.error('TTS error:', error) } } - } else if (blockId && eventType === 'end') { - setMessages((prev) => - prev.map((msg) => (msg.id === messageId ? { ...msg, isStreaming: false } : msg)) - ) } - } catch (parseError) { - logger.error('Error parsing stream data:', parseError) + } + } else if (blockId && eventType === 'end') { + setMessages((prev) => + prev.map((msg) => (msg.id === messageId ? { ...msg, isStreaming: false } : msg)) + ) + } + }, + }) + + if (!terminated) { + flushUI() + if ( + shouldPlayAudio && + streamingOptions?.audioStreamHandler && + accumulatedText.length > lastAudioPosition + ) { + const remainingText = accumulatedText.substring(lastAudioPosition).trim() + if (remainingText) { + try { + await streamingOptions.audioStreamHandler(remainingText) + } catch (error) { + logger.error('TTS error for remaining text:', error) } } } diff --git a/apps/sim/app/f/[token]/public-file-view.tsx b/apps/sim/app/f/[token]/public-file-view.tsx index f27b63df65d..360119e4945 100644 --- a/apps/sim/app/f/[token]/public-file-view.tsx +++ b/apps/sim/app/f/[token]/public-file-view.tsx @@ -9,7 +9,7 @@ import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { buildProvenance } from '@/app/f/[token]/utils' import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer' import { useBrandConfig } from '@/ee/whitelabeling' -import { type FileContentSource, FileContentSourceProvider } from '@/hooks/use-file-content-source' +import { createPublicFileContentSource } from '@/hooks/use-file-content-source' interface PublicFileViewProps { token: string @@ -41,7 +41,12 @@ export function PublicFileView({ // `updatedAt` fold in the content version so the React Query caches (keyed on the // storage key + `updatedAt`) refetch when the shared file changes — even when its // size is unchanged. - const source = useMemo(() => ({ buildUrl: () => contentUrl }), [contentUrl]) + // Embedded images route through the token-scoped cascade endpoint, which serves them only when the + // shared document actually references them and they live in its workspace. + const source = useMemo( + () => createPublicFileContentSource(token, contentUrl), + [token, contentUrl] + ) const file = useMemo( () => ({ id: token, @@ -116,9 +121,13 @@ export function PublicFileView({
- - - +
) diff --git a/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/error.tsx b/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/error.tsx new file mode 100644 index 00000000000..1ebfc47facb --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/chat/[chatId]/error.tsx @@ -0,0 +1,15 @@ +'use client' + +import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components' + +export default function ChatError({ error, reset }: ErrorBoundaryProps) { + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 2e1d4d834ea..3d4f4b5d3e9 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -6,6 +6,11 @@ import dynamic from 'next/dynamic' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { getFileExtension } from '@/lib/uploads/utils/file-utils' import { useWorkspaceFileBinary, useWorkspaceFileContent } from '@/hooks/queries/workspace-files' +import { + createWorkspaceFileContentSource, + type FileContentSource, + FileContentSourceProvider, +} from '@/hooks/use-file-content-source' import { CsvTablePreview } from './csv-table-preview' import { DocxPreview } from './docx-preview' import { resolveFileCategory } from './file-category' @@ -78,6 +83,12 @@ export type PreviewMode = 'editor' | 'split' | 'preview' interface FileViewerProps { file: WorkspaceFileRecord workspaceId: string + /** + * Content source for this view. Defaults to a workspace-scoped source derived from `workspaceId`; + * the public share page passes a token-scoped source. Provided to descendants (renderers, embedded + * images) via {@link FileContentSourceProvider}. + */ + contentSource?: FileContentSource canEdit: boolean /** * Render a read-only preview with no editing affordances. Text files render @@ -97,7 +108,20 @@ interface FileViewerProps { previewContextKey?: string } -export function FileViewer({ +export function FileViewer(props: FileViewerProps) { + const { contentSource, workspaceId } = props + const source = useMemo( + () => contentSource ?? createWorkspaceFileContentSource(workspaceId), + [contentSource, workspaceId] + ) + return ( + + + + ) +} + +function FileViewerContent({ file, workspaceId, canEdit, diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts index 41e2f888408..a2879b6da6f 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.test.ts @@ -2,26 +2,41 @@ * @vitest-environment jsdom */ import { describe, expect, it } from 'vitest' -import { resolveDisplaySrc } from './image' +import { + createPublicFileContentSource, + createWorkspaceFileContentSource, +} from '@/hooks/use-file-content-source' -describe('resolveDisplaySrc', () => { - it('rewrites an in-app workspace file path to its serving endpoint (display only)', () => { - expect(resolveDisplaySrc('/workspace/W1/files/F123')).toBe('/api/files/view/F123') - expect(resolveDisplaySrc('/workspace/any-ws-id/files/abc-def')).toBe('/api/files/view/abc-def') +const KEY = 'workspace/W1/1700000000000-deadbeefdeadbeef-photo.png' +const ENCODED = encodeURIComponent(KEY) + +describe('content-source resolveImageSrc', () => { + it('in-app source rewrites embeds to the workspace-scoped inline route', () => { + const src = createWorkspaceFileContentSource('ws-1') + expect(src.resolveImageSrc(`/api/files/serve/${ENCODED}?context=workspace`)).toBe( + `/api/workspaces/ws-1/files/inline?key=${encodeURIComponent(KEY)}` + ) + expect(src.resolveImageSrc('/api/files/view/wf_abc')).toBe( + '/api/workspaces/ws-1/files/inline?fileId=wf_abc' + ) }) - it('leaves absolute and non-workspace URLs untouched', () => { - expect(resolveDisplaySrc('https://cdn.example.com/a.png')).toBe('https://cdn.example.com/a.png') - expect(resolveDisplaySrc('http://localhost/workspace/W1/files/F1')).toBe( - 'http://localhost/workspace/W1/files/F1' + it('public source rewrites embeds to the token-scoped inline route', () => { + const src = createPublicFileContentSource('tok_1', '/api/files/public/tok_1/content') + expect(src.resolveImageSrc('/api/files/view/wf_abc')).toBe( + '/api/files/public/tok_1/inline?fileId=wf_abc' ) - expect(resolveDisplaySrc('/other/path/files/x')).toBe('/other/path/files/x') - expect(resolveDisplaySrc('relative/image.png')).toBe('relative/image.png') }) - it('passes through empty/undefined and unparseable values', () => { - expect(resolveDisplaySrc(undefined)).toBeUndefined() - expect(resolveDisplaySrc('')).toBe('') - expect(resolveDisplaySrc('/workspace/W1/files/')).toBe('/workspace/W1/files/') + it('passes external/data srcs through unchanged in both sources', () => { + const ws = createWorkspaceFileContentSource('ws-1') + const pub = createPublicFileContentSource('tok_1', '/c') + expect(ws.resolveImageSrc('https://cdn.example.com/a.png')).toBe( + 'https://cdn.example.com/a.png' + ) + expect(pub.resolveImageSrc('https://cdn.example.com/a.png')).toBe( + 'https://cdn.example.com/a.png' + ) + expect(ws.resolveImageSrc(undefined)).toBeUndefined() }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx index 8e76a4244bb..5809af9625a 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/image.tsx @@ -3,6 +3,7 @@ import type { JSONContent } from '@tiptap/core' import { Image } from '@tiptap/extension-image' import type { ReactNodeViewProps } from '@tiptap/react' import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react' +import { useFileContentSource } from '@/hooks/use-file-content-source' import { normalizeLinkHref } from './markdown-fidelity' const MIN_WIDTH = 64 @@ -26,24 +27,6 @@ function escapeAttr(value: string): string { .replace(/>/g, '>') } -/** - * Rewrite an in-app workspace file path (`/workspace/{id}/files/{fileId}`) to its serving endpoint - * (`/api/files/view/{fileId}`) for display only — the stored `src` attribute keeps the original path - * so markdown round-trips unchanged. Absolute and non-workspace URLs pass through untouched. - */ -export function resolveDisplaySrc(src: string | undefined): string | undefined { - if (!src) return src - try { - const parsed = new URL(src, 'http://placeholder') - if (parsed.origin !== 'http://placeholder') return src - const [, seg1, , seg3, fileId] = parsed.pathname.split('/') - if (seg1 === 'workspace' && seg3 === 'files' && fileId) return `/api/files/view/${fileId}` - } catch { - // not a parseable URL — render as-is - } - return src -} - /** * Serialize an image to markdown when it has no explicit size, and to an HTML `` tag when * it does — standard markdown has no width syntax, so a resized image must round-trip as HTML to @@ -174,6 +157,7 @@ export const MarkdownImage = Image.extend({ * commits the new pixel width to the `width` attribute, which serializes to ``. */ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactNodeViewProps) { + const source = useFileContentSource() const imageRef = useRef(null) const dragAbortRef = useRef(null) const [dragging, setDragging] = useState(false) @@ -232,7 +216,7 @@ function ResizableImageView({ node, updateAttributes, selected, editor }: ReactN const image = ( {attrs.alt(null) + // The `/Image` slash command opens this hidden picker; `pendingImagePosRef` holds the caret position + // captured when the command ran, so the upload inserts where `/Image` was typed. + const imageInputRef = useRef(null) + const pendingImagePosRef = useRef(null) + // Upload then insert each image at `at` (paste caret / drop point), sequentially; held in a ref so handlers reach the latest. const insertImagesRef = useRef<(images: File[], at: number) => Promise>(() => Promise.resolve() @@ -293,6 +298,19 @@ export function LoadedRichMarkdownEditor({ }) editorInstanceRef.current = editor + // Wire the `/Image` slash command to the hidden picker (per-editor storage, since the extension set is + // shared across instances). Reads only refs, so the handler stays stable across the editor's life. + useEffect(() => { + if (!editor) return + editor.storage.slashCommand.insertImage = (at: number) => { + pendingImagePosRef.current = at + imageInputRef.current?.click() + } + return () => { + editor.storage.slashCommand.insertImage = null + } + }, [editor]) + const wasStreamingRef = useRef(streamingAtMountRef.current) const pendingStreamBodyRef = useRef(null) @@ -386,6 +404,22 @@ export function LoadedRichMarkdownEditor({ > {editor && } {editor && } + { + const input = event.currentTarget + const images = Array.from(input.files ?? []).filter((f) => f.type.startsWith('image/')) + const at = + pendingImagePosRef.current ?? editorInstanceRef.current?.state.selection.from ?? 0 + pendingImagePosRef.current = null + input.value = '' + if (images.length > 0) void insertImagesRef.current(images, at) + }} + /> { @@ -48,4 +49,37 @@ describe('SLASH_COMMANDS registry', () => { const titles = SLASH_COMMANDS.map((c) => c.title) expect(new Set(titles).size).toBe(titles.length) }) + + it('Image command replaces the trigger and hands the caret to the host insertImage handler', () => { + const insertImage = vi.fn() + const deleteRange = vi.fn(() => chain) + const chain = { focus: () => chain, deleteRange, run: () => true } + const editor = { + chain: () => chain, + storage: { slashCommand: { insertImage } }, + state: { selection: { from: 7 } }, + } as unknown as Editor + + const image = SLASH_COMMANDS.find((c) => c.title === 'Image') + expect(image).toBeDefined() + image?.run({ editor, range: { from: 5, to: 6 } as Range }) + + expect(deleteRange).toHaveBeenCalledWith({ from: 5, to: 6 }) + expect(insertImage).toHaveBeenCalledWith(7) + }) + + it('Image command is a no-op when no handler is wired', () => { + const chain = { focus: () => chain, deleteRange: () => chain, run: () => true } + const editor = { + chain: () => chain, + storage: { slashCommand: { insertImage: null } }, + state: { selection: { from: 0 } }, + } as unknown as Editor + expect(() => + SLASH_COMMANDS.find((c) => c.title === 'Image')?.run({ + editor, + range: { from: 0, to: 1 } as Range, + }) + ).not.toThrow() + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts index acf945017d9..a3bdd960bc8 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/commands.ts @@ -4,6 +4,7 @@ import { Heading1, Heading2, Heading3, + Image as ImageIcon, List, ListChecks, ListOrdered, @@ -19,6 +20,15 @@ export interface SlashCommandContext { range: Range } +/** + * Per-editor storage on the `slashCommand` extension. The host editor component sets `insertImage` + * after mount; it opens an image file picker and uploads + inserts the chosen image(s) at `at`. Null + * in headless/read-only contexts, where the Image command is a no-op. + */ +export interface SlashCommandStorage { + insertImage: ((at: number) => void) | null +} + export interface SlashCommandItem { title: string /** Group heading the item is shown under in the menu. */ @@ -131,6 +141,18 @@ export const SLASH_COMMANDS: readonly SlashCommandItem[] = [ aliases: ['hr', 'horizontal rule', 'separator'], run: ({ editor, range }) => editor.chain().focus().deleteRange(range).setHorizontalRule().run(), }, + { + title: 'Image', + group: 'Media', + icon: ImageIcon, + aliases: ['picture', 'photo', 'upload', 'img'], + run: ({ editor, range }) => { + // Replace the typed `/query`, then hand off to the host component's picker, which uploads and + // inserts the image at the caret (the same path as paste/drop). No-op when no handler is wired. + editor.chain().focus().deleteRange(range).run() + editor.storage.slashCommand.insertImage?.(editor.state.selection.from) + }, + }, ] /** diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts index 2a5118ec1cf..1ceff0c9eed 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/slash-command/slash-command.ts @@ -3,9 +3,20 @@ import type { Editor } from '@tiptap/core' import { Extension } from '@tiptap/core' import { ReactRenderer } from '@tiptap/react' import Suggestion, { type SuggestionOptions, type SuggestionProps } from '@tiptap/suggestion' -import { filterSlashCommands, type SlashCommandContext, type SlashCommandItem } from './commands' +import { + filterSlashCommands, + type SlashCommandContext, + type SlashCommandItem, + type SlashCommandStorage, +} from './commands' import { SlashCommandList, type SlashCommandListHandle } from './slash-command-list' +declare module '@tiptap/core' { + interface Storage { + slashCommand: SlashCommandStorage + } +} + type SlashSuggestionProps = SuggestionProps function positionPopup(element: HTMLElement, getRect: SlashSuggestionProps['clientRect']) { @@ -76,9 +87,13 @@ function renderSlashSuggestion(): ReturnType, SlashCommandStorage>({ name: 'slashCommand', + addStorage() { + return { insertImage: null } + }, + addProseMirrorPlugins() { return [ Suggestion({ diff --git a/apps/sim/app/workspace/[workspaceId]/files/page.tsx b/apps/sim/app/workspace/[workspaceId]/files/page.tsx index ab21f2f3b72..2ed876e4ba0 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/page.tsx @@ -1,5 +1,8 @@ import { Suspense } from 'react' +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefetch' import { Files } from './files' import FilesLoading from './loading' @@ -15,10 +18,17 @@ export const metadata: Metadata = { * table headers) so a suspend never shows a blank frame; the route-level * `loading.tsx` covers the navigation/chunk-load transition the same way. */ -export default function FilesPage() { +export default async function FilesPage({ params }: { params: Promise<{ workspaceId: string }> }) { + const { workspaceId } = await params + + const queryClient = getQueryClient() + await prefetchFilesBrowser(queryClient, workspaceId) + return ( - }> - - + + }> + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts new file mode 100644 index 00000000000..8780aa537fa --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/files/prefetch.ts @@ -0,0 +1,43 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { WorkspaceFileFolderApi } from '@/lib/api/contracts/workspace-file-folders' +import type { ListWorkspaceFilesResponse } from '@/lib/api/contracts/workspace-files' +import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders' +import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' + +/** + * Prefetches the Files browser's two lists — workspace files and file folders — + * under the same query keys their client hooks (`useWorkspaceFiles`, + * `useWorkspaceFileFolders`) use (scope `active`), so the browser paints + * populated on first render. + * + * Both payloads carry `Date` fields, so they go through their routes and cache + * the serialized wire shape — see {@link prefetchInternalJson}. + */ +export async function prefetchFilesBrowser( + queryClient: QueryClient, + workspaceId: string +): Promise { + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: workspaceFilesKeys.list(workspaceId, 'active'), + queryFn: async () => { + const data = await prefetchInternalJson( + `/api/workspaces/${workspaceId}/files?scope=active` + ) + return data.success ? data.files : [] + }, + staleTime: 30 * 1000, + }), + queryClient.prefetchQuery({ + queryKey: workspaceFileFolderKeys.list(workspaceId, 'active'), + queryFn: async () => { + const data = await prefetchInternalJson<{ folders?: WorkspaceFileFolderApi[] }>( + `/api/workspaces/${workspaceId}/files/folders?scope=active` + ) + return data.folders ?? [] + }, + staleTime: 30 * 1000, + }), + ]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx index 460950dfa18..7c3f6a4db5e 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry/resource-registry.tsx @@ -25,9 +25,9 @@ import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' import { logKeys } from '@/hooks/queries/logs' import { mothershipChatKeys } from '@/hooks/queries/mothership-chats' import { scheduleKeys } from '@/hooks/queries/schedules' -import { tableKeys } from '@/hooks/queries/tables' import { folderKeys } from '@/hooks/queries/utils/folder-keys' import { invalidateWorkflowLists } from '@/hooks/queries/utils/invalidate-workflow-lists' +import { tableKeys } from '@/hooks/queries/utils/table-keys' import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders' import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' diff --git a/apps/sim/app/workspace/[workspaceId]/home/error.tsx b/apps/sim/app/workspace/[workspaceId]/home/error.tsx new file mode 100644 index 00000000000..03d205fdcd0 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/error.tsx @@ -0,0 +1,15 @@ +'use client' + +import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components' + +export default function HomeError({ error, reset }: ErrorBoundaryProps) { + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index b1ec30520af..e81ec0603f9 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -63,6 +63,7 @@ import { } from '@/lib/copilot/tools/client/run-tool-execution' import { setCurrentChatTraceparent } from '@/lib/copilot/tools/client/trace-context' import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' +import { readSSELines } from '@/lib/core/utils/sse' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { useFilePreviewController } from '@/app/workspace/[workspaceId]/home/hooks/preview' import { @@ -1934,7 +1935,6 @@ export function useChat( shouldContinue?: () => boolean } ) => { - const decoder = new TextDecoder() const ctx = createStreamLoopContext({ workspaceId, queryClient, @@ -1987,71 +1987,47 @@ export function useChat( return { sawStreamError: false, sawComplete: false } } streamReaderRef.current = reader - let buffer = '' try { - const pendingLines: string[] = [] - - while (true) { - if (pendingLines.length === 0) { - // Don't read another chunk after `complete` has drained. - if (state.sawCompleteEvent) break - const { done, value } = await reader.read() - if (done) break - if (ops.isStale()) continue - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n') - buffer = lines.pop() || '' - pendingLines.push(...lines) - if (pendingLines.length === 0) { - continue + await readSSELines(reader, { + onData: (raw) => { + if (state.sawCompleteEvent) return true + if (ops.isStale()) return + + const parsedResult = parsePersistedStreamEventEnvelopeJson(raw) + if (!parsedResult.ok) { + const error = createStreamSchemaValidationError(parsedResult, 'Live SSE event.') + logger.error('Rejected chat SSE event due to client-side schema enforcement', { + reason: parsedResult.reason, + message: parsedResult.message, + errors: parsedResult.errors, + error: error.message, + }) + throw error } - } - - const line = pendingLines.shift() - if (line === undefined) { - continue - } - if (ops.isStale()) { - pendingLines.length = 0 - continue - } - if (!line.startsWith('data: ')) continue - const raw = line.slice(6) - - const parsedResult = parsePersistedStreamEventEnvelopeJson(raw) - if (!parsedResult.ok) { - const error = createStreamSchemaValidationError(parsedResult, 'Live SSE event.') - logger.error('Rejected chat SSE event due to client-side schema enforcement', { - reason: parsedResult.reason, - message: parsedResult.message, - errors: parsedResult.errors, - error: error.message, - }) - throw error - } - const parsed = parsedResult.event + const parsed = parsedResult.event - if (parsed.trace?.requestId && parsed.trace.requestId !== state.streamRequestId) { - state.streamRequestId = parsed.trace.requestId - streamRequestIdRef.current = state.streamRequestId - ops.flush() - } - if (parsed.stream?.streamId) { - streamIdRef.current = parsed.stream.streamId - } - const eventCursor = parsed.stream?.cursor ?? String(parsed.seq) - if (isAlreadyProcessedStreamCursor(eventCursor, lastCursorRef.current)) { - continue - } - if (eventCursor) { - lastCursorRef.current = eventCursor - } + if (parsed.trace?.requestId && parsed.trace.requestId !== state.streamRequestId) { + state.streamRequestId = parsed.trace.requestId + streamRequestIdRef.current = state.streamRequestId + ops.flush() + } + if (parsed.stream?.streamId) { + streamIdRef.current = parsed.stream.streamId + } + const eventCursor = parsed.stream?.cursor ?? String(parsed.seq) + if (isAlreadyProcessedStreamCursor(eventCursor, lastCursorRef.current)) { + return + } + if (eventCursor) { + lastCursorRef.current = eventCursor + } - logger.debug('SSE event received', parsed) - dispatchStreamEvent(ctx, parsed) - } + logger.debug('SSE event received', parsed) + dispatchStreamEvent(ctx, parsed) + if (state.sawCompleteEvent) return true + }, + }) } finally { if (state.sawStreamError && !state.sawCompleteEvent) { applyTurnTerminal(state.model, 'error') diff --git a/apps/sim/app/workspace/[workspaceId]/home/page.tsx b/apps/sim/app/workspace/[workspaceId]/home/page.tsx index e29acc640ee..13595d65398 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/page.tsx @@ -1,6 +1,9 @@ import { Suspense } from 'react' +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' import { getSession } from '@/lib/auth' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { prefetchHomeLists } from '@/app/workspace/[workspaceId]/home/prefetch' import { Home } from './home' import { HomeFallback } from './home-fallback' @@ -8,11 +11,20 @@ export const metadata: Metadata = { title: 'New chat', } -export default async function HomePage() { +export default async function HomePage({ params }: { params: Promise<{ workspaceId: string }> }) { + const { workspaceId } = await params + + const queryClient = getQueryClient() + const listsPrefetch = prefetchHomeLists(queryClient, workspaceId) + const session = await getSession() + await listsPrefetch + return ( - }> - - + + }> + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts new file mode 100644 index 00000000000..956a9e95555 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/home/prefetch.ts @@ -0,0 +1,48 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { FolderApi } from '@/lib/api/contracts' +import type { ListWorkspaceFilesResponse } from '@/lib/api/contracts/workspace-files' +import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { FOLDER_LIST_STALE_TIME, mapFolder } from '@/hooks/queries/folders' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' +import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' + +/** + * Prefetches the home page's secondary lists — folders and workspace files — + * under the same query keys their client hooks (`useFolders`, + * `useWorkspaceFiles`) use, so the home view paints populated on first render. + * + * The workflow list (`workflowKeys.list(ws, 'active')`) is already hydrated by + * the workspace sidebar prefetch and is intentionally not repeated here. + * + * Folders are fetched through the route and mapped with the same `mapFolder` + * the hook applies, matching its cached shape (string dates → `Date`). Files + * carry `Date` fields, so they go through the route and cache the serialized + * wire shape — see {@link prefetchInternalJson}. + */ +export async function prefetchHomeLists( + queryClient: QueryClient, + workspaceId: string +): Promise { + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: folderKeys.list(workspaceId, 'active'), + queryFn: async () => { + const { folders } = await prefetchInternalJson<{ folders?: FolderApi[] }>( + `/api/folders?workspaceId=${workspaceId}&scope=active` + ) + return (folders ?? []).map(mapFolder) + }, + staleTime: FOLDER_LIST_STALE_TIME, + }), + queryClient.prefetchQuery({ + queryKey: workspaceFilesKeys.list(workspaceId, 'active'), + queryFn: async () => { + const data = await prefetchInternalJson( + `/api/workspaces/${workspaceId}/files?scope=active` + ) + return data.success ? data.files : [] + }, + staleTime: 30 * 1000, + }), + ]) +} diff --git a/apps/sim/app/workspace/[workspaceId]/integrations/error.tsx b/apps/sim/app/workspace/[workspaceId]/integrations/error.tsx new file mode 100644 index 00000000000..706d7eea055 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/integrations/error.tsx @@ -0,0 +1,15 @@ +'use client' + +import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components' + +export default function IntegrationsError({ error, reset }: ErrorBoundaryProps) { + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/error.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/error.tsx new file mode 100644 index 00000000000..91dbaadb2c5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/error.tsx @@ -0,0 +1,15 @@ +'use client' + +import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components' + +export default function KnowledgeBaseError({ error, reset }: ErrorBoundaryProps) { + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx index be3743be659..6243dc42035 100644 --- a/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/page.tsx @@ -1,8 +1,26 @@ +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' +import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch' import { Knowledge } from './knowledge' export const metadata: Metadata = { title: 'Knowledge Base', } -export default Knowledge +export default async function KnowledgePage({ + params, +}: { + params: Promise<{ workspaceId: string }> +}) { + const { workspaceId } = await params + + const queryClient = getQueryClient() + await prefetchKnowledgeBases(queryClient, workspaceId) + + return ( + + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts new file mode 100644 index 00000000000..0e8e5578840 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts @@ -0,0 +1,28 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { KnowledgeBaseData } from '@/lib/api/contracts/knowledge' +import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' + +/** + * Prefetches the workspace's knowledge-bases list under the same query key the + * client `useKnowledgeBasesQuery` hook uses (scope `active`), so the list paints + * populated on first render. + * + * The list carries `Date` fields, so it goes through the `/api/knowledge` route + * and caches the serialized wire shape — see {@link prefetchInternalJson}. + */ +export async function prefetchKnowledgeBases( + queryClient: QueryClient, + workspaceId: string +): Promise { + await queryClient.prefetchQuery({ + queryKey: knowledgeKeys.list(workspaceId, 'active'), + queryFn: async () => { + const result = await prefetchInternalJson<{ data: KnowledgeBaseData[] }>( + `/api/knowledge?workspaceId=${workspaceId}&scope=active` + ) + return result.data + }, + staleTime: 60 * 1000, + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts new file mode 100644 index 00000000000..e48f6064c17 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch-internal-fetch.ts @@ -0,0 +1,25 @@ +import { headers } from 'next/headers' +import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' + +/** + * Server-side GET against an internal `/api` route, forwarding the incoming + * request's cookie so the route authenticates as the current user. + * + * List prefetches go through the route (rather than the data layer) when the + * payload carries `Date` fields: `NextResponse.json` serializes them to the + * string wire shape the client caches via `requestJson`, so the + * server-hydrated entry byte-matches the client-fetched one through + * dehydration. Calling the data layer directly would cache raw `Date` objects + * and drift from that wire shape. Mirrors the settings/subscription prefetch. + */ +export async function prefetchInternalJson(path: string): Promise { + const cookie = (await headers()).get('cookie') + // boundary-raw-fetch: server-side RSC prefetch forwarding the session cookie to an internal API route; requestJson is client-only and cannot run here + const response = await fetch(`${getInternalApiBaseUrl()}${path}`, { + headers: cookie ? { cookie } : {}, + }) + if (!response.ok) { + throw new Error(`Prefetch failed for ${path}: ${response.status}`) + } + return response.json() as Promise +} diff --git a/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts new file mode 100644 index 00000000000..2f8375c836f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/lib/prefetch.test.ts @@ -0,0 +1,169 @@ +/** + * @vitest-environment node + */ +import { QueryClient } from '@tanstack/react-query' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockPrefetchInternalJson } = vi.hoisted(() => ({ + mockPrefetchInternalJson: vi.fn(), +})) + +vi.mock('@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch', () => ({ + prefetchInternalJson: mockPrefetchInternalJson, +})) + +vi.mock('@/components/emcn', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})) + +import { prefetchFilesBrowser } from '@/app/workspace/[workspaceId]/files/prefetch' +import { prefetchHomeLists } from '@/app/workspace/[workspaceId]/home/prefetch' +import { prefetchKnowledgeBases } from '@/app/workspace/[workspaceId]/knowledge/prefetch' +import { prefetchTables } from '@/app/workspace/[workspaceId]/tables/prefetch' +import { knowledgeKeys } from '@/hooks/queries/kb/knowledge' +import { folderKeys } from '@/hooks/queries/utils/folder-keys' +import { tableKeys } from '@/hooks/queries/utils/table-keys' +import { workspaceFileFolderKeys } from '@/hooks/queries/workspace-file-folders' +import { workspaceFilesKeys } from '@/hooks/queries/workspace-files' + +const WORKSPACE_ID = 'ws-123' + +function makeClient() { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) +} + +describe('workspace list prefetches', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('prefetchKnowledgeBases', () => { + it('primes the exact key useKnowledgeBasesQuery reads and unwraps data', async () => { + const bases = [{ id: 'kb-1' }] + mockPrefetchInternalJson.mockResolvedValue({ data: bases }) + const client = makeClient() + + await prefetchKnowledgeBases(client, WORKSPACE_ID) + + expect(mockPrefetchInternalJson).toHaveBeenCalledWith( + `/api/knowledge?workspaceId=${WORKSPACE_ID}&scope=active` + ) + expect(client.getQueryData(knowledgeKeys.list(WORKSPACE_ID, 'active'))).toEqual(bases) + }) + }) + + describe('prefetchTables', () => { + it('primes the exact key useTablesList reads and unwraps data.tables', async () => { + const tables = [{ id: 't-1' }] + mockPrefetchInternalJson.mockResolvedValue({ data: { tables } }) + const client = makeClient() + + await prefetchTables(client, WORKSPACE_ID) + + expect(mockPrefetchInternalJson).toHaveBeenCalledWith( + `/api/table?workspaceId=${WORKSPACE_ID}&scope=active` + ) + expect(client.getQueryData(tableKeys.list(WORKSPACE_ID, 'active'))).toEqual(tables) + }) + }) + + describe('prefetchFilesBrowser', () => { + it('primes both file + folder keys the client hooks read', async () => { + const files = [{ id: 'f-1' }] + const folders = [{ id: 'folder-1' }] + mockPrefetchInternalJson.mockImplementation(async (path: string) => + path.includes('/folders') ? { folders } : { success: true, files } + ) + const client = makeClient() + + await prefetchFilesBrowser(client, WORKSPACE_ID) + + expect(mockPrefetchInternalJson).toHaveBeenCalledWith( + `/api/workspaces/${WORKSPACE_ID}/files?scope=active` + ) + expect(mockPrefetchInternalJson).toHaveBeenCalledWith( + `/api/workspaces/${WORKSPACE_ID}/files/folders?scope=active` + ) + expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files) + expect(client.getQueryData(workspaceFileFolderKeys.list(WORKSPACE_ID, 'active'))).toEqual( + folders + ) + }) + + it('caches an empty file list when the route reports failure', async () => { + mockPrefetchInternalJson.mockImplementation(async (path: string) => + path.includes('/folders') ? { folders: [] } : { success: false, files: [] } + ) + const client = makeClient() + + await prefetchFilesBrowser(client, WORKSPACE_ID) + + expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual([]) + }) + }) + + describe('prefetchHomeLists', () => { + it('primes folder + file keys, mapping folder rows to the client shape', async () => { + const folderRow = { + id: 'folder-1', + name: 'Docs', + userId: 'u-1', + workspaceId: WORKSPACE_ID, + parentId: null, + color: null, + isExpanded: true, + locked: false, + sortOrder: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-02T00:00:00.000Z', + archivedAt: null, + } + const files = [{ id: 'f-1' }] + mockPrefetchInternalJson.mockImplementation(async (path: string) => + path.startsWith('/api/folders') ? { folders: [folderRow] } : { success: true, files } + ) + const client = makeClient() + + await prefetchHomeLists(client, WORKSPACE_ID) + + expect(mockPrefetchInternalJson).toHaveBeenCalledWith( + `/api/folders?workspaceId=${WORKSPACE_ID}&scope=active` + ) + const cachedFolders = client.getQueryData(folderKeys.list(WORKSPACE_ID, 'active')) as Array<{ + id: string + color: string + createdAt: Date + }> + expect(cachedFolders).toHaveLength(1) + expect(cachedFolders[0].color).toBe('#6B7280') + expect(cachedFolders[0].createdAt).toBeInstanceOf(Date) + expect(client.getQueryData(workspaceFilesKeys.list(WORKSPACE_ID, 'active'))).toEqual(files) + }) + }) + + describe('graceful failure', () => { + it.each([ + [ + 'prefetchKnowledgeBases', + prefetchKnowledgeBases, + knowledgeKeys.list(WORKSPACE_ID, 'active'), + ], + ['prefetchTables', prefetchTables, tableKeys.list(WORKSPACE_ID, 'active')], + ['prefetchHomeLists', prefetchHomeLists, folderKeys.list(WORKSPACE_ID, 'active')], + [ + 'prefetchFilesBrowser', + prefetchFilesBrowser, + workspaceFilesKeys.list(WORKSPACE_ID, 'active'), + ], + ] as const)( + '%s does not throw when the fetcher rejects (page still renders, client refetches)', + async (_name, prefetch, queryKey) => { + mockPrefetchInternalJson.mockRejectedValue(new Error('500')) + const client = makeClient() + + await expect(prefetch(client, WORKSPACE_ID)).resolves.toBeUndefined() + expect(client.getQueryData(queryKey)).toBeUndefined() + } + ) + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/error.tsx b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/error.tsx new file mode 100644 index 00000000000..4fc75c7b937 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/scheduled-tasks/error.tsx @@ -0,0 +1,15 @@ +'use client' + +import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components' + +export default function ScheduledTasksError({ error, reset }: ErrorBoundaryProps) { + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx index e2581174df8..ffb2becd85a 100644 --- a/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx +++ b/apps/sim/app/workspace/[workspaceId]/settings/components/secrets/components/secrets-manager/secrets-manager.tsx @@ -17,11 +17,7 @@ import type { WorkspaceEnvironmentData } from '@/lib/environment/api' import { UnsavedChangesModal } from '@/app/workspace/[workspaceId]/components/credential-detail' import { SecretValueField } from '@/app/workspace/[workspaceId]/settings/components/secrets/components/secret-value-field' import { isValidEnvVarName } from '@/executor/constants' -import { - useWorkspaceCredentials, - type WorkspaceCredential, - workspaceCredentialKeys, -} from '@/hooks/queries/credentials' +import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials' import { usePersonalEnvironment, useRemoveWorkspaceEnvironment, @@ -29,6 +25,7 @@ import { useUpsertWorkspaceEnvironment, useWorkspaceEnvironment, } from '@/hooks/queries/environment' +import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' import { useWorkspacePermissionsQuery } from '@/hooks/queries/workspace' import { useSettingsDirtyStore } from '@/stores/settings/dirty/store' diff --git a/apps/sim/app/workspace/[workspaceId]/settings/error.tsx b/apps/sim/app/workspace/[workspaceId]/settings/error.tsx new file mode 100644 index 00000000000..02a18fd3362 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/settings/error.tsx @@ -0,0 +1,15 @@ +'use client' + +import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components' + +export default function SettingsError({ error, reset }: ErrorBoundaryProps) { + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/skills/error.tsx b/apps/sim/app/workspace/[workspaceId]/skills/error.tsx new file mode 100644 index 00000000000..9a860682257 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/skills/error.tsx @@ -0,0 +1,15 @@ +'use client' + +import { type ErrorBoundaryProps, ErrorState } from '@/app/workspace/[workspaceId]/components' + +export default function SkillsError({ error, reset }: ErrorBoundaryProps) { + return ( + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts index 34789dff546..6a0efe9695d 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table-event-stream.ts @@ -13,8 +13,8 @@ import { downloadExportResult, snapshotAndMutateRows, type TableRunState, - tableKeys, } from '@/hooks/queries/tables' +import { tableKeys } from '@/hooks/queries/utils/table-keys' const logger = createLogger('useTableEventStream') diff --git a/apps/sim/app/workspace/[workspaceId]/tables/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/page.tsx index 15a016a25a2..0e9390a5d95 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/page.tsx @@ -1,6 +1,9 @@ import { Suspense } from 'react' +import { dehydrate, HydrationBoundary } from '@tanstack/react-query' import type { Metadata } from 'next' +import { getQueryClient } from '@/app/_shell/providers/get-query-client' import TablesLoading from '@/app/workspace/[workspaceId]/tables/loading' +import { prefetchTables } from '@/app/workspace/[workspaceId]/tables/prefetch' import { Tables } from './tables' export const metadata: Metadata = { @@ -13,10 +16,17 @@ export const metadata: Metadata = { * fallback renders the real chrome so a suspend never shows a blank frame; the * route-level `loading.tsx` covers the navigation/chunk-load transition. */ -export default function TablesPage() { +export default async function TablesPage({ params }: { params: Promise<{ workspaceId: string }> }) { + const { workspaceId } = await params + + const queryClient = getQueryClient() + await prefetchTables(queryClient, workspaceId) + return ( - }> - - + + }> + + + ) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts new file mode 100644 index 00000000000..8d41a1d6680 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts @@ -0,0 +1,26 @@ +import type { QueryClient } from '@tanstack/react-query' +import type { TableDefinition } from '@/lib/table' +import { prefetchInternalJson } from '@/app/workspace/[workspaceId]/lib/prefetch-internal-fetch' +import { tableKeys } from '@/hooks/queries/utils/table-keys' + +/** + * Prefetches the workspace's tables list under the same query key the client + * `useTablesList` hook uses (scope `active`), so the list paints populated on + * first render. + * + * Table definitions carry `Date` fields, so the list goes through the + * `/api/table` route and caches the serialized wire shape — see + * {@link prefetchInternalJson}. + */ +export async function prefetchTables(queryClient: QueryClient, workspaceId: string): Promise { + await queryClient.prefetchQuery({ + queryKey: tableKeys.list(workspaceId, 'active'), + queryFn: async () => { + const response = await prefetchInternalJson<{ data: { tables: TableDefinition[] } }>( + `/api/table?workspaceId=${workspaceId}&scope=active` + ) + return response.data.tables + }, + staleTime: 30 * 1000, + }) +} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx index 588ff6da5cc..fe3892ec05c 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx @@ -25,6 +25,7 @@ import { extractPathFromOutputId, parseOutputContentSafely, } from '@/lib/core/utils/response-format' +import { readSSEEvents } from '@/lib/core/utils/sse' import { CHAT_ACCEPT_ATTRIBUTE } from '@/lib/uploads/utils/validation' import { normalizeInputFormatValue } from '@/lib/workflows/input-format' import { StartBlockPath, TriggerUtils } from '@/lib/workflows/triggers/triggers' @@ -520,12 +521,10 @@ export function Chat() { * @param responseMessageId - ID of the message to update with streamed content */ const processStreamingResponse = useCallback( - async (stream: ReadableStream, responseMessageId: string) => { + async (stream: ReadableStream, responseMessageId: string) => { const reader = stream.getReader() streamReaderRef.current = reader - const decoder = new TextDecoder() let accumulatedContent = '' - let buffer = '' const BATCH_MAX_MS = 50 let pendingChunks = '' @@ -562,64 +561,37 @@ export function Chat() { } } + let finalError: string | null = null try { - while (true) { - const { done, value } = await reader.read() - if (done) { - flushChunks() - finalizeMessageStream(responseMessageId) - break - } - - const chunk = decoder.decode(value, { stream: true }) - buffer += chunk - - const separatorIndex = buffer.lastIndexOf('\n\n') - if (separatorIndex === -1) { - continue - } - - const processable = buffer.slice(0, separatorIndex) - buffer = buffer.slice(separatorIndex + 2) - - const lines = processable.split('\n\n') - - for (const line of lines) { - if (!line.startsWith('data: ')) continue - - const data = line.substring(6) - if (data === '[DONE]') continue - - try { - const json = JSON.parse(data) - const { event, data: eventData, chunk: contentChunk } = json - - if (event === 'final' && eventData) { - const result = eventData as ExecutionResult + await readSSEEvents<{ event?: string; data?: ExecutionResult; chunk?: string }>(reader, { + onParseError: (_data, e) => { + logger.error('Error parsing stream data:', e) + }, + onEvent: (json) => { + const { event, data: eventData, chunk: contentChunk } = json - if ('success' in result && !result.success) { - const errorMessage = result.error || 'Workflow execution failed' - flushChunks() - appendMessageContent( - responseMessageId, - `${accumulatedContent ? '\n\n' : ''}Error: ${errorMessage}` - ) - finalizeMessageStream(responseMessageId) - return - } - - flushChunks() - finalizeMessageStream(responseMessageId) - } else if (contentChunk) { - accumulatedContent += contentChunk - pendingChunks += contentChunk - scheduleFlush() + if (event === 'final' && eventData) { + if ('success' in eventData && !eventData.success) { + finalError = eventData.error || 'Workflow execution failed' } - } catch (e) { - logger.error('Error parsing stream data:', e) + return true } - } + + if (contentChunk) { + accumulatedContent += contentChunk + pendingChunks += contentChunk + scheduleFlush() + } + }, + }) + flushChunks() + if (finalError) { + appendMessageContent( + responseMessageId, + `${accumulatedContent ? '\n\n' : ''}Error: ${finalError}` + ) } + finalizeMessageStream(responseMessageId) } catch (error) { if ((error as Error)?.name !== 'AbortError') { logger.error('Error processing stream:', error) diff --git a/apps/sim/app/workspace/providers/socket-provider.tsx b/apps/sim/app/workspace/providers/socket-provider.tsx index 47587fe4f0e..3e5d6fa9040 100644 --- a/apps/sim/app/workspace/providers/socket-provider.tsx +++ b/apps/sim/app/workspace/providers/socket-provider.tsx @@ -11,6 +11,19 @@ import { useState, } from 'react' import { createLogger } from '@sim/logger' +import type { + CursorUpdateBroadcast, + OperationConfirmedBroadcast, + OperationFailedBroadcast, + SelectionUpdateBroadcast, + SubblockUpdateBroadcast, + VariableUpdateBroadcast, + WorkflowDeletedBroadcast, + WorkflowDeployedBroadcast, + WorkflowOperationBroadcast, + WorkflowRevertedBroadcast, + WorkflowUpdatedBroadcast, +} from '@sim/realtime-protocol/events' import { generateId } from '@sim/utils/id' import { backoffWithJitter } from '@sim/utils/retry' import { useParams } from 'next/navigation' @@ -92,18 +105,18 @@ interface SocketContextType { emitCursorUpdate: (cursor: { x: number; y: number } | null) => void emitSelectionUpdate: (selection: { type: 'block' | 'edge' | 'none'; id?: string }) => void - onWorkflowOperation: (handler: (data: any) => void) => void - onSubblockUpdate: (handler: (data: any) => void) => void - onVariableUpdate: (handler: (data: any) => void) => void - - onCursorUpdate: (handler: (data: any) => void) => void - onSelectionUpdate: (handler: (data: any) => void) => void - onWorkflowDeleted: (handler: (data: any) => void) => void - onWorkflowReverted: (handler: (data: any) => void) => void - onWorkflowUpdated: (handler: (data: any) => void) => void - onWorkflowDeployed: (handler: (data: any) => void) => void - onOperationConfirmed: (handler: (data: any) => void) => void - onOperationFailed: (handler: (data: any) => void) => void + onWorkflowOperation: (handler: (data: WorkflowOperationBroadcast) => void) => void + onSubblockUpdate: (handler: (data: SubblockUpdateBroadcast) => void) => void + onVariableUpdate: (handler: (data: VariableUpdateBroadcast) => void) => void + + onCursorUpdate: (handler: (data: CursorUpdateBroadcast) => void) => void + onSelectionUpdate: (handler: (data: SelectionUpdateBroadcast) => void) => void + onWorkflowDeleted: (handler: (data: WorkflowDeletedBroadcast) => void) => void + onWorkflowReverted: (handler: (data: WorkflowRevertedBroadcast) => void) => void + onWorkflowUpdated: (handler: (data: WorkflowUpdatedBroadcast) => void) => void + onWorkflowDeployed: (handler: (data: WorkflowDeployedBroadcast) => void) => void + onOperationConfirmed: (handler: (data: OperationConfirmedBroadcast) => void) => void + onOperationFailed: (handler: (data: OperationFailedBroadcast) => void) => void } const SocketContext = createContext({ @@ -173,17 +186,17 @@ export function SocketProvider({ children, user }: SocketProviderProps) { explicitWorkflowIdRef.current = explicitWorkflowId const eventHandlers = useRef<{ - workflowOperation?: (data: any) => void - subblockUpdate?: (data: any) => void - variableUpdate?: (data: any) => void - cursorUpdate?: (data: any) => void - selectionUpdate?: (data: any) => void - workflowDeleted?: (data: any) => void - workflowReverted?: (data: any) => void - workflowUpdated?: (data: any) => void - workflowDeployed?: (data: any) => void - operationConfirmed?: (data: any) => void - operationFailed?: (data: any) => void + workflowOperation?: (data: WorkflowOperationBroadcast) => void + subblockUpdate?: (data: SubblockUpdateBroadcast) => void + variableUpdate?: (data: VariableUpdateBroadcast) => void + cursorUpdate?: (data: CursorUpdateBroadcast) => void + selectionUpdate?: (data: SelectionUpdateBroadcast) => void + workflowDeleted?: (data: WorkflowDeletedBroadcast) => void + workflowReverted?: (data: WorkflowRevertedBroadcast) => void + workflowUpdated?: (data: WorkflowUpdatedBroadcast) => void + workflowDeployed?: (data: WorkflowDeployedBroadcast) => void + operationConfirmed?: (data: OperationConfirmedBroadcast) => void + operationFailed?: (data: OperationFailedBroadcast) => void }>({}) const positionUpdateTimeouts = useRef>(new Map()) @@ -555,19 +568,19 @@ export function SocketProvider({ children, user }: SocketProviderProps) { executeJoinCommands(result.commands) }) - socketInstance.on('workflow-operation', (data) => { + socketInstance.on('workflow-operation', (data: WorkflowOperationBroadcast) => { eventHandlers.current.workflowOperation?.(data) }) - socketInstance.on('subblock-update', (data) => { + socketInstance.on('subblock-update', (data: SubblockUpdateBroadcast) => { eventHandlers.current.subblockUpdate?.(data) }) - socketInstance.on('variable-update', (data) => { + socketInstance.on('variable-update', (data: VariableUpdateBroadcast) => { eventHandlers.current.variableUpdate?.(data) }) - socketInstance.on('workflow-deleted', (data) => { + socketInstance.on('workflow-deleted', (data: WorkflowDeletedBroadcast) => { logger.warn(`Workflow ${data.workflowId} has been deleted`) const result = joinControllerRef.current.handleWorkflowDeleted(data.workflowId) if (result.shouldClearCurrent) { @@ -577,17 +590,17 @@ export function SocketProvider({ children, user }: SocketProviderProps) { eventHandlers.current.workflowDeleted?.(data) }) - socketInstance.on('workflow-reverted', (data) => { + socketInstance.on('workflow-reverted', (data: WorkflowRevertedBroadcast) => { logger.info(`Workflow ${data.workflowId} has been reverted to deployed state`) eventHandlers.current.workflowReverted?.(data) }) - socketInstance.on('workflow-updated', (data) => { + socketInstance.on('workflow-updated', (data: WorkflowUpdatedBroadcast) => { logger.info(`Workflow ${data.workflowId} has been updated externally`) eventHandlers.current.workflowUpdated?.(data) }) - socketInstance.on('workflow-deployed', (data) => { + socketInstance.on('workflow-deployed', (data: WorkflowDeployedBroadcast) => { logger.info(`Workflow ${data.workflowId} deployment state changed`) eventHandlers.current.workflowDeployed?.(data) }) @@ -647,17 +660,17 @@ export function SocketProvider({ children, user }: SocketProviderProps) { return true } - socketInstance.on('operation-confirmed', (data) => { + socketInstance.on('operation-confirmed', (data: OperationConfirmedBroadcast) => { logger.debug('Operation confirmed', { operationId: data.operationId }) eventHandlers.current.operationConfirmed?.(data) }) - socketInstance.on('operation-failed', (data) => { + socketInstance.on('operation-failed', (data: OperationFailedBroadcast) => { logger.warn('Operation failed', { operationId: data.operationId, error: data.error }) eventHandlers.current.operationFailed?.(data) }) - socketInstance.on('cursor-update', (data) => { + socketInstance.on('cursor-update', (data: CursorUpdateBroadcast) => { if (!isWorkflowVisible()) { return } @@ -675,7 +688,7 @@ export function SocketProvider({ children, user }: SocketProviderProps) { eventHandlers.current.cursorUpdate?.(data) }) - socketInstance.on('selection-update', (data) => { + socketInstance.on('selection-update', (data: SelectionUpdateBroadcast) => { if (!isWorkflowVisible()) { return } @@ -1045,47 +1058,50 @@ export function SocketProvider({ children, user }: SocketProviderProps) { [socket, currentWorkflowId, isWorkflowVisible] ) - const onWorkflowOperation = useCallback((handler: (data: any) => void) => { + const onWorkflowOperation = useCallback((handler: (data: WorkflowOperationBroadcast) => void) => { eventHandlers.current.workflowOperation = handler }, []) - const onSubblockUpdate = useCallback((handler: (data: any) => void) => { + const onSubblockUpdate = useCallback((handler: (data: SubblockUpdateBroadcast) => void) => { eventHandlers.current.subblockUpdate = handler }, []) - const onVariableUpdate = useCallback((handler: (data: any) => void) => { + const onVariableUpdate = useCallback((handler: (data: VariableUpdateBroadcast) => void) => { eventHandlers.current.variableUpdate = handler }, []) - const onCursorUpdate = useCallback((handler: (data: any) => void) => { + const onCursorUpdate = useCallback((handler: (data: CursorUpdateBroadcast) => void) => { eventHandlers.current.cursorUpdate = handler }, []) - const onSelectionUpdate = useCallback((handler: (data: any) => void) => { + const onSelectionUpdate = useCallback((handler: (data: SelectionUpdateBroadcast) => void) => { eventHandlers.current.selectionUpdate = handler }, []) - const onWorkflowDeleted = useCallback((handler: (data: any) => void) => { + const onWorkflowDeleted = useCallback((handler: (data: WorkflowDeletedBroadcast) => void) => { eventHandlers.current.workflowDeleted = handler }, []) - const onWorkflowReverted = useCallback((handler: (data: any) => void) => { + const onWorkflowReverted = useCallback((handler: (data: WorkflowRevertedBroadcast) => void) => { eventHandlers.current.workflowReverted = handler }, []) - const onWorkflowUpdated = useCallback((handler: (data: any) => void) => { + const onWorkflowUpdated = useCallback((handler: (data: WorkflowUpdatedBroadcast) => void) => { eventHandlers.current.workflowUpdated = handler }, []) - const onWorkflowDeployed = useCallback((handler: (data: any) => void) => { + const onWorkflowDeployed = useCallback((handler: (data: WorkflowDeployedBroadcast) => void) => { eventHandlers.current.workflowDeployed = handler }, []) - const onOperationConfirmed = useCallback((handler: (data: any) => void) => { - eventHandlers.current.operationConfirmed = handler - }, []) + const onOperationConfirmed = useCallback( + (handler: (data: OperationConfirmedBroadcast) => void) => { + eventHandlers.current.operationConfirmed = handler + }, + [] + ) - const onOperationFailed = useCallback((handler: (data: any) => void) => { + const onOperationFailed = useCallback((handler: (data: OperationFailedBroadcast) => void) => { eventHandlers.current.operationFailed = handler }, []) diff --git a/apps/sim/blocks/blocks/credential.ts b/apps/sim/blocks/blocks/credential.ts index fc324d721a4..830a8b65b65 100644 --- a/apps/sim/blocks/blocks/credential.ts +++ b/apps/sim/blocks/blocks/credential.ts @@ -2,7 +2,8 @@ import { CredentialIcon } from '@/components/icons' import { getServiceConfigByProviderId } from '@/lib/oauth/utils' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import type { BlockConfig } from '@/blocks/types' -import { fetchWorkspaceCredentialList, workspaceCredentialKeys } from '@/hooks/queries/credentials' +import { workspaceCredentialKeys } from '@/hooks/queries/utils/credential-keys' +import { fetchWorkspaceCredentialList } from '@/hooks/queries/utils/fetch-workspace-credentials' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' interface CredentialBlockOutput { diff --git a/apps/sim/blocks/blocks/gitlab.ts b/apps/sim/blocks/blocks/gitlab.ts index 59de55f8c26..078422bedac 100644 --- a/apps/sim/blocks/blocks/gitlab.ts +++ b/apps/sim/blocks/blocks/gitlab.ts @@ -46,6 +46,21 @@ export const GitLabBlock: BlockConfig = { { label: 'Create Pipeline', id: 'gitlab_create_pipeline' }, { label: 'Retry Pipeline', id: 'gitlab_retry_pipeline' }, { label: 'Cancel Pipeline', id: 'gitlab_cancel_pipeline' }, + // Repository Operations + { label: 'List Repository Tree', id: 'gitlab_list_repository_tree' }, + { label: 'Get File', id: 'gitlab_get_file' }, + { label: 'Create File', id: 'gitlab_create_file' }, + { label: 'Update File', id: 'gitlab_update_file' }, + { label: 'List Commits', id: 'gitlab_list_commits' }, + { label: 'List Branches', id: 'gitlab_list_branches' }, + { label: 'Create Branch', id: 'gitlab_create_branch' }, + // Additional Merge Request Operations + { label: 'Get MR Changes', id: 'gitlab_get_merge_request_changes' }, + { label: 'Approve Merge Request', id: 'gitlab_approve_merge_request' }, + // Job Operations + { label: 'List Pipeline Jobs', id: 'gitlab_list_pipeline_jobs' }, + { label: 'Get Job Log', id: 'gitlab_get_job_log' }, + { label: 'Play Job', id: 'gitlab_play_job' }, ], value: () => 'gitlab_list_projects', }, @@ -57,6 +72,15 @@ export const GitLabBlock: BlockConfig = { password: true, required: true, }, + // Self-managed GitLab host (defaults to gitlab.com) + { + id: 'host', + title: 'GitLab Host', + type: 'short-input', + placeholder: 'gitlab.com', + mode: 'advanced', + description: 'Self-managed GitLab host. Leave blank for gitlab.com.', + }, // Project ID (required for most operations) { id: 'projectId', @@ -85,6 +109,18 @@ export const GitLabBlock: BlockConfig = { 'gitlab_create_pipeline', 'gitlab_retry_pipeline', 'gitlab_cancel_pipeline', + 'gitlab_list_repository_tree', + 'gitlab_get_file', + 'gitlab_create_file', + 'gitlab_update_file', + 'gitlab_list_commits', + 'gitlab_list_branches', + 'gitlab_create_branch', + 'gitlab_get_merge_request_changes', + 'gitlab_approve_merge_request', + 'gitlab_list_pipeline_jobs', + 'gitlab_get_job_log', + 'gitlab_play_job', ], }, }, @@ -119,6 +155,8 @@ export const GitLabBlock: BlockConfig = { 'gitlab_update_merge_request', 'gitlab_merge_merge_request', 'gitlab_create_merge_request_note', + 'gitlab_get_merge_request_changes', + 'gitlab_approve_merge_request', ], }, }, @@ -131,7 +169,12 @@ export const GitLabBlock: BlockConfig = { required: true, condition: { field: 'operation', - value: ['gitlab_get_pipeline', 'gitlab_retry_pipeline', 'gitlab_cancel_pipeline'], + value: [ + 'gitlab_get_pipeline', + 'gitlab_retry_pipeline', + 'gitlab_cancel_pipeline', + 'gitlab_list_pipeline_jobs', + ], }, }, // Title (for issue/MR creation) @@ -238,7 +281,135 @@ Return ONLY the comment text - no explanations, no extra formatting.`, required: true, condition: { field: 'operation', - value: ['gitlab_create_pipeline'], + value: ['gitlab_create_pipeline', 'gitlab_get_file', 'gitlab_create_branch'], + }, + }, + // File Path + { + id: 'filePath', + title: 'File Path', + type: 'short-input', + placeholder: 'Path to file (e.g., src/index.ts)', + required: true, + condition: { + field: 'operation', + value: ['gitlab_get_file', 'gitlab_create_file', 'gitlab_update_file'], + }, + }, + // Branch + { + id: 'branch', + title: 'Branch', + type: 'short-input', + placeholder: 'Branch name', + required: true, + condition: { + field: 'operation', + value: ['gitlab_create_file', 'gitlab_update_file', 'gitlab_create_branch'], + }, + }, + // File Content + { + id: 'content', + title: 'File Content', + type: 'long-input', + placeholder: 'File content', + required: true, + condition: { + field: 'operation', + value: ['gitlab_create_file', 'gitlab_update_file'], + }, + }, + // Commit Message + { + id: 'commitMessage', + title: 'Commit Message', + type: 'short-input', + placeholder: 'Commit message', + required: true, + condition: { + field: 'operation', + value: ['gitlab_create_file', 'gitlab_update_file'], + }, + }, + // Job ID + { + id: 'jobId', + title: 'Job ID', + type: 'short-input', + placeholder: 'Enter job ID', + required: true, + condition: { + field: 'operation', + value: ['gitlab_get_job_log', 'gitlab_play_job'], + }, + }, + // Subdirectory path (for repository tree) + { + id: 'path', + title: 'Path', + type: 'short-input', + placeholder: 'Subdirectory path (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_repository_tree'], + }, + }, + // Recursive tree listing + { + id: 'recursive', + title: 'Recursive', + type: 'switch', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_repository_tree'], + }, + }, + // Ref name filter (for list commits) + { + id: 'refName', + title: 'Ref (branch/tag)', + type: 'short-input', + placeholder: 'Branch or tag (optional)', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_commits'], + }, + }, + // Job scope filter (for list pipeline jobs) + { + id: 'scope', + title: 'Job Scope', + type: 'dropdown', + options: [ + { label: 'All', id: '' }, + { label: 'Failed', id: 'failed' }, + { label: 'Success', id: 'success' }, + { label: 'Running', id: 'running' }, + { label: 'Pending', id: 'pending' }, + { label: 'Canceled', id: 'canceled' }, + { label: 'Manual', id: 'manual' }, + ], + value: () => '', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_list_pipeline_jobs'], + }, + }, + // Commit SHA (for approve merge request) + { + id: 'sha', + title: 'Commit SHA', + type: 'short-input', + placeholder: 'Optional HEAD SHA to approve', + mode: 'advanced', + condition: { + field: 'operation', + value: ['gitlab_approve_merge_request'], }, }, // Labels @@ -418,6 +589,10 @@ Return ONLY the commit message - no explanations, no extra text.`, 'gitlab_list_issues', 'gitlab_list_merge_requests', 'gitlab_list_pipelines', + 'gitlab_list_repository_tree', + 'gitlab_list_branches', + 'gitlab_list_commits', + 'gitlab_list_pipeline_jobs', ], }, }, @@ -435,6 +610,10 @@ Return ONLY the commit message - no explanations, no extra text.`, 'gitlab_list_issues', 'gitlab_list_merge_requests', 'gitlab_list_pipelines', + 'gitlab_list_repository_tree', + 'gitlab_list_branches', + 'gitlab_list_commits', + 'gitlab_list_pipeline_jobs', ], }, }, @@ -466,6 +645,18 @@ Return ONLY the commit message - no explanations, no extra text.`, 'gitlab_create_pipeline', 'gitlab_retry_pipeline', 'gitlab_cancel_pipeline', + 'gitlab_list_repository_tree', + 'gitlab_get_file', + 'gitlab_create_file', + 'gitlab_update_file', + 'gitlab_create_branch', + 'gitlab_list_branches', + 'gitlab_list_commits', + 'gitlab_get_merge_request_changes', + 'gitlab_approve_merge_request', + 'gitlab_list_pipeline_jobs', + 'gitlab_get_job_log', + 'gitlab_play_job', ], config: { tool: (params) => { @@ -474,6 +665,7 @@ Return ONLY the commit message - no explanations, no extra text.`, params: (params) => { const baseParams: Record = { accessToken: params.accessToken, + host: params.host?.trim() || undefined, } switch (params.operation) { @@ -700,6 +892,140 @@ Return ONLY the commit message - no explanations, no extra text.`, pipelineId: Number(params.pipelineId), } + case 'gitlab_list_repository_tree': + if (!params.projectId?.trim()) { + throw new Error('Project ID is required.') + } + return { + ...baseParams, + projectId: params.projectId.trim(), + path: params.path?.trim() || undefined, + recursive: params.recursive || undefined, + perPage: params.perPage ? Number(params.perPage) : undefined, + page: params.page ? Number(params.page) : undefined, + } + + case 'gitlab_get_file': + if (!params.projectId?.trim() || !params.filePath?.trim() || !params.ref?.trim()) { + throw new Error('Project ID, file path, and ref are required.') + } + return { + ...baseParams, + projectId: params.projectId.trim(), + filePath: params.filePath.trim(), + ref: params.ref.trim(), + } + + case 'gitlab_create_file': + case 'gitlab_update_file': + if ( + !params.projectId?.trim() || + !params.filePath?.trim() || + !params.branch?.trim() || + !params.content || + !params.commitMessage?.trim() + ) { + throw new Error( + 'Project ID, file path, branch, content, and commit message are required.' + ) + } + return { + ...baseParams, + projectId: params.projectId.trim(), + filePath: params.filePath.trim(), + branch: params.branch.trim(), + content: params.content, + commitMessage: params.commitMessage.trim(), + } + + case 'gitlab_create_branch': + if (!params.projectId?.trim() || !params.branch?.trim() || !params.ref?.trim()) { + throw new Error('Project ID, branch name, and source ref are required.') + } + return { + ...baseParams, + projectId: params.projectId.trim(), + branch: params.branch.trim(), + ref: params.ref.trim(), + } + + case 'gitlab_list_branches': + if (!params.projectId?.trim()) { + throw new Error('Project ID is required.') + } + return { + ...baseParams, + projectId: params.projectId.trim(), + perPage: params.perPage ? Number(params.perPage) : undefined, + page: params.page ? Number(params.page) : undefined, + } + + case 'gitlab_list_commits': + if (!params.projectId?.trim()) { + throw new Error('Project ID is required.') + } + return { + ...baseParams, + projectId: params.projectId.trim(), + refName: params.refName?.trim() || undefined, + perPage: params.perPage ? Number(params.perPage) : undefined, + page: params.page ? Number(params.page) : undefined, + } + + case 'gitlab_get_merge_request_changes': + if (!params.projectId?.trim() || !params.mergeRequestIid) { + throw new Error('Project ID and Merge Request IID are required.') + } + return { + ...baseParams, + projectId: params.projectId.trim(), + mergeRequestIid: Number(params.mergeRequestIid), + } + + case 'gitlab_approve_merge_request': + if (!params.projectId?.trim() || !params.mergeRequestIid) { + throw new Error('Project ID and Merge Request IID are required.') + } + return { + ...baseParams, + projectId: params.projectId.trim(), + mergeRequestIid: Number(params.mergeRequestIid), + sha: params.sha?.trim() || undefined, + } + + case 'gitlab_list_pipeline_jobs': + if (!params.projectId?.trim() || !params.pipelineId) { + throw new Error('Project ID and Pipeline ID are required.') + } + return { + ...baseParams, + projectId: params.projectId.trim(), + pipelineId: Number(params.pipelineId), + scope: params.scope || undefined, + perPage: params.perPage ? Number(params.perPage) : undefined, + page: params.page ? Number(params.page) : undefined, + } + + case 'gitlab_get_job_log': + if (!params.projectId?.trim() || !params.jobId) { + throw new Error('Project ID and Job ID are required.') + } + return { + ...baseParams, + projectId: params.projectId.trim(), + jobId: Number(params.jobId), + } + + case 'gitlab_play_job': + if (!params.projectId?.trim() || !params.jobId) { + throw new Error('Project ID and Job ID are required.') + } + return { + ...baseParams, + projectId: params.projectId.trim(), + jobId: Number(params.jobId), + } + default: return baseParams } @@ -708,7 +1034,8 @@ Return ONLY the commit message - no explanations, no extra text.`, }, inputs: { operation: { type: 'string', description: 'Operation to perform' }, - credential: { type: 'string', description: 'GitLab access token' }, + accessToken: { type: 'string', description: 'GitLab Personal Access Token' }, + host: { type: 'string', description: 'Self-managed GitLab host (defaults to gitlab.com)' }, projectId: { type: 'string', description: 'Project ID or URL-encoded path' }, issueIid: { type: 'number', description: 'Issue internal ID' }, mergeRequestIid: { type: 'number', description: 'Merge request internal ID' }, @@ -734,6 +1061,16 @@ Return ONLY the commit message - no explanations, no extra text.`, mergeCommitMessage: { type: 'string', description: 'Custom merge commit message' }, perPage: { type: 'number', description: 'Results per page' }, page: { type: 'number', description: 'Page number' }, + filePath: { type: 'string', description: 'Path to file in the repository' }, + branch: { type: 'string', description: 'Branch name' }, + content: { type: 'string', description: 'File content' }, + commitMessage: { type: 'string', description: 'Commit message' }, + jobId: { type: 'number', description: 'Job ID' }, + path: { type: 'string', description: 'Subdirectory path for repository tree' }, + recursive: { type: 'boolean', description: 'Recursively list repository tree' }, + refName: { type: 'string', description: 'Branch or tag name filter' }, + scope: { type: 'string', description: 'Job scope filter' }, + sha: { type: 'string', description: 'Commit SHA' }, }, outputs: { // Project outputs @@ -750,6 +1087,21 @@ Return ONLY the commit message - no explanations, no extra text.`, pipeline: { type: 'json', description: 'Pipeline details' }, // Note outputs note: { type: 'json', description: 'Comment/note details' }, + // Repository outputs + tree: { type: 'json', description: 'Repository tree entries' }, + content: { type: 'string', description: 'File contents (decoded)' }, + fileName: { type: 'string', description: 'File name' }, + branches: { type: 'json', description: 'List of branches' }, + commits: { type: 'json', description: 'List of commits' }, + name: { type: 'string', description: 'Created branch name' }, + webUrl: { type: 'string', description: 'Web URL' }, + // Merge request change outputs + changes: { type: 'json', description: 'Merge request file changes/diffs' }, + approvalsRequired: { type: 'number', description: 'Approvals required' }, + approvalsLeft: { type: 'number', description: 'Approvals remaining' }, + // Job outputs + jobs: { type: 'json', description: 'Pipeline jobs' }, + log: { type: 'string', description: 'Job log output' }, // Success indicator success: { type: 'boolean', description: 'Operation success status' }, }, diff --git a/apps/sim/blocks/blocks/salesforce.ts b/apps/sim/blocks/blocks/salesforce.ts index 11cbc2b58d1..100ac47cad6 100644 --- a/apps/sim/blocks/blocks/salesforce.ts +++ b/apps/sim/blocks/blocks/salesforce.ts @@ -11,7 +11,7 @@ export const SalesforceBlock: BlockConfig = { description: 'Interact with Salesforce CRM', authMode: AuthMode.OAuth, longDescription: - 'Integrate Salesforce into your workflow. Manage accounts, contacts, leads, opportunities, cases, and tasks with powerful automation capabilities.', + 'Integrate Salesforce into your workflow. Manage accounts, contacts, leads, opportunities, cases, and tasks, run reports and SOQL queries, and manage org schema by creating custom fields and objects via the Tooling API.', docsLink: 'https://docs.sim.ai/integrations/salesforce', category: 'tools', integrationType: IntegrationType.Sales, @@ -69,6 +69,11 @@ export const SalesforceBlock: BlockConfig = { { label: 'Get More Query Results', id: 'query_more' }, { label: 'Describe Object', id: 'describe_object' }, { label: 'List Objects', id: 'list_objects' }, + { label: 'Create Custom Field', id: 'create_custom_field' }, + { label: 'Update Custom Field', id: 'update_custom_field' }, + { label: 'Delete Custom Field', id: 'delete_custom_field' }, + { label: 'Create Custom Object', id: 'create_custom_object' }, + { label: 'Run Tooling Query', id: 'tooling_query' }, ], value: () => 'get_accounts', }, @@ -445,7 +450,7 @@ export const SalesforceBlock: BlockConfig = { type: 'short-input', placeholder: 'YYYY-MM-DD (required for create)', condition: { field: 'operation', value: ['create_opportunity', 'update_opportunity'] }, - required: true, + required: { field: 'operation', value: ['create_opportunity'] }, wandConfig: { enabled: true, prompt: `Generate a date in YYYY-MM-DD format based on the user's description. @@ -608,8 +613,8 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n title: 'SOQL Query', type: 'long-input', placeholder: 'SELECT Id, Name FROM Account LIMIT 10', - condition: { field: 'operation', value: ['query'] }, - required: true, + condition: { field: 'operation', value: ['query', 'tooling_query'] }, + required: { field: 'operation', value: ['query', 'tooling_query'] }, }, { id: 'nextRecordsUrl', @@ -624,8 +629,14 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n title: 'Object Name', type: 'short-input', placeholder: 'API name (e.g., Account, Lead, Custom_Object__c)', - condition: { field: 'operation', value: ['describe_object'] }, - required: true, + condition: { + field: 'operation', + value: ['describe_object', 'create_custom_field', 'create_custom_object'], + }, + required: { + field: 'operation', + value: ['describe_object', 'create_custom_field', 'create_custom_object'], + }, }, // Long-input fields at the bottom { @@ -649,9 +660,182 @@ Return ONLY the date string in YYYY-MM-DD format - no explanations, no quotes, n 'update_case', 'create_task', 'update_task', + 'create_custom_field', + 'update_custom_field', + 'create_custom_object', ], }, }, + // Schema / metadata fields (Tooling API) + { + id: 'fieldName', + title: 'Field Name', + type: 'short-input', + placeholder: 'API name without __c (e.g., Region)', + condition: { field: 'operation', value: ['create_custom_field'] }, + required: { field: 'operation', value: ['create_custom_field'] }, + }, + { + id: 'fieldId', + title: 'Field ID', + type: 'short-input', + placeholder: 'Tooling API Id (find via Run Tooling Query)', + condition: { field: 'operation', value: ['update_custom_field', 'delete_custom_field'] }, + required: { field: 'operation', value: ['update_custom_field', 'delete_custom_field'] }, + }, + { + id: 'fieldType', + title: 'Field Type', + type: 'dropdown', + options: [ + { label: 'Text', id: 'Text' }, + { label: 'Text Area', id: 'TextArea' }, + { label: 'Text Area (Long)', id: 'LongTextArea' }, + { label: 'Rich Text Area', id: 'Html' }, + { label: 'Number', id: 'Number' }, + { label: 'Currency', id: 'Currency' }, + { label: 'Percent', id: 'Percent' }, + { label: 'Checkbox', id: 'Checkbox' }, + { label: 'Date', id: 'Date' }, + { label: 'Date/Time', id: 'DateTime' }, + { label: 'Time', id: 'Time' }, + { label: 'Phone', id: 'Phone' }, + { label: 'Email', id: 'Email' }, + { label: 'URL', id: 'Url' }, + { label: 'Picklist', id: 'Picklist' }, + { label: 'Picklist (Multi-Select)', id: 'MultiselectPicklist' }, + ], + condition: { field: 'operation', value: ['create_custom_field'] }, + required: { field: 'operation', value: ['create_custom_field'] }, + }, + { + id: 'label', + title: 'Label', + type: 'short-input', + placeholder: 'Display label', + condition: { + field: 'operation', + value: ['create_custom_field', 'update_custom_field', 'create_custom_object'], + }, + required: { field: 'operation', value: ['create_custom_object'] }, + }, + { + id: 'pluralLabel', + title: 'Plural Label', + type: 'short-input', + placeholder: 'Plural display label (e.g., Projects)', + condition: { field: 'operation', value: ['create_custom_object'] }, + required: { field: 'operation', value: ['create_custom_object'] }, + }, + { + id: 'picklistValues', + title: 'Picklist Values', + type: 'short-input', + placeholder: 'Comma-separated values (e.g., Low, Medium, High)', + condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] }, + }, + { + id: 'length', + title: 'Length', + type: 'short-input', + placeholder: 'Max length for Text/LongTextArea/Html', + mode: 'advanced', + condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] }, + }, + { + id: 'precision', + title: 'Precision', + type: 'short-input', + placeholder: 'Total digits for Number/Currency/Percent', + mode: 'advanced', + condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] }, + }, + { + id: 'scale', + title: 'Scale', + type: 'short-input', + placeholder: 'Decimal places for numeric fields', + mode: 'advanced', + condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] }, + }, + { + id: 'visibleLines', + title: 'Visible Lines', + type: 'short-input', + placeholder: 'Lines for LongTextArea/Html/MultiselectPicklist', + mode: 'advanced', + condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] }, + }, + { + id: 'defaultValue', + title: 'Default Value', + type: 'short-input', + placeholder: 'Default value (true/false for Checkbox)', + mode: 'advanced', + condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] }, + }, + { + id: 'inlineHelpText', + title: 'Help Text', + type: 'short-input', + placeholder: 'Help text shown next to the field', + mode: 'advanced', + condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] }, + }, + { + id: 'required', + title: 'Required', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + mode: 'advanced', + condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] }, + }, + { + id: 'unique', + title: 'Unique', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + mode: 'advanced', + condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] }, + }, + { + id: 'externalId', + title: 'External ID', + type: 'dropdown', + options: [ + { label: 'Yes', id: 'true' }, + { label: 'No', id: 'false' }, + ], + mode: 'advanced', + condition: { field: 'operation', value: ['create_custom_field', 'update_custom_field'] }, + }, + { + id: 'nameFieldLabel', + title: 'Name Field Label', + type: 'short-input', + placeholder: 'Label for the Name field (defaults to "