diff --git a/docs/content/guides/ai-workflows.mdx b/docs/content/guides/ai-workflows.mdx new file mode 100644 index 0000000..092af15 --- /dev/null +++ b/docs/content/guides/ai-workflows.mdx @@ -0,0 +1,191 @@ +--- +title: "Multi-Step AI Workflows" +description: "Chain LLM calls and tool calls into one job that picks up at the failed step after a crash or a rate-limit error." +--- + +import ExampleLinkBar from "@/components/docs/ExampleLinkBar.astro"; + + + + + +What we'll build: + +- An `aiJob` workflow per job that turns a meeting transcript into a short brief for your sales team. +- Two model steps, extract and summarize, that retry with backoff when the provider is rate limited or down. +- Enrich steps that look up each company in your CRM between the two model calls. +- A publish step that posts the brief to your CMS exactly once. +- A `/jobs` route that starts a job and returns its id right away, and progress events that tell your app which step a job is on. + + + + + +```sh +npm add @rivet-dev/workflows rivetkit hono @hono/node-server +``` + + + + + +Set your model key and the base URLs of your CRM and CMS APIs: + +```sh +OPENAI_API_KEY=sk-... +CRM_API_URL=https://crm.internal.example.com +CMS_API_URL=https://cms.internal.example.com +``` + +`OPENAI_BASE_URL` and `OPENAI_MODEL` are optional. Point `OPENAI_BASE_URL` at any server that speaks the OpenAI chat completions format. + + + + + +Every model call goes through one function: + + + +- Keeping the call in one file means the workflow never depends on a provider. To use Anthropic, Gemini, or an SDK, change only this function. +- A `429` or `5xx` throws a plain `Error`. The workflow step that called it retries with backoff. +- Any other error throws `CriticalError`, which stops retries right away. A bad API key or a malformed request fails the same way every time, so retrying only wastes time. + + + + + +Each job is its own `aiJob` workflow, keyed by the job id. It waits for a `start` message, then runs the extract step: + + + +- `ctx.step` records the step's return value. After a crash or a deploy, the workflow replays and gets the recorded value back instead of calling the model again, so you never pay twice for the same answer. +- `MODEL_STEP` gives model calls up to 7 attempts. The first retry waits 4 seconds and the wait doubles up to 60 seconds, which is enough for most rate limits to clear. The workflow sleeps durably between attempts, so a long wait does not hold a process. +- Model calls can be slow, so the step timeout is 2 minutes instead of the default 30 seconds. `retryOnTimeout` makes a timeout retry like any other error. +- If the model replies with invalid JSON, `JSON.parse` throws and the step asks again. + + + + + +Between the two model calls, the workflow looks up each company in your CRM: + + + + + + +- Each lookup is its own step, named by its position. If the third lookup fails, only that one retries, and the first two are not repeated. +- The loop is safe to replay because the list of companies comes from the recorded extract step, so every replay sees the same list in the same order. + + + + + +The summarize step sends the transcript, the action items, and the CRM data back to the model: + + + +- It uses the same `MODEL_STEP` retry settings as the extract step. +- `companies` and `extracted` are recorded results from earlier steps, so this step can run in a later process than the ones that produced them. + + + + + +The last step posts the brief to your CMS: + + + +- A step can run more than once. If the process dies after your CMS saved the post but before the step was recorded, the step runs again. +- `publishPost` sends the job id as an `idempotency-key` header, so your CMS can return the existing post instead of creating a second one. See `tools.ts` in the previous step. + + + + + +Every step calls `setStage`, and the `onError` hook reports retries: + + + + + + +- The stage lives in `state`, so a client that connects late can call `getStatus` and see where the job is. +- `broadcast` pushes each change to every client connected to the job. No separate pub/sub service is needed. +- `onError` sees each failed attempt with its retry delay. It only observes, so it is a good place for progress updates and error reporting, but not for work the job depends on. + + + + + +The HTTP server has one route. It creates a workflow for the job and hands it the input: + + + +- `send` puts the input on the job's `start` queue and returns as soon as it is stored, so the route answers in milliseconds even though the job takes much longer. +- A new random id per request means each POST starts its own job. To make retries of the same request safe, derive the id from something stable, such as a document id, and `getOrCreate` returns the existing job. + + + + + +Start the server: + +```sh +npx tsx server.ts +``` + +Then submit a job and follow its progress: + + + +```sh +npx tsx client.ts +``` + +The client prints each stage as it happens, including any retries, and then the job's final state. + + +Stop the server while a job is running and start it again. The job continues from the step it was on, and steps that already finished do not call the model or your APIs again. + + + + + + +## Next steps + +- [Human-in-the-Loop Approvals](/guides/human-in-the-loop): have a person approve the brief before the publish step runs. +- [Timers & Concurrency](/workflows/docs/timers-and-concurrency#join): look up every company at once with `join` instead of one after another. +- [Failure & Recovery](/workflows/docs/failure-and-recovery#rollback): undo earlier steps when a later one fails. + +## Related docs + +- [Steps](/workflows/docs/steps): how steps are recorded and replayed. +- [Timers & Concurrency](/workflows/docs/timers-and-concurrency#timeouts): step timeouts and retry settings. +- [Failure & Recovery](/workflows/docs/failure-and-recovery#error-hooks): the `onError` hook. +- [Patterns](/workflows/docs/patterns): storing progress in state and broadcasting it. diff --git a/docs/content/guides/handle-webhooks.mdx b/docs/content/guides/handle-webhooks.mdx new file mode 100644 index 0000000..117a34f --- /dev/null +++ b/docs/content/guides/handle-webhooks.mdx @@ -0,0 +1,180 @@ +--- +title: "Handle Incoming Webhooks" +description: "Accept webhooks from Stripe, GitHub, or Shopify, process each event once, and keep events for the same customer in order." +--- + +import ExampleLinkBar from "@/components/docs/ExampleLinkBar.astro"; + + + + + +Webhook providers send each event at least once. They resend it when your server is slow to answer or answers with an error, and they do not promise to send events in order. This guide puts a workflow in front of your database so each event is applied once, and events about the same customer are applied one after another. + +What we'll build: + +- A `/webhooks` route that checks the provider's signature and rejects anything that fails. +- A `customerEvents` workflow per customer, keyed by the customer id in the event. +- A record of event ids in the workflow, so a redelivered event is skipped instead of applied twice. +- An answer sent as soon as the event is queued, well inside the provider's timeout. +- Steps that apply each event to your database in arrival order and retry with backoff when your database is down. + +The example uses Stripe. The same shape works for GitHub, Shopify, or any provider that signs its requests. + + + + + +```sh +npm add @rivet-dev/workflows rivetkit hono @hono/node-server +``` + + + + + +Stripe signs every webhook with a secret that belongs to the endpoint. In the Stripe Dashboard, add an endpoint for `https:///webhooks` and copy its signing secret. To test locally, the Stripe CLI prints one when you run `stripe listen`. Then set: + +```sh +STRIPE_WEBHOOK_SECRET=whsec_... +``` + + + + + +Anyone can post to a public URL, so the route checks that each request came from Stripe before it does anything else: + + + +- Stripe sends a `Stripe-Signature` header with a timestamp and an HMAC-SHA256 of `.`. The check recomputes it with your secret and compares the two with `timingSafeEqual`, which takes the same time whether or not they match. +- Requests signed more than 5 minutes ago are rejected, so someone who captures a request cannot send it again later. +- The official `stripe` package does the same check with `stripe.webhooks.constructEvent`. Use it if you already depend on it. + + +Other providers sign differently. GitHub sends `X-Hub-Signature-256`, a hex HMAC-SHA256 of the raw body. Shopify sends `X-Shopify-Hmac-Sha256`, a base64 HMAC-SHA256 of the raw body. Swap in a check for your provider. The rest of this guide stays the same. + + + + + + +Each customer gets a `customerEvents` workflow, keyed by their Stripe customer id. It takes events off its queue one at a time and records each event id before it handles the event: + + + +- Queue `send` has no idempotency key, so the workflow dedupes by event id. Stripe gives every event a unique `id` and keeps it the same on every retry. +- The check and the write happen in one step, and the workflow handles one event at a time. A redelivery that arrives while the first copy is still queued is still caught. +- The step's result is saved in the workflow's history. If the Rivet Actor restarts, it replays the saved result instead of checking again, so an event is never counted as new twice. +- Ids are kept for 7 days, longer than Stripe's 3 days of retries, then dropped so `state` stays small. + + + + + +Once an event is known to be new, the workflow writes it to your database: + + + + + + +- Events for one customer are applied one after another, in the order they reached your server. Events for different customers run in parallel, because each customer has its own workflow. +- If your database is down, `tryStep` retries with backoff, starting at 1 second and doubling up to 1 minute. The waits are durable, so the Rivet Actor can sleep or restart in between. +- If every retry fails, the event is added to `failed` instead of stopping the workflow. That list is your dead letter queue: later events keep flowing, and you can replay the failed ones once the cause is fixed. +- `db.ts` keeps rows in memory so the example runs on its own. Replace `applyEvent` with writes to your database. + + +Make the write idempotent, such as an upsert keyed by the object id. If the process dies after the write commits but before the step is saved, the step runs again on restart. + + + +Stripe does not promise to deliver events in the order they happened. When order matters, compare the event's `created` time with the last one you applied, or fetch the latest version of the object from Stripe before writing it. + + + + + + +The HTTP server has one route. It verifies the request, hands the event to the right customer's workflow, and answers: + + + +- The route reads the raw body with `c.req.text()`. The signature covers the exact bytes Stripe sent, so check it before parsing the JSON. +- Events are keyed by `customer`. Events about a customer object have no `customer` field, so the object's own id, which is the customer id, is used instead. +- `send` resolves once the event is stored in the workflow's queue. The route answers `200` right away instead of waiting for your database, so it stays well inside the few seconds providers wait before they give up and retry. +- A request with a bad signature gets a `400` and never reaches the workflow. + + + + + +Start the server: + +```sh +STRIPE_WEBHOOK_SECRET=whsec_test npx tsx server.ts +``` + +In another terminal, post a signed test event, then the same event again, then one with a forged signature: + + + +```sh +STRIPE_WEBHOOK_SECRET=whsec_test npx tsx send-test-event.ts +``` + +Both copies are accepted, but the event is applied once: + +```text +delivery: 200 {"received":true} +redelivery: 200 {"received":true} +forged: 400 invalid signature +{ applied: 1, duplicates: 1, failed: [] } +``` + +The server logs a single `applied evt_test_... (customer.subscription.updated): sub_123 is active` line. + +To try real Stripe events, forward them to the server with the Stripe CLI and start the server with the secret it prints: + +```sh +stripe listen --forward-to localhost:3000/webhooks +stripe trigger customer.subscription.updated +``` + + +To receive webhooks from a provider before you deploy, run `ngrok http 3000` and use the ngrok URL as `https://`. + + + + + + +## Next steps + +- [Reliable Webhook Delivery](/guides/webhook-delivery): send your own events to customer endpoints with retries. +- [Human-in-the-Loop Approvals](/guides/human-in-the-loop): ask a person before acting on an event, such as a refund request. +- [Failure & Recovery](/workflows/docs/failure-and-recovery#error-hooks): get notified when an event lands in `failed`. + +## Related docs + +- [Queues & Messaging](/workflows/docs/queues#queue-driven-worker): the queue-driven worker this guide builds on. +- [Steps](/workflows/docs/steps#handling-terminal-failures-as-data): how `tryStep` returns failures as data. +- [Timers & Concurrency](/workflows/docs/timers-and-concurrency#timeouts): retries, backoff, and why steps should be idempotent. +- [Reference](/workflows/docs/reference#recommendations): recommendations for replay-safe workflows. diff --git a/docs/content/guides/human-in-the-loop.mdx b/docs/content/guides/human-in-the-loop.mdx new file mode 100644 index 0000000..821a8c7 --- /dev/null +++ b/docs/content/guides/human-in-the-loop.mdx @@ -0,0 +1,194 @@ +--- +title: "Human-in-the-Loop Approvals" +description: "Pause a workflow or an agent's tool call until a person approves or rejects it, even if the answer takes days." +--- + +import ExampleLinkBar from "@/components/docs/ExampleLinkBar.astro"; + + + + + +What we'll build: + +- An `approval` workflow per request that records which tool should run, with which arguments, and who must answer. +- A Slack message that shows the request to the approver with Approve and Reject buttons. +- A durable wait on the decision queue that can last for days without a running server. +- A timeout that expires the request and tells the requester when no one answers. +- The same gate wrapped around an agent's tool, so the tool runs only after a person approves it. + + + + + +```sh +npm add @rivet-dev/workflows rivetkit hono @hono/node-server +``` + + + + + +Create a Slack app and give its bot the `chat:write` scope. Install it to your workspace. Then set: + +```sh +SLACK_BOT_TOKEN=xoxb-... +SLACK_SIGNING_SECRET=... +APPROVER_SLACK_ID=U... +AGENT_SLACK_CHANNEL=C... +ORDERS_API_URL=https://api.example.com +``` + +`APPROVER_SLACK_ID` is the Slack user who answers requests. `AGENT_SLACK_CHANNEL` is where the requester hears the outcome. You turn on button clicks in the last step, once the server is running. + + + + + +The tools that need approval live in one place. The workflow runs them, never the caller: + + + +- A request names a tool and its arguments as data, such as `{ tool: "refund_order", args: { ... } }`. Code cannot be put on a queue, so the workflow looks the tool up by name when it runs it. +- Each call sends the request id as an idempotency key. If the call is retried, your API can return the first result instead of refunding twice. + + + + + +Each request is its own `approval` workflow, keyed by a request id. It waits for the request, records it, and asks the approver: + + + + + + +- The request is stored in `state`, so `getStatus` can show what is being asked at any time, even while the workflow waits. +- Each button's `value` is the request id. That is all `/approvals` needs to find the right workflow. +- Without `SLACK_BOT_TOKEN`, `postMessage` logs the message instead of posting it, so you can try the flow before Slack is set up. +- To approve by email instead, send two links to a route of your own. The route then sends the decision the same way `/approvals` does below. + + + + + +The workflow then waits for a `decision` message or for the timeout, whichever comes first: + + + +- `nextBatch` with a `timeout` returns an empty list when the time runs out. With a message, it returns that message. +- The deadline is recorded in the workflow's history. While it waits, the Rivet Actor can sleep and no process is held. A decision message or the deadline wakes it up, even after a restart or a deploy. +- `names: ["decision"]` reads only decisions, so nothing else on the queue can be mistaken for an answer. + + + + + +On approve, the workflow runs the tool. Every path records the outcome and tells the requester: + + + +- No decision means the request expired. Expired and rejected requests skip the tool entirely. +- `tryStep` retries the tool with backoff. If it keeps failing, it returns the error as data instead of failing the workflow, so the outcome is `failed` and the requester still hears about it. +- `broadcast` sends the outcome to connected clients, which is how a waiting agent tool gets its answer. The `notify-requester` step also posts it in Slack. + + + + + +Slack posts every button click to `/approvals`. The route checks the request, then hands the decision to the workflow: + + + +- `verifySlackRequest` in `slack.ts` checks Slack's signature and rejects requests more than 5 minutes old. +- `getStatus` reads the waiting workflow directly. A late click on an expired or already decided request gets a short reply instead of a second decision. +- Only the assigned approver can answer. Anyone else who clicks gets an ephemeral reply that only they can see. +- The route replaces the original message through Slack's `response_url`, so the buttons disappear once someone has answered. + + + + + +An agent tool can ask for approval the same way. Its `execute` waits until a person answers: + + + +- `requestApproval` starts a workflow with a new id, sends the request, and waits for the `finished` event. +- The tool shape, with a name, a description, JSON Schema parameters, and `execute`, works with most agent frameworks. Adapt it to yours. +- The refund itself happens in the workflow's `run-tool` step, not in the agent. Even a confused agent cannot refund without a person saying yes. + + +The tool waits only as long as its process runs. The request, the decision, and the refund live in the workflow, so they continue even if the agent restarts. Keep the request id and call `getStatus` to pick up the outcome later. + + + + + + +Start the server: + +```sh +npx tsx server.ts +``` + +In your Slack app settings, open **Interactivity & Shortcuts**, turn it on, and set the **Request URL** to `https:///approvals`. + + +If you haven't deployed yet, run `ngrok http 3000` and use the ngrok URL as `https://`. + + +Then call the tool the way an agent would: + + + +```sh +npx tsx client.ts +``` + +The approver gets a Slack message. When they click **Approve**, the refund runs and `client.ts` prints the result. + +To test without Slack, leave `SLACK_BOT_TOKEN` unset. The server logs the request id. Answer it from another terminal: + + + +```sh +npx tsx decide.ts approve +``` + + + + + +## Next steps + +- [Multi-Step AI Workflows](/guides/ai-workflows): add an approval before an AI job publishes its result. +- [Human in the Loop for agents](/agents/docs/human-in-the-loop): ask the user before an agent acts. +- [Timers & Concurrency](/workflows/docs/timers-and-concurrency#race): use `race` when the wait has more than two ways to end. + +## Related docs + +- [Queues & Messaging](/workflows/docs/queues#human-approval-gate): the basic approval gate. +- [Steps](/workflows/docs/steps#handling-terminal-failures-as-data): how `tryStep` returns failures as data. +- [Timers & Concurrency](/workflows/docs/timers-and-concurrency): durable sleeps and timeouts. +- [Patterns](/workflows/docs/patterns): storing progress in state and broadcasting it. diff --git a/docs/content/guides/webhook-delivery.mdx b/docs/content/guides/webhook-delivery.mdx new file mode 100644 index 0000000..7820bfc --- /dev/null +++ b/docs/content/guides/webhook-delivery.mdx @@ -0,0 +1,194 @@ +--- +title: "Reliable Webhook Delivery" +description: "Send your app's events to customer endpoints in order, retry failed deliveries, and turn off endpoints that keep failing." +--- + +import ExampleLinkBar from "@/components/docs/ExampleLinkBar.astro"; + + + + + +When your customers subscribe to webhooks, their endpoints will be slow, down for a deploy, or broken for days. This guide gives each customer endpoint its own workflow that delivers events in order, retries with backoff, keeps a log of every attempt, and stops sending to an endpoint that keeps failing until the customer turns it back on. + +What we'll build: + +- An `endpoint` workflow per customer endpoint that holds an ordered outbox of events to deliver. +- Signed payloads, so customers can check that each webhook came from you. +- Retries with backoff and a record of every attempt, with its status code and timing. +- A rule that disables an endpoint after repeated failures and keeps its events until it is turned back on. +- Actions your customer portal calls to show the delivery log and re-enable an endpoint. + + + + + +```sh +npm add @rivet-dev/workflows rivetkit hono @hono/node-server +``` + + + + + +Every request carries a signature, so a customer can tell your webhooks apart from anyone else posting to their URL: + + + +- The headers follow the Standard Webhooks spec. `webhook-signature` is an HMAC-SHA256 of `..`, keyed by the endpoint's secret, so customers can check it with any Standard Webhooks library. +- `webhook-id` is the event id. It stays the same on every retry, so customers can use it to skip events they have already handled. +- Each endpoint gets its own secret from `createSecret`. A leaked secret affects only that endpoint. + + + + + +Each customer endpoint is its own `endpoint` workflow, keyed by an endpoint id. Its queue is the outbox: + + + +- Events come off the queue one at a time, in the order your backend sent them. The next event stays in the queue until the current one is delivered, so a customer never sees event 2 before event 1. +- The inner `deliver-event` loop keeps working on the same event until it is delivered, including across a disable and re-enable. +- One slow or broken endpoint backs up only its own outbox. Every other endpoint has its own workflow and keeps receiving events. +- The URL and secret are set in `onCreate` from the input your backend passes when it creates the endpoint. + + + + + +The delivery itself is one step that posts the signed event and records the attempt: + + + +- Any `2xx` answer counts as delivered. Any other status, a network error, or no answer within 10 seconds throws, and `tryStep` retries after 2, 4, 8, 16, and 32 seconds. +- The waits between retries are durable. The Rivet Actor can sleep or restart during a long backoff and picks up at the next attempt. +- The attempt is pushed to `attempts` before the step throws. A failed step keeps the changes it made to `state`, so the log shows every try, not just the last one. +- The log keeps the newest 100 attempts. Store attempts in your own database if you need a longer history. + + +These retries span about a minute so the example is quick to try. Real endpoints can be down for hours, so raise `MAX_RETRIES` and `RETRY_BACKOFF_MAX_MS` for production. For example, 20 retries capped at 1 hour keep trying for about 10 hours. + + + +Delivery is at least once. If a customer's server handles a request but its answer is lost or arrives after the timeout, the event is sent again. Tell customers to skip any `webhook-id` they have already handled. + + + + + + +When the retries run out, the workflow disables the endpoint instead of dropping the event: + + + +- On success, the event is cleared from `pending` and the loop breaks, so the workflow takes the next event off the queue. +- On failure, the endpoint is marked `disabled` and the workflow waits on the `enable` queue. The failed event and every event behind it are kept until then, so nothing is lost. +- While it waits, the Rivet Actor can sleep. No process is held for a disabled endpoint, however long it stays off. +- After `enable`, the loop runs again and retries the same event with a fresh set of retries. + + + + + +Your backend creates endpoints, emits events, and gives your customer portal two routes: + + + + + + +- `emit` sends the event to the `endpoint` workflow of every endpoint subscribed to its type. `send` returns once the event is stored in the outbox, so your app never waits on a customer's server. +- `POST /endpoints` creates the workflow with the URL and a new secret, and returns the secret once so the customer can check signatures. +- `listAttempts` returns the delivery log, newest first, along with whether the endpoint is disabled. It never returns the secret. +- `enable` queues a message for the waiting workflow, and only when the endpoint is disabled. +- The `subscriptions` map is in memory to keep the example short. Store subscriptions in your database. + + +These routes trust the endpoint id in the URL. In production, check that the signed-in customer owns the endpoint before you call `listAttempts` or `enable`. + + + + + + +Your customers run the other half. This is what their check looks like, and the test receiver in the next step uses it: + + + +- It rebuilds the signature from the raw body and the `webhook-id` and `webhook-timestamp` headers, then compares with `timingSafeEqual`. +- Requests signed more than 5 minutes ago are rejected, so a captured request cannot be replayed later. +- The header can hold several signatures separated by spaces, which lets you rotate an endpoint's secret without breaking deliveries. + + + + + +Start the server: + +```sh +npx tsx server.ts +``` + +In another terminal, run the client. It adds an endpoint, starts a receiver that answers `503` to the first 3 requests, and emits two events: + + + + + + +```sh +npx tsx client.ts +``` + +The first event fails 3 times, 2, 4, and 8 seconds apart, then gets through. The second event waits behind it and is delivered right after: + +```text +endpoint ep_83460a4b-7b27-416c-acf1-856402c9b7c2 +receiver: msg_e202e0a2-9126-4a90-8cb8-189d9f9b6d0e order.created -> 503 +08:59:47 msg_e202e0a2 attempt 1: 503 (17ms) +receiver: msg_e202e0a2-9126-4a90-8cb8-189d9f9b6d0e order.created -> 503 +08:59:49 msg_e202e0a2 attempt 2: 503 (3ms) +receiver: msg_e202e0a2-9126-4a90-8cb8-189d9f9b6d0e order.created -> 503 +08:59:53 msg_e202e0a2 attempt 3: 503 (3ms) +receiver: msg_e202e0a2-9126-4a90-8cb8-189d9f9b6d0e order.created -> 200 +receiver: msg_1e4e1568-28a1-498c-9d9f-bb95c83215e9 order.created -> 200 +09:00:01 msg_e202e0a2 attempt 4: 200 (3ms) +09:00:01 msg_1e4e1568 attempt 1: 200 (2ms) +both events delivered +``` + +Run it with `FAIL_FIRST=100 npx tsx client.ts` to watch the endpoint run out of retries after 6 attempts and get disabled. The client then fixes the receiver and calls `enable`, and both events are delivered in order. + + + + + +## Next steps + +- [Handle Incoming Webhooks](/guides/handle-webhooks): the receiving side, with signature checks and deduplication. +- [Failure & Recovery](/workflows/docs/failure-and-recovery#error-hooks): alert your team or email the customer when an endpoint is disabled. +- [Patterns](/workflows/docs/patterns#store-workflow-progress-in-state-broadcast): broadcast each attempt so the portal updates live. + +## Related docs + +- [Steps](/workflows/docs/steps#handling-terminal-failures-as-data): how `tryStep` returns failures as data. +- [Timers & Concurrency](/workflows/docs/timers-and-concurrency#timeouts): step timeouts, retries, and backoff. +- [Queues & Messaging](/workflows/docs/queues#signal-driven-control-loop): waiting on a queue for a control message, like `enable`. +- [Reference](/workflows/docs/reference#recommendations): recommendations for replay-safe workflows. diff --git a/docs/sidebar.json b/docs/sidebar.json index 79d2010..faca630 100644 --- a/docs/sidebar.json +++ b/docs/sidebar.json @@ -53,5 +53,28 @@ } ] } + ], + "guides": [ + { + "title": "Workflows", + "pages": [ + { + "title": "Multi-Step AI Workflows", + "href": "/guides/ai-workflows" + }, + { + "title": "Human-in-the-Loop Approvals", + "href": "/guides/human-in-the-loop" + }, + { + "title": "Handle Incoming Webhooks", + "href": "/guides/handle-webhooks" + }, + { + "title": "Reliable Webhook Delivery", + "href": "/guides/webhook-delivery" + } + ] + } ] } diff --git a/examples/docs/guides/ai-workflows/ai-job.ts b/examples/docs/guides/ai-workflows/ai-job.ts new file mode 100644 index 0000000..ac214bb --- /dev/null +++ b/examples/docs/guides/ai-workflows/ai-job.ts @@ -0,0 +1,155 @@ +import { + event, + queue, + type WorkflowStepContextOf, + workflow, +} from "@rivet-dev/workflows"; +import { callModel } from "./model"; +import { type Company, lookupCompany, publishPost } from "./tools"; + +export type JobInput = { title: string; transcript: string }; +type Extracted = { companies: string[]; actionItems: string[] }; +type Stage = + | "queued" + | "extracting" + | "enriching" + | "summarizing" + | "publishing" + | "published" + | "failed"; +export type Progress = { stage: Stage; detail: string | null }; + +// docs:start retries +// Model calls get a long timeout and slow backoff, because rate limits clear in seconds to minutes. +const MODEL_STEP = { + timeout: 120_000, + retryOnTimeout: true, + maxRetries: 6, + retryBackoffBase: 2_000, + retryBackoffMax: 60_000, +}; +// docs:end retries + +// One workflow per job, keyed by the job id. +export const aiJob = workflow( + { + state: { + stage: "queued" as Stage, + extracted: null as Extracted | null, + companies: [] as Company[], + summary: null as string | null, + url: null as string | null, + }, + queues: { + start: queue(), + }, + events: { + progress: event(), + }, + run: async (ctx) => { + const jobId = ctx.key[0]; + const { body: input } = await ctx.queue.next("wait-start", { + names: ["start"], + }); + + // docs:start extract + const extracted = await ctx.step({ + name: "extract", + ...MODEL_STEP, + run: async (step) => { + setStage(step, "extracting"); + const text = await callModel({ + system: "You read meeting transcripts and reply with JSON only.", + prompt: `Extract the companies mentioned and the action items. Reply as {"companies": string[], "actionItems": string[]}.\n\n${input.transcript}`, + json: true, + }); + // Bad JSON throws, so the step asks the model again. + const result = JSON.parse(text) as Extracted; + step.state.extracted = result; + return result; + }, + }); + // docs:end extract + + // docs:start enrich + // One step per company, so a failed lookup retries alone and finished lookups are not repeated. + const companies: Company[] = []; + for (const [index, name] of extracted.companies.entries()) { + const company = await ctx.step({ + name: `enrich-${index}`, + maxRetries: 5, + run: async (step) => { + setStage(step, "enriching", name); + const found = await lookupCompany(name); + step.state.companies.push(found); + return found; + }, + }); + companies.push(company); + } + // docs:end enrich + + // docs:start summarize + const summary = await ctx.step({ + name: "summarize", + ...MODEL_STEP, + run: async (step) => { + setStage(step, "summarizing"); + const text = await callModel({ + system: "You write short meeting briefs for a sales team.", + prompt: `Write a brief for "${input.title}".\n\nCRM data: ${JSON.stringify(companies)}\nAction items: ${extracted.actionItems.join("; ")}\n\nTranscript:\n${input.transcript}`, + }); + step.state.summary = text; + return text; + }, + }); + // docs:end summarize + + // docs:start publish + await ctx.step({ + name: "publish", + maxRetries: 5, + run: async (step) => { + setStage(step, "publishing"); + const url = await publishPost({ + jobId, + title: input.title, + body: summary, + }); + step.state.url = url; + setStage(step, "published", url); + }, + }); + // docs:end publish + }, + actions: { + getStatus: (c) => c.state, + }, + }, + { + // docs:start on-error + // Tell the app about retries and failures. This hook only observes. It does not change the run. + onError: (c, event) => { + if (!("step" in event)) return; + const { stepName, willRetry, retryDelay, error } = event.step; + if (!willRetry) c.state.stage = "failed"; + const detail = willRetry + ? `${stepName} failed (${error.message}), retrying in ${retryDelay}ms` + : `${stepName} failed: ${error.message}`; + c.broadcast("progress", { stage: c.state.stage, detail }); + }, + // docs:end on-error + }, +); + +// docs:start progress +// Saves the stage in state, so a client that connects later can read it, and pushes it to connected clients. +function setStage( + step: WorkflowStepContextOf, + stage: Stage, + detail: string | null = null, +) { + step.state.stage = stage; + step.broadcast("progress", { stage, detail }); +} +// docs:end progress diff --git a/examples/docs/guides/ai-workflows/client.ts b/examples/docs/guides/ai-workflows/client.ts new file mode 100644 index 0000000..9ee6b78 --- /dev/null +++ b/examples/docs/guides/ai-workflows/client.ts @@ -0,0 +1,33 @@ +import { createClient } from "rivetkit/client"; +import type { Progress } from "./ai-job"; +import type { registry } from "./server"; + +const serverUrl = process.env.SERVER_URL ?? "http://localhost:3000"; +const client = createClient(); + +// 1. Submit a job to the HTTP server. +const res = await fetch(`${serverUrl}/jobs`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + title: "Pricing call with Acme and Globex", + transcript: + "Dana (Acme Corp): We need pricing for 1,200 seats. Lee (Globex): We are evaluating too. Sam: I will send pricing to Acme by Friday.", + }), +}); +const { jobId } = (await res.json()) as { jobId: string }; +console.log("job", jobId); + +// 2. Follow the job's progress until it finishes. +const job = client.aiJob.get([jobId]); +const conn = job.connect(); +await new Promise((resolve) => { + conn.on("progress", (event) => { + const progress = event as Progress; + console.log(progress.stage, progress.detail ?? ""); + if (progress.stage === "published" || progress.stage === "failed") + resolve(); + }); +}); +console.log(await job.getStatus()); +await conn.dispose(); diff --git a/examples/docs/guides/ai-workflows/model.ts b/examples/docs/guides/ai-workflows/model.ts new file mode 100644 index 0000000..2d586a8 --- /dev/null +++ b/examples/docs/guides/ai-workflows/model.ts @@ -0,0 +1,43 @@ +import { CriticalError } from "@rivet-dev/workflows"; + +// docs:start model +// The only function that talks to a model. Swap the body to use another provider or SDK. +export async function callModel(opts: { + system: string; + prompt: string; + json?: boolean; +}): Promise { + const baseUrl = process.env.OPENAI_BASE_URL ?? "https://api.openai.com/v1"; + const res = await fetch(`${baseUrl}/chat/completions`, { + method: "POST", + headers: { + authorization: `Bearer ${process.env.OPENAI_API_KEY}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + model: process.env.OPENAI_MODEL ?? "gpt-4.1-mini", + messages: [ + { role: "system", content: opts.system }, + { role: "user", content: opts.prompt }, + ], + response_format: opts.json ? { type: "json_object" } : undefined, + }), + }); + + // Rate limits and server errors throw a plain Error, so the step retries with backoff. + if (res.status === 429 || res.status >= 500) { + throw new Error(`model returned ${res.status}`); + } + // Other errors, such as a bad API key, will not fix themselves, so the step fails without retrying. + if (!res.ok) { + throw new CriticalError( + `model returned ${res.status}: ${await res.text()}`, + ); + } + + const body = (await res.json()) as { + choices: Array<{ message: { content: string } }>; + }; + return body.choices[0].message.content; +} +// docs:end model diff --git a/examples/docs/guides/ai-workflows/server.ts b/examples/docs/guides/ai-workflows/server.ts new file mode 100644 index 0000000..ae4c238 --- /dev/null +++ b/examples/docs/guides/ai-workflows/server.ts @@ -0,0 +1,28 @@ +import { randomUUID } from "node:crypto"; +import { serve } from "@hono/node-server"; +import { setup } from "@rivet-dev/workflows"; +import { Hono } from "hono"; +import { createClient } from "rivetkit/client"; +import { aiJob, type JobInput } from "./ai-job"; + +export const registry = setup({ use: { aiJob } }); +const client = createClient(); + +registry.start(); + +const app = new Hono(); + +// docs:start jobs +// Starts a job and returns its id without waiting for it to finish. +app.post("/jobs", async (c) => { + const input = await c.req.json(); + if (!input.title || !input.transcript) + return c.text("title and transcript are required", 400); + + const jobId = randomUUID(); + await client.aiJob.getOrCreate([jobId]).send("start", input); + return c.json({ jobId }, 202); +}); +// docs:end jobs + +serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) }); diff --git a/examples/docs/guides/ai-workflows/tools.ts b/examples/docs/guides/ai-workflows/tools.ts new file mode 100644 index 0000000..51646dd --- /dev/null +++ b/examples/docs/guides/ai-workflows/tools.ts @@ -0,0 +1,32 @@ +// Calls to your own APIs. Each one runs inside a workflow step, so a failure retries only that call. + +export type Company = { name: string; plan: string; seats: number }; + +// Looks up a company in your CRM. +export async function lookupCompany(name: string): Promise { + const crmUrl = process.env.CRM_API_URL ?? "https://crm.example.com"; + const res = await fetch( + `${crmUrl}/companies?name=${encodeURIComponent(name)}`, + ); + if (!res.ok) throw new Error(`CRM lookup failed with status ${res.status}`); + return (await res.json()) as Company; +} + +// Publishes a post to your CMS. The job id is the idempotency key, so a retry never publishes twice. +export async function publishPost(post: { + jobId: string; + title: string; + body: string; +}): Promise { + const cmsUrl = process.env.CMS_API_URL ?? "https://cms.example.com"; + const res = await fetch(`${cmsUrl}/posts`, { + method: "POST", + headers: { + "content-type": "application/json", + "idempotency-key": post.jobId, + }, + body: JSON.stringify({ title: post.title, body: post.body }), + }); + if (!res.ok) throw new Error(`CMS publish failed with status ${res.status}`); + return ((await res.json()) as { url: string }).url; +} diff --git a/examples/docs/guides/handle-webhooks/customer-events.ts b/examples/docs/guides/handle-webhooks/customer-events.ts new file mode 100644 index 0000000..002826a --- /dev/null +++ b/examples/docs/guides/handle-webhooks/customer-events.ts @@ -0,0 +1,77 @@ +import { queue, workflow } from "@rivet-dev/workflows"; +import { applyEvent } from "./db"; +import type { StripeEvent } from "./stripe"; + +// How long to remember an event id. Stripe retries a failed delivery for +// up to three days. +const SEEN_FOR_MS = 7 * 24 * 60 * 60 * 1000; + +// One workflow per customer, keyed by the Stripe customer id. +export const customerEvents = workflow({ + state: { + // Event id to the time it was first received. + seen: {} as Record, + applied: 0, + duplicates: 0, + failed: [] as { eventId: string; type: string; error: string }[], + }, + queues: { + events: queue(), + }, + run: async (ctx) => { + await ctx.loop("event-loop", async (loopCtx) => { + // docs:start dedupe + // Events come off the queue one at a time, in the order they arrived. + const { body: event } = await loopCtx.queue.next("wait-event", { + names: ["events"], + }); + + const isNew = await loopCtx.step("record-event-id", async (step) => { + const now = Date.now(); + for (const [id, seenAt] of Object.entries(step.state.seen)) { + if (now - seenAt > SEEN_FOR_MS) delete step.state.seen[id]; + } + if (step.state.seen[event.id] !== undefined) { + step.state.duplicates += 1; + return false; + } + step.state.seen[event.id] = now; + return true; + }); + if (!isNew) return; + // docs:end dedupe + + // docs:start apply + // Retries with backoff while your database is down: 1s, 2s, 4s, and so + // on, up to 1 minute apart. + const result = await loopCtx.tryStep({ + name: "apply-event", + maxRetries: 10, + retryBackoffBase: 500, + retryBackoffMax: 60_000, + run: async () => await applyEvent(event), + }); + + await loopCtx.step("record-result", async (step) => { + if (result.ok) { + step.state.applied += 1; + return; + } + // Park the event so it does not block this customer's later events. + step.state.failed.push({ + eventId: event.id, + type: event.type, + error: result.failure.error.message, + }); + }); + // docs:end apply + }); + }, + actions: { + getStatus: (c) => ({ + applied: c.state.applied, + duplicates: c.state.duplicates, + failed: c.state.failed, + }), + }, +}); diff --git a/examples/docs/guides/handle-webhooks/db.ts b/examples/docs/guides/handle-webhooks/db.ts new file mode 100644 index 0000000..0b65766 --- /dev/null +++ b/examples/docs/guides/handle-webhooks/db.ts @@ -0,0 +1,32 @@ +import type { StripeEvent } from "./stripe"; + +// A stand-in for your database so the example runs on its own. +// Replace it with writes to your own database. +const subscriptions = new Map< + string, + { customerId: string; status: string; updatedBy: string } +>(); + +// docs:start db +export async function applyEvent(event: StripeEvent): Promise { + const object = event.data.object; + switch (event.type) { + case "customer.subscription.created": + case "customer.subscription.updated": + case "customer.subscription.deleted": + // An upsert keyed by the subscription id. Writing the same event twice + // leaves the same row. + subscriptions.set(object.id, { + customerId: String(object.customer), + status: String(object.status), + updatedBy: event.id, + }); + console.log( + `applied ${event.id} (${event.type}): ${object.id} is ${object.status}`, + ); + return; + default: + console.log(`ignored ${event.id} (${event.type})`); + } +} +// docs:end db diff --git a/examples/docs/guides/handle-webhooks/send-test-event.ts b/examples/docs/guides/handle-webhooks/send-test-event.ts new file mode 100644 index 0000000..84820da --- /dev/null +++ b/examples/docs/guides/handle-webhooks/send-test-event.ts @@ -0,0 +1,47 @@ +import { createClient } from "rivetkit/client"; +import type { registry } from "./server"; +import { type StripeEvent, signStripePayload } from "./stripe"; + +const secret = process.env.STRIPE_WEBHOOK_SECRET; +if (!secret) { + throw new Error("Set STRIPE_WEBHOOK_SECRET to the server's value."); +} +const url = process.env.WEBHOOK_URL ?? "http://localhost:3000/webhooks"; + +const event: StripeEvent = { + id: `evt_test_${Date.now()}`, + type: "customer.subscription.updated", + created: Math.floor(Date.now() / 1000), + data: { object: { id: "sub_123", customer: "cus_123", status: "active" } }, +}; +const body = JSON.stringify(event); + +// Post the same signed event twice, the way a provider redelivers an event +// when it does not get your answer. +for (const label of ["delivery", "redelivery"]) { + const res = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + "stripe-signature": signStripePayload(body, secret), + }, + body, + }); + console.log(`${label}: ${res.status} ${await res.text()}`); +} + +// A request with a bad signature is rejected before it reaches the workflow. +const forged = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/json", + "stripe-signature": "t=0,v1=00", + }, + body, +}); +console.log(`forged: ${forged.status} ${await forged.text()}`); + +// Give the workflow a moment to drain its queue, then read its counts. +await new Promise((resolve) => setTimeout(resolve, 1000)); +const client = createClient(); +console.log(await client.customerEvents.getOrCreate(["cus_123"]).getStatus()); diff --git a/examples/docs/guides/handle-webhooks/server.ts b/examples/docs/guides/handle-webhooks/server.ts new file mode 100644 index 0000000..f3bd573 --- /dev/null +++ b/examples/docs/guides/handle-webhooks/server.ts @@ -0,0 +1,40 @@ +import { serve } from "@hono/node-server"; +import { setup } from "@rivet-dev/workflows"; +import { Hono } from "hono"; +import { createClient } from "rivetkit/client"; +import { customerEvents } from "./customer-events"; +import { type StripeEvent, verifyStripeSignature } from "./stripe"; + +const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET; +if (!webhookSecret) { + throw new Error("Set STRIPE_WEBHOOK_SECRET to your signing secret."); +} + +export const registry = setup({ use: { customerEvents } }); +const client = createClient(); + +registry.start(); + +const app = new Hono(); + +// docs:start webhooks +app.post("/webhooks", async (c) => { + // Check the signature against the raw body. Parsed and re-encoded JSON + // would not match the bytes the provider signed. + const rawBody = await c.req.text(); + const signature = c.req.header("stripe-signature"); + if (!verifyStripeSignature(rawBody, signature, webhookSecret)) { + return c.text("invalid signature", 400); + } + + const event = JSON.parse(rawBody) as StripeEvent; + const customerId = event.data.object.customer ?? event.data.object.id; + + // `send` returns once the event is stored in the customer's queue, + // before the workflow handles it. + await client.customerEvents.getOrCreate([customerId]).send("events", event); + return c.json({ received: true }); +}); +// docs:end webhooks + +serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) }); diff --git a/examples/docs/guides/handle-webhooks/stripe.ts b/examples/docs/guides/handle-webhooks/stripe.ts new file mode 100644 index 0000000..07d4b40 --- /dev/null +++ b/examples/docs/guides/handle-webhooks/stripe.ts @@ -0,0 +1,56 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +// The parts of a Stripe event this guide reads. +export type StripeEvent = { + id: string; + type: string; + created: number; + data: { + object: { + id: string; + customer?: string | null; + [field: string]: unknown; + }; + }; +}; + +// Reject requests signed longer ago than this, so a captured request +// cannot be replayed later. +const TOLERANCE_SECONDS = 5 * 60; + +// docs:start verify +// Checks the `Stripe-Signature` header, which looks like `t=1700000000,v1=`. +export function verifyStripeSignature( + rawBody: string, + header: string | undefined, + secret: string, +): boolean { + if (!header) return false; + const pairs = header.split(",").map((part) => part.split("=", 2)); + const timestamp = Number(pairs.find(([key]) => key === "t")?.[1]); + const signatures = pairs + .filter(([key]) => key === "v1") + .map(([, value]) => value); + if (!Number.isFinite(timestamp) || signatures.length === 0) return false; + if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false; + + // Stripe signs `.` with HMAC-SHA256, keyed by the + // endpoint's signing secret. + const expected = createHmac("sha256", secret) + .update(`${timestamp}.${rawBody}`) + .digest(); + return signatures.some((signature) => { + const given = Buffer.from(signature, "hex"); + return given.length === expected.length && timingSafeEqual(given, expected); + }); +} +// docs:end verify + +// Signs a payload the way Stripe does, for `send-test-event.ts`. +export function signStripePayload(rawBody: string, secret: string): string { + const timestamp = Math.floor(Date.now() / 1000); + const signature = createHmac("sha256", secret) + .update(`${timestamp}.${rawBody}`) + .digest("hex"); + return `t=${timestamp},v1=${signature}`; +} diff --git a/examples/docs/guides/human-in-the-loop/agent-tool.ts b/examples/docs/guides/human-in-the-loop/agent-tool.ts new file mode 100644 index 0000000..88d94d9 --- /dev/null +++ b/examples/docs/guides/human-in-the-loop/agent-tool.ts @@ -0,0 +1,59 @@ +import { randomUUID } from "node:crypto"; +import { createClient } from "rivetkit/client"; +import type { ApprovalRequest, Outcome } from "./approval"; +import type { registry } from "./server"; + +const client = createClient(); + +// docs:start request-approval +// Starts an approval workflow for a new request and resolves when it finishes. +export async function requestApproval( + request: ApprovalRequest, +): Promise { + const handle = client.approval.getOrCreate([randomUUID()]); + const conn = handle.connect(); + try { + const finished = new Promise((resolve) => { + conn.once("finished", (outcome) => resolve(outcome as Outcome)); + }); + await handle.send("request", request); + return await finished; + } finally { + await conn.dispose(); + } +} +// docs:end request-approval + +// docs:start gated-tool +// An agent tool that asks a person before it refunds an order. +export const refundOrderTool = { + name: "refund_order", + description: + "Refund part or all of an order. A person approves every refund before it happens.", + parameters: { + type: "object", + properties: { + orderId: { type: "string" }, + amountCents: { type: "number" }, + reason: { type: "string" }, + }, + required: ["orderId", "amountCents", "reason"], + }, + execute: async (args: { + orderId: string; + amountCents: number; + reason: string; + }) => { + const outcome = await requestApproval({ + tool: "refund_order", + args: { orderId: args.orderId, amountCents: args.amountCents }, + summary: `Refund $${(args.amountCents / 100).toFixed(2)} on order ${args.orderId}. Reason: ${args.reason}`, + requestedBy: process.env.AGENT_SLACK_CHANNEL ?? "C0AGENT", + approver: process.env.APPROVER_SLACK_ID ?? "U0APPROVER", + }); + if (outcome.status === "approved") + return `Refund done: ${JSON.stringify(outcome.result)}`; + return `No refund was made. The request ended as ${outcome.status}.`; + }, +}; +// docs:end gated-tool diff --git a/examples/docs/guides/human-in-the-loop/approval.ts b/examples/docs/guides/human-in-the-loop/approval.ts new file mode 100644 index 0000000..658ee7a --- /dev/null +++ b/examples/docs/guides/human-in-the-loop/approval.ts @@ -0,0 +1,104 @@ +import { event, queue, workflow } from "@rivet-dev/workflows"; +import { approvalBlocks, postMessage } from "./slack"; +import { runTool, type ToolCall } from "./tools"; + +// What is being asked, and who must answer. `requestedBy` and `approver` are Slack user or channel ids. +export type ApprovalRequest = ToolCall & { + summary: string; + requestedBy: string; + approver: string; +}; + +export type Decision = { approved: boolean; decidedBy: string }; + +export type Outcome = { + status: "approved" | "rejected" | "expired" | "failed"; + decidedBy: string | null; + result: unknown; + error: string | null; +}; + +// How long the approver has to answer before the request expires. +const DECISION_TIMEOUT_MS = 3 * 24 * 60 * 60 * 1000; + +// One approval workflow per request, keyed by the request id. +export const approval = workflow({ + state: { + request: null as ApprovalRequest | null, + status: "new" as "new" | "pending" | Outcome["status"], + outcome: null as Outcome | null, + }, + queues: { + request: queue(), + decision: queue(), + }, + events: { + finished: event(), + }, + run: async (ctx) => { + const requestId = ctx.key[0]; + + // docs:start request + const { body: request } = await ctx.queue.next("wait-request", { + names: ["request"], + }); + await ctx.step("record-request", async (step) => { + step.state.request = request; + step.state.status = "pending"; + }); + await ctx.step("notify-approver", async () => { + const text = `Run \`${request.tool}\`? ${request.summary}`; + await postMessage( + request.approver, + `Approval needed for request ${requestId}`, + approvalBlocks(requestId, text), + ); + }); + // docs:end request + + // docs:start wait + // Wait for a decision message, or give up after the timeout. Both are durable. + const [decision] = await ctx.queue.nextBatch("wait-decision", { + names: ["decision"], + timeout: DECISION_TIMEOUT_MS, + }); + // docs:end wait + + // docs:start outcome + // No decision means the timeout fired first. + let outcome: Outcome = { + status: decision ? "rejected" : "expired", + decidedBy: decision?.body.decidedBy ?? null, + result: null, + error: null, + }; + if (decision?.body.approved) { + // The tool runs only here, after a person approved it. + const run = await ctx.tryStep({ + name: "run-tool", + maxRetries: 5, + retryBackoffBase: 1000, + run: async () => await runTool(request, requestId), + }); + outcome = run.ok + ? { ...outcome, status: "approved", result: run.value } + : { ...outcome, status: "failed", error: run.failure.error.message }; + } + + await ctx.step("record-outcome", async (step) => { + step.state.status = outcome.status; + step.state.outcome = outcome; + step.broadcast("finished", outcome); + }); + await ctx.step("notify-requester", async () => { + await postMessage( + request.requestedBy, + `Your request to run \`${request.tool}\` ended as ${outcome.status}.`, + ); + }); + // docs:end outcome + }, + actions: { + getStatus: (c) => c.state, + }, +}); diff --git a/examples/docs/guides/human-in-the-loop/client.ts b/examples/docs/guides/human-in-the-loop/client.ts new file mode 100644 index 0000000..2bda381 --- /dev/null +++ b/examples/docs/guides/human-in-the-loop/client.ts @@ -0,0 +1,9 @@ +import { refundOrderTool } from "./agent-tool"; + +// Call the tool the way an agent would. This waits until someone approves or rejects the refund. +const answer = await refundOrderTool.execute({ + orderId: "order-1042", + amountCents: 2500, + reason: "The package arrived damaged.", +}); +console.log(answer); diff --git a/examples/docs/guides/human-in-the-loop/decide.ts b/examples/docs/guides/human-in-the-loop/decide.ts new file mode 100644 index 0000000..e838ebb --- /dev/null +++ b/examples/docs/guides/human-in-the-loop/decide.ts @@ -0,0 +1,14 @@ +import { createClient } from "rivetkit/client"; +import type { registry } from "./server"; + +// Answer a request without Slack: npx tsx decide.ts approve|reject +const [requestId, answer] = process.argv.slice(2); +const client = createClient(); +const handle = client.approval.get([requestId]); + +const { request } = await handle.getStatus(); +await handle.send("decision", { + approved: answer === "approve", + decidedBy: request?.approver ?? "unknown", +}); +console.log(`sent ${answer} for request ${requestId}`); diff --git a/examples/docs/guides/human-in-the-loop/server.ts b/examples/docs/guides/human-in-the-loop/server.ts new file mode 100644 index 0000000..69b3c95 --- /dev/null +++ b/examples/docs/guides/human-in-the-loop/server.ts @@ -0,0 +1,62 @@ +import { serve } from "@hono/node-server"; +import { setup } from "@rivet-dev/workflows"; +import { Hono } from "hono"; +import { createClient } from "rivetkit/client"; +import { approval } from "./approval"; +import { respond, type SlackButtonClick, verifySlackRequest } from "./slack"; + +export const registry = setup({ use: { approval } }); +const client = createClient(); + +registry.start(); + +const app = new Hono(); + +// docs:start approvals +// Slack calls this when the approver clicks Approve or Reject. +app.post("/approvals", async (c) => { + const rawBody = await c.req.text(); + const signed = verifySlackRequest( + rawBody, + c.req.header("x-slack-request-timestamp"), + c.req.header("x-slack-signature"), + ); + if (!signed) return c.text("invalid signature", 401); + + const click = JSON.parse( + new URLSearchParams(rawBody).get("payload") ?? "{}", + ) as SlackButtonClick; + const [button] = click.actions; + const handle = client.approval.get([button.value]); + + // The pending request is readable while the workflow waits. + const { status, request } = await handle.getStatus(); + if (status !== "pending") { + await respond(click.response_url, { + response_type: "ephemeral", + replace_original: false, + text: `This request is already ${status}.`, + }); + return c.body(null, 200); + } + if (click.user.id !== request?.approver) { + await respond(click.response_url, { + response_type: "ephemeral", + replace_original: false, + text: "Only the assigned approver can answer.", + }); + return c.body(null, 200); + } + + const approved = button.action_id === "approve"; + await handle.send("decision", { approved, decidedBy: click.user.id }); + // Replace the buttons so nobody clicks twice. + await respond(click.response_url, { + replace_original: true, + text: `${approved ? "Approved" : "Rejected"} by <@${click.user.id}>.`, + }); + return c.body(null, 200); +}); +// docs:end approvals + +serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) }); diff --git a/examples/docs/guides/human-in-the-loop/slack.ts b/examples/docs/guides/human-in-the-loop/slack.ts new file mode 100644 index 0000000..4d3bfac --- /dev/null +++ b/examples/docs/guides/human-in-the-loop/slack.ts @@ -0,0 +1,86 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +// Posts a message to a Slack user or channel id. Without SLACK_BOT_TOKEN it only logs the message, for local testing. +export async function postMessage( + channel: string, + text: string, + blocks?: unknown[], +) { + const token = process.env.SLACK_BOT_TOKEN; + if (!token) { + console.log(`slack message to ${channel}: ${text}`); + return; + } + const res = await fetch("https://slack.com/api/chat.postMessage", { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json; charset=utf-8", + }, + body: JSON.stringify({ channel, text, blocks }), + }); + const body = (await res.json()) as { ok: boolean; error?: string }; + if (!body.ok) throw new Error(`slack chat.postMessage failed: ${body.error}`); +} + +// docs:start buttons +// A message with Approve and Reject buttons. Each button carries the request id back to /approvals. +export function approvalBlocks(requestId: string, text: string) { + const button = (actionId: string, label: string, style: string) => ({ + type: "button", + action_id: actionId, + style, + text: { type: "plain_text", text: label }, + value: requestId, + }); + return [ + { type: "section", text: { type: "mrkdwn", text } }, + { + type: "actions", + elements: [ + button("approve", "Approve", "primary"), + button("reject", "Reject", "danger"), + ], + }, + ]; +} +// docs:end buttons + +// The parts of a Slack button click that /approvals reads. +export type SlackButtonClick = { + user: { id: string }; + actions: Array<{ action_id: string; value: string }>; + response_url: string; +}; + +// Replaces or adds to the message that held the clicked button. +export async function respond( + responseUrl: string, + body: Record, +) { + await fetch(responseUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); +} + +// Slack signs "v0::" with your app's signing secret. +export function verifySlackRequest( + rawBody: string, + timestamp: string | undefined, + signature: string | undefined, +) { + if (!timestamp || !signature) return false; + if (Math.abs(Date.now() / 1000 - Number(timestamp)) > 60 * 5) return false; + const expected = `v0=${createHmac( + "sha256", + process.env.SLACK_SIGNING_SECRET ?? "", + ) + .update(`v0:${timestamp}:${rawBody}`) + .digest("hex")}`; + return ( + expected.length === signature.length && + timingSafeEqual(Buffer.from(expected), Buffer.from(signature)) + ); +} diff --git a/examples/docs/guides/human-in-the-loop/tools.ts b/examples/docs/guides/human-in-the-loop/tools.ts new file mode 100644 index 0000000..b08b643 --- /dev/null +++ b/examples/docs/guides/human-in-the-loop/tools.ts @@ -0,0 +1,31 @@ +// Tools that change something important. The approval workflow runs them only after a person approves. +// Each tool gets the approval request id, so your API can ignore a repeated call for the same request. +const tools = { + refund_order: async ( + args: { orderId: string; amountCents: number }, + requestId: string, + ) => { + const apiUrl = process.env.ORDERS_API_URL ?? "https://api.example.com"; + const res = await fetch(`${apiUrl}/orders/${args.orderId}/refunds`, { + method: "POST", + headers: { + "content-type": "application/json", + "idempotency-key": requestId, + }, + body: JSON.stringify({ amountCents: args.amountCents }), + }); + if (!res.ok) throw new Error(`refund failed with status ${res.status}`); + return (await res.json()) as { refundId: string }; + }, +}; + +type Tools = typeof tools; + +// A tool name with matching arguments, such as { tool: "refund_order", args: { orderId, amountCents } }. +export type ToolCall = { + [K in keyof Tools]: { tool: K; args: Parameters[0] }; +}[keyof Tools]; + +export async function runTool(call: ToolCall, requestId: string) { + return await tools[call.tool](call.args, requestId); +} diff --git a/examples/docs/guides/webhook-delivery/client.ts b/examples/docs/guides/webhook-delivery/client.ts new file mode 100644 index 0000000..623bcac --- /dev/null +++ b/examples/docs/guides/webhook-delivery/client.ts @@ -0,0 +1,61 @@ +import type { Attempt } from "./endpoint"; +import { startReceiver } from "./receiver"; + +const api = process.env.API_URL ?? "http://localhost:3000"; +const receiverPort = Number(process.env.RECEIVER_PORT ?? 4000); +// Set FAIL_FIRST=100 to watch the endpoint run out of retries and get disabled. +const failFirst = Number(process.env.FAIL_FIRST ?? 3); + +async function call(path: string, body?: unknown): Promise { + const res = await fetch(`${api}${path}`, { + method: body === undefined ? "GET" : "POST", + headers: { "content-type": "application/json" }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return (await res.json()) as T; +} + +// Add an endpoint the way a customer would in your portal, then start a +// receiver that checks signatures with its secret. +const { id, secret } = await call<{ id: string; secret: string }>( + "/endpoints", + { + url: `http://localhost:${receiverPort}/webhooks`, + eventTypes: ["order.created"], + }, +); +const receiver = startReceiver({ port: receiverPort, secret, failFirst }); +console.log(`endpoint ${id}`); + +// Emit two events. The second one waits until the first is delivered. +await call("/events", { type: "order.created", data: { orderId: "order_1" } }); +await call("/events", { type: "order.created", data: { orderId: "order_2" } }); + +// Print the delivery log as a portal would, until both events are delivered. +let printed = 0; +for (;;) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + const log = await call<{ + disabled: boolean; + delivered: number; + attempts: Attempt[]; + }>(`/endpoints/${id}/attempts`); + for (const a of log.attempts.toReversed().slice(printed)) { + const time = new Date(a.at).toISOString().slice(11, 19); + console.log( + `${time} ${a.eventId.slice(0, 12)} attempt ${a.attempt}: ${a.status ?? a.error} (${a.durationMs}ms)`, + ); + } + printed = log.attempts.length; + if (log.delivered === 2) break; + if (log.disabled) { + console.log( + "endpoint disabled. Fixing the receiver and turning the endpoint back on.", + ); + receiver.recover(); + await call(`/endpoints/${id}/enable`, {}); + } +} + +console.log("both events delivered"); +receiver.close(); diff --git a/examples/docs/guides/webhook-delivery/endpoint.ts b/examples/docs/guides/webhook-delivery/endpoint.ts new file mode 100644 index 0000000..44ed9a6 --- /dev/null +++ b/examples/docs/guides/webhook-delivery/endpoint.ts @@ -0,0 +1,156 @@ +import { Loop, queue, workflow } from "@rivet-dev/workflows"; +import { signatureHeaders } from "./sign"; + +export type WebhookEvent = { + id: string; + type: string; + data: unknown; + createdAt: number; +}; + +export type Attempt = { + eventId: string; + eventType: string; + attempt: number; + status: number | null; + error: string | null; + durationMs: number; + at: number; +}; + +// A failed delivery is retried 5 more times, 2s, 4s, 8s, 16s, and 32s apart, +// before the endpoint is disabled. Raise these numbers for production. +const MAX_RETRIES = 5; +const RETRY_BACKOFF_BASE_MS = 1000; +const RETRY_BACKOFF_MAX_MS = 60 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 10_000; +// How many attempts the delivery log keeps. +const LOG_SIZE = 100; + +// One workflow per customer endpoint, keyed by the endpoint id. +export const endpoint = workflow({ + state: { + url: "", + secret: "", + disabled: false, + // The id of the event being delivered, if any. + pending: null as string | null, + delivered: 0, + attempts: [] as Attempt[], + }, + // The backend passes the URL and signing secret when it creates the endpoint. + onCreate: (c, input: { url: string; secret: string }) => { + c.state.url = input.url; + c.state.secret = input.secret; + }, + queues: { + events: queue(), + enable: queue>(), + }, + run: async (ctx) => { + await ctx.loop("delivery-loop", async (loopCtx) => { + // docs:start outbox + // The queue is the outbox. Events come off it one at a time, in order. + const { body: event } = await loopCtx.queue.next("wait-event", { + names: ["events"], + }); + await loopCtx.step("mark-pending", async (step) => { + step.state.pending = event.id; + }); + + // Keep at this event until it is delivered. Later events wait behind it. + await loopCtx.loop("deliver-event", async (eventCtx) => { + // docs:end outbox + // docs:start deliver + const delivery = await eventCtx.tryStep({ + name: "deliver", + maxRetries: MAX_RETRIES, + retryBackoffBase: RETRY_BACKOFF_BASE_MS, + retryBackoffMax: RETRY_BACKOFF_MAX_MS, + timeout: REQUEST_TIMEOUT_MS + 5_000, + retryOnTimeout: true, + run: async (step) => { + const body = JSON.stringify(event); + const startedAt = Date.now(); + let status: number | null = null; + let error: string | null = null; + try { + const res = await fetch(step.state.url, { + method: "POST", + headers: { + "content-type": "application/json", + ...signatureHeaders(step.state.secret, event.id, body), + }, + body, + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + status = res.status; + } catch (err) { + error = err instanceof Error ? err.message : String(err); + } + + // Record the attempt before throwing. A failed step keeps its + // state changes, so every attempt is logged. + const log = step.state.attempts; + log.push({ + eventId: event.id, + eventType: event.type, + attempt: log.filter((a) => a.eventId === event.id).length + 1, + status, + error, + durationMs: Date.now() - startedAt, + at: startedAt, + }); + if (log.length > LOG_SIZE) log.splice(0, log.length - LOG_SIZE); + + // Any 2xx answer counts as delivered. Anything else is retried. + if (status === null || status < 200 || status >= 300) { + throw new Error( + `delivery failed: ${error ?? `status ${status}`}`, + ); + } + }, + }); + // docs:end deliver + + // docs:start disable + if (delivery.ok) { + await eventCtx.step("record-delivery", async (step) => { + step.state.pending = null; + step.state.delivered += 1; + }); + return Loop.break(undefined); + } + + // Out of retries. Stop sending until the endpoint is turned back on. + await eventCtx.step("disable", async (step) => { + step.state.disabled = true; + step.log.warn({ msg: "endpoint disabled", eventId: event.id }); + }); + await eventCtx.queue.nextBatch("wait-enable", { + names: ["enable"], + count: 10, + }); + await eventCtx.step("enable", async (step) => { + step.state.disabled = false; + }); + // The loop runs again and retries the same event. + // docs:end disable + }); + }); + }, + // docs:start actions + actions: { + listAttempts: (c) => ({ + url: c.state.url, + disabled: c.state.disabled, + delivered: c.state.delivered, + pending: c.state.pending, + attempts: c.state.attempts.toReversed(), + }), + enable: async (c) => { + if (c.state.disabled) await c.queue.send("enable", {}); + }, + }, + // docs:end actions +}); diff --git a/examples/docs/guides/webhook-delivery/receiver.ts b/examples/docs/guides/webhook-delivery/receiver.ts new file mode 100644 index 0000000..d1b38cd --- /dev/null +++ b/examples/docs/guides/webhook-delivery/receiver.ts @@ -0,0 +1,45 @@ +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { verifyWebhook } from "./sign"; + +// A test endpoint that plays the customer. It answers 503 to the first +// `failFirst` requests, then 200. +export function startReceiver(opts: { + port: number; + secret: string; + failFirst: number; +}) { + let remainingFailures = opts.failFirst; + const seen = new Set(); + const app = new Hono(); + + app.post("/webhooks", async (c) => { + const body = await c.req.text(); + if (!verifyWebhook(opts.secret, c.req.raw.headers, body)) + return c.text("bad signature", 401); + + const id = c.req.header("webhook-id") ?? ""; + const { type } = JSON.parse(body) as { type: string }; + if (remainingFailures > 0) { + remainingFailures -= 1; + console.log(`receiver: ${id} ${type} -> 503`); + return c.text("unavailable", 503); + } + // Skip ids already handled, since a delivery can arrive more than once. + const duplicate = seen.has(id); + seen.add(id); + console.log( + `receiver: ${id} ${type} -> 200${duplicate ? " (duplicate, skipped)" : ""}`, + ); + return c.text("ok"); + }); + + const server = serve({ fetch: app.fetch, port: opts.port }); + return { + // Makes every later request succeed. + recover: () => { + remainingFailures = 0; + }, + close: () => server.close(), + }; +} diff --git a/examples/docs/guides/webhook-delivery/server.ts b/examples/docs/guides/webhook-delivery/server.ts new file mode 100644 index 0000000..e49cfc3 --- /dev/null +++ b/examples/docs/guides/webhook-delivery/server.ts @@ -0,0 +1,71 @@ +import { randomUUID } from "node:crypto"; +import { serve } from "@hono/node-server"; +import { setup } from "@rivet-dev/workflows"; +import { Hono } from "hono"; +import { createClient } from "rivetkit/client"; +import { endpoint, type WebhookEvent } from "./endpoint"; +import { createSecret } from "./sign"; + +export const registry = setup({ use: { endpoint } }); +const client = createClient(); + +registry.start(); + +// Which endpoints get which event types. Keep this in your database. +const subscriptions = new Map>(); + +// docs:start emit +// Call this from your app whenever something happens that customers can subscribe to. +export async function emit(type: string, data: unknown): Promise { + const event: WebhookEvent = { + id: `msg_${randomUUID()}`, + type, + data, + createdAt: Date.now(), + }; + for (const endpointId of subscriptions.get(type) ?? []) { + await client.endpoint.get([endpointId]).send("events", event); + } + return event; +} +// docs:end emit + +const app = new Hono(); + +// docs:start register +// Your customer portal calls this when a customer adds an endpoint. +app.post("/endpoints", async (c) => { + const { url, eventTypes } = await c.req.json<{ + url: string; + eventTypes: string[]; + }>(); + const id = `ep_${randomUUID()}`; + const secret = createSecret(); + await client.endpoint.create([id], { input: { url, secret } }); + for (const type of eventTypes) { + if (!subscriptions.has(type)) subscriptions.set(type, new Set()); + subscriptions.get(type)?.add(id); + } + // Show the secret to the customer once so they can check signatures. + return c.json({ id, secret }); +}); +// docs:end register + +// docs:start portal +app.get("/endpoints/:id/attempts", async (c) => { + return c.json(await client.endpoint.get([c.req.param("id")]).listAttempts()); +}); + +app.post("/endpoints/:id/enable", async (c) => { + await client.endpoint.get([c.req.param("id")]).enable(); + return c.json({ enabled: true }); +}); +// docs:end portal + +// Stands in for your app code, so you can emit test events over HTTP. +app.post("/events", async (c) => { + const { type, data } = await c.req.json<{ type: string; data: unknown }>(); + return c.json(await emit(type, data)); +}); + +serve({ fetch: app.fetch, port: Number(process.env.PORT ?? 3000) }); diff --git a/examples/docs/guides/webhook-delivery/sign.ts b/examples/docs/guides/webhook-delivery/sign.ts new file mode 100644 index 0000000..70e4124 --- /dev/null +++ b/examples/docs/guides/webhook-delivery/sign.ts @@ -0,0 +1,64 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; + +// Signatures follow the Standard Webhooks spec (standardwebhooks.com), so +// customers can check them with any Standard Webhooks library. A secret is +// `whsec_` followed by the base64 key bytes. +export function createSecret(): string { + return `whsec_${randomBytes(24).toString("base64")}`; +} + +function sign( + secret: string, + id: string, + timestamp: number, + body: string, +): string { + const key = Buffer.from(secret.slice("whsec_".length), "base64"); + return createHmac("sha256", key) + .update(`${id}.${timestamp}.${body}`) + .digest("base64"); +} + +// docs:start sign +export function signatureHeaders( + secret: string, + id: string, + body: string, +): Record { + const timestamp = Math.floor(Date.now() / 1000); + return { + "webhook-id": id, + "webhook-timestamp": String(timestamp), + "webhook-signature": `v1,${sign(secret, id, timestamp, body)}`, + }; +} +// docs:end sign + +// Reject requests signed longer ago than this, so a captured request +// cannot be replayed later. +const TOLERANCE_SECONDS = 5 * 60; + +// docs:start verify +// What your customers run on their side to check that a webhook came from you. +export function verifyWebhook( + secret: string, + headers: Headers, + body: string, +): boolean { + const id = headers.get("webhook-id"); + const timestamp = Number(headers.get("webhook-timestamp")); + const header = headers.get("webhook-signature"); + if (!id || !header || !Number.isFinite(timestamp)) return false; + if (Math.abs(Date.now() / 1000 - timestamp) > TOLERANCE_SECONDS) return false; + + const expected = Buffer.from(sign(secret, id, timestamp, body), "base64"); + // The header holds several space-separated signatures while a secret is + // being rotated. + return header.split(" ").some((entry) => { + const [version, signature] = entry.split(",", 2); + if (version !== "v1" || !signature) return false; + const given = Buffer.from(signature, "base64"); + return given.length === expected.length && timingSafeEqual(given, expected); + }); +} +// docs:end verify diff --git a/examples/docs/package.json b/examples/docs/package.json index 8ad2611..56be19c 100644 --- a/examples/docs/package.json +++ b/examples/docs/package.json @@ -6,7 +6,9 @@ "check-types": "tsc --noEmit" }, "dependencies": { + "@hono/node-server": "^2.1.5", "@rivet-dev/workflows": "workspace:*", + "hono": "^4.13.4", "rivetkit": "2.3.11" }, "devDependencies": { diff --git a/examples/docs/tsconfig.json b/examples/docs/tsconfig.json index 52656dd..c7b4b93 100644 --- a/examples/docs/tsconfig.json +++ b/examples/docs/tsconfig.json @@ -8,5 +8,5 @@ }, "types": ["node"] }, - "include": ["actors-workflows/**/*.ts"] + "include": ["actors-workflows/**/*.ts", "guides/**/*.ts"] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f7255c4..620e46f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,9 +17,15 @@ importers: examples/docs: dependencies: + '@hono/node-server': + specifier: ^2.1.5 + version: 2.1.5(hono@4.13.4) '@rivet-dev/workflows': specifier: workspace:* version: link:../../packages/workflows + hono: + specifier: ^4.13.4 + version: 4.13.4 rivetkit: specifier: 2.3.11 version: 2.3.11(better-sqlite3@12.11.1) @@ -493,6 +499,12 @@ packages: cpu: [x64] os: [win32] + '@hono/node-server@2.1.5': + resolution: {integrity: sha512-MgP/I06GdvsilDspMk/qmRzpk8LOUStP6jFAbSRi40W09DhoyUn+Mj7eyKICPqeYKybOjVFLVIAQzvu1bfIa7Q==} + engines: {node: '>=20'} + peerDependencies: + hono: ^4 || ^5.0.0-0 + '@hono/zod-openapi@1.6.0': resolution: {integrity: sha512-42HXUBIaGmQh+hZGLw3Hy5piOreIXgnBnUSs55mtn2gnW/xWtU9nQwEUtRRCk5to8Vm2XbfO0J83eUCwCf5eTg==} engines: {node: '>=16.0.0'} @@ -1406,10 +1418,6 @@ packages: hmac-drbg@1.0.1: resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} - hono@4.13.3: - resolution: {integrity: sha512-r8AO2mYHoLxSHkgafNeC/BXyb2vWRxD3jem4Ts+ptav8oTG5FIRifAjuJEmZI4bSvvc2ns0GxmIYiZnHqN3mMw==} - engines: {node: '>=16.9.0'} - hono@4.13.4: resolution: {integrity: sha512-AGEwKIyRMHRv1t8Wjwa3LHxQ61X5CqrdFT+4BRNTpqS5aJNnpl5WLjADb7vFlJzI/8uK7T5QLVApCMQKNa3LgQ==} engines: {node: '>=16.9.0'} @@ -2361,11 +2369,15 @@ snapshots: '@esbuild/win32-x64@0.28.2': optional: true - '@hono/zod-openapi@1.6.0(hono@4.13.3)(zod@4.4.3)': + '@hono/node-server@2.1.5(hono@4.13.4)': + dependencies: + hono: 4.13.4 + + '@hono/zod-openapi@1.6.0(hono@4.13.4)(zod@4.4.3)': dependencies: '@asteasolutions/zod-to-openapi': 9.1.0(zod@4.4.3) - '@hono/zod-validator': 0.9.0(hono@4.13.3)(zod@4.4.3) - hono: 4.13.3 + '@hono/zod-validator': 0.9.0(hono@4.13.4)(zod@4.4.3) + hono: 4.13.4 openapi3-ts: 4.6.1 zod: 4.4.3 @@ -2377,11 +2389,6 @@ snapshots: openapi3-ts: 4.6.1 zod: 4.4.3 - '@hono/zod-validator@0.9.0(hono@4.13.3)(zod@4.4.3)': - dependencies: - hono: 4.13.3 - zod: 4.4.3 - '@hono/zod-validator@0.9.0(hono@4.13.4)(zod@4.4.3)': dependencies: hono: 4.13.4 @@ -3225,8 +3232,6 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 - hono@4.13.3: {} - hono@4.13.4: {} https-browserify@1.0.0: {} @@ -3674,7 +3679,7 @@ snapshots: rivetkit@2.3.7(better-sqlite3@12.11.1): dependencies: - '@hono/zod-openapi': 1.6.0(hono@4.13.3)(zod@4.4.3) + '@hono/zod-openapi': 1.6.0(hono@4.13.4)(zod@4.4.3) '@rivet-dev/agent-os-core': 0.1.1 '@rivetkit/bare-ts': 0.6.2 '@rivetkit/engine-cli': 2.3.7 @@ -3687,7 +3692,7 @@ snapshots: '@rivetkit/workflow-engine': 2.3.7 cbor-x: 1.6.5 drizzle-orm: 0.44.7(better-sqlite3@12.11.1) - hono: 4.13.3 + hono: 4.13.4 invariant: 2.2.4 p-retry: 6.2.1 pino: 9.14.0