|
| 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 | +} |
0 commit comments