Skip to content

Commit 5f1a29e

Browse files
authored
perf(cli): load a manual run's draft once and keep embedded CLI results lean (#8625)
* perf(cli): load a manual run's draft once and keep embedded CLI results lean - Manual runs reuse the draft state the use case loaded to pick and validate the trigger, instead of reading the draft tables twice more in the execute service and the execution core. The trigger is now chosen from the same snapshot that runs. - Embedded (in-app agent) synchronous runs ask for file references without inline base64 unless --include-file-base64 is passed; --async never sends it. - Embedded logs get leaves the workflow snapshot out unless --include-workflow-state is passed, via a new embeddedRequestDefault contract field. The installed CLI and public API defaults are unchanged. - Add test-cli-run-latency-e2e.ts, which times both commands per segment against a running app and writes a JSON report; CI runs a short pass. * fix(cli): cover followed runs, assert lean results, own CI step for the CLI E2E - Embedded --follow runs also ask for file references only. - The E2E asserts this checkout's embedded results carry no inline file bytes or workflow snapshot; a compared CLI build is measured, not asserted. - Run the CLI E2E against its own self-hosted app: hosted billing admits runs through Redis, which the HTTP E2E job does not provision. * fix(cli): remove the CLI E2E's execution logs and snapshots on cleanup
1 parent 551e05f commit 5f1a29e

15 files changed

Lines changed: 652 additions & 12 deletions

File tree

‎.github/workflows/test-build.yml‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,49 @@ jobs:
269269
VERSION_COMPARE_E2E_REPORT_PATH="$report_dir/version-compare-http-report.json" \
270270
bun run test:workflow-version-compare:e2e
271271
272+
# Self-hosted without billing: hosted billing admits runs through Redis, which
273+
# this job does not provision.
274+
- name: Verify embedded CLI workflow runs and log reads over real HTTP
275+
working-directory: apps/sim
276+
env:
277+
NEXT_PUBLIC_APP_URL: http://127.0.0.1:3018
278+
BETTER_AUTH_URL: http://127.0.0.1:3018
279+
NEXT_PUBLIC_FORCE_HOSTED: 'false'
280+
INTERNAL_API_SECRET: cli-http-ci-local-secret-at-least-32-characters
281+
DISABLE_TELEMETRY: 'true'
282+
NEXT_TELEMETRY_DISABLED: '1'
283+
READY_TIMEOUT_SECONDS: 300
284+
run: |
285+
report_dir="$RUNNER_TEMP/e2e"
286+
server_log="$report_dir/cli-next.log"
287+
mkdir -p "$report_dir"
288+
node ../../node_modules/next/dist/bin/next dev --hostname 127.0.0.1 --port 3018 > "$server_log" 2>&1 &
289+
server_pid=$!
290+
finish() {
291+
kill "$server_pid" 2>/dev/null || true
292+
wait "$server_pid" 2>/dev/null || true
293+
}
294+
trap finish EXIT
295+
fail_startup() {
296+
echo "::error::$1"
297+
tail -n 200 "$server_log"
298+
exit 1
299+
}
300+
started=$SECONDS
301+
until curl --fail --silent --max-time 10 http://127.0.0.1:3018/api/health > /dev/null; do
302+
kill -0 "$server_pid" 2>/dev/null || fail_startup 'Local CLI app exited during startup.'
303+
[ $((SECONDS - started)) -lt "$READY_TIMEOUT_SECONDS" ] ||
304+
fail_startup "Local CLI app did not become ready within $READY_TIMEOUT_SECONDS seconds."
305+
sleep 2
306+
done
307+
echo "Local CLI app ready after $((SECONDS - started))s"
308+
CLI_LATENCY_E2E_BASE_URL="$NEXT_PUBLIC_APP_URL" \
309+
CLI_LATENCY_E2E_DATABASE_URL="$DATABASE_URL" \
310+
CLI_LATENCY_E2E_RUNS=3 \
311+
CLI_LATENCY_E2E_WARMUP=1 \
312+
CLI_LATENCY_E2E_REPORT_PATH="$report_dir/cli-run-latency-report.json" \
313+
bun run test:cli-run-latency:e2e
314+
272315
# A self-hosted app: hosted billing admits a run only through a Redis usage
273316
# reservation, and the SCIM suite above asserts PostgreSQL rate-limit storage,
274317
# so workflow execution gets its own app rather than adding Redis to that one.

‎apps/docs/content/docs/cli/logs.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ sim logs get <runId> [options]
3131

3232
| Option | Required | Description |
3333
| --- | --- | --- |
34-
| `--include-workflow-state` | No | Include the saved workflow snapshot (default: true). Set false to omit block configuration from a log read. Other run fields are unchanged. |
34+
| `--include-workflow-state` | No | Include the saved workflow snapshot (default: true; Sim’s in-app agent leaves it out unless this flag is passed). Set false to omit block configuration from a log read. Other run fields are unchanged. |
3535
| `--no-include-workflow-state` | No | Send --include-workflow-state as false. |
3636
| `--trace` | No | Show expanded trace spans with inputs, outputs, errors, timing, and cost. |
3737

‎apps/docs/content/docs/cli/reference.mdx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2751,7 +2751,7 @@ sim logs get <runId> [options]
27512751

27522752
| Option | Required | Description |
27532753
| --- | --- | --- |
2754-
| `--include-workflow-state` | No | Include the saved workflow snapshot (default: true). Set false to omit block configuration from a log read. Other run fields are unchanged. |
2754+
| `--include-workflow-state` | No | Include the saved workflow snapshot (default: true; Sim’s in-app agent leaves it out unless this flag is passed). Set false to omit block configuration from a log read. Other run fields are unchanged. |
27552755
| `--no-include-workflow-state` | No | Send --include-workflow-state as false. |
27562756
| `--trace` | No | Show expanded trace spans with inputs, outputs, errors, timing, and cost. |
27572757

@@ -6695,7 +6695,7 @@ sim workflows run <workflowId> [options]
66956695
| `--async` | No | Queue the run and return immediately. |
66966696
| `--execution-timeout-seconds <value>` | No | Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`. |
66976697
| `--select-output <value...>` | No | Return blockName.path values (e.g. agent_1.content), or childWorkflowId.blockName.path for a child workflow (applies to every invocation) — in blockOutputs on a sync run, or from the streamed result with --follow; missing paths are omitted. Not available with --async (space-separated, or @path / @- with one value per line; @@value for a literal leading @). |
6698-
| `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. |
6698+
| `--include-file-base64` | No | Inline eligible output files as base64 content (default: true; Sim’s in-app agent gets file references only unless this flag is passed). Rejected when `async` is true. |
66996699
| `--no-include-file-base64` | No | Send --include-file-base64 as false. |
67006700
| `--base64-max-bytes <value>` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. |
67016701
| `--run-id <value>` | No | One-shot identifier for this run; NOT an idempotency key — reusing a claimed value fails with RUN_ID_CONFLICT instead of replaying the first result, and a fresh value starts another run. |

‎apps/docs/content/docs/cli/workflows.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -633,7 +633,7 @@ sim workflows run <workflowId> [options]
633633
| `--async` | No | Queue the run and return immediately. |
634634
| `--execution-timeout-seconds <value>` | No | Maximum duration of an asynchronous run, in seconds, capped by the plan's execution timeout. Requires `async: true`; otherwise returns `400`. |
635635
| `--select-output <value...>` | No | Return blockName.path values (e.g. agent_1.content), or childWorkflowId.blockName.path for a child workflow (applies to every invocation) — in blockOutputs on a sync run, or from the streamed result with --follow; missing paths are omitted. Not available with --async (space-separated, or @path / @- with one value per line; @@value for a literal leading @). |
636-
| `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. |
636+
| `--include-file-base64` | No | Inline eligible output files as base64 content (default: true; Sim’s in-app agent gets file references only unless this flag is passed). Rejected when `async` is true. |
637637
| `--no-include-file-base64` | No | Send --include-file-base64 as false. |
638638
| `--base64-max-bytes <value>` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. |
639639
| `--run-id <value>` | No | One-shot identifier for this run; NOT an idempotency key — reusing a claimed value fails with RUN_ID_CONFLICT instead of replaying the first result, and a fresh value starts another run. |

‎apps/sim/lib/workflows/application/execute-manual-workflow.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ import {
1010
executeWorkflowService,
1111
} from '@/lib/workflows/executor/execute-service'
1212
import { getExecutionStateForWorkflow } from '@/lib/workflows/executor/execution-state'
13-
import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils'
13+
import {
14+
loadWorkflowFromNormalizedTables,
15+
type NormalizedWorkflowData,
16+
} from '@/lib/workflows/persistence/utils'
1417
import {
1518
resolveTriggerRunOptions,
1619
validateTriggerInput,
@@ -119,6 +122,7 @@ function executionServiceInput(params: {
119122
principal: WorkflowExecutionPrincipal
120123
context: Awaited<ReturnType<typeof resolveActiveWorkflowApplicationContext>>
121124
input: ManualExecutionInput
125+
draftState: NormalizedWorkflowData
122126
}) {
123127
return {
124128
workflowId: params.context.workflowId,
@@ -141,6 +145,7 @@ function executionServiceInput(params: {
141145
includeToolCalls: params.input.includeToolCalls,
142146
triggerType: 'manual' as const,
143147
useDraftState: true,
148+
draftState: params.draftState,
144149
}
145150
}
146151

@@ -189,7 +194,7 @@ export const executeManualWorkflowOperation = defineAuthorizedWorkflowUseCase({
189194
}
190195

191196
return executeWorkflowService({
192-
...executionServiceInput({ principal, context, input }),
197+
...executionServiceInput({ principal, context, input, draftState: state }),
193198
input: executionInput,
194199
triggerBlockId: selected.triggerBlockId,
195200
})
@@ -218,7 +223,7 @@ export const executeManualWorkflowFromBlockOperation = defineAuthorizedWorkflowU
218223
}
219224

220225
return executeWorkflowService({
221-
...executionServiceInput({ principal, context, input }),
226+
...executionServiceInput({ principal, context, input, draftState: state }),
222227
input: input.input,
223228
runFromBlock: {
224229
startBlockId: input.blockId,

‎apps/sim/lib/workflows/executor/execute-service.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
loadDeployedWorkflowState,
3131
loadWorkflowDeploymentVersionState,
3232
loadWorkflowFromNormalizedTables,
33+
type NormalizedWorkflowData,
3334
} from '@/lib/workflows/persistence/utils'
3435
import { shouldEmitAgentStreamEvents } from '@/lib/workflows/streaming/agent-stream-protocol'
3536
import { resolveOutputSelectors } from '@/lib/workflows/streaming/resolve-output-selectors'
@@ -110,6 +111,12 @@ export interface ExecuteWorkflowServiceParams {
110111
includeToolCalls?: boolean
111112
/** Execute the current saved state manually instead of the active deployment. */
112113
useDraftState?: boolean
114+
/**
115+
* The saved state the application use case loaded to choose and validate the entry
116+
* point. Requires `useDraftState`; the run executes this same snapshot instead of
117+
* reading the draft tables again.
118+
*/
119+
draftState?: NormalizedWorkflowData
113120
/** Explicit trigger entry point selected and validated by the application use case. */
114121
triggerBlockId?: string
115122
/** Trusted prior-run snapshot resolved by the application use case. */
@@ -272,6 +279,7 @@ export async function executeWorkflowService(
272279
includeThinking = false,
273280
includeToolCalls = false,
274281
useDraftState = false,
282+
draftState,
275283
triggerBlockId,
276284
runFromBlock,
277285
stopAfterBlockId,
@@ -295,6 +303,9 @@ export async function executeWorkflowService(
295303
if (stopAfterBlockId && !useDraftState) {
296304
throw new Error('Stop-after-block requires manual execution state')
297305
}
306+
if (draftState && !useDraftState) {
307+
throw new Error('A preloaded draft state requires manual execution state')
308+
}
298309

299310
if (callChain) {
300311
const chainError = validateCallChain(callChain)
@@ -467,7 +478,7 @@ export async function executeWorkflowService(
467478
let workflowBlocks: Record<string, unknown> = {}
468479
try {
469480
const workflowData = useDraftState
470-
? await loadWorkflowFromNormalizedTables(workflowId)
481+
? (draftState ?? (await loadWorkflowFromNormalizedTables(workflowId)))
471482
: deploymentVersionId
472483
? await loadWorkflowDeploymentVersionState(workflowId, deploymentVersionId, workspaceId)
473484
: await loadDeployedWorkflowState(workflowId, workspaceId)
@@ -594,6 +605,7 @@ export async function executeWorkflowService(
594605
workflowTriggerType: triggerType,
595606
triggerBlockId,
596607
useDraftState,
608+
draftState,
597609
runFromBlock,
598610
stopAfterBlockId,
599611
onStream,
@@ -697,6 +709,7 @@ export async function executeWorkflowService(
697709
abortSignal: timeoutController.signal,
698710
runFromBlock,
699711
stopAfterBlockId,
712+
draftState,
700713
})
701714

702715
await handlePostExecutionPauseState({ result, workflowId, executionId, loggingSession })

‎apps/sim/lib/workflows/executor/execute-workflow.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { LoggingSession } from '@/lib/logs/execution/logging-session'
1111
import { captureServerEvent } from '@/lib/posthog/server'
1212
import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core'
1313
import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence'
14+
import type { NormalizedWorkflowData } from '@/lib/workflows/persistence/utils'
1415
import { ExecutionSnapshot } from '@/executor/execution/snapshot'
1516
import type {
1617
BlockCompletionCallbackData,
@@ -54,6 +55,8 @@ export interface ExecuteWorkflowOptions {
5455
abortSignal?: AbortSignal
5556
/** Use the live/draft workflow state instead of the deployed state. Used by copilot. */
5657
useDraftState?: boolean
58+
/** Draft state the caller already loaded, reused instead of reading the draft tables again. */
59+
draftState?: NormalizedWorkflowData
5760
/** Immutable workflow state selected by a trusted server-side trigger boundary. */
5861
workflowStateOverride?: NonNullable<ExecutionMetadata['workflowStateOverride']>
5962
/** Stop execution after this block completes. Used for "run until block" feature. */
@@ -226,6 +229,7 @@ export async function executeWorkflow(
226229
trustedInitialResolvedSecretTraceProvenance:
227230
streamConfig?.trustedInitialResolvedSecretTraceProvenance,
228231
runFromBlock: streamConfig?.runFromBlock,
232+
draftState: streamConfig?.draftState,
229233
}))
230234

231235
const blockTypes = [

‎apps/sim/lib/workflows/executor/execution-core.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
loadDeployedWorkflowState,
4444
loadWorkflowDeploymentVersionState,
4545
loadWorkflowFromNormalizedTables,
46+
type NormalizedWorkflowData,
4647
} from '@/lib/workflows/persistence/utils'
4748
import { TriggerUtils } from '@/lib/workflows/triggers/triggers'
4849
import { updateWorkflowRunCounts } from '@/lib/workflows/utils'
@@ -143,6 +144,12 @@ export interface ExecuteWorkflowCoreOptions {
143144
trustedInitialResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
144145
/** Immutable deployment admitted by the durable parent log for a resumed execution. */
145146
resumeDeploymentVersionId?: string
147+
/**
148+
* Draft state the caller already loaded for this draft-state run. Reused instead of
149+
* reading the draft tables again, so the graph that executes is the snapshot the
150+
* caller validated its trigger and output selectors against.
151+
*/
152+
draftState?: NormalizedWorkflowData
146153
/**
147154
* Environment the caller already resolved for this run, reused instead of loading
148155
* and decrypting it again. Used only when it was resolved for exactly the
@@ -627,6 +634,7 @@ async function executeWorkflowCoreImpl(
627634
stopAfterBlockId,
628635
runFromBlock,
629636
resumeDeploymentVersionId,
637+
draftState,
630638
} = options
631639
loggingSession.setExecutionDeadlineAt(getExecutionDeadlineAt(abortSignal))
632640
const { metadata, input, workflowVariables, selectedOutputs } = snapshot
@@ -721,7 +729,7 @@ async function executeWorkflowCoreImpl(
721729
}
722730

723731
if (useDraftState) {
724-
const draftData = await loadWorkflowFromNormalizedTables(workflowId)
732+
const draftData = draftState ?? (await loadWorkflowFromNormalizedTables(workflowId))
725733

726734
if (!draftData) {
727735
throw new Error('Workflow not found or not yet saved')

‎apps/sim/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"test:workflow-version-compare:e2e": "bun --no-env-file scripts/test-workflow-version-compare-e2e.ts",
2626
"test:workflow-stop-after:e2e": "bun --no-env-file scripts/test-workflow-stop-after-e2e.ts",
2727
"test:desktop-inbox:e2e": "bun --no-env-file scripts/test-desktop-inbox-e2e.ts",
28+
"test:cli-run-latency:e2e": "bun --no-env-file scripts/test-cli-run-latency-e2e.ts",
2829
"test:watch": "vitest",
2930
"test:coverage": "vitest run --coverage",
3031
"email:dev": "email dev --dir components/emails",

0 commit comments

Comments
 (0)