Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
8548821
feat(playground): wire the org project view to real workspaces
mzxchandra Sep 30, 2026
cb12e31
fix(playground): pick overlay packs by workflow names, thread project…
mzxchandra Sep 30, 2026
ad5a255
fix(playground): defer the handed-off first message, share the projec…
mzxchandra Sep 30, 2026
c76aa17
fix(playground): wait for the overlay pack before 404ing an issue
mzxchandra Sep 30, 2026
8db9094
feat(playground): mock projects beside real ones, Connectors as the o…
mzxchandra Sep 30, 2026
224d287
feat(playground): forked workspaces are one project with environments
mzxchandra Sep 30, 2026
ac1905c
feat(projects): shared lineage model, org-project-view flag, Environm…
mzxchandra Sep 30, 2026
2af068d
feat(playground): Environments tab manages the project's fork lineage…
mzxchandra Sep 30, 2026
794e8fe
feat(playground): Build opens the workspace routes, Issues per lineag…
mzxchandra Sep 30, 2026
5291156
feat(workspace): project Build sidebar and Workflows index page behin…
mzxchandra Sep 30, 2026
d921f6c
feat(playground): project rows with the workspace sidebar's ergonomic…
mzxchandra Sep 30, 2026
f51110a
feat(playground): org sidebar account footer, fork from any environment
mzxchandra Sep 30, 2026
d00e347
feat(playground): New chat is the product's org Home plus a project p…
mzxchandra Sep 30, 2026
bc5bf6e
feat(workspace): workflow location bar above the canvas in the Build …
mzxchandra Sep 30, 2026
9c398c1
feat(org): project view moves onto the organization surface behind or…
mzxchandra Sep 30, 2026
3dab93c
feat(workflows): describe container changes and build a canvas diff o…
mzxchandra Sep 30, 2026
d5a6030
feat(preview): paint comparison status on the read-only canvas
mzxchandra Sep 30, 2026
5438d26
feat(workflows): version diff view with a field-level change list
mzxchandra Sep 30, 2026
c4bb130
feat(deploy): compare deployment versions and the draft
mzxchandra Sep 30, 2026
02c29ab
feat(forks): preview one workflow's block-level changes before a sync
mzxchandra Sep 30, 2026
778636a
fix(workflows): harden the version diff after review
mzxchandra Sep 30, 2026
9b55e84
fix(workflows): scope the fork preview to one workflow and close revi…
mzxchandra Sep 30, 2026
babdccc
test(forks): use a neutral workflow name in preview fixtures
mzxchandra Sep 30, 2026
0ab7772
fix(forks): offer "View changes" only on workflows the sync changes
mzxchandra Sep 30, 2026
53d1b70
fix(workflows): bound the sync change check and tighten diff rendering
mzxchandra Sep 30, 2026
e84728d
fix(workflows): mask message text, count all block columns, reset sel…
mzxchandra Sep 30, 2026
3278348
fix(workflows): scope selection reset to picked versions and test out…
mzxchandra Sep 30, 2026
43bb39e
fix(workflows): only call a message change masked when the message ex…
mzxchandra Sep 30, 2026
5d319f5
test(workflows): use absolute imports in new tests
mzxchandra Sep 30, 2026
2672611
feat(org): promote and refresh between environments from the project'…
mzxchandra Sep 30, 2026
6a215b1
feat(org): every chat in one Chats section, marked with its projects;…
mzxchandra Sep 30, 2026
905a58f
feat(projects): a projects table every workspace belongs to, with bac…
mzxchandra Sep 30, 2026
c4c1d08
feat(projects): read projects from the projects API; environment labe…
mzxchandra Sep 30, 2026
da7d9a2
fix(chats): move only org members' workspace chats to the organization
mzxchandra Oct 1, 2026
6f42b47
fix(chats): keep moved workspace chats in agent mode
mzxchandra Oct 1, 2026
63d595e
feat(projects): name a project after its root workspace with a " - Pr…
mzxchandra Oct 1, 2026
b45a6ba
feat(projects): checkpoint project UI and environment settings redesign
mzxchandra Oct 2, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
25 changes: 25 additions & 0 deletions apps/sim/app/api/organizations/[id]/projects/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { listOrganizationProjectsContract } from '@/lib/api/contracts/projects'
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { internalPermissionGroupErrorPolicy } from '@/lib/api/server/routes/permission-groups'
import {
listOrganizationProjects,
listOrganizationProjectsOperation,
} from '@/lib/projects/application/organization-projects'

export const GET = defineInternalJsonRoute({
contract: listOrganizationProjectsContract,
auth: internalSessionAuth,
operation: listOrganizationProjectsOperation,
rateLimit: internalRateLimits.none({
reason:
'Read-only organization settings inventory follows existing workspace inventory admission',
}),
errorPolicy: internalPermissionGroupErrorPolicy,
mapInput: ({ params }) => ({ organizationId: params.id }),
useCase: listOrganizationProjects,
present: (result) => result,
})
18 changes: 18 additions & 0 deletions apps/sim/app/api/projects/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { renameProjectContract } from '@/lib/api/contracts/projects'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { projectOperations, renameProject } from '@/lib/projects/application/projects'

export const PATCH = defineInternalJsonRoute({
contract: renameProjectContract,
auth: internalSessionAuth,
operation: projectOperations.rename,
rateLimit: internalRateLimits.none({ reason: 'Admin renaming a project they administer' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params, body }) => ({ projectId: params.id, name: body.name }),
useCase: renameProject,
})
18 changes: 18 additions & 0 deletions apps/sim/app/api/projects/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { listProjectsContract } from '@/lib/api/contracts/projects'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { listProjects, projectOperations } from '@/lib/projects/application/projects'

export const GET = defineInternalJsonRoute({
contract: listProjectsContract,
auth: internalSessionAuth,
operation: projectOperations.list,
rateLimit: internalRateLimits.none({ reason: 'Read-only list of the caller’s own projects' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ query }) => query,
useCase: listProjects,
})
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* `DATA_RETENTION_ENABLED` (or `ENTERPRISE_ENABLED`) when billing is off.
*
* Body: any subset of `logRetentionHours`, `softDeleteRetentionHours`,
* `taskCleanupHours`, `fileVersionRetentionHours`, `piiRedaction`, `retentionOverrides`.
* `taskCleanupHours`, `fileVersionRetentionHours`, `piiRedaction`, `retentionOverrides`, `projectOverrides`.
* Omitted keys keep their current value; `null` means "forever" for an hours field.
*
* Response: AdminSingleResponse<{ success, organizationId }>
Expand Down Expand Up @@ -85,6 +85,7 @@ export const PATCH = withRouteHandler(
merged.piiRedaction = body.piiRedaction
}

if (body.projectOverrides !== undefined) merged.projectOverrides = body.projectOverrides
if (body.retentionOverrides !== undefined) {
merged.retentionOverrides = body.retentionOverrides
}
Expand All @@ -97,6 +98,7 @@ export const PATCH = withRouteHandler(
const foreignTargetsReason = await getForeignWorkspaceTargetsReason({
organizationId,
retentionOverrides: body.retentionOverrides,
projectOverrides: body.projectOverrides,
piiRedaction: body.piiRedaction,
})
if (foreignTargetsReason) {
Expand Down
19 changes: 19 additions & 0 deletions apps/sim/app/api/workspaces/[id]/dashboard/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { readWorkspaceDashboardContract } from '@/lib/api/contracts/dashboards'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { readWorkspaceDashboard } from '@/lib/dashboards/application/dashboards'
import { dashboardOperations } from '@/lib/dashboards/application/operations'

export const GET = defineInternalJsonRoute({
contract: readWorkspaceDashboardContract,
auth: internalSessionAuth,
operation: dashboardOperations.read,
rateLimit: internalRateLimits.user({ bucketName: 'dashboards' }),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params }) => ({ workspaceId: params.id }),
useCase: readWorkspaceDashboard,
})
13 changes: 13 additions & 0 deletions apps/sim/app/api/workspaces/[id]/fork/mapping/route.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
createForkSecretMappingContract,
getForkMappingContract,
updateForkMappingContract,
} from '@/lib/api/contracts/workspace-fork'
Expand All @@ -8,6 +9,7 @@ import {
internalSessionAuth,
} from '@/lib/api/server/routes'
import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies'
import { createWorkspaceForkSecretMapping } from '@/ee/workspace-forking/application/create-secret-mapping'
import { getWorkspaceForkMappingDetails } from '@/ee/workspace-forking/application/mapping-details'
import { forkOperations } from '@/ee/workspace-forking/application/operations'
import { updateWorkspaceForkMappings } from '@/ee/workspace-forking/application/recovery-and-mappings'
Expand Down Expand Up @@ -38,3 +40,14 @@ export const PUT = defineInternalJsonRoute({
useCase: updateWorkspaceForkMappings,
present: (result) => ({ success: true as const, ...result }),
})

export const POST = defineInternalJsonRoute({
contract: createForkSecretMappingContract,
auth: internalSessionAuth,
operation: forkOperations.createSecretMapping,
rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal fork request policy' }),
errorPolicy: internalForkErrorPolicy,
mapInput: ({ params, body }) => ({ workspaceId: params.id, ...body }),
useCase: createWorkspaceForkSecretMapping,
present: ({ name }) => ({ name }),
})
181 changes: 181 additions & 0 deletions apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
/**
* @vitest-environment node
*/
import { authMockFns, createMockRequest } from '@sim/testing'
import { createRouteContext } from '@sim/testing/helpers/http'
import { permissionsMock, permissionsMockFns } from '@sim/testing/mocks/permissions.mock'
import {
workspaceAuthorizationMock,
workspaceAuthorizationMockFns,
} from '@sim/testing/mocks/workspace-authorization.mock'
import { workspaceForkingAuthzMock } from '@sim/testing/mocks/workspace-forking-authz.mock'
import {
workspaceForkingLineageMock,
workspaceForkingLineageMockFns,
} from '@sim/testing/mocks/workspace-forking-lineage.mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { OrchestrationError } from '@/lib/core/orchestration/types'

const mocks = vi.hoisted(() => ({
loadSourceDeployedWorkflow: vi.fn(),
loadTargetDraftState: vi.fn(),
loadForkBlockMap: vi.fn(),
resolveForkPlanItem: vi.fn(),
}))

vi.mock('@/lib/core/application/workspace-authorization', () => workspaceAuthorizationMock)
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => workspaceForkingAuthzMock)
vi.mock('@/ee/workspace-forking/lib/lineage/lineage', () => workspaceForkingLineageMock)
vi.mock('@/ee/workspace-forking/lib/copy/deploy-bridge', () => ({
loadSourceDeployedWorkflow: mocks.loadSourceDeployedWorkflow,
loadTargetDraftState: mocks.loadTargetDraftState,
}))
vi.mock('@/ee/workspace-forking/lib/mapping/block-map-store', () => ({
loadForkBlockMap: mocks.loadForkBlockMap,
}))
vi.mock('@/ee/workspace-forking/lib/promote/promote-plan', () => ({
resolveForkPlanItem: mocks.resolveForkPlanItem,
}))

import { GET } from '@/app/api/workspaces/[id]/fork/workflow-diff/route'

const { mockAuthorizeWorkspaceOperation } = workspaceAuthorizationMockFns
const mockGetSession = authMockFns.mockGetSession
permissionsMockFns.mockGetWorkspaceWithOwner.mockImplementation(async (id: string) => ({
id,
name: id,
organizationId: null,
allowPersonalApiKeys: true,
}))

const WORKSPACE_ID = 'child'
const routeContext = createRouteContext({ id: WORKSPACE_ID })
const BASE_URL = `http://localhost/api/workspaces/${WORKSPACE_ID}/fork/workflow-diff`

const emptyState = { blocks: {}, edges: [], loops: {}, parallels: {}, variables: {} }

function request(query: Record<string, string>) {
const url = `${BASE_URL}?${new URLSearchParams(query).toString()}`
return createMockRequest('GET', undefined, undefined, url)
}

describe('fork workflow-diff route', () => {
beforeEach(() => {
mockGetSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } })
mockAuthorizeWorkspaceOperation.mockResolvedValue(undefined)
workspaceForkingLineageMockFns.mockResolveForkEdge.mockResolvedValue({
parentWorkspaceId: 'parent',
childWorkspaceId: 'child',
})
mocks.loadForkBlockMap.mockResolvedValue({ parentToChild: new Map(), childToParent: new Map() })
mocks.loadSourceDeployedWorkflow.mockImplementation(async (_ws: string, id: string) =>
id === 'wf-src' ? { summary: { id, name: 'Support Agent' }, state: emptyState } : null
)
mocks.resolveForkPlanItem.mockResolvedValue({
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
targetName: null,
mode: 'create',
sourceMeta: { name: 'Support Agent' },
})
})

it('does not read any state when workspace authorization is refused', async () => {
mockAuthorizeWorkspaceOperation.mockRejectedValue(
new OrchestrationError('forbidden', 'Admin access required')
)

const response = await GET(
request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }),
routeContext
)

expect(response.status).toBe(403)
})

it('rejects a request without the source workflow id', async () => {
const response = await GET(
request({ otherWorkspaceId: 'parent', direction: 'push' }),
routeContext
)

expect(response.status).toBe(400)
})

it('rejects workspaces that are not a direct fork edge without reading state', async () => {
workspaceForkingLineageMockFns.mockResolveForkEdge.mockResolvedValue(null)

const response = await GET(
request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }),
routeContext
)

expect(response.status).toBe(400)
})

it('maps a workflow outside the sync plan to 404', async () => {
const response = await GET(
request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'foreign' }),
routeContext
)

expect(response.status).toBe(404)
})

it('returns the before and after states with their labels', async () => {
const response = await GET(
request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }),
routeContext
)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toEqual({
targetWorkflowId: null,
before: null,
after: emptyState,
beforeLabel: 'Support Agent (current)',
afterLabel: 'Support Agent (deployed)',
})
})
it('maps a workflow whose target is excluded from sync to 404', async () => {
mocks.resolveForkPlanItem.mockResolvedValue(null)

const response = await GET(
request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }),
routeContext
)

expect(response.status).toBe(404)
})

it('returns the target draft as before when the sync replaces a workflow', async () => {
const targetState = {
...emptyState,
blocks: {},
}
mocks.resolveForkPlanItem.mockResolvedValue({
sourceWorkflowId: 'wf-src',
targetWorkflowId: 'wf-tgt',
targetName: 'Support Agent prod',
mode: 'replace',
sourceMeta: { name: 'Support Agent' },
})
/* Only the replaced target in the target workspace has a draft; any other read is a 404. */
mocks.loadTargetDraftState.mockImplementation(async (id: string, workspaceId: string) =>
id === 'wf-tgt' && workspaceId === 'parent' ? targetState : null
)

const response = await GET(
request({ otherWorkspaceId: 'parent', direction: 'push', sourceWorkflowId: 'wf-src' }),
routeContext
)

expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
targetWorkflowId: 'wf-tgt',
before: targetState,
beforeLabel: 'Support Agent prod (current)',
})
})
})
20 changes: 20 additions & 0 deletions apps/sim/app/api/workspaces/[id]/fork/workflow-diff/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { getForkWorkflowDiffContract } from '@/lib/api/contracts/workspace-fork'
import {
defineInternalJsonRoute,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import { internalForkErrorPolicy } from '@/ee/workspace-forking/api/route-policies'
import { forkOperations } from '@/ee/workspace-forking/application/operations'
import { getWorkspaceSyncWorkflowDiff } from '@/ee/workspace-forking/application/sync-workflow-diff'

export const GET = defineInternalJsonRoute({
contract: getForkWorkflowDiffContract,
auth: internalSessionAuth,
operation: forkOperations.syncPreview,
rateLimit: internalRateLimits.user({ bucketName: 'workspace-fork-workflow-diff' }),
errorPolicy: internalForkErrorPolicy,
mapInput: ({ params, query }) => ({ workspaceId: params.id, ...query }),
useCase: getWorkspaceSyncWorkflowDiff,
present: (result) => result,
})
24 changes: 24 additions & 0 deletions apps/sim/app/api/workspaces/[id]/settings-navigation/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { getWorkspaceSettingsNavigationContract } from '@/lib/api/contracts/workspace-settings'
import {
defineInternalJsonRoute,
internalOrchestrationErrorPolicy,
internalRateLimits,
internalSessionAuth,
} from '@/lib/api/server/routes'
import {
readWorkspaceSettingsNavigation,
readWorkspaceSettingsNavigationOperation,
} from '@/lib/settings/application/workspace-navigation'

export const GET = defineInternalJsonRoute({
contract: getWorkspaceSettingsNavigationContract,
auth: internalSessionAuth,
operation: readWorkspaceSettingsNavigationOperation,
rateLimit: internalRateLimits.none({
reason: 'Read-only settings navigation, matching existing settings-page admission',
}),
errorPolicy: internalOrchestrationErrorPolicy,
mapInput: ({ params }) => ({ workspaceId: params.id }),
useCase: readWorkspaceSettingsNavigation,
staticResponseHeaders: { 'Cache-Control': 'private, no-store' },
})
Loading