Skip to content

Commit f343eb0

Browse files
committed
route and connection validation, trigger defaults on add, table folder restore, dispatch listing, group column attach, import block summary; logs query and deps engines; run-tool errors and payload compaction; stale tests aligned
Editing: router routes validated as {id?, title, value} with unknown keys named; malformed connections reported instead of dropped; sub-block value() defaults evaluated on add so a webhook gets its token. Tables: folder restore no longer 500s (lock inside the row transaction), completed dispatches are listed, groups attach to existing output columns. Workflows: import answers with its blocks. Mothership engines: logs query resolves bare paths under output, marks missing paths, drops non-executed runs under --where; deps lists graph predecessors and child return shapes; run-from-block validation errors reach the agent; run payloads compact input.code. CLI generated types regenerated.
1 parent f4785f4 commit f343eb0

47 files changed

Lines changed: 1769 additions & 185 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/docs/openapi-v2-tables.json

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -2126,7 +2126,7 @@
21262126
"post": {
21272127
"operationId": "addTableWorkflowGroup",
21282128
"summary": "Add Workflow Group",
2129-
"description": "Bind a workflow or enrichment to the table and create the columns populated by its outputs.",
2129+
"description": "Bind a workflow or enrichment to the table and create the columns populated by its outputs. An output whose column the table already has attaches that column to the group instead of creating it, so `outputColumns` may be omitted when every output lands in an existing column.",
21302130
"tags": ["Tables"],
21312131
"parameters": [
21322132
{
@@ -2459,8 +2459,8 @@
24592459
},
24602460
"get": {
24612461
"operationId": "listTableDispatches",
2462-
"summary": "List Active Run Dispatches",
2463-
"description": "List the run dispatches still in flight on one table. Bounded by the dispatcher rather than by a page size, so this list is unpaginated and `nextCursor` is always null. A settled dispatch is read by identifier.",
2462+
"summary": "List Run Dispatches",
2463+
"description": "List the run dispatches on one table, most recent first — settled dispatches (`complete`, `canceled`) alongside the ones still in flight, so a run that finished between two polls is still visible next to the `dispatchId` its create returned. Capped at the 100 most recent, so this list is unpaginated and `nextCursor` is always null.",
24642464
"tags": ["Tables"],
24652465
"parameters": [
24662466
{
@@ -2488,7 +2488,7 @@
24882488
],
24892489
"responses": {
24902490
"200": {
2491-
"description": "The table's active run dispatches.",
2491+
"description": "The table's most recent run dispatches.",
24922492
"headers": {
24932493
"X-RateLimit-Limit": {
24942494
"$ref": "#/components/headers/X-RateLimit-Limit"
@@ -7630,7 +7630,8 @@
76307630
"description": "Workflow or enrichment producer definition."
76317631
},
76327632
"outputColumns": {
7633-
"minItems": 1,
7633+
"default": [],
7634+
"description": "Columns to create for producer outputs. An entry naming a column the table already has attaches that column to the group instead of creating it (its `type` must match), and an output whose column already exists may omit its entry entirely — so `[]` attaches existing columns only.",
76347635
"type": "array",
76357636
"items": {
76367637
"type": "object",
@@ -7665,16 +7666,15 @@
76657666
},
76667667
"required": ["name", "type"],
76677668
"additionalProperties": false
7668-
},
7669-
"description": "Columns created for producer outputs."
7669+
}
76707670
},
76717671
"autoRun": {
76727672
"default": false,
76737673
"description": "Whether to schedule existing rows after group creation.",
76747674
"type": "boolean"
76757675
}
76767676
},
7677-
"required": ["workspaceId", "group", "outputColumns"],
7677+
"required": ["workspaceId", "group"],
76787678
"additionalProperties": false,
76797679
"title": "Add table workflow group request",
76807680
"description": "Workspace scope, producer definition, and output columns.",
@@ -10196,7 +10196,7 @@
1019610196
"required": ["data", "nextCursor"],
1019710197
"additionalProperties": false,
1019810198
"title": "Table run dispatch list response",
10199-
"description": "The table's active run dispatches."
10199+
"description": "The table's most recent run dispatches, settled ones included."
1020010200
},
1020110201
"V2MoveTablesData": {
1020210202
"type": "object",

apps/docs/openapi-v2-workflows.json

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8383,6 +8383,27 @@
83838383
}
83848384
]
83858385
},
8386+
"ImportedWorkflowBlock": {
8387+
"type": "object",
8388+
"properties": {
8389+
"id": {
8390+
"type": "string",
8391+
"description": "Block identifier."
8392+
},
8393+
"type": {
8394+
"type": "string",
8395+
"description": "Registered block type."
8396+
},
8397+
"name": {
8398+
"type": "string",
8399+
"description": "Block display name."
8400+
}
8401+
},
8402+
"required": ["id", "type", "name"],
8403+
"additionalProperties": false,
8404+
"title": "Imported workflow block",
8405+
"description": "A block the import created in the new workflow."
8406+
},
83868407
"ImportedWorkflow": {
83878408
"type": "object",
83888409
"properties": {
@@ -8424,6 +8445,13 @@
84248445
"type": "string",
84258446
"description": "ISO 8601 timestamp when the workflow was last updated.",
84268447
"format": "date-time"
8448+
},
8449+
"blocks": {
8450+
"type": "array",
8451+
"items": {
8452+
"$ref": "#/components/schemas/ImportedWorkflowBlock"
8453+
},
8454+
"description": "Blocks the import created, in payload order. A summary only; `GET /workflows/{workflowId}/state` returns the full graph."
84278455
}
84288456
},
84298457
"required": [
@@ -8433,7 +8461,8 @@
84338461
"workspaceId",
84348462
"folderPath",
84358463
"createdAt",
8436-
"updatedAt"
8464+
"updatedAt",
8465+
"blocks"
84378466
],
84388467
"additionalProperties": false,
84398468
"title": "Imported workflow",
@@ -8460,7 +8489,24 @@
84608489
"workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64",
84618490
"folderPath": "/Operations",
84628491
"createdAt": "2026-05-01T09:00:00.000Z",
8463-
"updatedAt": "2026-08-09T18:04:11.000Z"
8492+
"updatedAt": "2026-08-09T18:04:11.000Z",
8493+
"blocks": [
8494+
{
8495+
"id": "block_start",
8496+
"type": "starter",
8497+
"name": "Start"
8498+
},
8499+
{
8500+
"id": "block_triage",
8501+
"type": "agent",
8502+
"name": "Triage"
8503+
},
8504+
{
8505+
"id": "block_reply",
8506+
"type": "response",
8507+
"name": "Reply"
8508+
}
8509+
]
84648510
}
84658511
}
84668512
]

apps/sim/app/api/v2/tables/[tableId]/dispatches/route.test.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,14 @@ const DISPATCH = {
6262
completedAt: null,
6363
cancelledAt: null,
6464
}
65+
const COMPLETED_DISPATCH = {
66+
...DISPATCH,
67+
id: 'dispatch-2',
68+
status: 'complete' as const,
69+
processedCount: 13,
70+
requestedAt: new Date('2026-01-01T00:10:00Z'),
71+
completedAt: new Date('2026-01-01T00:12:00Z'),
72+
}
6573

6674
function list(query = `?workspaceId=${WORKSPACE_ID}`) {
6775
const request = new NextRequest(`http://localhost/api/v2/tables/table-1/dispatches${query}`, {
@@ -80,11 +88,18 @@ describe('GET /api/v2/tables/[tableId]/dispatches', () => {
8088
v2RouteMocks.authenticate.mockResolvedValue(AUTH)
8189
v2RouteMocks.preauthRate.mockResolvedValue(V2_PREAUTH_RATE_LIMIT_ALLOWED)
8290
v2RouteMocks.operationRate.mockResolvedValue(V2_OPERATION_RATE_LIMIT_ALLOWED)
83-
mocks.listDispatches.mockResolvedValue({ table: { id: 'table-1' }, dispatches: [DISPATCH] })
91+
mocks.listDispatches.mockResolvedValue({
92+
table: { id: 'table-1' },
93+
dispatches: [COMPLETED_DISPATCH, DISPATCH],
94+
})
8495
mocks.startRun.mockResolvedValue({ table: { id: 'table-1' }, dispatchId: 'dispatch-1' })
8596
})
8697

87-
it('delegates the canonical table scope and returns the full set', async () => {
98+
/**
99+
* Settled dispatches are part of the set: a run that completed between two
100+
* polls must still show up next to the `dispatchId` its create returned.
101+
*/
102+
it('delegates the canonical table scope and returns the full set, settled dispatches included', async () => {
88103
const invocation = list()
89104
const response = await invocation.response
90105

@@ -95,11 +110,15 @@ describe('GET /api/v2/tables/[tableId]/dispatches', () => {
95110
request: invocation.request,
96111
})
97112
const body = await response.json()
98-
expect(body.data).toHaveLength(1)
113+
expect(body.data.map((dispatch: { id: string; status: string }) => dispatch.status)).toEqual([
114+
'complete',
115+
'pending',
116+
])
117+
expect(body.data[0]).toMatchObject({ id: 'dispatch-2', processedCount: 13 })
99118
expect(body.nextCursor).toBeNull()
100119
})
101120

102-
/** The set is dispatcher-bounded, so there is no page for a limit to select. */
121+
/** The set is capped by the use case, so there is no page for a limit to select. */
103122
it('rejects pagination parameters this list does not implement', async () => {
104123
const response = await list(`?workspaceId=${WORKSPACE_ID}&limit=10`).response
105124

apps/sim/app/api/v2/tables/[tableId]/dispatches/route.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@ export const dynamic = 'force-dynamic'
1313
export const revalidate = 0
1414

1515
/**
16-
* Every dispatch still in flight on one table. Unpaged: the dispatcher bounds
17-
* how many dispatches a table can have active, so `nextCursor` is always null
18-
* and there is no page for a `limit` to select.
16+
* The dispatches on one table, most recent first, settled ones included — a
17+
* run that finished between two polls is still listed next to the id its
18+
* create returned. Unpaged: the use case caps the list at the most recent
19+
* hundred, so `nextCursor` is always null and there is no page for a `limit`
20+
* to select.
1921
*/
2022
export const GET = defineV2JsonRoute({
2123
contract: v2ListTableDispatchesContract,

apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/resume/route.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,7 @@ describe('POST /api/v2/workflows/[workflowId]/runs/[runId]/resume', () => {
220220
workflowId: WORKFLOW_ID,
221221
status: 'completed',
222222
output: { approved: true },
223+
blockOutputs: null,
223224
error: null,
224225
startedAt: '2026-08-05T00:00:00.000Z',
225226
endedAt: '2026-08-05T00:00:01.000Z',

apps/sim/app/api/v2/workflows/import/route.test.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,19 @@ vi.mock('@/lib/api/server/routes', () => ({
1717
}))
1818

1919
import { v2WorkflowErrorPolicies } from '@/lib/workflows/api'
20-
import { importWorkflow } from '@/lib/workflows/application/import-export'
20+
import {
21+
type ImportWorkflowResult,
22+
importWorkflow,
23+
} from '@/lib/workflows/application/import-export'
2124
import { workflowOperations } from '@/lib/workflows/application/operations'
2225
import { MAX_IMPORT_BODY_BYTES } from '@/lib/workflows/operations/import-workflow'
2326
import { POST } from '@/app/api/v2/workflows/import/route'
2427

28+
/** With `defineV2JsonRoute` mocked to return its definition, `POST` is that definition. */
29+
const definition = POST as unknown as {
30+
present: (result: ImportWorkflowResult) => { data: Record<string, unknown> }
31+
}
32+
2533
describe('/api/v2/workflows/import route definition', () => {
2634
it('uses authorized admission and preserves the bounded import lifecycle', () => {
2735
expect(POST).toMatchObject({
@@ -31,4 +39,34 @@ describe('/api/v2/workflows/import route definition', () => {
3139
parseOptions: { maxBodyBytes: MAX_IMPORT_BODY_BYTES },
3240
})
3341
})
42+
43+
/**
44+
* The presenter dropped the imported blocks, so an import that created three
45+
* blocks answered with nothing a caller could check short of reading the
46+
* state back — and a client filling in `blocks: []` reported an empty import.
47+
*/
48+
it('presents the blocks the import created', () => {
49+
const blocks = [
50+
{ id: 'block-1', type: 'starter', name: 'Start' },
51+
{ id: 'block-2', type: 'agent', name: 'Classify' },
52+
{ id: 'block-3', type: 'response', name: 'Reply' },
53+
]
54+
55+
const { data } = definition.present({
56+
workflow: {
57+
id: 'workflow-1',
58+
name: 'Imported',
59+
description: null,
60+
workspaceId: 'ws-1',
61+
folderId: null,
62+
sortOrder: 0,
63+
createdAt: new Date('2026-01-01T00:00:00Z'),
64+
updatedAt: new Date('2026-01-01T00:00:00Z'),
65+
blocks,
66+
},
67+
folderPath: '/',
68+
})
69+
70+
expect(data).toMatchObject({ id: 'workflow-1', name: 'Imported', folderPath: '/', blocks })
71+
})
3472
})

apps/sim/app/api/v2/workflows/import/route.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ export const POST = defineV2JsonRoute({
3232
folderPath,
3333
createdAt: workflow.createdAt.toISOString(),
3434
updatedAt: workflow.updatedAt.toISOString(),
35+
blocks: workflow.blocks,
3536
},
3637
}),
3738
})

apps/sim/executor/execution/executor.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { DAGBuilder } from '@/executor/dag/builder'
77
import { DAGExecutor } from '@/executor/execution/executor'
88
import type { SerializableExecutionState } from '@/executor/execution/types'
99
import type { ExecutionContext, ExecutionResult } from '@/executor/types'
10+
import { RunFromBlockValidationError } from '@/executor/utils/run-from-block'
1011
import { buildSentinelStartId } from '@/executor/utils/subflow-utils'
1112
import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types'
1213

@@ -329,6 +330,36 @@ describe('DAGExecutor run-from-block snapshot metadata', () => {
329330
})
330331
expect(capturedContext?.blockStates.has('unreachable__obranch-0')).toBe(false)
331332
})
333+
it('refuses a start block whose upstream never executed with a typed validation error', async () => {
334+
const workflow: SerializedWorkflow = {
335+
version: '1',
336+
blocks: [
337+
createBlock('start', BlockType.STARTER),
338+
createBlock('producer', BlockType.FUNCTION),
339+
createBlock('consumer', BlockType.FUNCTION),
340+
],
341+
connections: [
342+
{ source: 'start', target: 'producer' },
343+
{ source: 'producer', target: 'consumer' },
344+
],
345+
loops: {},
346+
parallels: {},
347+
}
348+
const executor = new DAGExecutor({ workflow })
349+
const sourceSnapshot: SerializableExecutionState = {
350+
blockStates: {},
351+
executedBlocks: [],
352+
blockLogs: [],
353+
decisions: { router: {}, condition: {} },
354+
completedLoops: [],
355+
activeExecutionPath: [],
356+
}
357+
358+
const error = await executor.executeFromBlock('wf', 'consumer', sourceSnapshot).catch((e) => e)
359+
360+
expect(error).toBeInstanceOf(RunFromBlockValidationError)
361+
expect(error.message).toBe('Upstream dependency not executed: producer')
362+
})
332363
})
333364

334365
describe('DAGExecutor resume DAG construction', () => {

apps/sim/executor/execution/executor.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
computeExecutionSets,
2828
overlayVariableInputs,
2929
type RunFromBlockContext,
30+
RunFromBlockValidationError,
3031
resolveContainerToSentinelStart,
3132
validateRunFromBlock,
3233
} from '@/executor/utils/run-from-block'
@@ -143,7 +144,9 @@ export class DAGExecutor {
143144
const executedBlocks = new Set(sourceSnapshot.executedBlocks)
144145
const validation = validateRunFromBlock(startBlockId, dag, executedBlocks)
145146
if (!validation.valid) {
146-
throw new Error(validation.error)
147+
throw new RunFromBlockValidationError(
148+
validation.error ?? `Cannot run from block: ${startBlockId}`
149+
)
147150
}
148151

149152
const { dirtySet, upstreamSet, reachableUpstreamSet } = computeExecutionSets(dag, startBlockId)

apps/sim/executor/utils/run-from-block.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { describe, expect, it } from 'vitest'
22
import type { DAG, DAGNode } from '@/executor/dag/builder'
33
import type { DAGEdge, NodeMetadata } from '@/executor/dag/types'
4-
import { computeExecutionSets, validateRunFromBlock } from '@/executor/utils/run-from-block'
4+
import {
5+
computeExecutionSets,
6+
RunFromBlockValidationError,
7+
validateRunFromBlock,
8+
} from '@/executor/utils/run-from-block'
59
import type { SerializedLoop, SerializedParallel } from '@/serializer/types'
610

711
/**
@@ -1664,3 +1668,13 @@ describe('upstream block addition/deletion scenarios', () => {
16641668
expect(result.error).toContain('X')
16651669
})
16661670
})
1671+
1672+
describe('RunFromBlockValidationError', () => {
1673+
it('is a named Error carrying the validation message verbatim', () => {
1674+
const error = new RunFromBlockValidationError('Upstream dependency not executed: a')
1675+
1676+
expect(error).toBeInstanceOf(Error)
1677+
expect(error.name).toBe('RunFromBlockValidationError')
1678+
expect(error.message).toBe('Upstream dependency not executed: a')
1679+
})
1680+
})

0 commit comments

Comments
 (0)