Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,18 @@ If any of the above commands fail or show errors:
- **No dependencies field**: Do not use the `dependencies` field in plugin `index.ts` files. All API calls should use native `fetch`.
- **Why**: Using `fetch` instead of SDKs reduces supply chain attack surface. SDKs have transitive dependencies that could be compromised.

## Step Output Format
All plugin steps must return a standardized output format:

```typescript
// Success
return { success: true, data: { id: "...", name: "..." } };

// Error
return { success: false, error: { message: "Error description" } };
```

- **outputFields** in plugin `index.ts` should reference fields without `data.` prefix (e.g., `{ field: "id" }` not `{ field: "data.id" }`)
- Template variables automatically unwrap: `{{GetUser.firstName}}` resolves to `data.firstName`
- Logs display only the inner `data` or `error` object, not the full wrapper

1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ Visit [http://localhost:3000](http://localhost:3000) to get started.
- **Stripe**: Create Customer, Get Customer, Create Invoice
- **Superagent**: Guard, Redact
- **v0**: Create Chat, Send Message
- **Webflow**: List Sites, Get Site, Publish Site
<!-- PLUGINS:END -->

## Code Generation
Expand Down
63 changes: 47 additions & 16 deletions components/workflow/workflow-runs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
executionLogsAtom,
selectedExecutionIdAtom,
} from "@/lib/workflow-store";
import { findActionById } from "@/plugins";
import { Button } from "../ui/button";
import { Spinner } from "../ui/spinner";

Expand Down Expand Up @@ -68,7 +69,7 @@ function getOutputConfig(nodeType: string): OutputDisplayConfig | undefined {
// Helper to extract the displayable value from output based on config
function getOutputDisplayValue(
output: unknown,
config: OutputDisplayConfig
config: { type: "image" | "video" | "url"; field: string }
): string | undefined {
if (typeof output !== "object" || output === null) {
return;
Expand Down Expand Up @@ -265,31 +266,51 @@ function CollapsibleSection({
function OutputDisplay({
output,
input,
actionType,
}: {
output: unknown;
input?: unknown;
actionType?: string;
}) {
// Get actionType from input to look up the output config
const actionType =
typeof input === "object" && input !== null
? (input as Record<string, unknown>).actionType
: undefined;
const config =
typeof actionType === "string" ? getOutputConfig(actionType) : undefined;
const displayValue = config
? getOutputDisplayValue(output, config)
// Look up action from plugin registry to get outputConfig (including custom components)
const action = actionType ? findActionById(actionType) : undefined;
const pluginConfig = action?.outputConfig;

// Fall back to auto-generated config for legacy support (only built-in types)
const builtInConfig = actionType ? getOutputConfig(actionType) : undefined;

// Get the effective built-in config (plugin config if not component, else auto-generated)
const effectiveBuiltInConfig =
pluginConfig?.type !== "component" ? pluginConfig : builtInConfig;

// Get display value for built-in types (image/video/url)
const displayValue = effectiveBuiltInConfig
? getOutputDisplayValue(output, effectiveBuiltInConfig)
: undefined;

// Check for legacy base64 image
const isLegacyBase64 = !config && isBase64ImageOutput(output);
const isLegacyBase64 =
!(pluginConfig || builtInConfig) && isBase64ImageOutput(output);

const renderRichResult = () => {
if (config && displayValue) {
switch (config.type) {
// Priority 1: Custom component from plugin outputConfig
if (pluginConfig?.type === "component") {
const CustomComponent = pluginConfig.component;
return (
<div className="overflow-hidden rounded-lg border bg-muted/50 p-3">
<CustomComponent input={input} output={output} />
</div>
);
}

// Priority 2: Built-in output config (image/video/url)
if (effectiveBuiltInConfig && displayValue) {
switch (effectiveBuiltInConfig.type) {
case "image": {
// Handle base64 images by adding data URI prefix if needed
const imageSrc =
config.field === "base64" && !displayValue.startsWith("data:")
effectiveBuiltInConfig.field === "base64" &&
!displayValue.startsWith("data:")
? `data:image/png;base64,${displayValue}`
: displayValue;
return (
Expand Down Expand Up @@ -355,6 +376,12 @@ function OutputDisplay({
const richResult = renderRichResult();
const hasRichResult = richResult !== null;

// Determine external link for URL type configs
const externalLink =
effectiveBuiltInConfig?.type === "url" && displayValue
? displayValue
: undefined;

return (
<>
{/* Always show JSON output */}
Expand All @@ -368,7 +395,7 @@ function OutputDisplay({
{hasRichResult && (
<CollapsibleSection
defaultExpanded
externalLink={config?.type === "url" ? displayValue : undefined}
externalLink={externalLink}
title="Result"
>
{richResult}
Expand Down Expand Up @@ -458,7 +485,11 @@ function ExecutionLogEntry({
</CollapsibleSection>
)}
{log.output !== null && log.output !== undefined && (
<OutputDisplay input={log.input} output={log.output} />
<OutputDisplay
actionType={log.nodeType}
input={log.input}
output={log.output}
/>
)}
{log.error && (
<CollapsibleSection
Expand Down
55 changes: 40 additions & 15 deletions lib/steps/step-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,21 @@ async function logStepComplete(
}

/**
* Strip _context from input for logging (we don't want to log internal metadata)
* Internal fields to strip from logged input
*/
function stripContext<T extends StepInput>(input: T): Omit<T, "_context"> {
const { _context, ...rest } = input;
return rest as Omit<T, "_context">;
const INTERNAL_FIELDS = ["_context", "actionType", "integrationId"] as const;

/**
* Strip internal fields from input for logging (we don't want to log internal metadata)
*/
function stripInternalFields<T extends StepInput>(
input: T
): Omit<T, "_context" | "actionType" | "integrationId"> {
const result = { ...input };
for (const field of INTERNAL_FIELDS) {
delete (result as Record<string, unknown>)[field];
}
return result as Omit<T, "_context" | "actionType" | "integrationId">;
}

/**
Expand Down Expand Up @@ -160,30 +170,45 @@ export async function withStepLogging<TInput extends StepInput, TOutput>(
input: TInput,
stepLogic: () => Promise<TOutput>
): Promise<TOutput> {
// Extract context and log input without _context
// Extract context and log input without internal fields
const context = input._context as StepContextWithWorkflow | undefined;
const loggedInput = stripContext(input);
const loggedInput = stripInternalFields(input);
const logInfo = await logStepStart(context, loggedInput);

try {
const result = await stepLogic();

// Check if result indicates an error
const isErrorResult =
// Check if result has standardized format { success, data } or { success, error }
const isStandardizedResult =
result &&
typeof result === "object" &&
"success" in result &&
typeof (result as { success: unknown }).success === "boolean";

// Check if result indicates an error
const isErrorResult =
isStandardizedResult &&
(result as { success: boolean }).success === false;

if (isErrorResult) {
const errorResult = result as { success: false; error?: string };
await logStepComplete(
logInfo,
"error",
result,
errorResult.error || "Step execution failed"
);
const errorResult = result as {
success: false;
error?: string | { message: string };
};
// Support both old format (error: string) and new format (error: { message: string })
const errorMessage =
typeof errorResult.error === "string"
? errorResult.error
: errorResult.error?.message || "Step execution failed";
// Log just the error object, not the full result
const loggedOutput = errorResult.error ?? { message: errorMessage };
await logStepComplete(logInfo, "error", loggedOutput, errorMessage);
} else if (isStandardizedResult) {
// For standardized success results, log just the data
const successResult = result as { success: true; data?: unknown };
await logStepComplete(logInfo, "success", successResult.data ?? result);
} else {
// For non-standardized results, log as-is
await logStepComplete(logInfo, "success", result);
}

Expand Down
47 changes: 45 additions & 2 deletions lib/utils/template.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,33 @@ export function processConfigTemplates(
/**
* Resolve a field path in data like "field.nested" or "items[0]"
*/
/**
* Check if data has the standardized step output format: { success: boolean, data: {...} }
*/
function isStandardizedOutput(
data: unknown
): data is { success: boolean; data: unknown } {
return (
data !== null &&
typeof data === "object" &&
"success" in data &&
"data" in data &&
typeof (data as Record<string, unknown>).success === "boolean"
);
}

/**
* Unwrap standardized output to get the inner data
* For { success: true, data: {...} } returns the inner data object
*/
function unwrapStandardizedOutput(data: unknown): unknown {
if (isStandardizedOutput(data)) {
return data.data;
}
return data;
}

// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Field path resolution requires nested logic for arrays and standardized outputs
function resolveFieldPath(data: unknown, fieldPath: string): unknown {
if (!data) {
return;
Expand All @@ -168,6 +195,18 @@ function resolveFieldPath(data: unknown, fieldPath: string): unknown {
const parts = fieldPath.split(".");
let current: unknown = data;

// For standardized outputs, automatically look inside data.data
// unless explicitly accessing 'success', 'data', or 'error'
const firstPart = parts[0]?.trim();
if (
isStandardizedOutput(current) &&
firstPart !== "success" &&
firstPart !== "data" &&
firstPart !== "error"
) {
current = current.data;
}

for (const part of parts) {
const trimmedPart = part.trim();

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

// For standardized outputs, extract fields from inside data
// so autocomplete shows firstName instead of data.firstName
const dataToExtract = unwrapStandardizedOutput(output.data);

// Add individual fields if data is an object
if (output.data && typeof output.data === "object") {
extractFields(output.data, output.label, fields, {
if (dataToExtract && typeof dataToExtract === "object") {
extractFields(dataToExtract, output.label, fields, {
currentPath: `{{${output.label}`,
});
}
Expand Down
46 changes: 42 additions & 4 deletions lib/workflow-executor.workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export type WorkflowExecutionInput = {
* Helper to replace template variables in conditions
*/
// biome-ignore lint/nursery/useMaxParams: Helper function needs all parameters for template replacement
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Template variable replacement requires nested logic for standardized outputs
function replaceTemplateVariable(
match: string,
nodeId: string,
Expand Down Expand Up @@ -85,6 +86,21 @@ function replaceTemplateVariable(
// biome-ignore lint/suspicious/noExplicitAny: Dynamic data traversal
let current: any = output.data;

// For standardized outputs { success, data, error }, automatically look inside data
// unless explicitly accessing success/data/error
const firstField = fields[0];
if (
current &&
typeof current === "object" &&
"success" in current &&
"data" in current &&
firstField !== "success" &&
firstField !== "data" &&
firstField !== "error"
) {
current = current.data;
}

for (const field of fields) {
if (current && typeof current === "object") {
current = current[field];
Expand Down Expand Up @@ -310,6 +326,21 @@ function processTemplates(
// biome-ignore lint/suspicious/noExplicitAny: Dynamic output data traversal
let current: any = output.data;

// For standardized outputs { success, data, error }, automatically look inside data
// unless explicitly accessing success/data/error
const firstField = fields[0];
if (
current &&
typeof current === "object" &&
"success" in current &&
"data" in current &&
firstField !== "success" &&
firstField !== "data" &&
firstField !== "error"
) {
current = current.data;
}

for (const field of fields) {
if (current && typeof current === "object") {
current = current[field];
Expand Down Expand Up @@ -555,12 +586,19 @@ export async function executeWorkflow(input: WorkflowExecutionInput) {
(stepResult as { success: boolean }).success === false;

if (isErrorResult) {
const errorResult = stepResult as { success: false; error?: string };
const errorResult = stepResult as {
success: false;
error?: string | { message: string };
};
// Support both old format (error: string) and new format (error: { message: string })
const errorMessage =
typeof errorResult.error === "string"
? errorResult.error
: errorResult.error?.message ||
`Step "${actionType}" in node "${node.data.label || node.id}" failed without a specific error message.`;
result = {
success: false,
error:
errorResult.error ||
`Step "${actionType}" in node "${node.data.label || node.id}" failed without a specific error message.`,
error: errorMessage,
};
} else {
result = {
Expand Down
Loading