Skip to content

Commit 9200724

Browse files
committed
refactor(logs): extract the legacy workflow-input reader into a shared module
Moves extractLegacyWorkflowInput out of execution-state.ts so both the functional re-run reader and the log display projection can use it without a circular import (execution-state already imports trace-store). Adds two display-only helpers alongside it: hasPersistedBlockStates, and recoverLegacyWorkflowInputForDisplay, which narrows the raw trigger block output to the shape workflowInput originally held. The functional reader keeps first-match behavior; only the display path refuses to guess. No behavior change to re-execution.
1 parent 6c10ac2 commit 9200724

2 files changed

Lines changed: 139 additions & 19 deletions

File tree

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { isRecordLike, omit } from '@sim/utils/object'
2+
3+
/**
4+
* The shape the executor records for a trigger block: never executed, zero
5+
* duration, populated output (`executor.ts` `setBlockState` after
6+
* `buildStartBlockOutput`).
7+
*
8+
* The shape is not unique to the trigger. A human-in-the-loop pause writes a
9+
* placeholder block state with the same three properties (`block-executor.ts`,
10+
* `{ url, resumeEndpoint }` output), so a run that paused has more than one
11+
* match. Callers that cannot tolerate the wrong block must disambiguate - see
12+
* {@link recoverLegacyWorkflowInputForDisplay}.
13+
*/
14+
function isLegacyTriggerBlockState(state: unknown): state is { output: unknown } {
15+
return (
16+
isRecordLike(state) &&
17+
state.executed === false &&
18+
state.executionTime === 0 &&
19+
state.output != null
20+
)
21+
}
22+
23+
function collectLegacyTriggerOutputs(executionData: Record<string, unknown>): unknown[] {
24+
if (!isRecordLike(executionData.executionState)) return []
25+
const { blockStates } = executionData.executionState
26+
if (!isRecordLike(blockStates)) return []
27+
28+
const outputs: unknown[] = []
29+
for (const state of Object.values(blockStates)) {
30+
if (isLegacyTriggerBlockState(state)) outputs.push(state.output)
31+
}
32+
return outputs
33+
}
34+
35+
/**
36+
* Recovers the inbound trigger payload from execution data written before
37+
* `workflowInput` was persisted as a top-level field.
38+
*
39+
* Returns the first matching block state, preserving the long-standing
40+
* behavior of the functional re-run reader. Display callers must use
41+
* {@link recoverLegacyWorkflowInputForDisplay}, which refuses to guess.
42+
*/
43+
export function extractLegacyWorkflowInput(
44+
executionData: Record<string, unknown>
45+
): unknown | undefined {
46+
return collectLegacyTriggerOutputs(executionData)[0]
47+
}
48+
49+
/**
50+
* Whether the persisted state carries block states at all.
51+
*
52+
* Callers pair this with an absent provenance key. The trace registry is
53+
* attached before the executor runs (`execution-core.ts` installs it ahead of
54+
* `safeStart`), so any execution that produced block states also stamped
55+
* provenance. An absent key together with populated block states therefore
56+
* identifies pre-stamping data, and never a post-stamping run that failed
57+
* early enough to miss the stamp - those carry no block states to recover from.
58+
*/
59+
export function hasPersistedBlockStates(executionData: Record<string, unknown>): boolean {
60+
if (!isRecordLike(executionData.executionState)) return false
61+
const { blockStates } = executionData.executionState
62+
return isRecordLike(blockStates) && Object.keys(blockStates).length > 0
63+
}
64+
65+
/**
66+
* Keys that the trigger block hoists next to a nested `input` payload. Blob
67+
* forensics on pre-persistence executions show the block output is a strict
68+
* superset of the original `workflowInput` in this shape, so the recovered
69+
* value is projected back down to `{ input }`.
70+
*/
71+
const NESTED_INPUT_KEY = 'input'
72+
73+
/**
74+
* Whether the nested `input` is merely a clone of its sibling keys.
75+
*
76+
* `buildApiOrInputOutput` records an object input as
77+
* `{ ...finalInput, input: { ...finalInput } }`, so the original
78+
* `workflowInput` was the FLAT object and the nested copy is redundant.
79+
* Narrowing that shape to `{ input }` would display something the run never
80+
* received. The superset shape the narrowing targets is distinguishable: its
81+
* nested `input` carries keys the siblings do not.
82+
*/
83+
function isNestedInputSiblingClone(recovered: Record<string, unknown>): boolean {
84+
const nested = recovered[NESTED_INPUT_KEY]
85+
if (!isRecordLike(nested)) return false
86+
87+
const siblings = omit(recovered, [NESTED_INPUT_KEY])
88+
const siblingKeys = Object.keys(siblings)
89+
if (siblingKeys.length === 0 || siblingKeys.length !== Object.keys(nested).length) return false
90+
91+
return siblingKeys.every(
92+
(key) =>
93+
Object.hasOwn(nested, key) && JSON.stringify(siblings[key]) === JSON.stringify(nested[key])
94+
)
95+
}
96+
97+
/**
98+
* A Slack verification token echoed into the trigger block output. It is the
99+
* only key that diverges from the original `workflowInput` in the Slack
100+
* envelope shape, and it is secret-shaped, so it is dropped rather than
101+
* displayed with a value that is both wrong and sensitive.
102+
*/
103+
const DROPPED_RECOVERED_KEYS = ['token'] as const
104+
105+
/**
106+
* Recovers `workflowInput` for the log display projection, narrowing the raw
107+
* trigger block output to the shape the field originally held. Functional
108+
* readers must keep using {@link extractLegacyWorkflowInput} directly - this
109+
* narrowing is display-only and intentionally lossy.
110+
*
111+
* Callers must restrict this to executions written before resolved-secret
112+
* provenance was stamped. Block state is gated content, and this routes it to
113+
* the ungated workflow-boundary envelope; that is only sound while the matched
114+
* block is the trigger, which holds the payload captured before any secret was
115+
* resolved.
116+
*
117+
* Recovery is therefore refused when more than one block state matches the
118+
* trigger shape - a paused run also carries a resume placeholder with the same
119+
* shape, and showing its `{ url, resumeEndpoint }` output labelled as the
120+
* workflow input would be both wrong and a capability-URL disclosure. An empty
121+
* panel beats confidently wrong content.
122+
*/
123+
export function recoverLegacyWorkflowInputForDisplay(
124+
executionData: Record<string, unknown>
125+
): unknown | undefined {
126+
const candidates = collectLegacyTriggerOutputs(executionData)
127+
if (candidates.length !== 1) return undefined
128+
129+
const recovered = candidates[0]
130+
if (!isRecordLike(recovered)) return recovered
131+
132+
const narrowed: Record<string, unknown> =
133+
isRecordLike(recovered[NESTED_INPUT_KEY]) && !isNestedInputSiblingClone(recovered)
134+
? { [NESTED_INPUT_KEY]: recovered[NESTED_INPUT_KEY] }
135+
: recovered
136+
137+
return omit(narrowed, [...DROPPED_RECOVERED_KEYS])
138+
}

apps/sim/lib/workflows/executor/execution-state.ts

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { db } from '@sim/db'
22
import { workflowExecutionLogs } from '@sim/db/schema'
33
import { isRecordLike } from '@sim/utils/object'
44
import { and, desc, eq, or, sql } from 'drizzle-orm'
5+
import { extractLegacyWorkflowInput } from '@/lib/logs/execution/legacy-workflow-input'
56
import { materializeExecutionData, TRACE_STORE_REF_KEY } from '@/lib/logs/execution/trace-store'
67
import type { SerializableExecutionState } from '@/executor/execution/types'
78
import {
@@ -35,25 +36,6 @@ function extractExecutionState(executionData: unknown): SerializableExecutionSta
3536
return isSerializableExecutionState(state) ? state : null
3637
}
3738

38-
function extractLegacyWorkflowInput(executionData: Record<string, unknown>): unknown | undefined {
39-
if (!isRecordLike(executionData.executionState)) return undefined
40-
const { blockStates } = executionData.executionState
41-
if (!isRecordLike(blockStates)) return undefined
42-
43-
for (const state of Object.values(blockStates)) {
44-
if (
45-
isRecordLike(state) &&
46-
state.executed === false &&
47-
state.executionTime === 0 &&
48-
state.output != null
49-
) {
50-
return state.output
51-
}
52-
}
53-
54-
return undefined
55-
}
56-
5739
interface ExecutionStateRow {
5840
executionId: string
5941
workflowId: string | null

0 commit comments

Comments
 (0)