Skip to content

Commit 6e5c4b9

Browse files
committed
workflow lint, block catalog, tables, files, sandbox tools: fixes from exploration run 5
Lint: trigger-category blocks are entry blocks (schedule was an orphan); references inside Function code are checked with the runtime tokenizer; the agent-cli lint and grep read the draft state instead of the sanitized export. Catalog: operation inputs publish sub-block ids only (no canonical-param aliases); trigger-category blocks expose their trigger-mode fields. Tables: dispatch processedCount counts unlimited dispatches; json-language code fields accept objects. Files: restore keeps the folder when it still exists. Sandbox tools: outputTable failures report the files already written and the per-language result shape; the file writer keeps the computed result.
1 parent dfa64bd commit 6e5c4b9

25 files changed

Lines changed: 604 additions & 78 deletions

File tree

‎apps/docs/openapi-v2-files-audit.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1265,7 +1265,7 @@
12651265
"post": {
12661266
"operationId": "restoreFile",
12671267
"summary": "Restore File",
1268-
"description": "Reverse a soft delete and return the file to the workspace. Not a pure undo: the file comes back at the workspace root, and gains a `_restored` suffix when another file there already holds its name, so read `folderPath` and `name` off the response. Restoring an already-active file returns it unchanged, so a retry is safe. An archived workspace is a `400`, and a name the restore could not free is a `409`.",
1268+
"description": "Reverse a soft delete and return the file to the workspace. Not a pure undo: the file comes back in the folder it was deleted from, or at the workspace root when that folder has since been archived, and gains a `_restored` suffix when another file there already holds its name, so read `folderPath` and `name` off the response. Restoring an already-active file returns it unchanged, so a retry is safe. An archived workspace is a `400`, and a name the restore could not free is a `409`.",
12691269
"tags": ["Files"],
12701270
"parameters": [
12711271
{

‎apps/sim/app/api/v2/files/[fileId]/restore/route.ts‎

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,11 +14,12 @@ export const revalidate = 0
1414
* `DELETE /api/v2/files/[fileId]` is a soft delete; this reverses it. Find the
1515
* ids to pass here with `GET /api/v2/files?scope=archived`.
1616
*
17-
* Restore is not a pure undo: the file returns to the workspace root regardless
18-
* of the folder it was deleted from, and it is renamed when its original name
19-
* is no longer free. The response is therefore the post-restore record, not the
20-
* one the caller deleted. Restoring an already-active file is a no-op that
21-
* returns that file, so a retried request is safe.
17+
* Restore is not a pure undo: the file returns to the folder it was deleted
18+
* from while that folder is still active, and to the workspace root once the
19+
* folder has been archived; it is renamed when its original name is no longer
20+
* free there. The response is therefore the post-restore record, not the one
21+
* the caller deleted. Restoring an already-active file is a no-op that returns
22+
* that file, so a retried request is safe.
2223
*/
2324
export const POST = defineV2JsonRoute({
2425
contract: v2RestoreFileContract,

‎apps/sim/blocks/blocks/table_v2.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,7 @@ export const TableV2Block: BlockConfig<TableQueryV2Response> = {
370370
id: 'data',
371371
title: 'Row Data (JSON)',
372372
type: 'code',
373+
language: 'json',
373374
placeholder: '{"column_name": "value"}',
374375
condition: {
375376
field: 'operation',
@@ -426,6 +427,7 @@ Return ONLY the data JSON:`,
426427
id: 'rows',
427428
title: 'Rows Data (Array of JSON)',
428429
type: 'code',
430+
language: 'json',
429431
placeholder: '[{"col1": "val1"}, {"col1": "val2"}]',
430432
condition: { field: 'operation', value: 'batch_insert_rows' },
431433
required: true,
@@ -467,6 +469,7 @@ Return ONLY the rows array:`,
467469
id: 'filter',
468470
title: 'Filter',
469471
type: 'code',
472+
language: 'json',
470473
canonicalParamId: 'filterInput',
471474
mode: 'advanced',
472475
placeholder: '{"field":"wins","op":"gte","value":10}',

‎apps/sim/lib/api/contracts/v2/openapi/files-audit.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -560,7 +560,7 @@ const declaredRoutes = [
560560
operationId: 'restoreFile',
561561
summary: 'Restore File',
562562
description:
563-
'Reverse a soft delete and return the file to the workspace. Not a pure undo: the file comes back at the workspace root, and gains a `_restored` suffix when another file there already holds its name, so read `folderPath` and `name` off the response. Restoring an already-active file returns it unchanged, so a retry is safe. An archived workspace is a `400`, and a name the restore could not free is a `409`.',
563+
'Reverse a soft delete and return the file to the workspace. Not a pure undo: the file comes back in the folder it was deleted from, or at the workspace root when that folder has since been archived, and gains a `_restored` suffix when another file there already holds its name, so read `folderPath` and `name` off the response. Restoring an already-active file returns it unchanged, so a retry is safe. An archived workspace is a `400`, and a name the restore could not free is a `409`.',
564564
errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'],
565565
success: { description: 'The file as it exists after the restore.' },
566566
}),

‎apps/sim/lib/catalog/projection/block-detail.ts‎

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -142,9 +142,20 @@ export function hiddenParamKeys(block: BlockConfig): Set<string> {
142142
return hidden
143143
}
144144

145-
/** Sub-blocks an authoring surface may configure: action fields, minus the hidden ones. */
145+
/**
146+
* Sub-blocks an authoring surface may configure, minus the hidden ones.
147+
*
148+
* Action fields for an ordinary block. A `triggers`-category block has no
149+
* action side — every field it declares is trigger-mode, and the serializer
150+
* keeps those — so its trigger fields are the authorable ones, minus the
151+
* system-managed display and lifecycle fields.
152+
*/
146153
function authorableSubBlocks(block: BlockConfig): SubBlockConfig[] {
147-
return actionSubBlocks(block).filter((subBlock) => !subBlock.hideFromCopilot)
154+
const fields =
155+
block.category === 'triggers'
156+
? (block.subBlocks ?? []).filter((subBlock) => !SYSTEM_SUBBLOCK_IDS.includes(subBlock.id))
157+
: actionSubBlocks(block)
158+
return fields.filter((subBlock) => !subBlock.hideFromCopilot)
148159
}
149160

150161
/** Whether a condition gates its field on a specific operation being selected. */
@@ -227,7 +238,13 @@ export function computeBlockLevelInputs(
227238
return blockInputs
228239
}
229240

230-
/** Input definitions scoped to one operation, keyed by operation id. */
241+
/**
242+
* Input definitions scoped to one operation, keyed by operation id.
243+
*
244+
* Each input is published under the sub-block id — the field name an apply
245+
* accepts — even when the block declares its definition under the field's
246+
* `canonicalParamId`, which is the executor-side param and not a writable field.
247+
*/
231248
export function computeOperationLevelInputs(
232249
block: BlockConfig
233250
): Record<string, Record<string, CatalogInputDefinition>> {
@@ -237,15 +254,13 @@ export function computeOperationLevelInputs(
237254
for (const subBlock of authorableSubBlocks(block)) {
238255
const gate = operationGate(subBlock)
239256
if (!gate) continue
240-
const keys = [subBlock.canonicalParamId, subBlock.id].filter(
241-
(key): key is string => typeof key === 'string'
242-
)
243-
for (const key of keys) {
244-
if (!(key in inputs)) continue
245-
for (const operationId of gate.values) {
246-
operationInputs[operationId] ??= {}
247-
operationInputs[operationId][key] = inputs[key]
248-
}
257+
const definition =
258+
inputs[subBlock.id] ??
259+
(subBlock.canonicalParamId !== undefined ? inputs[subBlock.canonicalParamId] : undefined)
260+
if (!definition) continue
261+
for (const operationId of gate.values) {
262+
operationInputs[operationId] ??= {}
263+
operationInputs[operationId][subBlock.id] = definition
249264
}
250265
}
251266

‎apps/sim/lib/catalog/projection/catalog-sweep.test.ts‎

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,11 +126,40 @@ describe('block catalog projection sweep', () => {
126126
})
127127
})
128128

129+
/**
130+
* Pinned shapes of two real registry blocks, where a projection rule is only
131+
* visible against an authored block rather than a synthetic fixture.
132+
*/
133+
describe('block detail regressions', () => {
134+
const registry = getBlockRegistry()
135+
const registered = (type: string) => {
136+
const block = registry[type]
137+
if (!block) throw new Error(`block ${type} is not registered`)
138+
return block
139+
}
140+
141+
it('keys operation inputs by the sub-block id an apply accepts, not the canonical param id', () => {
142+
const detail = projectBlockDetail(registered('table_v2'), { deployment: HOSTED })
143+
const inputKeys = Object.keys(detail.operations.query_rows?.inputs ?? {})
144+
expect(inputKeys).toContain('filter')
145+
expect(inputKeys).not.toContain('filterInput')
146+
expect(inputKeys).not.toContain('sortInput')
147+
})
148+
149+
it('publishes a triggers-category block’s trigger-mode fields as its input schema', () => {
150+
const detail = projectBlockDetail(registered('schedule'), { deployment: HOSTED })
151+
const ids = detail.inputSchema.map((field) => field.id)
152+
expect(ids).toEqual(expect.arrayContaining(['scheduleType', 'cronExpression', 'timezone']))
153+
expect(ids).not.toContain('scheduleInfo')
154+
})
155+
})
156+
129157
/**
130158
* Custom (deploy-as-block) blocks, which the registry sweep above cannot reach.
131159
*
132-
* `projectCustomBlockDetail` is a separate branch with its own field set — and
133-
* the only one whose `inputSchema` includes `mode: 'trigger'` sub-blocks — yet
160+
* `projectCustomBlockDetail` is a separate branch with its own field set — it
161+
* publishes `mode: 'trigger'` sub-blocks in `inputSchema`, as the main branch
162+
* does only for `triggers`-category blocks — yet
134163
* it is caller-reachable through `GET /api/v2/blocks/custom_block_*`. Built from
135164
* the same `buildCustomBlockConfig` the overlay uses, so a change to the synthesized
136165
* shape shows up here rather than as a 500 on a well-formed request.

‎apps/sim/lib/mothership/agent-cli/engines.test.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,15 +51,15 @@ function runtimeWith(responses: Record<string, unknown>): AgentCliRuntime {
5151
}
5252
}
5353

54-
const EXPORT_PATH = '/api/v2/workflows/wf-1/export'
55-
const exportResponse = { data: { state: WORKFLOW_STATE } }
54+
const STATE_PATH = '/api/v2/workflows/wf-1/state'
55+
const stateResponse = { data: WORKFLOW_STATE }
5656

5757
describe('workflows lint', () => {
5858
it('lints a workflow through the shared engine with the caller scoped as subject', async () => {
5959
const result = await runEngine(
6060
'workflows lint',
6161
['wf-1'],
62-
runtimeWith({ [EXPORT_PATH]: exportResponse }),
62+
runtimeWith({ [STATE_PATH]: stateResponse }),
6363
{}
6464
)
6565
expect(result.stderr).toBe('')

‎apps/sim/lib/mothership/agent-cli/engines/universal-grep.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,4 +96,27 @@ describe('universal grep', () => {
9696
expect(result.exitCode).toBe(0)
9797
expect(result.stdout).toContain('No matches for "zzz-nope" in blocks')
9898
})
99+
100+
it('names files by their VFS path, with no doubled slash at the root', async () => {
101+
const result = await runEngine(
102+
'grep',
103+
['runbook'],
104+
runtimeWith({
105+
'/api/v2/files': {
106+
data: [
107+
{ id: 'file-root', name: 'xp-runbook.md', folderPath: '/' },
108+
{ id: 'file-ops', name: 'notes.md', folderPath: '/Ops' },
109+
],
110+
nextCursor: null,
111+
},
112+
'/api/v2/files/file-root/text': { data: { text: 'root runbook', degraded: false } },
113+
'/api/v2/files/file-ops/text': { data: { text: 'ops runbook', degraded: false } },
114+
}),
115+
{ scope: 'files' }
116+
)
117+
expect(result.exitCode).toBe(0)
118+
expect(result.stdout).toContain('files/xp-runbook.md (file-root):')
119+
expect(result.stdout).toContain('files/Ops/notes.md (file-ops):')
120+
expect(result.stdout).not.toContain('files//')
121+
})
99122
})

‎apps/sim/lib/mothership/agent-cli/engines/universal-grep.ts‎

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -103,12 +103,12 @@ const MATERIALIZERS: Record<Scope, (runtime: AgentCliRuntime) => Promise<Materia
103103
const list = await listAll(runtime, '/api/v2/workflows')
104104
return mapConcurrent(list, FETCH_CONCURRENCY, async (w) => {
105105
const id = str(w.id) ?? ''
106-
// The export route scopes by workflow id alone (`query: noInputSchema`); a
107-
// workspaceId here is an "Unrecognized key" — the other detail routes require it.
108-
const exported = await runtime.client.request<{ data: { state: unknown } }>(
109-
`/api/v2/workflows/${id}/export`
110-
)
111-
return render('workflows', id, str(w.name) ?? id, exported.data.state)
106+
// The draft state, not the export: export is sanitized for sharing and nulls
107+
// workspace-specific fields (a Table block's `tableId`), so a grep for a table id
108+
// inside a workflow would miss it. The state route scopes by workflow id alone
109+
// (`query: noInputSchema`); a workspaceId here is an "Unrecognized key".
110+
const state = await runtime.client.request<{ data: unknown }>(`/api/v2/workflows/${id}/state`)
111+
return render('workflows', id, str(w.name) ?? id, state.data)
112112
})
113113
},
114114
blocks: async (runtime) => {
@@ -178,9 +178,11 @@ const MATERIALIZERS: Record<Scope, (runtime: AgentCliRuntime) => Promise<Materia
178178
})
179179
return texts.flatMap(({ file, text }) => {
180180
if (text === null) return []
181-
const folder = (str(file.folderPath) ?? '').replace(/\/+$/, '')
181+
// `folderPath` is `/` at the root and `/Ops` below it; the match header already
182+
// supplies the `files/` prefix, so the label carries no slash of its own.
183+
const folder = (str(file.folderPath) ?? '').replace(/^\/+|\/+$/g, '')
182184
const name = str(file.name) ?? str(file.id) ?? ''
183-
const label = folder ? `${folder}/${name}` : `/${name}`
185+
const label = folder ? `${folder}/${name}` : name
184186
return [{ scope: 'files' as const, id: str(file.id) ?? '', label, text }]
185187
})
186188
},
Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,23 @@
1-
import type { ExportWorkflowResponse } from 'sim/embed'
21
import type { AgentCliRuntime } from '@/lib/mothership/agent-cli/types'
32

4-
/** One workflow's exported state — the v2 source of truth the analysis engines read. */
3+
interface WorkflowStateResponse {
4+
data: Record<string, unknown>
5+
}
6+
7+
/**
8+
* One workflow's draft state — the v2 source of truth the analysis engines read.
9+
*
10+
* The `/state` route, not `/export`: export is sanitized for sharing, which nulls
11+
* workspace-specific fields such as a Table block's `tableId` while leaving its
12+
* advanced-mode marker, so a lint over the export reported a working block as missing
13+
* "Table ID" although the same graph linted clean inside `operations apply`.
14+
*/
515
export async function fetchWorkflowState(
616
runtime: AgentCliRuntime,
717
workflowId: string
818
): Promise<Record<string, unknown>> {
9-
const response = await runtime.client.request<ExportWorkflowResponse>(
10-
`/api/v2/workflows/${encodeURIComponent(workflowId)}/export`
19+
const response = await runtime.client.request<WorkflowStateResponse>(
20+
`/api/v2/workflows/${encodeURIComponent(workflowId)}/state`
1121
)
12-
return response.data.state
22+
return response.data
1323
}

0 commit comments

Comments
 (0)