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'
27import {
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