Skip to content

Commit 6d047b5

Browse files
committed
VFS updates and linter
1 parent 6090b3b commit 6d047b5

15 files changed

Lines changed: 1292 additions & 378 deletions

File tree

apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -722,8 +722,11 @@ function resolveToolDisplayTitle(name: string, args?: Record<string, unknown>):
722722
}
723723

724724
if (name === QueryLogs.id) {
725-
const workflowName = resolveWorkflowNameForDisplay(args.workflowId)
726-
return workflowName ? `Querying logs for ${workflowName}` : 'Querying logs'
725+
const workflowName =
726+
resolveWorkflowNameForDisplay(args.workflowId) ?? stringParam(args.workflowName)
727+
// Fall back to the server-provided title (which is workflow-scoped when the
728+
// request is attached to a workflow) instead of a generic label.
729+
return workflowName ? `Querying logs for ${workflowName}` : undefined
727730
}
728731

729732
return undefined

apps/sim/lib/copilot/tools/handlers/materialize-file.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,14 @@ export async function executeMaterializeFile(
213213
}
214214

215215
const operation = (params.operation as string | undefined) || 'save'
216+
// Only save/import are implemented. Reject anything else with guidance instead of
217+
// silently falling back to save (table/knowledge_base are handled by their subagents).
218+
if (operation !== 'save' && operation !== 'import') {
219+
return {
220+
success: false,
221+
error: `Unsupported materialize_file operation "${operation}". Use "save" or "import". For CSV/TSV/JSON → use the table subagent; for documents → use the knowledge subagent.`,
222+
}
223+
}
216224
const succeeded: string[] = []
217225
const failed: Array<{ fileName: string; error: string }> = []
218226

apps/sim/lib/copilot/tools/handlers/vfs.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,4 +382,32 @@ describe('vfs uploads are opt-in (like recently-deleted/)', () => {
382382
expect(listChatUploads).not.toHaveBeenCalled()
383383
expect((broad.output as { files: string[] }).files).not.toContain('uploads/My%20Report.json')
384384
})
385+
386+
it('reads an upload directly, tolerating a spurious /content suffix', async () => {
387+
const vfs = makeVfs()
388+
getOrMaterializeVFS.mockResolvedValue(vfs)
389+
readChatUpload.mockResolvedValue({ content: 'hello upload', totalLines: 1 })
390+
391+
const bare = await executeVfsRead({ path: 'uploads/report.csv' }, GREP_CTX_CHAT)
392+
expect(bare.success).toBe(true)
393+
expect(readChatUpload).toHaveBeenLastCalledWith('report.csv', 'chat-1')
394+
395+
// The model adds /content out of habit (from files/) — it must still resolve.
396+
const withContent = await executeVfsRead({ path: 'uploads/report.csv/content' }, GREP_CTX_CHAT)
397+
expect(withContent.success).toBe(true)
398+
expect(readChatUpload).toHaveBeenLastCalledWith('report.csv', 'chat-1')
399+
})
400+
401+
it('tolerates a trailing /content on an uploads grep path', async () => {
402+
grepChatUpload.mockResolvedValue([])
403+
404+
await executeVfsGrep({ pattern: 'x', path: 'uploads/report.json/content' }, GREP_CTX_CHAT)
405+
406+
expect(grepChatUpload).toHaveBeenCalledWith(
407+
'report.json',
408+
'chat-1',
409+
'x',
410+
expect.any(Object)
411+
)
412+
})
385413
})

apps/sim/lib/copilot/tools/handlers/vfs.ts

Lines changed: 8 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,9 @@ export async function executeVfsGrep(
104104
if (!context.chatId) {
105105
return { success: false, error: 'No chat context available for uploads/' }
106106
}
107-
const filename = rawPath.replace(/^\/+/, '').replace(/^uploads\/?/, '')
107+
// The upload is the first segment after uploads/; any trailing segment
108+
// (e.g. a /content suffix) is ignored, mirroring the uploads read path.
109+
const filename = rawPath.replace(/^\/+/, '').replace(/^uploads\/?/, '').split('/')[0]
108110
if (!filename) {
109111
return {
110112
success: false,
@@ -230,12 +232,15 @@ export async function executeVfsRead(
230232
}
231233
}
232234

233-
// Handle chat-scoped uploads via the uploads/ virtual prefix
235+
// Handle chat-scoped uploads via the uploads/ virtual prefix.
236+
// Uploads are flat and have no metadata/content split like files/ — the upload
237+
// IS the first path segment after uploads/. Any trailing segment (e.g. a
238+
// /content suffix added out of habit) is ignored so the read resolves either way.
234239
if (path.startsWith('uploads/')) {
235240
if (!context.chatId) {
236241
return { success: false, error: 'No chat context available for uploads/' }
237242
}
238-
const filename = path.slice('uploads/'.length)
243+
const filename = path.slice('uploads/'.length).split('/')[0]
239244
const uploadResult = await readChatUpload(filename, context.chatId)
240245
if (uploadResult) {
241246
const isAttachment = hasModelAttachment(uploadResult)
@@ -350,31 +355,3 @@ export async function executeVfsRead(
350355
return { success: false, error: getErrorMessage(err, 'vfs_read failed') }
351356
}
352357
}
353-
354-
async function executeVfsList(
355-
params: Record<string, unknown>,
356-
context: ExecutionContext
357-
): Promise<ToolCallResult> {
358-
const path = params.path as string | undefined
359-
if (!path) {
360-
return { success: false, error: "Missing required parameter 'path'" }
361-
}
362-
363-
const workspaceId = context.workspaceId
364-
if (!workspaceId) {
365-
return { success: false, error: 'No workspace context available' }
366-
}
367-
368-
try {
369-
const vfs = await getOrMaterializeVFS(workspaceId, context.userId)
370-
const entries = vfs.list(path)
371-
logger.debug('vfs_list result', { path, entryCount: entries.length })
372-
return { success: true, output: { entries } }
373-
} catch (err) {
374-
logger.error('vfs_list failed', {
375-
path,
376-
error: toError(err).message,
377-
})
378-
return { success: false, error: getErrorMessage(err, 'vfs_list failed') }
379-
}
380-
}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { describe, expect, it } from 'vitest'
2+
import { computeWorkflowDag } from './dag'
3+
4+
function block(id: string, name: string, type = 'function') {
5+
return { id, type, name }
6+
}
7+
8+
describe('computeWorkflowDag', () => {
9+
it('builds a linear adjacency with sinks as empty arrays', () => {
10+
const dag = computeWorkflowDag({
11+
blocks: { a: block('a', 'A', 'starter'), b: block('b', 'B'), c: block('c', 'C') },
12+
edges: [
13+
{ source: 'a', target: 'b' },
14+
{ source: 'b', target: 'c' },
15+
],
16+
} as any)
17+
18+
expect(dag).toEqual({ A: ['B'], B: ['C'], C: [] })
19+
})
20+
21+
it('captures condition branches (if/else) as downstream names', () => {
22+
const dag = computeWorkflowDag({
23+
blocks: {
24+
cond: block('cond', 'Cond', 'condition'),
25+
x: block('x', 'X'),
26+
y: block('y', 'Y'),
27+
},
28+
edges: [
29+
{ source: 'cond', sourceHandle: 'if', target: 'x' },
30+
{ source: 'cond', sourceHandle: 'else', target: 'y' },
31+
],
32+
} as any)
33+
34+
expect(dag.Cond).toEqual(['X', 'Y'])
35+
expect(dag.X).toEqual([])
36+
expect(dag.Y).toEqual([])
37+
})
38+
39+
it('captures router routes (sorted)', () => {
40+
const dag = computeWorkflowDag({
41+
blocks: {
42+
r: block('r', 'Router', 'router_v2'),
43+
s: block('s', 'Support'),
44+
sa: block('sa', 'Sales'),
45+
},
46+
edges: [
47+
{ source: 'r', sourceHandle: 'route-0', target: 's' },
48+
{ source: 'r', sourceHandle: 'route-1', target: 'sa' },
49+
],
50+
} as any)
51+
52+
expect(dag.Router).toEqual(['Sales', 'Support'])
53+
})
54+
55+
it('captures subflow container edges (loop-start-source / loop-end-source)', () => {
56+
const dag = computeWorkflowDag({
57+
blocks: {
58+
loop: block('loop', 'Loop', 'loop'),
59+
child: block('child', 'Child'),
60+
after: block('after', 'After'),
61+
},
62+
edges: [
63+
{ source: 'loop', sourceHandle: 'loop-start-source', target: 'child' },
64+
{ source: 'loop', sourceHandle: 'loop-end-source', target: 'after' },
65+
],
66+
} as any)
67+
68+
expect(dag.Loop).toEqual(['After', 'Child'])
69+
expect(dag.Child).toEqual([])
70+
expect(dag.After).toEqual([])
71+
})
72+
73+
it('excludes note blocks, dedups parallel edges, and ignores edges to missing blocks', () => {
74+
const dag = computeWorkflowDag({
75+
blocks: {
76+
a: block('a', 'A', 'starter'),
77+
x: block('x', 'X'),
78+
note: block('note', 'Note', 'note'),
79+
},
80+
edges: [
81+
{ source: 'a', target: 'x' },
82+
{ source: 'a', sourceHandle: 'error', target: 'x' }, // duplicate target
83+
{ source: 'a', target: 'note' }, // note is excluded
84+
{ source: 'a', target: 'ghost' }, // missing block
85+
],
86+
} as any)
87+
88+
expect(dag.A).toEqual(['X'])
89+
expect(dag.X).toEqual([])
90+
expect(dag.Note).toBeUndefined()
91+
})
92+
})
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import type { WorkflowState } from '@/stores/workflows/workflow/types'
2+
3+
type DagBlockState = {
4+
id?: string
5+
type?: string
6+
name?: string
7+
}
8+
9+
type DagEdgeState = {
10+
source?: string | null
11+
target?: string | null
12+
}
13+
14+
/**
15+
* Compute the workflow's downstream adjacency: each non-note block name mapped
16+
* to the sorted, de-duplicated names of the blocks it connects to.
17+
*
18+
* Because conditions, routers, and subflow containers all encode their fan-out
19+
* as real edges (`if`/`else`, `route-N`, `loop-start-source`/`loop-end-source`,
20+
* `error`), a plain edge-based adjacency captures them correctly. Every non-note
21+
* block appears as a key; sink blocks map to `[]`. Edges to/from note blocks or
22+
* missing blocks are ignored, as are self-edges (in the name view).
23+
*/
24+
export function computeWorkflowDag(
25+
workflowState: Pick<WorkflowState, 'blocks' | 'edges'>
26+
): Record<string, string[]> {
27+
const blocks = (workflowState.blocks || {}) as Record<string, DagBlockState>
28+
const edges = Array.isArray(workflowState.edges)
29+
? (workflowState.edges as DagEdgeState[])
30+
: ([] as DagEdgeState[])
31+
32+
const nameById = new Map<string, string>()
33+
for (const [blockId, block] of Object.entries(blocks)) {
34+
if (block?.type === 'note') continue
35+
nameById.set(blockId, block?.name || blockId)
36+
}
37+
38+
const downstream = new Map<string, Set<string>>()
39+
for (const name of nameById.values()) {
40+
if (!downstream.has(name)) downstream.set(name, new Set<string>())
41+
}
42+
43+
for (const edge of edges) {
44+
const sourceName = nameById.get(edge?.source || '')
45+
const targetName = nameById.get(edge?.target || '')
46+
if (!sourceName || !targetName) continue
47+
if (sourceName === targetName) continue
48+
downstream.get(sourceName)?.add(targetName)
49+
}
50+
51+
const result: Record<string, string[]> = {}
52+
for (const [name, targets] of downstream) {
53+
result[name] = Array.from(targets).sort()
54+
}
55+
return result
56+
}

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts

Lines changed: 40 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,20 @@ import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-ch
3131
import { generateLoopBlocks, generateParallelBlocks } from '@/stores/workflows/workflow/utils'
3232
import { normalizeWorkflowState } from '@/stores/workflows/workflow/validation'
3333
import { applyOperationsToWorkflowState } from './engine'
34-
import { formatWorkflowLintMessage, hasWorkflowLintIssues, lintEditedWorkflowState } from './lint'
34+
import {
35+
collectWorkflowFieldIssues,
36+
formatWorkflowLintMessage,
37+
hasWorkflowLintIssues,
38+
lintEditedWorkflowState,
39+
type WorkflowLintReport,
40+
type WorkflowLintUnresolvedReference,
41+
} from './lint'
3542
import { type EditWorkflowParams, isDeferredSkippedItem, type ValidationError } from './types'
36-
import { preValidateCredentialInputs, validateWorkflowSelectorIds } from './validation'
43+
import {
44+
collectUnresolvedReferences,
45+
preValidateCredentialInputs,
46+
UNRESOLVABLE_AT_LINT_NOTE,
47+
} from './validation'
3748

3849
async function getCurrentWorkflowStateFromDb(
3950
workflowId: string
@@ -149,14 +160,26 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
149160
// Add credential validation errors
150161
validationErrors.push(...credentialErrors)
151162

152-
// Validate selector IDs exist in the database
163+
// Resolve credential/resource references against the workspace (Tier 2).
164+
// Includes oauth-input credentials and only the active canonical member, so a
165+
// credential "set in basic mode but unresolved in the dropdown" is caught.
166+
let unresolvedReferences: WorkflowLintUnresolvedReference[] = []
153167
if (context?.userId) {
154168
try {
155-
const selectorErrors = await validateWorkflowSelectorIds(modifiedWorkflowState, {
169+
unresolvedReferences = await collectUnresolvedReferences(modifiedWorkflowState, {
156170
userId: context.userId,
157171
workspaceId,
158172
})
159-
validationErrors.push(...selectorErrors)
173+
// Back-compat: also surface unresolved references through the input-validation channel.
174+
validationErrors.push(
175+
...unresolvedReferences.map((ref) => ({
176+
blockId: ref.blockId,
177+
blockType: ref.blockType ?? 'unknown',
178+
field: ref.field,
179+
value: ref.value,
180+
error: ref.reason,
181+
}))
182+
)
160183
} catch (error) {
161184
logger.warn('Selector ID validation failed', {
162185
error: toError(error).message,
@@ -286,7 +309,16 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
286309
isDeployed: false,
287310
}
288311

289-
const workflowLint = lintEditedWorkflowState(workflowStateForDb as any)
312+
// Aggregate lint report: graph (sources/sinks/orphans/ports) + Tier-1 config
313+
// (required + canonical-mode) + Tier-2 resolution (credential/resource IDs).
314+
const graphLint = lintEditedWorkflowState(workflowStateForDb as any)
315+
const fieldIssues = collectWorkflowFieldIssues(workflowStateForDb.blocks as any)
316+
const workflowLint: WorkflowLintReport = {
317+
...graphLint,
318+
fieldIssues,
319+
unresolvedReferences,
320+
notes: unresolvedReferences.length > 0 ? [UNRESOLVABLE_AT_LINT_NOTE] : [],
321+
}
290322
const workflowLintMessage = hasWorkflowLintIssues(workflowLint)
291323
? formatWorkflowLintMessage(workflowLint)
292324
: undefined
@@ -331,10 +363,8 @@ export const editWorkflowServerTool: BaseServerTool<EditWorkflowParams, unknown>
331363
workflowId,
332364
workflowName: workflowName ?? 'Workflow',
333365
workflowState: { ...finalWorkflowState, blocks: layoutedBlocks },
334-
...(workflowLintMessage && {
335-
workflowLint,
336-
workflowLintMessage,
337-
}),
366+
workflowLint,
367+
...(workflowLintMessage && { workflowLintMessage }),
338368
...(inputErrors && {
339369
inputValidationErrors: inputErrors,
340370
inputValidationMessage: `${inputErrors.length} input(s) were rejected due to validation errors. The workflow was still updated with valid inputs only. Errors: ${inputErrors.join('; ')}`,

apps/sim/lib/copilot/tools/server/workflow/edit-workflow/lint.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,11 +163,42 @@ describe('lintEditedWorkflowState', () => {
163163
const lint = lintEditedWorkflowState(workflowState as any)
164164

165165
expect(lint).toEqual({
166+
sources: [{ blockId: 'start', blockName: 'Start', blockType: 'starter' }],
167+
sinks: [{ blockId: 'agent', blockName: 'Agent', blockType: 'agent' }],
166168
orphanBlocks: [],
167169
emptyOutgoingPorts: [],
168170
invalidBranchPorts: [],
169171
invalidConnectionTargets: [],
170172
})
171173
expect(hasWorkflowLintIssues(lint)).toBe(false)
172174
})
175+
176+
it('reports sources and sinks (triggers are sources, terminals are sinks, notes excluded)', () => {
177+
const workflowState = {
178+
blocks: {
179+
start: baseBlock('start', 'starter', 'Start'),
180+
agent: baseBlock('agent', 'agent', 'Agent'),
181+
end: baseBlock('end', 'function', 'End'),
182+
note: baseBlock('note', 'note', 'Note'),
183+
},
184+
edges: [
185+
{ id: 'e1', source: 'start', sourceHandle: 'source', target: 'agent', targetHandle: 'target' },
186+
{ id: 'e2', source: 'agent', sourceHandle: 'source', target: 'end', targetHandle: 'target' },
187+
],
188+
}
189+
190+
const lint = lintEditedWorkflowState(workflowState as any)
191+
192+
// 'start' has no incoming edge -> a source, even though it is NOT an orphan (trigger).
193+
expect(lint.sources).toEqual([{ blockId: 'start', blockName: 'Start', blockType: 'starter' }])
194+
expect(lint.orphanBlocks).toEqual([])
195+
// 'end' has no outgoing edge -> a sink.
196+
expect(lint.sinks).toEqual([{ blockId: 'end', blockName: 'End', blockType: 'function' }])
197+
// 'agent' has both in and out edges -> neither source nor sink.
198+
expect(lint.sources.map((b) => b.blockId)).not.toContain('agent')
199+
expect(lint.sinks.map((b) => b.blockId)).not.toContain('agent')
200+
// 'note' is excluded from both even though it has no edges.
201+
expect(lint.sources.map((b) => b.blockId)).not.toContain('note')
202+
expect(lint.sinks.map((b) => b.blockId)).not.toContain('note')
203+
})
173204
})

0 commit comments

Comments
 (0)