Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .agents/skills/ship/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ When the user runs `/ship`:
# Runs every audit CI runs, concurrently, and replays the output of any that fail.
# The audit list is derived in scripts/run-audits.ts — do not hand-list audits here.
bun run check:audits || { echo "❌ audit(s) failed — do not ship"; exit 1; }
# CI's "Verify docs manifest is in sync" step is not a `check:*` script, so the runner above
# does not cover it. (CI's "Security audit" `bun audit` step is `continue-on-error` — advisory
# only, not a gate — so it is deliberately not run here.)
bun run docs-manifest:check || { echo "❌ docs manifest out of sync — do not ship"; exit 1; }
```
If Phase A regenerated a file, its matching `:check` in Phase B now passes trivially — that parity is the point. Do not ship with any generator or audit failing; fix the cause (never silence it) and re-run. `check:migrations` and `type-check` are covered by steps 5 and CI respectively and are not repeated here.
7. **Stage and commit** the changes with the generated message — including any files Phase A regenerated in step 6
Expand Down
4 changes: 3 additions & 1 deletion apps/docs/content/docs/platform/enterprise/forks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -136,7 +136,7 @@ The setting belongs to **this workspace's copy** only. Excluding a workflow here

<Image src="/static/enterprise/forks-activity.png" alt="Activity view showing Fork and Push events with expandable detail rows" width={900} height={614} />

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).
Expand a row for names of workflows and resources that were created, updated, or archived, and any warnings (for example failed background copies or deploy failures). A push or pull that copies resources fills their content in the background, and that progress and outcome show in the same row.

---

Expand Down Expand Up @@ -331,6 +331,8 @@ Servers that **publish workflows as MCP tools**.
| **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. |

Notes are documentation: a `{{KEY}}` that appears only inside a Note block never needs mapping and never blocks 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.

---
Expand Down
69 changes: 48 additions & 21 deletions apps/sim/app/api/workspaces/[id]/fork/promote/route.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Tests for the fork sync (promote) route's error projection.
* Tests for the fork sync (promote) route's error projection and input mapping.
*
* `promoteFork` returns its deliberate refusals as a `blocked` result, but a classified
* failure raised deeper in the copy — the target workspace's folder ceiling being full —
Expand All @@ -12,22 +12,19 @@ import { auditMock, authMockFns, createMockRequest, type MockUser } from '@sim/t
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { FolderCollectionFullError } from '@/lib/folders/errors'

const { mockLogger, mockPromoteFork, mockAssertCanPromote, mockRecordBackgroundWork } = vi.hoisted(
() => ({
mockLogger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
child: vi.fn(),
},
mockPromoteFork: vi.fn(),
mockAssertCanPromote: vi.fn(),
mockRecordBackgroundWork: vi.fn(),
})
)
const { mockLogger, mockPromoteFork, mockAssertCanPromote } = vi.hoisted(() => ({
mockLogger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
trace: vi.fn(),
fatal: vi.fn(),
child: vi.fn(),
},
mockPromoteFork: vi.fn(),
mockAssertCanPromote: vi.fn(),
}))

vi.mock('@sim/audit', () => auditMock)
vi.mock('@sim/logger', () => ({
Expand All @@ -39,9 +36,6 @@ vi.mock('@/ee/workspace-forking/lib/promote/promote', () => ({ promoteFork: mock
vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({
assertCanPromote: mockAssertCanPromote,
}))
vi.mock('@/ee/workspace-forking/lib/background-work/store', () => ({
recordBackgroundWork: mockRecordBackgroundWork,
}))

import { POST } from '@/app/api/workspaces/[id]/fork/promote/route'

Expand Down Expand Up @@ -69,8 +63,41 @@ describe('POST /api/workspaces/[id]/fork/promote', () => {
edge: { childWorkspaceId: WORKSPACE_ID },
sourceWorkspaceId: WORKSPACE_ID,
targetWorkspaceId: 'ws-parent',
source: { name: 'Child' },
target: { name: 'Parent' },
})
})

/**
* The sync's Activity row is recorded by the use case, not here, so the route's job is to
* hand it the one thing only the route knows: the display name of the edge's other side.
*/
it('names the other side of the edge for promoteFork to record the sync', async () => {
mockPromoteFork.mockResolvedValue({
promoteRunId: 'run-1',
updated: 1,
created: 0,
archived: 0,
redeployed: 1,
deployFailed: 0,
unmappedRequired: [],
blockers: [],
blocked: null,
updatedNames: ['Flow'],
createdNames: [],
archivedNames: [],
needsConfiguration: [],
clearedOptional: [],
droppedReferences: [],
triggerUrlChanges: [],
})
mockRecordBackgroundWork.mockResolvedValue(undefined)

const response = await POST(promoteRequest(), routeContext)

expect(response.status).toBe(200)
expect(mockPromoteFork).toHaveBeenCalledWith(
expect.objectContaining({ direction: 'push', actorName: 'A', otherWorkspaceName: 'Parent' })
)
})

it('renders a full-folder-tree refusal as an actionable 409', async () => {
Expand Down
44 changes: 3 additions & 41 deletions apps/sim/app/api/workspaces/[id]/fork/promote/route.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
import { db } from '@sim/db'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { promoteForkContract } from '@/lib/api/contracts/workspace-fork'
import { parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { 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'

Expand All @@ -36,6 +33,8 @@ export const POST = withRouteHandler(
} = parsed.data.body

const auth = await assertCanPromote(id, otherWorkspaceId, direction, session.user.id)
const otherName =
otherWorkspaceId === auth.sourceWorkspaceId ? auth.source.name : auth.target.name

let result: Awaited<ReturnType<typeof promoteFork>>
try {
Expand All @@ -46,6 +45,7 @@ export const POST = withRouteHandler(
direction,
userId: session.user.id,
actorName: session.user.name ?? undefined,
otherWorkspaceName: otherName,
dependentValues,
copyResources,
dropReferences,
Expand Down Expand Up @@ -114,44 +114,6 @@ export const POST = withRouteHandler(
request: req,
})

const otherName =
otherWorkspaceId === auth.sourceWorkspaceId ? auth.source.name : auth.target.name
await recordBackgroundWork(db, {
workspaceId: id,
kind: 'fork_sync',
status:
result.deployFailed > 0 ||
result.needsConfiguration.length > 0 ||
result.clearedOptional.length > 0 ||
result.droppedReferences.length > 0 ||
result.triggerUrlChanges.length > 0
? 'completed_with_warnings'
: 'completed',
message: direction === 'pull' ? `Pulled from "${otherName}"` : `Pushed to "${otherName}"`,
metadata: {
actorName: session.user.name ?? undefined,
otherWorkspaceId,
otherWorkspaceName: otherName,
direction,
updated: result.updated,
created: result.created,
archived: result.archived,
redeployed: result.redeployed,
deployFailed: result.deployFailed,
updatedNames: result.updatedNames,
createdNames: result.createdNames,
archivedNames: result.archivedNames,
needsConfiguration: result.needsConfiguration,
clearedOptional: result.clearedOptional,
droppedReferences: result.droppedReferences.length,
triggerUrlChanges: result.triggerUrlChanges.length,
},
}).catch((error) =>
logger.error(`[${requestId}] Failed to record sync activity`, {
error: getErrorMessage(error),
})
)

return NextResponse.json(body)
}
)
Original file line number Diff line number Diff line change
Expand Up @@ -169,3 +169,121 @@ describe('ForkActivityPanel event badge tooltip', () => {
expect(row?.getAttribute('aria-expanded')).toBe('false')
})
})

describe('ForkActivityPanel sync report', () => {
beforeEach(() => {
vi.clearAllMocks()
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
})

afterEach(() => {
act(() => root.unmount())
container.remove()
})

const syncMetadata = {
actorName: 'Brandon Tarr',
direction: 'push' as const,
otherWorkspaceId: PARTNER_ID,
otherWorkspaceName: 'another workspace',
updatedNames: ['Flow A'],
tables: 2,
files: 1,
}

function expandRow() {
const row = container.querySelector<HTMLButtonElement>('button[aria-expanded]')
if (!row) throw new Error('row is not expandable')
act(() => row.click())
}

/**
* The reported bug: a push that copied resources showed a second row, badged "Fork", for the
* background fill. The fill belongs to the push, so it reads inside the push's own row.
*/
it('shows the background copy inside the push row while it is still running', () => {
renderJobs([
makeJob({
kind: 'fork_sync',
workspaceId: WORKSPACE_ID,
status: 'processing',
metadata: syncMetadata,
}),
])

expect(container.querySelectorAll('button[aria-expanded]')).toHaveLength(1)
expect(badgeElement().textContent).toBe('Push')
expandRow()
expect(container.textContent).toContain('Copying')
expect(container.textContent).toContain('2 tables, 1 file')
})

it('shows a fill of only skills and documents, which carry no table or file count', () => {
renderJobs([
makeJob({
kind: 'fork_sync',
workspaceId: WORKSPACE_ID,
status: 'processing',
metadata: { ...syncMetadata, tables: 0, files: 0, skills: 1, documents: 2 },
}),
])

expandRow()
expect(container.textContent).toContain('Copying')
expect(container.textContent).toContain('2 documents, 1 skill')
})

it('does not call a fill copied when the row failed before it finished', () => {
renderJobs([
makeJob({
kind: 'fork_sync',
workspaceId: WORKSPACE_ID,
status: 'failed',
error: 'Background resource copy failed',
metadata: syncMetadata,
}),
])

expandRow()
expect(container.textContent).toContain('Copy failed')
expect(container.textContent).toContain('2 tables, 1 file')
expect(container.textContent).not.toContain('Copied')
})

it('surfaces a deploy that succeeded with its cutover still pending', () => {
renderJobs([
makeJob({
kind: 'fork_sync',
workspaceId: WORKSPACE_ID,
status: 'completed_with_warnings',
metadata: {
...syncMetadata,
deployWarnings: ['Flow A — prior workflow version remains active'],
},
}),
])

expandRow()
expect(container.textContent).toContain('Flow A — prior workflow version remains active')
})

it('reports the finished copy and what it lost on the same row', () => {
renderJobs([
makeJob({
kind: 'fork_sync',
workspaceId: WORKSPACE_ID,
status: 'completed_with_warnings',
message: 'Copied 2 items; 1 could not be copied',
metadata: { ...syncMetadata, copied: 2, failed: 1 },
}),
])

expandRow()
expect(container.textContent).toContain('Copied')
expect(container.textContent).not.toContain('Copying')
expect(container.textContent).toContain('2 tables, 1 file')
expect(container.textContent).toContain('1 resource failed to copy')
})
})
Loading
Loading