Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
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
6 changes: 6 additions & 0 deletions .changeset/cuddly-years-fall.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@workflow/web-shared": patch
---

Add fork workflow feature to create fresh runs from existing durable workflow executions

2 changes: 2 additions & 0 deletions packages/web-shared/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@
"format": "biome format --write"
},
"dependencies": {
"@radix-ui/react-dialog": "1.1.14",
"@radix-ui/react-slot": "1.2.3",
"@tailwindcss/postcss": "4",
"@workflow/core": "workspace:*",
"@workflow/errors": "workspace:*",
Expand Down
27 changes: 27 additions & 0 deletions packages/web-shared/src/api/workflow-api-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type {
WorkflowRun,
WorkflowRunStatus,
} from '@workflow/world';
import type { ModelMessage } from 'ai';
import { useCallback, useEffect, useRef, useState } from 'react';
import { getPaginationDisplay } from '../lib/utils';
import type { EnvMap, ServerActionError } from './workflow-server-actions';
Expand All @@ -21,6 +22,7 @@ import {
fetchStep,
fetchSteps,
fetchStreams,
forkRunFromConversation,
readStreamServerAction,
recreateRun as recreateRunServerAction,
} from './workflow-server-actions';
Expand Down Expand Up @@ -1101,6 +1103,31 @@ export async function recreateRun(env: EnvMap, runId: string): Promise<string> {
return resultData;
}

/**
* Fork a workflow run from a specific point in a conversation.
*
* Creates a new run with a truncated conversation, allowing the LLM to
* generate a fresh response from that point.
*
* @param env - Environment variables for world configuration
* @param runId - The original run ID to fork from
* @param truncatedMessages - The conversation messages truncated to the fork point
* @returns The new run ID
*/
export async function forkRun(
env: EnvMap,
runId: string,
truncatedMessages: ModelMessage[]
): Promise<string> {
const { error, result: resultData } = await unwrapServerActionResult(
forkRunFromConversation(env, runId, truncatedMessages)
);
if (error) {
throw error;
}
return resultData;
}

function isServerActionError(value: unknown): value is ServerActionError {
return (
typeof value === 'object' &&
Expand Down
57 changes: 57 additions & 0 deletions packages/web-shared/src/api/workflow-server-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import type {
WorkflowRunStatus,
World,
} from '@workflow/world';
import type { ModelMessage } from 'ai';

export type EnvMap = Record<string, string | undefined>;

Expand Down Expand Up @@ -507,6 +508,62 @@ export async function recreateRun(
}
}

/**
* Fork a workflow run from a specific point in a conversation.
*
* This creates a new run with a truncated conversation - all messages up to and
* including the specified message index. The workflow will re-execute with this
* modified input, allowing the LLM to generate a fresh response.
*
* System messages are filtered out from the truncated conversation since the
* workflow's DurableAgent will add its own system message, preventing duplicates.
*
* @param worldEnv - Environment variables for world configuration
* @param runId - The original run ID to fork from
* @param truncatedMessages - The conversation messages truncated to the fork point
*/
export async function forkRunFromConversation(
worldEnv: EnvMap,
runId: string,
truncatedMessages: ModelMessage[]
): Promise<ServerActionResult<string>> {
try {
const world = getWorldFromEnv({ ...worldEnv });
const run = await world.runs.get(runId);
const hydratedRun = hydrate(run as WorkflowRun);
const deploymentId = run.deploymentId;

// Filter out system messages from the truncated conversation.
// The workflow's DurableAgent will add its own system message, so including
// one from the original conversation would cause duplication.
const messagesWithoutSystem = truncatedMessages.filter((msg) => {
if (msg && typeof msg === 'object' && 'role' in msg) {
return (msg as { role: string }).role !== 'system';
}
return true;
});

// The input for doStreamStep is [conversationPrompt, model, writable, tools, options]
// We need to replace the first argument (conversation) with the truncated version
const modifiedInput = [...hydratedRun.input];
modifiedInput[0] = messagesWithoutSystem;

const newRun = await start(
{ workflowId: run.workflowName },
modifiedInput,
{
deploymentId,
}
);
return createResponse(newRun.runId);
} catch (error) {
return createServerActionError<string>(error, 'forkRunFromConversation', {
runId,
messageCount: truncatedMessages.length,
});
}
}

export async function readStreamServerAction(
env: EnvMap,
streamId: string,
Expand Down
57 changes: 57 additions & 0 deletions packages/web-shared/src/components/ui/button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
'use client';

import { Slot } from '@radix-ui/react-slot';
import { cva, type VariantProps } from 'class-variance-authority';
import * as React from 'react';
import { cn } from '../../lib/utils';

const buttonVariants = cva(
'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
{
variants: {
variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90',
outline:
'border border-input bg-background hover:bg-accent hover:text-accent-foreground',
secondary:
'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground',
link: 'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-10 px-4 py-2',
sm: 'h-9 rounded-md px-3',
lg: 'h-11 rounded-md px-8',
icon: 'h-10 w-10',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
}
);

export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : 'button';
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
);
}
);
Button.displayName = 'Button';

export { Button, buttonVariants };
121 changes: 121 additions & 0 deletions packages/web-shared/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
'use client';

import * as DialogPrimitive from '@radix-ui/react-dialog';
import { X } from 'lucide-react';
import * as React from 'react';
import { cn } from '../../lib/utils';

const Dialog = DialogPrimitive.Root;

const DialogTrigger = DialogPrimitive.Trigger;

const DialogPortal = DialogPrimitive.Portal;

const DialogClose = DialogPrimitive.Close;

const DialogOverlay = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className
)}
{...props}
/>
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;

const DialogContent = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;

const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
/>
);
DialogHeader.displayName = 'DialogHeader';

const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
'flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2',
className
)}
{...props}
/>
);
DialogFooter.displayName = 'DialogFooter';

const DialogTitle = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
'text-lg font-semibold leading-none tracking-tight',
className
)}
{...props}
/>
));
DialogTitle.displayName = DialogPrimitive.Title.displayName;

const DialogDescription = React.forwardRef<
React.ComponentRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
));
DialogDescription.displayName = DialogPrimitive.Description.displayName;

export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
};
Loading
Loading