Skip to content

Commit eb23c6c

Browse files
committed
feat(vfs): send typed workspace snapshot for append-only deltas
Build the workspace inventory from the primary db (fixes replica-lag staleness) and emit it as a typed VfsSnapshotV1 `vfs` payload alongside the markdown, so the mothership can diff it into append-only baseline/delta messages. Generate the TS contract mirror from the Go-owned JSON schema (sync-vfs-snapshot-contract) and sort connector types so diffs stay byte-stable.
1 parent e986ce3 commit eb23c6c

8 files changed

Lines changed: 346 additions & 24 deletions

File tree

apps/sim/lib/copilot/chat/payload.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { isPaid } from '@/lib/billing/plan-helpers'
66
import { getExposedIntegrationTools } from '@/lib/copilot/integration-tools'
77
import { getToolEntry } from '@/lib/copilot/tool-executor/router'
88
import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions'
9+
import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1'
910
import { encodeVfsSegment } from '@/lib/copilot/vfs/path-utils'
1011
import { isE2BDocEnabled, isHosted } from '@/lib/core/config/env-flags'
1112
import { buildUserSkillTool } from '@/lib/mothership/skills'
@@ -33,6 +34,7 @@ interface BuildPayloadParams {
3334
prefetch?: boolean
3435
implicitFeedback?: string
3536
workspaceContext?: string
37+
vfs?: VfsSnapshotV1
3638
userPermission?: string
3739
userTimezone?: string
3840
userMetadata?: {
@@ -366,6 +368,7 @@ export async function buildCopilotRequestPayload(
366368
...(mothershipTools.length > 0 ? { mothershipTools } : {}),
367369
...(commands && commands.length > 0 ? { commands } : {}),
368370
...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}),
371+
...(params.vfs ? { vfs: params.vfs } : {}),
369372
...(params.userPermission ? { userPermission: params.userPermission } : {}),
370373
...(params.userTimezone ? { userTimezone: params.userTimezone } : {}),
371374
...(params.userMetadata &&

apps/sim/lib/copilot/chat/post.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ const getUserEntityPermissions = permissionsMockFns.mockGetUserEntityPermissions
1717

1818
const {
1919
getEffectiveDecryptedEnv,
20-
generateWorkspaceContext,
20+
generateWorkspaceSnapshot,
2121
processContextsServer,
2222
resolveActiveResourceContext,
2323
buildCopilotRequestPayload,
@@ -31,7 +31,7 @@ const {
3131
mockPublishStatusChanged,
3232
} = vi.hoisted(() => ({
3333
getEffectiveDecryptedEnv: vi.fn(),
34-
generateWorkspaceContext: vi.fn(),
34+
generateWorkspaceSnapshot: vi.fn(),
3535
processContextsServer: vi.fn(),
3636
resolveActiveResourceContext: vi.fn(),
3737
buildCopilotRequestPayload: vi.fn(),
@@ -56,7 +56,7 @@ vi.mock('@/lib/environment/utils', () => ({
5656
}))
5757

5858
vi.mock('@/lib/copilot/chat/workspace-context', () => ({
59-
generateWorkspaceContext,
59+
generateWorkspaceSnapshot,
6060
}))
6161

6262
vi.mock('@/lib/copilot/chat/process-contents', () => ({
@@ -142,7 +142,7 @@ describe('handleUnifiedChatPost', () => {
142142
})
143143
getUserEntityPermissions.mockResolvedValue('write')
144144
getEffectiveDecryptedEnv.mockResolvedValue({ API_KEY: 'secret' })
145-
generateWorkspaceContext.mockResolvedValue('workspace context')
145+
generateWorkspaceSnapshot.mockResolvedValue({ markdown: 'workspace context', snapshot: undefined })
146146
processContextsServer.mockResolvedValue([])
147147
resolveActiveResourceContext.mockResolvedValue(null)
148148
buildCopilotRequestPayload.mockImplementation(async (params: Record<string, unknown>) => params)
@@ -178,7 +178,7 @@ describe('handleUnifiedChatPost', () => {
178178
)
179179

180180
expect(response.status).toBe(200)
181-
expect(generateWorkspaceContext).toHaveBeenCalledWith('ws-1', 'user-1')
181+
expect(generateWorkspaceSnapshot).toHaveBeenCalledWith('ws-1', 'user-1')
182182
expect(buildCopilotRequestPayload).toHaveBeenCalledWith(
183183
expect.objectContaining({
184184
model: 'claude-opus-4-8',

apps/sim/lib/copilot/chat/post.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ import {
2222
resolveActiveResourceContext,
2323
} from '@/lib/copilot/chat/process-contents'
2424
import { finalizeAssistantTurn } from '@/lib/copilot/chat/terminal-state'
25-
import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context'
25+
import { generateWorkspaceSnapshot } from '@/lib/copilot/chat/workspace-context'
26+
import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1'
2627
import { chatPubSub } from '@/lib/copilot/chat-status'
2728
import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants'
2829
import {
@@ -183,6 +184,7 @@ type UnifiedChatBranch =
183184
prefetch?: boolean
184185
implicitFeedback?: string
185186
workspaceContext?: string
187+
vfs?: VfsSnapshotV1
186188
}) => Promise<Record<string, unknown>>
187189
buildExecutionContext: (params: {
188190
userId: string
@@ -210,6 +212,7 @@ type UnifiedChatBranch =
210212
userTimezone?: string
211213
userMetadata?: { name?: string; email?: string; timezone?: string }
212214
workspaceContext?: string
215+
vfs?: VfsSnapshotV1
213216
}) => Promise<Record<string, unknown>>
214217
buildExecutionContext: (params: {
215218
userId: string
@@ -898,7 +901,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
898901
? withCopilotSpan(
899902
TraceSpan.CopilotChatBuildWorkspaceContext,
900903
{ [TraceAttr.WorkspaceId]: workspaceId },
901-
() => generateWorkspaceContext(workspaceId, authenticatedUserId),
904+
() => generateWorkspaceSnapshot(workspaceId, authenticatedUserId),
902905
activeOtelRoot.context
903906
)
904907
: Promise.resolve(undefined)
@@ -943,14 +946,19 @@ export async function handleUnifiedChatPost(req: NextRequest) {
943946
activeOtelRoot.context
944947
)
945948

946-
const [agentContexts, userPermission, workspaceContext, , executionContext] =
949+
const [agentContexts, userPermission, workspaceSnapshot, , executionContext] =
947950
await Promise.all([
948951
agentContextsPromise,
949952
userPermissionPromise,
950953
workspaceContextPromise,
951954
persistUserMessagePromise,
952955
executionContextPromise,
953956
])
957+
// Both halves come from one primary-db fetch (workspace-context.ts):
958+
// `workspaceContext` is the markdown transition fallback, `vfs` is the
959+
// typed snapshot Go diffs into baseline+delta messages.
960+
const workspaceContext = workspaceSnapshot?.markdown
961+
const vfs = workspaceSnapshot?.snapshot
954962

955963
executionContext.userPermission = userPermission ?? undefined
956964

@@ -987,6 +995,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
987995
prefetch: body.prefetch,
988996
implicitFeedback: body.implicitFeedback,
989997
workspaceContext,
998+
vfs,
990999
})
9911000
: branch.buildPayload({
9921001
message: body.message,
@@ -999,6 +1008,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
9991008
userTimezone: body.userTimezone,
10001009
userMetadata,
10011010
workspaceContext,
1011+
vfs,
10021012
}),
10031013
activeOtelRoot.context
10041014
)

apps/sim/lib/copilot/chat/workspace-context.ts

Lines changed: 145 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { dbReplica } from '@sim/db'
1+
import { db } from '@sim/db'
2+
import type {
3+
VfsSnapshotV1,
4+
VfsSnapshotV1Job,
5+
VfsSnapshotV1Workflow,
6+
} from '@/lib/copilot/generated/vfs-snapshot-v1'
27
import {
38
knowledgeBase,
49
knowledgeConnector,
@@ -311,15 +316,20 @@ export function buildWorkspaceContextMd(data: WorkspaceMdData): string {
311316
* discovery rules; the LLM reads dynamic workspace state from VFS files.
312317
* The LLM never writes this file directly.
313318
*/
314-
export async function generateWorkspaceContext(
319+
// Fetch + assemble the workspace inventory data once, from the PRIMARY db
320+
// (read-your-writes: a just-edited workflow is visible immediately, so the
321+
// injected snapshot can't lag behind a `glob`). Both the markdown inventory and
322+
// the typed VFS snapshot are built from this single fetch. Returns null when the
323+
// workspace is unavailable or a fetch fails.
324+
async function buildWorkspaceMdData(
315325
workspaceId: string,
316326
userId: string
317-
): Promise<string> {
327+
): Promise<WorkspaceMdData | null> {
318328
try {
319329
await assertActiveWorkspaceAccess(workspaceId, userId)
320330
const wsRow = await getWorkspaceWithOwner(workspaceId)
321331
if (!wsRow) {
322-
return '## Workspace\n(unavailable)'
332+
return null
323333
}
324334

325335
const [
@@ -337,7 +347,7 @@ export async function generateWorkspaceContext(
337347
] = await Promise.all([
338348
getUsersWithPermissions(workspaceId),
339349

340-
dbReplica
350+
db
341351
.select({
342352
id: workflow.id,
343353
name: workflow.name,
@@ -349,7 +359,7 @@ export async function generateWorkspaceContext(
349359
.from(workflow)
350360
.where(and(eq(workflow.workspaceId, workspaceId), isNull(workflow.archivedAt))),
351361

352-
dbReplica
362+
db
353363
.select({
354364
id: workflowFolder.id,
355365
name: workflowFolder.name,
@@ -358,7 +368,7 @@ export async function generateWorkspaceContext(
358368
.from(workflowFolder)
359369
.where(and(eq(workflowFolder.workspaceId, workspaceId), isNull(workflowFolder.archivedAt))),
360370

361-
dbReplica
371+
db
362372
.select({
363373
id: knowledgeBase.id,
364374
name: knowledgeBase.name,
@@ -367,7 +377,7 @@ export async function generateWorkspaceContext(
367377
.from(knowledgeBase)
368378
.where(and(eq(knowledgeBase.workspaceId, workspaceId), isNull(knowledgeBase.deletedAt))),
369379

370-
dbReplica
380+
db
371381
.select({
372382
id: userTableDefinitions.id,
373383
name: userTableDefinitions.name,
@@ -387,7 +397,7 @@ export async function generateWorkspaceContext(
387397

388398
listCustomTools({ userId, workspaceId }),
389399

390-
dbReplica
400+
db
391401
.select({
392402
id: mcpServers.id,
393403
name: mcpServers.name,
@@ -399,7 +409,7 @@ export async function generateWorkspaceContext(
399409

400410
listSkills({ workspaceId, includeBuiltins: false }),
401411

402-
dbReplica
412+
db
403413
.select({
404414
id: workflowSchedule.id,
405415
jobTitle: workflowSchedule.jobTitle,
@@ -422,7 +432,7 @@ export async function generateWorkspaceContext(
422432
const kbIds = kbs.map((kb) => kb.id)
423433
const connectorRows =
424434
kbIds.length > 0
425-
? await dbReplica
435+
? await db
426436
.select({
427437
knowledgeBaseId: knowledgeConnector.knowledgeBaseId,
428438
connectorType: knowledgeConnector.connectorType,
@@ -459,7 +469,7 @@ export async function generateWorkspaceContext(
459469
return path
460470
}
461471

462-
return buildWorkspaceMd({
472+
return {
463473
workspace: wsRow,
464474
members,
465475
workflows: workflows.map((wf) => ({
@@ -468,7 +478,11 @@ export async function generateWorkspaceContext(
468478
})),
469479
knowledgeBases: kbs.map((kb) => ({
470480
...kb,
471-
connectorTypes: connectorTypesByKb.get(kb.id),
481+
// Sort connector types so the snapshot is order-stable: the DB query has
482+
// no ORDER BY, and the Go delta engine compares item JSON byte-wise, so
483+
// an unsorted (but unchanged) list would emit a spurious "modified"
484+
// delta and needlessly bust the prompt cache.
485+
connectorTypes: connectorTypesByKb.get(kb.id)?.sort(stableCompare),
472486
})),
473487
tables: tables.map((t) => ({ id: t.id, name: t.name, description: t.description })),
474488
files: files.map((f) => ({
@@ -499,13 +513,128 @@ export async function generateWorkspaceContext(
499513
lifecycle: j.lifecycle,
500514
sourceTaskName: j.sourceTaskName,
501515
})),
502-
})
516+
}
503517
} catch (err) {
504-
logger.error('Failed to generate workspace context', {
518+
logger.error('Failed to build workspace data', {
505519
workspaceId,
506520
error: toError(err).message,
507521
})
508-
return '## Workspace\n(unavailable)\n\n## Workflows\n(unavailable)\n\n## Knowledge Bases\n(unavailable)\n\n## Tables\n(unavailable)\n\n## Files\n(unavailable)\n\n## Connected Integrations\n(unavailable)'
522+
return null
523+
}
524+
}
525+
526+
const WORKSPACE_CONTEXT_UNAVAILABLE_MD =
527+
'## Workspace\n(unavailable)\n\n## Workflows\n(unavailable)\n\n## Knowledge Bases\n(unavailable)\n\n## Tables\n(unavailable)\n\n## Files\n(unavailable)\n\n## Connected Integrations\n(unavailable)'
528+
529+
/**
530+
* Generate WORKSPACE.md markdown from current DB state (primary db). The LLM
531+
* reads dynamic workspace state from VFS files; it never writes this file.
532+
*/
533+
export async function generateWorkspaceContext(
534+
workspaceId: string,
535+
userId: string
536+
): Promise<string> {
537+
const data = await buildWorkspaceMdData(workspaceId, userId)
538+
return data ? buildWorkspaceMd(data) : WORKSPACE_CONTEXT_UNAVAILABLE_MD
539+
}
540+
541+
/**
542+
* Build BOTH the markdown inventory and the typed VFS snapshot from a single
543+
* primary-db fetch. The snapshot is the structured form Go diffs into
544+
* baseline+delta messages; the markdown is the transition fallback. Returns null
545+
* when the workspace is unavailable.
546+
*/
547+
export async function generateWorkspaceSnapshot(
548+
workspaceId: string,
549+
userId: string
550+
): Promise<{ markdown: string; snapshot: VfsSnapshotV1 } | null> {
551+
const data = await buildWorkspaceMdData(workspaceId, userId)
552+
if (!data) return null
553+
return { markdown: buildWorkspaceMd(data), snapshot: buildVfsSnapshot(data) }
554+
}
555+
556+
/**
557+
* Map the workspace inventory data to the typed VFS snapshot contract. Pure;
558+
* mirrors buildWorkspaceMd's field selection. Resource order is irrelevant — Go
559+
* diffs by stable id, not position.
560+
*/
561+
export function buildVfsSnapshot(data: WorkspaceMdData): VfsSnapshotV1 {
562+
const workflows: VfsSnapshotV1Workflow[] = data.workflows.map((wf) => ({
563+
id: wf.id,
564+
name: wf.name,
565+
path: canonicalWorkflowVfsDir({ name: wf.name, folderPath: wf.folderPath }),
566+
...(wf.description ? { description: wf.description } : {}),
567+
...(wf.isDeployed ? { isDeployed: true } : {}),
568+
...(wf.folderPath ? { folderPath: wf.folderPath } : {}),
569+
}))
570+
const jobs: VfsSnapshotV1Job[] = (data.jobs ?? [])
571+
.filter((j) => j.status !== 'completed')
572+
.map((j) => ({
573+
id: j.id,
574+
...(j.title ? { title: j.title } : {}),
575+
...(j.prompt ? { prompt: j.prompt } : {}),
576+
...(j.cronExpression ? { cronExpression: j.cronExpression } : {}),
577+
...(j.status ? { status: j.status } : {}),
578+
...(j.lifecycle ? { lifecycle: j.lifecycle } : {}),
579+
...(j.sourceTaskName ? { sourceTaskName: j.sourceTaskName } : {}),
580+
}))
581+
return {
582+
...(data.workspace
583+
? {
584+
workspace: {
585+
id: data.workspace.id,
586+
name: data.workspace.name,
587+
...(data.workspace.ownerId ? { ownerId: data.workspace.ownerId } : {}),
588+
},
589+
}
590+
: {}),
591+
members: data.members.map((m) => ({
592+
...(m.name ? { name: m.name } : {}),
593+
email: m.email,
594+
...(m.permissionType ? { permissionType: m.permissionType } : {}),
595+
})),
596+
workflows,
597+
knowledgeBases: data.knowledgeBases.map((kb) => ({
598+
id: kb.id,
599+
name: kb.name,
600+
...(kb.description ? { description: kb.description } : {}),
601+
...(kb.connectorTypes && kb.connectorTypes.length > 0
602+
? { connectorTypes: kb.connectorTypes }
603+
: {}),
604+
})),
605+
tables: data.tables.map((t) => ({
606+
id: t.id,
607+
name: t.name,
608+
...(t.description ? { description: t.description } : {}),
609+
})),
610+
files: data.files.map((f) => ({
611+
id: f.id,
612+
name: f.name,
613+
path: canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }),
614+
...(f.type ? { type: f.type } : {}),
615+
...(f.size ? { size: f.size } : {}),
616+
...(f.folderPath ? { folderPath: f.folderPath } : {}),
617+
})),
618+
integrations: data.oauthIntegrations.map((c) => ({
619+
id: c.id,
620+
providerId: c.providerId,
621+
...(c.displayName ? { displayName: c.displayName } : {}),
622+
...(c.role ? { role: c.role } : {}),
623+
})),
624+
envVars: data.envVariables,
625+
customTools: (data.customTools ?? []).map((t) => ({ id: t.id, name: t.name })),
626+
mcpServers: (data.mcpServers ?? []).map((s) => ({
627+
id: s.id,
628+
name: s.name,
629+
...(s.url ? { url: s.url } : {}),
630+
...(s.enabled ? { enabled: true } : {}),
631+
})),
632+
skills: (data.skills ?? []).map((s) => ({
633+
id: s.id,
634+
name: s.name,
635+
...(s.description ? { description: s.description } : {}),
636+
})),
637+
jobs,
509638
}
510639
}
511640

0 commit comments

Comments
 (0)