mirror of
https://github.com/vercel/workflow.git
synced 2026-09-14 19:59:43 +08:00
02681dce4a
* feat(core): add hook.dispose() method to release hook tokens early Add a `dispose()` method to the Hook interface that allows workflows to explicitly release hook tokens for reuse by other workflows while the current workflow is still running. This enables handoff patterns where one workflow can transfer a hook token to another workflow. - Add `dispose()` method to Hook interface in create-hook.ts - Implement dispose functionality in workflow/hook.ts - Add HookDisposedInvocationQueueItem to global.ts - Handle hook_disposed events in suspension-handler.ts - Update documentation in hooks.mdx and create-hook.mdx - Add e2e test for hook token reuse after explicit disposal https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH * feat(core): implement TypeScript Disposable spec for hooks Add Symbol.dispose to Hook interface to support the TC39 Explicit Resource Management proposal. This allows hooks to be used with the `using` keyword for automatic disposal when exiting scope. https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH * docs: prefer `using` keyword for hooks and webhooks Update all documentation and e2e tests to use the `using` keyword as the recommended approach for creating hooks and webhooks. This leverages the TC39 Explicit Resource Management proposal for automatic disposal. - Update e2e tests to use `using` syntax - Update foundational hooks guide to recommend `using` - Update create-hook API reference with `using` examples - Update create-webhook API reference with `using` examples - Update example workflow to use `using` https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH * chore: simplify `using` examples in docs and e2e tests Remove unnecessary block scopes and excessive comments about automatic disposal. Block scopes are only used when early disposal is relevant (like in the handoff test). https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH * docs: add brief explanation of `using` in first examples Add a one-liner explaining the `using` keyword in the intro examples of the API reference docs, so new users understand the syntax. https://claude.ai/code/session_01AkvrXduyFTbtV2joTPHrDH * fix: address PR review comments - Restore code highlights (`[!code highlight]`) that were unintentionally removed from pre-existing doc examples - Move `using` explanation from prose to inline code comment in intro examples - Add `{/* @skip-typecheck */}` to incomplete manual dispose() snippet - Add 409 (conflict/duplicate) error handling for hook_disposed events in suspension handler to handle workflow re-invocation Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: polyfill Symbol.dispose in workflow VM context The workflow VM sandbox doesn't have Symbol.dispose/Symbol.asyncDispose available, causing `using` keyword to fail with "Symbol.dispose is not defined" at runtime. Add polyfill in the VM context creation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: use VM's Symbol.dispose for hook disposable support The workflow VM sandbox has its own Symbol object with a polyfilled Symbol.dispose. The hook object was using the host's Symbol.dispose, which is a different symbol instance. The SWC-compiled `using` keyword looks up the VM's Symbol.dispose on the object, causing "Object not disposable" errors. Fix by setting Symbol.dispose on the hook object dynamically using the VM's globalThis.Symbol.dispose from the orchestrator context. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: revert webhookWorkflow to create all webhooks upfront The webhookWorkflow e2e test creates 3 webhooks that must all exist before the test sends HTTP requests. Using `using` with sequential creation meant only the first webhook existed at the first suspension point. Revert to `const` since all webhooks need to be created upfront. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: eliminate hook_disposed queue item, use flag pattern for disposal Instead of adding a separate HookDisposedInvocationQueueItem to the queue on dispose, keep the HookInvocationQueueItem throughout the hook lifecycle and track state with flags (hasCreatedEvent, disposed). A closure variable (hasDisposedEvent) makes disposeHook() a pure no-op on replay, avoiding redundant server calls and 409 errors. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: delete hook from invocations queue on hook_disposed terminal event Match the pattern used by steps (step_completed/step_failed) and waits (wait_completed) where the queue item is removed on the terminal event. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: verify dispose() is safe when called twice after replay Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add comprehensive edge case tests for hook disposal - WorkflowSuspension counts disposed hooks separately from active hooks - Dispose after hook_created replay produces correct suspension - Dispose before first suspension (needs both create + dispose) - Multiple hooks where only one is disposed - Dispose on a conflicted hook is safe (no crash) - Symbol.dispose calls disposeHook correctly (using keyword pattern) - Iterator break without dispose keeps hook alive in queue - Await after dispose on first invocation triggers suspension Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: remove unsafe cast by adding Symbol.dispose to hook object literal Add [Symbol.dispose] directly to the hook object so it satisfies the Hook<T> type without `as unknown as`. The VM's Symbol.dispose is still added separately when it differs from the host's. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: address remaining Copilot review comments - Rename test to match new behavior (hooks stay in queue, not removed) - Use neutral "processed" verb in WorkflowSuspension message when mixed item types are present - Remove extends Disposable from Hook interface to avoid requiring lib.esnext.disposable in downstream consumers (explicit [Symbol.dispose]() method is still declared on the interface) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: drain pending promises in disposeHook to prevent orphaned awaits When dispose() is called while a promise is pending (e.g., iterator suspended on yield await this, or direct await hook after dispose), the promise would hang forever since the event consumer will never deliver another hook_received. Now disposeHook() clears the promises array and triggers a WorkflowSuspension so the runtime processes the disposal cleanly. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * chore: remove workflow from changeset, keep only @workflow/core Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
85 lines
2.5 KiB
TypeScript
85 lines
2.5 KiB
TypeScript
import { OpenAI } from 'openai';
|
|
import { createHook, getStepMetadata, getWorkflowMetadata } from 'workflow';
|
|
|
|
/**
|
|
* `getStepMetadata()` is a hook that allows you to access the step's context
|
|
* of the current workflow run.
|
|
*
|
|
* It is useful for accessing the context of the current workflow run, such as
|
|
* the workflow run ID, the workflow started at, and the attempt number.
|
|
*/
|
|
async function stepWithGetMetadata() {
|
|
'use step';
|
|
const ctx = getStepMetadata();
|
|
console.log('step context', ctx);
|
|
|
|
// Mimic a retryable error 50% of the time (so that the `attempt` counter increases)
|
|
if (Math.random() < 0.5) {
|
|
throw new Error('Retryable error');
|
|
}
|
|
|
|
return ctx;
|
|
}
|
|
|
|
export async function withWorkflowMetadata() {
|
|
'use workflow';
|
|
const ctx = getWorkflowMetadata();
|
|
console.log('workflow context', ctx);
|
|
|
|
const stepCtx = await stepWithGetMetadata();
|
|
|
|
return { workflowCtx: ctx, stepCtx };
|
|
}
|
|
|
|
async function initiateOpenAIResponse() {
|
|
'use step';
|
|
const openai = new OpenAI();
|
|
const resp = await openai.responses.create({
|
|
model: 'o3',
|
|
input: 'Write a very long novel about otters in space.',
|
|
background: true,
|
|
});
|
|
console.log('OpenAI response:', resp);
|
|
return resp.id;
|
|
}
|
|
|
|
async function getOpenAIResponse(respId: string): Promise<string> {
|
|
'use step';
|
|
const openai = new OpenAI();
|
|
const resp = await openai.responses.retrieve(respId);
|
|
return resp.output_text;
|
|
}
|
|
|
|
/**
|
|
* `createHook()` registers a token that can be used to resume the workflow run.
|
|
* The token can be passed to external services as a callback URL, or used
|
|
* for human-in-the-loop workflows by, for example, including in an email.
|
|
*
|
|
* The workflow run will be suspended until the hook is invoked.
|
|
*/
|
|
export async function withCreateHook() {
|
|
'use workflow';
|
|
|
|
// Initiate a background "Response" request to OpenAI,
|
|
// which will invoke the hook when it's done.
|
|
const respId = await initiateOpenAIResponse();
|
|
|
|
// Register the hook with the token that is specific
|
|
// to the response ID that we are interested in.
|
|
using hook = createHook<{ type: string; data: { id: string } }>({
|
|
token: `openai:${respId}`,
|
|
});
|
|
console.log('Registered hook:', hook.token);
|
|
|
|
// Wait for the hook to be called.
|
|
const payload = await hook;
|
|
console.log('Received hook payload:', payload);
|
|
|
|
if (payload.type === 'response.completed') {
|
|
const text = await getOpenAIResponse(payload.data.id);
|
|
console.log('OpenAI response text:', text);
|
|
}
|
|
|
|
console.log('Hook demo workflow completed');
|
|
}
|