feat: add withSnapshot API (#97)

Co-authored-by: Marcus Schiesser <mail@marcusschiesser.de>
This commit is contained in:
Alex Yang
2025-05-20 01:13:46 -07:00
committed by GitHub
parent 23ecfc79f1
commit 9c657855fd
14 changed files with 1217 additions and 56 deletions
+12
View File
@@ -0,0 +1,12 @@
---
"demo": patch
"@llama-flow/core": patch
---
feat: add `withSnapshot` middleware API
Add snapshot API, for human in the loop feature. The API is designed for cross JavaScript platform, including node.js, browser, and serverless platform such as cloudflare worker and edge runtime
- `workflow.createContext(): Context`
- `context.snapshot(): Promise<[requestEvent, snapshot]>`
- `workflow.resume(data, snapshot)`
+48
View File
@@ -18,6 +18,54 @@ app.post(
),
);
const serializableMemoryMap = new Map<string, any>();
app.post("/human-in-the-loop", async (ctx) => {
const { workflow, stopEvent, startEvent, humanInteractionEvent } =
await import("../workflows/human-in-the-loop");
const json = await ctx.req.json();
let context: ReturnType<typeof workflow.createContext>;
if (json.requestId) {
const data = json.data;
const serializable = serializableMemoryMap.get(json.requestId);
context = workflow.resume(data, serializable);
} else {
context = workflow.createContext();
context.sendEvent(startEvent.with(json.data));
}
const { onRequest, stream } = context;
return new Promise<Response>((resolve) => {
// listen to human interaction
onRequest(humanInteractionEvent, async (reason) => {
context.snapshot().then(([re, sd]) => {
const requestId = crypto.randomUUID();
serializableMemoryMap.set(requestId, sd);
resolve(
Response.json({
requestId: requestId,
reason: reason,
data: re.map((r) =>
r === humanInteractionEvent
? "request human in the loop"
: "UNKNOWN",
),
}),
);
});
});
// consume stream
stream
.until(stopEvent)
.toArray()
.then((events) => {
const stopEvent = events.at(-1)!;
resolve(Response.json(stopEvent.data));
});
});
});
serve(app, ({ port }) => {
console.log(`Server started at http://localhost:${port}`);
});
+29
View File
@@ -0,0 +1,29 @@
import { input } from "@inquirer/prompts";
import {
workflow,
stopEvent,
startEvent,
humanInteractionEvent,
} from "../workflows/human-in-the-loop";
const name = await input({
message: "What is your name?",
});
const { onRequest, stream, sendEvent } = workflow.createContext();
sendEvent(startEvent.with(name));
onRequest(humanInteractionEvent, async (reason) => {
console.log("Requesting human interaction...");
const name = await input({
message: JSON.parse(reason).message,
});
console.log("Human interaction completed.");
sendEvent(humanInteractionEvent.with(name));
});
stream.on(stopEvent, ({ data }) => {
console.log("AI analysis: ", data);
});
await stream.until(stopEvent).toArray();
+1
View File
@@ -4,6 +4,7 @@
"private": true,
"dependencies": {
"@hono/node-server": "^1.14.1",
"@inquirer/prompts": "^7.5.0",
"@llama-flow/core": "latest",
"@modelcontextprotocol/sdk": "^1.10.1",
"hono": "^4.7.7",
+73
View File
@@ -0,0 +1,73 @@
import { withSnapshot, request } from "@llama-flow/core/middleware/snapshot";
import { createWorkflow, workflowEvent, getContext } from "@llama-flow/core";
import { OpenAI } from "openai";
const openai = new OpenAI();
const workflow = withSnapshot(createWorkflow());
const startEvent = workflowEvent<string>({
debugLabel: "start",
});
const humanInteractionEvent = workflowEvent<string>({
debugLabel: "humanInteraction",
});
const stopEvent = workflowEvent<string>({
debugLabel: "stop",
});
workflow.handle([startEvent], async ({ data }) => {
const response = await openai.chat.completions.create({
stream: false,
model: "gpt-4.1",
messages: [
{
role: "system",
content: `You are a helpful assistant.
If user doesn't provide his/her name, call ask_name tool to ask for user's name.
Otherwise, analyze user's name with a good meaning and return the analysis.
For example, alex is from "Alexander the Great", who was a king of the ancient Greek kingdom of Macedon and one of history's greatest military minds.`,
},
{
role: "user",
content: data,
},
],
tools: [
{
type: "function",
function: {
name: "ask_name",
description: "Ask for user's name",
parameters: {
type: "object",
properties: {
message: {
type: "string",
description: "The message to ask for user's name",
},
},
required: ["message"],
},
},
},
],
});
const tools = response.choices[0].message.tool_calls;
if (tools && tools.length > 0) {
const askName = tools.find((tool) => tool.function.name === "ask_name");
if (askName) {
return request(humanInteractionEvent, askName.function.arguments);
}
}
return stopEvent.with(response.choices[0].message.content!);
});
workflow.handle([humanInteractionEvent], async ({ data }) => {
const { sendEvent } = getContext();
// going back to the start event
sendEvent(startEvent.with(data));
});
export { workflow, startEvent, humanInteractionEvent, stopEvent };
+4
View File
@@ -68,6 +68,10 @@
"types": "./middleware/validation.d.ts",
"default": "./middleware/validation.js"
},
"./middleware/snapshot": {
"types": "./middleware/snapshot.d.ts",
"default": "./middleware/snapshot.js"
},
"./util/p-retry": {
"types": "./util/p-retry.d.ts",
"default": "./util/p-retry.js"
+46 -40
View File
@@ -156,11 +156,11 @@ export const createContext = ({
// return value is a special event
if (isPromiseLike(result)) {
(handlerContext as any).async = true;
(handlerContext as any).pending = result;
result.then((event) => {
(handlerContext as any).pending = result.then((event) => {
if (isEventData(event)) {
workflowContext.sendEvent(event);
}
return event;
});
} else if (isEventData(result)) {
workflowContext.sendEvent(result);
@@ -196,44 +196,50 @@ export const createContext = ({
};
const createWorkflowContext = (
handlerContext: HandlerContext,
): WorkflowContext => ({
get stream() {
const subscribable = createSubscribable<
[event: WorkflowEventData<any>],
void
>();
rootWorkflowContext.__internal__call_send_event.subscribe(
(newEvent: WorkflowEventData<any>) => {
let currentEventContext = eventContextWeakMap.get(newEvent);
while (currentEventContext) {
if (currentEventContext === handlerContext) {
subscribable.publish(newEvent);
break;
}
currentEventContext = currentEventContext.prev;
}
},
);
return new WorkflowStream<WorkflowEventData<any>>(subscribable, null);
},
get signal() {
return handlerContext.abortController.signal;
},
sendEvent: (...events) => {
events.forEach((event) => {
eventContextWeakMap.set(event, handlerContext);
handlerContext.outputs.push(event);
queue.push(event);
rootWorkflowContext.__internal__call_send_event.publish(
event,
handlerContext,
);
queueUpdateCallback(handlerContext);
});
},
__internal__call_context: createSubscribable(),
__internal__call_send_event: createSubscribable(),
});
): WorkflowContext => {
let lazyLoadStream: WorkflowStream | null = null;
return {
get stream() {
if (!lazyLoadStream) {
const subscribable = createSubscribable<
[event: WorkflowEventData<any>],
void
>();
rootWorkflowContext.__internal__call_send_event.subscribe(
(newEvent: WorkflowEventData<any>) => {
let currentEventContext = eventContextWeakMap.get(newEvent);
while (currentEventContext) {
if (currentEventContext === handlerContext) {
subscribable.publish(newEvent);
break;
}
currentEventContext = currentEventContext.prev;
}
},
);
lazyLoadStream = new WorkflowStream(subscribable, null);
}
return lazyLoadStream;
},
get signal() {
return handlerContext.abortController.signal;
},
sendEvent: (...events) => {
events.forEach((event) => {
eventContextWeakMap.set(event, handlerContext);
handlerContext.outputs.push(event);
queue.push(event);
rootWorkflowContext.__internal__call_send_event.publish(
event,
handlerContext,
);
queueUpdateCallback(handlerContext);
});
},
__internal__call_context: createSubscribable(),
__internal__call_send_event: createSubscribable(),
};
};
let rootAbortController = new AbortController();
const handlerRootContext: HandlerContext = {
+3 -3
View File
@@ -76,9 +76,9 @@ export class WorkflowStream<R = any>
#stream: ReadableStream<R>;
#subscribable: Subscribable<[data: R], void>;
on(
event: WorkflowEvent<any>,
handler: (event: WorkflowEventData<any>) => void,
on<T>(
event: WorkflowEvent<T>,
handler: (event: WorkflowEventData<T>) => void,
): () => void {
return this.#subscribable.subscribe((ev) => {
if (event.include(ev)) {
+16 -10
View File
@@ -29,7 +29,7 @@ export function flattenEvents(
export type Subscribable<Args extends any[], R> = {
subscribe: (callback: (...args: Args) => R) => () => void;
publish: (...args: Args) => void;
publish: (...args: Args) => unknown[];
};
const __internal__subscribesSourcemap = new WeakMap<
@@ -49,24 +49,30 @@ export function getSubscribers<Args extends any[], R>(
/**
* @internal
*/
export function createSubscribable<Args extends any[], R>(): Subscribable<
Args,
R
> {
const subscribers = new Set<(...args: Args) => R>();
export function createSubscribable<
FnOrArgs extends ((...args: any[]) => any) | any[],
R = unknown,
>(): FnOrArgs extends (...args: any[]) => any
? Subscribable<Parameters<FnOrArgs>, ReturnType<FnOrArgs>>
: FnOrArgs extends any[]
? Subscribable<FnOrArgs, R>
: never {
const subscribers = new Set<(...args: any) => any>();
const obj = {
subscribe: (callback: (...args: Args) => R) => {
subscribe: (callback: (...args: any) => any) => {
subscribers.add(callback);
return () => {
subscribers.delete(callback);
};
},
publish: (...args: Args) => {
publish: (...args: any) => {
const results: unknown[] = [];
for (const callback of subscribers) {
callback(...args);
results.push(callback(...args));
}
return results;
},
};
__internal__subscribesSourcemap.set(obj, subscribers);
return obj;
return obj as any;
}
+361
View File
@@ -0,0 +1,361 @@
import {
eventSource,
type Workflow as WorkflowCore,
type WorkflowContext,
type WorkflowEvent,
workflowEvent,
type WorkflowEventData,
WorkflowStream,
} from "@llama-flow/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>;
}
@@ -0,0 +1,60 @@
// Ref: https://github.com/shuding/stable-hash/blob/main/src/index.ts
export function createStableHash() {
// Use WeakMap to store the object-key mapping so the objects can still be
// garbage collected. WeakMap uses a hashtable under the hood, so the lookup
// complexity is almost O(1).
const table = new WeakMap<object, string>();
// A counter of the key.
let counter = 0;
// A stable hash implementation that supports:
// - Fast and ensures unique hash properties
// - Handles unserializable values
// - Handles object key ordering
// - Generates short results
//
// This is not a serialization function, and the result is not guaranteed to be
// parsable.
return function stableHash(arg: any): string {
const type = typeof arg;
const constructor = arg && arg.constructor;
const isDate = constructor == Date;
if (Object(arg) === arg && !isDate && constructor != RegExp) {
// Object/function, not null/date/regexp. Use WeakMap to store the id first.
// If it's already hashed, directly return the result.
let result = table.get(arg);
if (result) return result;
// Store the hash first for circular reference detection before entering the
// recursive `stableHash` calls.
// For other objects like set and map, we use this id directly as the hash.
result = ++counter + "~";
table.set(arg, result);
let index: any;
if (constructor == Array) {
// Array.
result = "@";
for (index = 0; index < arg.length; index++) {
result += stableHash(arg[index]) + ",";
}
table.set(arg, result);
} else if (constructor == Object) {
// Object, sort keys.
result = "#";
const keys = Object.keys(arg).sort();
while ((index = keys.pop() as string) !== undefined) {
if (arg[index] !== undefined) {
result += index + ":" + stableHash(arg[index]) + ",";
}
}
table.set(arg, result);
}
return result;
}
if (isDate) return arg.toJSON();
if (type == "symbol") return arg.toString();
return type == "string" ? JSON.stringify(arg) : "" + arg;
};
}
@@ -0,0 +1,279 @@
import { describe, expect, test, vi } from "vitest";
import { withSnapshot, request } from "@llama-flow/core/middleware/snapshot";
import {
createWorkflow,
eventSource,
getContext,
workflowEvent,
} from "@llama-flow/core";
const startEvent = workflowEvent({
debugLabel: "start",
});
const messageEvent = workflowEvent<string>({
debugLabel: "message",
});
const humanResponseEvent = workflowEvent<string>({
debugLabel: "human-response",
});
const stopEvent = workflowEvent({
debugLabel: "stop",
});
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe("with snapshot - snapshot API", () => {
test("single handler (sync)", async () => {
const workflow = withSnapshot(createWorkflow());
workflow.handle([startEvent], () => {
return request(humanResponseEvent);
});
workflow.handle([humanResponseEvent], ({ data }) => {
expect(data).toBe("hello world");
return stopEvent.with();
});
const { sendEvent, snapshot } = workflow.createContext();
sendEvent(startEvent.with());
const [req, sd] = await snapshot();
expect(req.length).toBe(1);
expect(req[0]!).toBe(humanResponseEvent);
expect(sd).toMatchInlineSnapshot(`
{
"missing": [
1,
],
"queue": [],
"unrecoverableQueue": [],
"version": "@@@0,,4~,,@@1,,7~,,",
}
`);
// recover
const { stream } = workflow.resume(["hello world"], sd);
const events = await stream.until(stopEvent).toArray();
expect(events.length).toBe(2);
expect(events.map(eventSource)).toEqual([humanResponseEvent, stopEvent]);
});
test("single handler (async)", async () => {
const workflow = withSnapshot(createWorkflow());
workflow.handle([startEvent], async () => {
return request(humanResponseEvent);
});
workflow.handle([humanResponseEvent], ({ data }) => {
expect(data).toBe("hello world");
return stopEvent.with();
});
const { sendEvent, snapshot } = workflow.createContext();
sendEvent(startEvent.with());
const [req, sd] = await snapshot();
expect(req.length).toBe(1);
expect(req[0]!).toBe(humanResponseEvent);
expect(sd).toMatchInlineSnapshot(`
{
"missing": [
1,
],
"queue": [],
"unrecoverableQueue": [],
"version": "@@@0,,4~,,@@1,,7~,,",
}
`);
// recover
const { stream } = workflow.resume(["hello world"], sd);
const events = await stream.until(stopEvent).toArray();
expect(events.length).toBe(2);
expect(events.map(eventSource)).toEqual([humanResponseEvent, stopEvent]);
});
test("single handler (timer)", async () => {
const workflow = withSnapshot(createWorkflow());
workflow.handle([startEvent], async () => {
await sleep(10);
return request(humanResponseEvent);
});
workflow.handle([humanResponseEvent], ({ data }) => {
expect(data).toBe("hello world");
return stopEvent.with();
});
const { sendEvent, snapshot } = workflow.createContext();
sendEvent(startEvent.with());
const [req, sd] = await snapshot();
expect(req.length).toBe(1);
expect(req[0]!).toBe(humanResponseEvent);
expect(sd).toMatchInlineSnapshot(`
{
"missing": [
1,
],
"queue": [],
"unrecoverableQueue": [],
"version": "@@@0,,4~,,@@1,,7~,,",
}
`);
// recover
const { stream } = workflow.resume(["hello world"], sd);
const events = await stream.until(stopEvent).toArray();
expect(events.length).toBe(2);
expect(events.map(eventSource)).toEqual([humanResponseEvent, stopEvent]);
});
test("multiple message in the queue", async () => {
const workflow = withSnapshot(createWorkflow());
workflow.handle([startEvent], async () => {
const { sendEvent } = getContext();
await sleep(10);
sendEvent(messageEvent.with("1"));
sendEvent(messageEvent.with("2"));
return request(humanResponseEvent);
});
workflow.handle([humanResponseEvent], ({ data }) => {
expect(data).toBe("hello world");
return stopEvent.with();
});
const { sendEvent, snapshot } = workflow.createContext();
sendEvent(startEvent.with());
const [req, sd] = await snapshot();
expect(req.length).toBe(1);
expect(req[0]!).toBe(humanResponseEvent);
// messageEvent is not in the queue, because it's not in any handler
expect(sd).toMatchInlineSnapshot(`
{
"missing": [
1,
],
"queue": [],
"unrecoverableQueue": [
[
"1",
2,
],
[
"2",
2,
],
],
"version": "@@@0,,4~,,@@1,,7~,,",
}
`);
// recover
const { stream } = workflow.resume(["hello world"], sd);
const events = await stream.until(stopEvent).toArray();
expect(events.length).toBe(2);
expect(events.map(eventSource)).toEqual([humanResponseEvent, stopEvent]);
});
test("multiple requests in the response", async () => {
const workflow = withSnapshot(createWorkflow());
workflow.handle([startEvent], async () => {
return request(humanResponseEvent);
});
workflow.handle([startEvent], async () => {
await sleep(10);
return request(humanResponseEvent);
});
workflow.handle([humanResponseEvent], ({ data }) => {
expect(data).toBe("hello world");
return stopEvent.with();
});
const { sendEvent, snapshot } = workflow.createContext();
sendEvent(startEvent.with());
const [req, sd] = await snapshot();
expect(req.length).toBe(2);
expect(req[0]!).toBe(humanResponseEvent);
expect(sd).toMatchInlineSnapshot(`
{
"missing": [
1,
1,
],
"queue": [],
"unrecoverableQueue": [],
"version": "@@@0,,4~,,@@0,,7~,,@@1,,10~,,",
}
`);
// recover
const { stream } = workflow.resume(["hello world"], sd);
const events = await stream.until(stopEvent).toArray();
expect(events.length).toBe(2);
expect(events.map(eventSource)).toEqual([humanResponseEvent, stopEvent]);
});
test("cannot handle setTimeout without ", async () => {
const warn = vi.fn();
vi.stubGlobal("console", {
warn,
});
const workflow = withSnapshot(createWorkflow());
workflow.handle([startEvent], async () => {
const { sendEvent } = getContext();
setTimeout(() => {
sendEvent(request(humanResponseEvent));
}, 100);
});
const { sendEvent, snapshot } = workflow.createContext();
sendEvent(startEvent.with());
const [req, se] = await snapshot();
expect(req.length).toBe(0);
expect(se).toMatchInlineSnapshot(`
{
"missing": [],
"queue": [],
"unrecoverableQueue": [],
"version": "@@@0,,4~,,",
}
`);
await sleep(100);
expect(warn.mock.calls.length).toBe(1);
expect(warn.mock.calls[0]).toMatchInlineSnapshot(`
[
"snapshot is already ready, sendEvent after snapshot is not allowed",
]
`);
});
test("onRequestEvent callback", async () => {
const workflow = withSnapshot(createWorkflow());
workflow.handle([startEvent], async () => {
return request(humanResponseEvent, 1);
});
workflow.handle([humanResponseEvent], ({ data }) => {
expect(data).toBe("hello world");
return stopEvent.with();
});
const { sendEvent, stream, onRequest } = workflow.createContext();
sendEvent(startEvent.with());
const onRequestCallback = vi.fn((reason) => {
expect(reason).toBe(1);
sendEvent(humanResponseEvent.with("hello world"));
});
onRequest(humanResponseEvent, onRequestCallback);
expect(onRequestCallback).toBeCalledTimes(0);
const events = await stream.until(stopEvent).toArray();
expect(onRequestCallback).toBeCalledTimes(1);
expect(events.length).toBe(3);
expect(events.map(eventSource)).toEqual([
startEvent,
humanResponseEvent,
stopEvent,
]);
});
});
+3 -3
View File
@@ -11,9 +11,9 @@ export default defineConfig({
}),
],
test: {
exclude: ["**/dist/**", "**/lib/**"],
name: "DOM",
environment: "happy-dom",
exclude: ["**/lib/**", "**/dist/**", "**/node_modules/**"],
},
},
{
@@ -23,9 +23,9 @@ export default defineConfig({
}),
],
test: {
exclude: ["**/dist/**", "**/lib/**"],
name: "Node.js",
environment: "node",
exclude: ["**/lib/**", "**/dist/**", "**/node_modules/**"],
},
},
{
@@ -35,9 +35,9 @@ export default defineConfig({
}),
],
test: {
exclude: ["**/dist/**", "**/lib/**"],
name: "Edge Runtime",
environment: "edge-runtime",
exclude: ["**/lib/**", "**/dist/**", "**/node_modules/**"],
},
},
],
+282
View File
@@ -44,6 +44,9 @@ importers:
'@hono/node-server':
specifier: ^1.14.1
version: 1.14.1(hono@4.7.7)
'@inquirer/prompts':
specifier: ^7.5.0
version: 7.5.0(@types/node@22.14.1)
'@llama-flow/core':
specifier: latest
version: link:../packages/core
@@ -863,6 +866,127 @@ packages:
cpu: [x64]
os: [win32]
'@inquirer/checkbox@4.1.5':
resolution: {integrity: sha512-swPczVU+at65xa5uPfNP9u3qx/alNwiaykiI/ExpsmMSQW55trmZcwhYWzw/7fj+n6Q8z1eENvR7vFfq9oPSAQ==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/confirm@5.1.9':
resolution: {integrity: sha512-NgQCnHqFTjF7Ys2fsqK2WtnA8X1kHyInyG+nMIuHowVTIgIuS10T4AznI/PvbqSpJqjCUqNBlKGh1v3bwLFL4w==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/core@10.1.10':
resolution: {integrity: sha512-roDaKeY1PYY0aCqhRmXihrHjoSW2A00pV3Ke5fTpMCkzcGF64R8e0lw3dK+eLEHwS4vB5RnW1wuQmvzoRul8Mw==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/editor@4.2.10':
resolution: {integrity: sha512-5GVWJ+qeI6BzR6TIInLP9SXhWCEcvgFQYmcRG6d6RIlhFjM5TyG18paTGBgRYyEouvCmzeco47x9zX9tQEofkw==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/expand@4.0.12':
resolution: {integrity: sha512-jV8QoZE1fC0vPe6TnsOfig+qwu7Iza1pkXoUJ3SroRagrt2hxiL+RbM432YAihNR7m7XnU0HWl/WQ35RIGmXHw==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/figures@1.0.11':
resolution: {integrity: sha512-eOg92lvrn/aRUqbxRyvpEWnrvRuTYRifixHkYVpJiygTgVSBIHDqLh0SrMQXkafvULg3ck11V7xvR+zcgvpHFw==}
engines: {node: '>=18'}
'@inquirer/input@4.1.9':
resolution: {integrity: sha512-mshNG24Ij5KqsQtOZMgj5TwEjIf+F2HOESk6bjMwGWgcH5UBe8UoljwzNFHqdMbGYbgAf6v2wU/X9CAdKJzgOA==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/number@3.0.12':
resolution: {integrity: sha512-7HRFHxbPCA4e4jMxTQglHJwP+v/kpFsCf2szzfBHy98Wlc3L08HL76UDiA87TOdX5fwj2HMOLWqRWv9Pnn+Z5Q==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/password@4.0.12':
resolution: {integrity: sha512-FlOB0zvuELPEbnBYiPaOdJIaDzb2PmJ7ghi/SVwIHDDSQ2K4opGBkF+5kXOg6ucrtSUQdLhVVY5tycH0j0l+0g==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/prompts@7.5.0':
resolution: {integrity: sha512-tk8Bx7l5AX/CR0sVfGj3Xg6v7cYlFBkEahH+EgBB+cZib6Fc83dwerTbzj7f2+qKckjIUGsviWRI1d7lx6nqQA==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/rawlist@4.1.0':
resolution: {integrity: sha512-6ob45Oh9pXmfprKqUiEeMz/tjtVTFQTgDDz1xAMKMrIvyrYjAmRbQZjMJfsictlL4phgjLhdLu27IkHNnNjB7g==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/search@3.0.12':
resolution: {integrity: sha512-H/kDJA3kNlnNIjB8YsaXoQI0Qccgf0Na14K1h8ExWhNmUg2E941dyFPrZeugihEa9AZNW5NdsD/NcvUME83OPQ==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/select@4.2.0':
resolution: {integrity: sha512-KkXQ4aSySWimpV4V/TUJWdB3tdfENZUU765GjOIZ0uPwdbGIG6jrxD4dDf1w68uP+DVtfNhr1A92B+0mbTZ8FA==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@inquirer/type@3.0.6':
resolution: {integrity: sha512-/mKVCtVpyBu3IDarv0G+59KC4stsD5mDsGpYh+GKs1NZT88Jh52+cuoA1AtLk2Q0r/quNl+1cSUyLRHBFeD0XA==}
engines: {node: '>=18'}
peerDependencies:
'@types/node': '>=18'
peerDependenciesMeta:
'@types/node':
optional: true
'@isaacs/cliui@8.0.2':
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
@@ -1563,6 +1687,10 @@ packages:
resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==}
engines: {node: '>=6'}
ansi-escapes@4.3.2:
resolution: {integrity: sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==}
engines: {node: '>=8'}
ansi-escapes@7.0.0:
resolution: {integrity: sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw==}
engines: {node: '>=18'}
@@ -1737,6 +1865,10 @@ packages:
resolution: {integrity: sha512-nPdaFdQ0h/GEigbPClz11D0v/ZJEwxmeVZGeMo3Z5StPtUTkA9o1lD6QwoirYiSDzbcwn2XcjwmCp68W1IS4TA==}
engines: {node: '>=18'}
cli-width@4.1.0:
resolution: {integrity: sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==}
engines: {node: '>= 12'}
client-only@0.0.1:
resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==}
@@ -2587,6 +2719,10 @@ packages:
resolution: {integrity: sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==}
hasBin: true
mute-stream@2.0.0:
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
engines: {node: ^18.17.0 || >=20.5.0}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -3369,6 +3505,10 @@ packages:
resolution: {integrity: sha512-PvSRruOsitjy6qdqwIIyolv99+fEn57gP6gn4zhsHTEcCYgXPhv6BAxzAjleS8XKpo+Y582vTTA9nuqYDmbRuA==}
hasBin: true
type-fest@0.21.3:
resolution: {integrity: sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==}
engines: {node: '>=10'}
type-is@2.0.1:
resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
engines: {node: '>= 0.6'}
@@ -3574,6 +3714,10 @@ packages:
'@cloudflare/workers-types':
optional: true
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -3624,6 +3768,10 @@ packages:
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
engines: {node: '>=12'}
yoctocolors-cjs@2.1.2:
resolution: {integrity: sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA==}
engines: {node: '>=18'}
youch@3.3.4:
resolution: {integrity: sha512-UeVBXie8cA35DS6+nBkls68xaBBXCye0CNznrhszZjTbRVnJKQuNsyLKBTTL4ln1o1rh2PKtv35twV7irj5SEg==}
@@ -4227,6 +4375,122 @@ snapshots:
'@img/sharp-win32-x64@0.34.1':
optional: true
'@inquirer/checkbox@4.1.5(@types/node@22.14.1)':
dependencies:
'@inquirer/core': 10.1.10(@types/node@22.14.1)
'@inquirer/figures': 1.0.11
'@inquirer/type': 3.0.6(@types/node@22.14.1)
ansi-escapes: 4.3.2
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.14.1
'@inquirer/confirm@5.1.9(@types/node@22.14.1)':
dependencies:
'@inquirer/core': 10.1.10(@types/node@22.14.1)
'@inquirer/type': 3.0.6(@types/node@22.14.1)
optionalDependencies:
'@types/node': 22.14.1
'@inquirer/core@10.1.10(@types/node@22.14.1)':
dependencies:
'@inquirer/figures': 1.0.11
'@inquirer/type': 3.0.6(@types/node@22.14.1)
ansi-escapes: 4.3.2
cli-width: 4.1.0
mute-stream: 2.0.0
signal-exit: 4.1.0
wrap-ansi: 6.2.0
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.14.1
'@inquirer/editor@4.2.10(@types/node@22.14.1)':
dependencies:
'@inquirer/core': 10.1.10(@types/node@22.14.1)
'@inquirer/type': 3.0.6(@types/node@22.14.1)
external-editor: 3.1.0
optionalDependencies:
'@types/node': 22.14.1
'@inquirer/expand@4.0.12(@types/node@22.14.1)':
dependencies:
'@inquirer/core': 10.1.10(@types/node@22.14.1)
'@inquirer/type': 3.0.6(@types/node@22.14.1)
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.14.1
'@inquirer/figures@1.0.11': {}
'@inquirer/input@4.1.9(@types/node@22.14.1)':
dependencies:
'@inquirer/core': 10.1.10(@types/node@22.14.1)
'@inquirer/type': 3.0.6(@types/node@22.14.1)
optionalDependencies:
'@types/node': 22.14.1
'@inquirer/number@3.0.12(@types/node@22.14.1)':
dependencies:
'@inquirer/core': 10.1.10(@types/node@22.14.1)
'@inquirer/type': 3.0.6(@types/node@22.14.1)
optionalDependencies:
'@types/node': 22.14.1
'@inquirer/password@4.0.12(@types/node@22.14.1)':
dependencies:
'@inquirer/core': 10.1.10(@types/node@22.14.1)
'@inquirer/type': 3.0.6(@types/node@22.14.1)
ansi-escapes: 4.3.2
optionalDependencies:
'@types/node': 22.14.1
'@inquirer/prompts@7.5.0(@types/node@22.14.1)':
dependencies:
'@inquirer/checkbox': 4.1.5(@types/node@22.14.1)
'@inquirer/confirm': 5.1.9(@types/node@22.14.1)
'@inquirer/editor': 4.2.10(@types/node@22.14.1)
'@inquirer/expand': 4.0.12(@types/node@22.14.1)
'@inquirer/input': 4.1.9(@types/node@22.14.1)
'@inquirer/number': 3.0.12(@types/node@22.14.1)
'@inquirer/password': 4.0.12(@types/node@22.14.1)
'@inquirer/rawlist': 4.1.0(@types/node@22.14.1)
'@inquirer/search': 3.0.12(@types/node@22.14.1)
'@inquirer/select': 4.2.0(@types/node@22.14.1)
optionalDependencies:
'@types/node': 22.14.1
'@inquirer/rawlist@4.1.0(@types/node@22.14.1)':
dependencies:
'@inquirer/core': 10.1.10(@types/node@22.14.1)
'@inquirer/type': 3.0.6(@types/node@22.14.1)
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.14.1
'@inquirer/search@3.0.12(@types/node@22.14.1)':
dependencies:
'@inquirer/core': 10.1.10(@types/node@22.14.1)
'@inquirer/figures': 1.0.11
'@inquirer/type': 3.0.6(@types/node@22.14.1)
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.14.1
'@inquirer/select@4.2.0(@types/node@22.14.1)':
dependencies:
'@inquirer/core': 10.1.10(@types/node@22.14.1)
'@inquirer/figures': 1.0.11
'@inquirer/type': 3.0.6(@types/node@22.14.1)
ansi-escapes: 4.3.2
yoctocolors-cjs: 2.1.2
optionalDependencies:
'@types/node': 22.14.1
'@inquirer/type@3.0.6(@types/node@22.14.1)':
optionalDependencies:
'@types/node': 22.14.1
'@isaacs/cliui@8.0.2':
dependencies:
string-width: 5.1.2
@@ -4879,6 +5143,10 @@ snapshots:
ansi-colors@4.1.3: {}
ansi-escapes@4.3.2:
dependencies:
type-fest: 0.21.3
ansi-escapes@7.0.0:
dependencies:
environment: 1.1.0
@@ -5062,6 +5330,8 @@ snapshots:
slice-ansi: 5.0.0
string-width: 7.2.0
cli-width@4.1.0: {}
client-only@0.0.1: {}
cliui@8.0.1:
@@ -5850,6 +6120,8 @@ snapshots:
mustache@4.2.0: {}
mute-stream@2.0.0: {}
nanoid@3.3.11: {}
negotiator@1.0.0: {}
@@ -6642,6 +6914,8 @@ snapshots:
turbo-windows-64: 2.5.0
turbo-windows-arm64: 2.5.0
type-fest@0.21.3: {}
type-is@2.0.1:
dependencies:
content-type: 1.0.5
@@ -6895,6 +7169,12 @@ snapshots:
- bufferutil
- utf-8-validate
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -6937,6 +7217,8 @@ snapshots:
y18n: 5.0.8
yargs-parser: 21.1.1
yoctocolors-cjs@2.1.2: {}
youch@3.3.4:
dependencies:
cookie: 0.7.2