diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml
index 53a18cf16fd..5d906dc709a 100644
--- a/.github/workflows/test-build.yml
+++ b/.github/workflows/test-build.yml
@@ -116,6 +116,9 @@ jobs:
- name: API contract boundary audit
run: bun run check:api-validation:strict
+ - name: Shared utils enforcement audit
+ run: bun run check:utils
+
- name: Zustand v5 selector audit
run: bun run check:zustand-v5
diff --git a/.gitignore b/.gitignore
index a700a66602a..a8a0d8e2fde 100644
--- a/.gitignore
+++ b/.gitignore
@@ -96,3 +96,7 @@ i18n.cache
# Personal Cursor Skills
.cursor/skills/ask-sim/
+
+# Python (apps/pii tests/tooling)
+__pycache__/
+.pytest_cache/
diff --git a/apps/docs/content/docs/en/platform/enterprise/access-control.mdx b/apps/docs/content/docs/en/platform/enterprise/access-control.mdx
index f71ad748aff..dcc896d9fc3 100644
--- a/apps/docs/content/docs/en/platform/enterprise/access-control.mdx
+++ b/apps/docs/content/docs/en/platform/enterprise/access-control.mdx
@@ -77,7 +77,7 @@ Controls which workflow blocks members can place and execute.
Controls visibility of platform features and modules.
- Each checkbox maps to a specific feature; checking it hides or disables that feature for group members.
+ Each checkbox maps to a specific feature; checking it hides or disables that feature for group members.
**Sidebar**
@@ -85,7 +85,6 @@ Controls visibility of platform features and modules.
|---------|-------------------|
| Knowledge Base | Hides the Knowledge Base section from the sidebar |
| Tables | Hides the Tables section from the sidebar |
-| Templates | Hides the Templates section from the sidebar |
**Workflow Panel**
diff --git a/apps/docs/content/docs/en/platform/enterprise/custom-blocks.mdx b/apps/docs/content/docs/en/platform/enterprise/custom-blocks.mdx
new file mode 100644
index 00000000000..57fe1bc6ce4
--- /dev/null
+++ b/apps/docs/content/docs/en/platform/enterprise/custom-blocks.mdx
@@ -0,0 +1,136 @@
+---
+title: Custom Blocks
+description: Publish a deployed workflow as a reusable block for your organization
+---
+
+import { FAQ } from '@/components/ui/faq'
+import { Image } from '@/components/ui/image'
+
+Custom blocks let you package a deployed workflow as a reusable block for your whole organization. Once published, the block appears in the workflow editor's block toolbar alongside built-in blocks like Agent and Function. Anyone in your organization can drop it into a workflow, fill in its inputs, and use its outputs — without needing access to the workflow behind it.
+
+A custom block always runs the **latest deployed version** of its source workflow, so improving the block is as simple as redeploying that workflow.
+
+---
+
+## Common uses
+
+Custom blocks turn a workflow one team owns into infrastructure the whole organization can safely reuse. Credentials and complexity stay with the block's author; everyone else gets a clean block that's always up to date. A few patterns:
+
+- **Internal API gateway.** Wrap an authenticated internal or partner endpoint — "Create Ticket", "Charge Account", "Provision User" — behind a block that takes only the business inputs. Teammates call it without the base URL, API key, or auth headers, and when the endpoint changes you update one workflow instead of every consumer's.
+- **Blessed knowledge lookup.** Package a vetted retrieval pipeline — chunking, filters, reranking — as "Search Company Docs" with a single query input, so teams reuse the approved retrieval instead of each rebuilding it.
+- **Governed LLM step.** Share a prompt, model, and guardrail combination like "Summarize (house style)". You control the model and prompt org-wide; changing them is a redeploy, not a hunt through everyone's workflows.
+- **Data enrichment.** Expose "Enrich Company" or "Lookup Customer by Email" that hides the provider keys and returns clean fields.
+- **Compliance gate.** Offer "Redact PII" or "Policy Check" as a standardized step teams drop into their flows, with the rules maintained in one place.
+
+---
+
+## Before you start
+
+- **Deploy the workflow first.** Only deployed workflows can be published as a block. If the workflow isn't deployed, deploy it, then come back.
+- **Be a workspace admin.** Publishing and managing custom blocks requires workspace admin access.
+
+---
+
+## Publishing a block
+
+### 1. Open Custom blocks settings
+
+Go to **Settings → Enterprise → Custom blocks** and click **Create block**.
+
+
+
+
+### 2. Choose the source workflow
+
+| Field | Description |
+|-------|-------------|
+| **Workspace** | The workspace that holds the workflow you want to publish. Only workspaces in your organization are listed. |
+| **Workflow** | The workflow to publish. Only deployed workflows appear. If a workflow isn't deployed, deploy it first, then return here. |
+
+The block runs the source workflow's latest deployment. You publish one block per workflow, and the source workflow can't be changed later — to point at a different workflow, delete the block and publish a new one.
+
+### 3. Name and describe the block
+
+| Field | Description |
+|-------|-------------|
+| **Icon** | Optional. A square image (PNG, JPEG, SVG, or WebP) shown on the block. Falls back to your organization's logo, then a default glyph. |
+| **Name** | The block's display name in the toolbar (e.g. `Invoice Parser`). Max 60 characters. |
+| **Description** | A short summary of what the block does. Max 280 characters. |
+
+### 4. Configure inputs
+
+Every input on the source workflow's **Start** block is exposed as a field on the custom block. You don't choose which inputs to include — they all carry over, along with each input's name, type, and description.
+
+For each input you can add an optional **placeholder** (max 200 characters) — the hint text shown in the empty field to tell consumers what to enter.
+
+Because inputs are read live from the deployed workflow, renaming or adding a Start input and redeploying updates the block's fields automatically.
+
+### 5. Choose outputs
+
+Pick which of the workflow's outputs consumers can use, and give each one a name. Outputs are grouped by the block they come from, so you can expose exactly the values that matter and hide everything internal.
+
+- **At least one output is required.**
+- Each exposed output needs a unique **name** (max 60 characters) — that's the name consumers reference in their workflows.
+
+
+
+### 6. Save
+
+Click **Save changes**. The block is published immediately and becomes available to everyone in your organization in the workflow editor's block toolbar.
+
+---
+
+## Using a custom block
+
+In the workflow editor, open the block toolbar. Published custom blocks appear under a **Custom blocks** section. Drag one onto the canvas like any other block, fill in its inputs (using the placeholders as a guide), and reference its outputs in downstream blocks.
+
+
+
+Consumers don't need any access to the source workflow. The block runs on its own, using only the inputs provided, and returns only the outputs you exposed. Internal steps, models, and intermediate values of the source workflow are never visible.
+
+
+
+---
+
+## Managing blocks
+
+Open a block from **Settings → Enterprise → Custom blocks** to edit or delete it.
+
+- **Editing** changes only the block's presentation and interface — name, description, icon, input placeholders, and exposed outputs. The source workflow can't be re-pointed.
+- **Changing what the block does** is done by editing and **redeploying the source workflow**. The block picks up the new deployment automatically; there's nothing to republish.
+- **Deleting** a block is permanent. Workflows already using it will have that block removed, so replace it before deleting if it's in active use.
+
+---
+
+## Good to know
+
+- **Always current.** A custom block runs the source workflow's latest deployment. There are no versions to pin or manage.
+- **Access control.** Custom blocks appear in [Access Control](/platform/enterprise/access-control) alongside built-in blocks, so you can allowlist or restrict them per permission group.
+- **Disabled blocks.** A disabled block can't be added to new workflows, but existing placements keep working.
+- **Removed blocks.** Deleting a block (or deleting its source workflow) removes it from workflows that used it.
+- **Not deployed.** If the source workflow is undeployed, the block shows a clear error when run. Redeploy the workflow to fix it.
+
+---
+
+
diff --git a/apps/docs/content/docs/en/platform/enterprise/forks.mdx b/apps/docs/content/docs/en/platform/enterprise/forks.mdx
new file mode 100644
index 00000000000..08f7e3bfd3a
--- /dev/null
+++ b/apps/docs/content/docs/en/platform/enterprise/forks.mdx
@@ -0,0 +1,361 @@
+---
+title: Workspace Forks
+description: Clone a workspace, keep it linked to its parent, and push or pull deployed workflow changes
+---
+
+import { Callout } from 'fumadocs-ui/components/callout'
+import { FAQ } from '@/components/ui/faq'
+import { Image } from '@/components/ui/image'
+
+Workspace Forks let you clone a workspace into a child, keep the two linked, and move **deployed** workflow changes between them later. Think of **Deploy** as a git commit: only what you have deployed is what Forks can copy or sync. **Sync** is always a force push or force pull — the target side is overwritten for the workflows in the sync. There is no merge UI.
+
+One workspace has at most one parent. A parent can have many children. You can only sync along a **direct** parent↔child edge — not with a grandparent or sibling.
+
+---
+
+## Who can use Forks
+
+| Requirement | Detail |
+|-------------|--------|
+| **Plan** | Enterprise on Sim Cloud |
+| **Role** | Workspace **admin** — non-admins never see the Forks tab |
+| **Self-hosted** | Set `FORKING_ENABLED` / `NEXT_PUBLIC_FORKING_ENABLED` (see [Self-hosted setup](#self-hosted-setup)) |
+
+On Sim Cloud, your organization may also need the feature turned on for your account before the tab appears.
+
+---
+
+## Setup
+
+### 1. Open Forks
+
+Go to **Settings → Enterprise → Workspace Forks** in the workspace you want to fork from (or manage).
+
+
+
+You will see:
+
+- **Parent** — if this workspace was forked from another
+- **Forks** — children of this workspace
+- **See activity** — history of forks, syncs, and rollbacks
+- **Create fork** — start a new child
+
+### 2. Create a fork
+
+Click **Create fork**. Name the child (defaults to `{workspace} (fork)`), then review **Copy resources**.
+
+
+
+Everything under **Copy resources** starts **selected**. That is usually what you want: tables, knowledge bases, files, custom tools, skills, and MCP servers the child will need.
+
+
+ If you deselect a resource, references to it in the forked workflows are **cleared** in the child. You will see a warning before you confirm.
+
+
+
+
+Click **Fork**. The child workspace is created immediately. Deployed workflows land as **drafts** in the child. Large content (table rows, knowledge base files, file blobs) may finish copying in the background — watch **Activity** on the source workspace.
+
+
+ Only **deployed** workflows are forked. Drafts and undeployed work stay in the parent. If the parent has nothing deployed, the child starts with a blank starter workflow.
+
+
+### 3. Open the parent edge (from the child)
+
+Open the **child** workspace → **Settings → Enterprise → Workspace Forks**. On the **Parent** row, open the menu and choose **Edit mappings**.
+
+Child rows (when you are on the parent) only offer **Open workspace** and **Disconnect** — mapping and sync are owned by the child configuring how it relates to its parent.
+
+**Open workspace** is disabled with a tooltip if you do not have access to the other workspace. **Disconnect** stays available so you can still sever the link.
+
+### 4. Understand mappings
+
+A **mapping** means: “this resource in the source is the same logical thing as that resource in the target.” When you sync, workflow fields that pointed at the source resource are rewritten to the target resource so blocks keep working.
+
+| Approach | When to use it |
+|----------|----------------|
+| **Map** | Both sides already have (or should keep) their own resource — e.g. each workspace’s Slack credential, Gmail account, or secret value |
+| **Copy** | The target should receive a **new** clone of the source resource — a new table, knowledge base, file, or MCP server config |
+
+**Credentials** and **Secrets** are **map-only**. They are never copied. Secret *values* never leave their workspace; only names like `{{API_KEY}}` appear in workflow text.
+
+Mappings are saved on the fork relationship. You can click **Save** without syncing. Sync always uses the saved mappings.
+
+After you map or copy a parent resource (credential, knowledge base, table, …), **dependent fields** — Gmail labels, Slack channels, knowledge base documents, and similar — often need a fresh pick in the target. Required dependents block **Sync** until they are filled.
+
+### 5. Map, reconfigure, then Sync
+
+On the sync page you will see direction (**Push** / **Pull**), deployed workflow changes, **Mappings**, optional **Copy resources**, and any **Blocking sync** items.
+
+
+
+- **Push** — overwrite the **other** workspace with this workspace’s deployed workflows
+- **Pull** — overwrite **this** workspace with the other’s deployed workflows
+
+Both are force operations. Confirm carefully.
+
+
+
+Resources referenced by the workflows in the sync default to selected for copy. Unused ones sit under **Not used by any workflow** (off by default). If you **map** a resource, it leaves the copy list — maps win.
+
+
+
+**Sync** stays disabled until:
+
+- Every blocking reference is mapped, copied, or fixed in the source
+- Required credentials and secrets are mapped
+- Required dependent fields are filled
+- Sync details have finished loading
+
+### 6. Confirm and run
+
+Click **Sync**. You will get an overwrite confirmation.
+
+
+
+On success you will see a toast such as **Pushed to "…"** or **Pulled from "…"**. If some workflows fail to redeploy, you get a warning to open and redeploy them manually.
+
+---
+
+## Activity
+
+**See activity** (or the Activity view from the Forks header) lists forks, pushes, pulls, and rollbacks that involve this workspace — including events recorded on the other side of the edge.
+
+
+
+Expand a row for names of workflows and resources that were created, updated, or archived, and any warnings (for example failed background copies or deploy failures).
+
+---
+
+## Rollback vs Disconnect
+
+| Action | What it does | Keep in mind |
+|--------|--------------|--------------|
+| **Rollback** | Undoes the **last sync into this workspace** — restores each affected workflow to its prior deployed version and removes workflows that sync created | Copied resources from past syncs **may remain**. Rollback does not delete tables, KBs, or files that were copied in. |
+| **Disconnect** | Permanently removes the fork relationship | Both workspaces stay. Saved mappings and sync history for the pair are deleted. Forking again creates a **new** child, not a reconnect. |
+
+---
+
+## Permissions
+
+| Action | Who |
+|--------|-----|
+| See Forks / create a fork | Admin on this workspace (+ feature available) |
+| Sync / edit mappings | Admin on **both** sides of the edge |
+| Rollback | Admin on the workspace the sync landed in |
+| Disconnect | Admin on **this** side only (you can disconnect even without access to the other workspace) |
+| Open the other workspace | You must be a member of that workspace |
+
+---
+
+## Resource reference
+
+How each resource behaves at **fork** time vs **sync** time. Use this when you are deciding what to select under **Copy resources**, or why Sync is asking you to map something.
+
+### At a glance
+
+| Resource | Fork | Sync |
+|----------|------|------|
+| Deployed workflows | Always copied as drafts | Updated / created / archived (force overwrite) |
+| Undeployed workflows | Not copied | Not synced |
+| Files | Optional copy (default on) | Map or copy |
+| Tables | Optional copy (default on) | Map or copy |
+| Knowledge bases (+ documents) | Optional copy; referenced docs come with the KB | Map or copy; documents follow the KB |
+| Custom tools | Optional copy (default on) | Map or copy |
+| Skills | Optional copy (default on) | Map or copy |
+| External MCP servers | Optional copy (config only; sign-in cleared) | Map or copy (config only; sign-in cleared) |
+| Workflow MCP servers | Optional copy (server shells + attachments when workflows copy) | Kept in sync automatically with the workflows |
+| Deployed chats | Carried with a **new** chat URL | Created on the target if it has no chat yet |
+| Credentials | Never — fields cleared | Map only |
+| Secrets | Values never; `{{KEY}}` names kept | Map key names only |
+| Public API | Child starts private | Flag follows the workflow |
+| Schedules / webhooks / triggers | Not live until you deploy in the child | Follow whatever you deploy on the target |
+| History, API keys, memory, scheduled jobs | Never | Never |
+
+---
+
+### Workflows
+
+Only **deployed** workflows move. Deploy is the commit; sync is the force push/pull of those commits.
+
+| | Behavior |
+|---|----------|
+| **Fork** | Each deployed workflow becomes a **draft** in the child. Run history is not copied. Only folders that contain a copied workflow are kept. |
+| **Sync** | The change list shows what will be updated, created, or archived. The target is overwritten for those workflows. |
+
+**Example:** Parent has `Support triage` deployed and `WIP experiment` as a draft. The fork gets only `Support triage` as a draft. A later push updates the child from the parent’s latest deploy of `Support triage`.
+
+---
+
+### Files
+
+| | Behavior |
+|---|----------|
+| **Fork** | Listed under **Copy resources** (default on). Copies land at the child’s **file root** (original file folders are not rebuilt). Deselect → file fields in workflows clear. |
+| **Sync** | Map to a file that already exists on the target, or copy. Files used by the sync’s workflows default to selected. |
+
+**Example:** A workflow attaches `brand-guide.pdf`. Fork with Files selected → the child has its own copy and the block still points at it.
+
+---
+
+### Tables
+
+| | Behavior |
+|---|----------|
+| **Fork** | Optional copy (default on). Rows may finish copying in the background after the fork is created. Deselect → table fields clear. |
+| **Sync** | Map if both sides should keep pointing at “the same” logical table through the mapping, or copy for an independent dataset on the target. |
+
+**Example:** An enrichment workflow uses a “Leads” table. Copy on fork so the child can experiment without touching production data. On sync, map if you intentionally want both sides aligned to paired tables, or copy a new table when the target should get a fresh clone.
+
+---
+
+### Knowledge bases and documents
+
+| | Behavior |
+|---|----------|
+| **Fork** | Optional copy (default on). Tag definitions come with the knowledge base. Documents that the forked workflows actually reference are included. Deselect → knowledge base / document fields clear. |
+| **Sync** | Map or copy the knowledge base. Documents are not mapped by themselves — they follow the knowledge base (copied with it, or re-picked when you map to an existing one). |
+
+**Example:** An agent searches knowledge base “Product docs.” Fork with that knowledge base selected → the child gets the base, tags, and the documents the agent used. On sync, mapping to the child’s existing “Product docs” means re-picking which document the tool should use.
+
+---
+
+### Custom tools
+
+| | Behavior |
+|---|----------|
+| **Fork** | Optional copy (default on). The tool definition comes along so agent / tool picks keep working. |
+| **Sync** | Map when both sides already maintain the same tool; copy when the target should receive the source’s definition as a new tool. |
+
+**Example:** A `lookup_customer` custom tool used by an agent. Fork with Custom tools selected → the child’s agent still has the tool. On push, a brand-new tool on the child can be copied into the parent if you leave it selected under **Copy resources**.
+
+---
+
+### Skills
+
+| | Behavior |
+|---|----------|
+| **Fork** | Optional copy (default on). Skill content comes along. Links inside the skill to files or other Sim resources are updated when those resources were also copied. Deselect → skill fields on agents clear. |
+| **Sync** | Map or copy, same idea as custom tools. |
+
+**Example:** A “Support tone” skill on an agent. Deselecting it on fork clears the skill on the child’s agent until you attach one again.
+
+---
+
+### External MCP servers
+
+Servers you connect to an external MCP endpoint (not “publish this workflow as MCP”).
+
+| | Behavior |
+|---|----------|
+| **Fork** | Optional copy (default on). **Connection settings** (URL, headers, transport) copy. **Sign-in / OAuth is not copied** — OAuth servers show up disconnected until someone signs in again in the child. Tool picks on MCP and agent blocks follow the new server. |
+| **Sync** | Map or copy under the same rules. Mapping is typical when each workspace has its own server aimed at the same upstream system. |
+
+**Example:** An MCP server for an internal API. After fork, open MCP settings in the child and complete OAuth (or confirm API headers) before those tools will run. On sync, map child ↔ parent servers so tool selections survive push and pull.
+
+---
+
+### Workflow MCP servers
+
+Servers that **publish workflows as MCP tools**.
+
+| | Behavior |
+|---|----------|
+| **Fork** | Optional under **Copy resources**. You get matching server shells in the child. When the workflow was also forked, its tool attachment on that server is carried over. |
+| **Sync** | These are not shown in the mapping list. Attachments stay aligned as you sync — tools are added, updated, or removed to match the source. |
+
+**Example:** Parent exposes `Support triage` on a workflow MCP server. Fork with Workflow MCP servers selected → the child gets a matching server and the attachment to the child’s copy of the workflow.
+
+---
+
+### Deployed chats
+
+| | Behavior |
+|---|----------|
+| **Fork** | Live chat deployments on copied workflows come along with a **new chat URL**. Conversation history is not copied — only the chat setup (including which blocks feed the chat). |
+| **Sync** | If the target workflow has **no** chat yet, the source’s live chat is created there (again with a new URL). Existing chats on the target are left alone. Workflows that are not ready to deploy do not get a chat. |
+
+**Example:** Parent has a public chat on `Support triage`. The fork gets its own chat URL right away. A later push onto a parent workflow that never had a chat can create one there.
+
+---
+
+### Credentials
+
+| | Behavior |
+|---|----------|
+| **Fork** | **Never copied.** Credential fields in workflows are cleared. Connect or pick credentials in the child. |
+| **Sync** | **Map only.** Every credential used by the synced workflows must be mapped to a credential on the target (same kind of integration). Sync stays blocked until that is done. Then re-pick dependents (labels, calendars, channels, …). |
+
+**Example:** A Gmail block using “Support inbox.” Fork clears it. Before the first sync, map it to the child’s “Support inbox” credential and re-pick the label.
+
+---
+
+### Secrets (environment variables)
+
+| | Behavior |
+|---|----------|
+| **Fork** | **Values never leave the source.** Workflow text still contains `{{KEY}}` names. Create matching secrets (or the names you will map to) under the child’s **Secrets**. |
+| **Sync** | Map source key names to target key names. Values stay in each workspace. Unmapped required secrets block Sync. |
+
+**Example:** Workflows use `{{OPENAI_API_KEY}}`. After fork, add that secret in the child (or map `OPENAI_API_KEY` to whatever name the child uses) before runs and syncs succeed.
+
+---
+
+### Public API
+
+| | Behavior |
+|---|----------|
+| **Fork** | Child workflows start **private** — not exposed as public API endpoints, even if the parent was. |
+| **Sync** | The source workflow’s public API setting is carried. Push a public endpoint and the target becomes public too. |
+
+---
+
+### Not carried
+
+These do not move at fork or sync time:
+
+- Undeployed workflows and local drafts
+- Execution / run history
+- Sim API keys
+- Memory stores and scheduled jobs
+- BYOK provider keys
+- Permission groups (the child inherits the organization, but groups are not specially applied by forking)
+
+Schedules, webhooks, and triggers are not live in the child until you **deploy** there — same “deploy = commit” rule.
+
+---
+
+## Edge cases to keep in mind
+
+- **Force overwrite** — Sync does not merge canvas changes. Anything only on the target that conflicts with the source’s deployed workflows can be lost. Use the confirm dialog’s archive list carefully.
+- **Deselect on fork** — Cleared references are intentional. Prefer copying the resource, or plan to reconnect it in the child.
+- **Background copy** — Right after fork, large tables / knowledge bases / files may still be copying. Check **Activity** if something looks empty.
+- **OAuth MCP** — Expect a reconnect in the child (and after a copied server on sync).
+- **Rollback ≠ undo copies** — Workflow versions roll back; copied resources can remain as orphans.
+- **Disconnect is permanent** — You cannot “reconnect” the same edge; you would fork again into a new workspace.
+- **No grandparent sync** — Only the direct parent↔child pair.
+
+---
+
+
+
+---
+
+## Self-hosted setup
+
+Self-hosted deployments turn Forks on with an environment variable instead of the Enterprise plan.
+
+| Variable | Description |
+|----------|-------------|
+| `FORKING_ENABLED`, `NEXT_PUBLIC_FORKING_ENABLED` | Enables workspace forking when billing is not used as the entitlement gate |
+
+Once enabled, use the same **Settings → Enterprise → Workspace Forks** UI as Sim Cloud. Only workspace admins can manage forks.
diff --git a/apps/docs/content/docs/en/platform/enterprise/index.mdx b/apps/docs/content/docs/en/platform/enterprise/index.mdx
index fef18101d99..6aee826a8af 100644
--- a/apps/docs/content/docs/en/platform/enterprise/index.mdx
+++ b/apps/docs/content/docs/en/platform/enterprise/index.mdx
@@ -5,7 +5,7 @@ description: Enterprise features for business organizations
import { FAQ } from '@/components/ui/faq'
-Sim Enterprise adds fine-grained access control, SSO, audit logging, and compliance features on top of Team plans.
+Sim Enterprise adds fine-grained access control, SSO, audit logging, compliance features, and workspace forking on top of Team plans.
---
@@ -65,8 +65,14 @@ Continuously export workflow logs, audit logs, and Mothership data to a customer
---
+## Workspace Forks
+
+Clone a workspace into a linked child, then push or pull **deployed** workflow changes between them. Deploy is like a commit; sync is a force push or force pull. See the [workspace forks guide](/platform/enterprise/forks).
+
+---
+
@@ -86,6 +92,7 @@ Self-hosted deployments enable enterprise features via environment variables ins
| `AUDIT_LOGS_ENABLED`, `NEXT_PUBLIC_AUDIT_LOGS_ENABLED` | Audit logging |
| `NEXT_PUBLIC_DATA_RETENTION_ENABLED` | Data retention configuration |
| `DATA_DRAINS_ENABLED`, `NEXT_PUBLIC_DATA_DRAINS_ENABLED` | Data drains |
+| `FORKING_ENABLED`, `NEXT_PUBLIC_FORKING_ENABLED` | Workspace forking |
| `INBOX_ENABLED`, `NEXT_PUBLIC_INBOX_ENABLED` | Sim Mailer inbox |
| `DISABLE_INVITATIONS`, `NEXT_PUBLIC_DISABLE_INVITATIONS` | Disable invitations; manage membership via Admin API |
diff --git a/apps/docs/content/docs/en/platform/enterprise/meta.json b/apps/docs/content/docs/en/platform/enterprise/meta.json
index 8ac0662175e..c8a33a7e287 100644
--- a/apps/docs/content/docs/en/platform/enterprise/meta.json
+++ b/apps/docs/content/docs/en/platform/enterprise/meta.json
@@ -4,10 +4,12 @@
"index",
"sso",
"access-control",
+ "custom-blocks",
"whitelabeling",
"audit-logs",
"data-retention",
- "data-drains"
+ "data-drains",
+ "forks"
],
"defaultOpen": false
}
diff --git a/apps/docs/package.json b/apps/docs/package.json
index 9f8d622f6cc..6c9e64cf5bb 100644
--- a/apps/docs/package.json
+++ b/apps/docs/package.json
@@ -50,8 +50,9 @@
"@types/node": "^22.14.1",
"@types/react": "^19.1.2",
"@types/react-dom": "^19.0.4",
+ "@typescript/native-preview": "7.0.0-dev.20260707.2",
"postcss": "^8.5.3",
"tailwindcss": "^4.0.12",
- "typescript": "^5.8.2"
+ "typescript": "^7.0.2"
}
}
diff --git a/apps/docs/public/static/enterprise/custom-blocks-canvas.png b/apps/docs/public/static/enterprise/custom-blocks-canvas.png
new file mode 100644
index 00000000000..e952b15bb1b
Binary files /dev/null and b/apps/docs/public/static/enterprise/custom-blocks-canvas.png differ
diff --git a/apps/docs/public/static/enterprise/custom-blocks-form.png b/apps/docs/public/static/enterprise/custom-blocks-form.png
new file mode 100644
index 00000000000..ed68e0e9ce5
Binary files /dev/null and b/apps/docs/public/static/enterprise/custom-blocks-form.png differ
diff --git a/apps/docs/public/static/enterprise/custom-blocks-list.png b/apps/docs/public/static/enterprise/custom-blocks-list.png
new file mode 100644
index 00000000000..18671499275
Binary files /dev/null and b/apps/docs/public/static/enterprise/custom-blocks-list.png differ
diff --git a/apps/docs/public/static/enterprise/custom-blocks-toolbar.png b/apps/docs/public/static/enterprise/custom-blocks-toolbar.png
new file mode 100644
index 00000000000..cd8fc3b8d92
Binary files /dev/null and b/apps/docs/public/static/enterprise/custom-blocks-toolbar.png differ
diff --git a/apps/docs/public/static/enterprise/forks-activity.png b/apps/docs/public/static/enterprise/forks-activity.png
new file mode 100644
index 00000000000..dd9398b34a2
Binary files /dev/null and b/apps/docs/public/static/enterprise/forks-activity.png differ
diff --git a/apps/docs/public/static/enterprise/forks-copy-resources.png b/apps/docs/public/static/enterprise/forks-copy-resources.png
new file mode 100644
index 00000000000..616b8204a1f
Binary files /dev/null and b/apps/docs/public/static/enterprise/forks-copy-resources.png differ
diff --git a/apps/docs/public/static/enterprise/forks-create-modal.png b/apps/docs/public/static/enterprise/forks-create-modal.png
new file mode 100644
index 00000000000..ff20e2c78e7
Binary files /dev/null and b/apps/docs/public/static/enterprise/forks-create-modal.png differ
diff --git a/apps/docs/public/static/enterprise/forks-create-warning.png b/apps/docs/public/static/enterprise/forks-create-warning.png
new file mode 100644
index 00000000000..14e120bdc42
Binary files /dev/null and b/apps/docs/public/static/enterprise/forks-create-warning.png differ
diff --git a/apps/docs/public/static/enterprise/forks-list.png b/apps/docs/public/static/enterprise/forks-list.png
new file mode 100644
index 00000000000..5d31ccef839
Binary files /dev/null and b/apps/docs/public/static/enterprise/forks-list.png differ
diff --git a/apps/docs/public/static/enterprise/forks-reconfigure.png b/apps/docs/public/static/enterprise/forks-reconfigure.png
new file mode 100644
index 00000000000..32ea2605de1
Binary files /dev/null and b/apps/docs/public/static/enterprise/forks-reconfigure.png differ
diff --git a/apps/docs/public/static/enterprise/forks-sync-confirm.png b/apps/docs/public/static/enterprise/forks-sync-confirm.png
new file mode 100644
index 00000000000..74d5ea07dd2
Binary files /dev/null and b/apps/docs/public/static/enterprise/forks-sync-confirm.png differ
diff --git a/apps/docs/public/static/enterprise/forks-sync-overview.png b/apps/docs/public/static/enterprise/forks-sync-overview.png
new file mode 100644
index 00000000000..3ae743c1571
Binary files /dev/null and b/apps/docs/public/static/enterprise/forks-sync-overview.png differ
diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json
index 1a45ee64711..857bd1f7da6 100644
--- a/apps/docs/tsconfig.json
+++ b/apps/docs/tsconfig.json
@@ -1,7 +1,6 @@
{
"extends": "@sim/tsconfig/nextjs.json",
"compilerOptions": {
- "baseUrl": ".",
"paths": {
"@/.source/*": ["./.source/*"],
"@/*": ["./*"]
diff --git a/apps/pii/engines.py b/apps/pii/engines.py
new file mode 100644
index 00000000000..c8edf982ff1
--- /dev/null
+++ b/apps/pii/engines.py
@@ -0,0 +1,267 @@
+"""Analyzer engine builders for the PII service.
+
+Two NER engines share one recognizer surface:
+
+- spacy (default): the 5 large spaCy models do NER (PERSON/LOCATION/NRP/
+ DATE_TIME) and tokenization.
+- gliner (opt-in): one multilingual GLiNER model does NER on CPU or GPU;
+ small spaCy models remain only for tokenization + lemmas.
+
+Both engines register the identical regex/checksum recognizer set (Presidio
+defaults, EXTRA_RECOGNIZERS, VIN) — only the source of the 4 NER entity types
+differs. Side-effect free: importing this module loads no models.
+"""
+
+import importlib.util
+
+import spacy.util
+from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer
+from presidio_analyzer.nlp_engine import NlpEngineProvider
+from presidio_analyzer.predefined_recognizers import (
+ AuAbnRecognizer,
+ AuAcnRecognizer,
+ AuMedicareRecognizer,
+ AuTfnRecognizer,
+ EsNieRecognizer,
+ EsNifRecognizer,
+ FiPersonalIdentityCodeRecognizer,
+ GLiNERRecognizer,
+ InAadhaarRecognizer,
+ InPanRecognizer,
+ InPassportRecognizer,
+ InVehicleRegistrationRecognizer,
+ InVoterRecognizer,
+ ItDriverLicenseRecognizer,
+ ItFiscalCodeRecognizer,
+ ItIdentityCardRecognizer,
+ ItPassportRecognizer,
+ ItVatCodeRecognizer,
+ PlPeselRecognizer,
+ SgFinRecognizer,
+ SgUenRecognizer,
+ UkNinoRecognizer,
+)
+
+# Languages served. Each needs its spaCy model installed in the image; the
+# es/it/pl/fi predefined recognizers (ES_NIF, IT_FISCAL_CODE, PL_PESEL, ...)
+# auto-load once their NLP engine is present.
+NLP_CONFIGURATION = {
+ "nlp_engine_name": "spacy",
+ "models": [
+ {"lang_code": "en", "model_name": "en_core_web_lg"},
+ {"lang_code": "es", "model_name": "es_core_news_lg"},
+ {"lang_code": "it", "model_name": "it_core_news_lg"},
+ {"lang_code": "pl", "model_name": "pl_core_news_lg"},
+ {"lang_code": "fi", "model_name": "fi_core_news_lg"},
+ ],
+}
+SUPPORTED_LANGUAGES = [m["lang_code"] for m in NLP_CONFIGURATION["models"]]
+
+# The gliner engine still needs a spaCy pipeline per language: the regex
+# recognizers consume NlpArtifacts and the LemmaContextAwareEnhancer boosts
+# scores from surrounding lemmas. The small models (~12-40MB each vs ~400MB
+# large) keep tokenization + lemmas intact while GLiNER owns NER. Blank
+# pipelines ("blank:xx") are not an option: Presidio's SpacyNlpEngine treats
+# unknown model names as pip packages and tries to download them.
+# labels_to_ignore strips the small models' NER output from NlpArtifacts —
+# correctness comes from removing SpacyRecognizer in build_gliner_analyzer;
+# this only silences unmapped-label noise.
+GLINER_NLP_CONFIGURATION = {
+ "nlp_engine_name": "spacy",
+ "models": [
+ {"lang_code": "en", "model_name": "en_core_web_sm"},
+ {"lang_code": "es", "model_name": "es_core_news_sm"},
+ {"lang_code": "it", "model_name": "it_core_news_sm"},
+ {"lang_code": "pl", "model_name": "pl_core_news_sm"},
+ {"lang_code": "fi", "model_name": "fi_core_news_sm"},
+ ],
+ "ner_model_configuration": {
+ "labels_to_ignore": [
+ "CARDINAL", "DATE", "EVENT", "FAC", "GPE", "LANGUAGE", "LAW",
+ "LOC", "MISC", "MONEY", "NORP", "ORDINAL", "ORG", "PER",
+ "PERCENT", "PERSON", "PRODUCT", "QUANTITY", "TIME", "WORK_OF_ART",
+ ],
+ },
+}
+
+# Zero-shot label prompts -> the 4 Presidio NER entities GLiNER owns. Multiple
+# prompts per entity trade a little inference cost for recall; tune against
+# scripts/bench_engines.py output.
+GLINER_ENTITY_MAPPING = {
+ "person": "PERSON",
+ "name": "PERSON",
+ "location": "LOCATION",
+ "address": "LOCATION",
+ "date": "DATE_TIME",
+ "time": "DATE_TIME",
+ "nationality": "NRP",
+ "religious group": "NRP",
+ "political group": "NRP",
+ "ethnic group": "NRP",
+}
+
+# Predefined recognizers Presidio ships but does NOT load into the default
+# registry — they must be added explicitly. Each carries its own
+# supported_language, so it fires under that language once its NLP model is
+# loaded. en: UK/AU/IN/SG locale ids; es/it/pl/fi: national ids.
+EXTRA_RECOGNIZERS = [
+ UkNinoRecognizer,
+ AuAbnRecognizer,
+ AuAcnRecognizer,
+ AuTfnRecognizer,
+ AuMedicareRecognizer,
+ InPanRecognizer,
+ InAadhaarRecognizer,
+ InVehicleRegistrationRecognizer,
+ InVoterRecognizer,
+ InPassportRecognizer,
+ SgFinRecognizer,
+ SgUenRecognizer,
+ EsNifRecognizer,
+ EsNieRecognizer,
+ ItFiscalCodeRecognizer,
+ ItDriverLicenseRecognizer,
+ ItVatCodeRecognizer,
+ ItPassportRecognizer,
+ ItIdentityCardRecognizer,
+ PlPeselRecognizer,
+ FiPersonalIdentityCodeRecognizer,
+]
+
+
+class VinRecognizer(PatternRecognizer):
+ """VIN (17 chars, A-Z/0-9 excluding I/O/Q) with ISO 3779 check-digit
+ validation (position 9). Validation makes accidental matches on arbitrary
+ 17-char codes (request ids, SKUs, tokens) extremely unlikely. Some
+ non-North-American VINs omit the check digit and are skipped — an
+ intentional bias toward precision.
+ """
+
+ _TRANSLIT = {
+ **{str(d): d for d in range(10)},
+ "A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6, "G": 7, "H": 8,
+ "J": 1, "K": 2, "L": 3, "M": 4, "N": 5, "P": 7, "R": 9,
+ "S": 2, "T": 3, "U": 4, "V": 5, "W": 6, "X": 7, "Y": 8, "Z": 9,
+ }
+ _WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]
+
+ def validate_result(self, pattern_text: str):
+ vin = pattern_text.upper()
+ if len(vin) != 17:
+ return False
+ try:
+ total = sum(self._TRANSLIT[c] * w for c, w in zip(vin, self._WEIGHTS))
+ except KeyError:
+ return False
+ check = total % 11
+ expected = "X" if check == 10 else str(check)
+ return vin[8] == expected
+
+
+class SharedModelGLiNERRecognizer(GLiNERRecognizer):
+ """Per-language GLiNER recognizer sharing ONE loaded model.
+
+ Presidio routes recognizers by supported_language, so the registry holds
+ one instance per served language — but each instance's load() would pull
+ its own ~1.2GB model copy. The first instance loads (an ImportError from
+ a missing gliner package propagates — fail fast in the lean image); the
+ rest reuse the cached model.
+ """
+
+ _shared_models: dict = {}
+
+ def load(self) -> None:
+ key = (self.model_name, self.map_location)
+ cached = self._shared_models.get(key)
+ if cached is None:
+ super().load()
+ self._shared_models[key] = self.gliner
+ else:
+ self.gliner = cached
+
+ def analyze(self, text, entities, nlp_artifacts=None):
+ """GLiNERRecognizer appends any requested entity it doesn't know as an
+ ad-hoc zero-shot label and returns its hits. The analyzer passes ALL
+ supported entities (~40) when a request doesn't narrow them, which
+ would prompt GLiNER for CREDIT_CARD/VIN/ES_NIF/... — wrong scope, and
+ inference cost scales with label count. Restrict to the NER entities
+ this recognizer owns."""
+ requested = [e for e in (entities or self.supported_entities) if e in self.supported_entities]
+ if not requested:
+ return []
+ return super().analyze(text, requested, nlp_artifacts)
+
+
+def _register_common_recognizers(analyzer: AnalyzerEngine) -> None:
+ """Regex/checksum recognizers shared by both engines."""
+ # VIN is language-agnostic, so register it under every served language —
+ # a recognizer only fires for the language the caller routes to.
+ vin_pattern = Pattern(name="vin", regex=r"\b[A-HJ-NPR-Z0-9]{17}\b", score=0.7)
+ for language in SUPPORTED_LANGUAGES:
+ analyzer.registry.add_recognizer(
+ VinRecognizer(
+ supported_entity="VIN",
+ patterns=[vin_pattern],
+ context=["vin", "vehicle", "chassis"],
+ supported_language=language,
+ )
+ )
+ for recognizer_cls in EXTRA_RECOGNIZERS:
+ analyzer.registry.add_recognizer(recognizer_cls())
+
+
+def build_spacy_analyzer() -> AnalyzerEngine:
+ nlp_engine = NlpEngineProvider(nlp_configuration=NLP_CONFIGURATION).create_engine()
+ analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=SUPPORTED_LANGUAGES)
+ _register_common_recognizers(analyzer)
+ return analyzer
+
+
+def build_gliner_analyzer(model_name: str, device: str | None) -> AnalyzerEngine:
+ """GLiNER engine: one multilingual zero-shot model replaces spaCy NER for
+ PERSON/LOCATION/NRP/DATE_TIME; everything else is unchanged.
+
+ :param model_name: HuggingFace id of the GLiNER model.
+ :param device: torch device ("cpu", "cuda", "cuda:0"); None auto-detects
+ via Presidio's device_detector (cuda when available, else cpu).
+ """
+ # Fail fast with an actionable message when gliner deps are missing (e.g.
+ # a custom-built image without them). Without these checks Presidio would
+ # try to pip-download the missing spaCy models at startup (a silent
+ # network fallback that dies with an unrelated pip permission error), and
+ # the gliner ImportError would surface only later.
+ if importlib.util.find_spec("gliner") is None:
+ raise RuntimeError(
+ "PII_ENGINE=gliner but the gliner package is not installed; "
+ "use the stock pii image (docker/pii.Dockerfile ships torch + gliner)"
+ )
+ missing = [
+ m["model_name"]
+ for m in GLINER_NLP_CONFIGURATION["models"]
+ if not spacy.util.is_package(m["model_name"])
+ ]
+ if missing:
+ raise RuntimeError(
+ f"PII_ENGINE=gliner needs spaCy models {missing}; "
+ "use the stock pii image (docker/pii.Dockerfile ships them)"
+ )
+ nlp_engine = NlpEngineProvider(nlp_configuration=GLINER_NLP_CONFIGURATION).create_engine()
+ analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=SUPPORTED_LANGUAGES)
+ # The default registry wires SpacyRecognizer per language; with GLiNER
+ # owning the NER entities it would emit duplicate/competing spans from the
+ # small models' ner pipe. remove_recognizer only logs when nothing matched,
+ # so assert the removal actually happened.
+ analyzer.registry.remove_recognizer("SpacyRecognizer")
+ if any(r.name == "SpacyRecognizer" for r in analyzer.registry.recognizers):
+ raise RuntimeError("SpacyRecognizer removal failed; Presidio registry layout changed")
+ for language in SUPPORTED_LANGUAGES:
+ analyzer.registry.add_recognizer(
+ SharedModelGLiNERRecognizer(
+ entity_mapping=GLINER_ENTITY_MAPPING,
+ model_name=model_name,
+ map_location=device,
+ supported_language=language,
+ )
+ )
+ _register_common_recognizers(analyzer)
+ return analyzer
diff --git a/apps/pii/requirements-dev.txt b/apps/pii/requirements-dev.txt
new file mode 100644
index 00000000000..4c330d999ae
--- /dev/null
+++ b/apps/pii/requirements-dev.txt
@@ -0,0 +1,5 @@
+# Test-only deps. Unit tests need requirements.txt + this file (no models);
+# integration tests additionally need the models baked into the docker images
+# (see tests/test_integration.py).
+pytest==8.4.1
+httpx==0.28.1
diff --git a/apps/pii/requirements-gliner.txt b/apps/pii/requirements-gliner.txt
new file mode 100644
index 00000000000..d620c5f1825
--- /dev/null
+++ b/apps/pii/requirements-gliner.txt
@@ -0,0 +1,10 @@
+# Extras for the opt-in GLiNER engine — installed ONLY in the `gliner`
+# Dockerfile target, on top of requirements.txt. Pinned for reproducible image
+# builds; bump deliberately. presidio-analyzer 2.2.362 requires
+# gliner >=0.2.13,<1.0.0.
+#
+# torch is pinned in the Dockerfile instead: the CPU and CUDA targets install
+# the same version from different wheel indexes.
+gliner==0.2.27
+transformers==4.56.2
+huggingface_hub==0.35.3
diff --git a/apps/pii/scripts/bench_engines.py b/apps/pii/scripts/bench_engines.py
new file mode 100644
index 00000000000..b7ea70ca764
--- /dev/null
+++ b/apps/pii/scripts/bench_engines.py
@@ -0,0 +1,206 @@
+"""Benchmark + parity harness for the spacy vs gliner NER engines.
+
+Runs the same payload through both engines and reports per-engine throughput
+(batch analyze, the production /redact_batch path) and per-text latency, plus
+an accuracy diff over the 4 NER entity types (PERSON/LOCATION/NRP/DATE_TIME).
+Non-NER (regex/checksum) results must be identical between engines — both
+register the same recognizers — so any mismatch there is a wiring bug and the
+script exits non-zero.
+
+Meant to run inside the pii image (both engines ship in it):
+
+ docker run --rm python scripts/bench_engines.py
+ docker run --rm -v $PWD/texts.json:/data.json \\
+ python scripts/bench_engines.py --payload /data.json
+
+Payload format: JSON list of {"text": str, "language": str} objects.
+This doubles as the tuning harness for GLINER_ENTITY_MAPPING label prompts.
+"""
+
+import argparse
+import json
+import statistics
+import sys
+import time
+from collections import defaultdict
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
+
+import engines # noqa: E402
+
+# Entities sourced from the NER models rather than regex/checksum patterns.
+# ORGANIZATION is emitted by the spacy engine's NER on unfiltered requests but
+# is not in the app's supported set and has no GLiNER mapping — it shows up in
+# the NER diff (spacy-only) rather than failing the regex-parity gate.
+NER_ENTITIES = {"PERSON", "LOCATION", "NRP", "DATE_TIME", "ORGANIZATION"}
+DEFAULT_PAYLOAD = Path(__file__).resolve().parent / "bench_payload.json"
+
+
+def parse_args():
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument("--payload", type=Path, default=DEFAULT_PAYLOAD)
+ parser.add_argument("--engines", default="spacy,gliner")
+ parser.add_argument("--runs", type=int, default=3)
+ parser.add_argument("--warmup", type=int, default=1)
+ parser.add_argument("--device", default=None, help="torch device for gliner (default: auto)")
+ parser.add_argument("--gliner-model", default="urchade/gliner_multi_pii-v1")
+ parser.add_argument("--max-examples", type=int, default=10)
+ parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
+ return parser.parse_args()
+
+
+def build(engine: str, args) -> tuple:
+ started = time.perf_counter()
+ if engine == "spacy":
+ analyzer = engines.build_spacy_analyzer()
+ elif engine == "gliner":
+ analyzer = engines.build_gliner_analyzer(model_name=args.gliner_model, device=args.device)
+ else:
+ raise ValueError(f"Unknown engine {engine!r}")
+ return analyzer, time.perf_counter() - started
+
+
+def analyze_all(analyzer, items) -> list[list]:
+ """One analyze() call per text, in payload order."""
+ return [analyzer.analyze(text=item["text"], language=item["language"]) for item in items]
+
+
+def bench(analyzer, items, runs: int, warmup: int) -> dict:
+ for _ in range(warmup):
+ analyze_all(analyzer, items)
+ run_times = []
+ latencies = []
+ for _ in range(runs):
+ run_started = time.perf_counter()
+ for item in items:
+ text_started = time.perf_counter()
+ analyzer.analyze(text=item["text"], language=item["language"])
+ latencies.append(time.perf_counter() - text_started)
+ run_times.append(time.perf_counter() - run_started)
+ total_chars = sum(len(item["text"]) for item in items)
+ avg_run = statistics.mean(run_times)
+ return {
+ "texts_per_sec": len(items) / avg_run,
+ "chars_per_sec": total_chars / avg_run,
+ "latency_p50_ms": statistics.median(latencies) * 1000,
+ "latency_p95_ms": statistics.quantiles(latencies, n=20)[18] * 1000,
+ }
+
+
+def spans(results, keep_ner: bool) -> set:
+ return {
+ (r.entity_type, r.start, r.end)
+ for r in results
+ if (r.entity_type in NER_ENTITIES) == keep_ner
+ }
+
+
+def iou(a: tuple, b: tuple) -> float:
+ inter = max(0, min(a[2], b[2]) - max(a[1], b[1]))
+ union = max(a[2], b[2]) - min(a[1], b[1])
+ return inter / union if union else 0.0
+
+
+def diff_ner(items, results_a, results_b, max_examples: int) -> dict:
+ """Per-entity-type agreement between two engines (span IoU >= 0.5)."""
+ per_type = defaultdict(lambda: {"a_total": 0, "b_total": 0, "matched": 0})
+ examples = []
+ for item, res_a, res_b in zip(items, results_a, results_b):
+ a = sorted(spans(res_a, keep_ner=True))
+ b = sorted(spans(res_b, keep_ner=True))
+ unmatched_b = set(b)
+ for span_a in a:
+ per_type[span_a[0]]["a_total"] += 1
+ match = next(
+ (s for s in unmatched_b if s[0] == span_a[0] and iou(span_a, s) >= 0.5), None
+ )
+ if match:
+ per_type[span_a[0]]["matched"] += 1
+ unmatched_b.discard(match)
+ for span_b in b:
+ per_type[span_b[0]]["b_total"] += 1
+ only_a = [s for s in a if not any(s[0] == t[0] and iou(s, t) >= 0.5 for t in b)]
+ only_b = sorted(unmatched_b)
+ if (only_a or only_b) and len(examples) < max_examples:
+ examples.append(
+ {
+ "text": item["text"],
+ "language": item["language"],
+ "only_a": [f"{t}[{s}:{e}]={item['text'][s:e]!r}" for t, s, e in only_a],
+ "only_b": [f"{t}[{s}:{e}]={item['text'][s:e]!r}" for t, s, e in only_b],
+ }
+ )
+ return {"per_type": dict(per_type), "examples": examples}
+
+
+def diff_regex(items, results_a, results_b) -> list:
+ """Non-NER results must be identical: same recognizers on both engines."""
+ mismatches = []
+ for item, res_a, res_b in zip(items, results_a, results_b):
+ a = spans(res_a, keep_ner=False)
+ b = spans(res_b, keep_ner=False)
+ if a != b:
+ mismatches.append({"text": item["text"], "only_a": sorted(a - b), "only_b": sorted(b - a)})
+ return mismatches
+
+
+def main() -> int:
+ args = parse_args()
+ items = json.loads(args.payload.read_text())
+ engine_names = [e.strip() for e in args.engines.split(",") if e.strip()]
+
+ report = {"payload": str(args.payload), "texts": len(items), "engines": {}}
+ results_by_engine = {}
+ for name in engine_names:
+ analyzer, build_secs = build(name, args)
+ stats = bench(analyzer, items, runs=args.runs, warmup=args.warmup)
+ stats["build_secs"] = build_secs
+ report["engines"][name] = stats
+ results_by_engine[name] = analyze_all(analyzer, items)
+
+ exit_code = 0
+ if set(engine_names) >= {"spacy", "gliner"}:
+ report["ner_diff"] = diff_ner(
+ items, results_by_engine["spacy"], results_by_engine["gliner"], args.max_examples
+ )
+ regex_mismatches = diff_regex(
+ items, results_by_engine["spacy"], results_by_engine["gliner"]
+ )
+ report["regex_mismatches"] = regex_mismatches
+ if regex_mismatches:
+ exit_code = 1
+
+ if args.json:
+ print(json.dumps(report, indent=2, default=str))
+ return exit_code
+
+ for name, stats in report["engines"].items():
+ print(f"\n== {name} ==")
+ print(f" build: {stats['build_secs']:.1f}s")
+ print(f" throughput: {stats['texts_per_sec']:.2f} texts/s ({stats['chars_per_sec']:.0f} chars/s)")
+ print(f" latency: p50 {stats['latency_p50_ms']:.1f}ms p95 {stats['latency_p95_ms']:.1f}ms")
+ if "ner_diff" in report:
+ print("\n== NER parity (spacy=a vs gliner=b, span IoU>=0.5) ==")
+ for entity, counts in sorted(report["ner_diff"]["per_type"].items()):
+ print(
+ f" {entity:<10} spacy={counts['a_total']:<4} gliner={counts['b_total']:<4} "
+ f"matched={counts['matched']}"
+ )
+ for example in report["ner_diff"]["examples"]:
+ print(f"\n [{example['language']}] {example['text']}")
+ if example["only_a"]:
+ print(f" spacy only: {', '.join(example['only_a'])}")
+ if example["only_b"]:
+ print(f" gliner only: {', '.join(example['only_b'])}")
+ if report["regex_mismatches"]:
+ print("\n!! REGEX MISMATCHES (wiring bug — engines must agree on non-NER):")
+ for mismatch in report["regex_mismatches"]:
+ print(f" {mismatch}")
+ else:
+ print("\n regex/checksum entities: identical across engines ✓")
+ return exit_code
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/apps/pii/scripts/bench_payload.json b/apps/pii/scripts/bench_payload.json
new file mode 100644
index 00000000000..fd0f207164d
--- /dev/null
+++ b/apps/pii/scripts/bench_payload.json
@@ -0,0 +1,97 @@
+[
+ { "text": "My name is John Smith and I live in Paris with my wife Marie.", "language": "en" },
+ {
+ "text": "Dr. Angela Rodriguez will see you on March 14, 2026 at 3:30 PM in the Boston clinic.",
+ "language": "en"
+ },
+ {
+ "text": "Contact Sarah O'Connor at sarah.oconnor@example.com or call (212) 555-0123.",
+ "language": "en"
+ },
+ { "text": "The package ships to 45 Queen Street, Toronto, next Tuesday.", "language": "en" },
+ {
+ "text": "Ahmed is a practicing Muslim from Egypt who moved to Berlin in 2019.",
+ "language": "en"
+ },
+ { "text": "She is a Catholic Norwegian citizen born on 12/05/1988.", "language": "en" },
+ {
+ "text": "Payment with card 4111111111111111 was declined yesterday at 10am.",
+ "language": "en"
+ },
+ {
+ "text": "The vehicle VIN 1HGCM82633A004352 was registered to James Wilson in Ohio.",
+ "language": "en"
+ },
+ {
+ "text": "His National Insurance number is AB123456C and he lives in Manchester.",
+ "language": "en"
+ },
+ {
+ "text": "Meeting rescheduled: Friday, 9 January 2026, 14:00, with Priya Natarajan from Mumbai.",
+ "language": "en"
+ },
+ { "text": "Send the invoice to accounts@acme.io before the end of Q1 2026.", "language": "en" },
+ {
+ "text": "Klaus, a German engineer, and his Buddhist colleague Mei flew from Munich to Osaka.",
+ "language": "en"
+ },
+ {
+ "text": "The Democrats and Republicans debated in Washington on election night.",
+ "language": "en"
+ },
+ {
+ "text": "No PII here: the quarterly revenue grew 14% and margins held steady.",
+ "language": "en"
+ },
+ {
+ "text": "Server request id a7f3k2m9x1q8w5z2b is not a VIN and not a person.",
+ "language": "en"
+ },
+ {
+ "text": "Me llamo María García y vivo en Madrid desde el 3 de mayo de 2020.",
+ "language": "es"
+ },
+ { "text": "Mi NIF es 12345678Z y mi correo es maria.garcia@ejemplo.es.", "language": "es" },
+ {
+ "text": "El señor Javier Morales, ciudadano mexicano, llegó a Barcelona el lunes.",
+ "language": "es"
+ },
+ { "text": "La reunión con Carmen será el 15 de junio de 2026 en Sevilla.", "language": "es" },
+ {
+ "text": "Los musulmanes y los católicos convivieron durante siglos en Córdoba.",
+ "language": "es"
+ },
+ { "text": "Mi chiamo Marco Rossi e abito a Roma vicino al Colosseo.", "language": "it" },
+ {
+ "text": "Il codice fiscale di Maria Rossi è RSSMRA85T10A562S, nata il 10 dicembre 1985.",
+ "language": "it"
+ },
+ {
+ "text": "Giulia Bianchi, cittadina italiana, si trasferì a Milano nel gennaio 2021.",
+ "language": "it"
+ },
+ {
+ "text": "L'appuntamento con il dottor Ferrari è fissato per il 20 marzo 2026 a Torino.",
+ "language": "it"
+ },
+ { "text": "Nazywam się Jan Kowalski i mieszkam w Warszawie od 2015 roku.", "language": "pl" },
+ {
+ "text": "Mój numer PESEL to 44051401359, urodziłem się 14 maja 1944 w Krakowie.",
+ "language": "pl"
+ },
+ {
+ "text": "Anna Nowak, obywatelka polska, spotka się z nami we wtorek 12 maja 2026.",
+ "language": "pl"
+ },
+ { "text": "Katolicy i protestanci wspólnie świętowali w Gdańsku.", "language": "pl" },
+ {
+ "text": "Nimeni on Matti Virtanen ja asun Helsingissä Töölön kaupunginosassa.",
+ "language": "fi"
+ },
+ {
+ "text": "Henkilötunnukseni on 131052-308T ja synnyin Tampereella lokakuussa 1952.",
+ "language": "fi"
+ },
+ { "text": "Liisa Korhonen muutti Ouluun maanantaina 5. tammikuuta 2026.", "language": "fi" },
+ { "text": "Suomalaiset ja ruotsalaiset kilpailevat jääkiekossa joka vuosi.", "language": "fi" }
+]
diff --git a/apps/pii/server.py b/apps/pii/server.py
index e2f7ad706d3..3f59cc540aa 100644
--- a/apps/pii/server.py
+++ b/apps/pii/server.py
@@ -3,148 +3,52 @@
Constructs one warm AnalyzerEngine (multi-language NLP + a native check-digit
VIN recognizer) and one AnonymizerEngine at startup, exposing stock-compatible
endpoints so a single PRESIDIO_URL serves both.
+
+NER engine selection (see engines.py):
+- PII_ENGINE=spacy (default): the 5 large spaCy models, unchanged behavior.
+- PII_ENGINE=gliner: one multilingual GLiNER model for PERSON/LOCATION/NRP/
+ DATE_TIME. The stock image ships both engines, so this is a pure env flip.
+ PII_DEVICE picks cpu/cuda (unset = auto-detect), PII_GLINER_MODEL overrides
+ the model id. The same code runs on CPU and GPU. Each uvicorn worker
+ (PII_WORKERS) loads its own GLiNER model copy — into GPU memory when on
+ cuda — so GPU deployments generally want PII_WORKERS=1 per GPU, unlike the
+ CPU/spacy path where workers scale with vCPUs.
"""
import logging
+import os
import time
from typing import Any
+from engines import build_gliner_analyzer, build_spacy_analyzer
from fastapi import FastAPI
-from presidio_analyzer import (
- AnalyzerEngine,
- BatchAnalyzerEngine,
- Pattern,
- PatternRecognizer,
- RecognizerResult,
-)
-from presidio_analyzer.nlp_engine import NlpEngineProvider
-from presidio_analyzer.predefined_recognizers import (
- AuAbnRecognizer,
- AuAcnRecognizer,
- AuMedicareRecognizer,
- AuTfnRecognizer,
- EsNieRecognizer,
- EsNifRecognizer,
- FiPersonalIdentityCodeRecognizer,
- InAadhaarRecognizer,
- InPanRecognizer,
- InPassportRecognizer,
- InVehicleRegistrationRecognizer,
- InVoterRecognizer,
- ItDriverLicenseRecognizer,
- ItFiscalCodeRecognizer,
- ItIdentityCardRecognizer,
- ItPassportRecognizer,
- ItVatCodeRecognizer,
- PlPeselRecognizer,
- SgFinRecognizer,
- SgUenRecognizer,
- UkNinoRecognizer,
-)
+from presidio_analyzer import AnalyzerEngine, BatchAnalyzerEngine, RecognizerResult
from presidio_anonymizer import AnonymizerEngine
from presidio_anonymizer.entities import OperatorConfig
from pydantic import BaseModel
-# Languages served. Each needs its spaCy model installed in the image; the
-# es/it/pl/fi predefined recognizers (ES_NIF, IT_FISCAL_CODE, PL_PESEL, ...)
-# auto-load once their NLP engine is present.
-NLP_CONFIGURATION = {
- "nlp_engine_name": "spacy",
- "models": [
- {"lang_code": "en", "model_name": "en_core_web_lg"},
- {"lang_code": "es", "model_name": "es_core_news_lg"},
- {"lang_code": "it", "model_name": "it_core_news_lg"},
- {"lang_code": "pl", "model_name": "pl_core_news_lg"},
- {"lang_code": "fi", "model_name": "fi_core_news_lg"},
- ],
-}
-SUPPORTED_LANGUAGES = [m["lang_code"] for m in NLP_CONFIGURATION["models"]]
-
-# Predefined recognizers Presidio ships but does NOT load into the default
-# registry — they must be added explicitly. Each carries its own
-# supported_language, so it fires under that language once its NLP model is
-# loaded. en: UK/AU/IN/SG locale ids; es/it/pl/fi: national ids.
-EXTRA_RECOGNIZERS = [
- UkNinoRecognizer,
- AuAbnRecognizer,
- AuAcnRecognizer,
- AuTfnRecognizer,
- AuMedicareRecognizer,
- InPanRecognizer,
- InAadhaarRecognizer,
- InVehicleRegistrationRecognizer,
- InVoterRecognizer,
- InPassportRecognizer,
- SgFinRecognizer,
- SgUenRecognizer,
- EsNifRecognizer,
- EsNieRecognizer,
- ItFiscalCodeRecognizer,
- ItDriverLicenseRecognizer,
- ItVatCodeRecognizer,
- ItPassportRecognizer,
- ItIdentityCardRecognizer,
- PlPeselRecognizer,
- FiPersonalIdentityCodeRecognizer,
-]
-
-
-class VinRecognizer(PatternRecognizer):
- """VIN (17 chars, A-Z/0-9 excluding I/O/Q) with ISO 3779 check-digit
- validation (position 9). Validation makes accidental matches on arbitrary
- 17-char codes (request ids, SKUs, tokens) extremely unlikely. Some
- non-North-American VINs omit the check digit and are skipped — an
- intentional bias toward precision.
- """
-
- _TRANSLIT = {
- **{str(d): d for d in range(10)},
- "A": 1, "B": 2, "C": 3, "D": 4, "E": 5, "F": 6, "G": 7, "H": 8,
- "J": 1, "K": 2, "L": 3, "M": 4, "N": 5, "P": 7, "R": 9,
- "S": 2, "T": 3, "U": 4, "V": 5, "W": 6, "X": 7, "Y": 8, "Z": 9,
- }
- _WEIGHTS = [8, 7, 6, 5, 4, 3, 2, 10, 0, 9, 8, 7, 6, 5, 4, 3, 2]
+PII_ENGINE = os.environ.get("PII_ENGINE", "spacy")
+if PII_ENGINE not in ("spacy", "gliner"):
+ raise ValueError(f"Invalid PII_ENGINE={PII_ENGINE!r}; expected 'spacy' or 'gliner'")
+# Empty/unset -> None -> auto-detect (cuda when torch sees a GPU, else cpu).
+PII_DEVICE = os.environ.get("PII_DEVICE") or None
+PII_GLINER_MODEL = os.environ.get("PII_GLINER_MODEL", "urchade/gliner_multi_pii-v1")
- def validate_result(self, pattern_text: str):
- vin = pattern_text.upper()
- if len(vin) != 17:
- return False
- try:
- total = sum(self._TRANSLIT[c] * w for c, w in zip(vin, self._WEIGHTS))
- except KeyError:
- return False
- check = total % 11
- expected = "X" if check == 10 else str(check)
- return vin[8] == expected
+# Propagates to uvicorn's root handler, so timing lands in the container log stream.
+logger = logging.getLogger("sim.pii")
def build_analyzer() -> AnalyzerEngine:
- nlp_engine = NlpEngineProvider(nlp_configuration=NLP_CONFIGURATION).create_engine()
- analyzer = AnalyzerEngine(nlp_engine=nlp_engine, supported_languages=SUPPORTED_LANGUAGES)
- # VIN is language-agnostic, so register it under every served language —
- # a recognizer only fires for the language the caller routes to.
- vin_pattern = Pattern(name="vin", regex=r"\b[A-HJ-NPR-Z0-9]{17}\b", score=0.7)
- for language in SUPPORTED_LANGUAGES:
- analyzer.registry.add_recognizer(
- VinRecognizer(
- supported_entity="VIN",
- patterns=[vin_pattern],
- context=["vin", "vehicle", "chassis"],
- supported_language=language,
- )
- )
- for recognizer_cls in EXTRA_RECOGNIZERS:
- analyzer.registry.add_recognizer(recognizer_cls())
- return analyzer
+ if PII_ENGINE == "gliner":
+ return build_gliner_analyzer(model_name=PII_GLINER_MODEL, device=PII_DEVICE)
+ return build_spacy_analyzer()
+logger.info("building analyzer engine=%s device=%s", PII_ENGINE, PII_DEVICE or "auto")
analyzer = build_analyzer()
batch_analyzer = BatchAnalyzerEngine(analyzer_engine=analyzer)
anonymizer = AnonymizerEngine()
-# Propagates to uvicorn's root handler, so timing lands in the container log stream.
-logger = logging.getLogger("sim.pii")
-
app = FastAPI(title="Sim Presidio", docs_url=None, redoc_url=None)
diff --git a/apps/pii/tests/conftest.py b/apps/pii/tests/conftest.py
new file mode 100644
index 00000000000..c9787a309d9
--- /dev/null
+++ b/apps/pii/tests/conftest.py
@@ -0,0 +1,5 @@
+import sys
+from pathlib import Path
+
+# server.py / engines.py live one level up (repo: apps/pii, image: /app).
+sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
diff --git a/apps/pii/tests/test_engines.py b/apps/pii/tests/test_engines.py
new file mode 100644
index 00000000000..1e6c1b8840c
--- /dev/null
+++ b/apps/pii/tests/test_engines.py
@@ -0,0 +1,105 @@
+"""Unit tests for engines.py — no models, no downloads, no network.
+
+Run: pip install -r requirements.txt -r requirements-dev.txt && python -m pytest tests
+"""
+
+import importlib.util
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+import pytest
+from presidio_analyzer.predefined_recognizers.ner import gliner_recognizer
+
+import engines
+
+PII_DIR = Path(__file__).resolve().parent.parent
+
+
+class FakeModel:
+ def __init__(self):
+ self.seen_labels: list[list[str]] = []
+
+ def predict_entities(self, text, labels, flat_ner=True, threshold=0.3, multi_label=False):
+ self.seen_labels.append(list(labels))
+ return [{"label": "person", "score": 0.92, "start": 0, "end": 4, "text": text[0:4]}]
+
+
+class FakeGLiNER:
+ calls = 0
+
+ @classmethod
+ def from_pretrained(cls, model_name, **kwargs):
+ cls.calls += 1
+ return FakeModel()
+
+
+@pytest.fixture
+def fake_gliner(monkeypatch):
+ monkeypatch.setattr(gliner_recognizer, "GLiNER", FakeGLiNER)
+ engines.SharedModelGLiNERRecognizer._shared_models.clear()
+ FakeGLiNER.calls = 0
+ yield FakeGLiNER
+ engines.SharedModelGLiNERRecognizer._shared_models.clear()
+
+
+def make_recognizer(language: str):
+ return engines.SharedModelGLiNERRecognizer(
+ entity_mapping=engines.GLINER_ENTITY_MAPPING,
+ model_name="fake/model",
+ map_location="cpu",
+ supported_language=language,
+ )
+
+
+def test_invalid_pii_engine_fails_import():
+ result = subprocess.run(
+ [sys.executable, "-c", "import server"],
+ cwd=PII_DIR,
+ env={**os.environ, "PII_ENGINE": "bogus"},
+ capture_output=True,
+ text=True,
+ )
+ assert result.returncode != 0
+ assert "Invalid PII_ENGINE" in result.stderr
+
+
+@pytest.mark.skipif(
+ importlib.util.find_spec("gliner") is not None,
+ reason="fail-fast path only exists when gliner is not installed",
+)
+def test_build_gliner_analyzer_fails_fast_without_gliner():
+ with pytest.raises(RuntimeError, match="gliner package is not installed"):
+ engines.build_gliner_analyzer(model_name="fake/model", device="cpu")
+
+
+def test_shared_model_loads_once_across_languages(fake_gliner):
+ first = make_recognizer("en")
+ second = make_recognizer("es")
+ assert fake_gliner.calls == 1
+ assert first.gliner is second.gliner
+
+
+def test_analyze_never_prompts_gliner_with_foreign_entities(fake_gliner):
+ recognizer = make_recognizer("en")
+ all_supported = ["PERSON", "LOCATION", "NRP", "DATE_TIME", "CREDIT_CARD", "VIN", "ES_NIF"]
+ results = recognizer.analyze("John went home", entities=all_supported)
+ for labels in recognizer.gliner.seen_labels:
+ assert set(labels) <= set(engines.GLINER_ENTITY_MAPPING)
+ assert results and results[0].entity_type == "PERSON"
+
+
+def test_analyze_skips_inference_when_no_owned_entity_requested(fake_gliner):
+ recognizer = make_recognizer("en")
+ assert recognizer.analyze("4111111111111111", entities=["CREDIT_CARD"]) == []
+ assert recognizer.gliner.seen_labels == []
+
+
+def test_entity_mapping_targets_exactly_the_ner_entities():
+ assert set(engines.GLINER_ENTITY_MAPPING.values()) == {
+ "PERSON",
+ "LOCATION",
+ "NRP",
+ "DATE_TIME",
+ }
diff --git a/apps/pii/tests/test_integration.py b/apps/pii/tests/test_integration.py
new file mode 100644
index 00000000000..40c97975754
--- /dev/null
+++ b/apps/pii/tests/test_integration.py
@@ -0,0 +1,102 @@
+"""Integration tests — exercise the real engines end-to-end via the FastAPI app.
+
+Requires the models present, so run inside the built images (gated behind
+RUN_PII_INTEGRATION to keep plain `pytest` runs model-free):
+
+ # spacy regression (default engine)
+ docker run --rm -e RUN_PII_INTEGRATION=1 python -m pytest tests
+
+ # gliner engine
+ docker run --rm -e RUN_PII_INTEGRATION=1 -e PII_ENGINE=gliner \
+ python -m pytest tests/test_integration.py
+
+The suite adapts to PII_ENGINE: shared assertions always run, engine-specific
+ones only for the active engine.
+"""
+
+import os
+
+import pytest
+
+if not os.environ.get("RUN_PII_INTEGRATION"):
+ pytest.skip(
+ "integration tests need the built image (RUN_PII_INTEGRATION=1)",
+ allow_module_level=True,
+ )
+
+from fastapi.testclient import TestClient
+
+import server
+
+ENGINE = server.PII_ENGINE
+client = TestClient(server.app)
+
+
+def redact_batch(texts, language="en"):
+ response = client.post("/redact_batch", json={"texts": texts, "language": language})
+ assert response.status_code == 200
+ return response.json()["texts"]
+
+
+def test_health():
+ assert client.get("/health").json() == {"status": "ok"}
+
+
+def test_masks_person_and_email():
+ [masked] = redact_batch(["My name is John Smith, email john.smith@example.com."])
+ assert "" in masked
+ assert "" in masked
+ assert "John Smith" not in masked
+ assert "john.smith@example.com" not in masked
+
+
+def test_masks_location_and_phone():
+ [masked] = redact_batch(["I live in Paris, call me at (212) 555-0123."])
+ assert "" in masked
+ assert "" in masked
+ assert "Paris" not in masked
+
+
+def test_regex_recognizers_fire_in_non_english_languages():
+ [masked] = redact_batch(["Mi NIF es 12345678Z."], language="es")
+ assert "" in masked
+ # On the spacy engine the it_core_news_lg NER tags the fiscal code as
+ # ORGANIZATION and outscores the pattern recognizer, so only assert the
+ # value is masked; the exact label is checked on the gliner engine where
+ # spaCy NER can't compete.
+ [masked] = redact_batch(["Il codice fiscale è RSSMRA85T10A562S."], language="it")
+ assert "RSSMRA85T10A562S" not in masked
+ if ENGINE == "gliner":
+ assert "" in masked
+
+
+def test_vin_checksum_recognizer_fires():
+ [masked] = redact_batch(["The car VIN is 1HGCM82633A004352."])
+ assert "" in masked
+
+
+def test_no_pii_passes_through_unchanged():
+ # NB: "Quarterly" would be tagged DATE_TIME by the spacy engine — keep
+ # this text free of anything either engine considers an entity.
+ text = "Revenue grew and margins held steady."
+ assert redact_batch([text]) == [text]
+
+
+@pytest.mark.skipif(ENGINE != "gliner", reason="gliner-only wiring assertions")
+def test_gliner_registry_has_no_spacy_recognizer():
+ names = {r.name for r in server.analyzer.registry.recognizers}
+ assert "SpacyRecognizer" not in names
+ assert "GLiNERRecognizer" in names
+
+
+@pytest.mark.skipif(ENGINE != "gliner", reason="gliner-only wiring assertions")
+def test_gliner_supported_entities_keep_ner_types():
+ supported = set(server.analyzer.get_supported_entities("en"))
+ assert {"PERSON", "LOCATION", "NRP", "DATE_TIME"} <= supported
+
+
+@pytest.mark.skipif(ENGINE != "spacy", reason="spacy-only wiring assertions")
+def test_spacy_registry_unchanged():
+ names = {r.name for r in server.analyzer.registry.recognizers}
+ assert "SpacyRecognizer" in names
+ assert "GLiNERRecognizer" not in names
diff --git a/apps/realtime/package.json b/apps/realtime/package.json
index 83ca341b44d..633ffb60827 100644
--- a/apps/realtime/package.json
+++ b/apps/realtime/package.json
@@ -43,7 +43,7 @@
"@sim/tsconfig": "workspace:*",
"@types/node": "24.2.1",
"socket.io-client": "4.8.1",
- "typescript": "^5.7.3",
+ "typescript": "^7.0.2",
"vitest": "^4.1.0"
}
}
diff --git a/apps/realtime/tsconfig.json b/apps/realtime/tsconfig.json
index cce62771d4d..00b8ae857fc 100644
--- a/apps/realtime/tsconfig.json
+++ b/apps/realtime/tsconfig.json
@@ -1,9 +1,9 @@
{
"extends": "@sim/tsconfig/base.json",
"compilerOptions": {
- "baseUrl": ".",
+ "lib": ["ES2022", "DOM"],
"paths": {
- "@/*": ["src/*"]
+ "@/*": ["./src/*"]
}
},
"include": ["src/**/*"],
diff --git a/apps/sim/app/(auth)/login/login-form.tsx b/apps/sim/app/(auth)/login/login-form.tsx
index d0f1fa59a29..9fd5adb148c 100644
--- a/apps/sim/app/(auth)/login/login-form.tsx
+++ b/apps/sim/app/(auth)/login/login-form.tsx
@@ -11,6 +11,7 @@ import {
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
+import { normalizeEmail } from '@sim/utils/string'
import { useRouter, useSearchParams } from 'next/navigation'
import { requestJson } from '@/lib/api/client/request'
import { forgetPasswordContract } from '@/lib/api/contracts'
@@ -45,7 +46,7 @@ const validateEmailField = (emailValue: string): string[] => {
return errors
}
- const validation = quickValidateEmail(emailValue.trim().toLowerCase())
+ const validation = quickValidateEmail(normalizeEmail(emailValue))
if (!validation.isValid) {
errors.push(validation.reason || 'Please enter a valid email address.')
}
@@ -159,7 +160,7 @@ export default function LoginPage({
const formData = new FormData(e.currentTarget)
const emailRaw = formData.get('email') as string
- const email = emailRaw.trim().toLowerCase()
+ const email = normalizeEmail(emailRaw)
const emailValidationErrors = validateEmailField(email)
setEmailErrors(emailValidationErrors)
@@ -277,7 +278,7 @@ export default function LoginPage({
return
}
- const emailValidation = quickValidateEmail(forgotPasswordEmail.trim().toLowerCase())
+ const emailValidation = quickValidateEmail(normalizeEmail(forgotPasswordEmail))
if (!emailValidation.isValid) {
setResetStatus({
type: 'error',
diff --git a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx
index e6bf318c428..88a7cf3edae 100644
--- a/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx
+++ b/apps/sim/app/(interfaces)/resume/[workflowId]/[executionId]/resume-page-client.tsx
@@ -18,6 +18,7 @@ import {
TableRow,
Tooltip,
} from '@sim/emcn'
+import { formatDateTime } from '@sim/utils/formatting'
import { useQueryClient } from '@tanstack/react-query'
import { RefreshCw } from 'lucide-react'
import { useRouter } from 'next/navigation'
@@ -68,7 +69,7 @@ const STATUS_BADGE_VARIANT: Record
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- )
+ return
}
diff --git a/apps/sim/app/(landing)/blog/[slug]/page.tsx b/apps/sim/app/(landing)/blog/[slug]/page.tsx
index afd7f32a439..2e8b50b2dd5 100644
--- a/apps/sim/app/(landing)/blog/[slug]/page.tsx
+++ b/apps/sim/app/(landing)/blog/[slug]/page.tsx
@@ -1,14 +1,8 @@
-import { Avatar, AvatarFallback, AvatarImage } from '@sim/emcn'
import type { Metadata } from 'next'
-import Image from 'next/image'
-import Link from 'next/link'
-import { FAQ } from '@/lib/blog/faq'
import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/blog/registry'
-import { buildPostGraphJsonLd, buildPostMetadata } from '@/lib/blog/seo'
+import { BLOG_SECTION, buildPostGraphJsonLd, buildPostMetadata } from '@/lib/blog/seo'
import { getBaseUrl } from '@/lib/core/utils/urls'
-import { ShareButton } from '@/app/(landing)/blog/[slug]/share-button'
-import { BackLink } from '@/app/(landing)/components'
-import { JsonLd } from '@/app/(landing)/components/json-ld'
+import { ContentPostPage } from '@/app/(landing)/components'
export const dynamicParams = false
@@ -32,150 +26,16 @@ export const revalidate = 86400
export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const post = await getPostBySlug(slug)
- const Article = post.Content
- const graphJsonLd = buildPostGraphJsonLd(post)
const related = await getRelatedPosts(slug, 3)
return (
-
-
-
-
+
+ )
+}
diff --git a/apps/sim/app/(landing)/components/content-tags-page/index.ts b/apps/sim/app/(landing)/components/content-tags-page/index.ts
new file mode 100644
index 00000000000..4411be701d5
--- /dev/null
+++ b/apps/sim/app/(landing)/components/content-tags-page/index.ts
@@ -0,0 +1,2 @@
+export { ContentTagsLoading } from './content-tags-loading'
+export { ContentTagsPage } from './content-tags-page'
diff --git a/apps/sim/app/(landing)/components/index.ts b/apps/sim/app/(landing)/components/index.ts
index 2a2f7d32da8..91c35fb1e93 100644
--- a/apps/sim/app/(landing)/components/index.ts
+++ b/apps/sim/app/(landing)/components/index.ts
@@ -1,5 +1,10 @@
export { BackLink } from './back-link'
export { ChevronArrow } from './chevron-arrow'
+export { ContentAuthorLoading, ContentAuthorPage } from './content-author-page'
+export { ContentImage } from './content-image'
+export { ContentIndexLoading, ContentIndexPage } from './content-index-page'
+export { ContentPostLoading, ContentPostPage } from './content-post-page'
+export { ContentTagsLoading, ContentTagsPage } from './content-tags-page'
export { Cta } from './cta/cta'
export { Features } from './features'
export { Footer } from './footer'
@@ -9,6 +14,7 @@ export type { JsonLdData } from './json-ld'
export { JsonLd } from './json-ld'
export { LandingShell } from './landing-shell'
export { Lifecycle } from './lifecycle'
+export { Lightbox } from './lightbox'
export { LogoShell } from './logo-shell'
export { Mothership } from './mothership/mothership'
export { Navbar } from './navbar'
@@ -21,6 +27,7 @@ export type {
} from './platform-page'
export { PlatformPage } from './platform-page'
export { ProductDemo } from './product-demo'
+export { ShareButton } from './share-button'
export { SiteStructuredData } from './site-structured-data'
export type {
SolutionsCardConfig,
diff --git a/apps/sim/app/(landing)/components/lightbox/index.ts b/apps/sim/app/(landing)/components/lightbox/index.ts
new file mode 100644
index 00000000000..1055bfa3ff7
--- /dev/null
+++ b/apps/sim/app/(landing)/components/lightbox/index.ts
@@ -0,0 +1 @@
+export { Lightbox } from './lightbox'
diff --git a/apps/sim/app/(landing)/blog/components/lightbox.tsx b/apps/sim/app/(landing)/components/lightbox/lightbox.tsx
similarity index 100%
rename from apps/sim/app/(landing)/blog/components/lightbox.tsx
rename to apps/sim/app/(landing)/components/lightbox/lightbox.tsx
diff --git a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.ts b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.ts
index 06b59cbcbd9..fc8bee83018 100644
--- a/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.ts
+++ b/apps/sim/app/(landing)/components/navbar/components/nav-menu-chip/constants.ts
@@ -41,7 +41,7 @@ export const PLATFORM_MENU: NavMenu = {
}
/**
- * The Resources menu - learning and reference surfaces. Five items in a
+ * The Resources menu - learning and reference surfaces. Six items in a
* three-column grid. Docs is the one off-site link.
*/
export const RESOURCES_MENU: NavMenu = {
@@ -58,6 +58,11 @@ export const RESOURCES_MENU: NavMenu = {
description: 'Ideas, news, and deep dives',
href: '/blog',
},
+ {
+ title: 'Library',
+ description: 'Comparisons, how-tos, and roundups',
+ href: '/library',
+ },
{
title: 'Changelog',
description: 'Everything we just shipped',
diff --git a/apps/sim/app/(landing)/components/share-button/index.ts b/apps/sim/app/(landing)/components/share-button/index.ts
new file mode 100644
index 00000000000..fe125f6b40c
--- /dev/null
+++ b/apps/sim/app/(landing)/components/share-button/index.ts
@@ -0,0 +1 @@
+export { ShareButton } from './share-button'
diff --git a/apps/sim/app/(landing)/blog/[slug]/share-button.tsx b/apps/sim/app/(landing)/components/share-button/share-button.tsx
similarity index 100%
rename from apps/sim/app/(landing)/blog/[slug]/share-button.tsx
rename to apps/sim/app/(landing)/components/share-button/share-button.tsx
diff --git a/apps/sim/app/(landing)/library/[slug]/loading.tsx b/apps/sim/app/(landing)/library/[slug]/loading.tsx
new file mode 100644
index 00000000000..286bbb590ca
--- /dev/null
+++ b/apps/sim/app/(landing)/library/[slug]/loading.tsx
@@ -0,0 +1,5 @@
+import { ContentPostLoading } from '@/app/(landing)/components'
+
+export default function LibraryPostLoading() {
+ return
+}
diff --git a/apps/sim/app/(landing)/library/[slug]/page.tsx b/apps/sim/app/(landing)/library/[slug]/page.tsx
new file mode 100644
index 00000000000..bd8de683cc0
--- /dev/null
+++ b/apps/sim/app/(landing)/library/[slug]/page.tsx
@@ -0,0 +1,41 @@
+import type { Metadata } from 'next'
+import { getBaseUrl } from '@/lib/core/utils/urls'
+import { getAllPostMeta, getPostBySlug, getRelatedPosts } from '@/lib/library/registry'
+import { buildPostGraphJsonLd, buildPostMetadata, LIBRARY_SECTION } from '@/lib/library/seo'
+import { ContentPostPage } from '@/app/(landing)/components'
+
+export const dynamicParams = false
+
+export async function generateStaticParams() {
+ const posts = await getAllPostMeta()
+ return posts.map((p) => ({ slug: p.slug }))
+}
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ slug: string }>
+}): Promise {
+ const { slug } = await params
+ const post = await getPostBySlug(slug)
+ return buildPostMetadata(post)
+}
+
+export const revalidate = 86400
+
+export default async function Page({ params }: { params: Promise<{ slug: string }> }) {
+ const { slug } = await params
+ const post = await getPostBySlug(slug)
+ const related = await getRelatedPosts(slug, 3)
+
+ return (
+
+ )
+}
diff --git a/apps/sim/app/(landing)/library/authors/[id]/loading.tsx b/apps/sim/app/(landing)/library/authors/[id]/loading.tsx
new file mode 100644
index 00000000000..315e1bad6d0
--- /dev/null
+++ b/apps/sim/app/(landing)/library/authors/[id]/loading.tsx
@@ -0,0 +1,5 @@
+import { ContentAuthorLoading } from '@/app/(landing)/components'
+
+export default function AuthorLoading() {
+ return
+}
diff --git a/apps/sim/app/(landing)/library/authors/[id]/page.tsx b/apps/sim/app/(landing)/library/authors/[id]/page.tsx
new file mode 100644
index 00000000000..6462c378525
--- /dev/null
+++ b/apps/sim/app/(landing)/library/authors/[id]/page.tsx
@@ -0,0 +1,32 @@
+import type { Metadata } from 'next'
+import { getAllPostMeta } from '@/lib/library/registry'
+import { buildAuthorGraphJsonLd, buildAuthorMetadata, LIBRARY_SECTION } from '@/lib/library/seo'
+import { ContentAuthorPage } from '@/app/(landing)/components'
+
+export const revalidate = 3600
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ id: string }>
+}): Promise {
+ const { id } = await params
+ const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
+ return buildAuthorMetadata(id, posts[0]?.author)
+}
+
+export default async function AuthorPage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params
+ const posts = (await getAllPostMeta()).filter((p) => p.author.id === id)
+ const author = posts[0]?.author
+
+ return (
+
+ )
+}
diff --git a/apps/sim/app/(landing)/library/layout.tsx b/apps/sim/app/(landing)/library/layout.tsx
new file mode 100644
index 00000000000..f2b8be62288
--- /dev/null
+++ b/apps/sim/app/(landing)/library/layout.tsx
@@ -0,0 +1,11 @@
+import type { ReactNode } from 'react'
+
+/**
+ * Library route segment. The shared landing layout owns the chrome (navbar,
+ * footer, site-wide JSON-LD, scroll port); this layout only provides the
+ * `` landmark for every library page. Library pages emit their own
+ * page-specific JSON-LD.
+ */
+export default function LibraryLayout({ children }: { children: ReactNode }) {
+ return {children}
+}
diff --git a/apps/sim/app/(landing)/library/loading.tsx b/apps/sim/app/(landing)/library/loading.tsx
new file mode 100644
index 00000000000..33e11cb90b6
--- /dev/null
+++ b/apps/sim/app/(landing)/library/loading.tsx
@@ -0,0 +1,5 @@
+import { ContentIndexLoading } from '@/app/(landing)/components'
+
+export default function LibraryLoading() {
+ return
+}
diff --git a/apps/sim/app/(landing)/library/page.tsx b/apps/sim/app/(landing)/library/page.tsx
new file mode 100644
index 00000000000..7102cba4472
--- /dev/null
+++ b/apps/sim/app/(landing)/library/page.tsx
@@ -0,0 +1,41 @@
+import type { Metadata } from 'next'
+import { getAllPostMeta } from '@/lib/library/registry'
+import { buildCollectionPageJsonLd, buildIndexMetadata, LIBRARY_SECTION } from '@/lib/library/seo'
+import { ContentIndexPage } from '@/app/(landing)/components'
+
+/**
+ * Filtered/paginated variants render genuinely different lists, but only the
+ * bare index is indexable — see `buildIndexMetadata` in `@/lib/content/seo`
+ * for the shared noindex policy.
+ */
+export async function generateMetadata({
+ searchParams,
+}: {
+ searchParams: Promise<{ page?: string; tag?: string }>
+}): Promise {
+ const { page, tag } = await searchParams
+ const pageNum = Math.max(1, Number(page || 1))
+ return buildIndexMetadata({ tag, pageNum })
+}
+
+export default async function LibraryIndex({
+ searchParams,
+}: {
+ searchParams: Promise<{ page?: string; tag?: string }>
+}) {
+ const { page, tag } = await searchParams
+ const pageNum = Math.max(1, Number(page || 1))
+ const posts = await getAllPostMeta()
+
+ return (
+
+ )
+}
diff --git a/apps/sim/app/(landing)/library/rss.xml/route.ts b/apps/sim/app/(landing)/library/rss.xml/route.ts
new file mode 100644
index 00000000000..4e96968c270
--- /dev/null
+++ b/apps/sim/app/(landing)/library/rss.xml/route.ts
@@ -0,0 +1,49 @@
+import { NextResponse } from 'next/server'
+import { latestModified } from '@/lib/content/utils'
+import { SITE_URL } from '@/lib/core/utils/urls'
+import { getAllPostMeta } from '@/lib/library/registry'
+import { LIBRARY_SECTION } from '@/lib/library/seo'
+
+export const revalidate = 3600
+
+export async function GET() {
+ const posts = await getAllPostMeta()
+ const items = posts.slice(0, 50)
+ const site = SITE_URL
+ const lastBuildDate = (latestModified(items) ?? new Date()).toUTCString()
+
+ const xml = `
+
+
+ Sim ${LIBRARY_SECTION.name}
+ ${site}
+ ${LIBRARY_SECTION.description}
+ en-us
+ ${lastBuildDate}
+
+ ${items
+ .map(
+ (p) => `
+
+
+ ${p.canonical}
+ ${p.canonical}
+ ${new Date(p.date).toUTCString()}
+
+ ${(p.authors || [p.author])
+ .map((a) => ``)
+ .join('\n')}
+ ${p.tags.map((t) => ``).join('\n ')}
+ `
+ )
+ .join('')}
+
+`
+
+ return new NextResponse(xml, {
+ headers: {
+ 'Content-Type': 'application/xml; charset=utf-8',
+ 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
+ },
+ })
+}
diff --git a/apps/sim/app/(landing)/library/sitemap-images.xml/route.ts b/apps/sim/app/(landing)/library/sitemap-images.xml/route.ts
new file mode 100644
index 00000000000..d97c02b2734
--- /dev/null
+++ b/apps/sim/app/(landing)/library/sitemap-images.xml/route.ts
@@ -0,0 +1,31 @@
+import { NextResponse } from 'next/server'
+import { SITE_URL } from '@/lib/core/utils/urls'
+import { getAllPostMeta } from '@/lib/library/registry'
+
+export const revalidate = 3600
+
+export async function GET() {
+ const posts = await getAllPostMeta()
+ const base = SITE_URL
+ const xml = `
+
+${posts
+ .map(
+ (p) => `
+ ${p.canonical}
+
+ ${p.ogImage.startsWith('http') ? p.ogImage : `${base}${p.ogImage}`}
+
+
+
+`
+ )
+ .join('\n')}
+`
+ return new NextResponse(xml, {
+ headers: {
+ 'Content-Type': 'application/xml; charset=utf-8',
+ 'Cache-Control': 'public, s-maxage=3600, stale-while-revalidate=86400',
+ },
+ })
+}
diff --git a/apps/sim/app/(landing)/library/tags/loading.tsx b/apps/sim/app/(landing)/library/tags/loading.tsx
new file mode 100644
index 00000000000..1fbabbbd995
--- /dev/null
+++ b/apps/sim/app/(landing)/library/tags/loading.tsx
@@ -0,0 +1,5 @@
+import { ContentTagsLoading } from '@/app/(landing)/components'
+
+export default function TagsLoading() {
+ return
+}
diff --git a/apps/sim/app/(landing)/library/tags/page.tsx b/apps/sim/app/(landing)/library/tags/page.tsx
new file mode 100644
index 00000000000..d676392e229
--- /dev/null
+++ b/apps/sim/app/(landing)/library/tags/page.tsx
@@ -0,0 +1,17 @@
+import type { Metadata } from 'next'
+import { getAllTags } from '@/lib/library/registry'
+import { buildTagsBreadcrumbJsonLd, buildTagsMetadata, LIBRARY_SECTION } from '@/lib/library/seo'
+import { ContentTagsPage } from '@/app/(landing)/components'
+
+export const metadata: Metadata = buildTagsMetadata()
+
+export default async function TagsIndex() {
+ const tags = await getAllTags()
+ return (
+
+ )
+}
diff --git a/apps/sim/app/api/auth/oauth/utils.ts b/apps/sim/app/api/auth/oauth/utils.ts
index 041a6a2ce53..7574b789443 100644
--- a/apps/sim/app/api/auth/oauth/utils.ts
+++ b/apps/sim/app/api/auth/oauth/utils.ts
@@ -2,7 +2,7 @@ import { createSign } from 'crypto'
import { db } from '@sim/db'
import { account, credential } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
-import { toError } from '@sim/utils/errors'
+import { getPostgresErrorCode, toError } from '@sim/utils/errors'
import { and, desc, eq } from 'drizzle-orm'
import { withLeaderLock } from '@/lib/concurrency/leader-lock'
import { coalesceLocally } from '@/lib/concurrency/singleflight'
@@ -281,7 +281,7 @@ export async function safeAccountInsert(
await db.insert(account).values(data)
logger.info(`Created new ${context.provider} account for user`, { userId: data.userId })
} catch (error: any) {
- if (error?.code === '23505') {
+ if (getPostgresErrorCode(error) === '23505') {
logger.error(`Duplicate ${context.provider} account detected, credential already exists`, {
userId: data.userId,
identifier: context.identifier,
diff --git a/apps/sim/app/api/billing/invoices/route.test.ts b/apps/sim/app/api/billing/invoices/route.test.ts
index 0247d5efda6..985b20c99c8 100644
--- a/apps/sim/app/api/billing/invoices/route.test.ts
+++ b/apps/sim/app/api/billing/invoices/route.test.ts
@@ -2,6 +2,7 @@
* @vitest-environment node
*/
import { createMockRequest, dbChainMock, dbChainMockFns } from '@sim/testing'
+import { generateShortId } from '@sim/utils/id'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockGetSession, mockGetStripeClient, mockStripeInvoicesList } = vi.hoisted(() => ({
@@ -25,7 +26,7 @@ import { GET } from '@/app/api/billing/invoices/route'
function makeInvoice(overrides: Record = {}) {
return {
- id: `in_${Math.random().toString(36).slice(2)}`,
+ id: `in_${generateShortId()}`,
number: 'INV-1',
created: 1700000000,
total: 1000,
diff --git a/apps/sim/app/api/cron/renew-subscriptions/route.test.ts b/apps/sim/app/api/cron/renew-subscriptions/route.test.ts
index 1f4b74cbce1..1340ec486d7 100644
--- a/apps/sim/app/api/cron/renew-subscriptions/route.test.ts
+++ b/apps/sim/app/api/cron/renew-subscriptions/route.test.ts
@@ -12,6 +12,7 @@ import {
redisConfigMockFns,
resetDbChainMock,
} from '@sim/testing'
+import { sleep } from '@sim/utils/helpers'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockVerifyCronAuth } = vi.hoisted(() => ({
@@ -37,7 +38,7 @@ function createRequest() {
)
}
-const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0))
+const flushMicrotasks = () => sleep(0)
describe('Teams subscription renewal route (fire-and-forget)', () => {
beforeEach(() => {
diff --git a/apps/sim/app/api/custom-blocks/route.ts b/apps/sim/app/api/custom-blocks/route.ts
index 99b6319aa3d..7c3c964b1dd 100644
--- a/apps/sim/app/api/custom-blocks/route.ts
+++ b/apps/sim/app/api/custom-blocks/route.ts
@@ -29,6 +29,7 @@ function toWire(block: CustomBlockWithInputs) {
organizationId: block.organizationId,
workflowId: block.workflowId,
workflowName: block.workflowName,
+ workspaceId: block.workspaceId,
workspaceName: block.workspaceName,
type: block.type,
name: block.name,
diff --git a/apps/sim/app/api/files/public/[token]/otp/route.ts b/apps/sim/app/api/files/public/[token]/otp/route.ts
index c7257db0d12..0dd240788fd 100644
--- a/apps/sim/app/api/files/public/[token]/otp/route.ts
+++ b/apps/sim/app/api/files/public/[token]/otp/route.ts
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
+import { normalizeEmail } from '@sim/utils/string'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { renderOTPEmail } from '@/components/emails'
@@ -71,7 +72,7 @@ export const POST = withRouteHandler(
const { token } = parsed.data.params
// Normalize once so allow-list matching, OTP storage, and the verify lookup
// all key off the same value (allow-list entries are stored lowercase).
- const email = parsed.data.body.email.trim().toLowerCase()
+ const email = normalizeEmail(parsed.data.body.email)
const resolved = await resolveActiveShareByToken(token)
if (!resolved) {
@@ -133,7 +134,7 @@ export const PUT = withRouteHandler(
if (!parsed.success) return parsed.response
const { token } = parsed.data.params
const { otp } = parsed.data.body
- const email = parsed.data.body.email.trim().toLowerCase()
+ const email = normalizeEmail(parsed.data.body.email)
const resolved = await resolveActiveShareByToken(token)
if (!resolved) {
diff --git a/apps/sim/app/api/files/public/[token]/sso/route.ts b/apps/sim/app/api/files/public/[token]/sso/route.ts
index 508c94777ab..b5185149440 100644
--- a/apps/sim/app/api/files/public/[token]/sso/route.ts
+++ b/apps/sim/app/api/files/public/[token]/sso/route.ts
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
+import { normalizeEmail } from '@sim/utils/string'
import type { NextRequest } from 'next/server'
import { NextResponse } from 'next/server'
import { publicFileSSOContract } from '@/lib/api/contracts/public-shares'
@@ -53,7 +54,7 @@ export const POST = withRouteHandler(
const parsed = await parseRequest(publicFileSSOContract, request, context)
if (!parsed.success) return parsed.response
const { token } = parsed.data.params
- const email = parsed.data.body.email.trim().toLowerCase()
+ const email = normalizeEmail(parsed.data.body.email)
const resolved = await resolveActiveShareByToken(token)
if (!resolved) {
diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts
index 6f04cfaf076..33ab418dac5 100644
--- a/apps/sim/app/api/function/execute/route.ts
+++ b/apps/sim/app/api/function/execute/route.ts
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { functionExecuteContract } from '@/lib/api/contracts'
import { parseRequest } from '@/lib/api/server'
@@ -109,13 +110,13 @@ const JS_RESERVED_WORDS = new Set([
'public',
])
-type TypeScriptModule = typeof import('typescript')
+type TypeScriptModule = typeof import('@typescript/typescript6')
let typescriptModulePromise: Promise | null = null
async function loadTypeScriptModule(): Promise {
if (!typescriptModulePromise) {
- typescriptModulePromise = import('typescript').then(
+ typescriptModulePromise = import('@typescript/typescript6').then(
(mod) => (mod?.default ?? mod) as TypeScriptModule,
(error) => {
typescriptModulePromise = null
@@ -1021,7 +1022,7 @@ async function maybeExportSandboxFileToWorkspace(args: {
return NextResponse.json(
{
success: false,
- error: error instanceof Error ? error.message : 'Failed to export sandbox file',
+ error: getErrorMessage(error, 'Failed to export sandbox file'),
output: { result: null, stdout: cleanStdout(stdout), executionTime },
},
{ status: 400 }
@@ -1165,7 +1166,7 @@ async function maybeExportSandboxFilesToWorkspace(args: {
return NextResponse.json(
{
success: false,
- error: error instanceof Error ? error.message : 'Invalid sandbox output destination',
+ error: getErrorMessage(error, 'Invalid sandbox output destination'),
output: {
result: null,
stdout: cleanStdout(args.stdout),
@@ -1220,7 +1221,7 @@ async function maybeExportSandboxFilesToWorkspace(args: {
return NextResponse.json(
{
success: false,
- error: error instanceof Error ? error.message : 'Failed to export sandbox files',
+ error: getErrorMessage(error, 'Failed to export sandbox files'),
output: {
result: null,
stdout: cleanStdout(args.stdout),
diff --git a/apps/sim/app/api/logs/export/route.ts b/apps/sim/app/api/logs/export/route.ts
index a2006538fd9..bca5e283796 100644
--- a/apps/sim/app/api/logs/export/route.ts
+++ b/apps/sim/app/api/logs/export/route.ts
@@ -1,6 +1,7 @@
import { dbReplica } from '@sim/db'
import { workflow, workflowExecutionLogs } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
+import { getErrorMessage } from '@sim/utils/errors'
import { and, desc, eq, sql } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { getSession } from '@/lib/auth'
@@ -139,7 +140,7 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
} catch (rowError) {
logger.warn('Skipping unserializable execution data for export row', {
executionId: r.executionId,
- error: rowError instanceof Error ? rowError.message : String(rowError),
+ error: getErrorMessage(rowError),
})
}
const line = [
diff --git a/apps/sim/app/api/mcp/servers/test-connection/route.ts b/apps/sim/app/api/mcp/servers/test-connection/route.ts
index a72c0c506bc..9707f28b651 100644
--- a/apps/sim/app/api/mcp/servers/test-connection/route.ts
+++ b/apps/sim/app/api/mcp/servers/test-connection/route.ts
@@ -1,5 +1,6 @@
import { createLogger } from '@sim/logger'
import { toError } from '@sim/utils/errors'
+import { truncate } from '@sim/utils/string'
import type { NextRequest } from 'next/server'
import { mcpServerTestBodySchema } from '@/lib/api/contracts/mcp'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
@@ -59,7 +60,7 @@ function sanitizeConnectionError(error: unknown): string {
}
const firstLine = error.message.split('\n')[0]
- return firstLine.length > 200 ? `${firstLine.slice(0, 200)}...` : firstLine
+ return truncate(firstLine, 200)
}
/**
diff --git a/apps/sim/app/api/memory/route.ts b/apps/sim/app/api/memory/route.ts
index 53b9340f3c6..b1df3620f44 100644
--- a/apps/sim/app/api/memory/route.ts
+++ b/apps/sim/app/api/memory/route.ts
@@ -1,6 +1,7 @@
import { db } from '@sim/db'
import { memory } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
+import { getPostgresErrorCode } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
import { and, eq, isNull, like } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
@@ -224,7 +225,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
{ status: 200 }
)
} catch (error: any) {
- if (error.code === '23505') {
+ if (getPostgresErrorCode(error) === '23505') {
return NextResponse.json(
{ success: false, error: { message: 'Memory with this key already exists' } },
{ status: 409 }
diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts
index c97a394c187..37236aa160f 100644
--- a/apps/sim/app/api/mothership/execute/route.ts
+++ b/apps/sim/app/api/mothership/execute/route.ts
@@ -326,12 +326,12 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
: 'Mothership execute error',
{
requestId,
- error: error instanceof Error ? error.message : 'Unknown error',
+ error: getErrorMessage(error, 'Unknown error'),
}
)
send({
type: 'error',
- error: error instanceof Error ? error.message : 'Internal server error',
+ error: getErrorMessage(error, 'Internal server error'),
})
} finally {
allowExplicitAbort = false
diff --git a/apps/sim/app/api/organizations/[id]/invitations/route.ts b/apps/sim/app/api/organizations/[id]/invitations/route.ts
index 0f954023a2d..650aa0f7e90 100644
--- a/apps/sim/app/api/organizations/[id]/invitations/route.ts
+++ b/apps/sim/app/api/organizations/[id]/invitations/route.ts
@@ -12,6 +12,7 @@ import {
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
import { getErrorMessage } from '@sim/utils/errors'
+import { normalizeEmail } from '@sim/utils/string'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
@@ -178,7 +179,7 @@ export const POST = withRouteHandler(
new Set(
invitationEmails
.map((raw) => {
- const normalized = raw.trim().toLowerCase()
+ const normalized = normalizeEmail(raw)
return quickValidateEmail(normalized).isValid ? normalized : null
})
.filter((email): email is string => !!email)
@@ -572,7 +573,7 @@ export const POST = withRouteHandler(
(email) => pendingEmails.includes(email) && !memberUserIdByEmail.has(email)
),
invalidEmails: invitationEmails.filter(
- (email) => !quickValidateEmail(email.trim().toLowerCase()).isValid
+ (email) => !quickValidateEmail(normalizeEmail(email)).isValid
),
workspaceGrantsPerInvite: validGrants.length,
...(seatValidation
diff --git a/apps/sim/app/api/organizations/[id]/members/route.ts b/apps/sim/app/api/organizations/[id]/members/route.ts
index 9482c76ac9b..74cb552e8e7 100644
--- a/apps/sim/app/api/organizations/[id]/members/route.ts
+++ b/apps/sim/app/api/organizations/[id]/members/route.ts
@@ -9,6 +9,7 @@ import {
} from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { isOrgAdminRole } from '@sim/platform-authz/workspace'
+import { normalizeEmail } from '@sim/utils/string'
import { and, eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import {
@@ -212,7 +213,7 @@ export const POST = withRouteHandler(
const { email, role = 'member' } = parsed.data.body
// Validate and normalize email
- const normalizedEmail = email.trim().toLowerCase()
+ const normalizedEmail = normalizeEmail(email)
const validation = quickValidateEmail(normalizedEmail)
if (!validation.isValid) {
return NextResponse.json(
diff --git a/apps/sim/app/api/schedules/execute/route.test.ts b/apps/sim/app/api/schedules/execute/route.test.ts
index 7b61e3198a1..b7057d4fe4f 100644
--- a/apps/sim/app/api/schedules/execute/route.test.ts
+++ b/apps/sim/app/api/schedules/execute/route.test.ts
@@ -140,6 +140,10 @@ vi.mock('@sim/utils/id', () => ({
),
}))
+vi.mock('@sim/utils/random', () => ({
+ randomInt: vi.fn(() => 0),
+}))
+
import { GET, runScheduleTick } from './route'
const SINGLE_SCHEDULE = [
@@ -382,7 +386,6 @@ describe('Scheduled Workflow Execution API Route', () => {
it('executes database fallback schedules through durable async job rows', async () => {
mockShouldExecuteInline.mockReturnValue(true)
- const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
dbChainMockFns.limit
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
.mockResolvedValueOnce([])
@@ -390,33 +393,28 @@ describe('Scheduled Workflow Execution API Route', () => {
.mockReturnValueOnce(SINGLE_SCHEDULE)
.mockResolvedValueOnce([{ id: 'job-id-1' }])
- try {
- await runScheduleTick('test-request-id')
- expect(mockEnqueue).toHaveBeenCalledWith(
- 'schedule-execution',
- expect.objectContaining({ scheduleId: 'schedule-1' }),
- expect.objectContaining({
- jobId: expect.stringMatching(/^schedule_[0-9a-f]{32}$/),
- metadata: expect.objectContaining({
- workflowId: 'workflow-1',
- workspaceId: 'workspace-1',
- }),
- })
- )
- expect(mockStartJob).not.toHaveBeenCalled()
- expect(mockExecuteScheduleJob).toHaveBeenCalledWith(
- expect.objectContaining({ scheduleId: 'schedule-1' })
- )
- expect(mockCompleteJob).toHaveBeenCalledWith('job-id-1', null)
- } finally {
- randomSpy.mockRestore()
- }
+ await runScheduleTick('test-request-id')
+ expect(mockEnqueue).toHaveBeenCalledWith(
+ 'schedule-execution',
+ expect.objectContaining({ scheduleId: 'schedule-1' }),
+ expect.objectContaining({
+ jobId: expect.stringMatching(/^schedule_[0-9a-f]{32}$/),
+ metadata: expect.objectContaining({
+ workflowId: 'workflow-1',
+ workspaceId: 'workspace-1',
+ }),
+ })
+ )
+ expect(mockStartJob).not.toHaveBeenCalled()
+ expect(mockExecuteScheduleJob).toHaveBeenCalledWith(
+ expect.objectContaining({ scheduleId: 'schedule-1' })
+ )
+ expect(mockCompleteJob).toHaveBeenCalledWith('job-id-1', null)
})
it('releases database fallback claims when the global concurrency cap is full', async () => {
mockShouldExecuteInline.mockReturnValue(true)
const claimedAt = new Date('2025-01-01T00:00:00.000Z')
- const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
mockProcessingCounts(0, 0, 50)
dbChainMockFns.limit
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
@@ -425,20 +423,15 @@ describe('Scheduled Workflow Execution API Route', () => {
.mockReturnValueOnce([{ ...SINGLE_SCHEDULE[0], lastQueuedAt: claimedAt }])
.mockResolvedValueOnce([])
- try {
- await runScheduleTick('test-request-id')
- expect(mockEnqueue).toHaveBeenCalled()
- expect(mockExecuteScheduleJob).not.toHaveBeenCalled()
- expect(mockCompleteJob).not.toHaveBeenCalled()
- expect(mockReleaseScheduleLock).not.toHaveBeenCalled()
- } finally {
- randomSpy.mockRestore()
- }
+ await runScheduleTick('test-request-id')
+ expect(mockEnqueue).toHaveBeenCalled()
+ expect(mockExecuteScheduleJob).not.toHaveBeenCalled()
+ expect(mockCompleteJob).not.toHaveBeenCalled()
+ expect(mockReleaseScheduleLock).not.toHaveBeenCalled()
})
it('recovers stale database fallback processing jobs before resuming them', async () => {
mockShouldExecuteInline.mockReturnValue(true)
- const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
const staleStartedAt = new Date('2024-12-31T00:00:00.000Z')
mockProcessingCounts(0, 0)
mockGetJob
@@ -473,25 +466,21 @@ describe('Scheduled Workflow Execution API Route', () => {
.mockReturnValueOnce([{ ...SINGLE_SCHEDULE[0], lastQueuedAt: new Date('2025-01-01') }])
.mockResolvedValueOnce([{ id: 'job-id-1' }])
- try {
- await runScheduleTick('test-request-id')
- expect(mockExecuteScheduleJob).toHaveBeenCalledWith(
- expect.objectContaining({ scheduleId: 'schedule-1' })
- )
- expect(mockCompleteJob).toHaveBeenCalledWith(
- expect.stringMatching(/^schedule_[0-9a-f]{32}$/),
- null
- )
- expect(dbChainMockFns.set).toHaveBeenCalledWith(
- expect.objectContaining({
- status: 'pending',
- startedAt: null,
- error: expect.stringContaining('stale schedule execution processing lease'),
- })
- )
- } finally {
- randomSpy.mockRestore()
- }
+ await runScheduleTick('test-request-id')
+ expect(mockExecuteScheduleJob).toHaveBeenCalledWith(
+ expect.objectContaining({ scheduleId: 'schedule-1' })
+ )
+ expect(mockCompleteJob).toHaveBeenCalledWith(
+ expect.stringMatching(/^schedule_[0-9a-f]{32}$/),
+ null
+ )
+ expect(dbChainMockFns.set).toHaveBeenCalledWith(
+ expect.objectContaining({
+ status: 'pending',
+ startedAt: null,
+ error: expect.stringContaining('stale schedule execution processing lease'),
+ })
+ )
})
it('resumes pending database fallback jobs without waiting for a stale schedule claim', async () => {
@@ -653,7 +642,6 @@ describe('Scheduled Workflow Execution API Route', () => {
it('uses one backend mode decision for slot accounting and schedule processing', async () => {
mockShouldExecuteInline.mockReturnValue(true)
- const randomSpy = vi.spyOn(Math, 'random').mockReturnValue(0)
dbChainMockFns.limit
.mockResolvedValueOnce(SINGLE_CLAIMED_SCHEDULE_ROWS)
.mockResolvedValueOnce([])
@@ -661,15 +649,11 @@ describe('Scheduled Workflow Execution API Route', () => {
.mockReturnValueOnce(SINGLE_SCHEDULE)
.mockResolvedValueOnce([{ id: 'job-id-1' }])
- try {
- await runScheduleTick('test-request-id')
- expect(mockShouldExecuteInline).toHaveBeenCalledTimes(1)
- expect(mockExecuteScheduleJob).toHaveBeenCalledWith(
- expect.objectContaining({ scheduleId: 'schedule-1' })
- )
- } finally {
- randomSpy.mockRestore()
- }
+ await runScheduleTick('test-request-id')
+ expect(mockShouldExecuteInline).toHaveBeenCalledTimes(1)
+ expect(mockExecuteScheduleJob).toHaveBeenCalledWith(
+ expect.objectContaining({ scheduleId: 'schedule-1' })
+ )
})
it('restores the original claim token when an active durable job owns the occurrence', async () => {
diff --git a/apps/sim/app/api/schedules/execute/route.ts b/apps/sim/app/api/schedules/execute/route.ts
index 52f7f3e5412..3214dca2f9b 100644
--- a/apps/sim/app/api/schedules/execute/route.ts
+++ b/apps/sim/app/api/schedules/execute/route.ts
@@ -4,6 +4,7 @@ import { sha256Hex } from '@sim/security/hash'
import { toError } from '@sim/utils/errors'
import { sleep } from '@sim/utils/helpers'
import { generateId } from '@sim/utils/id'
+import { randomInt } from '@sim/utils/random'
import { backoffWithJitter } from '@sim/utils/retry'
import { Cron } from 'croner'
import { and, asc, eq, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm'
@@ -770,7 +771,7 @@ async function processScheduleItem(
let enqueuedJobId: string | null = null
try {
- const delayMs = Math.floor(Math.random() * SCHEDULE_JITTER_MAX_MS)
+ const delayMs = randomInt(0, SCHEDULE_JITTER_MAX_MS)
const scheduleJobId = buildScheduleExecutionJobId(schedule)
const existingJob = await jobQueue.getJob(scheduleJobId)
diff --git a/apps/sim/app/api/table/[tableId]/rows/route.ts b/apps/sim/app/api/table/[tableId]/rows/route.ts
index ca239534696..e200220ea11 100644
--- a/apps/sim/app/api/table/[tableId]/rows/route.ts
+++ b/apps/sim/app/api/table/[tableId]/rows/route.ts
@@ -74,7 +74,6 @@ async function handleBatchInsert(
rows,
workspaceId: validated.workspaceId,
userId,
- positions: validated.positions,
orderKeys: validated.orderKeys,
},
table,
diff --git a/apps/sim/app/api/table/import-csv/route.test.ts b/apps/sim/app/api/table/import-csv/route.test.ts
index eaf0dde7872..b85e1ccb01b 100644
--- a/apps/sim/app/api/table/import-csv/route.test.ts
+++ b/apps/sim/app/api/table/import-csv/route.test.ts
@@ -2,6 +2,7 @@
* @vitest-environment node
*/
import { hybridAuthMockFns, permissionsMock, permissionsMockFns } from '@sim/testing'
+import { getErrorMessage } from '@sim/utils/errors'
import type { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -40,7 +41,7 @@ vi.mock('@/app/api/table/utils', async () => {
{ status: error.code === 'FILE_TOO_LARGE' ? 413 : 400 }
),
rowWriteErrorResponse: (error: unknown) => {
- const message = error instanceof Error ? error.message : String(error)
+ const message = getErrorMessage(error)
return message.includes('row limit')
? NextResponse.json({ error: message }, { status: 400 })
: null
diff --git a/apps/sim/app/api/v1/admin/credits/route.ts b/apps/sim/app/api/v1/admin/credits/route.ts
index 756f1efc304..cc78f84170f 100644
--- a/apps/sim/app/api/v1/admin/credits/route.ts
+++ b/apps/sim/app/api/v1/admin/credits/route.ts
@@ -27,6 +27,7 @@ import { db } from '@sim/db'
import { organization, subscription, user, userStats } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateShortId } from '@sim/utils/id'
+import { normalizeEmail } from '@sim/utils/string'
import { and, eq, inArray } from 'drizzle-orm'
import { adminV1IssueCreditsContract } from '@/lib/api/contracts/v1/admin'
import { parseRequest } from '@/lib/api/server'
@@ -88,7 +89,7 @@ export const POST = withRouteHandler(
return badRequestResponse('Either userId or email is required')
}
- const normalizedEmail = email.toLowerCase().trim()
+ const normalizedEmail = normalizeEmail(email)
const [userData] = await db
.select({ id: user.id, email: user.email })
.from(user)
diff --git a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts
index 002204f1425..b51bd8cb1e7 100644
--- a/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts
+++ b/apps/sim/app/api/v1/knowledge/[id]/documents/route.test.ts
@@ -3,6 +3,7 @@
*
* @vitest-environment node
*/
+import { getErrorMessage } from '@sim/utils/errors'
import { NextRequest } from 'next/server'
import { beforeEach, describe, expect, it, vi } from 'vitest'
@@ -32,7 +33,7 @@ vi.mock('@/app/api/v1/knowledge/utils', () => ({
resolveKnowledgeBase: mockResolveKnowledgeBase,
serializeDate: (date: unknown) => (date instanceof Date ? date.toISOString() : date),
handleError: (_requestId: string, error: unknown) =>
- new Response(JSON.stringify({ error: error instanceof Error ? error.message : 'error' }), {
+ new Response(JSON.stringify({ error: getErrorMessage(error, 'error') }), {
status: 500,
}),
}))
diff --git a/apps/sim/app/api/webhooks/outbox/process/route.ts b/apps/sim/app/api/webhooks/outbox/process/route.ts
index 251c556ec79..73728d6b06f 100644
--- a/apps/sim/app/api/webhooks/outbox/process/route.ts
+++ b/apps/sim/app/api/webhooks/outbox/process/route.ts
@@ -8,7 +8,7 @@ import { processOutboxEvents } from '@/lib/core/outbox/service'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { workflowDeploymentOutboxHandlers } from '@/lib/workflows/deployment-outbox'
-import { reapStaleBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
+import { reapStaleBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
const logger = createLogger('OutboxProcessorAPI')
diff --git a/apps/sim/app/api/webhooks/poll/[provider]/route.test.ts b/apps/sim/app/api/webhooks/poll/[provider]/route.test.ts
index e8d5fc91da4..859eba6b73d 100644
--- a/apps/sim/app/api/webhooks/poll/[provider]/route.test.ts
+++ b/apps/sim/app/api/webhooks/poll/[provider]/route.test.ts
@@ -4,6 +4,7 @@
* @vitest-environment node
*/
import { createMockRequest, redisConfigMock, redisConfigMockFns } from '@sim/testing'
+import { sleep } from '@sim/utils/helpers'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockVerifyCronAuth, mockPollProvider } = vi.hoisted(() => ({
@@ -32,7 +33,7 @@ function createContext(provider: string) {
return { params: Promise.resolve({ provider }) }
}
-const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0))
+const flushMicrotasks = () => sleep(0)
describe('webhook polling route (fire-and-forget)', () => {
beforeEach(() => {
diff --git a/apps/sim/app/api/workspace-events/poll/route.test.ts b/apps/sim/app/api/workspace-events/poll/route.test.ts
index a577d54b4fb..dee80482bc8 100644
--- a/apps/sim/app/api/workspace-events/poll/route.test.ts
+++ b/apps/sim/app/api/workspace-events/poll/route.test.ts
@@ -4,6 +4,7 @@
* @vitest-environment node
*/
import { createMockRequest, redisConfigMock, redisConfigMockFns } from '@sim/testing'
+import { sleep } from '@sim/utils/helpers'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { mockVerifyCronAuth, mockPollNoActivityEvents } = vi.hoisted(() => ({
@@ -29,7 +30,7 @@ function createRequest() {
return createMockRequest('GET', undefined, {}, 'http://localhost:3000/api/workspace-events/poll')
}
-const flushMicrotasks = () => new Promise((resolve) => setTimeout(resolve, 0))
+const flushMicrotasks = () => sleep(0)
describe('workspace events polling route (fire-and-forget)', () => {
beforeEach(() => {
diff --git a/apps/sim/app/api/workspaces/[id]/background-work/route.ts b/apps/sim/app/api/workspaces/[id]/background-work/route.ts
index e7d2862ac47..727b4445f78 100644
--- a/apps/sim/app/api/workspaces/[id]/background-work/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/background-work/route.ts
@@ -4,8 +4,8 @@ import { getWorkspaceBackgroundWorkContract } from '@/lib/api/contracts/workspac
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { listSurfacedBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
-import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz'
+import { listSurfacedBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
+import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz'
export const GET = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
@@ -17,12 +17,13 @@ export const GET = withRouteHandler(
const parsed = await parseRequest(getWorkspaceBackgroundWorkContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
+ const { cursor, limit } = parsed.data.query
// The fork Activity feed is a fork feature: gate it behind the same forking-enabled +
// workspace-admin check the other fork routes use, instead of a bare access check.
await assertWorkspaceAdminAccess(id, session.user.id)
- const rows = await listSurfacedBackgroundWork(db, id)
+ const { rows, nextCursor } = await listSurfacedBackgroundWork(db, id, { cursor, limit })
return NextResponse.json({
items: rows.map((row) => ({
id: row.id,
@@ -36,6 +37,7 @@ export const GET = withRouteHandler(
startedAt: row.startedAt.toISOString(),
completedAt: row.completedAt ? row.completedAt.toISOString() : null,
})),
+ nextCursor,
})
}
)
diff --git a/apps/sim/app/api/workspaces/[id]/fork/availability/route.ts b/apps/sim/app/api/workspaces/[id]/fork/availability/route.ts
new file mode 100644
index 00000000000..64142707f1e
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/fork/availability/route.ts
@@ -0,0 +1,37 @@
+import { type NextRequest, NextResponse } from 'next/server'
+import { getForkAvailabilityContract } from '@/lib/api/contracts/workspace-fork'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
+import { isForkingAvailableForWorkspace } from '@/ee/workspace-forking/lib/lineage/authz'
+
+/**
+ * Whether forking is available for this workspace: the server-evaluated verdict of the
+ * same gate every fork route enforces (env/plan + the `workspace-forking` AppConfig
+ * rollout flag). Member-readable — it only reveals feature on/off, and the client uses
+ * it to show/hide the Forks settings tab and context-menu entries.
+ */
+export const GET = withRouteHandler(
+ async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(getForkAvailabilityContract, req, context)
+ if (!parsed.success) return parsed.response
+ const { id } = parsed.data.params
+
+ const access = await checkWorkspaceAccess(id, session.user.id)
+ if (!access.exists || !access.workspace) {
+ return NextResponse.json({ error: 'Workspace not found' }, { status: 404 })
+ }
+
+ const available = await isForkingAvailableForWorkspace(
+ access.workspace.organizationId,
+ session.user.id
+ )
+ return NextResponse.json({ available })
+ }
+)
diff --git a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
index 7f2d2a4eb9a..b18782bd132 100644
--- a/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/fork/diff/route.ts
@@ -6,26 +6,26 @@ import { getForkDiffContract } from '@/lib/api/contracts/workspace-fork'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { loadTargetDraftSubBlocks } from '@/lib/workspaces/fork/copy/copy-workflows'
-import { loadSourceDeployedStates } from '@/lib/workspaces/fork/copy/deploy-bridge'
-import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz'
-import { loadForkBlockMap } from '@/lib/workspaces/fork/mapping/block-map-store'
+import { loadTargetDraftSubBlocks } from '@/ee/workspace-forking/lib/copy/copy-workflows'
+import { loadSourceDeployedStates } from '@/ee/workspace-forking/lib/copy/deploy-bridge'
+import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
+import { loadForkBlockMap } from '@/ee/workspace-forking/lib/mapping/block-map-store'
import {
collectForkDependentReconfigs,
collectForkResourceUsages,
-} from '@/lib/workspaces/fork/mapping/dependent-reconfigs'
+} from '@/ee/workspace-forking/lib/mapping/dependent-reconfigs'
import {
forkDependentValueKey,
loadForkDependentValues,
-} from '@/lib/workspaces/fork/mapping/dependent-value-store'
-import { listForkResourceCandidates } from '@/lib/workspaces/fork/mapping/resources'
+} from '@/ee/workspace-forking/lib/mapping/dependent-value-store'
+import { listForkResourceCandidates } from '@/ee/workspace-forking/lib/mapping/resources'
import {
annotateForkClearedRefSourceLiveness,
collectForkClearedRefCandidates,
-} from '@/lib/workspaces/fork/promote/cleared-refs'
-import { computeForkPromotePlan } from '@/lib/workspaces/fork/promote/promote-plan'
-import { buildForkBlockIdResolver } from '@/lib/workspaces/fork/remap/block-identity'
-import { readTargetDraftDependentValue } from '@/lib/workspaces/fork/remap/remap-references'
+} from '@/ee/workspace-forking/lib/promote/cleared-refs'
+import { computeForkPromotePlan } from '@/ee/workspace-forking/lib/promote/promote-plan'
+import { buildForkBlockIdResolver } from '@/ee/workspace-forking/lib/remap/block-identity'
+import { readTargetDraftDependentValue } from '@/ee/workspace-forking/lib/remap/remap-references'
export const GET = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
@@ -62,17 +62,20 @@ export const GET = withRouteHandler(
const resolveBlockId = buildForkBlockIdResolver(sourceIsParent, blockMap)
// Stored dependent values are the source of truth for what each selector is set to. Overlay
- // them as each field's currentValue so the modal pre-fills what the user actually saved. For
- // an edge that predates the store the fallback is the TARGET's own configured value (loaded
- // from its draft) - never the source's, which would overwrite the target's selection on the
- // first sync. Both the stored read and the draft read are scoped to the plan's replace
- // targets, the only workflows with dependents to reconfigure.
+ // them as each field's currentValue so the modal pre-fills what the user actually saved.
+ // Before the FIRST sync populates the store (fork-create seeds mappings but no dependent
+ // values), the fallback is the TARGET's own configured value (loaded from its draft) - never
+ // the source's, which would overwrite the target's selection. The stored read spans EVERY
+ // plan target: a create-mode (never-synced) workflow's deterministic target id is what the
+ // first sync will use, so values pre-configured for it in the mapping editor pre-fill here
+ // too. The draft read stays replace-scoped (creates have no target draft to fall back to).
const replaceTargetIds = plan.items
.filter((item) => item.mode === 'replace')
.map((item) => item.targetWorkflowId)
+ const allTargetIds = plan.items.map((item) => item.targetWorkflowId)
const [storedValues, targetDraftByWorkflow, sourceCandidates, sourceWorkflowRows] =
await Promise.all([
- loadForkDependentValues(db, auth.edge.childWorkspaceId, replaceTargetIds),
+ loadForkDependentValues(db, auth.edge.childWorkspaceId, allTargetIds),
loadTargetDraftSubBlocks(db, replaceTargetIds),
// Source resource labels (per kind) + workflow names, for the cleared-ref list's display.
listForkResourceCandidates(db, auth.sourceWorkspaceId),
@@ -105,22 +108,36 @@ export const GET = withRouteHandler(
sourceBlocksByTarget.set(item.targetWorkflowId, byBlock)
}
- const dependentReconfigs = collectForkDependentReconfigs(
- plan.items,
- sourceStates,
- resolveBlockId
- ).map((field) => ({
- ...field,
- currentValue:
- storedByKey.get(
- forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
- ) ??
- readTargetDraftDependentValue(
- targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId),
- sourceBlocksByTarget.get(field.targetWorkflowId)?.get(field.targetBlockId),
- field.subBlockKey
- ),
- }))
+ // Replace-target fields pre-fill from the store, falling back to the TARGET's own draft
+ // value before the first sync populates the store (never the source's, which would
+ // overwrite the target's selection). Create-target fields (never-synced workflows)
+ // pre-fill from the store, falling back to the SOURCE value the collector emitted -
+ // that's exactly what the first sync copies verbatim, so the pre-fill is honest and
+ // configuring it ahead of the first sync is possible (the deterministic target ids
+ // already exist).
+ const dependentReconfigs = [
+ ...collectForkDependentReconfigs(plan.items, sourceStates, resolveBlockId).map((field) => ({
+ ...field,
+ currentValue:
+ storedByKey.get(
+ forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
+ ) ??
+ readTargetDraftDependentValue(
+ targetDraftByWorkflow.get(field.targetWorkflowId)?.get(field.targetBlockId),
+ sourceBlocksByTarget.get(field.targetWorkflowId)?.get(field.targetBlockId),
+ field.subBlockKey
+ ),
+ })),
+ ...collectForkDependentReconfigs(plan.items, sourceStates, resolveBlockId, 'create').map(
+ (field) => ({
+ ...field,
+ currentValue:
+ storedByKey.get(
+ forkDependentValueKey(field.targetWorkflowId, field.targetBlockId, field.subBlockKey)
+ ) ?? field.currentValue,
+ })
+ ),
+ ]
// References this sync will blank in the target (per block/field), for the pre-sync cleared-ref
// list. Labels resolve from the source candidate lists + workflow names loaded above.
diff --git a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts
new file mode 100644
index 00000000000..1970a50b9e4
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.test.ts
@@ -0,0 +1,149 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockGetSession,
+ mockAssertWorkspaceAdminAccess,
+ mockGetForkParent,
+ mockGetForkChildren,
+ mockGetUndoableRunForTarget,
+ mockGetEffectiveWorkspacePermission,
+} = vi.hoisted(() => ({
+ mockGetSession: vi.fn(),
+ mockAssertWorkspaceAdminAccess: vi.fn(),
+ mockGetForkParent: vi.fn(),
+ mockGetForkChildren: vi.fn(),
+ mockGetUndoableRunForTarget: vi.fn(),
+ mockGetEffectiveWorkspacePermission: vi.fn(),
+}))
+
+vi.mock('@/lib/auth', () => ({
+ auth: { api: { getSession: vi.fn() } },
+ getSession: mockGetSession,
+}))
+
+vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({
+ assertWorkspaceAdminAccess: mockAssertWorkspaceAdminAccess,
+}))
+
+vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => ({
+ getForkParent: mockGetForkParent,
+ getForkChildren: mockGetForkChildren,
+}))
+
+vi.mock('@/ee/workspace-forking/lib/promote/promote-run-store', () => ({
+ getUndoableRunForTarget: mockGetUndoableRunForTarget,
+}))
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ getEffectiveWorkspacePermission: mockGetEffectiveWorkspacePermission,
+}))
+
+import { GET } from '@/app/api/workspaces/[id]/fork/lineage/route'
+
+const WORKSPACE_ID = 'workspace-1'
+const VIEWER_ID = 'user-1'
+const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) }
+
+const parentNode = { id: 'parent-1', name: 'Parent', organizationId: 'org-1' }
+const childCreatedAt = new Date('2026-01-02T03:04:05.000Z')
+const childNode = (id: string, name: string) => ({
+ id,
+ name,
+ organizationId: 'org-1',
+ createdAt: childCreatedAt,
+})
+
+describe('fork lineage route', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ mockGetSession.mockResolvedValue({ user: { id: VIEWER_ID } })
+ mockAssertWorkspaceAdminAccess.mockResolvedValue({ id: WORKSPACE_ID })
+ mockGetForkParent.mockResolvedValue(null)
+ mockGetForkChildren.mockResolvedValue([])
+ mockGetUndoableRunForTarget.mockResolvedValue(null)
+ mockGetEffectiveWorkspacePermission.mockResolvedValue(null)
+ })
+
+ it('returns 401 when there is no session', async () => {
+ mockGetSession.mockResolvedValue(null)
+
+ const res = await GET(createMockRequest('GET'), routeContext)
+
+ expect(res.status).toBe(401)
+ expect(mockAssertWorkspaceAdminAccess).not.toHaveBeenCalled()
+ })
+
+ it('requires admin on the current workspace before loading lineage', async () => {
+ await GET(createMockRequest('GET'), routeContext)
+
+ expect(mockAssertWorkspaceAdminAccess).toHaveBeenCalledWith(WORKSPACE_ID, VIEWER_ID)
+ })
+
+ it('marks accessible and inaccessible nodes via the canonical permission resolver', async () => {
+ mockGetForkParent.mockResolvedValue(parentNode)
+ mockGetForkChildren.mockResolvedValue([
+ childNode('fork-accessible', 'Accessible fork'),
+ childNode('fork-hidden', 'Hidden fork'),
+ ])
+ mockGetEffectiveWorkspacePermission.mockImplementation(
+ async (_userId: string, ws: { id: string }) => {
+ if (ws.id === parentNode.id) return 'read'
+ if (ws.id === 'fork-accessible') return 'admin'
+ return null
+ }
+ )
+
+ const res = await GET(createMockRequest('GET'), routeContext)
+
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.parent).toEqual({ ...parentNode, viewerAccessible: true })
+ expect(body.children).toEqual([
+ {
+ id: 'fork-accessible',
+ name: 'Accessible fork',
+ organizationId: 'org-1',
+ createdAt: childCreatedAt.toISOString(),
+ viewerAccessible: true,
+ },
+ {
+ id: 'fork-hidden',
+ name: 'Hidden fork',
+ organizationId: 'org-1',
+ createdAt: childCreatedAt.toISOString(),
+ viewerAccessible: false,
+ },
+ ])
+ expect(mockGetEffectiveWorkspacePermission).toHaveBeenCalledWith(
+ VIEWER_ID,
+ expect.objectContaining({ id: parentNode.id, organizationId: 'org-1' })
+ )
+ })
+
+ it('marks the parent inaccessible when the viewer holds no permission on it', async () => {
+ mockGetForkParent.mockResolvedValue(parentNode)
+ mockGetEffectiveWorkspacePermission.mockResolvedValue(null)
+
+ const res = await GET(createMockRequest('GET'), routeContext)
+
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.parent).toEqual({ ...parentNode, viewerAccessible: false })
+ expect(body.children).toEqual([])
+ })
+
+ it('keeps a null parent null without resolving permissions', async () => {
+ const res = await GET(createMockRequest('GET'), routeContext)
+
+ expect(res.status).toBe(200)
+ const body = await res.json()
+ expect(body.parent).toBeNull()
+ expect(body.children).toEqual([])
+ expect(body.undoableRun).toBeNull()
+ expect(mockGetEffectiveWorkspacePermission).not.toHaveBeenCalled()
+ })
+})
diff --git a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts
index 5e409b78acd..6e8b5b364e5 100644
--- a/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/fork/lineage/route.ts
@@ -7,9 +7,25 @@ import { getForkLineageContract } from '@/lib/api/contracts/workspace-fork'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz'
-import { getForkParent } from '@/lib/workspaces/fork/lineage/lineage'
-import { getUndoableRunForTarget } from '@/lib/workspaces/fork/promote/promote-run-store'
+import { getEffectiveWorkspacePermission } from '@/lib/workspaces/permissions/utils'
+import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz'
+import { getForkChildren, getForkParent } from '@/ee/workspace-forking/lib/lineage/lineage'
+import { getUndoableRunForTarget } from '@/ee/workspace-forking/lib/promote/promote-run-store'
+
+/**
+ * Annotates a lineage node with whether the viewer holds any access to it (explicit
+ * grant or org-admin derivation, via the canonical workspace-permission resolver).
+ * Lineage rows are visible to any admin of the CURRENT workspace, who may have no
+ * access to the other side of an edge; the flag drives per-action gating in the
+ * Forks UI. Resolved per node - lineage children lists are small and bounded.
+ */
+async function withViewerAccess(
+ node: T,
+ viewerId: string
+): Promise {
+ const permission = await getEffectiveWorkspacePermission(viewerId, node)
+ return { ...node, viewerAccessible: permission !== null }
+}
export const GET = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
@@ -24,11 +40,17 @@ export const GET = withRouteHandler(
await assertWorkspaceAdminAccess(workspaceId, session.user.id)
- const [parent, run] = await Promise.all([
+ const [rawParent, rawChildren, run] = await Promise.all([
getForkParent(workspaceId),
+ getForkChildren(workspaceId),
getUndoableRunForTarget(db, workspaceId),
])
+ const [parent, children] = await Promise.all([
+ rawParent ? withViewerAccess(rawParent, session.user.id) : null,
+ Promise.all(rawChildren.map((child) => withViewerAccess(child, session.user.id))),
+ ])
+
let undoableRun: {
otherWorkspaceId: string
otherName: string
@@ -50,6 +72,10 @@ export const GET = withRouteHandler(
return NextResponse.json({
workspaceId,
parent,
+ children: children.map((child) => ({
+ ...child,
+ createdAt: child.createdAt.toISOString(),
+ })),
undoableRun,
})
}
diff --git a/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts b/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts
index 0729b0d4ff6..63dc41e5e20 100644
--- a/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts
@@ -7,13 +7,14 @@ import {
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz'
-import { acquireForkEdgeLock, setForkLockTimeout } from '@/lib/workspaces/fork/lineage/lineage'
+import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
+import { acquireForkEdgeLock, setForkLockTimeout } from '@/ee/workspace-forking/lib/lineage/lineage'
+import { reconcileForkDependentValues } from '@/ee/workspace-forking/lib/mapping/dependent-value-store'
import {
applyForkMappingEntries,
getForkMappingView,
validateForkMappingTargets,
-} from '@/lib/workspaces/fork/mapping/mapping-service'
+} from '@/ee/workspace-forking/lib/mapping/mapping-service'
export const GET = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
@@ -55,7 +56,7 @@ export const PUT = withRouteHandler(
const parsed = await parseRequest(updateForkMappingContract, req, context)
if (!parsed.success) return parsed.response
const { id } = parsed.data.params
- const { otherWorkspaceId, direction, entries } = parsed.data.body
+ const { otherWorkspaceId, direction, entries, dependentValues } = parsed.data.body
const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
@@ -67,7 +68,35 @@ export const PUT = withRouteHandler(
const updated = await db.transaction(async (tx) => {
await setForkLockTimeout(tx)
await acquireForkEdgeLock(tx, auth.edge.childWorkspaceId)
- return applyForkMappingEntries(tx, auth.edge, session.user.id, direction, entries)
+ const applied = await applyForkMappingEntries(
+ tx,
+ auth.edge,
+ session.user.id,
+ direction,
+ entries
+ )
+ // Store dependent-field values with the mapping (each named workflow's stored set is
+ // replaced by exactly what was sent - promote's reconcile semantics, scoped to the
+ // payload's workflows since a mapping save has no promote plan). Omitted = untouched;
+ // rows for a workflow that never becomes a sync replace target are inert (promote
+ // loads the store scoped to its plan's targets).
+ if (dependentValues !== undefined) {
+ const targetWorkflowIds = Array.from(
+ new Set(dependentValues.map((entry) => entry.workflowId))
+ )
+ await reconcileForkDependentValues(
+ tx,
+ auth.edge.childWorkspaceId,
+ targetWorkflowIds,
+ dependentValues.map((entry) => ({
+ targetWorkflowId: entry.workflowId,
+ targetBlockId: entry.blockId,
+ subBlockKey: entry.subBlockKey,
+ value: entry.value,
+ }))
+ )
+ }
+ return applied
})
return NextResponse.json({ success: true as const, updated })
diff --git a/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts
index 7cfadf34bed..cbf6c0fb23b 100644
--- a/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/fork/promote/route.ts
@@ -8,9 +8,9 @@ import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { recordBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
-import { assertCanPromote } from '@/lib/workspaces/fork/lineage/authz'
-import { promoteFork } from '@/lib/workspaces/fork/promote/promote'
+import { recordBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
+import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
+import { promoteFork } from '@/ee/workspace-forking/lib/promote/promote'
const logger = createLogger('WorkspaceForkPromoteAPI')
@@ -35,6 +35,7 @@ export const POST = withRouteHandler(
targetWorkspaceId: auth.targetWorkspaceId,
direction,
userId: session.user.id,
+ actorName: session.user.name ?? undefined,
dependentValues,
copyResources,
requestId,
@@ -96,6 +97,7 @@ export const POST = withRouteHandler(
message: direction === 'pull' ? `Pulled from "${otherName}"` : `Pushed to "${otherName}"`,
metadata: {
actorName: session.user.name ?? undefined,
+ otherWorkspaceId,
otherWorkspaceName: otherName,
direction,
updated: result.updated,
diff --git a/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts b/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts
index ef489fc6a18..d639a56ed27 100644
--- a/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/fork/resources/route.ts
@@ -4,8 +4,8 @@ import { getForkResourcesContract } from '@/lib/api/contracts/workspace-fork'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { assertWorkspaceAdminAccess } from '@/lib/workspaces/fork/lineage/authz'
-import { listForkCopyableResources } from '@/lib/workspaces/fork/mapping/resources'
+import { assertWorkspaceAdminAccess } from '@/ee/workspace-forking/lib/lineage/authz'
+import { listForkCopyableResources } from '@/ee/workspace-forking/lib/mapping/resources'
export const GET = withRouteHandler(
async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
diff --git a/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts b/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts
index dffb841af5e..21363f21f1d 100644
--- a/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/fork/rollback/route.ts
@@ -10,9 +10,9 @@ import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { recordBackgroundWork } from '@/lib/workspaces/fork/background-work/store'
-import { assertCanRollback } from '@/lib/workspaces/fork/lineage/authz'
-import { rollbackFork } from '@/lib/workspaces/fork/promote/rollback'
+import { recordBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
+import { assertCanRollback } from '@/ee/workspace-forking/lib/lineage/authz'
+import { rollbackFork } from '@/ee/workspace-forking/lib/promote/rollback'
const logger = createLogger('WorkspaceForkRollbackAPI')
@@ -67,6 +67,7 @@ export const POST = withRouteHandler(
message: `Undid the last sync from "${otherName}"`,
metadata: {
actorName: session.user.name ?? undefined,
+ otherWorkspaceId,
otherWorkspaceName: otherName,
restored: result.restored,
removed: result.archived,
diff --git a/apps/sim/app/api/workspaces/[id]/fork/route.ts b/apps/sim/app/api/workspaces/[id]/fork/route.ts
index b3183465a6c..27cd8fdbd03 100644
--- a/apps/sim/app/api/workspaces/[id]/fork/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/fork/route.ts
@@ -6,8 +6,8 @@ import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { createFork } from '@/lib/workspaces/fork/create-fork'
-import { assertCanFork } from '@/lib/workspaces/fork/lineage/authz'
+import { createFork } from '@/ee/workspace-forking/lib/create-fork'
+import { assertCanFork } from '@/ee/workspace-forking/lib/lineage/authz'
const logger = createLogger('WorkspaceForkAPI')
@@ -39,6 +39,7 @@ export const POST = withRouteHandler(
knowledgeBases: copy?.knowledgeBases ?? [],
customTools: copy?.customTools ?? [],
skills: copy?.skills ?? [],
+ mcpServers: copy?.mcpServers ?? [],
workflowMcpServers: copy?.workflowMcpServers ?? [],
},
requestId,
diff --git a/apps/sim/app/api/workspaces/[id]/fork/unlink/route.ts b/apps/sim/app/api/workspaces/[id]/fork/unlink/route.ts
new file mode 100644
index 00000000000..d779547ef12
--- /dev/null
+++ b/apps/sim/app/api/workspaces/[id]/fork/unlink/route.ts
@@ -0,0 +1,49 @@
+import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
+import { type NextRequest, NextResponse } from 'next/server'
+import { unlinkForkContract } from '@/lib/api/contracts/workspace-fork'
+import { parseRequest } from '@/lib/api/server'
+import { getSession } from '@/lib/auth'
+import { generateRequestId } from '@/lib/core/utils/request'
+import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { assertCanUnlink } from '@/ee/workspace-forking/lib/lineage/authz'
+import { unlinkForkEdge } from '@/ee/workspace-forking/lib/lineage/unlink'
+
+export const POST = withRouteHandler(
+ async (req: NextRequest, context: { params: Promise<{ id: string }> }) => {
+ const requestId = generateRequestId()
+ const session = await getSession()
+ if (!session?.user?.id) {
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
+ }
+
+ const parsed = await parseRequest(unlinkForkContract, req, context)
+ if (!parsed.success) return parsed.response
+ const { id } = parsed.data.params
+ const { otherWorkspaceId } = parsed.data.body
+
+ const { edge, current } = await assertCanUnlink(id, otherWorkspaceId, session.user.id)
+ const result = await unlinkForkEdge(edge, requestId)
+
+ if (result.unlinked) {
+ recordAudit({
+ workspaceId: id,
+ actorId: session.user.id,
+ action: AuditAction.WORKSPACE_FORK_UNLINKED,
+ resourceType: AuditResourceType.WORKSPACE,
+ resourceId: id,
+ actorName: session.user.name ?? undefined,
+ actorEmail: session.user.email ?? undefined,
+ resourceName: current.name,
+ description: `Disconnected the fork relationship with workspace "${otherWorkspaceId}"`,
+ metadata: {
+ otherWorkspaceId,
+ childWorkspaceId: edge.childWorkspaceId,
+ parentWorkspaceId: edge.parentWorkspaceId,
+ },
+ request: req,
+ })
+ }
+
+ return NextResponse.json(result)
+ }
+)
diff --git a/apps/sim/app/f/[token]/public-file-email-auth.tsx b/apps/sim/app/f/[token]/public-file-email-auth.tsx
index beac8429fb7..cbb624f63e5 100644
--- a/apps/sim/app/f/[token]/public-file-email-auth.tsx
+++ b/apps/sim/app/f/[token]/public-file-email-auth.tsx
@@ -3,6 +3,7 @@
import { useEffect, useState } from 'react'
import { cn, Input, InputOTP, InputOTPGroup, InputOTPSlot, Label } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
+import { normalizeEmail } from '@sim/utils/string'
import { useRouter } from 'next/navigation'
import { quickValidateEmail } from '@/lib/messaging/email/validation'
import { AuthSubmitButton } from '@/app/(auth)/components'
@@ -37,13 +38,13 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
}, [countdown])
const sendCode = async () => {
- if (!quickValidateEmail(email.trim().toLowerCase()).isValid) {
+ if (!quickValidateEmail(normalizeEmail(email)).isValid) {
setError('Please enter a valid email address.')
return
}
setError(null)
try {
- await requestOtp.mutateAsync({ email: email.trim().toLowerCase() })
+ await requestOtp.mutateAsync({ email: normalizeEmail(email) })
setSent(true)
setOtp('')
} catch (err) {
@@ -55,7 +56,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
if (code.length !== 6) return
setError(null)
try {
- await verifyOtp.mutateAsync({ email: email.trim().toLowerCase(), otp: code })
+ await verifyOtp.mutateAsync({ email: normalizeEmail(email), otp: code })
router.refresh()
} catch (err) {
setError(getErrorMessage(err, 'Invalid verification code'))
@@ -65,7 +66,7 @@ export function PublicFileEmailAuth({ token }: PublicFileEmailAuthProps) {
const resend = async () => {
setCountdown(30)
try {
- await requestOtp.mutateAsync({ email: email.trim().toLowerCase() })
+ await requestOtp.mutateAsync({ email: normalizeEmail(email) })
setOtp('')
setError(null)
} catch (err) {
diff --git a/apps/sim/app/f/[token]/public-file-sso-auth.tsx b/apps/sim/app/f/[token]/public-file-sso-auth.tsx
index 5ed4a410da0..3e73581f715 100644
--- a/apps/sim/app/f/[token]/public-file-sso-auth.tsx
+++ b/apps/sim/app/f/[token]/public-file-sso-auth.tsx
@@ -3,6 +3,7 @@
import { useState } from 'react'
import { cn, Input, Label } from '@sim/emcn'
import { getErrorMessage } from '@sim/utils/errors'
+import { normalizeEmail } from '@sim/utils/string'
import { useRouter } from 'next/navigation'
import { requestJson } from '@/lib/api/client/request'
import { publicFileSSOContract } from '@/lib/api/contracts/public-shares'
@@ -26,14 +27,14 @@ export function PublicFileSSOAuth({ token }: PublicFileSSOAuthProps) {
const [isLoading, setIsLoading] = useState(false)
const handleAuthenticate = async () => {
- if (!quickValidateEmail(email.trim().toLowerCase()).isValid) {
+ if (!quickValidateEmail(normalizeEmail(email)).isValid) {
setError('Please enter a valid email address.')
return
}
setError(null)
setIsLoading(true)
try {
- const normalizedEmail = email.trim().toLowerCase()
+ const normalizedEmail = normalizeEmail(email)
const { eligible } = await requestJson(publicFileSSOContract, {
params: { token },
body: { email: normalizedEmail },
diff --git a/apps/sim/app/robots.ts b/apps/sim/app/robots.ts
index ca4b523265f..291cf1c8a46 100644
--- a/apps/sim/app/robots.ts
+++ b/apps/sim/app/robots.ts
@@ -16,6 +16,10 @@ const DISALLOWED_PATHS = [
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: '*', allow: '/', disallow: DISALLOWED_PATHS },
- sitemap: [`${SITE_URL}/sitemap.xml`, `${SITE_URL}/blog/sitemap-images.xml`],
+ sitemap: [
+ `${SITE_URL}/sitemap.xml`,
+ `${SITE_URL}/blog/sitemap-images.xml`,
+ `${SITE_URL}/library/sitemap-images.xml`,
+ ],
}
}
diff --git a/apps/sim/app/sitemap.ts b/apps/sim/app/sitemap.ts
index b012c01d4b0..30f3c91042c 100644
--- a/apps/sim/app/sitemap.ts
+++ b/apps/sim/app/sitemap.ts
@@ -1,7 +1,10 @@
import type { MetadataRoute } from 'next'
-import { getAllPostMeta } from '@/lib/blog/registry'
+import { getAllPostMeta as getAllBlogPostMeta } from '@/lib/blog/registry'
+import type { ContentMeta } from '@/lib/content/schema'
+import { latestModified } from '@/lib/content/utils'
import { SITE_URL } from '@/lib/core/utils/urls'
import { INTEGRATIONS, INTEGRATIONS_UPDATED_AT } from '@/lib/integrations'
+import { getAllPostMeta as getAllLibraryPostMeta } from '@/lib/library/registry'
import {
ALL_COMPETITORS,
getLatestVerifiedDate,
@@ -9,22 +12,38 @@ import {
} from '@/app/(landing)/comparison/utils'
import { ALL_CATALOG_MODELS, MODEL_PROVIDERS_WITH_CATALOGS } from '@/app/(landing)/models/utils'
+/** One sitemap entry per author, timestamped by their most recently updated post. */
+function buildAuthorPages(posts: ContentMeta[], basePath: string): MetadataRoute.Sitemap {
+ const authorsMap = new Map()
+ for (const p of posts) {
+ for (const author of p.authors ?? [p.author]) {
+ const postDate = new Date(p.updated ?? p.date)
+ const existing = authorsMap.get(author.id)
+ if (!existing || postDate > existing) {
+ authorsMap.set(author.id, postDate)
+ }
+ }
+ }
+ return [...authorsMap.entries()].map(([id, date]) => ({
+ url: `${SITE_URL}${basePath}/authors/${id}`,
+ lastModified: date,
+ }))
+}
+
/**
* Generate the public sitemap by composing static landing pages with the
- * dynamic catalogs (blog posts, authors, integrations, model providers, and
- * individual models). Per-integration entries are emitted under
- * `/integrations/{slug}` to match the landing route at
+ * dynamic catalogs (blog posts, library posts, authors, integrations, model
+ * providers, and individual models). Per-integration entries are emitted
+ * under `/integrations/{slug}` to match the landing route at
* `app/(landing)/integrations/[slug]`; slugs are guaranteed unique
* by the catalog generator in `scripts/generate-docs.ts`.
*/
export default async function sitemap(): Promise {
const baseUrl = SITE_URL
- const posts = await getAllPostMeta()
+ const [posts, libraryPosts] = await Promise.all([getAllBlogPostMeta(), getAllLibraryPostMeta()])
- const latestPostDate =
- posts.length > 0
- ? new Date(Math.max(...posts.map((p) => new Date(p.updated ?? p.date).getTime())))
- : undefined
+ const latestPostDateValue = latestModified(posts)
+ const latestLibraryPostDate = latestModified(libraryPosts)
const modelTimes = MODEL_PROVIDERS_WITH_CATALOGS.flatMap((provider) =>
provider.models.map((model) => new Date(model.pricing.updatedAt).getTime())
@@ -72,15 +91,23 @@ export default async function sitemap(): Promise {
},
{
url: `${baseUrl}/blog`,
- lastModified: latestPostDate,
+ lastModified: latestPostDateValue,
},
{
url: `${baseUrl}/blog/tags`,
- lastModified: latestPostDate,
+ lastModified: latestPostDateValue,
+ },
+ {
+ url: `${baseUrl}/library`,
+ lastModified: latestLibraryPostDate,
+ },
+ {
+ url: `${baseUrl}/library/tags`,
+ lastModified: latestLibraryPostDate,
},
{
url: `${baseUrl}/changelog`,
- lastModified: latestPostDate,
+ lastModified: latestPostDateValue,
},
{
url: `${baseUrl}/integrations`,
@@ -104,21 +131,13 @@ export default async function sitemap(): Promise {
url: p.canonical,
lastModified: new Date(p.updated ?? p.date),
}))
+ const authorPages = buildAuthorPages(posts, '/blog')
- const authorsMap = new Map()
- for (const p of posts) {
- for (const author of p.authors ?? [p.author]) {
- const postDate = new Date(p.updated ?? p.date)
- const existing = authorsMap.get(author.id)
- if (!existing || postDate > existing) {
- authorsMap.set(author.id, postDate)
- }
- }
- }
- const authorPages: MetadataRoute.Sitemap = [...authorsMap.entries()].map(([id, date]) => ({
- url: `${baseUrl}/blog/authors/${id}`,
- lastModified: date,
+ const libraryPages: MetadataRoute.Sitemap = libraryPosts.map((p) => ({
+ url: p.canonical,
+ lastModified: new Date(p.updated ?? p.date),
}))
+ const libraryAuthorPages = buildAuthorPages(libraryPosts, '/library')
const integrationPages: MetadataRoute.Sitemap = INTEGRATIONS.map((integration) => ({
url: `${baseUrl}/integrations/${integration.slug}`,
@@ -164,6 +183,8 @@ export default async function sitemap(): Promise {
...staticPages,
...blogPages,
...authorPages,
+ ...libraryPages,
+ ...libraryAuthorPages,
...integrationPages,
...providerPages,
...modelEntries,
diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx
index 5dbb7656374..48fca17aa52 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-chrome-fallback/resource-chrome-fallback.tsx
@@ -1,6 +1,7 @@
'use client'
import type { ComponentType } from 'react'
+import { noop } from '@sim/utils/helpers'
import type { BreadcrumbItem } from '@/app/workspace/[workspaceId]/components/resource/components/resource-header'
import {
Resource,
@@ -43,8 +44,6 @@ interface ResourceChromeFallbackProps {
hasFilter?: boolean
}
-const noop = () => {}
-
/**
* Route-transition fallback rendered by each resource route's `loading.tsx`. It
* paints the REAL resource chrome — the header (icon/title or breadcrumbs + the
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts
index 2c44021309c..d16d4a58531 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/dirty-on-open.test.ts
@@ -13,6 +13,8 @@
* falsely marks the file dirty ("unsaved changes"). The fix normalizes the dirty-check baseline to
* the canonical form; this asserts that normalized form equals what the live editor emits.
*/
+
+import { sleep } from '@sim/utils/helpers'
import { Editor } from '@tiptap/core'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { createMarkdownEditorExtensions } from './editor-extensions'
@@ -94,7 +96,7 @@ describe('baseline neutralizes the mount-time dirty signal', () => {
},
})
- await new Promise((resolve) => setTimeout(resolve, 30))
+ await sleep(30)
// The deferred mount transaction re-serializes to canonical markdown; the baseline must match it
// exactly, so `content === savedContent` and the file is never falsely dirty on open.
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts
index 1f29545bebb..bf91dfd2cd9 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts
@@ -1,6 +1,7 @@
import type { Extensions, JSONContent, MarkdownRendererHelpers, Node } from '@tiptap/core'
import { Code } from '@tiptap/extension-code'
import { TaskItem, TaskList } from '@tiptap/extension-list'
+import { Paragraph } from '@tiptap/extension-paragraph'
import {
renderTableToMarkdown,
Table,
@@ -57,6 +58,39 @@ const PipeSafeTable = Table.extend({
.replace(/\n+$/, ''),
})
+/**
+ * Guards a paragraph's serialized text so its leading characters don't re-parse it into a different
+ * block on the next load:
+ *
+ * - **Leading whitespace** is stripped. It never renders in a paragraph (CommonMark strips up to three
+ * leading spaces, and four or more would re-parse the paragraph as an indented code block), so
+ * removing it is lossless and makes the round-trip idempotent.
+ * - **A leading block marker** (`#`, `-`, `+`, `1.`, `1)`, or a bare `---`) is backslash-escaped so the
+ * paragraph doesn't become a heading / list / thematic break. The upstream serializer escapes inline
+ * delimiters (`* _ \` [ ] ~`, so `*` bullets and `>` quotes already round-trip) but not these
+ * block-starting markers. Escaping is idempotent: parsing consumes the backslash, so the stored
+ * ProseMirror text never carries it and re-serialization is stable.
+ */
+function guardParagraphLeading(text: string): string {
+ const stripped = text.replace(/^[ \t]+/, '')
+ if (/^(#{1,6}([ \t]|$)|[-+][ \t]|-(?:[ \t]*-){2,}[ \t]*$)/.test(stripped)) {
+ return `\\${stripped}`
+ }
+ const ordered = /^(\d{1,9})([.)][ \t])/.exec(stripped)
+ return ordered ? `${ordered[1]}\\${stripped.slice(ordered[1].length)}` : stripped
+}
+
+/**
+ * Paragraph that guards its leading characters on serialize (see {@link guardParagraphLeading}) —
+ * otherwise a paragraph beginning with a block marker or an indent silently becomes a heading / list /
+ * thematic break / code block on the next load. Block separators are owned by the parent joiner, so a
+ * paragraph renders as just its inline children; this override wraps that with the leading guard.
+ */
+const BlockSafeParagraph = Paragraph.extend({
+ renderMarkdown: (node: JSONContent, h: MarkdownRendererHelpers) =>
+ guardParagraphLeading(h.renderChildren(node.content ?? [])),
+})
+
/**
* Node-view variants the live editor injects in place of the headless defaults — the code-block
* language picker, the resizable image, and the mention chip. The mention chip pulls the block registry
@@ -92,7 +126,9 @@ export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {}
underline: false,
codeBlock: false,
code: false,
+ paragraph: false,
}),
+ BlockSafeParagraph,
InlineCode,
codeBlock,
(nodeViews.image ?? MarkdownImage).configure({ allowBase64: true }),
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts
index c7c8f425417..1f12ca3b7ee 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.test.ts
@@ -33,12 +33,37 @@ function firstPosOf(editor: Editor, type: string): number {
return pos
}
-function pressBackspace(editor: Editor): void {
+function pressKey(editor: Editor, key: string): void {
editor.view.dom.dispatchEvent(
- new KeyboardEvent('keydown', { key: 'Backspace', bubbles: true, cancelable: true })
+ new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })
)
}
+function pressBackspace(editor: Editor): void {
+ pressKey(editor, 'Backspace')
+}
+
+/** Empties the item whose text is `word` (caret left at its start), the state before a boundary key. */
+function emptyItem(editor: Editor, word: string): void {
+ let from = -1
+ let to = -1
+ editor.state.doc.descendants((node, pos) => {
+ if (from < 0 && node.isText && node.text === word) {
+ from = pos
+ to = pos + word.length
+ }
+ })
+ editor.commands.setTextSelection({ from, to })
+ editor.commands.deleteSelection()
+}
+
+/** Serialized markdown after re-parsing it once — equal to `getMarkdown()` only if it round-trips. */
+function markdownRoundTrip(editor: Editor): { md: string; reparsed: string } {
+ const md = editor.getMarkdown()
+ editor.commands.setContent(md, { contentType: 'markdown' })
+ return { md, reparsed: editor.getMarkdown() }
+}
+
describe('suggestion-aware arrow keymap', () => {
beforeEach(() => {
// The suggestion render lifecycle uses these; jsdom lacks them.
@@ -141,3 +166,94 @@ describe('divider Backspace', () => {
editor.destroy()
})
})
+
+describe('empty wrapped-block Backspace', () => {
+ beforeEach(() => {
+ Element.prototype.scrollIntoView = vi.fn()
+ })
+
+ it.each([
+ ['bullet middle', '- one\n- two\n- three', 'two', '- one\n- three'],
+ ['bullet first', '- one\n- two\n- three', 'one', '- two\n- three'],
+ ['bullet last', '- one\n- two', 'two', '- one'],
+ ['ordered middle', '1. one\n2. two\n3. three', 'two', '1. one\n2. three'],
+ ['task middle', '- [ ] one\n- [ ] two\n- [ ] three', 'two', '- [ ] one\n- [ ] three'],
+ ['blockquote middle', '> one\n>\n> two\n>\n> three', 'two', '> one\n>\n> three'],
+ ['nested item', '- one\n - two\n- three', 'two', '- one\n- three'],
+ ])(
+ 'removes the emptied %s cleanly — one container, no stray paragraph, round-trips',
+ (_label, markdown, word, expected) => {
+ const editor = editorWith('')
+ editor.commands.setContent(markdown, { contentType: 'markdown' })
+ editor.commands.focus()
+ emptyItem(editor, word)
+ pressBackspace(editor)
+
+ const { md, reparsed } = markdownRoundTrip(editor)
+ expect(md.trim()).toBe(expected)
+ expect(reparsed).toBe(md)
+ editor.destroy()
+ }
+ )
+})
+
+describe('empty list-item Enter', () => {
+ beforeEach(() => {
+ Element.prototype.scrollIntoView = vi.fn()
+ })
+
+ it('removes an empty MIDDLE item instead of splitting the list into a stranded paragraph', () => {
+ const editor = editorWith('')
+ editor.commands.setContent('- one\n- two\n- three', { contentType: 'markdown' })
+ editor.commands.focus()
+ emptyItem(editor, 'two')
+ pressKey(editor, 'Enter')
+
+ const { md, reparsed } = markdownRoundTrip(editor)
+ expect(md.trim()).toBe('- one\n- three')
+ expect(reparsed).toBe(md)
+ editor.destroy()
+ })
+
+ it('leaves an empty TRAILING item to the default (exits the list)', () => {
+ const editor = editorWith('')
+ editor.commands.setContent('- one\n- two', { contentType: 'markdown' })
+ editor.commands.focus()
+ emptyItem(editor, 'two')
+ pressKey(editor, 'Enter')
+
+ const list = editor.getJSON().content?.find((node) => node.type === 'bulletList')
+ expect(list?.content).toHaveLength(1)
+ expect(editor.getJSON().content?.some((node) => node.type === 'paragraph')).toBe(true)
+ editor.destroy()
+ })
+})
+
+describe('verbatim block boundary (isolating)', () => {
+ beforeEach(() => {
+ Element.prototype.scrollIntoView = vi.fn()
+ })
+
+ function caretIntoNode(editor: Editor, nodeType: string): void {
+ editor.state.doc.descendants((node, pos) => {
+ if (node.type.name === nodeType) editor.commands.setTextSelection(pos + 1)
+ })
+ }
+
+ it.each([
+ ['footnote definition', 'body text[^x]\n\n[^x]: the note', 'footnoteDef'],
+ ['raw HTML block', 'body\n\n
\nhello\n
', 'rawHtmlBlock'],
+ ])(
+ 'Backspace at the start of a %s does not merge across its boundary and destroy it',
+ (_label, markdown, nodeType) => {
+ const editor = editorWith('')
+ editor.commands.setContent(markdown, { contentType: 'markdown' })
+ editor.commands.focus()
+ expect(blockShape(editor)).toContain(nodeType)
+ caretIntoNode(editor, nodeType)
+ pressBackspace(editor)
+ expect(blockShape(editor)).toContain(nodeType)
+ editor.destroy()
+ }
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts
index 2c51d43646f..6fb6ad4ac66 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/keymap.ts
@@ -1,7 +1,8 @@
import type { Editor } from '@tiptap/core'
import { Extension } from '@tiptap/core'
import { GapCursor } from '@tiptap/pm/gapcursor'
-import { NodeSelection, Plugin, PluginKey } from '@tiptap/pm/state'
+import type { ResolvedPos } from '@tiptap/pm/model'
+import { NodeSelection, Plugin, PluginKey, Selection } from '@tiptap/pm/state'
import { Decoration, DecorationSet } from '@tiptap/pm/view'
import { MENTION_PLUGIN_KEY } from './mention'
import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command'
@@ -9,6 +10,47 @@ import { SLASH_COMMAND_PLUGIN_KEY } from './slash-command/slash-command'
/** Leaf nodes that have no text position, so they can only be reached as a NodeSelection. */
const SELECTABLE_LEAVES = new Set(['horizontalRule', 'image'])
+/**
+ * Wrapper nodes whose empty child a boundary key must remove cleanly rather than lift. Lifting an empty
+ * block out of one of these splits the container in two and strands an empty paragraph — a visible gap
+ * that also fails to round-trip through markdown (see {@link removeEmptyWrappedBlock}).
+ */
+const WRAPPER_TYPES = new Set(['listItem', 'taskItem', 'blockquote'])
+
+/** Item node types a list is built from, used to detect an empty item's position within its list. */
+const LIST_ITEM_TYPES = new Set(['listItem', 'taskItem'])
+
+/** True when the resolved position sits anywhere inside a {@link WRAPPER_TYPES} ancestor. */
+function isInsideWrapper($from: ResolvedPos): boolean {
+ for (let depth = $from.depth - 1; depth >= 1; depth--) {
+ if (WRAPPER_TYPES.has($from.node(depth).type.name)) return true
+ }
+ return false
+}
+
+/**
+ * Removes the empty textblock at `$from`, deleting up through the outermost ancestor it is the sole
+ * child of, then places the caret at the end of the preceding block. This keeps a list or blockquote
+ * whole when its middle/first/last item is emptied — where ProseMirror's default lift would split the
+ * container and strand an empty paragraph (a visible gap, and markdown that re-parses to a different
+ * document). Walking up while `childCount === 1` deletes the whole now-empty wrapper (the emptied list
+ * item, not just its paragraph) so no orphan `
` or empty continuation line is left behind.
+ */
+function removeEmptyWrappedBlock(editor: Editor, $from: ResolvedPos): boolean {
+ let depth = $from.depth
+ while (depth > 1 && $from.node(depth - 1).childCount === 1) depth--
+ const start = $from.before(depth)
+ const end = $from.after(depth)
+ return editor.commands.command(({ tr, dispatch }) => {
+ if (dispatch) {
+ tr.delete(start, end)
+ tr.setSelection(Selection.near(tr.doc.resolve(start), -1))
+ dispatch(tr.scrollIntoView())
+ }
+ return true
+ })
+}
+
/**
* True while a `/` or `@` suggestion menu is open. Arrow keys must reach that menu's own handler, so
* the leaf-selection shortcuts below yield rather than stealing the key to select an adjacent divider.
@@ -66,12 +108,21 @@ function selectAdjacentSelectedLeaf(editor: Editor, direction: 'up' | 'down'): b
* Editor-specific keyboard behavior layered on top of StarterKit's defaults:
*
* - **Backspace** at the start of a heading reverts it to a paragraph (ProseMirror's default joins or
- * no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of a
- * block whose previous sibling is a divider or image, where ProseMirror's `joinBackward` can't cross
- * the leaf and no-ops: an *empty* block is deleted (clearing the blank line between/below dividers
- * without touching the divider itself), while a *non-empty* block selects the leaf — so a first
- * Backspace highlights what a second deletes, the same highlight-before-delete affordance as clicking
- * it and parity with the arrow-key leaf selection.
+ * no-ops, stranding the heading style; a second Backspace then merges as usual). At the start of an
+ * *empty block inside a list item, task item, or blockquote* it removes that whole emptied wrapper via
+ * {@link removeEmptyWrappedBlock} instead of ProseMirror's default lift — lifting an empty item out of
+ * the middle of a list/quote splits the container in two and strands an empty paragraph (a visible gap
+ * that also re-parses to a different markdown document), while the default `joinBackward` alternately
+ * no-ops on nested items (leaving them stuck) or merges an empty continuation paragraph into the
+ * previous item. At the start of a block whose previous sibling is a divider or image, where
+ * ProseMirror's `joinBackward` can't cross the leaf and no-ops: an *empty* block is deleted (clearing
+ * the blank line between/below dividers without touching the divider itself), while a *non-empty*
+ * block selects the leaf — so a first Backspace highlights what a second deletes, the same
+ * highlight-before-delete affordance as clicking it and parity with the arrow-key leaf selection.
+ * - **Enter** on an *empty, non-trailing list/task item* removes the empty item ({@link
+ * removeEmptyWrappedBlock}) rather than letting the default split the list into two around a stranded
+ * empty paragraph (which does not round-trip). A *trailing* empty item still falls through to the
+ * default, which exits the list — the standard "press Enter on a blank bullet to leave the list".
* - **Mod-A** inside a code block selects only that block's contents; pressing it again (when the
* block is already fully selected) falls through to the default whole-document select-all, the
* same scoped behavior as a code editor.
@@ -97,6 +148,9 @@ export const RichMarkdownKeymap = Extension.create({
if ($from.parent.type.name === 'heading') {
return editor.commands.setParagraph()
}
+ if ($from.parent.content.size === 0 && isInsideWrapper($from)) {
+ return removeEmptyWrappedBlock(editor, $from)
+ }
const blockStart = $from.before($from.depth)
const nodeBefore = doc.resolve(blockStart).nodeBefore
if (!nodeBefore || !SELECTABLE_LEAVES.has(nodeBefore.type.name)) return false
@@ -113,6 +167,18 @@ export const RichMarkdownKeymap = Extension.create({
}
return editor.commands.setNodeSelection(leafStart)
},
+ Enter: ({ editor }) => {
+ const { selection } = editor.state
+ if (!selection.empty || selection.$from.parentOffset !== 0) return false
+ const { $from } = selection
+ if ($from.parent.content.size !== 0) return false
+ const itemDepth = $from.depth - 1
+ if (itemDepth < 1 || !LIST_ITEM_TYPES.has($from.node(itemDepth).type.name)) return false
+ const listDepth = itemDepth - 1
+ const isTrailingItem = $from.index(listDepth) === $from.node(listDepth).childCount - 1
+ if (isTrailingItem) return false
+ return removeEmptyWrappedBlock(editor, $from)
+ },
'Mod-a': ({ editor }) => {
const { $from } = editor.state.selection
if ($from.parent.type.name !== 'codeBlock') return false
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-view.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-view.test.ts
index 18afe06fd51..0c3fa22b689 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-view.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet-view.test.ts
@@ -8,6 +8,8 @@
* text inside is genuinely editable via a normal ProseMirror transaction, surviving serialization
* back to markdown.
*/
+
+import { sleep } from '@sim/utils/helpers'
import { Editor } from '@tiptap/core'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createMarkdownEditorExtensions } from './editor-extensions'
@@ -52,7 +54,7 @@ function posOf(ed: Editor, typeName: string): number {
/** React node views flush on a microtask after mount, so DOM assertions need one tick. */
function nextTick(): Promise {
- return new Promise((resolve) => setTimeout(resolve, 0))
+ return sleep(0)
}
// The hover "Raw HTML"/"Footnote" badge is rendered by `RawBlockView` through
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx
index f2e27209ce7..1aad444de29 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/raw-markdown-snippet.tsx
@@ -167,6 +167,10 @@ function verbatimNodeConfig({ name, inline, badgeLabel }: VerbatimNodeOptions) {
marks: '',
code: true,
defining: !inline,
+ // Block verbatim nodes hold exact source text; `isolating` stops a boundary Backspace/Delete from
+ // joining across their edge, which would otherwise merge their raw markdown into an adjacent
+ // paragraph as HTML-escaped prose and destroy the node (silent data loss on save).
+ isolating: !inline,
selectable: true,
atom: false,
parseHTML() {
diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts
index 34db32f85c5..1f8a9df6a1e 100644
--- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts
+++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts
@@ -330,3 +330,107 @@ describe('link href sanitization — dangerous schemes from file content are neu
expect(hrefs).toContain('mailto:x@y.com')
})
})
+
+describe('paragraph leading guard (marker escaping + indent stripping)', () => {
+ /** Serialize a doc whose first paragraph literally starts with `text`, then re-parse its first node. */
+ function serializeParagraph(text: string): {
+ md: string
+ reparsedType: string
+ idempotent: boolean
+ } {
+ editor = new Editor({ extensions: createMarkdownContentExtensions() })
+ editor.commands.setContent(
+ { type: 'doc', content: [{ type: 'paragraph', content: [{ type: 'text', text }] }] },
+ { contentType: 'json' }
+ )
+ const md = postProcessSerializedMarkdown(editor.getMarkdown())
+ editor.commands.setContent(md, { contentType: 'markdown' })
+ const reparsedType = editor.getJSON().content?.[0]?.type ?? ''
+ const idempotent = postProcessSerializedMarkdown(editor.getMarkdown()) === md
+ editor.destroy()
+ editor = null
+ return { md, reparsedType, idempotent }
+ }
+
+ it.each([
+ ['# note', '\\# note'],
+ ['###### note', '\\###### note'],
+ ['#', '\\#'],
+ ['- item', '\\- item'],
+ ['+ item', '\\+ item'],
+ ['1. step', '1\\. step'],
+ ['1) step', '1\\) step'],
+ ['---', '\\---'],
+ ['- - -', '\\- - -'],
+ ])('escapes a paragraph starting with %j so it stays a paragraph', (text, expectedMd) => {
+ const { md, reparsedType, idempotent } = serializeParagraph(text)
+ expect(md.trim()).toBe(expectedMd)
+ expect(reparsedType).toBe('paragraph')
+ expect(idempotent).toBe(true)
+ })
+
+ it.each([
+ ['#hashtag'], // no space after # → not a heading
+ ['-5 degrees'], // no space after - → not a bullet
+ ['plain text'],
+ ])('does not over-escape %j', (text) => {
+ const { md, reparsedType, idempotent } = serializeParagraph(text)
+ expect(md.trim()).toBe(text)
+ expect(reparsedType).toBe('paragraph')
+ expect(idempotent).toBe(true)
+ })
+
+ it.each([
+ [' four spaces', 'four spaces'],
+ ['\ttab indent', 'tab indent'],
+ [' eight spaces', 'eight spaces'],
+ [' # indented marker', '\\# indented marker'],
+ ])(
+ 'strips leading indent so %j stays a paragraph instead of an indented code block',
+ (text, expectedMd) => {
+ const { md, reparsedType, idempotent } = serializeParagraph(text)
+ expect(md.trim()).toBe(expectedMd)
+ expect(reparsedType).toBe('paragraph')
+ expect(idempotent).toBe(true)
+ }
+ )
+})
+
+describe('consecutive empty paragraphs', () => {
+ /** Doc with `a`, then `count` empty paragraphs, then `b`; serialized and round-tripped. */
+ function serializeEmpties(count: number) {
+ editor = new Editor({ extensions: createMarkdownContentExtensions() })
+ const emptyParas = Array.from({ length: count }, () => ({ type: 'paragraph', content: [] }))
+ editor.commands.setContent(
+ {
+ type: 'doc',
+ content: [
+ { type: 'paragraph', content: [{ type: 'text', text: 'a' }] },
+ ...emptyParas,
+ { type: 'paragraph', content: [{ type: 'text', text: 'b' }] },
+ ],
+ },
+ { contentType: 'json' }
+ )
+ const md = postProcessSerializedMarkdown(editor.getMarkdown())
+ editor.commands.setContent(md, { contentType: 'markdown' })
+ const emptyCount = (editor.getJSON().content ?? []).filter(
+ (n) => n.type === 'paragraph' && !n.content?.length
+ ).length
+ const idempotent = postProcessSerializedMarkdown(editor.getMarkdown()) === md
+ editor.destroy()
+ editor = null
+ return { md, emptyCount, idempotent }
+ }
+
+ it.each([[1], [2], [3], [4]])(
+ 'preserves %i empty paragraph(s) via blank lines (no , idempotent, no read-only trigger)',
+ (count) => {
+ const { md, emptyCount, idempotent } = serializeEmpties(count)
+ expect(md).not.toContain(' ')
+ expect(md).not.toContain(String.fromCharCode(0x00a0))
+ expect(emptyCount).toBe(count)
+ expect(idempotent).toBe(true)
+ }
+ )
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx
index 4e5ca2b901c..b3f9b824970 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/chat-surface-context/chat-surface-context.tsx
@@ -9,6 +9,7 @@ import {
useMemo,
useRef,
} from 'react'
+import { noop } from '@sim/utils/helpers'
import type { MothershipResource } from '@/app/workspace/[workspaceId]/home/types'
import type { ChatContext } from '@/stores/panel'
@@ -31,8 +32,6 @@ interface ChatSurfaceContextValue {
onWorkspaceResourceSelect: (resource: MothershipResource) => void
}
-const noop = () => {}
-
const ChatSurfaceContext = createContext({
onContextAdd: noop,
onContextRemove: noop,
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx
index 234289967b0..736bf30fefa 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/suggested-actions/suggested-actions.tsx
@@ -3,6 +3,7 @@
import { type ComponentType, type CSSProperties, useMemo, useState } from 'react'
import { ArrowRight, ChevronDown, chipVariants, cn, Expandable, ExpandableContent } from '@sim/emcn'
import { Shuffle, Table } from '@sim/emcn/icons'
+import { randomFloat } from '@sim/utils/random'
import { stripVersionSuffix } from '@sim/utils/string'
import { useParams } from 'next/navigation'
import { usePostHog } from 'posthog-js/react'
@@ -156,7 +157,7 @@ function weightedSample(pool: readonly T[], n: number, weightOf: (item: T) =>
while (out.length < n && remaining.length > 0) {
const total = remaining.reduce((sum, entry) => sum + entry.weight, 0)
if (total <= 0) break
- let roll = Math.random() * total
+ let roll = randomFloat() * total
const index = remaining.findIndex((entry) => {
roll -= entry.weight
return roll <= 0
diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts
index 11acb17ec32..fde7fd7e1e4 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/stream/turn-model.ts
@@ -1,3 +1,4 @@
+import { isRecordLike as isRecord } from '@sim/utils/object'
import { resolveStreamToolOutcome } from '@/lib/copilot/chat/stream-tool-outcome'
import {
MothershipStreamV1CompletionStatus,
@@ -175,10 +176,6 @@ function finalizeStaleWorkspaceFiles(model: TurnModel, spanId: string): void {
}
}
-function isRecord(value: unknown): value is Record {
- return typeof value === 'object' && value !== null && !Array.isArray(value)
-}
-
function asString(value: unknown): string | undefined {
return typeof value === 'string' ? value : undefined
}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx
index 77f7e904539..581a37f374d 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components/document-tags-modal/document-tags-modal.tsx
@@ -17,6 +17,7 @@ import {
Trash,
} from '@sim/emcn'
import { createLogger } from '@sim/logger'
+import { formatDate } from '@sim/utils/formatting'
import { ALL_TAG_SLOTS, type AllTagSlot, MAX_TAG_SLOTS } from '@/lib/knowledge/constants'
import type { DocumentTag } from '@/lib/knowledge/tags/types'
import type { DocumentData } from '@/lib/knowledge/types'
@@ -60,13 +61,9 @@ function formatValueForDisplay(value: string, fieldType: string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
if (typeof value === 'string' && (value.endsWith('Z') || /[+-]\d{2}:\d{2}$/.test(value))) {
- return new Date(
- date.getUTCFullYear(),
- date.getUTCMonth(),
- date.getUTCDate()
- ).toLocaleDateString()
+ return formatDate(new Date(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
}
- return date.toLocaleDateString()
+ return formatDate(date)
} catch {
return value
}
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
index e25881872d8..33d2e17d9b2 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } fro
import { Badge, ChipCombobox, ChipConfirmModal, Plus, Trash } from '@sim/emcn'
import { Database } from '@sim/emcn/icons'
import { createLogger } from '@sim/logger'
+import { truncate } from '@sim/utils/string'
import { ChevronDown, ChevronUp, FileText, Pencil, Tag } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
import { useQueryStates } from 'nuqs'
@@ -108,7 +109,7 @@ function truncateContent(content: string, maxLength = 150, searchQuery = ''): st
}
}
- return `${content.substring(0, maxLength)}...`
+ return truncate(content, maxLength)
}
const CHUNK_COLUMNS: ResourceColumn[] = [
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx
index a0df3250449..14d42c6bfa7 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/loading.tsx
@@ -2,6 +2,7 @@
import { Plus } from '@sim/emcn'
import { Database } from '@sim/emcn/icons'
+import { noop } from '@sim/utils/helpers'
import { FileText } from 'lucide-react'
import {
type BreadcrumbItem,
@@ -9,8 +10,6 @@ import {
ResourceChromeFallback,
} from '@/app/workspace/[workspaceId]/components'
-const noop = () => {}
-
const COLUMNS = [
{ id: 'content', header: 'Content' },
{ id: 'index', header: 'Index', widthMultiplier: 0.6 },
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
index f3c673c8325..2d6863b3250 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
@@ -49,6 +49,7 @@ import type {
SortConfig,
} from '@/app/workspace/[workspaceId]/components'
import { FloatingOverflowText, Resource } from '@/app/workspace/[workspaceId]/components'
+import { DocumentTagsModal } from '@/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/components'
import {
ActionBar,
AddConnectorModal,
@@ -342,6 +343,8 @@ export function KnowledgeBase({
const [contextMenuDocument, setContextMenuDocument] = useState(null)
const [showRenameModal, setShowRenameModal] = useState(false)
const [documentToRename, setDocumentToRename] = useState(null)
+ const [showDocumentTagsModal, setShowDocumentTagsModal] = useState(false)
+ const [documentForTagsId, setDocumentForTagsId] = useState(null)
const showAddConnectorModal = addConnectorType != null
const updateAddConnectorParam = useCallback(
(value: string | null) => {
@@ -531,6 +534,14 @@ export function KnowledgeBase({
setShowRenameModal(true)
}
+ /**
+ * Opens the document tags modal
+ */
+ const handleViewDocumentTags = (doc: DocumentData) => {
+ setDocumentForTagsId(doc.id)
+ setShowDocumentTagsModal(true)
+ }
+
/**
* Saves the renamed document
*/
@@ -1345,6 +1356,17 @@ export function KnowledgeBase({
/>
)}
+ {documentForTagsId && (
+ doc.id === documentForTagsId) ?? null}
+ onDocumentUpdate={(updates) => updateDocument(documentForTagsId, updates)}
+ />
+ )}
+
0
- : false
- }
selectedCount={selectedDocuments.size}
enabledCount={enabledCount}
disabledCount={disabledCount}
@@ -1413,16 +1430,8 @@ export function KnowledgeBase({
: undefined
}
onViewTags={
- contextMenuDocument && selectedDocuments.size === 1
- ? () => {
- const urlParams = new URLSearchParams({
- kbName: knowledgeBaseName,
- docName: contextMenuDocument.filename || 'Document',
- })
- router.push(
- `/workspace/${workspaceId}/knowledge/${id}/${contextMenuDocument.id}?${urlParams.toString()}`
- )
- }
+ contextMenuDocument && selectedDocuments.size === 1 && userPermissions.canEdit
+ ? () => handleViewDocumentTags(contextMenuDocument)
: undefined
}
onDelete={
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx
index bb751cf2502..7050da64725 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/document-context-menu/document-context-menu.tsx
@@ -22,7 +22,6 @@ interface DocumentContextMenuProps {
onAddDocument?: () => void
isDocumentEnabled?: boolean
hasDocument: boolean
- hasTags?: boolean
disableRename?: boolean
disableToggleEnabled?: boolean
disableDelete?: boolean
@@ -50,7 +49,6 @@ export function DocumentContextMenu({
onAddDocument,
isDocumentEnabled = true,
hasDocument,
- hasTags = false,
disableRename = false,
disableToggleEnabled = false,
disableDelete = false,
@@ -70,7 +68,7 @@ export function DocumentContextMenu({
}
const hasNavigationSection = !isMultiSelect && (!!onOpenInNewTab || !!onOpenSource)
- const hasEditSection = !isMultiSelect && (!!onRename || (hasTags && !!onViewTags))
+ const hasEditSection = !isMultiSelect && (!!onRename || !!onViewTags)
const hasStateSection = !!onToggleEnabled
const hasDestructiveSection = !!onDelete
@@ -121,10 +119,10 @@ export function DocumentContextMenu({
Rename
)}
- {!isMultiSelect && hasTags && onViewTags && (
+ {!isMultiSelect && onViewTags && (
- View tags
+ Tags
)}
{hasEditSection && (hasStateSection || hasDestructiveSection) && (
diff --git a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx
index 975b4ab9560..dd0f8aa5ce4 100644
--- a/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/knowledge/[id]/loading.tsx
@@ -2,14 +2,13 @@
import { Plus } from '@sim/emcn'
import { Database } from '@sim/emcn/icons'
+import { noop } from '@sim/utils/helpers'
import {
type BreadcrumbItem,
type ChromeActionSpec,
ResourceChromeFallback,
} from '@/app/workspace/[workspaceId]/components'
-const noop = () => {}
-
const COLUMNS = [
{ id: 'name', header: 'Name', widthMultiplier: 0.8 },
{ id: 'size', header: 'Size', widthMultiplier: 0.75 },
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
index a1553590097..6a28af7d077 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
+++ b/apps/sim/app/workspace/[workspaceId]/logs/utils.ts
@@ -1,6 +1,6 @@
import React from 'react'
import { Badge } from '@sim/emcn'
-import { formatDuration } from '@sim/utils/formatting'
+import { formatDuration, formatRelativeTime } from '@sim/utils/formatting'
import { format } from 'date-fns'
import type { WorkflowLogDetail } from '@/lib/api/contracts/logs'
import { getIntegrationMetadata } from '@/lib/logs/get-trigger-options'
@@ -224,23 +224,7 @@ export const formatDate = (dateString: string) => {
compact: format(date, 'MMM d HH:mm:ss'),
compactDate: format(date, 'MMM d').toUpperCase(),
compactTime: format(date, 'h:mm a'),
- relative: (() => {
- const now = new Date()
- const diffMs = now.getTime() - date.getTime()
- const diffMins = Math.floor(diffMs / 60000)
-
- if (diffMins < 1) return 'just now'
- if (diffMins < 60) return `${diffMins}m ago`
-
- const diffHours = Math.floor(diffMins / 60)
- if (diffHours < 24) return `${diffHours}h ago`
-
- const diffDays = Math.floor(diffHours / 24)
- if (diffDays === 1) return 'yesterday'
- if (diffDays < 7) return `${diffDays}d ago`
-
- return format(date, 'MMM d')
- })(),
+ relative: formatRelativeTime(dateString),
}
}
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
index f0ac5684c16..f9022cfb0b2 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/page.tsx
@@ -32,6 +32,7 @@ const SECTION_TITLES: Record = {
sso: 'Single Sign-On',
whitelabeling: 'Whitelabeling',
copilot: 'Chat Keys',
+ forks: 'Workspace Forks',
mcp: 'MCP Tools',
'custom-tools': 'Custom Tools',
'workflow-mcp-servers': 'MCP Servers',
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts
index 907d11c765d..bdf4eb6350c 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/search-params.ts
@@ -1,4 +1,4 @@
-import { parseAsString } from 'nuqs/server'
+import { parseAsString, parseAsStringLiteral } from 'nuqs/server'
/**
* Co-located, typed URL query-param definitions for the settings section pages.
@@ -18,3 +18,48 @@ export const mcpServerIdUrlKeys = {
history: 'push',
clearOnDefault: true,
} as const
+
+/**
+ * `fork-id` deep-links the Forks settings tab to a specific fork's detail
+ * sub-view (mirrors `mcpServerId` on the MCP tab).
+ */
+export const forkIdParam = {
+ key: 'fork-id',
+ parser: parseAsString,
+} as const
+
+/** Opening a fork's detail is a destination → push to history; clear on close. */
+export const forkIdUrlKeys = {
+ history: 'push',
+ clearOnDefault: true,
+} as const
+
+/**
+ * `fork-view` deep-links the Forks settings tab to its workspace-scoped Activity
+ * view (opened from the page header's "See activity" action).
+ */
+export const forkViewParam = {
+ key: 'fork-view',
+ parser: parseAsStringLiteral(['activity'] as const),
+} as const
+
+/** Opening the activity view is a destination → push to history; clear on close. */
+export const forkViewUrlKeys = {
+ history: 'push',
+ clearOnDefault: true,
+} as const
+
+/**
+ * `fork-direction` is the sync direction (push/pull) on the parent fork's detail
+ * page — shareable view state, so a copied link opens the same side of the sync.
+ */
+export const forkSyncDirectionParam = {
+ key: 'fork-direction',
+ parser: parseAsStringLiteral(['push', 'pull'] as const).withDefault('push'),
+} as const
+
+/** Toggling direction is in-place view state → replace history; clear at the push default. */
+export const forkSyncDirectionUrlKeys = {
+ history: 'replace',
+ clearOnDefault: true,
+} as const
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
index 13c6e1cf876..193c9646313 100644
--- a/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx
@@ -25,6 +25,7 @@ const BYOK = dynamic(() =>
const Copilot = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/copilot/copilot').then((m) => m.Copilot)
)
+const Forks = dynamic(() => import('@/ee/workspace-forking/components/forks').then((m) => m.Forks))
const Secrets = dynamic(() =>
import('@/app/workspace/[workspaceId]/settings/components/secrets/secrets').then((m) => m.Secrets)
)
@@ -138,6 +139,7 @@ export function SettingsPage({ section }: SettingsPageProps) {
{effectiveSection === 'byok' && }
{effectiveSection === 'copilot' && }
{effectiveSection === 'mcp' && }
+ {effectiveSection === 'forks' && }
{effectiveSection === 'custom-tools' && }
{effectiveSection === 'workflow-mcp-servers' && }
{effectiveSection === 'inbox' && }
diff --git a/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx b/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx
new file mode 100644
index 00000000000..940969b0ced
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/settings/components/activity-log/activity-log.tsx
@@ -0,0 +1,151 @@
+'use client'
+
+import { type ReactNode, useState } from 'react'
+import { cn } from '@sim/emcn'
+import { ChevronDown } from 'lucide-react'
+import { FloatingOverflowText } from '@/app/workspace/[workspaceId]/components'
+
+/**
+ * One row of an activity/audit log. `details`, when present, renders inside the
+ * expandable bordered box below the row; omit it to make the row non-expandable.
+ */
+export interface ActivityLogEntry {
+ id: string
+ timestamp: ReactNode
+ /** Leading badge conveying the action/status (typically a `Badge`). */
+ event: ReactNode
+ description: ReactNode
+ actor: ReactNode
+ details?: ReactNode
+}
+
+/**
+ * Event-column width presets, shared by the header and every row so the column
+ * stays aligned: `wide` fits the audit log's long action badges; `compact` suits
+ * short operation badges (Fork / Push / Rollback), returning the spare width to
+ * the flexible description column.
+ */
+const EVENT_COLUMN_WIDTH_CLASS = {
+ wide: 'w-[180px]',
+ compact: 'w-[90px]',
+} as const
+
+type EventColumnWidth = keyof typeof EVENT_COLUMN_WIDTH_CLASS
+
+function ActivityLogRow({
+ entry,
+ eventColumn,
+}: {
+ entry: ActivityLogEntry
+ eventColumn: EventColumnWidth
+}) {
+ const [expanded, setExpanded] = useState(false)
+ const expandable = entry.details != null
+
+ return (
+
+
+ {expandable && expanded && (
+
+
+ {entry.details}
+
+
+ )}
+
+ )
+}
+
+export interface ActivityLogProps {
+ entries: ActivityLogEntry[]
+ /** Header label for the badge column. */
+ eventLabel?: string
+ /** Header label for the wide middle column. */
+ descriptionLabel?: string
+ /** Badge-column width preset; use `compact` when every badge is a short word. Defaults to `wide`. */
+ eventColumn?: EventColumnWidth
+ /** Rendered below the header when there are no entries (the header stays visible). */
+ emptyState?: ReactNode
+ /** Rendered after the rows (e.g. a "Load more" control). */
+ footer?: ReactNode
+}
+
+/**
+ * Canonical expandable activity/audit-log table: a four-column header
+ * (Timestamp / event / description / Actor) over rows that expand to a bordered
+ * detail box. Shared by the enterprise audit log and the fork Activity view so
+ * both read identically — callers own data fetching and map their rows to
+ * {@link ActivityLogEntry}.
+ */
+export function ActivityLog({
+ entries,
+ eventLabel = 'Event',
+ descriptionLabel = 'Description',
+ eventColumn = 'wide',
+ emptyState,
+ footer,
+}: ActivityLogProps) {
+ return (
+