mirror of
https://github.com/run-llama/workflows-ts.git
synced 2026-07-20 10:03:31 -04:00
fix: merge snapshot and state middleware and fix middleware chaining (#137)
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@llamaindex/workflow-core": patch
|
||||
---
|
||||
|
||||
merge snapshot and state middleware and fix middleware chaining
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"@llamaindex/workflow-core": patch
|
||||
---
|
||||
|
||||
fix: snapshot not passing createContext params
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
run: pnpm install
|
||||
|
||||
- name: Run build
|
||||
run: pnpx turbo run build --filter="./packages/*"
|
||||
run: pnpm run build
|
||||
|
||||
- name: Pre Release
|
||||
run: pnpx pkg-pr-new publish --pnpm ./packages/*
|
||||
|
||||
@@ -31,9 +31,9 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Build packages
|
||||
run: pnpx turbo run build --filter="./packages/*"
|
||||
run: pnpm run build
|
||||
- name: Run tests
|
||||
run: pnpx turbo run test
|
||||
run: pnpm run test
|
||||
deno-test:
|
||||
name: Test on Deno
|
||||
runs-on: ubuntu-latest
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Run build
|
||||
run: pnpx turbo run build --filter="./packages/*"
|
||||
run: pnpm run build
|
||||
- name: Run Deno tests
|
||||
run: deno test
|
||||
working-directory: ./demo/deno
|
||||
@@ -84,6 +84,6 @@ jobs:
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Run build
|
||||
run: pnpx turbo run build --filter="./packages/*"
|
||||
run: pnpm run build
|
||||
- name: Run type check
|
||||
run: pnpm run typecheck
|
||||
|
||||
@@ -17,12 +17,14 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
- **Core package dev**: `cd packages/core && pnpm run dev` (watch mode)
|
||||
- **Core package test**: `cd packages/core && pnpm run test` (Vitest)
|
||||
- **HTTP package build**: `cd packages/http && pnpm run build` (uses Bunchee)
|
||||
- **LlamaIndex package test**: `cd packages/llamaindex && pnpm run test`
|
||||
- **HTTP package dev**: `cd packages/http && pnpm run dev` (watch mode with Bunchee)
|
||||
|
||||
### Testing
|
||||
|
||||
- **Run tests**: `vitest run` from package directories
|
||||
- **Run all tests**: `pnpm run test` (from root or individual packages)
|
||||
- **Test workspace**: Tests are configured in `vitest.workspace.ts` for all packages
|
||||
- **Browser tests**: Use `happy-dom` environment for browser-specific testing
|
||||
- **CJS compatibility tests**: Located in `tests/cjs/` directory
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -54,10 +56,9 @@ sendEvent(startEvent.with("42"));
|
||||
|
||||
### Middleware System
|
||||
|
||||
- **State**: `withState()` adds stateful context
|
||||
- **State**: `withState()` adds stateful context (with built-in snapshot support)
|
||||
- **Validation**: `withValidation()` provides type-safe event handling
|
||||
- **Trace Events**: `withTraceEvents()` enables debugging and handler decorators
|
||||
- **Snapshot**: `withSnapshot()` allows workflow state save/restore
|
||||
|
||||
### Key Directories
|
||||
|
||||
@@ -71,8 +72,10 @@ sendEvent(startEvent.with("42"));
|
||||
### Monorepo Structure
|
||||
|
||||
- Uses pnpm workspaces with Turbo for build orchestration
|
||||
- Packages are independently versioned and published
|
||||
- Packages are independently versioned and published with Changesets
|
||||
- Demo projects showcase integrations with Next.js, Hono, Deno, browser environments
|
||||
- **Main packages**: `@llamaindex/workflow-core` (core engine), `@llamaindex/workflow-http` (HTTP protocol)
|
||||
- **Package exports**: Core package has extensive subpath exports for middleware, utilities, and stream helpers
|
||||
|
||||
### Browser Compatibility
|
||||
|
||||
@@ -83,3 +86,11 @@ Important: Call `getContext()` at the top level of handlers due to browser async
|
||||
- Uses Vitest for testing across all packages
|
||||
- Tests are colocated with source files (`.test.ts` files)
|
||||
- Browser-specific tests use `happy-dom` environment
|
||||
- TypeScript compilation builds configured for multiple targets (browser, Node.js, CommonJS)
|
||||
|
||||
### Build System
|
||||
|
||||
- **Core package**: Uses `tsdown` for building with multiple output formats (ESM, CJS, browser)
|
||||
- **HTTP package**: Uses `bunchee` for lightweight bundling
|
||||
- **Turbo**: Orchestrates builds across monorepo with dependency-aware caching
|
||||
- **Lint-staged**: Automatically formats files on commit with Prettier
|
||||
|
||||
@@ -247,6 +247,18 @@ const workflow = withState(createWorkflow());
|
||||
const { state } = workflow.createContext({ id: "1" });
|
||||
```
|
||||
|
||||
`withState` also supports snapshot, you can use `snapshot` to save the state of the workflow, and `resume` to restore the state of the workflow.
|
||||
|
||||
```ts
|
||||
const { snapshot, resume } = workflow.createContext();
|
||||
|
||||
// create snapshot
|
||||
const [req, snapshotData] = await snapshot();
|
||||
|
||||
// resume workflow from snapshot
|
||||
const { stream } = workflow.resume(["hello"], snapshotData);
|
||||
```
|
||||
|
||||
### `withValidation`
|
||||
|
||||
Make first parameter of `handler` to be `sendEvent` and its type safe and runtime safe
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
import { createWorkflow, workflowEvent } from "@llamaindex/workflow-core";
|
||||
import {
|
||||
createStatefulMiddleware,
|
||||
request,
|
||||
SnapshotData,
|
||||
} from "@llamaindex/workflow-core/middleware/state";
|
||||
import { OpenAI } from "openai";
|
||||
import {
|
||||
ChatCompletionMessage,
|
||||
ChatCompletionMessageParam,
|
||||
ChatCompletionMessageToolCall,
|
||||
ChatCompletionTool,
|
||||
} from "openai/resources/chat/completions";
|
||||
import * as readline from "readline/promises";
|
||||
|
||||
type ToolResponseEventData = {
|
||||
toolResponse: string;
|
||||
toolId: string;
|
||||
};
|
||||
|
||||
type AgentWorkflowState = {
|
||||
expectedToolCount: number;
|
||||
messages: ChatCompletionMessageParam[];
|
||||
toolResponses: Array<ToolResponseEventData>;
|
||||
humanToolId: string | null;
|
||||
};
|
||||
|
||||
const { withState, getContext } = createStatefulMiddleware(
|
||||
(state: AgentWorkflowState) => state,
|
||||
);
|
||||
const workflow = withState(createWorkflow());
|
||||
|
||||
let snapshotData: SnapshotData | null = null;
|
||||
|
||||
// Define our events
|
||||
const userInputEvent = workflowEvent<{
|
||||
messages: ChatCompletionMessageParam[];
|
||||
}>();
|
||||
const toolCallEvent = workflowEvent<{
|
||||
toolCall: ChatCompletionMessageToolCall;
|
||||
}>();
|
||||
const toolResponseEvent = workflowEvent<ToolResponseEventData>();
|
||||
const finalResponseEvent = workflowEvent<string>();
|
||||
const humanResponseEvent = workflowEvent<string>();
|
||||
|
||||
// Initialize OpenAI client (same as before)
|
||||
const openai = new OpenAI({
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
});
|
||||
|
||||
// Define available tools
|
||||
const tools: ChatCompletionTool[] = [
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "get_weather",
|
||||
description: "Get the current weather for a location",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
location: {
|
||||
type: "string",
|
||||
description: "The city and state, e.g. San Francisco, CA",
|
||||
},
|
||||
},
|
||||
required: ["location"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function" as const,
|
||||
function: {
|
||||
name: "human_ask_name",
|
||||
description: "Ask the user for his/her name",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// LLM function - handles the AI reasoning
|
||||
async function llm(
|
||||
messages: ChatCompletionMessageParam[],
|
||||
tools: ChatCompletionTool[],
|
||||
): Promise<ChatCompletionMessage> {
|
||||
const completion = await openai.chat.completions.create({
|
||||
model: "gpt-4.1-mini",
|
||||
messages,
|
||||
tools,
|
||||
tool_choice: "auto",
|
||||
});
|
||||
|
||||
const message = completion.choices[0]?.message;
|
||||
if (!message) {
|
||||
throw new Error("No response from LLM");
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
// Tool calling function - executes the requested tools
|
||||
async function callTool(
|
||||
toolCall: ChatCompletionMessageToolCall,
|
||||
): Promise<string> {
|
||||
const toolName = toolCall.function.name;
|
||||
const toolInput = JSON.parse(toolCall.function.arguments);
|
||||
|
||||
// Execute the requested tool
|
||||
switch (toolName) {
|
||||
case "get_weather":
|
||||
// Mock weather API call
|
||||
const location = toolInput.location;
|
||||
return `The weather in ${location} is sunny and 72°F`;
|
||||
default:
|
||||
return `Unknown tool: ${toolName}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for processing user input and LLM responses
|
||||
workflow.handle([userInputEvent], async (event) => {
|
||||
const { sendEvent, state } = getContext();
|
||||
const { messages } = event.data;
|
||||
|
||||
try {
|
||||
// Use our same llm() function
|
||||
const response = await llm(messages, tools);
|
||||
|
||||
// Add the assistant's response to the conversation
|
||||
state.messages = [...messages, response];
|
||||
|
||||
// Check if the LLM wants to call tools
|
||||
if (response.tool_calls && response.tool_calls.length > 0) {
|
||||
state.expectedToolCount = response.tool_calls.length;
|
||||
// Send tool call events for each requested tool
|
||||
for (const toolCall of response.tool_calls) {
|
||||
sendEvent(
|
||||
toolCallEvent.with({
|
||||
toolCall,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// No tools requested, send final response
|
||||
sendEvent(finalResponseEvent.with(response.content || ""));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error calling LLM:", error);
|
||||
sendEvent(finalResponseEvent.with("Error processing request"));
|
||||
}
|
||||
});
|
||||
|
||||
// Handler for aggregating tool call responses
|
||||
workflow.handle([toolResponseEvent], async (event) => {
|
||||
const { sendEvent, state } = getContext();
|
||||
// Collect all tool responses until we have all of them
|
||||
state.toolResponses.push(event.data);
|
||||
|
||||
// Once we have all responses, continue the conversation
|
||||
if (state.toolResponses.length === state.expectedToolCount) {
|
||||
// Add tool response messages
|
||||
const finalMessages = [
|
||||
...state.messages,
|
||||
// TODO: simplify this using openai type
|
||||
...state.toolResponses.map((response) => ({
|
||||
role: "tool" as const,
|
||||
content: response.toolResponse,
|
||||
tool_call_id: response.toolId,
|
||||
})),
|
||||
];
|
||||
|
||||
// Continue the loop with the updated conversation
|
||||
sendEvent(userInputEvent.with({ messages: finalMessages }));
|
||||
}
|
||||
});
|
||||
|
||||
// Handler for executing tool calls
|
||||
workflow.handle([toolCallEvent], async (event) => {
|
||||
const { toolCall } = event.data;
|
||||
const { sendEvent, state, snapshot } = getContext();
|
||||
|
||||
try {
|
||||
if (toolCall.function.name.startsWith("human_")) {
|
||||
// delegate to human if tool call starts with "human_"
|
||||
state.humanToolId = toolCall.id;
|
||||
sendEvent(request(humanResponseEvent));
|
||||
const [_, snapshotData_] = await snapshot();
|
||||
snapshotData = snapshotData_;
|
||||
// stop workflow
|
||||
// TODO: this shows a 'sendEvent after snapshot is not allowed' warning
|
||||
sendEvent(finalResponseEvent.with("Waiting for human response"));
|
||||
} else {
|
||||
// normal machine tool call
|
||||
const toolResponse = await callTool(toolCall);
|
||||
// Send the tool response back
|
||||
sendEvent(
|
||||
toolResponseEvent.with({
|
||||
toolResponse,
|
||||
toolId: toolCall.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error executing tool ${toolCall.function.name}:`, error);
|
||||
sendEvent(
|
||||
toolResponseEvent.with({
|
||||
toolResponse: `Error executing ${toolCall.function.name}: ${error}`,
|
||||
toolId: toolCall.id,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
workflow.handle([humanResponseEvent], async (event) => {
|
||||
const { sendEvent, state } = getContext();
|
||||
sendEvent(
|
||||
toolResponseEvent.with({
|
||||
toolResponse: "My name is " + event.data,
|
||||
toolId: state.humanToolId!,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// Run the workflow
|
||||
const context = workflow.createContext({
|
||||
expectedToolCount: 0,
|
||||
messages: [],
|
||||
toolResponses: [],
|
||||
humanToolId: null,
|
||||
});
|
||||
|
||||
context.sendEvent(
|
||||
userInputEvent.with({
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content:
|
||||
"What's the weather in San Francisco and what is the user's name?",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
await context.stream.until(finalResponseEvent).toArray();
|
||||
|
||||
if (!snapshotData) {
|
||||
throw new Error("No snapshot data");
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
const userName = await rl.question("Name? ");
|
||||
|
||||
const resumedContext = workflow.resume([userName], snapshotData);
|
||||
|
||||
const result = await resumedContext.stream.until(finalResponseEvent).toArray();
|
||||
|
||||
console.log(result[result.length - 1].data);
|
||||
|
||||
// TODO: ensure process exit is not needed
|
||||
process.exit(0);
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
withSnapshot,
|
||||
createStatefulMiddleware,
|
||||
request,
|
||||
} from "@llamaindex/workflow-core/middleware/snapshot";
|
||||
} from "@llamaindex/workflow-core/middleware/state";
|
||||
import {
|
||||
createWorkflow,
|
||||
workflowEvent,
|
||||
@@ -11,7 +11,8 @@ import { OpenAI } from "openai";
|
||||
|
||||
const openai = new OpenAI();
|
||||
|
||||
const workflow = withSnapshot(createWorkflow());
|
||||
const { withState } = createStatefulMiddleware();
|
||||
const workflow = withState(createWorkflow());
|
||||
|
||||
const startEvent = workflowEvent<string>({
|
||||
debugLabel: "start",
|
||||
|
||||
+5
-2
@@ -3,12 +3,14 @@
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.12.4",
|
||||
"packageManager": "pnpm@10.15.0",
|
||||
"scripts": {
|
||||
"build": "turbo build --filter=\"./packages/*\"",
|
||||
"test": "turbo test --filter=\"./packages/*\"",
|
||||
"typecheck": "tsc -b --verbose",
|
||||
"lint": "prettier . --check",
|
||||
"lint:fix": "prettier . --write",
|
||||
"publish": "turbo build --filter=\"./packages/*\" && changeset publish",
|
||||
"publish": "pnpm run build && changeset publish",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -20,6 +22,7 @@
|
||||
"prettier": "^3.6.1",
|
||||
"tsdown": "^0.12.9",
|
||||
"turbo": "^2.5.4",
|
||||
"typescript": "^5.9.2",
|
||||
"vite-tsconfig-paths": "^5.1.4",
|
||||
"vitest": "^3.2.4"
|
||||
}
|
||||
|
||||
@@ -72,8 +72,17 @@ export type WorkflowContext = {
|
||||
[event: WorkflowEventData<any>, handlerContext: HandlerContext],
|
||||
void
|
||||
>;
|
||||
__internal__property_inheritance_handlers?: Map<
|
||||
string,
|
||||
InheritanceTransformer
|
||||
>;
|
||||
};
|
||||
|
||||
export type InheritanceTransformer = (
|
||||
handlerContext: WorkflowContext,
|
||||
originalDescriptor: PropertyDescriptor,
|
||||
) => PropertyDescriptor;
|
||||
|
||||
export const _executorAsyncLocalStorage =
|
||||
new AsyncContext.Variable<WorkflowContext>();
|
||||
|
||||
@@ -85,6 +94,51 @@ export function getContext(): WorkflowContext {
|
||||
return context;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this function to add or extend properties of the root context.
|
||||
* Called by middleware's createContext to update the root context.
|
||||
* Handler-scoped contexts will automatically inherit these properties from the root context.
|
||||
* Never create a new object (e.g., using a spread `{...context}`) in your middleware's createContext.
|
||||
*
|
||||
* @param context The context to extend
|
||||
* @param properties The properties to add to the context
|
||||
* @param inheritanceTransformers The inheritance transformers to apply to existing properties (optional)
|
||||
*/
|
||||
export function extendContext(
|
||||
context: WorkflowContext,
|
||||
properties: Record<string, any>,
|
||||
inheritanceTransformers?: Record<string, InheritanceTransformer>,
|
||||
): void {
|
||||
// Add simple properties directly to the context (these inherit normally via prototype chain)
|
||||
Object.assign(context, properties);
|
||||
|
||||
// Register inheritance transformers for properties that need custom inheritance behavior
|
||||
if (inheritanceTransformers) {
|
||||
if (!context.__internal__property_inheritance_handlers) {
|
||||
context.__internal__property_inheritance_handlers = new Map();
|
||||
}
|
||||
|
||||
for (const [propertyKey, transformer] of Object.entries(
|
||||
inheritanceTransformers,
|
||||
)) {
|
||||
context.__internal__property_inheritance_handlers.set(
|
||||
propertyKey,
|
||||
transformer,
|
||||
);
|
||||
|
||||
// Apply the transformer to the root context immediately
|
||||
const rootDescriptor = Object.getOwnPropertyDescriptor(
|
||||
context,
|
||||
propertyKey,
|
||||
);
|
||||
if (rootDescriptor) {
|
||||
const newDescriptor = transformer(context, rootDescriptor);
|
||||
Object.defineProperty(context, propertyKey, newDescriptor);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlerContextAsyncLocalStorage =
|
||||
new AsyncContext.Variable<HandlerContext>();
|
||||
|
||||
@@ -104,6 +158,7 @@ export const createContext = ({
|
||||
listeners,
|
||||
}: ExecutorParams): WorkflowContext => {
|
||||
const queue: WorkflowEventData<any>[] = [];
|
||||
let rootWorkflowContext: WorkflowContext;
|
||||
const runHandler = (
|
||||
handler: Handler<WorkflowEvent<any>[], any>,
|
||||
inputEvents: WorkflowEvent<any>[],
|
||||
@@ -134,7 +189,32 @@ export const createContext = ({
|
||||
},
|
||||
};
|
||||
handlerContext.prev.next.add(handlerContext);
|
||||
const workflowContext = createWorkflowContext(handlerContext);
|
||||
// Use prototype chain to inherit the properties of the root workflow context for the specific context for the handler
|
||||
const specificContext = createWorkflowContext(handlerContext);
|
||||
const workflowContext = Object.create(rootWorkflowContext);
|
||||
const specificDescriptors =
|
||||
Object.getOwnPropertyDescriptors(specificContext);
|
||||
|
||||
// Apply inheritance transformers if available
|
||||
if (rootWorkflowContext.__internal__property_inheritance_handlers) {
|
||||
for (const [
|
||||
propertyKey,
|
||||
transformer,
|
||||
] of rootWorkflowContext.__internal__property_inheritance_handlers) {
|
||||
if (propertyKey in specificDescriptors) {
|
||||
const originalDescriptor = specificDescriptors[propertyKey];
|
||||
if (originalDescriptor) {
|
||||
const newDescriptor = transformer(
|
||||
workflowContext,
|
||||
originalDescriptor,
|
||||
);
|
||||
specificDescriptors[propertyKey] = newDescriptor;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperties(workflowContext, specificDescriptors);
|
||||
handlerContextAsyncLocalStorage.run(handlerContext, () => {
|
||||
const cbs = [
|
||||
...new Set([
|
||||
@@ -267,6 +347,6 @@ export const createContext = ({
|
||||
},
|
||||
};
|
||||
|
||||
const rootWorkflowContext = createWorkflowContext(handlerRootContext);
|
||||
rootWorkflowContext = createWorkflowContext(handlerRootContext);
|
||||
return rootWorkflowContext;
|
||||
};
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
// workflow
|
||||
export { createWorkflow, type Workflow } from "./workflow";
|
||||
// context
|
||||
export { getContext, type WorkflowContext, type Handler } from "./context";
|
||||
export {
|
||||
getContext,
|
||||
extendContext,
|
||||
type WorkflowContext,
|
||||
type Handler,
|
||||
type InheritanceTransformer,
|
||||
} from "./context";
|
||||
// event system
|
||||
export {
|
||||
isWorkflowEvent,
|
||||
|
||||
@@ -1,361 +0,0 @@
|
||||
import {
|
||||
eventSource,
|
||||
type Workflow as WorkflowCore,
|
||||
type WorkflowContext,
|
||||
type WorkflowEvent,
|
||||
workflowEvent,
|
||||
type WorkflowEventData,
|
||||
WorkflowStream,
|
||||
} from "@llamaindex/workflow-core";
|
||||
import type { HandlerContext } from "../core/context";
|
||||
import { createStableHash } from "./snapshot/stable-hash";
|
||||
import { createSubscribable, isPromiseLike } from "../core/utils";
|
||||
|
||||
/**
|
||||
* @internal We don't want to expose this special event to the user
|
||||
*/
|
||||
const snapshotEvent = workflowEvent<WorkflowEvent<any>>();
|
||||
const reasonWeakMap = new WeakMap<WorkflowEventData<any>, any>();
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export const request = <T>(
|
||||
event: WorkflowEvent<T>,
|
||||
reason?: any,
|
||||
): WorkflowEventData<WorkflowEvent<T>> => {
|
||||
const ev = snapshotEvent.with(event);
|
||||
reasonWeakMap.set(ev, reason);
|
||||
return ev;
|
||||
};
|
||||
|
||||
export type SnapshotFn = () => Promise<
|
||||
[requestEvents: WorkflowEvent<any>[], serializable: SnapshotData]
|
||||
>;
|
||||
|
||||
type SnapshotWorkflowContext<Workflow extends WorkflowCore> = ReturnType<
|
||||
Workflow["createContext"]
|
||||
> & {
|
||||
onRequest: <Event extends WorkflowEvent<any>>(
|
||||
event: Event,
|
||||
callback: (reason: any) => void | Promise<void>,
|
||||
) => () => void;
|
||||
/**
|
||||
* Snapshot will lock the context and wait for there is no pending event.
|
||||
*
|
||||
* This is useful when you want to take a current snapshot of the workflow
|
||||
*
|
||||
*/
|
||||
snapshot: SnapshotFn;
|
||||
};
|
||||
|
||||
type WithSnapshotWorkflow<Workflow extends WorkflowCore> = Omit<
|
||||
Workflow,
|
||||
"createContext"
|
||||
> & {
|
||||
createContext: () => SnapshotWorkflowContext<Workflow>;
|
||||
resume: (
|
||||
data: any[],
|
||||
serializable: Omit<SnapshotData, "unrecoverableQueue">,
|
||||
) => SnapshotWorkflowContext<Workflow>;
|
||||
};
|
||||
|
||||
interface SnapshotData {
|
||||
queue: [data: any, id: number][];
|
||||
/**
|
||||
* These events are not recoverable because they are not in any handler
|
||||
*
|
||||
* This is useful when you have `messageEvent` but you don't have any handler for it
|
||||
*/
|
||||
unrecoverableQueue: [data: any, id: number][];
|
||||
/**
|
||||
* This is the version of the snapshot
|
||||
*
|
||||
* Change any of the handlers will change the version
|
||||
*/
|
||||
version: string;
|
||||
missing: number[];
|
||||
}
|
||||
|
||||
type OnRequestFn<Event extends WorkflowEvent<any> = WorkflowEvent<any>> = (
|
||||
eventData: Event,
|
||||
reason: any,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export function withSnapshot<Workflow extends WorkflowCore>(
|
||||
workflow: Workflow,
|
||||
): WithSnapshotWorkflow<Workflow> {
|
||||
const requests = createSubscribable<OnRequestFn>();
|
||||
const pendingRequestSetMap = new WeakMap<
|
||||
WorkflowContext,
|
||||
Set<PromiseLike<unknown>>
|
||||
>();
|
||||
const getPendingRequestSet = (context: WorkflowContext) => {
|
||||
if (!pendingRequestSetMap.has(context)) {
|
||||
pendingRequestSetMap.set(context, new Set());
|
||||
}
|
||||
return pendingRequestSetMap.get(context)!;
|
||||
};
|
||||
const stableHash = createStableHash();
|
||||
/**
|
||||
* This is to indicate the version of the snapshot
|
||||
*
|
||||
* It happens when you modify the workflow, all old snapshots should be invalidated
|
||||
*/
|
||||
const versionObj: [number[], Function][] = [];
|
||||
const getVersion = () => stableHash(versionObj);
|
||||
|
||||
const registeredEvents = new Set<WorkflowEvent<any>>();
|
||||
const isContextLockedWeakMap = new WeakMap<WorkflowContext, boolean>();
|
||||
const isContextLocked = (context: WorkflowContext): boolean => {
|
||||
return isContextLockedWeakMap.get(context) === true;
|
||||
};
|
||||
const isContextSnapshotReadyWeakSet = new WeakSet<WorkflowContext>();
|
||||
const isContextSnapshotReady = (context: WorkflowContext) => {
|
||||
return isContextSnapshotReadyWeakSet.has(context);
|
||||
};
|
||||
|
||||
const contextEventQueueWeakMap = new WeakMap<
|
||||
WorkflowContext,
|
||||
WorkflowEventData<any>[]
|
||||
>();
|
||||
const handlerContextSetWeakMap = new WeakMap<
|
||||
WorkflowContext,
|
||||
Set<HandlerContext>
|
||||
>();
|
||||
const collectedEventHandlerContextWeakMap = new WeakMap<
|
||||
WorkflowEventData<any>,
|
||||
Set<HandlerContext>
|
||||
>();
|
||||
|
||||
const createSnapshotFn = (context: WorkflowContext): SnapshotFn => {
|
||||
return async function snapshotHandler() {
|
||||
if (isContextLocked(context)) {
|
||||
throw new Error(
|
||||
"Context is already locked, you cannot snapshot a same context twice",
|
||||
);
|
||||
}
|
||||
isContextLockedWeakMap.set(context, true);
|
||||
|
||||
// 1. wait for all context is ready
|
||||
const handlerContexts = handlerContextSetWeakMap.get(context)!;
|
||||
|
||||
await Promise.all(
|
||||
[...handlerContexts]
|
||||
.filter((context) => context.async)
|
||||
.map((context) => context.pending),
|
||||
);
|
||||
// 2. collect all necessary data for a snapshot after lock
|
||||
const collectedEvents = contextEventQueueWeakMap.get(context)!;
|
||||
const requestEvents = collectedEvents
|
||||
.filter((event) => snapshotEvent.include(event))
|
||||
.map((event) => event.data);
|
||||
// there might have pending events in the queue, we need to collect them
|
||||
const queue = collectedEvents.filter(
|
||||
(event) => !snapshotEvent.include(event),
|
||||
);
|
||||
|
||||
// 3. serialize the data
|
||||
isContextSnapshotReadyWeakSet.add(context);
|
||||
|
||||
if (requestEvents.some((event) => !registeredEvents.has(event))) {
|
||||
console.warn("request event is not registered in the workflow");
|
||||
}
|
||||
|
||||
const serializable: SnapshotData = {
|
||||
queue: queue
|
||||
.filter((event) => eventCounterWeakMap.has(eventSource(event)!))
|
||||
.map((event) => [event.data, getEventCounter(eventSource(event)!)]),
|
||||
unrecoverableQueue: queue
|
||||
.filter((event) => !eventCounterWeakMap.has(eventSource(event)!))
|
||||
.map((event) => [event.data, getEventCounter(eventSource(event)!)]),
|
||||
version: getVersion(),
|
||||
missing: requestEvents
|
||||
// if you are request an event that is not in the handler, it's meaningless (from a logic perspective)
|
||||
.filter((event) => eventCounterWeakMap.has(event))
|
||||
.map((event) => getEventCounter(event)),
|
||||
};
|
||||
return [requestEvents, serializable];
|
||||
};
|
||||
};
|
||||
|
||||
let counter = 0;
|
||||
const eventCounterWeakMap = new WeakMap<WorkflowEvent<any>, number>();
|
||||
const counterEventMap = new Map<number, WorkflowEvent<any>>();
|
||||
const getEventCounter = (event: WorkflowEvent<any>) => {
|
||||
if (!eventCounterWeakMap.has(event)) {
|
||||
eventCounterWeakMap.set(event, counter++);
|
||||
}
|
||||
return eventCounterWeakMap.get(event)!;
|
||||
};
|
||||
const getCounterEvent = (counter: number) => {
|
||||
if (!counterEventMap.has(counter)) {
|
||||
throw new Error(`event counter ${counter} not found`);
|
||||
}
|
||||
return counterEventMap.get(counter)!;
|
||||
};
|
||||
|
||||
function initContext(context: WorkflowContext) {
|
||||
handlerContextSetWeakMap.set(context, new Set());
|
||||
contextEventQueueWeakMap.set(context, []);
|
||||
context.__internal__call_send_event.subscribe(
|
||||
(eventData, handlerContext) => {
|
||||
contextEventQueueWeakMap.get(context)!.push(eventData);
|
||||
if (isContextLocked(context)) {
|
||||
if (isContextSnapshotReady(context)) {
|
||||
console.warn(
|
||||
"snapshot is already ready, sendEvent after snapshot is not allowed",
|
||||
);
|
||||
}
|
||||
if (!collectedEventHandlerContextWeakMap.has(eventData)) {
|
||||
collectedEventHandlerContextWeakMap.set(eventData, new Set());
|
||||
}
|
||||
collectedEventHandlerContextWeakMap
|
||||
.get(eventData)!
|
||||
.add(handlerContext);
|
||||
}
|
||||
},
|
||||
);
|
||||
context.__internal__call_context.subscribe((handlerContext, next) => {
|
||||
if (isContextLocked(context)) {
|
||||
// replace it with noop, avoid calling the handler after snapshot
|
||||
handlerContext.handler = noop;
|
||||
next(handlerContext);
|
||||
} else {
|
||||
const queue = contextEventQueueWeakMap.get(context)!;
|
||||
handlerContext.inputs.forEach((input) => {
|
||||
queue.splice(queue.indexOf(input), 1);
|
||||
});
|
||||
const originalHandler = handlerContext.handler;
|
||||
const pendingRequests = getPendingRequestSet(context);
|
||||
const isPendingTask = pendingRequests.size !== 0;
|
||||
if (isPendingTask) {
|
||||
handlerContext.handler = async (...events) => {
|
||||
return Promise.all([...pendingRequests]).finally(() => {
|
||||
return originalHandler(...events);
|
||||
});
|
||||
};
|
||||
}
|
||||
handlerContextSetWeakMap.get(context)!.add(handlerContext);
|
||||
next(handlerContext);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...workflow,
|
||||
handle: (events: WorkflowEvent<any>[], handler: any) => {
|
||||
// version the snapshot based on the input events and function
|
||||
// I assume `uniqueId` is changeable
|
||||
versionObj.push([events.map(getEventCounter), handler]);
|
||||
|
||||
events.forEach((event) => {
|
||||
counterEventMap.set(getEventCounter(event), event);
|
||||
});
|
||||
|
||||
events.forEach((event) => {
|
||||
registeredEvents.add(event);
|
||||
});
|
||||
return workflow.handle(events, handler);
|
||||
},
|
||||
resume(
|
||||
data: any[],
|
||||
serializable: Omit<SnapshotData, "unrecoverableQueue">,
|
||||
): any {
|
||||
const events = data.map((d, i) =>
|
||||
getCounterEvent(serializable.missing[i]!).with(d),
|
||||
);
|
||||
const context = workflow.createContext();
|
||||
initContext(context);
|
||||
const stream = context.stream;
|
||||
context.sendEvent(
|
||||
...serializable.queue.map(([data, id]) => {
|
||||
const event = getCounterEvent(id);
|
||||
return event.with(data);
|
||||
}),
|
||||
);
|
||||
context.sendEvent(...events);
|
||||
|
||||
let lazyInitStream: WorkflowStream | null = null;
|
||||
const snapshotFn = createSnapshotFn(context);
|
||||
return {
|
||||
...context,
|
||||
snapshot: snapshotFn,
|
||||
onRequest: (
|
||||
event: WorkflowEvent<any>,
|
||||
callback: (reason: any) => void | Promise<void>,
|
||||
): (() => void) =>
|
||||
requests.subscribe((ev, reason) => {
|
||||
if (ev === event) {
|
||||
return callback(reason);
|
||||
}
|
||||
}),
|
||||
get stream() {
|
||||
if (!lazyInitStream) {
|
||||
lazyInitStream = stream.pipeThrough(
|
||||
new TransformStream({
|
||||
transform: (event, controller) => {
|
||||
if (snapshotEvent.include(event)) {
|
||||
const data = event.data;
|
||||
requests.publish(data, reasonWeakMap.get(event));
|
||||
} else {
|
||||
// ignore snapshot event from stream
|
||||
controller.enqueue(event);
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return lazyInitStream;
|
||||
},
|
||||
};
|
||||
},
|
||||
createContext(): any {
|
||||
const context = workflow.createContext();
|
||||
initContext(context);
|
||||
const stream = context.stream;
|
||||
let lazyInitStream: WorkflowStream | null = null;
|
||||
const snapshotFn = createSnapshotFn(context);
|
||||
return {
|
||||
...context,
|
||||
snapshot: snapshotFn,
|
||||
onRequest: (
|
||||
event: WorkflowEvent<any>,
|
||||
callback: (reason: any) => void | Promise<void>,
|
||||
): (() => void) =>
|
||||
requests.subscribe((ev, reason) => {
|
||||
if (ev === event) {
|
||||
return callback(reason);
|
||||
}
|
||||
}),
|
||||
get stream() {
|
||||
if (!lazyInitStream) {
|
||||
lazyInitStream = stream.pipeThrough(
|
||||
new TransformStream({
|
||||
transform: (event, controller) => {
|
||||
if (snapshotEvent.include(event)) {
|
||||
const data = event.data;
|
||||
const results = requests.publish(
|
||||
data,
|
||||
reasonWeakMap.get(event),
|
||||
);
|
||||
const pendingRequests = getPendingRequestSet(context);
|
||||
results.filter(isPromiseLike).forEach((promise) => {
|
||||
const task = promise.then(() => {
|
||||
pendingRequests.delete(task);
|
||||
});
|
||||
pendingRequests.add(task);
|
||||
});
|
||||
} else {
|
||||
// ignore snapshot event from stream
|
||||
controller.enqueue(event);
|
||||
}
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
return lazyInitStream;
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as WithSnapshotWorkflow<Workflow>;
|
||||
}
|
||||
@@ -1,68 +1,423 @@
|
||||
import type {
|
||||
Workflow,
|
||||
Workflow as WorkflowCore,
|
||||
WorkflowContext,
|
||||
import {
|
||||
eventSource,
|
||||
getContext,
|
||||
type Workflow,
|
||||
type WorkflowContext,
|
||||
type Workflow as WorkflowCore,
|
||||
type WorkflowEvent,
|
||||
workflowEvent,
|
||||
type WorkflowEventData,
|
||||
WorkflowStream,
|
||||
} from "@llamaindex/workflow-core";
|
||||
import { getContext } from "@llamaindex/workflow-core";
|
||||
import type { HandlerContext } from "../core/context";
|
||||
import { extendContext } from "../core/context";
|
||||
import { createSubscribable, isPromiseLike } from "../core/utils";
|
||||
import { createStableHash } from "./snapshot/stable-hash";
|
||||
|
||||
type WithState<State, Input> = Input extends void | undefined
|
||||
/**
|
||||
* @internal We don't want to expose this special event to the user
|
||||
*/
|
||||
const snapshotEvent = workflowEvent<WorkflowEvent<any>>();
|
||||
const reasonWeakMap = new WeakMap<WorkflowEventData<any>, any>();
|
||||
|
||||
const noop = () => {};
|
||||
|
||||
export const request = <T>(
|
||||
event: WorkflowEvent<T>,
|
||||
reason?: any,
|
||||
): WorkflowEventData<WorkflowEvent<T>> => {
|
||||
const ev = snapshotEvent.with(event);
|
||||
reasonWeakMap.set(ev, reason);
|
||||
return ev;
|
||||
};
|
||||
|
||||
export type SnapshotFn = () => Promise<
|
||||
[requestEvents: WorkflowEvent<any>[], serializable: SnapshotData]
|
||||
>;
|
||||
|
||||
export type SnapshotWorkflowContext<Workflow extends WorkflowCore> = ReturnType<
|
||||
Workflow["createContext"]
|
||||
> & {
|
||||
onRequest: <Event extends WorkflowEvent<any>>(
|
||||
event: Event,
|
||||
callback: (reason: any) => void | Promise<void>,
|
||||
) => () => void;
|
||||
/**
|
||||
* Snapshot will lock the context and wait for there is no pending event.
|
||||
*
|
||||
* This is useful when you want to take a current snapshot of the workflow
|
||||
*
|
||||
*/
|
||||
snapshot: SnapshotFn;
|
||||
};
|
||||
|
||||
export interface SnapshotData {
|
||||
queue: [data: any, id: number][];
|
||||
/**
|
||||
* These events are not recoverable because they are not in any handler
|
||||
*
|
||||
* This is useful when you have `messageEvent` but you don't have any handler for it
|
||||
*/
|
||||
unrecoverableQueue: [data: any, id: number][];
|
||||
/**
|
||||
* This is the version of the snapshot
|
||||
*
|
||||
* Change any of the handlers will change the version
|
||||
*/
|
||||
version: string;
|
||||
missing: number[];
|
||||
|
||||
/**
|
||||
* Save the current serializable state of the workflow
|
||||
* This state will be restored when you resume the workflow
|
||||
*/
|
||||
state?: string | undefined;
|
||||
}
|
||||
|
||||
type OnRequestFn<Event extends WorkflowEvent<any> = WorkflowEvent<any>> = (
|
||||
eventData: Event,
|
||||
reason: any,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export interface SnapshotableContext {
|
||||
snapshot: SnapshotFn;
|
||||
onRequest: <Event extends WorkflowEvent<any>>(
|
||||
event: Event,
|
||||
callback: (reason: any) => void | Promise<void>,
|
||||
) => () => void;
|
||||
}
|
||||
|
||||
export type StatefulContextWithSnapshot<State> = ReturnType<
|
||||
Workflow["createContext"]
|
||||
> & {
|
||||
get state(): State;
|
||||
} & SnapshotableContext;
|
||||
|
||||
export type ResumeFunction<State> = (
|
||||
data: any[],
|
||||
serializable: Omit<SnapshotData, "unrecoverableQueue">,
|
||||
) => StatefulContextWithSnapshot<State>;
|
||||
|
||||
export type WorkflowWithState<State, Input> = Input extends void | undefined
|
||||
? {
|
||||
<Workflow extends WorkflowCore>(
|
||||
workflow: Workflow,
|
||||
): Omit<Workflow, "createContext"> & {
|
||||
createContext(): ReturnType<Workflow["createContext"]> & {
|
||||
get state(): State;
|
||||
};
|
||||
createContext(): StatefulContextWithSnapshot<State>;
|
||||
resume: ResumeFunction<State>;
|
||||
};
|
||||
}
|
||||
: {
|
||||
<Workflow extends WorkflowCore>(
|
||||
workflow: Workflow,
|
||||
): Omit<Workflow, "createContext"> & {
|
||||
createContext(input: Input): ReturnType<Workflow["createContext"]> & {
|
||||
get state(): State;
|
||||
};
|
||||
createContext(input: Input): StatefulContextWithSnapshot<State>;
|
||||
resume: ResumeFunction<State>;
|
||||
};
|
||||
};
|
||||
|
||||
type CreateState<State, Input, Context extends WorkflowContext> = {
|
||||
getContext(): Context & {
|
||||
get state(): State;
|
||||
};
|
||||
withState: WithState<State, Input>;
|
||||
} & SnapshotableContext;
|
||||
withState: WorkflowWithState<State, Input>;
|
||||
};
|
||||
|
||||
type InitFunc<Input, State> = (input: Input) => State;
|
||||
|
||||
export function createStatefulMiddleware<
|
||||
State,
|
||||
Input = void,
|
||||
Context extends WorkflowContext = WorkflowContext,
|
||||
>(init: (input: Input) => State): CreateState<State, Input, Context> {
|
||||
>(init?: InitFunc<Input, State>): CreateState<State, Input, Context> {
|
||||
return {
|
||||
getContext: getContext as never,
|
||||
withState: ((workflow: Workflow) => {
|
||||
const requests = createSubscribable<OnRequestFn>();
|
||||
const pendingRequestSetMap = new WeakMap<
|
||||
WorkflowContext,
|
||||
Set<PromiseLike<unknown>>
|
||||
>();
|
||||
const getPendingRequestSet = (context: WorkflowContext) => {
|
||||
if (!pendingRequestSetMap.has(context)) {
|
||||
pendingRequestSetMap.set(context, new Set());
|
||||
}
|
||||
return pendingRequestSetMap.get(context)!;
|
||||
};
|
||||
const stableHash = createStableHash();
|
||||
/**
|
||||
* This is to indicate the version of the snapshot
|
||||
*
|
||||
* It happens when you modify the workflow, all old snapshots should be invalidated
|
||||
*/
|
||||
const versionObj: [number[], Function][] = [];
|
||||
const getVersion = () => stableHash(versionObj);
|
||||
|
||||
const registeredEvents = new Set<WorkflowEvent<any>>();
|
||||
const isContextLockedWeakMap = new WeakMap<WorkflowContext, boolean>();
|
||||
const isContextLocked = (context: WorkflowContext): boolean => {
|
||||
return isContextLockedWeakMap.get(context) === true;
|
||||
};
|
||||
const isContextSnapshotReadyWeakSet = new WeakSet<WorkflowContext>();
|
||||
const isContextSnapshotReady = (context: WorkflowContext) => {
|
||||
return isContextSnapshotReadyWeakSet.has(context);
|
||||
};
|
||||
|
||||
const contextEventQueueWeakMap = new WeakMap<
|
||||
WorkflowContext,
|
||||
WorkflowEventData<any>[]
|
||||
>();
|
||||
const handlerContextSetWeakMap = new WeakMap<
|
||||
WorkflowContext,
|
||||
Set<HandlerContext>
|
||||
>();
|
||||
const collectedEventHandlerContextWeakMap = new WeakMap<
|
||||
WorkflowEventData<any>,
|
||||
Set<HandlerContext>
|
||||
>();
|
||||
|
||||
const createSnapshotFn = (context: WorkflowContext): SnapshotFn => {
|
||||
return async function snapshotHandler() {
|
||||
if (isContextLocked(context)) {
|
||||
throw new Error(
|
||||
"Context is already locked, you cannot snapshot a same context twice",
|
||||
);
|
||||
}
|
||||
isContextLockedWeakMap.set(context, true);
|
||||
|
||||
// 1. wait for all context is ready
|
||||
const handlerContexts = handlerContextSetWeakMap.get(context)!;
|
||||
|
||||
await Promise.all(
|
||||
[...handlerContexts]
|
||||
.filter((context) => context.async)
|
||||
.map((context) => context.pending),
|
||||
);
|
||||
// 2. collect all necessary data for a snapshot after lock
|
||||
const collectedEvents = contextEventQueueWeakMap.get(context)!;
|
||||
const requestEvents = collectedEvents
|
||||
.filter((event) => snapshotEvent.include(event))
|
||||
.map((event) => event.data);
|
||||
// there might have pending events in the queue, we need to collect them
|
||||
const queue = collectedEvents.filter(
|
||||
(event) => !snapshotEvent.include(event),
|
||||
);
|
||||
|
||||
// 3. serialize the data
|
||||
isContextSnapshotReadyWeakSet.add(context);
|
||||
|
||||
if (requestEvents.some((event) => !registeredEvents.has(event))) {
|
||||
console.warn("request event is not registered in the workflow");
|
||||
}
|
||||
|
||||
const serializable: SnapshotData = {
|
||||
queue: queue
|
||||
.filter((event) => eventCounterWeakMap.has(eventSource(event)!))
|
||||
.map((event) => [
|
||||
event.data,
|
||||
getEventCounter(eventSource(event)!),
|
||||
]),
|
||||
unrecoverableQueue: queue
|
||||
.filter((event) => !eventCounterWeakMap.has(eventSource(event)!))
|
||||
.map((event) => [
|
||||
event.data,
|
||||
getEventCounter(eventSource(event)!),
|
||||
]),
|
||||
version: getVersion(),
|
||||
missing: requestEvents
|
||||
// if you are request an event that is not in the handler, it's meaningless (from a logic perspective)
|
||||
.filter((event) => eventCounterWeakMap.has(event))
|
||||
.map((event) => getEventCounter(event)),
|
||||
state: (context as any).state
|
||||
? JSON.stringify((context as any).state)
|
||||
: undefined,
|
||||
};
|
||||
return [requestEvents, serializable];
|
||||
};
|
||||
};
|
||||
|
||||
let counter = 0;
|
||||
const eventCounterWeakMap = new WeakMap<WorkflowEvent<any>, number>();
|
||||
const counterEventMap = new Map<number, WorkflowEvent<any>>();
|
||||
const getEventCounter = (event: WorkflowEvent<any>) => {
|
||||
if (!eventCounterWeakMap.has(event)) {
|
||||
eventCounterWeakMap.set(event, counter++);
|
||||
}
|
||||
return eventCounterWeakMap.get(event)!;
|
||||
};
|
||||
const getCounterEvent = (counter: number) => {
|
||||
if (!counterEventMap.has(counter)) {
|
||||
throw new Error(`event counter ${counter} not found`);
|
||||
}
|
||||
return counterEventMap.get(counter)!;
|
||||
};
|
||||
|
||||
function initContext(context: WorkflowContext) {
|
||||
handlerContextSetWeakMap.set(context, new Set());
|
||||
contextEventQueueWeakMap.set(context, []);
|
||||
context.__internal__call_send_event.subscribe(
|
||||
(eventData, handlerContext) => {
|
||||
contextEventQueueWeakMap.get(context)!.push(eventData);
|
||||
if (isContextLocked(context)) {
|
||||
if (isContextSnapshotReady(context)) {
|
||||
console.warn(
|
||||
"snapshot is already ready, sendEvent after snapshot is not allowed",
|
||||
);
|
||||
}
|
||||
if (!collectedEventHandlerContextWeakMap.has(eventData)) {
|
||||
collectedEventHandlerContextWeakMap.set(eventData, new Set());
|
||||
}
|
||||
collectedEventHandlerContextWeakMap
|
||||
.get(eventData)!
|
||||
.add(handlerContext);
|
||||
}
|
||||
},
|
||||
);
|
||||
context.__internal__call_context.subscribe((handlerContext, next) => {
|
||||
if (isContextLocked(context)) {
|
||||
// replace it with noop, avoid calling the handler after snapshot
|
||||
handlerContext.handler = noop;
|
||||
next(handlerContext);
|
||||
} else {
|
||||
const queue = contextEventQueueWeakMap.get(context)!;
|
||||
handlerContext.inputs.forEach((input) => {
|
||||
queue.splice(queue.indexOf(input), 1);
|
||||
});
|
||||
const originalHandler = handlerContext.handler;
|
||||
const pendingRequests = getPendingRequestSet(context);
|
||||
const isPendingTask = pendingRequests.size !== 0;
|
||||
if (isPendingTask) {
|
||||
handlerContext.handler = async (...events) => {
|
||||
return Promise.all([...pendingRequests]).finally(() => {
|
||||
return originalHandler(...events);
|
||||
});
|
||||
};
|
||||
}
|
||||
handlerContextSetWeakMap.get(context)!.add(handlerContext);
|
||||
next(handlerContext);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create a function to generate stream transform wrappers
|
||||
// Shared function to create stateful context
|
||||
const createStatefulContext = (state: State): any => {
|
||||
const context = (workflow.createContext as any)();
|
||||
initContext(context);
|
||||
|
||||
const snapshotFn = createSnapshotFn(context);
|
||||
|
||||
extendContext(
|
||||
context,
|
||||
{
|
||||
get state() {
|
||||
return state;
|
||||
},
|
||||
snapshot: snapshotFn,
|
||||
onRequest: (
|
||||
event: WorkflowEvent<any>,
|
||||
callback: (reason: any) => void | Promise<void>,
|
||||
): (() => void) =>
|
||||
requests.subscribe((ev, reason) => {
|
||||
if (ev === event) {
|
||||
return callback(reason);
|
||||
}
|
||||
}),
|
||||
},
|
||||
{
|
||||
stream: (currentContext, originalDescriptor) => {
|
||||
let lazyWrappedStream: WorkflowStream | null = null;
|
||||
|
||||
return {
|
||||
...originalDescriptor,
|
||||
get() {
|
||||
if (!lazyWrappedStream) {
|
||||
const originalStream =
|
||||
originalDescriptor.get?.call(currentContext);
|
||||
if (originalStream) {
|
||||
lazyWrappedStream = originalStream.pipeThrough(
|
||||
createStreamWrapper(context),
|
||||
);
|
||||
}
|
||||
}
|
||||
return lazyWrappedStream;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
const createStreamWrapper = (context: WorkflowContext) =>
|
||||
new TransformStream({
|
||||
transform: (event, controller) => {
|
||||
if (snapshotEvent.include(event)) {
|
||||
const data = event.data;
|
||||
const results = requests.publish(data, reasonWeakMap.get(event));
|
||||
const pendingRequests = getPendingRequestSet(context);
|
||||
results.filter(isPromiseLike).forEach((promise) => {
|
||||
const task = promise.then(() => {
|
||||
pendingRequests.delete(task);
|
||||
});
|
||||
pendingRequests.add(task);
|
||||
});
|
||||
} else {
|
||||
// ignore snapshot event from stream
|
||||
controller.enqueue(event);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
...workflow,
|
||||
createContext: (input: Input) => {
|
||||
const state = init(input);
|
||||
const context = workflow.createContext();
|
||||
context.__internal__call_context.subscribe((_, next) => {
|
||||
// todo: make sure getContext is consistent with `workflow.createContext`
|
||||
const context = getContext();
|
||||
if (!Reflect.has(context, "state")) {
|
||||
Object.defineProperty(context, "state", {
|
||||
get: () => state,
|
||||
});
|
||||
}
|
||||
next(_);
|
||||
handle: (events: WorkflowEvent<any>[], handler: any) => {
|
||||
// version the snapshot based on the input events and function
|
||||
// I assume `uniqueId` is changeable
|
||||
versionObj.push([events.map(getEventCounter), handler]);
|
||||
|
||||
events.forEach((event) => {
|
||||
counterEventMap.set(getEventCounter(event), event);
|
||||
});
|
||||
if (!Reflect.has(context, "state")) {
|
||||
Object.defineProperty(context, "state", {
|
||||
get: () => state,
|
||||
});
|
||||
}
|
||||
return context as any;
|
||||
|
||||
events.forEach((event) => {
|
||||
registeredEvents.add(event);
|
||||
});
|
||||
return workflow.handle(events, handler);
|
||||
},
|
||||
resume(
|
||||
data: any[],
|
||||
serializable: Omit<SnapshotData, "unrecoverableQueue">,
|
||||
): any {
|
||||
const resumedState = serializable.state
|
||||
? JSON.parse(serializable.state)
|
||||
: undefined;
|
||||
const events = data.map((d, i) =>
|
||||
getCounterEvent(serializable.missing[i]!).with(d),
|
||||
);
|
||||
|
||||
// Call the stateful createContext with the resumed state
|
||||
const context = createStatefulContext(resumedState);
|
||||
|
||||
// triggers the lazy initialization of the stream wrapper
|
||||
context.stream;
|
||||
|
||||
context.sendEvent(
|
||||
...serializable.queue.map(([data, id]) => {
|
||||
const event = getCounterEvent(id);
|
||||
return event.with(data);
|
||||
}),
|
||||
);
|
||||
context.sendEvent(...events);
|
||||
|
||||
return context;
|
||||
},
|
||||
createContext(input?: Input): any {
|
||||
const state = init?.(input as Input) as State;
|
||||
return createStatefulContext(state);
|
||||
},
|
||||
};
|
||||
}) as unknown as WithState<State, Input>,
|
||||
}) as unknown as WorkflowWithState<State, Input>,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
import type {
|
||||
Workflow,
|
||||
Workflow as WorkflowCore,
|
||||
WorkflowContext,
|
||||
} from "@llamaindex/workflow-core";
|
||||
import { getContext } from "@llamaindex/workflow-core";
|
||||
|
||||
type WithState<State, Input> = Input extends void | undefined
|
||||
? {
|
||||
<Workflow extends WorkflowCore>(
|
||||
workflow: Workflow,
|
||||
): Omit<Workflow, "createContext"> & {
|
||||
createContext(): ReturnType<Workflow["createContext"]> & {
|
||||
get state(): State;
|
||||
};
|
||||
};
|
||||
}
|
||||
: {
|
||||
<Workflow extends WorkflowCore>(
|
||||
workflow: Workflow,
|
||||
): Omit<Workflow, "createContext"> & {
|
||||
createContext(input: Input): ReturnType<Workflow["createContext"]> & {
|
||||
get state(): State;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type CreateState<State, Input, Context extends WorkflowContext> = {
|
||||
getContext(): Context & {
|
||||
get state(): State;
|
||||
};
|
||||
withState: WithState<State, Input>;
|
||||
};
|
||||
|
||||
export function createStateMiddleware<
|
||||
State,
|
||||
Input = void,
|
||||
Context extends WorkflowContext = WorkflowContext,
|
||||
>(init: (input: Input) => State): CreateState<State, Input, Context> {
|
||||
return {
|
||||
getContext: getContext as never,
|
||||
withState: ((workflow: Workflow) => {
|
||||
return {
|
||||
...workflow,
|
||||
createContext: (input: Input) => {
|
||||
const state = init(input);
|
||||
const context = workflow.createContext();
|
||||
context.__internal__call_context.subscribe((_, next) => {
|
||||
// todo: make sure getContext is consistent with `workflow.createContext`
|
||||
const context = getContext();
|
||||
if (!Reflect.has(context, "state")) {
|
||||
Object.defineProperty(context, "state", {
|
||||
get: () => state,
|
||||
});
|
||||
}
|
||||
next(_);
|
||||
});
|
||||
if (!Reflect.has(context, "state")) {
|
||||
Object.defineProperty(context, "state", {
|
||||
get: () => state,
|
||||
});
|
||||
}
|
||||
return context as any;
|
||||
},
|
||||
};
|
||||
}) as unknown as WithState<State, Input>,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
createWorkflow,
|
||||
extendContext,
|
||||
getContext,
|
||||
workflowEvent,
|
||||
type InheritanceTransformer,
|
||||
type WorkflowContext,
|
||||
} from "@llamaindex/workflow-core";
|
||||
|
||||
const startEvent = workflowEvent({
|
||||
debugLabel: "start",
|
||||
});
|
||||
|
||||
const stopEvent = workflowEvent({
|
||||
debugLabel: "stop",
|
||||
});
|
||||
|
||||
describe("extendContext", () => {
|
||||
test("should add simple properties to context", () => {
|
||||
const workflow = createWorkflow();
|
||||
const context = workflow.createContext();
|
||||
|
||||
const testProperty = "test-value";
|
||||
const testFunction = vi.fn();
|
||||
|
||||
extendContext(context, {
|
||||
testProperty,
|
||||
testFunction,
|
||||
});
|
||||
|
||||
expect((context as any).testProperty).toBe(testProperty);
|
||||
expect((context as any).testFunction).toBe(testFunction);
|
||||
});
|
||||
|
||||
test("should apply inheritance transformers to root context immediately", () => {
|
||||
const workflow = createWorkflow();
|
||||
const context = workflow.createContext();
|
||||
|
||||
const originalValue = "original";
|
||||
const transformedValue = "transformed";
|
||||
|
||||
// Add a property first
|
||||
extendContext(context, {
|
||||
testProperty: originalValue,
|
||||
});
|
||||
|
||||
// Then add a transformer for that property
|
||||
const transformer: InheritanceTransformer = vi.fn(
|
||||
(handlerContext, originalDescriptor) => ({
|
||||
...originalDescriptor,
|
||||
value: transformedValue,
|
||||
}),
|
||||
);
|
||||
|
||||
extendContext(
|
||||
context,
|
||||
{},
|
||||
{
|
||||
testProperty: transformer,
|
||||
},
|
||||
);
|
||||
|
||||
expect(transformer).toHaveBeenCalledWith(context, expect.any(Object));
|
||||
expect((context as any).testProperty).toBe(transformedValue);
|
||||
});
|
||||
|
||||
test("should handle getter properties with transformers", () => {
|
||||
const workflow = createWorkflow();
|
||||
const context = workflow.createContext();
|
||||
|
||||
// Add a getter property
|
||||
Object.defineProperty(context, "dynamicProperty", {
|
||||
get() {
|
||||
return "dynamic-value";
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
const transformer: InheritanceTransformer = (
|
||||
handlerContext,
|
||||
originalDescriptor,
|
||||
) => ({
|
||||
...originalDescriptor,
|
||||
get() {
|
||||
const original = originalDescriptor.get?.call(handlerContext);
|
||||
return `transformed-${original}`;
|
||||
},
|
||||
});
|
||||
|
||||
extendContext(
|
||||
context,
|
||||
{},
|
||||
{
|
||||
dynamicProperty: transformer,
|
||||
},
|
||||
);
|
||||
|
||||
expect((context as any).dynamicProperty).toBe("transformed-dynamic-value");
|
||||
});
|
||||
|
||||
test("should handle multiple inheritance transformers", () => {
|
||||
const workflow = createWorkflow();
|
||||
const context = workflow.createContext();
|
||||
|
||||
extendContext(context, {
|
||||
prop1: "value1",
|
||||
prop2: "value2",
|
||||
prop3: "value3",
|
||||
});
|
||||
|
||||
const transformer1: InheritanceTransformer = (_, originalDescriptor) => ({
|
||||
...originalDescriptor,
|
||||
value: `transformed-${originalDescriptor.value}`,
|
||||
});
|
||||
|
||||
const transformer2: InheritanceTransformer = (_, originalDescriptor) => ({
|
||||
...originalDescriptor,
|
||||
value: `${originalDescriptor.value}-extra`,
|
||||
});
|
||||
|
||||
extendContext(
|
||||
context,
|
||||
{},
|
||||
{
|
||||
prop1: transformer1,
|
||||
prop2: transformer2,
|
||||
},
|
||||
);
|
||||
|
||||
expect((context as any).prop1).toBe("transformed-value1");
|
||||
expect((context as any).prop2).toBe("value2-extra");
|
||||
expect((context as any).prop3).toBe("value3"); // No transformer, unchanged
|
||||
});
|
||||
|
||||
test("should handle lazy initialization in transformers", () => {
|
||||
const workflow = createWorkflow();
|
||||
const context = workflow.createContext();
|
||||
const initCalls: string[] = [];
|
||||
|
||||
Object.defineProperty(context, "lazyProperty", {
|
||||
get() {
|
||||
initCalls.push("root");
|
||||
return "root-value";
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
const transformer: InheritanceTransformer = (
|
||||
handlerContext,
|
||||
originalDescriptor,
|
||||
) => {
|
||||
let lazyValue: any = null;
|
||||
return {
|
||||
...originalDescriptor,
|
||||
get() {
|
||||
if (lazyValue === null) {
|
||||
initCalls.push("handler");
|
||||
const original = originalDescriptor.get?.call(handlerContext);
|
||||
lazyValue = `lazy-${original}`;
|
||||
}
|
||||
return lazyValue;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
extendContext(
|
||||
context,
|
||||
{},
|
||||
{
|
||||
lazyProperty: transformer,
|
||||
},
|
||||
);
|
||||
|
||||
const value = (context as any).lazyProperty;
|
||||
expect(value).toBe("lazy-root-value");
|
||||
expect(initCalls).toEqual(["handler", "root"]);
|
||||
|
||||
// Second access should use cached value
|
||||
const value2 = (context as any).lazyProperty;
|
||||
expect(value2).toBe("lazy-root-value");
|
||||
expect(initCalls).toEqual(["handler", "root"]); // No additional calls
|
||||
});
|
||||
|
||||
test("should not apply transformers for non-existent properties", () => {
|
||||
const workflow = createWorkflow();
|
||||
const context = workflow.createContext();
|
||||
|
||||
const transformer = vi.fn();
|
||||
|
||||
extendContext(
|
||||
context,
|
||||
{},
|
||||
{
|
||||
nonExistentProperty: transformer,
|
||||
},
|
||||
);
|
||||
|
||||
expect(transformer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("should work with multiple extendContext calls", () => {
|
||||
const workflow = createWorkflow();
|
||||
const context = workflow.createContext();
|
||||
|
||||
// First call
|
||||
extendContext(context, {
|
||||
prop1: "value1",
|
||||
});
|
||||
|
||||
// Second call
|
||||
extendContext(context, {
|
||||
prop2: "value2",
|
||||
});
|
||||
|
||||
// Third call with transformer
|
||||
extendContext(context, {
|
||||
prop3: "value3",
|
||||
});
|
||||
|
||||
const transformer: InheritanceTransformer = (_, originalDescriptor) => ({
|
||||
...originalDescriptor,
|
||||
value: `transformed-${originalDescriptor.value}`,
|
||||
});
|
||||
|
||||
extendContext(
|
||||
context,
|
||||
{},
|
||||
{
|
||||
prop3: transformer,
|
||||
},
|
||||
);
|
||||
|
||||
expect((context as any).prop1).toBe("value1");
|
||||
expect((context as any).prop2).toBe("value2");
|
||||
expect((context as any).prop3).toBe("transformed-value3");
|
||||
});
|
||||
|
||||
test("should preserve original property descriptor properties", () => {
|
||||
const workflow = createWorkflow();
|
||||
const context = workflow.createContext();
|
||||
|
||||
Object.defineProperty(context, "testProperty", {
|
||||
value: "test",
|
||||
writable: false,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const transformer: InheritanceTransformer = (_, originalDescriptor) => ({
|
||||
...originalDescriptor,
|
||||
value: "transformed",
|
||||
});
|
||||
|
||||
extendContext(
|
||||
context,
|
||||
{},
|
||||
{
|
||||
testProperty: transformer,
|
||||
},
|
||||
);
|
||||
|
||||
const descriptor = Object.getOwnPropertyDescriptor(context, "testProperty");
|
||||
expect(descriptor?.value).toBe("transformed");
|
||||
expect(descriptor?.writable).toBe(false);
|
||||
expect(descriptor?.enumerable).toBe(false);
|
||||
expect(descriptor?.configurable).toBe(true);
|
||||
});
|
||||
|
||||
test("should handle inheritance transformers when property descriptors change", () => {
|
||||
const workflow = createWorkflow();
|
||||
const context = workflow.createContext();
|
||||
|
||||
// Test that the transformer is called with the latest descriptor
|
||||
extendContext(context, {
|
||||
changingProperty: "initial",
|
||||
});
|
||||
|
||||
// Change the property descriptor
|
||||
Object.defineProperty(context, "changingProperty", {
|
||||
get() {
|
||||
return "getter-value";
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true,
|
||||
});
|
||||
|
||||
const transformer: InheritanceTransformer = (_, originalDescriptor) => {
|
||||
if (originalDescriptor.get) {
|
||||
return {
|
||||
...originalDescriptor,
|
||||
get() {
|
||||
return `transformed-${originalDescriptor.get?.call(this)}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...originalDescriptor,
|
||||
value: `transformed-${originalDescriptor.value}`,
|
||||
};
|
||||
};
|
||||
|
||||
extendContext(
|
||||
context,
|
||||
{},
|
||||
{
|
||||
changingProperty: transformer,
|
||||
},
|
||||
);
|
||||
|
||||
expect((context as any).changingProperty).toBe("transformed-getter-value");
|
||||
});
|
||||
|
||||
test("should handle transformer that returns identical descriptor", () => {
|
||||
const workflow = createWorkflow();
|
||||
const context = workflow.createContext();
|
||||
|
||||
extendContext(context, {
|
||||
testProperty: "original-value",
|
||||
});
|
||||
|
||||
const transformer: InheritanceTransformer = (_, originalDescriptor) => {
|
||||
// Return the same descriptor without modifications
|
||||
return originalDescriptor;
|
||||
};
|
||||
|
||||
extendContext(
|
||||
context,
|
||||
{},
|
||||
{
|
||||
testProperty: transformer,
|
||||
},
|
||||
);
|
||||
|
||||
expect((context as any).testProperty).toBe("original-value");
|
||||
});
|
||||
|
||||
test("should handle concurrent property inheritance handlers map", () => {
|
||||
const workflow = createWorkflow();
|
||||
const context = workflow.createContext();
|
||||
|
||||
extendContext(context, {
|
||||
prop1: "value1",
|
||||
prop2: "value2",
|
||||
});
|
||||
|
||||
const transformer1: InheritanceTransformer = (_, originalDescriptor) => ({
|
||||
...originalDescriptor,
|
||||
value: `t1-${originalDescriptor.value}`,
|
||||
});
|
||||
|
||||
const transformer2: InheritanceTransformer = (_, originalDescriptor) => ({
|
||||
...originalDescriptor,
|
||||
value: `t2-${originalDescriptor.value}`,
|
||||
});
|
||||
|
||||
// Add transformers in separate calls
|
||||
extendContext(context, {}, { prop1: transformer1 });
|
||||
extendContext(context, {}, { prop2: transformer2 });
|
||||
|
||||
expect((context as any).prop1).toBe("t1-value1");
|
||||
expect((context as any).prop2).toBe("t2-value2");
|
||||
|
||||
// Verify internal map has both transformers
|
||||
const internalMap = (context as any)
|
||||
.__internal__property_inheritance_handlers;
|
||||
expect(internalMap).toBeInstanceOf(Map);
|
||||
expect(internalMap.size).toBe(2);
|
||||
expect(internalMap.has("prop1")).toBe(true);
|
||||
expect(internalMap.has("prop2")).toBe(true);
|
||||
});
|
||||
|
||||
test("should allow simple properties to inherit via prototype chain in handlers", async () => {
|
||||
const workflow = createWorkflow();
|
||||
let handlerContext: WorkflowContext | null = null;
|
||||
|
||||
workflow.handle([startEvent], () => {
|
||||
handlerContext = getContext();
|
||||
return stopEvent.with();
|
||||
});
|
||||
|
||||
const context = workflow.createContext();
|
||||
const testValue = "inherited-value";
|
||||
|
||||
extendContext(context, {
|
||||
testProperty: testValue,
|
||||
});
|
||||
|
||||
context.sendEvent(startEvent.with());
|
||||
|
||||
// Wait for handler to execute
|
||||
await new Promise<void>((resolve) => {
|
||||
const checkHandler = () => {
|
||||
if (handlerContext) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkHandler, 10);
|
||||
}
|
||||
};
|
||||
checkHandler();
|
||||
});
|
||||
|
||||
expect(handlerContext).toBeTruthy();
|
||||
expect((handlerContext as any).testProperty).toBe(testValue);
|
||||
});
|
||||
|
||||
test("should apply inheritance transformers to handler contexts", async () => {
|
||||
const workflow = createWorkflow();
|
||||
let handlerContext: WorkflowContext | null = null;
|
||||
let transformedValue: string | null = null;
|
||||
|
||||
workflow.handle([startEvent], () => {
|
||||
handlerContext = getContext();
|
||||
transformedValue = (handlerContext as any).testProperty;
|
||||
return stopEvent.with();
|
||||
});
|
||||
|
||||
const context = workflow.createContext();
|
||||
|
||||
extendContext(context, {
|
||||
testProperty: "original",
|
||||
});
|
||||
|
||||
const transformer: InheritanceTransformer = (
|
||||
handlerContext,
|
||||
originalDescriptor,
|
||||
) => ({
|
||||
...originalDescriptor,
|
||||
value: `transformed-${originalDescriptor.value}`,
|
||||
});
|
||||
|
||||
extendContext(
|
||||
context,
|
||||
{},
|
||||
{
|
||||
testProperty: transformer,
|
||||
},
|
||||
);
|
||||
|
||||
context.sendEvent(startEvent.with());
|
||||
|
||||
// Wait for handler to execute
|
||||
await new Promise<void>((resolve) => {
|
||||
const checkHandler = () => {
|
||||
if (handlerContext && transformedValue !== null) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(checkHandler, 10);
|
||||
}
|
||||
};
|
||||
checkHandler();
|
||||
});
|
||||
|
||||
expect(handlerContext).toBeTruthy();
|
||||
expect(transformedValue).toBe("transformed-original");
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
type WorkflowEvent,
|
||||
type WorkflowEventData,
|
||||
} from "@llamaindex/workflow-core";
|
||||
import { createStateMiddleware } from "@llamaindex/workflow-core/middleware/store";
|
||||
import { createStatefulMiddleware } from "@llamaindex/workflow-core/middleware/state";
|
||||
import { withTraceEvents } from "@llamaindex/workflow-core/middleware/trace-events";
|
||||
import { withValidation } from "@llamaindex/workflow-core/middleware/validation";
|
||||
import { zodEvent } from "@llamaindex/workflow-core/util/zod";
|
||||
@@ -24,7 +24,7 @@ describe("full workflow middleware", () => {
|
||||
validation: Validation,
|
||||
createStore: (input: Input) => T,
|
||||
) => {
|
||||
const { withState, getContext } = createStateMiddleware(createStore);
|
||||
const { withState, getContext } = createStatefulMiddleware(createStore);
|
||||
return [
|
||||
withState(withValidation(withTraceEvents(createWorkflow()), validation)),
|
||||
getContext,
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createWorkflow, workflowEvent } from "@llamaindex/workflow-core";
|
||||
import {
|
||||
createStatefulMiddleware,
|
||||
request,
|
||||
} from "@llamaindex/workflow-core/middleware/state";
|
||||
|
||||
const startEvent = workflowEvent({});
|
||||
|
||||
const requestEvent = workflowEvent<string>({});
|
||||
|
||||
const stopEvent = workflowEvent({});
|
||||
|
||||
type TestState = {
|
||||
counter: number;
|
||||
message: string;
|
||||
};
|
||||
|
||||
describe("state with snapshot middleware", () => {
|
||||
test("should preserve state when resuming from snapshot", async () => {
|
||||
const { withState, getContext } = createStatefulMiddleware(
|
||||
(input: TestState) => input,
|
||||
);
|
||||
const workflow = withState(createWorkflow());
|
||||
|
||||
let handlerState: TestState | null = null;
|
||||
let snapshotData: any = null;
|
||||
|
||||
// Handler that modifies state and creates a snapshot
|
||||
workflow.handle([startEvent], async () => {
|
||||
const context = getContext();
|
||||
|
||||
// Modify the state
|
||||
context.state.counter = 42;
|
||||
context.state.message = "original state";
|
||||
|
||||
// Send a request and snapshot
|
||||
context.sendEvent(request(requestEvent));
|
||||
const [_, sd] = await context.snapshot();
|
||||
snapshotData = sd;
|
||||
|
||||
return stopEvent.with();
|
||||
});
|
||||
|
||||
// Handler for human response
|
||||
workflow.handle([requestEvent], ({ data }) => {
|
||||
const { state } = getContext();
|
||||
handlerState = state;
|
||||
|
||||
return stopEvent.with();
|
||||
});
|
||||
|
||||
// Create initial context with state
|
||||
const context = workflow.createContext({
|
||||
counter: 0,
|
||||
message: "initial",
|
||||
});
|
||||
|
||||
expect(context.state.counter).toBe(0);
|
||||
expect(context.state.message).toBe("initial");
|
||||
|
||||
// run the workflow
|
||||
context.sendEvent(startEvent.with());
|
||||
await context.stream.until(stopEvent).toArray();
|
||||
|
||||
expect(snapshotData).toBeTruthy();
|
||||
expect(context.state.counter).toBe(42);
|
||||
expect(context.state.message).toBe("original state");
|
||||
|
||||
const resumedContext = workflow.resume(["hello"], snapshotData);
|
||||
|
||||
const events = await resumedContext.stream.until(stopEvent).toArray();
|
||||
expect(events.length).toBe(2);
|
||||
expect(handlerState).toBeDefined();
|
||||
expect(handlerState!.counter).toBe(42);
|
||||
expect(handlerState!.message).toBe("original state");
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,11 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
import {
|
||||
withSnapshot,
|
||||
createStatefulMiddleware,
|
||||
request,
|
||||
} from "@llamaindex/workflow-core/middleware/snapshot";
|
||||
} from "@llamaindex/workflow-core/middleware/state";
|
||||
import {
|
||||
createWorkflow,
|
||||
eventSource,
|
||||
getContext,
|
||||
workflowEvent,
|
||||
} from "@llamaindex/workflow-core";
|
||||
|
||||
@@ -27,7 +26,8 @@ const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
describe("with snapshot - snapshot API", () => {
|
||||
test("single handler (sync)", async () => {
|
||||
const workflow = withSnapshot(createWorkflow());
|
||||
const { withState } = createStatefulMiddleware();
|
||||
const workflow = withState(createWorkflow());
|
||||
workflow.handle([startEvent], () => {
|
||||
return request(humanResponseEvent);
|
||||
});
|
||||
@@ -48,6 +48,7 @@ describe("with snapshot - snapshot API", () => {
|
||||
1,
|
||||
],
|
||||
"queue": [],
|
||||
"state": undefined,
|
||||
"unrecoverableQueue": [],
|
||||
"version": "@@@0,,4~,,@@1,,7~,,",
|
||||
}
|
||||
@@ -61,7 +62,8 @@ describe("with snapshot - snapshot API", () => {
|
||||
});
|
||||
|
||||
test("single handler (async)", async () => {
|
||||
const workflow = withSnapshot(createWorkflow());
|
||||
const { withState } = createStatefulMiddleware();
|
||||
const workflow = withState(createWorkflow());
|
||||
workflow.handle([startEvent], async () => {
|
||||
return request(humanResponseEvent);
|
||||
});
|
||||
@@ -82,6 +84,7 @@ describe("with snapshot - snapshot API", () => {
|
||||
1,
|
||||
],
|
||||
"queue": [],
|
||||
"state": undefined,
|
||||
"unrecoverableQueue": [],
|
||||
"version": "@@@0,,4~,,@@1,,7~,,",
|
||||
}
|
||||
@@ -95,7 +98,8 @@ describe("with snapshot - snapshot API", () => {
|
||||
});
|
||||
|
||||
test("single handler (timer)", async () => {
|
||||
const workflow = withSnapshot(createWorkflow());
|
||||
const { withState } = createStatefulMiddleware();
|
||||
const workflow = withState(createWorkflow());
|
||||
workflow.handle([startEvent], async () => {
|
||||
await sleep(10);
|
||||
return request(humanResponseEvent);
|
||||
@@ -117,6 +121,7 @@ describe("with snapshot - snapshot API", () => {
|
||||
1,
|
||||
],
|
||||
"queue": [],
|
||||
"state": undefined,
|
||||
"unrecoverableQueue": [],
|
||||
"version": "@@@0,,4~,,@@1,,7~,,",
|
||||
}
|
||||
@@ -130,7 +135,8 @@ describe("with snapshot - snapshot API", () => {
|
||||
});
|
||||
|
||||
test("multiple message in the queue", async () => {
|
||||
const workflow = withSnapshot(createWorkflow());
|
||||
const { withState, getContext } = createStatefulMiddleware();
|
||||
const workflow = withState(createWorkflow());
|
||||
workflow.handle([startEvent], async () => {
|
||||
const { sendEvent } = getContext();
|
||||
await sleep(10);
|
||||
@@ -156,6 +162,7 @@ describe("with snapshot - snapshot API", () => {
|
||||
1,
|
||||
],
|
||||
"queue": [],
|
||||
"state": undefined,
|
||||
"unrecoverableQueue": [
|
||||
[
|
||||
"1",
|
||||
@@ -178,7 +185,8 @@ describe("with snapshot - snapshot API", () => {
|
||||
});
|
||||
|
||||
test("multiple requests in the response", async () => {
|
||||
const workflow = withSnapshot(createWorkflow());
|
||||
const { withState } = createStatefulMiddleware();
|
||||
const workflow = withState(createWorkflow());
|
||||
workflow.handle([startEvent], async () => {
|
||||
return request(humanResponseEvent);
|
||||
});
|
||||
@@ -204,6 +212,7 @@ describe("with snapshot - snapshot API", () => {
|
||||
1,
|
||||
],
|
||||
"queue": [],
|
||||
"state": undefined,
|
||||
"unrecoverableQueue": [],
|
||||
"version": "@@@0,,4~,,@@0,,7~,,@@1,,10~,,",
|
||||
}
|
||||
@@ -220,7 +229,8 @@ describe("with snapshot - snapshot API", () => {
|
||||
vi.stubGlobal("console", {
|
||||
warn,
|
||||
});
|
||||
const workflow = withSnapshot(createWorkflow());
|
||||
const { withState, getContext } = createStatefulMiddleware();
|
||||
const workflow = withState(createWorkflow());
|
||||
workflow.handle([startEvent], async () => {
|
||||
const { sendEvent } = getContext();
|
||||
setTimeout(() => {
|
||||
@@ -236,6 +246,7 @@ describe("with snapshot - snapshot API", () => {
|
||||
{
|
||||
"missing": [],
|
||||
"queue": [],
|
||||
"state": undefined,
|
||||
"unrecoverableQueue": [],
|
||||
"version": "@@@0,,4~,,",
|
||||
}
|
||||
@@ -250,7 +261,8 @@ describe("with snapshot - snapshot API", () => {
|
||||
});
|
||||
|
||||
test("onRequestEvent callback", async () => {
|
||||
const workflow = withSnapshot(createWorkflow());
|
||||
const { withState } = createStatefulMiddleware();
|
||||
const workflow = withState(createWorkflow());
|
||||
workflow.handle([startEvent], async () => {
|
||||
return request(humanResponseEvent, 1);
|
||||
});
|
||||
|
||||
Generated
+62
-12
@@ -28,13 +28,16 @@ importers:
|
||||
version: 3.6.1
|
||||
tsdown:
|
||||
specifier: ^0.12.9
|
||||
version: 0.12.9(typescript@5.8.3)
|
||||
version: 0.12.9(typescript@5.9.2)
|
||||
turbo:
|
||||
specifier: ^2.5.4
|
||||
version: 2.5.4
|
||||
typescript:
|
||||
specifier: ^5.9.2
|
||||
version: 5.9.2
|
||||
vite-tsconfig-paths:
|
||||
specifier: ^5.1.4
|
||||
version: 5.1.4(typescript@5.8.3)(vite@7.0.0(@types/node@24.0.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
|
||||
version: 5.1.4(typescript@5.9.2)(vite@7.0.0(@types/node@24.0.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(@edge-runtime/vm@5.0.0)(@types/node@24.0.4)(happy-dom@18.0.1)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
|
||||
@@ -235,7 +238,7 @@ importers:
|
||||
version: link:../core
|
||||
bunchee:
|
||||
specifier: ^6.5.4
|
||||
version: 6.5.4(typescript@5.8.3)
|
||||
version: 6.5.4(typescript@5.9.2)
|
||||
|
||||
tests/cjs:
|
||||
dependencies:
|
||||
@@ -3910,6 +3913,11 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
typescript@5.9.2:
|
||||
resolution: {integrity: sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
ufo@1.6.1:
|
||||
resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==}
|
||||
|
||||
@@ -5874,7 +5882,7 @@ snapshots:
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
bunchee@6.5.4(typescript@5.8.3):
|
||||
bunchee@6.5.4(typescript@5.9.2):
|
||||
dependencies:
|
||||
'@rollup/plugin-commonjs': 28.0.6(rollup@4.44.0)
|
||||
'@rollup/plugin-json': 6.1.0(rollup@4.44.0)
|
||||
@@ -5891,13 +5899,13 @@ snapshots:
|
||||
picomatch: 4.0.2
|
||||
pretty-bytes: 5.6.0
|
||||
rollup: 4.44.0
|
||||
rollup-plugin-dts: 6.2.1(rollup@4.44.0)(typescript@5.8.3)
|
||||
rollup-plugin-dts: 6.2.1(rollup@4.44.0)(typescript@5.9.2)
|
||||
rollup-plugin-swc3: 0.11.2(@swc/core@1.12.6(@swc/helpers@0.5.17))(rollup@4.44.0)
|
||||
rollup-preserve-directives: 1.1.3(rollup@4.44.0)
|
||||
tslib: 2.8.1
|
||||
yargs: 17.7.2
|
||||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
typescript: 5.9.2
|
||||
|
||||
bundle-name@4.1.0:
|
||||
dependencies:
|
||||
@@ -7138,6 +7146,23 @@ snapshots:
|
||||
- oxc-resolver
|
||||
- supports-color
|
||||
|
||||
rolldown-plugin-dts@0.13.12(rolldown@1.0.0-beta.20)(typescript@5.9.2):
|
||||
dependencies:
|
||||
'@babel/generator': 7.27.5
|
||||
'@babel/parser': 7.27.5
|
||||
'@babel/types': 7.27.6
|
||||
ast-kit: 2.1.0
|
||||
birpc: 2.4.0
|
||||
debug: 4.4.1
|
||||
dts-resolver: 2.1.1
|
||||
get-tsconfig: 4.10.1
|
||||
rolldown: 1.0.0-beta.20
|
||||
optionalDependencies:
|
||||
typescript: 5.9.2
|
||||
transitivePeerDependencies:
|
||||
- oxc-resolver
|
||||
- supports-color
|
||||
|
||||
rolldown@1.0.0-beta.20:
|
||||
dependencies:
|
||||
'@oxc-project/runtime': 0.75.0
|
||||
@@ -7158,11 +7183,11 @@ snapshots:
|
||||
'@rolldown/binding-win32-ia32-msvc': 1.0.0-beta.20
|
||||
'@rolldown/binding-win32-x64-msvc': 1.0.0-beta.20
|
||||
|
||||
rollup-plugin-dts@6.2.1(rollup@4.44.0)(typescript@5.8.3):
|
||||
rollup-plugin-dts@6.2.1(rollup@4.44.0)(typescript@5.9.2):
|
||||
dependencies:
|
||||
magic-string: 0.30.17
|
||||
rollup: 4.44.0
|
||||
typescript: 5.8.3
|
||||
typescript: 5.9.2
|
||||
optionalDependencies:
|
||||
'@babel/code-frame': 7.27.1
|
||||
|
||||
@@ -7571,9 +7596,9 @@ snapshots:
|
||||
|
||||
toidentifier@1.0.1: {}
|
||||
|
||||
tsconfck@3.1.5(typescript@5.8.3):
|
||||
tsconfck@3.1.5(typescript@5.9.2):
|
||||
optionalDependencies:
|
||||
typescript: 5.8.3
|
||||
typescript: 5.9.2
|
||||
|
||||
tsdown@0.12.9(typescript@5.8.3):
|
||||
dependencies:
|
||||
@@ -7598,6 +7623,29 @@ snapshots:
|
||||
- supports-color
|
||||
- vue-tsc
|
||||
|
||||
tsdown@0.12.9(typescript@5.9.2):
|
||||
dependencies:
|
||||
ansis: 4.1.0
|
||||
cac: 6.7.14
|
||||
chokidar: 4.0.3
|
||||
debug: 4.4.1
|
||||
diff: 8.0.2
|
||||
empathic: 2.0.0
|
||||
hookable: 5.5.3
|
||||
rolldown: 1.0.0-beta.20
|
||||
rolldown-plugin-dts: 0.13.12(rolldown@1.0.0-beta.20)(typescript@5.9.2)
|
||||
semver: 7.7.2
|
||||
tinyexec: 1.0.1
|
||||
tinyglobby: 0.2.14
|
||||
unconfig: 7.3.2
|
||||
optionalDependencies:
|
||||
typescript: 5.9.2
|
||||
transitivePeerDependencies:
|
||||
- '@typescript/native-preview'
|
||||
- oxc-resolver
|
||||
- supports-color
|
||||
- vue-tsc
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
tsx@4.20.3:
|
||||
@@ -7644,6 +7692,8 @@ snapshots:
|
||||
|
||||
typescript@5.8.3: {}
|
||||
|
||||
typescript@5.9.2: {}
|
||||
|
||||
ufo@1.6.1: {}
|
||||
|
||||
uglify-js@3.19.3:
|
||||
@@ -7709,11 +7759,11 @@ snapshots:
|
||||
- tsx
|
||||
- yaml
|
||||
|
||||
vite-tsconfig-paths@5.1.4(typescript@5.8.3)(vite@7.0.0(@types/node@24.0.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)):
|
||||
vite-tsconfig-paths@5.1.4(typescript@5.9.2)(vite@7.0.0(@types/node@24.0.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)):
|
||||
dependencies:
|
||||
debug: 4.4.0
|
||||
globrex: 0.1.2
|
||||
tsconfck: 3.1.5(typescript@5.8.3)
|
||||
tsconfck: 3.1.5(typescript@5.9.2)
|
||||
optionalDependencies:
|
||||
vite: 7.0.0(@types/node@24.0.4)(jiti@2.4.2)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
|
||||
transitivePeerDependencies:
|
||||
|
||||
Reference in New Issue
Block a user