Skip to content

Commit 314ed40

Browse files
authored
feat(workflow): add observer/watch stream APIs (#1100)
1 parent bd66b11 commit 314ed40

7 files changed

Lines changed: 555 additions & 5 deletions

File tree

.changeset/blue-bags-remember.md

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
---
2+
"@voltagent/core": minor
3+
---
4+
5+
feat: add workflow observer/watch APIs for stream results
6+
7+
### What's New
8+
9+
- Added run-level observer APIs on `WorkflowStreamResult`:
10+
- `watch(cb)`
11+
- `watchAsync(cb)`
12+
- `observeStream()`
13+
- `streamLegacy()`
14+
- These APIs are now available on:
15+
- `workflow.stream(...)`
16+
- `workflow.timeTravelStream(...)`
17+
- resumed stream results returned from `.resume(...)`
18+
- Added test coverage for event ordering, unsubscribe behavior, multiple watchers, callback isolation, and `observeStream()` close semantics.
19+
20+
### SDK Example
21+
22+
```ts
23+
const stream = workflow.stream({ value: 3 });
24+
25+
const unwatch = stream.watch((event) => {
26+
console.log("[watch]", event.type, event.from);
27+
});
28+
29+
const unwatchAsync = await stream.watchAsync(async (event) => {
30+
if (event.type === "workflow-error") {
31+
await notifyOps(event);
32+
}
33+
});
34+
35+
const reader = stream.observeStream().getReader();
36+
const observerTask = (async () => {
37+
while (true) {
38+
const { done, value } = await reader.read();
39+
if (done) break;
40+
console.log("[observeStream]", value.type);
41+
}
42+
})();
43+
44+
for await (const event of stream) {
45+
console.log("[main iterator]", event.type);
46+
}
47+
48+
unwatch();
49+
unwatchAsync();
50+
await observerTask;
51+
52+
const state = await stream.streamLegacy().getWorkflowState();
53+
console.log("final status:", state?.status);
54+
```
55+
56+
### Time Travel Stream Example
57+
58+
```ts
59+
const replayStream = workflow.timeTravelStream({
60+
executionId: sourceExecutionId,
61+
stepId: "step-approval",
62+
});
63+
64+
const stopReplayWatch = replayStream.watch((event) => {
65+
console.log("[replay]", event.type, event.from);
66+
});
67+
68+
for await (const event of replayStream) {
69+
console.log("[replay iterator]", event.type);
70+
}
71+
72+
stopReplayWatch();
73+
```

packages/core/src/workflow/core.spec.ts

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,97 @@ describe.sequential("workflow streaming", () => {
382382
expect(result).toEqual({ result: 10 });
383383
});
384384

385+
it("should expose watch/watchAsync and streamLegacy on workflow stream results", async () => {
386+
const memory = new Memory({ storage: new InMemoryStorageAdapter() });
387+
const workflow = createWorkflow(
388+
{
389+
id: "stream-observer-surface",
390+
name: "Stream Observer Surface",
391+
input: z.object({ value: z.number() }),
392+
result: z.object({ result: z.number() }),
393+
memory,
394+
},
395+
andThen({
396+
id: "multiply",
397+
execute: async ({ data }) => ({ result: data.value * 3 }),
398+
}),
399+
);
400+
401+
const registry = WorkflowRegistry.getInstance();
402+
registry.registerWorkflow(workflow);
403+
404+
const stream = workflow.stream({ value: 7 });
405+
const watchedEvents: string[] = [];
406+
const watchedAsyncEvents: string[] = [];
407+
408+
const unsubscribe = stream.watch((event) => {
409+
watchedEvents.push(event.type);
410+
});
411+
const unsubscribeAsync = await stream.watchAsync((event) => {
412+
watchedAsyncEvents.push(event.type);
413+
});
414+
415+
for await (const _event of stream) {
416+
// Drain the iterator to completion.
417+
}
418+
419+
unsubscribe();
420+
unsubscribeAsync();
421+
422+
expect(watchedEvents.length).toBeGreaterThan(0);
423+
expect(watchedAsyncEvents.length).toBeGreaterThan(0);
424+
expect(watchedEvents).toContain("workflow-start");
425+
expect(watchedEvents).toContain("workflow-complete");
426+
expect(watchedAsyncEvents).toContain("workflow-start");
427+
expect(watchedAsyncEvents).toContain("workflow-complete");
428+
429+
const legacyState = await stream.streamLegacy().getWorkflowState();
430+
expect(legacyState).toEqual(
431+
expect.objectContaining({
432+
id: stream.executionId,
433+
status: "completed",
434+
}),
435+
);
436+
});
437+
438+
it("should expose observeStream as readable stream on workflow stream results", async () => {
439+
const memory = new Memory({ storage: new InMemoryStorageAdapter() });
440+
const workflow = createWorkflow(
441+
{
442+
id: "stream-observe-readable",
443+
name: "Stream Observe Readable",
444+
input: z.object({ value: z.number() }),
445+
result: z.object({ result: z.number() }),
446+
memory,
447+
},
448+
andThen({
449+
id: "multiply",
450+
execute: async ({ data }) => ({ result: data.value * 2 }),
451+
}),
452+
);
453+
454+
const registry = WorkflowRegistry.getInstance();
455+
registry.registerWorkflow(workflow);
456+
457+
const stream = workflow.stream({ value: 4 });
458+
const reader = stream.observeStream().getReader();
459+
const observedTypes: string[] = [];
460+
461+
while (true) {
462+
const { done, value } = await reader.read();
463+
if (done) {
464+
break;
465+
}
466+
if (value) {
467+
observedTypes.push(value.type);
468+
}
469+
}
470+
471+
expect(observedTypes).toContain("workflow-start");
472+
expect(observedTypes).toContain("workflow-complete");
473+
expect(await stream.result).toEqual({ result: 8 });
474+
});
475+
385476
it("should have usage with default values", async () => {
386477
const memory = new Memory({ storage: new InMemoryStorageAdapter() });
387478
const workflow = createWorkflow(

packages/core/src/workflow/core.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3116,6 +3116,14 @@ export function createWorkflow<
31163116
resumedSuspendController.cancel(reason);
31173117
},
31183118
abort: () => streamController.abort(),
3119+
watch: (cb) => streamController.watch(cb),
3120+
watchAsync: (cb) => streamController.watchAsync(cb),
3121+
observeStream: () => streamController.observeStream(),
3122+
streamLegacy: () => ({
3123+
stream: streamController.observeStream(),
3124+
getWorkflowState: () =>
3125+
replayExecutionMemory.getWorkflowState(suspendedResult.executionId),
3126+
}),
31193127
toUIMessageStreamResponse: eventToUIMessageStreamResponse(streamController),
31203128
[Symbol.asyncIterator]: () => streamController.getStream(),
31213129
};
@@ -3148,6 +3156,13 @@ export function createWorkflow<
31483156
abort: () => {
31493157
streamController.abort();
31503158
},
3159+
watch: (cb) => streamController.watch(cb),
3160+
watchAsync: (cb) => streamController.watchAsync(cb),
3161+
observeStream: () => streamController.observeStream(),
3162+
streamLegacy: () => ({
3163+
stream: streamController.observeStream(),
3164+
getWorkflowState: () => replayExecutionMemory.getWorkflowState(executionId),
3165+
}),
31513166
[Symbol.asyncIterator]: () => streamController.getStream(),
31523167
};
31533168

@@ -3294,6 +3309,14 @@ export function createWorkflow<
32943309
resumedSuspendController.cancel(reason);
32953310
},
32963311
abort: () => streamController.abort(),
3312+
watch: (cb) => streamController.watch(cb),
3313+
watchAsync: (cb) => streamController.watchAsync(cb),
3314+
observeStream: () => streamController.observeStream(),
3315+
streamLegacy: () => ({
3316+
stream: streamController.observeStream(),
3317+
getWorkflowState: () =>
3318+
streamExecutionMemory.getWorkflowState(suspendedResult.executionId),
3319+
}),
32973320
toUIMessageStreamResponse: eventToUIMessageStreamResponse(streamController),
32983321
[Symbol.asyncIterator]: () => streamController.getStream(),
32993322
};
@@ -3327,6 +3350,13 @@ export function createWorkflow<
33273350
abort: () => {
33283351
streamController.abort();
33293352
},
3353+
watch: (cb) => streamController.watch(cb),
3354+
watchAsync: (cb) => streamController.watchAsync(cb),
3355+
observeStream: () => streamController.observeStream(),
3356+
streamLegacy: () => ({
3357+
stream: streamController.observeStream(),
3358+
getWorkflowState: () => streamExecutionMemory.getWorkflowState(executionId),
3359+
}),
33303360
// AsyncIterable implementation
33313361
[Symbol.asyncIterator]: () => streamController.getStream(),
33323362
};

0 commit comments

Comments
 (0)