Skip to content

Commit fdd0e95

Browse files
committed
Encode field-observed needs: trace rollup, env-token audit, dangling-ref lint
Three right-layer fixes from the AskRVT field study. The core lint now catches dangling block-output references (kind block-output) — the silent class where an API body or agent prompt ships literal template text while apply reported unresolved: [] and a desk went dark in prod; pure graph check, every caller, code fields excluded (runtime fails those loudly), Slack-link/comparison shapes guarded, v2 contract enum widened. The workflow lint augmentation adds an undeclared {{TOKEN}} audit (a missing token resolves to empty string at run time — the trap that masked a production error monitor). New workflow trace <runId> augmentation rolls up a run: recursive span walk (shallow walks silently dropped subworkflow blocks), per-type stats, real-error filtering, slowest blocks. All three live-verified through the agent. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w
1 parent 2795728 commit fdd0e95

7 files changed

Lines changed: 382 additions & 5 deletions

File tree

apps/sim/lib/api/contracts/v2/workflows.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2520,7 +2520,7 @@ const v2WorkflowLintSchema = z
25202520
.union([z.string(), z.array(z.string())])
25212521
.describe('The reference, or references, that did not resolve.'),
25222522
kind: z
2523-
.enum(['credential', 'resource', 'custom-tool', 'mcp-tool', 'skill'])
2523+
.enum(['credential', 'resource', 'custom-tool', 'mcp-tool', 'skill', 'block-output'])
25242524
.describe('What kind of entity the reference was expected to name.'),
25252525
reason: z.string().describe('Why the reference does not resolve.'),
25262526
})

apps/sim/lib/mothership/tools/handlers/agent-cli/commands/lint.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { WorkflowState } from '@sim/workflow-types/workflow'
22
import { fetchWorkflowState } from '@/lib/mothership/tools/handlers/agent-cli/commands/workflow-views'
33
import {
44
type AgentCliCommand,
5+
type AgentCliRuntime,
56
agentCliFail,
67
agentCliOk,
78
} from '@/lib/mothership/tools/handlers/agent-cli/types'
@@ -31,9 +32,67 @@ export const workflowLintCommand: AgentCliCommand = {
3132
workspaceId: runtime.workspaceId,
3233
subjectUserId: runtime.userId,
3334
})
35+
const undeclaredEnvVars = await collectUndeclaredEnvVars(runtime, graph)
3436
const summary = hasWorkflowLintIssues(report)
3537
? formatWorkflowLintMessage(report)
36-
: 'No lint issues found.'
37-
return agentCliOk(JSON.stringify({ summary, ...report }, null, 2))
38+
: undeclaredEnvVars.length > 0
39+
? `Undeclared environment variables referenced: ${undeclaredEnvVars.map((v) => v.name).join(', ')} — an unresolved {{TOKEN}} resolves to an EMPTY STRING at run time, not an error.`
40+
: 'No lint issues found.'
41+
return agentCliOk(JSON.stringify({ summary, undeclaredEnvVars, ...report }, null, 2))
3842
},
3943
}
44+
45+
const ENV_TOKEN = /\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g
46+
47+
function envTokenNames(value: unknown, out: Map<string, Set<string>>, blockName: string): void {
48+
if (typeof value === 'string') {
49+
for (const match of value.matchAll(ENV_TOKEN)) {
50+
if (!match[1]) continue
51+
const blocks = out.get(match[1]) ?? new Set<string>()
52+
blocks.add(blockName)
53+
out.set(match[1], blocks)
54+
}
55+
} else if (Array.isArray(value)) {
56+
for (const item of value) envTokenNames(item, out, blockName)
57+
} else if (typeof value === 'object' && value !== null) {
58+
for (const item of Object.values(value)) envTokenNames(item, out, blockName)
59+
}
60+
}
61+
62+
/**
63+
* Referenced-but-undeclared {{TOKEN}} audit. Sim resolves a missing token to an
64+
* empty string rather than an error, so the failure it causes is silent and
65+
* downstream — the exact trap a fleet audit found masking a broken production
66+
* error monitor. Declared names come from the same secrets surface the CLI
67+
* exposes (workspace + the caller's personal scope).
68+
*/
69+
async function collectUndeclaredEnvVars(
70+
runtime: AgentCliRuntime,
71+
graph: Pick<WorkflowState, 'blocks'>
72+
): Promise<{ name: string; blocks: string[] }[]> {
73+
const referenced = new Map<string, Set<string>>()
74+
for (const block of Object.values(graph.blocks ?? {})) {
75+
const b = block as { name?: string; subBlocks?: Record<string, { value?: unknown }> }
76+
for (const subBlock of Object.values(b.subBlocks ?? {})) {
77+
envTokenNames(subBlock?.value, referenced, b.name ?? 'unnamed block')
78+
}
79+
}
80+
if (referenced.size === 0) return []
81+
const declared = new Set<string>()
82+
let cursor: string | undefined
83+
for (let page = 0; page < 10; page++) {
84+
const response = await runtime.client.request<{
85+
data: { name: string }[]
86+
nextCursor: string | null
87+
}>('/api/v2/secrets', {
88+
query: { workspaceId: runtime.workspaceId, ...(cursor ? { cursor } : {}) },
89+
})
90+
for (const secret of response.data) declared.add(secret.name)
91+
if (!response.nextCursor) break
92+
cursor = response.nextCursor
93+
}
94+
return [...referenced.entries()]
95+
.filter(([name]) => !declared.has(name))
96+
.map(([name, blocks]) => ({ name, blocks: [...blocks].sort() }))
97+
.sort((a, b) => a.name.localeCompare(b.name))
98+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import {
2+
type AgentCliCommand,
3+
type AgentCliRuntime,
4+
agentCliFail,
5+
agentCliOk,
6+
} from '@/lib/mothership/tools/handlers/agent-cli/types'
7+
8+
/**
9+
* `workflow trace <runId>` — a run's trace rolled up for diagnosis, replacing
10+
* the hand-written span filters agents otherwise improvise (a field audit found
11+
* four divergent jq programs over the same trace, two of which disagreed 7 vs
12+
* 21 blocks because a shallow walk silently drops every block inside a child
13+
* workflow). The walk here is always recursive, errors come only from status
14+
* and error FIELDS (never from schema literals that merely contain the word
15+
* "error"), and subworkflow spans keep their nesting depth visible.
16+
*/
17+
18+
interface TraceSpan {
19+
id?: string
20+
name?: string
21+
type?: string
22+
duration?: number
23+
durationMs?: number
24+
status?: string
25+
errorHandled?: boolean
26+
errorType?: string
27+
errorMessage?: string
28+
blockId?: string
29+
children?: TraceSpan[]
30+
}
31+
32+
interface FlatSpan {
33+
name: string
34+
type: string
35+
durationMs: number
36+
depth: number
37+
status?: string
38+
errorMessage?: string
39+
errorHandled?: boolean
40+
}
41+
42+
function flattenSpans(spans: TraceSpan[], depth: number, out: FlatSpan[]): void {
43+
for (const span of spans) {
44+
out.push({
45+
name: span.name ?? span.blockId ?? 'unnamed',
46+
type: span.type ?? 'unknown',
47+
durationMs: span.durationMs ?? span.duration ?? 0,
48+
depth,
49+
...(span.status !== undefined ? { status: span.status } : {}),
50+
...(span.errorMessage !== undefined ? { errorMessage: span.errorMessage } : {}),
51+
...(span.errorHandled !== undefined ? { errorHandled: span.errorHandled } : {}),
52+
})
53+
if (span.children?.length) flattenSpans(span.children, depth + 1, out)
54+
}
55+
}
56+
57+
function percentile(sorted: number[], p: number): number {
58+
if (sorted.length === 0) return 0
59+
const index = Math.min(sorted.length - 1, Math.floor(p * sorted.length))
60+
return sorted[index] ?? 0
61+
}
62+
63+
export const workflowTraceCommand: AgentCliCommand = {
64+
path: ['workflow', 'trace'],
65+
summary: 'Roll up one run trace: per-block timings, per-type stats, real errors, slowest path',
66+
usage: 'workflow trace <runId>',
67+
async execute(rest, runtime: AgentCliRuntime) {
68+
const runId = rest[0]
69+
if (!runId) return agentCliFail('Usage: sim workflow trace <runId>')
70+
const run = await runtime.client.request<{
71+
data: {
72+
status?: string
73+
totalDurationMs?: number
74+
trigger?: string
75+
workflow?: { id?: string; name?: string }
76+
traceSpans?: TraceSpan[]
77+
}
78+
}>(`/api/v2/logs/${encodeURIComponent(runId)}`)
79+
const record = run.data
80+
const spans: FlatSpan[] = []
81+
flattenSpans(record.traceSpans ?? [], 0, spans)
82+
if (spans.length === 0) {
83+
return agentCliOk(
84+
JSON.stringify(
85+
{
86+
runId,
87+
status: record.status,
88+
workflow: record.workflow?.name,
89+
note: 'No trace spans (spans age out on their own retention schedule).',
90+
},
91+
null,
92+
2
93+
)
94+
)
95+
}
96+
97+
const byType = new Map<string, number[]>()
98+
for (const span of spans) {
99+
const durations = byType.get(span.type) ?? []
100+
durations.push(span.durationMs)
101+
byType.set(span.type, durations)
102+
}
103+
const typeStats = [...byType.entries()]
104+
.map(([type, durations]) => {
105+
const sorted = [...durations].sort((a, b) => a - b)
106+
return {
107+
type,
108+
count: sorted.length,
109+
totalMs: sorted.reduce((a, b) => a + b, 0),
110+
p50Ms: percentile(sorted, 0.5),
111+
maxMs: sorted[sorted.length - 1] ?? 0,
112+
}
113+
})
114+
.sort((a, b) => b.totalMs - a.totalMs)
115+
116+
const errors = spans
117+
.filter((span) => span.errorMessage || (span.status && /^(error|failed)$/i.test(span.status)))
118+
.map((span) => ({
119+
block: span.name,
120+
type: span.type,
121+
depth: span.depth,
122+
...(span.status ? { status: span.status } : {}),
123+
...(span.errorMessage ? { message: span.errorMessage.slice(0, 400) } : {}),
124+
...(span.errorHandled !== undefined ? { handled: span.errorHandled } : {}),
125+
}))
126+
127+
const slowest = [...spans]
128+
.sort((a, b) => b.durationMs - a.durationMs)
129+
.slice(0, 10)
130+
.map((span) => ({
131+
block: span.name,
132+
type: span.type,
133+
durationMs: span.durationMs,
134+
depth: span.depth,
135+
}))
136+
137+
return agentCliOk(
138+
JSON.stringify(
139+
{
140+
runId,
141+
workflow: record.workflow?.name,
142+
status: record.status,
143+
trigger: record.trigger,
144+
totalDurationMs: record.totalDurationMs,
145+
blockCount: spans.length,
146+
maxDepth: Math.max(...spans.map((span) => span.depth)),
147+
errors,
148+
typeStats,
149+
slowestBlocks: slowest,
150+
},
151+
null,
152+
2
153+
)
154+
)
155+
},
156+
}

apps/sim/lib/mothership/tools/handlers/agent-cli/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
workflowsGrepCommand,
66
} from '@/lib/mothership/tools/handlers/agent-cli/commands/grep'
77
import { workflowLintCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/lint'
8+
import { workflowTraceCommand } from '@/lib/mothership/tools/handlers/agent-cli/commands/trace'
89
import {
910
workflowBlocksCommand,
1011
workflowEdgesCommand,
@@ -28,6 +29,7 @@ const AGENT_CLI_COMMANDS: readonly AgentCliCommand[] = [
2829
workflowEdgesCommand,
2930
workflowGrepCommand,
3031
workflowLintCommand,
32+
workflowTraceCommand,
3133
workflowsGrepCommand,
3234
]
3335

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { collectDanglingBlockOutputReferences } from '@/lib/workflows/editing/lint'
6+
7+
function graph(
8+
blocks: Record<
9+
string,
10+
{ type?: string; name?: string; subBlocks?: Record<string, { value?: unknown }> }
11+
>
12+
) {
13+
return { blocks } as Parameters<typeof collectDanglingBlockOutputReferences>[0]
14+
}
15+
16+
describe('collectDanglingBlockOutputReferences', () => {
17+
it('flags a reference to a deleted block in an API body', () => {
18+
const findings = collectDanglingBlockOutputReferences(
19+
graph({
20+
b1: {
21+
type: 'api',
22+
name: 'PostBack',
23+
subBlocks: { body: { value: '{"spec": "<attachformspec.result>"}' } },
24+
},
25+
})
26+
)
27+
expect(findings).toHaveLength(1)
28+
expect(findings[0]).toMatchObject({
29+
blockId: 'b1',
30+
field: 'body',
31+
kind: 'block-output',
32+
value: ['<attachformspec.result>'],
33+
})
34+
})
35+
36+
it('resolves heads by normalized block name, id, and special prefixes', () => {
37+
const findings = collectDanglingBlockOutputReferences(
38+
graph({
39+
b1: { type: 'starter', name: 'Start' },
40+
b2: {
41+
type: 'api',
42+
name: 'Call',
43+
subBlocks: {
44+
url: { value: 'https://x.test/<start.input>' },
45+
body: { value: '<loop.index> and <b1.input> are fine' },
46+
},
47+
},
48+
})
49+
)
50+
expect(findings).toHaveLength(0)
51+
})
52+
53+
it('ignores non-reference angle text (Slack links, comparisons, bare tags)', () => {
54+
const findings = collectDanglingBlockOutputReferences(
55+
graph({
56+
b1: {
57+
type: 'slack',
58+
name: 'Notify',
59+
subBlocks: {
60+
text: { value: 'See <https://sim.ai|the docs> or <b>bold</b>, math a<b.c && d>e' },
61+
},
62+
},
63+
})
64+
)
65+
expect(findings).toHaveLength(0)
66+
})
67+
68+
it('skips function code fields (the runtime fails those loudly)', () => {
69+
const findings = collectDanglingBlockOutputReferences(
70+
graph({
71+
b1: {
72+
type: 'function',
73+
name: 'Fn',
74+
subBlocks: { code: { value: 'return <ghost.value>' } },
75+
},
76+
})
77+
)
78+
expect(findings).toHaveLength(0)
79+
})
80+
81+
it('walks nested values like inputMapping objects', () => {
82+
const findings = collectDanglingBlockOutputReferences(
83+
graph({
84+
b1: {
85+
type: 'workflow_input',
86+
name: 'Invoke',
87+
subBlocks: { inputMapping: { value: { lead: '<ghostblock.output.lead>' } } },
88+
},
89+
})
90+
)
91+
expect(findings).toHaveLength(1)
92+
expect(findings[0]!.field).toBe('inputMapping')
93+
})
94+
})

apps/sim/lib/workflows/editing/lint-report.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger'
22
import { getErrorMessage } from '@sim/utils/errors'
33
import type { WorkflowState } from '@sim/workflow-types/workflow'
44
import {
5+
collectDanglingBlockOutputReferences,
56
collectWorkflowFieldIssues,
67
lintEditedWorkflowState,
78
type WorkflowLintReport,
@@ -57,6 +58,11 @@ export async function buildWorkflowLintReport(
5758
): Promise<WorkflowLintReport> {
5859
const unresolvedReferences: WorkflowLintUnresolvedReference[] = []
5960

61+
// Pure graph check, so it runs for every caller: a dangling block-output
62+
// reference passes literal text through at run time on the surfaces that
63+
// do not fail loudly (API bodies, agent prompts).
64+
unresolvedReferences.push(...collectDanglingBlockOutputReferences(graph))
65+
6066
if (scope.subjectUserId) {
6167
for (const collect of [collectUnresolvedReferences, collectUnresolvedAgentToolReferences]) {
6268
try {

0 commit comments

Comments
 (0)