Skip to content

Commit 888c625

Browse files
committed
Merge remote-tracking branch 'origin/staging' into feat/cli-update-notifier
2 parents c22df05 + c261ac1 commit 888c625

22 files changed

Lines changed: 845 additions & 149 deletions

File tree

.agents/skills/ship/SKILL.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,10 @@ When the user runs `/ship`:
6666
# Runs every audit CI runs, concurrently, and replays the output of any that fail.
6767
# The audit list is derived in scripts/run-audits.ts — do not hand-list audits here.
6868
bun run check:audits || { echo "❌ audit(s) failed — do not ship"; exit 1; }
69+
# CI's "Verify docs manifest is in sync" step is not a `check:*` script, so the runner above
70+
# does not cover it. (CI's "Security audit" `bun audit` step is `continue-on-error` — advisory
71+
# only, not a gate — so it is deliberately not run here.)
72+
bun run docs-manifest:check || { echo "❌ docs manifest out of sync — do not ship"; exit 1; }
6973
```
7074
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.
7175
7. **Stage and commit** the changes with the generated message — including any files Phase A regenerated in step 6

apps/docs/content/docs/platform/enterprise/forks.mdx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,7 @@ The setting belongs to **this workspace's copy** only. Excluding a workflow here
136136

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

139-
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).
139+
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.
140140

141141
---
142142

@@ -331,6 +331,8 @@ Servers that **publish workflows as MCP tools**.
331331
| **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**. |
332332
| **Sync** | Map source key names to target key names. Values stay in each workspace. Unmapped required secrets block Sync. |
333333

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

336338
---

apps/sim/app/api/workspaces/[id]/fork/promote/route.test.ts

Lines changed: 48 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Tests for the fork sync (promote) route's error projection.
2+
* Tests for the fork sync (promote) route's error projection and input mapping.
33
*
44
* `promoteFork` returns its deliberate refusals as a `blocked` result, but a classified
55
* failure raised deeper in the copy — the target workspace's folder ceiling being full —
@@ -12,22 +12,19 @@ import { auditMock, authMockFns, createMockRequest, type MockUser } from '@sim/t
1212
import { beforeEach, describe, expect, it, vi } from 'vitest'
1313
import { FolderCollectionFullError } from '@/lib/folders/errors'
1414

15-
const { mockLogger, mockPromoteFork, mockAssertCanPromote, mockRecordBackgroundWork } = vi.hoisted(
16-
() => ({
17-
mockLogger: {
18-
info: vi.fn(),
19-
warn: vi.fn(),
20-
error: vi.fn(),
21-
debug: vi.fn(),
22-
trace: vi.fn(),
23-
fatal: vi.fn(),
24-
child: vi.fn(),
25-
},
26-
mockPromoteFork: vi.fn(),
27-
mockAssertCanPromote: vi.fn(),
28-
mockRecordBackgroundWork: vi.fn(),
29-
})
30-
)
15+
const { mockLogger, mockPromoteFork, mockAssertCanPromote } = vi.hoisted(() => ({
16+
mockLogger: {
17+
info: vi.fn(),
18+
warn: vi.fn(),
19+
error: vi.fn(),
20+
debug: vi.fn(),
21+
trace: vi.fn(),
22+
fatal: vi.fn(),
23+
child: vi.fn(),
24+
},
25+
mockPromoteFork: vi.fn(),
26+
mockAssertCanPromote: vi.fn(),
27+
}))
3128

3229
vi.mock('@sim/audit', () => auditMock)
3330
vi.mock('@sim/logger', () => ({
@@ -39,9 +36,6 @@ vi.mock('@/ee/workspace-forking/lib/promote/promote', () => ({ promoteFork: mock
3936
vi.mock('@/ee/workspace-forking/lib/lineage/authz', () => ({
4037
assertCanPromote: mockAssertCanPromote,
4138
}))
42-
vi.mock('@/ee/workspace-forking/lib/background-work/store', () => ({
43-
recordBackgroundWork: mockRecordBackgroundWork,
44-
}))
4539

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

@@ -69,8 +63,41 @@ describe('POST /api/workspaces/[id]/fork/promote', () => {
6963
edge: { childWorkspaceId: WORKSPACE_ID },
7064
sourceWorkspaceId: WORKSPACE_ID,
7165
targetWorkspaceId: 'ws-parent',
66+
source: { name: 'Child' },
67+
target: { name: 'Parent' },
68+
})
69+
})
70+
71+
/**
72+
* The sync's Activity row is recorded by the use case, not here, so the route's job is to
73+
* hand it the one thing only the route knows: the display name of the edge's other side.
74+
*/
75+
it('names the other side of the edge for promoteFork to record the sync', async () => {
76+
mockPromoteFork.mockResolvedValue({
77+
promoteRunId: 'run-1',
78+
updated: 1,
79+
created: 0,
80+
archived: 0,
81+
redeployed: 1,
82+
deployFailed: 0,
83+
unmappedRequired: [],
84+
blockers: [],
85+
blocked: null,
86+
updatedNames: ['Flow'],
87+
createdNames: [],
88+
archivedNames: [],
89+
needsConfiguration: [],
90+
clearedOptional: [],
91+
droppedReferences: [],
92+
triggerUrlChanges: [],
7293
})
73-
mockRecordBackgroundWork.mockResolvedValue(undefined)
94+
95+
const response = await POST(promoteRequest(), routeContext)
96+
97+
expect(response.status).toBe(200)
98+
expect(mockPromoteFork).toHaveBeenCalledWith(
99+
expect.objectContaining({ direction: 'push', actorName: 'A', otherWorkspaceName: 'Parent' })
100+
)
74101
})
75102

76103
it('renders a full-folder-tree refusal as an actionable 409', async () => {

apps/sim/app/api/workspaces/[id]/fork/promote/route.ts

Lines changed: 3 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,12 @@
11
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
2-
import { db } from '@sim/db'
32
import { createLogger } from '@sim/logger'
4-
import { getErrorMessage } from '@sim/utils/errors'
53
import { type NextRequest, NextResponse } from 'next/server'
64
import { promoteForkContract } from '@/lib/api/contracts/workspace-fork'
75
import { parseRequest } from '@/lib/api/server'
86
import { getSession } from '@/lib/auth'
97
import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types'
108
import { generateRequestId } from '@/lib/core/utils/request'
119
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
12-
import { recordBackgroundWork } from '@/ee/workspace-forking/lib/background-work/store'
1310
import { assertCanPromote } from '@/ee/workspace-forking/lib/lineage/authz'
1411
import { promoteFork } from '@/ee/workspace-forking/lib/promote/promote'
1512

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

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

4039
let result: Awaited<ReturnType<typeof promoteFork>>
4140
try {
@@ -46,6 +45,7 @@ export const POST = withRouteHandler(
4645
direction,
4746
userId: session.user.id,
4847
actorName: session.user.name ?? undefined,
48+
otherWorkspaceName: otherName,
4949
dependentValues,
5050
copyResources,
5151
dropReferences,
@@ -114,44 +114,6 @@ export const POST = withRouteHandler(
114114
request: req,
115115
})
116116

117-
const otherName =
118-
otherWorkspaceId === auth.sourceWorkspaceId ? auth.source.name : auth.target.name
119-
await recordBackgroundWork(db, {
120-
workspaceId: id,
121-
kind: 'fork_sync',
122-
status:
123-
result.deployFailed > 0 ||
124-
result.needsConfiguration.length > 0 ||
125-
result.clearedOptional.length > 0 ||
126-
result.droppedReferences.length > 0 ||
127-
result.triggerUrlChanges.length > 0
128-
? 'completed_with_warnings'
129-
: 'completed',
130-
message: direction === 'pull' ? `Pulled from "${otherName}"` : `Pushed to "${otherName}"`,
131-
metadata: {
132-
actorName: session.user.name ?? undefined,
133-
otherWorkspaceId,
134-
otherWorkspaceName: otherName,
135-
direction,
136-
updated: result.updated,
137-
created: result.created,
138-
archived: result.archived,
139-
redeployed: result.redeployed,
140-
deployFailed: result.deployFailed,
141-
updatedNames: result.updatedNames,
142-
createdNames: result.createdNames,
143-
archivedNames: result.archivedNames,
144-
needsConfiguration: result.needsConfiguration,
145-
clearedOptional: result.clearedOptional,
146-
droppedReferences: result.droppedReferences.length,
147-
triggerUrlChanges: result.triggerUrlChanges.length,
148-
},
149-
}).catch((error) =>
150-
logger.error(`[${requestId}] Failed to record sync activity`, {
151-
error: getErrorMessage(error),
152-
})
153-
)
154-
155117
return NextResponse.json(body)
156118
}
157119
)

apps/sim/ee/workspace-forking/components/fork-activity-panel/fork-activity-panel.test.tsx

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,3 +169,121 @@ describe('ForkActivityPanel event badge tooltip', () => {
169169
expect(row?.getAttribute('aria-expanded')).toBe('false')
170170
})
171171
})
172+
173+
describe('ForkActivityPanel sync report', () => {
174+
beforeEach(() => {
175+
vi.clearAllMocks()
176+
container = document.createElement('div')
177+
document.body.appendChild(container)
178+
root = createRoot(container)
179+
})
180+
181+
afterEach(() => {
182+
act(() => root.unmount())
183+
container.remove()
184+
})
185+
186+
const syncMetadata = {
187+
actorName: 'Brandon Tarr',
188+
direction: 'push' as const,
189+
otherWorkspaceId: PARTNER_ID,
190+
otherWorkspaceName: 'another workspace',
191+
updatedNames: ['Flow A'],
192+
tables: 2,
193+
files: 1,
194+
}
195+
196+
function expandRow() {
197+
const row = container.querySelector<HTMLButtonElement>('button[aria-expanded]')
198+
if (!row) throw new Error('row is not expandable')
199+
act(() => row.click())
200+
}
201+
202+
/**
203+
* The reported bug: a push that copied resources showed a second row, badged "Fork", for the
204+
* background fill. The fill belongs to the push, so it reads inside the push's own row.
205+
*/
206+
it('shows the background copy inside the push row while it is still running', () => {
207+
renderJobs([
208+
makeJob({
209+
kind: 'fork_sync',
210+
workspaceId: WORKSPACE_ID,
211+
status: 'processing',
212+
metadata: syncMetadata,
213+
}),
214+
])
215+
216+
expect(container.querySelectorAll('button[aria-expanded]')).toHaveLength(1)
217+
expect(badgeElement().textContent).toBe('Push')
218+
expandRow()
219+
expect(container.textContent).toContain('Copying')
220+
expect(container.textContent).toContain('2 tables, 1 file')
221+
})
222+
223+
it('shows a fill of only skills and documents, which carry no table or file count', () => {
224+
renderJobs([
225+
makeJob({
226+
kind: 'fork_sync',
227+
workspaceId: WORKSPACE_ID,
228+
status: 'processing',
229+
metadata: { ...syncMetadata, tables: 0, files: 0, skills: 1, documents: 2 },
230+
}),
231+
])
232+
233+
expandRow()
234+
expect(container.textContent).toContain('Copying')
235+
expect(container.textContent).toContain('2 documents, 1 skill')
236+
})
237+
238+
it('does not call a fill copied when the row failed before it finished', () => {
239+
renderJobs([
240+
makeJob({
241+
kind: 'fork_sync',
242+
workspaceId: WORKSPACE_ID,
243+
status: 'failed',
244+
error: 'Background resource copy failed',
245+
metadata: syncMetadata,
246+
}),
247+
])
248+
249+
expandRow()
250+
expect(container.textContent).toContain('Copy failed')
251+
expect(container.textContent).toContain('2 tables, 1 file')
252+
expect(container.textContent).not.toContain('Copied')
253+
})
254+
255+
it('surfaces a deploy that succeeded with its cutover still pending', () => {
256+
renderJobs([
257+
makeJob({
258+
kind: 'fork_sync',
259+
workspaceId: WORKSPACE_ID,
260+
status: 'completed_with_warnings',
261+
metadata: {
262+
...syncMetadata,
263+
deployWarnings: ['Flow A — prior workflow version remains active'],
264+
},
265+
}),
266+
])
267+
268+
expandRow()
269+
expect(container.textContent).toContain('Flow A — prior workflow version remains active')
270+
})
271+
272+
it('reports the finished copy and what it lost on the same row', () => {
273+
renderJobs([
274+
makeJob({
275+
kind: 'fork_sync',
276+
workspaceId: WORKSPACE_ID,
277+
status: 'completed_with_warnings',
278+
message: 'Copied 2 items; 1 could not be copied',
279+
metadata: { ...syncMetadata, copied: 2, failed: 1 },
280+
}),
281+
])
282+
283+
expandRow()
284+
expect(container.textContent).toContain('Copied')
285+
expect(container.textContent).not.toContain('Copying')
286+
expect(container.textContent).toContain('2 tables, 1 file')
287+
expect(container.textContent).toContain('1 resource failed to copy')
288+
})
289+
})

0 commit comments

Comments
 (0)