diff --git a/.github/workflows/conventional-label.yml b/.github/workflows/conventional-label.yml deleted file mode 100644 index 52161a86..00000000 --- a/.github/workflows/conventional-label.yml +++ /dev/null @@ -1,13 +0,0 @@ -name: "🏷️ Conventional release labels" - -on: - pull_request_target: - types: [opened, edited, reopened] - -jobs: - label: - runs-on: ubuntu-latest - steps: - - uses: bcoe/conventional-release-labels@v1 - with: - type_labels: '{"feat": "kind/feature", "fix": "kind/bug", "breaking": "kind/breaking-change"}' diff --git a/.husky/pre-commit b/.husky/pre-commit index 2312dc58..e69de29b 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +0,0 @@ -npx lint-staged diff --git a/.vscode/settings.json b/.vscode/settings.json index f3c3acec..83b390a6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,5 +3,8 @@ "source.fixAll.eslint": "always", "source.removeUnusedImports": "always" }, - "cSpell.words": ["gensx"] + "cSpell.words": [ + "gensx" + ], + "typescript.tsdk": "node_modules/typescript/lib" } diff --git a/package.json b/package.json index b7ef36d6..0d97fff9 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "packageManager": "pnpm@9.14.2", "type": "module", "scripts": { + "run": "tsx playground/index.tsx", "dev": "nodemon", "prepublishOnly": "pnpm i && pnpm build", "build": "pnpm validate-typescript && pnpm build:clean && pnpm generate-dist", diff --git a/playground/blog/BlogWritingWorkflow.tsx b/playground/blog/BlogWritingWorkflow.tsx index 9a5287f5..3fa21355 100644 --- a/playground/blog/BlogWritingWorkflow.tsx +++ b/playground/blog/BlogWritingWorkflow.tsx @@ -12,13 +12,13 @@ interface BlogWritingWorkflowInputs { export const BlogWritingWorkflow = createWorkflow< BlogWritingWorkflowInputs, string ->((props, render) => ( +>((props, { resolve }) => ( {({ research }) => ( {({ content }) => ( - {editedContent => render(editedContent)} + {editedContent => resolve(editedContent)} )} diff --git a/playground/fanout/FanoutExample.tsx b/playground/fanout/FanoutExample.tsx new file mode 100644 index 00000000..f086950e --- /dev/null +++ b/playground/fanout/FanoutExample.tsx @@ -0,0 +1,80 @@ +import { createWorkflow } from "@/src/utils/workflowBuilder"; + +interface FanoutWorkflowInputs { + numbers: number[]; + strings: string[]; +} + +interface FanoutWorkflowOutputs { + num: number; + str: string; +} + +export const FanoutWorkflow = createWorkflow< + FanoutWorkflowInputs, + FanoutWorkflowOutputs +>(async (props, { resolve, execute }) => { + const doubledNums = props.numbers.map(n => + execute(DoubleNumber, { input: n }), + ); + const doubledStrings = props.strings.map(s => + execute(DoubleString, { input: s }), + ); + + const resolvedNums = await Promise.all(doubledNums); + const resolvedStrings = await Promise.all(doubledStrings); + + return ( + + {sum => ( + + {str => resolve({ num: sum, str })} + + )} + + ); +}); + +interface ConcatStringsInputs { + strings: string[]; +} + +export const ConcatStrings = createWorkflow( + (props, { resolve }) => { + return resolve(props.strings.join("")); + }, +); + +interface SumNumbersInputs { + numbers: number[]; +} + +export const SumNumbers = createWorkflow( + (props, { resolve }) => { + return resolve(props.numbers.reduce((a, b) => a + b, 0)); + }, +); + +// Component for doubling a number +export interface DoubleNumberProps { + input: number; +} + +export const DoubleNumber = createWorkflow( + (props, { resolve }) => { + const doubled = props.input * 2; + return resolve(doubled); + }, +); + +// Component for doubling a string +export interface DoubleStringProps { + input: string; +} + +export const DoubleString = createWorkflow( + (props, { resolve }) => { + const doubled = props.input + props.input; + return resolve(doubled); + }, +); diff --git a/playground/index.tsx b/playground/index.tsx index 64a4780e..2529f7ea 100644 --- a/playground/index.tsx +++ b/playground/index.tsx @@ -3,6 +3,7 @@ import { WorkflowContext } from "@/src/components/Workflow"; import { createWorkflowOutput } from "@/src/hooks/useWorkflowOutput"; import { BlogWritingWorkflow } from "./blog/BlogWritingWorkflow"; +import { FanoutWorkflow } from "./fanout/FanoutExample"; import { TweetWritingWorkflow } from "./tweet/TweetWritingWorkflow"; async function runParallelWorkflow() { @@ -40,14 +41,7 @@ async function runNestedWorkflow() { const workflow = ( - { - resolve(title); - }) - } - prompt={prompt} - > + {blogPostResult => ( {tweetResult => { @@ -69,10 +63,38 @@ async function runNestedWorkflow() { console.log("Tweet:", tweet); } +async function runFanoutWorkflow() { + const nums = [1, 2, 3, 4, 5]; + const strs = ["a", "b", "c", "d", "e"]; + + let numResult = 0; + let strResult = ""; + + const workflow = ( + + + {({ num, str }) => { + numResult = num; + strResult = str; + return null; + }} + + + ); + + const context = new WorkflowContext(workflow); + await context.execute(); + + console.log("\n=== Fanout Workflow Results ==="); + console.log("Number:", numResult); + console.log("String:", strResult); +} + async function main() { try { await runParallelWorkflow(); await runNestedWorkflow(); + await runFanoutWorkflow(); } catch (error) { console.error("Workflow execution failed:", error); process.exit(1); diff --git a/playground/shared/components/LLMEditor.tsx b/playground/shared/components/LLMEditor.tsx index a40e2bdd..c4f055ae 100644 --- a/playground/shared/components/LLMEditor.tsx +++ b/playground/shared/components/LLMEditor.tsx @@ -5,8 +5,8 @@ interface EditorProps { } export const LLMEditor = createWorkflow( - async (props, render) => { + async (props, { resolve }) => { const editedContent = await Promise.resolve(`Edited: ${props.content}`); - return render(editedContent); + return resolve(editedContent); }, ); diff --git a/playground/shared/components/LLMResearcher.tsx b/playground/shared/components/LLMResearcher.tsx index 2e7cf663..ca7fa28a 100644 --- a/playground/shared/components/LLMResearcher.tsx +++ b/playground/shared/components/LLMResearcher.tsx @@ -12,14 +12,17 @@ interface ResearcherOutput { } export const LLMResearcher = createWorkflow( - async (props, render) => { - const result = { - research: await Promise.resolve( - `Research based on title: ${props.title}, prompt: ${props.prompt}`, - ), - sources: ["source1.com", "source2.com"], - summary: "Brief summary of findings", - }; - return render(result); + async (props, { resolve }) => { + const processedResearch = await Promise.resolve( + `Research based on title: ${props.title}, prompt: ${props.prompt}`, + ); + const processedSources = ["source1.com", "source2.com"]; + const processedSummary = "Brief summary of findings"; + + return resolve({ + research: processedResearch, + sources: processedSources, + summary: processedSummary, + }); }, ); diff --git a/playground/shared/components/LLMWriter.tsx b/playground/shared/components/LLMWriter.tsx index b4a20114..7db84a57 100644 --- a/playground/shared/components/LLMWriter.tsx +++ b/playground/shared/components/LLMWriter.tsx @@ -14,7 +14,7 @@ interface WriterOutput { } export const LLMWriter = createWorkflow( - async (props, render) => { + async (props, { resolve }) => { const processedContent = await Promise.resolve( `Written content based on: ${props.content}`, ); @@ -24,7 +24,7 @@ export const LLMWriter = createWorkflow( keywords: ["sample", "content", "test"], }; - return render({ + return resolve({ content: processedContent, metadata: processedMetadata, }); diff --git a/playground/tweet/TweetWritingWorkflow.tsx b/playground/tweet/TweetWritingWorkflow.tsx index 0354802f..a1c20faa 100644 --- a/playground/tweet/TweetWritingWorkflow.tsx +++ b/playground/tweet/TweetWritingWorkflow.tsx @@ -10,12 +10,14 @@ interface TweetWritingWorkflowInputs { export const TweetWritingWorkflow = createWorkflow< TweetWritingWorkflowInputs, string ->((props, render) => ( - - {({ content }) => ( - - {editedContent => render(editedContent)} - - )} - -)); +>(async (props, { resolve, execute }) => { + return ( + + {({ content }) => ( + + {editedContent => resolve(editedContent)} + + )} + + ); +}); diff --git a/src/components/Workflow.tsx b/src/components/Workflow.tsx index e578cfef..ad868650 100644 --- a/src/components/Workflow.tsx +++ b/src/components/Workflow.tsx @@ -14,10 +14,13 @@ export class WorkflowContext { private steps: Step[] = []; private dynamicSteps = new Map(); private executedSteps = new Set(); + readonly id: string; constructor(workflow: React.ReactElement) { + this.id = `workflow_${Math.random().toString(36).slice(2)}`; const wrappedWorkflow = React.createElement(React.Fragment, null, workflow); this.steps = renderWorkflow(wrappedWorkflow); + WorkflowContext.current = this; } notifyUpdate(componentId: string) { @@ -52,8 +55,6 @@ export class WorkflowContext { } async execute() { - WorkflowContext.current = this; - try { // Execute all initial steps in parallel await Promise.all( diff --git a/src/hooks/useWorkflowOutput.ts b/src/hooks/useWorkflowOutput.ts index d56b2a62..89d72d25 100644 --- a/src/hooks/useWorkflowOutput.ts +++ b/src/hooks/useWorkflowOutput.ts @@ -1,14 +1,16 @@ -type WorkflowOutput = Map< +import { WorkflowContext } from "../components/Workflow"; + +type WorkflowOutput = Map< string, { - promise: Promise; - resolve: (value: T) => void; + promise: Promise; + resolve: (value: unknown) => void; hasResolved: boolean; } >; -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export const workflowOutputs: WorkflowOutput = new Map(); +// Map of workflow context ID to its outputs +const contextOutputs = new Map(); let counter = 0; function generateStableId() { @@ -18,46 +20,27 @@ function generateStableId() { export function createWorkflowOutput( _initialValue: T, ): [Promise, (value: T) => void] { - const outputId = generateStableId(); + const context = WorkflowContext.current ?? { + id: generateStableId(), + }; - if (!workflowOutputs.has(outputId)) { - let resolvePromise: (value: T) => void; - let rejectPromise: (error: unknown) => void; - const promise = new Promise((resolve, reject) => { - resolvePromise = resolve; - rejectPromise = reject; - }); - - // Only add timeout if WORKFLOW_TIMEOUT is set - let timeoutId: NodeJS.Timeout | undefined; - if (process.env.WORKFLOW_TIMEOUT === "true") { - timeoutId = setTimeout(() => { - if (!workflowOutputs.get(outputId)?.hasResolved) { - console.error(`Output ${outputId} timed out without being resolved`); - rejectPromise( - new Error(`Output ${outputId} timed out waiting for resolution`), - ); - } - }, 5000); - } - - workflowOutputs.set(outputId, { - promise, - resolve: (value: T) => { - if (timeoutId) { - clearTimeout(timeoutId); - } - if (workflowOutputs.get(outputId)?.hasResolved) { - throw new Error("Cannot set value multiple times"); - } - resolvePromise(value); - workflowOutputs.get(outputId)!.hasResolved = true; - }, - hasResolved: false, - }); + if (!contextOutputs.has(context.id)) { + contextOutputs.set(context.id, new Map()); } - const output = workflowOutputs.get(outputId)!; - // eslint-disable-next-line @typescript-eslint/no-unsafe-return - return [output.promise, output.resolve] as const; + const outputs = contextOutputs.get(context.id)!; + const outputId = generateStableId(); + + let resolvePromise!: (value: T) => void; + const promise = new Promise(resolve => { + resolvePromise = resolve; + }); + + outputs.set(outputId, { + promise: promise as Promise, + resolve: resolvePromise as (value: unknown) => void, + hasResolved: false, + }); + + return [promise, resolvePromise]; } diff --git a/src/utils/workflowBuilder.ts b/src/utils/workflowBuilder.ts index f390eceb..14ce29cd 100644 --- a/src/utils/workflowBuilder.ts +++ b/src/utils/workflowBuilder.ts @@ -1,52 +1,80 @@ -import React from "react"; +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-unsafe-argument */ +/* eslint-disable @typescript-eslint/no-unnecessary-condition */ + +import React, { + ComponentType, + FunctionComponent, + JSXElementConstructor, + ReactElement, +} from "react"; import { Step } from "../components/Step"; import { createWorkflowOutput } from "../hooks/useWorkflowOutput"; import { renderWorkflow } from "../utils/renderWorkflow"; -type WorkflowRenderFunction = (value: T) => React.ReactElement | null; +// Public interface that components will use +export interface WorkflowContext { + resolve: (value: TOutput) => ReactElement | null; + execute: ( + component: FunctionComponent< + WorkflowComponentProps, TComponentOutput> + > & { + implementation?: WorkflowImplementation; + }, + props: Omit, + ) => Promise; +} + +// Private implementation type +type WorkflowExecutionContextImpl = WorkflowContext; type WorkflowImplementation = ( props: ResolvedProps, - render: WorkflowRenderFunction, -) => - | React.ReactElement - | Promise - | null - | Promise; + context: WorkflowContext, +) => ReactElement | Promise | null | Promise; type WorkflowComponentProps = TProps & { children?: (output: TOutput) => React.ReactNode; setOutput?: (value: TOutput) => void; }; -// Type to convert a props type to allow promises type PromiseProps = { [K in keyof TProps]: TProps[K] | Promise; }; -// Type to ensure implementation gets resolved props type ResolvedProps = { [K in keyof TProps]: TProps[K] extends Promise ? U : TProps[K]; }; -// This function resolves value in a promise. -// You can await a promise or a plain value and the effect is the same. -// Even though this function might seem unnecessary, using it makes our intent more clear. async function resolveValue(value: T | Promise): Promise { return await value; } -// Keep track of processed results to prevent infinite loops -const processedResults = new Set(); +function getComponentName( + type: string | JSXElementConstructor, +): string { + if (typeof type === "string") return type; + const component = type as { displayName?: string; name?: string }; + return component.displayName ?? component.name ?? "Unknown"; +} + +// Track processed workflows by their type and props +const processedWorkflows = new Map>(); // eslint-disable-next-line @typescript-eslint/no-explicit-any export function createWorkflow, TOutput>( implementation: WorkflowImplementation, -): React.ComponentType, TOutput>> { +): FunctionComponent, TOutput>> & { + getWorkflowResult: ( + props: WorkflowComponentProps, TOutput>, + ) => Promise; + implementation: typeof implementation; + __outputType?: TOutput; // Add type marker to component +} { const WorkflowComponent = ( props: WorkflowComponentProps, TOutput>, - ): React.ReactElement | null => { + ): ReactElement | null => { const { children, setOutput, ...componentProps } = props; const [, setWorkflowOutput] = createWorkflowOutput( null as unknown as TOutput, @@ -55,44 +83,91 @@ export function createWorkflow, TOutput>( const step: Step = { async execute(context) { try { - // Resolve all props in parallel const resolvedProps = {} as ResolvedProps; await Promise.all( Object.entries(componentProps).map(async ([key, value]) => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment resolvedProps[key as keyof TProps] = await resolveValue(value); }), ); - // Create render function that sets output and returns element - const render: WorkflowRenderFunction = value => { - setWorkflowOutput(value); - if (setOutput) { - setOutput(value); - } - if (children) { - return children(value) as React.ReactElement; - } - return null; + const workflowContext: WorkflowExecutionContextImpl = { + resolve: (value: TOutput) => { + setWorkflowOutput(value); + if (setOutput) { + setOutput(value); + } + if (children) { + return children(value) as ReactElement; + } + return null; + }, + execute: async ( + component: FunctionComponent< + WorkflowComponentProps, TComponentOutput> + > & { + implementation?: WorkflowImplementation< + TProps, + TComponentOutput + >; + }, + props: Omit, + ): Promise => { + const componentName = getComponentName( + component as ComponentType, + ); + const [output, setOutput] = + createWorkflowOutput( + null as unknown as TComponentOutput, + ); + + try { + if (!component.implementation) { + throw new Error( + `Component ${componentName} does not have an implementation`, + ); + } + + const resolvedProps = { + ...props, + setOutput, + } as unknown as ResolvedProps; + + const elementWithOutput = React.createElement( + component as ComponentType< + TProps & { setOutput: typeof setOutput } + >, + resolvedProps as TProps & { setOutput: typeof setOutput }, + ); + + const elementSteps = renderWorkflow(elementWithOutput); + await Promise.all( + elementSteps.map(step => step.execute(context)), + ); + + const result = await output; + return result; + } catch (error) { + console.error( + `[WorkflowBuilder] Error in ${componentName}:`, + error, + ); + throw error; + } + }, }; - // Get the workflow result with resolved props const element = await Promise.resolve( - implementation(resolvedProps, render), + implementation(resolvedProps, workflowContext), ); - // Process the element chain if (element) { const elementSteps = renderWorkflow(element); - // Execute steps sequentially to ensure proper chaining - for (const step of elementSteps) { - await step.execute(context); - } + await Promise.all(elementSteps.map(step => step.execute(context))); } return []; } catch (error) { - console.error("Error in workflow step:", error); + console.error("[WorkflowBuilder] Error in workflow step:", error); throw error; } }, @@ -100,58 +175,127 @@ export function createWorkflow, TOutput>( return React.createElement("div", { "data-workflow-step": true, - step, + step: step as unknown as Record, }); }; - // For execution phase, we need a way to get the workflow result without React WorkflowComponent.getWorkflowResult = async ( props: WorkflowComponentProps, TOutput>, - ): Promise => { + ): Promise => { const { children, setOutput, ...componentProps } = props; - // Generate a unique key for this result - const resultKey = JSON.stringify(componentProps); - if (processedResults.has(resultKey)) { + const componentType = implementation.name ?? "anonymous"; + const propsKey = JSON.stringify(componentProps); + + if (!processedWorkflows.has(componentType)) { + processedWorkflows.set(componentType, new Set()); + } + const processedProps = processedWorkflows.get(componentType)!; + + if (processedProps.has(propsKey)) { return null; } - processedResults.add(resultKey); - const [, setWorkflowOutput] = createWorkflowOutput( - null as unknown as TOutput, - ); + processedProps.add(propsKey); try { - // Resolve all props before passing to implementation const resolvedProps = {} as ResolvedProps; for (const [key, value] of Object.entries(componentProps)) { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment resolvedProps[key as keyof TProps] = await resolveValue(value); } - // Create render function that sets output and returns element - const render: WorkflowRenderFunction = value => { - setWorkflowOutput(value); - if (setOutput) { - setOutput(value); - } - if (children) { - return children(value) as React.ReactElement; - } - return null; + const pendingExecutions: Promise[] = []; + + const workflowContext: WorkflowExecutionContextImpl = { + resolve: (value: TOutput) => { + if (setOutput) { + setOutput(value); + } + if (children) { + return children(value) as ReactElement; + } + return null; + }, + execute: async ( + component: FunctionComponent< + WorkflowComponentProps, TComponentOutput> + > & { + implementation?: WorkflowImplementation; + }, + props: Omit, + ): Promise => { + const componentName = getComponentName( + component as ComponentType, + ); + const [output, setOutput] = createWorkflowOutput( + null as unknown as TComponentOutput, + ); + + try { + if (!component.implementation) { + throw new Error( + `Component ${componentName} does not have an implementation`, + ); + } + + const resolvedProps = { + ...props, + setOutput, + } as unknown as ResolvedProps; + + const executionPromise = (async () => { + const subContext: WorkflowExecutionContextImpl = + { + resolve: (value: TComponentOutput) => { + setOutput(value); + return null; + }, + execute: workflowContext.execute, + }; + + await component.implementation!(resolvedProps, subContext); + const result = await output; + return result; + })(); + + pendingExecutions.push(executionPromise); + const result = await executionPromise; + return result; + } catch (error) { + console.error( + `[WorkflowBuilder] Error executing ${componentName}:`, + error, + ); + throw error; + } + }, }; - // Get the workflow result - const implementationResult = await implementation(resolvedProps, render); - return implementationResult; + const result = await implementation(resolvedProps, workflowContext); + + if (pendingExecutions.length > 0) { + await Promise.all(pendingExecutions); + } + + return result; } catch (error) { - console.error("Error in getWorkflowResult:", error); + console.error( + `[WorkflowBuilder] Error in workflow ${componentType}:`, + error, + ); throw error; } finally { - processedResults.delete(resultKey); + processedWorkflows.get(componentType)?.delete(propsKey); } }; - WorkflowComponent.displayName = "Workflow"; - return WorkflowComponent; + WorkflowComponent.displayName = implementation.name ?? "Workflow"; + WorkflowComponent.implementation = implementation; + + return WorkflowComponent as FunctionComponent< + WorkflowComponentProps, TOutput> + > & { + getWorkflowResult: typeof WorkflowComponent.getWorkflowResult; + implementation: typeof implementation; + }; } diff --git a/tsconfig.prod.json b/tsconfig.prod.json index 1c35d66d..389233d1 100644 --- a/tsconfig.prod.json +++ b/tsconfig.prod.json @@ -1,8 +1,10 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "rootDir": "./src", - "outDir": "./dist" + "outDir": "./dist", + "paths": { + "@/src/*": ["src/*"] + } }, "include": ["src"] }