Skip to content

Commit 3ab5c76

Browse files
authored
standardize output (#146)
* standardize output * resend * linear * perplexity * webflow + fal * fix user cards * another catch
1 parent 05a05af commit 3ab5c76

29 files changed

+715
-314
lines changed

AGENTS.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,18 @@ If any of the above commands fail or show errors:
8383
- **No dependencies field**: Do not use the `dependencies` field in plugin `index.ts` files. All API calls should use native `fetch`.
8484
- **Why**: Using `fetch` instead of SDKs reduces supply chain attack surface. SDKs have transitive dependencies that could be compromised.
8585

86+
## Step Output Format
87+
All plugin steps must return a standardized output format:
88+
89+
```typescript
90+
// Success
91+
return { success: true, data: { id: "...", name: "..." } };
92+
93+
// Error
94+
return { success: false, error: { message: "Error description" } };
95+
```
96+
97+
- **outputFields** in plugin `index.ts` should reference fields without `data.` prefix (e.g., `{ field: "id" }` not `{ field: "data.id" }`)
98+
- Template variables automatically unwrap: `{{GetUser.firstName}}` resolves to `data.firstName`
99+
- Logs display only the inner `data` or `error` object, not the full wrapper
100+

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,7 @@ Visit [http://localhost:3000](http://localhost:3000) to get started.
9292
- **Stripe**: Create Customer, Get Customer, Create Invoice
9393
- **Superagent**: Guard, Redact
9494
- **v0**: Create Chat, Send Message
95+
- **Webflow**: List Sites, Get Site, Publish Site
9596
<!-- PLUGINS:END -->
9697

9798
## Code Generation

components/workflow/workflow-runs.tsx

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
executionLogsAtom,
2828
selectedExecutionIdAtom,
2929
} from "@/lib/workflow-store";
30+
import { findActionById } from "@/plugins";
3031
import { Button } from "../ui/button";
3132
import { Spinner } from "../ui/spinner";
3233

@@ -68,7 +69,7 @@ function getOutputConfig(nodeType: string): OutputDisplayConfig | undefined {
6869
// Helper to extract the displayable value from output based on config
6970
function getOutputDisplayValue(
7071
output: unknown,
71-
config: OutputDisplayConfig
72+
config: { type: "image" | "video" | "url"; field: string }
7273
): string | undefined {
7374
if (typeof output !== "object" || output === null) {
7475
return;
@@ -265,31 +266,51 @@ function CollapsibleSection({
265266
function OutputDisplay({
266267
output,
267268
input,
269+
actionType,
268270
}: {
269271
output: unknown;
270272
input?: unknown;
273+
actionType?: string;
271274
}) {
272-
// Get actionType from input to look up the output config
273-
const actionType =
274-
typeof input === "object" && input !== null
275-
? (input as Record<string, unknown>).actionType
276-
: undefined;
277-
const config =
278-
typeof actionType === "string" ? getOutputConfig(actionType) : undefined;
279-
const displayValue = config
280-
? getOutputDisplayValue(output, config)
275+
// Look up action from plugin registry to get outputConfig (including custom components)
276+
const action = actionType ? findActionById(actionType) : undefined;
277+
const pluginConfig = action?.outputConfig;
278+
279+
// Fall back to auto-generated config for legacy support (only built-in types)
280+
const builtInConfig = actionType ? getOutputConfig(actionType) : undefined;
281+
282+
// Get the effective built-in config (plugin config if not component, else auto-generated)
283+
const effectiveBuiltInConfig =
284+
pluginConfig?.type !== "component" ? pluginConfig : builtInConfig;
285+
286+
// Get display value for built-in types (image/video/url)
287+
const displayValue = effectiveBuiltInConfig
288+
? getOutputDisplayValue(output, effectiveBuiltInConfig)
281289
: undefined;
282290

283291
// Check for legacy base64 image
284-
const isLegacyBase64 = !config && isBase64ImageOutput(output);
292+
const isLegacyBase64 =
293+
!(pluginConfig || builtInConfig) && isBase64ImageOutput(output);
285294

286295
const renderRichResult = () => {
287-
if (config && displayValue) {
288-
switch (config.type) {
296+
// Priority 1: Custom component from plugin outputConfig
297+
if (pluginConfig?.type === "component") {
298+
const CustomComponent = pluginConfig.component;
299+
return (
300+
<div className="overflow-hidden rounded-lg border bg-muted/50 p-3">
301+
<CustomComponent input={input} output={output} />
302+
</div>
303+
);
304+
}
305+
306+
// Priority 2: Built-in output config (image/video/url)
307+
if (effectiveBuiltInConfig && displayValue) {
308+
switch (effectiveBuiltInConfig.type) {
289309
case "image": {
290310
// Handle base64 images by adding data URI prefix if needed
291311
const imageSrc =
292-
config.field === "base64" && !displayValue.startsWith("data:")
312+
effectiveBuiltInConfig.field === "base64" &&
313+
!displayValue.startsWith("data:")
293314
? `data:image/png;base64,${displayValue}`
294315
: displayValue;
295316
return (
@@ -355,6 +376,12 @@ function OutputDisplay({
355376
const richResult = renderRichResult();
356377
const hasRichResult = richResult !== null;
357378

379+
// Determine external link for URL type configs
380+
const externalLink =
381+
effectiveBuiltInConfig?.type === "url" && displayValue
382+
? displayValue
383+
: undefined;
384+
358385
return (
359386
<>
360387
{/* Always show JSON output */}
@@ -368,7 +395,7 @@ function OutputDisplay({
368395
{hasRichResult && (
369396
<CollapsibleSection
370397
defaultExpanded
371-
externalLink={config?.type === "url" ? displayValue : undefined}
398+
externalLink={externalLink}
372399
title="Result"
373400
>
374401
{richResult}
@@ -458,7 +485,11 @@ function ExecutionLogEntry({
458485
</CollapsibleSection>
459486
)}
460487
{log.output !== null && log.output !== undefined && (
461-
<OutputDisplay input={log.input} output={log.output} />
488+
<OutputDisplay
489+
actionType={log.nodeType}
490+
input={log.input}
491+
output={log.output}
492+
/>
462493
)}
463494
{log.error && (
464495
<CollapsibleSection

lib/steps/step-handler.ts

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -90,11 +90,21 @@ async function logStepComplete(
9090
}
9191

9292
/**
93-
* Strip _context from input for logging (we don't want to log internal metadata)
93+
* Internal fields to strip from logged input
9494
*/
95-
function stripContext<T extends StepInput>(input: T): Omit<T, "_context"> {
96-
const { _context, ...rest } = input;
97-
return rest as Omit<T, "_context">;
95+
const INTERNAL_FIELDS = ["_context", "actionType", "integrationId"] as const;
96+
97+
/**
98+
* Strip internal fields from input for logging (we don't want to log internal metadata)
99+
*/
100+
function stripInternalFields<T extends StepInput>(
101+
input: T
102+
): Omit<T, "_context" | "actionType" | "integrationId"> {
103+
const result = { ...input };
104+
for (const field of INTERNAL_FIELDS) {
105+
delete (result as Record<string, unknown>)[field];
106+
}
107+
return result as Omit<T, "_context" | "actionType" | "integrationId">;
98108
}
99109

100110
/**
@@ -160,30 +170,45 @@ export async function withStepLogging<TInput extends StepInput, TOutput>(
160170
input: TInput,
161171
stepLogic: () => Promise<TOutput>
162172
): Promise<TOutput> {
163-
// Extract context and log input without _context
173+
// Extract context and log input without internal fields
164174
const context = input._context as StepContextWithWorkflow | undefined;
165-
const loggedInput = stripContext(input);
175+
const loggedInput = stripInternalFields(input);
166176
const logInfo = await logStepStart(context, loggedInput);
167177

168178
try {
169179
const result = await stepLogic();
170180

171-
// Check if result indicates an error
172-
const isErrorResult =
181+
// Check if result has standardized format { success, data } or { success, error }
182+
const isStandardizedResult =
173183
result &&
174184
typeof result === "object" &&
175185
"success" in result &&
186+
typeof (result as { success: unknown }).success === "boolean";
187+
188+
// Check if result indicates an error
189+
const isErrorResult =
190+
isStandardizedResult &&
176191
(result as { success: boolean }).success === false;
177192

178193
if (isErrorResult) {
179-
const errorResult = result as { success: false; error?: string };
180-
await logStepComplete(
181-
logInfo,
182-
"error",
183-
result,
184-
errorResult.error || "Step execution failed"
185-
);
194+
const errorResult = result as {
195+
success: false;
196+
error?: string | { message: string };
197+
};
198+
// Support both old format (error: string) and new format (error: { message: string })
199+
const errorMessage =
200+
typeof errorResult.error === "string"
201+
? errorResult.error
202+
: errorResult.error?.message || "Step execution failed";
203+
// Log just the error object, not the full result
204+
const loggedOutput = errorResult.error ?? { message: errorMessage };
205+
await logStepComplete(logInfo, "error", loggedOutput, errorMessage);
206+
} else if (isStandardizedResult) {
207+
// For standardized success results, log just the data
208+
const successResult = result as { success: true; data?: unknown };
209+
await logStepComplete(logInfo, "success", successResult.data ?? result);
186210
} else {
211+
// For non-standardized results, log as-is
187212
await logStepComplete(logInfo, "success", result);
188213
}
189214

lib/utils/template.ts

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,33 @@ export function processConfigTemplates(
160160
/**
161161
* Resolve a field path in data like "field.nested" or "items[0]"
162162
*/
163+
/**
164+
* Check if data has the standardized step output format: { success: boolean, data: {...} }
165+
*/
166+
function isStandardizedOutput(
167+
data: unknown
168+
): data is { success: boolean; data: unknown } {
169+
return (
170+
data !== null &&
171+
typeof data === "object" &&
172+
"success" in data &&
173+
"data" in data &&
174+
typeof (data as Record<string, unknown>).success === "boolean"
175+
);
176+
}
177+
178+
/**
179+
* Unwrap standardized output to get the inner data
180+
* For { success: true, data: {...} } returns the inner data object
181+
*/
182+
function unwrapStandardizedOutput(data: unknown): unknown {
183+
if (isStandardizedOutput(data)) {
184+
return data.data;
185+
}
186+
return data;
187+
}
188+
189+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Field path resolution requires nested logic for arrays and standardized outputs
163190
function resolveFieldPath(data: unknown, fieldPath: string): unknown {
164191
if (!data) {
165192
return;
@@ -168,6 +195,18 @@ function resolveFieldPath(data: unknown, fieldPath: string): unknown {
168195
const parts = fieldPath.split(".");
169196
let current: unknown = data;
170197

198+
// For standardized outputs, automatically look inside data.data
199+
// unless explicitly accessing 'success', 'data', or 'error'
200+
const firstPart = parts[0]?.trim();
201+
if (
202+
isStandardizedOutput(current) &&
203+
firstPart !== "success" &&
204+
firstPart !== "data" &&
205+
firstPart !== "error"
206+
) {
207+
current = current.data;
208+
}
209+
171210
for (const part of parts) {
172211
const trimmedPart = part.trim();
173212

@@ -449,9 +488,13 @@ export function getAvailableFields(nodeOutputs: NodeOutputs): Array<{
449488
sample: output.data,
450489
});
451490

491+
// For standardized outputs, extract fields from inside data
492+
// so autocomplete shows firstName instead of data.firstName
493+
const dataToExtract = unwrapStandardizedOutput(output.data);
494+
452495
// Add individual fields if data is an object
453-
if (output.data && typeof output.data === "object") {
454-
extractFields(output.data, output.label, fields, {
496+
if (dataToExtract && typeof dataToExtract === "object") {
497+
extractFields(dataToExtract, output.label, fields, {
455498
currentPath: `{{${output.label}`,
456499
});
457500
}

lib/workflow-executor.workflow.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export type WorkflowExecutionInput = {
5656
* Helper to replace template variables in conditions
5757
*/
5858
// biome-ignore lint/nursery/useMaxParams: Helper function needs all parameters for template replacement
59+
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Template variable replacement requires nested logic for standardized outputs
5960
function replaceTemplateVariable(
6061
match: string,
6162
nodeId: string,
@@ -85,6 +86,21 @@ function replaceTemplateVariable(
8586
// biome-ignore lint/suspicious/noExplicitAny: Dynamic data traversal
8687
let current: any = output.data;
8788

89+
// For standardized outputs { success, data, error }, automatically look inside data
90+
// unless explicitly accessing success/data/error
91+
const firstField = fields[0];
92+
if (
93+
current &&
94+
typeof current === "object" &&
95+
"success" in current &&
96+
"data" in current &&
97+
firstField !== "success" &&
98+
firstField !== "data" &&
99+
firstField !== "error"
100+
) {
101+
current = current.data;
102+
}
103+
88104
for (const field of fields) {
89105
if (current && typeof current === "object") {
90106
current = current[field];
@@ -310,6 +326,21 @@ function processTemplates(
310326
// biome-ignore lint/suspicious/noExplicitAny: Dynamic output data traversal
311327
let current: any = output.data;
312328

329+
// For standardized outputs { success, data, error }, automatically look inside data
330+
// unless explicitly accessing success/data/error
331+
const firstField = fields[0];
332+
if (
333+
current &&
334+
typeof current === "object" &&
335+
"success" in current &&
336+
"data" in current &&
337+
firstField !== "success" &&
338+
firstField !== "data" &&
339+
firstField !== "error"
340+
) {
341+
current = current.data;
342+
}
343+
313344
for (const field of fields) {
314345
if (current && typeof current === "object") {
315346
current = current[field];
@@ -555,12 +586,19 @@ export async function executeWorkflow(input: WorkflowExecutionInput) {
555586
(stepResult as { success: boolean }).success === false;
556587

557588
if (isErrorResult) {
558-
const errorResult = stepResult as { success: false; error?: string };
589+
const errorResult = stepResult as {
590+
success: false;
591+
error?: string | { message: string };
592+
};
593+
// Support both old format (error: string) and new format (error: { message: string })
594+
const errorMessage =
595+
typeof errorResult.error === "string"
596+
? errorResult.error
597+
: errorResult.error?.message ||
598+
`Step "${actionType}" in node "${node.data.label || node.id}" failed without a specific error message.`;
559599
result = {
560600
success: false,
561-
error:
562-
errorResult.error ||
563-
`Step "${actionType}" in node "${node.data.label || node.id}" failed without a specific error message.`,
601+
error: errorMessage,
564602
};
565603
} else {
566604
result = {

0 commit comments

Comments
 (0)