diff --git a/plugins/temporal-developer/skills/temporal-developer/SKILL.md b/plugins/temporal-developer/skills/temporal-developer/SKILL.md index 6194c90..471c806 100644 --- a/plugins/temporal-developer/skills/temporal-developer/SKILL.md +++ b/plugins/temporal-developer/skills/temporal-developer/SKILL.md @@ -1,14 +1,14 @@ --- name: temporal-developer -description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java and .NET. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. -version: 0.4.0 +description: Develop, debug, and manage Temporal applications across Python, TypeScript, Go, Java, .NET and Ruby. Use when the user is building workflows, activities, or workers with a Temporal SDK, debugging issues like non-determinism errors, stuck workflows, or activity retries, using Temporal CLI, Temporal Server, or Temporal Cloud, or working with durable execution concepts like signals, queries, heartbeats, versioning, continue-as-new, child workflows, or saga patterns. Also use when the user mentions "run a Temporal workflow from the CLI", "start a dev server", "run temporal server start-dev", "temporal workflow start", "temporal workflow execute", "temporal workflow signal", "temporal workflow query", "temporal workflow update". +version: 0.5.0 --- # Skill: temporal-developer ## Overview -Temporal is a durable execution platform that makes workflows survive failures automatically. This skill provides guidance for building Temporal applications in Python, TypeScript, Go, Java and .NET. +Temporal is a durable execution platform that makes workflows survive failures automatically. This skill provides guidance for building Temporal applications in Python, TypeScript, Go, Java, .NET, and Ruby. ## Core Architecture @@ -58,6 +58,7 @@ Check if `temporal` CLI is installed. If not, follow the instructions at `refere - Go -> read `references/go/go.md` - Java -> read `references/java/java.md` - .NET (C#) -> read `references/dotnet/dotnet.md` + - Ruby -> read `references/ruby/ruby.md` 2. Second, read appropriate `core` and language-specific references for the task at hand. ## Primary References @@ -74,6 +75,7 @@ Check if `temporal` CLI is installed. If not, follow the instructions at `refere - **`references/core/error-reference.md`** - Common error types, workflow status reference - **`references/core/interactive-workflows.md`** - Testing signals, updates, queries - **`references/core/dev-management.md`** - Dev cycle & management of server and workers +- **`references/core/cli-workflow-commands.md`** - Developer-facing CLI commands for workflow interaction (start, execute, signal, query, update) - **`references/core/ai-patterns.md`** - AI/LLM pattern concepts - Language-specific info at `references/{your_language}/ai-patterns.md`, if available. Currently Python only. diff --git a/plugins/temporal-developer/skills/temporal-developer/references/core/cli-workflow-commands.md b/plugins/temporal-developer/skills/temporal-developer/references/core/cli-workflow-commands.md new file mode 100644 index 0000000..de73094 --- /dev/null +++ b/plugins/temporal-developer/skills/temporal-developer/references/core/cli-workflow-commands.md @@ -0,0 +1,255 @@ +# CLI Workflow Commands for Developers + +Developer-facing CLI commands for interacting with workflows during development and testing. These commands work identically against a dev server, a self-hosted cluster, or Temporal Cloud -- only the connection descriptor changes. + +**IMPORTANT:** In order to make outputs of `temporal` CLI commands easier to read and parse, use the `--output json` flag. + +## Table of contents + +- [Workflow start](#workflow-start) +- [Workflow execute](#workflow-execute) +- [Workflow signal](#workflow-signal) +- [Workflow query](#workflow-query) +- [Workflow update](#workflow-update) +- [Workflow signal-with-start](#workflow-signal-with-start) +- [Workflow result](#workflow-result) +- [Workflow metadata](#workflow-metadata) + +## Workflow start + +Start a new Workflow Execution asynchronously. Returns the Workflow ID and Run ID. + +```bash +temporal workflow start \ + --output json \ + --workflow-id YourWorkflowId \ + --type YourWorkflow \ + --task-queue YourTaskQueue \ + --input '{"some-key": "some-value"}' +``` + +Required flags: `--type`, `--task-queue`. Optional `--workflow-id` -- the Service generates a UUID if omitted. + +| Flag | Required | Purpose | +|---|---|---| +| `--type` | Yes | Workflow Type name. | +| `--task-queue`, `-t` | Yes | Workflow Task queue. | +| `--workflow-id`, `-w` | No | Workflow ID. Service generates a UUID if omitted. | +| `--input`, `-i` | No | Input value (JSON). Repeatable. Mutually exclusive with `--input-file`. | +| `--input-file` | No | Read input from file(s). Repeatable. Mutually exclusive with `--input`. | +| `--input-base64` | No | Decode `--input` as base64 before sending. | +| `--input-meta` | No | Override payload metadata as `KEY=VALUE` (e.g., `encoding=json/protobuf`). Repeatable. | +| `--id-reuse-policy` | No | How to reuse a previously-seen Workflow ID. Values: `AllowDuplicate`, `AllowDuplicateFailedOnly`, `RejectDuplicate`, `TerminateIfRunning`. | +| `--id-conflict-policy` | No | How to resolve conflicts with a running execution sharing the same ID. Values: `Fail`, `UseExisting`, `TerminateExisting`. | +| `--execution-timeout` | No | Fail a Workflow Execution if it lasts longer than this (duration). Includes retries and ContinueAsNew. | +| `--run-timeout` | No | Fail a single Workflow Run if it lasts longer than this (duration). | +| `--task-timeout` | No | Start-to-close timeout for a Workflow Task (duration). | +| `--search-attribute` | No | Set a search attribute as `KEY=VALUE` (JSON values). Repeatable. | +| `--memo` | No | Attach unindexed metadata as `KEY="VALUE"` (JSON values). Repeatable. | +| `--start-delay` | No | Delay before starting (duration). Cannot combine with `--cron`. | +| `--cron` | No | Legacy cron schedule (prefer `temporal schedule create`). | +| `--priority-key` | No | Priority 1-5 (default 3). Lower = higher priority. | +| `--fairness-key` | No | Proportional task dispatch grouping key (string, max 64 bytes). | +| `--fairness-weight` | No | Weight for this fairness key (0.001-1000). Keys dispatched proportionally. | +| `--static-summary` | No | Human-readable summary for UIs. Single line. _(Experimental)_ | +| `--static-details` | No | Human-readable details for UIs. May be multi-line. _(Experimental)_ | +| `--fail-existing` | No | Fail if the Workflow already exists. | +| `--headers` | No | Temporal workflow headers as `KEY=VALUE` (JSON). Not gRPC headers. Repeatable. | + +## Workflow execute + +Start a Workflow Execution and block until it completes, streaming progress to stdout. + +```bash +temporal workflow execute \ + --output json \ + --workflow-id YourWorkflowId \ + --type YourWorkflow \ + --task-queue YourTaskQueue \ + --input '{"some-key": "some-value"}' +``` + +Accepts the same start-time flags as `workflow start`. The only `workflow execute` specific flag is `--detailed` (display events as sections rather than a table; not applied to JSON output). With `--output json`, the emitted blob includes the full `history` key for the run. + +A non-zero exit code means the Workflow failed, was cancelled, terminated, or timed out. Useful for one-shot scripts and smoke tests during development. + +## Workflow signal + +Send an asynchronous signal to a running Workflow Execution. + +```bash +temporal workflow signal \ + --output json \ + --workflow-id YourWorkflowId \ + --name YourSignal \ + --input '{"YourInputKey": "YourInputValue"}' +``` + +| Flag | Required | Purpose | +|---|---|---| +| `--workflow-id`, `-w` | Yes (or `--query`) | Workflow ID. | +| `--name` | Yes | Signal name. | +| `--input`, `-i` | No | Input value (JSON). Repeatable. | +| `--run-id`, `-r` | No | Pin to a specific run. Only with `--workflow-id`. | + +For bulk signaling with `--query` (runs as a batch job), see skill-temporal-ops. + +## Workflow query + +Invoke a read-only query handler. Queries do not mutate workflow state and can run on both running and completed workflows. + +```bash +temporal workflow query \ + --output json \ + --workflow-id YourWorkflowId \ + --name YourQueryType \ + --input '{"YourInputKey": "YourInputValue"}' +``` + +| Flag | Required | Purpose | +|---|---|---| +| `--workflow-id`, `-w` | Yes | Workflow ID. | +| `--name` | Yes | Query Type/Name. | +| `--input`, `-i` | No | Input value (JSON). Repeatable. | +| `--run-id`, `-r` | No | Run ID. | +| `--reject-condition` | No | Reject queries based on Workflow state. Accepted values: `not_open`, `not_completed_cleanly`. | + +## Workflow update + +Update is a command **group**, not a single command. It has four subcommands: `describe`, `execute`, `result`, `start`. + +### `temporal workflow update start` + +Initiate an update and wait for the validator to accept or reject it. + +```bash +temporal workflow update start \ + --output json \ + --workflow-id YourWorkflowId \ + --name YourUpdate \ + --input '{"some-key": "some-value"}' \ + --wait-for-stage accepted +``` + +| Flag | Required | Purpose | +|---|---|---| +| `--workflow-id`, `-w` | Yes | Workflow ID. | +| `--name` | Yes | Handler method name. | +| `--wait-for-stage` | Yes | Update stage to wait for. The **only** accepted value is `accepted`. Required to allow a future CLI version to choose a default. | +| `--input`, `-i` | No | Input value (JSON). Repeatable. | +| `--update-id` | No | Idempotency key. Defaults to a UUID. | +| `--run-id`, `-r` | No | Run ID. If unset, targets the currently-running execution. | +| `--first-execution-run-id` | No | Pin the update to the last execution in the chain started with this Run ID. | + +### `temporal workflow update execute` + +Start an update and wait for it to complete or fail. Can also wait on an existing in-flight update by reusing its Update ID. + +```bash +temporal workflow update execute \ + --output json \ + --workflow-id YourWorkflowId \ + --name YourUpdate \ + --input '{"some-key": "some-value"}' +``` + +| Flag | Required | Purpose | +|---|---|---| +| `--workflow-id`, `-w` | Yes | Workflow ID. | +| `--name` | Yes | Handler method name. | +| `--input`, `-i` | No | Input value (JSON). Repeatable. | +| `--update-id` | No | Idempotency key. Defaults to a UUID. | +| `--run-id`, `-r` | No | Run ID. If unset, targets the currently-running execution. | +| `--first-execution-run-id` | No | Pin the update to the last execution in the chain started with this Run ID. | + +### `temporal workflow update result` + +Wait for a previously started update to complete or fail, then print the result. + +```bash +temporal workflow update result \ + --output json \ + --workflow-id YourWorkflowId \ + --update-id YourUpdateId +``` + +| Flag | Required | Purpose | +|---|---|---| +| `--workflow-id`, `-w` | Yes | Workflow ID. | +| `--update-id` | Yes | Update ID. Must be unique per Workflow Execution. | +| `--run-id`, `-r` | No | Run ID. | + +### `temporal workflow update describe` + +Inspect the current status of an update, including a result if it has finished. + +```bash +temporal workflow update describe \ + --output json \ + --workflow-id YourWorkflowId \ + --update-id YourUpdateId +``` + +| Flag | Required | Purpose | +|---|---|---| +| `--workflow-id`, `-w` | Yes | Workflow ID. | +| `--update-id` | Yes | Update ID. Must be unique per Workflow Execution. | +| `--run-id`, `-r` | No | Run ID. | + +## Workflow signal-with-start + +Atomically signal a Workflow Execution -- if the target run does not exist, a new Workflow Execution is created first, then the signal is delivered. + +```bash +temporal workflow signal-with-start \ + --output json \ + --signal-name YourSignal \ + --signal-input '{"some-key": "some-value"}' \ + --workflow-id YourWorkflowId \ + --type YourWorkflowType \ + --task-queue YourTaskQueue \ + --input '{"some-key": "some-value"}' +``` + +Takes `--signal-name` (required), `--signal-input`, plus all start-time flags from `workflow start`. + +| Flag | Required | Purpose | +|---|---|---| +| `--signal-name` | Yes | Signal name. | +| `--signal-input` | No | Signal input value (JSON). Repeatable. | +| `--type` | Yes | Workflow Type name. | +| `--task-queue`, `-t` | Yes | Workflow Task queue. | +| `--workflow-id`, `-w` | No | Workflow ID. Service generates a UUID if omitted. | + +All other start-time flags (`--input`, `--id-reuse-policy`, `--id-conflict-policy`, timeouts, `--search-attribute`, `--memo`, etc.) are accepted. See [Workflow start](#workflow-start) for the full flag table. + +## Workflow result + +Block until a running Workflow Execution completes, then print the result. + +```bash +temporal workflow result \ + --output json \ + --workflow-id YourWorkflowId +``` + +| Flag | Required | Purpose | +|---|---|---| +| `--workflow-id`, `-w` | Yes | Workflow ID. | +| `--run-id`, `-r` | No | Run ID. | + +## Workflow metadata + +Issue a query to read user-set summary and details metadata for a Workflow Execution. + +```bash +temporal workflow metadata \ + --output json \ + --workflow-id YourWorkflowId +``` + +| Flag | Required | Purpose | +|---|---|---| +| `--workflow-id`, `-w` | Yes | Workflow ID. | +| `--run-id`, `-r` | No | Run ID. | +| `--reject-condition` | No | Reject queries based on Workflow state. Accepted values: `not_open`, `not_completed_cleanly`. | diff --git a/plugins/temporal-developer/skills/temporal-developer/references/core/determinism.md b/plugins/temporal-developer/skills/temporal-developer/references/core/determinism.md index 004f879..d24f868 100644 --- a/plugins/temporal-developer/skills/temporal-developer/references/core/determinism.md +++ b/plugins/temporal-developer/skills/temporal-developer/references/core/determinism.md @@ -89,6 +89,7 @@ Each Temporal SDK language provides a different level of protection against non- - Java: The Java SDK has no sandbox. Determinism is enforced by developer conventions — the SDK provides `Workflow.*` APIs as safe alternatives (e.g., `Workflow.sleep()` instead of `Thread.sleep()`), and non-determinism is only detected at replay time via `NonDeterministicException`. A static analysis tool (`temporal-workflowcheck`, beta) can catch violations at build time. Cooperative threading under a global lock eliminates the need for synchronization. - Go: The Go SDK has no runtime sandbox. Therefore, non-determinism bugs will never be immediately appararent, and are usually only observable during replay. The optional `workflowcheck` static analysis tool can be used to check for many sources of non-determinism at compile time. - .NET: The .NET SDK has no sandbox. It uses a custom TaskScheduler and a runtime EventListener to detect invalid task scheduling. Developers must use `Workflow.*` safe alternatives (e.g., Workflow.DelayAsync instead of Task.Delay) and avoid non-deterministic .NET Task APIs. +- Ruby: The Ruby SDK uses Illegal Call Tracing (via `TracePoint`) to detect forbidden method calls at runtime on the workflow fiber, combined with a Durable Fiber Scheduler that makes fiber operations deterministic. Regardless of which SDK you are using, it is your responsibility to ensure that workflow code does not contain sources of non-determinism. Use SDK-specific tools as well as replay tests for doing so. diff --git a/plugins/temporal-developer/skills/temporal-developer/references/core/dev-management.md b/plugins/temporal-developer/skills/temporal-developer/references/core/dev-management.md index 45385d3..6ced2c6 100644 --- a/plugins/temporal-developer/skills/temporal-developer/references/core/dev-management.md +++ b/plugins/temporal-developer/skills/temporal-developer/references/core/dev-management.md @@ -2,13 +2,43 @@ ## Server Management -Before starting workers or workflows, you MUST start a local dev server, using the Temporal CLI: +Workers and workflows need a running Temporal Server. You can develop against a local dev server, a self-hosted cluster, or Temporal Cloud — the choice depends on your setup. If you need a local server, start one with the Temporal CLI: ```bash temporal server start-dev # Start this in the background. ``` -It is perfectly OK for this process to be shared across multiple projects / left running as you develop your Temporal code. +The dev server can be shared across projects and left running as you develop. + +The dev server is in-memory by default -- all workflows, schedules, and history are lost on restart. Use `--db-filename temporal.db` to persist across restarts. + +The dev server is for local development only, not production. + +### `temporal server start-dev` flags + +| Flag | Default | Purpose | +|---|---|---| +| `--db-filename`, `-f` | in-memory | Persistent SQLite file. Without it, state is in-memory and lost on exit. | +| `--namespace`, `-n` | `default` only | Namespaces to create at launch. Repeatable. The `default` namespace is always created. | +| `--search-attribute` | — | Register search attributes as `KEY=TYPE` pairs. TYPE is one of: `Text`, `Keyword`, `Int`, `Double`, `Bool`, `Datetime`, `KeywordList`. Repeatable. | +| `--port`, `-p` | `7233` | Front-end gRPC port. | +| `--ui-port` | `--port` + 1000 | Web UI port. | +| `--ip` | `127.0.0.1` | IP address bound to the front-end service. Use `0.0.0.0` for Docker/LAN access. | +| `--dynamic-config-value` | — | Dynamic config in `KEY=JSON_VALUE` form. Repeatable. | +| `--log-level` | `warn` | (Global flag) Log level. Accepted values: `debug`, `info`, `warn`, `error`, `never`. Default is `warn` for `start-dev`. | +| `--log-format` | `text` | (Global flag) Log format. Accepted values: `text`, `json`. | +| `--headless` | — | Disable the Web UI. | +| `--http-port` | random free port | HTTP API port. | +| `--metrics-port` | random free port | Prometheus `/metrics` port. | + +Example with persistence, extra namespaces, and a search attribute: + +```bash +temporal server start-dev \ + --db-filename /tmp/temporal.db \ + --namespace dev \ + --search-attribute OrderId=Keyword +``` ## Worker Management Details @@ -23,3 +53,56 @@ When you need a new worker, you should start it in the background (and preferrab ### Cleanup **Always kill workers when done.** Don't leave workers running. + +## Dev to Prod + +Steps to promote a workflow from a local dev server to a production backend. For the most part, the workflow and worker code do not change between environments; only the connection descriptor does. If the connection code is in the application code, then just those spots in the code need to be updated. + +### 1. Start a local dev server with persistence + +```bash +temporal server start-dev --db-filename dev.db +``` + +### 2. Run the workflow against dev + +Start your worker, then execute the workflow: + +```bash +temporal workflow execute \ + --type MyWorkflow \ + --task-queue my-queue \ + --input '{"key": "value"}' +``` + +`workflow execute` blocks until the run terminates; a non-zero exit means the run failed, was cancelled, terminated, or timed out. + +### 3. Create a prod profile configuration + +```bash +temporal config --profile prod set --prop address --value "your-ns.your-acct.tmprl.cloud:7233" +temporal config --profile prod set --prop namespace --value "your-ns.your-acct" +temporal config --profile prod set --prop api-key --value "your-key" +``` + +The profile-selecting flag is `--profile `. + +### 4. Smoke-test prod + +```bash +temporal workflow list --profile prod --limit 1 --output json +``` + +If this returns (even an empty list), the connection descriptor is correct. + +### 5. Run in prod + +```bash +temporal workflow start \ + --profile prod \ + --type MyWorkflow \ + --task-queue my-queue \ + --input '{"key": "value"}' +``` + +`workflow start` is asynchronous (returns a Workflow/Run ID); use `workflow execute` instead if you want the CLI to block. \ No newline at end of file diff --git a/plugins/temporal-developer/skills/temporal-developer/references/core/gotchas.md b/plugins/temporal-developer/skills/temporal-developer/references/core/gotchas.md index 677362f..372caed 100644 --- a/plugins/temporal-developer/skills/temporal-developer/references/core/gotchas.md +++ b/plugins/temporal-developer/skills/temporal-developer/references/core/gotchas.md @@ -195,6 +195,29 @@ See language-specific gotchas for details. **The Fix**: Heartbeat regularly and check for cancellation. See language-specific gotchas for implementation patterns. +## CLI Gotchas for Developers + +### Dev Server Is In-Memory and Not for Production + +The dev server loses all state on restart (use `--db-filename` to persist) and runs everything in a single process. See `dev-management.md` for the full flag table and persistence guidance. Even with persistence enabled, the dev server should NEVER be used for production deployments. + +### `workflow update` Is a Command Group, Not a Single Command + +Running `temporal workflow update` alone will not work. Use the correct subcommand: + +- `temporal workflow update execute` -- start an update and wait for completion. +- `temporal workflow update start` -- fire an update and wait for acceptance. Requires `--wait-for-stage accepted`. +- `temporal workflow update result` -- get the result of a previously started update. +- `temporal workflow update describe` -- check an update's current status. + +### `--wait-for-stage` Only Accepts `accepted` + +Despite looking like an enum, the only valid value for `--wait-for-stage` on `temporal workflow update start` is `accepted`. Passing `completed` or other values will fail. The flag is required to allow a future CLI version to choose a default. + +### `--reapply-type` Only Accepts `Signal` or `None` + +When resetting a workflow with `temporal workflow reset`, `--reapply-type` controls which events get reapplied after the reset point. Only `Signal` and `None` are valid values. + ## Payload Size Limits **The Problem**: Temporal has built-in limits on payload sizes. Exceeding them causes workflows to fail. diff --git a/plugins/temporal-developer/skills/temporal-developer/references/core/install_cli.md b/plugins/temporal-developer/skills/temporal-developer/references/core/install_cli.md index 4421172..41b46fb 100644 --- a/plugins/temporal-developer/skills/temporal-developer/references/core/install_cli.md +++ b/plugins/temporal-developer/skills/temporal-developer/references/core/install_cli.md @@ -2,24 +2,55 @@ ## macOS -``` +### Via homebrew + +```bash brew install temporal ``` +### Via tarball download + +- [Darwin amd64](https://temporal.download/cli/archive/latest?platform=darwin&arch=amd64) +- [Darwin arm64](https://temporal.download/cli/archive/latest?platform=darwin&arch=arm64) + +Extract any downloaded archive and add the `temporal` binary to your `PATH`. + ## Linux -Check your machine's architecture and download the appropriate archive: +Homebrew (if available), Snap, or tarball download: + +```bash +brew install temporal +# or +snap install temporal +``` - [Linux amd64](https://temporal.download/cli/archive/latest?platform=linux&arch=amd64) - [Linux arm64](https://temporal.download/cli/archive/latest?platform=linux&arch=arm64) -Once you've downloaded the file, extract the downloaded archive and add the temporal binary to your PATH by copying it to a directory like /usr/local/bin +Extract any downloaded archive and add the `temporal` binary to your `PATH`. ## Windows -Check your machine's architecture and download the appropriate archive: +Download the tarballs: - [Windows amd64](https://temporal.download/cli/archive/latest?platform=windows&arch=amd64) - [Windows arm64](https://temporal.download/cli/archive/latest?platform=windows&arch=arm64) -Once you've downloaded the file, extract the downloaded archive and add the temporal.exe binary to your PATH. \ No newline at end of file +Extract the archive and add the `temporal.exe` binary to your `PATH`. + +## Docker + +```bash +docker run --rm temporalio/temporal --help +``` + +## `tcld` (Temporal Cloud CLI) + +Only needed for Cloud-connected development (managing Cloud namespaces, API keys, etc.). + +Homebrew: + +```bash +brew install temporalio/brew/tcld +``` \ No newline at end of file diff --git a/plugins/temporal-developer/skills/temporal-developer/references/integrations.md b/plugins/temporal-developer/skills/temporal-developer/references/integrations.md index 0a07f28..71b3169 100644 --- a/plugins/temporal-developer/skills/temporal-developer/references/integrations.md +++ b/plugins/temporal-developer/skills/temporal-developer/references/integrations.md @@ -15,6 +15,7 @@ Temporal ships and supports a growing set of integrations with third-party frame |---|---|---|---|---| | Spring Boot (`temporal-spring-boot-starter`) | Java | Auto-configuration of `WorkflowClient`, worker factories, workflow/activity bean registration, lifecycle, testing | `references/java/integrations/spring-boot.md` | `references/java/java.md` | | Spring AI (`temporal-spring-ai`) | Java | Durable Spring AI agents: chat-model calls run as Activities; tools dispatched per type (Activity stub, Nexus stub, `@SideEffectTool`, plain); vector stores, embeddings, and MCP clients auto-registered | `references/java/integrations/spring-ai.md` | `references/java/integrations/spring-boot.md`, `references/core/ai-patterns.md` | +| OpenAI Agents SDK (`temporalio.contrib.openai_agents`) | Python | Durable OpenAI Agents SDK agents: model calls run as Activities via `OpenAIAgentsPlugin`; tools are Activities (`activity_as_tool`) or workflow-resident `@function_tool`s; stateless/stateful MCP, sandbox backends, streaming, and OpenTelemetry export are supported | `references/python/integrations/openai-agents-sdk.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | LangSmith tracing (`temporalio.contrib.langsmith`) | Python | Experimental Temporal Plugin that propagates LangSmith trace context across Worker boundaries; lets `@traceable` run inside Workflows and Activities | `references/python/integrations/langsmith.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | LangGraph (`temporalio.contrib.langgraph`, Pre-release) | Python | Runs LangGraph Graph-API and Functional-API code as Temporal Workflows - nodes/tasks can execute as either in-workflow or as Activities | `references/python/integrations/langgraph.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | | Google ADK (`temporalio[google-adk]`) | Python | Durable Google ADK agents: model calls run through `TemporalModel`-wrapped Activities, tools via `activity_tool`, MCP toolsets via `TemporalMcpToolSet` | `references/python/integrations/google-adk.md` | `references/python/ai-patterns.md`, `references/core/ai-patterns.md` | diff --git a/plugins/temporal-developer/skills/temporal-developer/references/python/advanced-features.md b/plugins/temporal-developer/skills/temporal-developer/references/python/advanced-features.md index c5ec1b3..6ad8ae8 100644 --- a/plugins/temporal-developer/skills/temporal-developer/references/python/advanced-features.md +++ b/plugins/temporal-developer/skills/temporal-developer/references/python/advanced-features.md @@ -114,6 +114,33 @@ worker = Worker( ) ``` +## DNS Resolver Configuration + +`DnsLoadBalancingConfig` makes Core periodically re-resolve the client's target host and round-robin requests across the resolved addresses . Use it when `target_host` resolves to multiple A/AAAA records (e.g., a load-balanced gRPC frontend, multi-address private endpoints) and you want the client to spread RPCs across them. + +### Configuration + +```python +from temporalio.client import Client +from temporalio.service import DnsLoadBalancingConfig + +client = await Client.connect( + "frontend.example.internal:7233", + dns_load_balancing_config=DnsLoadBalancingConfig( + resolution_interval_millis=5000, # re-resolve every 5 seconds + ), +) +``` + +- The only field is `resolution_interval_millis: int = 30000` — how often to re-resolve DNS, in milliseconds. +- `DnsLoadBalancingConfig.default` is a pre-built instance with the default 30-second interval. +- `dns_load_balancing_config` defaults to 30 seconds if you don't pass anything explicitly. +- Pass `dns_load_balancing_config=None` to disable DNS load balancing entirely. + +### Mutual exclusion with HTTP CONNECT proxy + +DNS load balancing and `HttpConnectProxyConfig` cannot be used together. When `http_connect_proxy_config` is set on the same client, DNS load balancing is **silently disabled** — there is no error and no precedence flag. If you need both, you cannot have both; choose the one your network requires. + ## Workflow Init Decorator You should always put state initialization logic in the `__init__` of your workflow class, so that it happens before signals/updates arrive. diff --git a/plugins/temporal-developer/skills/temporal-developer/references/python/integrations/openai-agents-sdk.md b/plugins/temporal-developer/skills/temporal-developer/references/python/integrations/openai-agents-sdk.md new file mode 100644 index 0000000..3807eb7 --- /dev/null +++ b/plugins/temporal-developer/skills/temporal-developer/references/python/integrations/openai-agents-sdk.md @@ -0,0 +1,470 @@ +# Temporal OpenAI Agents SDK Integration (Python) + +## Overview + +The Temporal Python SDK ships a contrib module that runs [OpenAI Agents SDK](https://github.com/openai/openai-agents-python) agents as durable Temporal Workflows. Model calls execute as Temporal Activities; tools can be Activities, Nexus stubs, or workflow-resident `@function_tool`s; MCP servers, sandbox backends, and OpenTelemetry export are layered on top. + +The integration is delivered as a Temporal plugin: `OpenAIAgentsPlugin` from `temporalio.contrib.openai_agents`, registered on both the client and the worker via `plugins=[...]`. + +For language-agnostic AI/LLM patterns (centralized retries, multi-agent orchestration, when to put a tool in an Activity vs. the workflow) see `references/core/ai-patterns.md`. For Python-side LLM patterns that apply when **not** using this plugin (Pydantic data converter, generic LLM activity, `max_retries=0` on the raw OpenAI client) see `references/python/ai-patterns.md` — note that the plugin already configures Pydantic serialization for you. + +## Install + +The integration lives at `temporalio.contrib.openai_agents`; use it by installing `temporalio[openai-agents]` to get the extra OpenAI Agents SDK dep. + +```python +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters +``` + +## Register the plugin + +Pass `OpenAIAgentsPlugin` to `Client.connect(..., plugins=[...])`. Register it on **both** the worker process and the client process — the worker uses it to host the model activity; the client uses it to keep payload serialization compatible. + +```python +from datetime import timedelta +from temporalio.client import Client +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters +from temporalio.worker import Worker + +client = await Client.connect( + "localhost:7233", + plugins=[ + OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=30) + ) + ), + ], +) +``` + +The 30-second timeout above is the example from the README, not a documented default. Pick a value sized to your model and prompt. + +With the plugin registered, four things happen automatically: + +- Pydantic types serialize correctly across activity boundaries. +- OpenAI Agents tracing context propagates between workflow and activity. +- The model-invocation activity is registered with every Temporal worker. +- The OpenAI Agents SDK is reconfigured so its model calls run as Temporal activities. + +## A durable agent + +Inside a workflow, write standard OpenAI Agents SDK code — `Agent`, `Runner.run`. The plugin reroutes model calls through an Activity, so the agent loop is durable. + +```python +from temporalio import workflow +from agents import Agent, Runner + +@workflow.defn +class HelloWorldAgent: + @workflow.run + async def run(self, prompt: str) -> str: + agent = Agent( + name="Assistant", + instructions="You only respond in haikus.", + ) + result = await Runner.run(agent, input=prompt) + return result.final_output +``` + +Register the workflow with a `Worker` exactly like any other Temporal workflow. + +## Tools + +Two ways to wire a tool into an agent. Choose based on whether the tool has side effects. + +### Activities as tools — for I/O, retries, timeouts + +Wrap a Temporal activity with `temporalio.contrib.openai_agents.workflow.activity_as_tool` to expose it to the agent. Each invocation runs as a Temporal Activity, with retries and timeouts governed by the `ActivityOptions` you pass. + +```python +from dataclasses import dataclass +from datetime import timedelta +from temporalio import activity, workflow +from temporalio.contrib import openai_agents +from agents import Agent, Runner + +@dataclass +class Weather: + city: str + temperature_range: str + conditions: str + +@activity.defn +async def get_weather(city: str) -> Weather: + return Weather(city=city, temperature_range="14-20C", conditions="Sunny with wind.") + +@workflow.defn +class WeatherAgent: + @workflow.run + async def run(self, question: str) -> str: + agent = Agent( + name="Weather Assistant", + instructions="You are a helpful weather agent.", + tools=[ + openai_agents.workflow.activity_as_tool( + get_weather, + start_to_close_timeout=timedelta(seconds=10), + ), + ], + ) + result = await Runner.run(starting_agent=agent, input=question) + return result.final_output +``` + +Just like with standard `@function_tool` declarations, if your Activity-as-tool has a `RunContextWrapper[T]` as the first parameter, then it will receive the [OpenAI Agents context wrapper](https://openai.github.io/openai-agents-python/ref/run_context/#agents.run_context.RunContextWrapper). However, unlike with a `@function_tool`, +it will only be a **read-only copy** of the OpenAI Agents context — mutations from the tool body are not visible to other tools or to the agent! + +```python +@activity.defn +async def get_weather(ctx: RunContextWrapper[MyState], city: str) -> Weather: + state: MyState = ctx.context + # Now we have **read-only** access to state which is shared across tool invocations. + pass +``` + +Note that the initial run context comes from the `context=...` argument you pass to `Runner.run()`, and is `None` by default. + +### `@function_tool` — for deterministic, in-process tools + +For pure computations or tools that mutate agent state, use the upstream `@function_tool` decorator. The tool runs as part of the workflow, so it must obey workflow determinism rules. + +```python +from temporalio import workflow +from agents import Agent, Runner, function_tool + +@function_tool +def calculate_circle_area(radius: float) -> float: + return 3.14 * radius ** 2 + +@workflow.defn +class MathAssistantAgent: + @workflow.run + async def run(self, message: str) -> str: + agent = Agent( + name="Math Assistant", + instructions="You are a helpful math assistant.", + tools=[calculate_circle_area], + ) + result = await Runner.run(agent, input=message) + return result.final_output +``` + +`@function_tool` bodies can read **and update** OpenAI Agents context: + +```python +@activity.defn +async def calculate_circle_area(ctx: RunContextWrapper[MyState], radius: float) -> float: + state: MyState = ctx.context + # Now we have **read-write** access to state which is shared across tool invocations. + pass +``` + +Note that the initial run context comes from the `context=...` argument you pass to `Runner.run()`, and is `None` by default. + +In addition, since a `@function_tool` runs in the workflow, they can also call Temporal activities or other durable primitives themselves. + + +**Don't put I/O, system clock, or sources of randomness inside a `@function_tool` body.** Make it an `@activity.defn` and wrap with `activity_as_tool` instead. + +### Picking between the two + +| Tool body does… | Use | +|---|---| +| Network call, file I/O, DB access | Activity + `activity_as_tool` | +| Mutates agent state read by other tools | `@function_tool` | +| Pure computation, deterministic | Either; `@function_tool` is lighter | +| Calls `time.time()`, RNG, threads | Activity + `activity_as_tool` | + +## MCP servers + +MCP support comes in two flavors based on whether the server keeps session state between calls. Choose by examining the server's protocol, not by guessing. + +- **Stateless MCP server** — each call is self-contained. Wrap with `StatelessMCPServerProvider(factory)` and register on the plugin. +- **Stateful MCP server** — session state persists between calls. Failure raises `ApplicationError`; **Temporal cannot auto-recover** the lost server state, so you implement application-level retry. + +Both wrappers work with `MCPServerStdio`, `MCPServerSse`, and `MCPServerStreamableHttp` transports. + +### Stateless MCP — worker setup + +```python +from agents.mcp import MCPServerStdio +from temporalio.contrib.openai_agents import ( + ModelActivityParameters, + OpenAIAgentsPlugin, + StatelessMCPServerProvider, +) + +filesystem_server = StatelessMCPServerProvider( + lambda: MCPServerStdio( + name="FileSystemServer", + params={ + "command": "npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/path/to/files"], + }, + ) +) + +client = await Client.connect( + "localhost:7233", + plugins=[ + OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=60) + ), + mcp_server_providers=[filesystem_server], + ), + ], +) +``` + +### Stateless MCP — workflow usage + +Reference the server inside a workflow with `openai_agents.workflow.stateless_mcp_server("Name")`. The string must match the `name=` argument on the MCP server instance the factory creates. + +```python +from temporalio import workflow +from temporalio.contrib import openai_agents +from agents import Agent, Runner + +@workflow.defn +class FileSystemWorkflow: + @workflow.run + async def run(self, query: str) -> str: + server = openai_agents.workflow.stateless_mcp_server("FileSystemServer") + agent = Agent( + name="File Assistant", + instructions="Use the filesystem tools to read files and answer questions.", + mcp_servers=[server], + ) + result = await Runner.run(agent, input=query) + return result.final_output +``` + +### Hosted MCP + +For network-accessible MCP servers, the upstream `HostedMCPTool` (OpenAI Responses API hosting an MCP client) is also supported and avoids the stateless/stateful wrapping choice. + +## Sandbox support + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +The OpenAI Agents SDK's `SandboxAgent` runs commands inside a remote or local sandbox (e.g. Daytona, Docker, E2B, local Unix). With this integration, every sandbox operation — creating a session, exec, file I/O, PTY — dispatches as a Temporal activity, so sandbox work is durable like any other activity and sandbox session state survives worker restarts. + +> Naming gotcha: this is the **Agents SDK** sandbox (remote command execution), **not** the Temporal Python SDK workflow sandbox (determinism protection). They are unrelated. + +### Worker setup + +Register one or more `SandboxClientProvider("name", BaseSandboxClient())` on the plugin via `sandbox_clients=[...]`. Each provider's name becomes the prefix for its activities, so names must be unique. + +```python +from temporalio.contrib.openai_agents import ( + OpenAIAgentsPlugin, + SandboxClientProvider, + ModelActivityParameters, +) +from agents.extensions.sandbox.daytona import DaytonaSandboxClient +from agents.extensions.sandbox.unix_local import UnixLocalSandboxClient + +client = await Client.connect( + "localhost:7233", + plugins=[ + OpenAIAgentsPlugin( + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=30) + ), + sandbox_clients=[ + SandboxClientProvider("daytona", DaytonaSandboxClient()), + SandboxClientProvider("local", UnixLocalSandboxClient()), + ], + ), + ], +) +``` + +### Workflow usage + +Reference a registered backend in the workflow with `temporal_sandbox_client("name")`. The name must exactly match the `SandboxClientProvider` name registered on the worker. Pass it through `RunConfig(sandbox=SandboxRunConfig(client=...))`. + +```python +from temporalio import workflow +from temporalio.contrib.openai_agents.workflow import temporal_sandbox_client +from agents import Runner +from agents.sandbox import SandboxAgent, SandboxRunConfig +from agents.run import RunConfig + +@workflow.defn +class MyWorkflow: + @workflow.run + async def run(self, prompt: str) -> str: + agent = SandboxAgent( + name="Coding Assistant", + instructions="You are a helpful coding assistant with access to a sandbox.", + ) + result = await Runner.run( + agent, + prompt, + run_config=RunConfig( + sandbox=SandboxRunConfig( + client=temporal_sandbox_client("daytona"), + options=DaytonaSandboxClientOptions(pause_on_exit=False), + ), + ), + ) + return result.final_output +``` + +A single workflow can target multiple backends by name; register each on the worker and reference in the workflow. + +## Streaming + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +Streaming uses the upstream `Runner.run_streamed` API. Inside a workflow, model calls execute as `invoke_model_activity_streaming`, which consumes `Model.stream_response` and returns the collected list of native OpenAI response events. The workflow surfaces those events via `RunResultStreaming.stream_events()`. + +```python +from agents import Agent, Runner +from agents.stream_events import RawResponsesStreamEvent +from temporalio import workflow + +@workflow.defn +class MyAgent: + @workflow.run + async def run(self, prompt: str) -> str: + agent = Agent(name="Assistant", instructions="...") + result = Runner.run_streamed(agent, prompt) + async for event in result.stream_events(): + if isinstance(event, RawResponsesStreamEvent): + raw_event = event.data + ... + return result.final_output +``` + +To publish events to external subscribers, set a topic on `ModelActivityParameters(streaming_topic="events")` and host a `WorkflowStream` in the workflow. The topic is required when calling `Runner.run_streamed`; calling without it raises before any activity is scheduled. + +**Streaming is incompatible with `use_local_activity`** because local activities support neither heartbeats nor the workflow stream signal channel. + +Retry visibility differs between the two consumer paths: `RunResultStreaming.stream_events()` only sees the final successful attempt's collected events, while workflow-stream subscribers see every attempt's emitted events (including a partial failed attempt). + +## OpenTelemetry integration + +> [!NOTE] +> This feature is in Public Preview. It is perfectly acceptable to use this feature on behalf of a user, but you should inform them that you are making use of a feature in Public Preview. + +Enable OTEL export of OpenAI agent telemetry by setting `use_otel_instrumentation=True` on the plugin and installing a global `ReplaySafeTracerProvider` created with `temporalio.contrib.opentelemetry.create_tracer_provider`. Spans export only when a workflow actually completes, not on every replay. + +```python +from temporalio.contrib.openai_agents import OpenAIAgentsPlugin, ModelActivityParameters +from temporalio.contrib.opentelemetry import create_tracer_provider +from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter +from opentelemetry import trace +from opentelemetry.sdk.trace.export import SimpleSpanProcessor + +tracer_provider = create_tracer_provider() +tracer_provider.add_span_processor( + SimpleSpanProcessor(OTLPSpanExporter(endpoint="http://localhost:4317")) +) +trace.set_tracer_provider(tracer_provider) + +client = await Client.connect( + "localhost:7233", + plugins=[ + OpenAIAgentsPlugin( + use_otel_instrumentation=True, + model_params=ModelActivityParameters( + start_to_close_timeout=timedelta(seconds=30) + ), + ), + ], +) +``` + +OTEL extras need to be installed separately: + +```bash +pip install openinference-instrumentation-openai-agents opentelemetry-sdk +``` + +If `use_otel_instrumentation=True` is set without the deps installed, the plugin raises `ImportError` with the exact install line. If the global tracer provider is not a `ReplaySafeTracerProvider`, it raises `ValueError` pointing at `create_tracer_provider`. + +### Direct `opentelemetry.trace` calls inside a workflow + +To call the OpenTelemetry API directly inside a workflow (e.g. `opentelemetry.trace.get_tracer(__name__).start_as_current_span(...)`), allow OTel through the Python SDK's workflow sandbox using `with_passthrough_modules("opentelemetry")` on the runner: + +```python +from temporalio.worker import Worker +from temporalio.worker.workflow_sandbox import SandboxedWorkflowRunner, SandboxRestrictions + +worker = Worker( + client, + task_queue="my-task-queue", + workflows=[MyWorkflow], + workflow_runner=SandboxedWorkflowRunner( + SandboxRestrictions.default.with_passthrough_modules("opentelemetry"), + ), +) +``` + +To get correct trace parenting, start an Agents SDK span with `agents.custom_span(...)` before opening any direct OTEL spans — the Agents-SDK span establishes the OTEL context that subsequent direct spans inherit from. + +### Starting a trace from the client + +`plugin.tracing_context()` lets the client side open an Agents-SDK trace before calling `execute_workflow`, so the whole workflow run is part of one larger trace: + +```python +plugin = OpenAIAgentsPlugin(use_otel_instrumentation=True) +client = await Client.connect("localhost:7233", plugins=[plugin]) + +with plugin.tracing_context(): + with trace("Customer support workflow"): + with custom_span("Workflow execution"): + await client.execute_workflow( + CustomerSupportAgent.run, + "Help me with my order", + id="customer-support-123", + task_queue="my-task-queue", + ) +``` + +## Feature support + +The README's compatibility matrix, condensed: + +| Area | Supported | Not supported | +|---|---|---| +| Model providers | OpenAI, LiteLLM | — | +| Model response | `Runner.run`; `Runner.run_streamed` (experimental) | — | +| Tools | `FunctionTool`, `WebSearchTool`, `FileSearchTool`, `HostedMCPTool`, `ImageGenerationTool`, `CodeInterpreterTool` | `LocalShellTool`, `ComputerTool` | +| MCP transports | `MCPServerStdio`, `MCPServerSse`, `MCPServerStreamableHttp` | — | +| Guardrails | Code, Agent | — | +| Sessions | (in-workflow agent state) | `SQLiteSession` | +| Tracing | OpenAI platform; OpenTelemetry (Public Preview) | — | +| Voice | `VoicePipeline` (STT/TTS outside Temporal, agent loop durable) | Realtime agents | +| Utilities | — | REPL | + +Tool context propagation: + +| Path | Receives context | Can update context | +|---|---|---| +| Activity tool (`activity_as_tool`) | Yes (copy) | **No** | +| Function tool (`@function_tool`) | Yes | Yes | + +## Common pitfalls + +- **Register the plugin on both client and worker.** Skipping client-side registration breaks payload compatibility. +- **Don't put I/O or non-deterministic code in `@function_tool` bodies.** Move it to an `@activity.defn` and wrap with `activity_as_tool`. +- **Don't expect Temporal to auto-recover stateful MCP server sessions.** A failed session raises `ApplicationError`; implement your own application-level retry. +- **Don't enable streaming together with `use_local_activity`.** Local activities lack heartbeats and the workflow-stream signal channel. Use the standard activity path. +- **Don't call `Runner.run_streamed` without `ModelActivityParameters(streaming_topic="...")`.** It raises before any activity is scheduled. +- **MCP server names must match exactly** between `MCPServerStdio(name="X")` and `stateless_mcp_server("X")`. Same for `SandboxClientProvider("Y", ...)` and `temporal_sandbox_client("Y")`. +- **`use_otel_instrumentation=True` requires `ReplaySafeTracerProvider`.** Setting `trace.set_tracer_provider(...)` with anything else raises `ValueError`. +- **Activity-tool context is a read-only copy.** A tool that needs to mutate agent state must be a `@function_tool`. + +## Resources + +- `references/core/ai-patterns.md` — language-agnostic agent patterns (when to wrap a tool as an activity, centralized retry, multi-agent orchestration). +- `references/python/ai-patterns.md` — Python-side LLM patterns for when you are **not** using this plugin (Pydantic data converter, OpenAI client `max_retries=0`). +- `references/python/determinism.md` and `references/core/determinism.md` — determinism rules that apply to `@function_tool` bodies and any in-workflow agent code. +- Upstream samples — [`temporalio/samples-python/openai_agents`](https://github.com/temporalio/samples-python/tree/main/openai_agents). diff --git a/plugins/temporal-developer/skills/temporal-developer/references/ruby/advanced-features.md b/plugins/temporal-developer/skills/temporal-developer/references/ruby/advanced-features.md new file mode 100644 index 0000000..a6681bf --- /dev/null +++ b/plugins/temporal-developer/skills/temporal-developer/references/ruby/advanced-features.md @@ -0,0 +1,247 @@ +# Ruby SDK Advanced Features + +## Schedules + +Create recurring workflow executions with `Temporalio::Client::Schedule`. + +```ruby +require 'temporalio/client' + +# Create a schedule +schedule_id = 'daily-report' +client.create_schedule( + schedule_id, + Temporalio::Client::Schedule.new( + action: Temporalio::Client::Schedule::Action::StartWorkflow.new( + DailyReportWorkflow, + id: 'daily-report', + task_queue: 'reports' + ), + spec: Temporalio::Client::Schedule::Spec.new( + intervals: [ + Temporalio::Client::Schedule::Spec::Interval.new(every: 86_400) # 1 day in seconds + ] + ) + ) +) + +# Manage schedules +handle = client.schedule_handle(schedule_id) +handle.pause(note: 'Maintenance window') +handle.unpause +handle.trigger +handle.delete +``` + +## Async Activity Completion + +For activities that complete asynchronously (e.g., human tasks, external callbacks). + +**Note:** If the external system that completes the asynchronous action can reliably be trusted to do the task and Signal back with the result, and it doesn't need to Heartbeat or receive Cancellation, then consider using **signals** instead. + +```ruby +class RequestApproval < Temporalio::Activity::Definition + def execute(request_id) + # Get task token for async completion + task_token = Temporalio::Activity::Context.current.info.task_token + + # Store task token for later completion (e.g., in database) + store_task_token(request_id, task_token) + + # Signal that this activity completes asynchronously + Temporalio::Activity::Context.current.raise_complete_async + end +end + +# Later, complete the activity from another process +client = Temporalio::Client.connect('localhost:7233') +task_token = get_task_token(request_id) +handle = client.async_activity_handle(task_token: task_token) + +if approved + handle.complete('approved') +else + handle.fail(Temporalio::Error::ApplicationError.new('Rejected')) +end +``` + +If you configure a `heartbeat_timeout:` on the activity, the external completer is responsible for sending heartbeats via the async handle. If you do NOT set a `heartbeat_timeout`, no heartbeats are required. + +## Worker Tuning + +Configure worker performance settings. + +```ruby +worker = Temporalio::Worker.new( + client: client, + task_queue: 'my-queue', + workflows: [MyWorkflow], + activities: [MyActivity], + # Max concurrent execution slots (default 100 each): how many workflow tasks + # and activities run at once on this worker. + tuner: Temporalio::Worker::Tuner.create_fixed( + workflow_slots: 100, + activity_slots: 100 + ), + # Grace period (seconds) after shutdown is requested before in-progress + # activities are canceled. Defaults to 0 (cancel immediately on shutdown). + graceful_shutdown_period: 30 +) +worker.run +``` + +On shutdown the worker stops polling for new tasks and cancels the `worker_shutdown_cancellation` on each running activity's context. After `graceful_shutdown_period` seconds it then issues actual cancellation to any still-running activities. The worker will not finish shutting down until all in-progress activities complete, so activities that ignore cancellation can block shutdown indefinitely. + +## Workflow Init Decorator + +Always initialize workflow state before signals/updates arrive. Signal and Update handlers can run *before* the main `execute` method -- for example with Signal-with-Start, when the Task Queue is backlogged, or right after continue-as-new -- so a handler may otherwise read uninitialized instance variables. + +Normally `initialize` must accept no required arguments. If you place the `workflow_init` class method directly above `initialize`, the constructor receives the same workflow arguments that `execute` receives (the same input the Client sent). It is guaranteed to run before any handler. + +```ruby +class GreetingWorkflow < Temporalio::Workflow::Definition + workflow_init + def initialize(input) + # Runs before any signal/update handler + @name_with_title = "Sir #{input['name']}" + @title_has_been_checked = false + end + + def execute(input) + Temporalio::Workflow.wait_condition { @title_has_been_checked } + "Hello, #{@name_with_title}" + end + + workflow_update + def check_title_validity + # Guaranteed to see workflow input, since initialize ran first + valid = Temporalio::Workflow.execute_activity( + CheckTitleValidityActivity, + @name_with_title, + start_to_close_timeout: 100 + ) + @title_has_been_checked = true + valid + end +end +``` + +`initialize` (with `workflow_init`) and `execute` must have the same parameters with the same types. You cannot make blocking calls (activities, sleeps, etc.) from `initialize`. + +## Workflow Failure Exception Types + +Control which exceptions cause workflow failure vs workflow task failure (which Temporal retries automatically). + +### Per-Workflow Configuration + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + # Class method approach + def self.workflow_failure_exception_type + MyCustomError + end + + def execute + raise MyCustomError, 'This fails the workflow, not just the task' + end +end +``` + +### Worker-Level Configuration + +```ruby +Temporalio::Worker.new( + client: client, + task_queue: 'my-queue', + workflows: [MyWorkflow], + workflow_failure_exception_types: [MyCustomError] +) +``` + +**Tips:** +- Set to `[Exception]` in tests so any unhandled exception fails the workflow immediately rather than retrying the workflow task forever. Surfaces bugs faster. +- Include `Temporalio::Workflow::NondeterminismError` to fail the workflow instead of leaving it in a retrying state on non-determinism errors. + +## Activity Concurrency and Executors + +Ruby uses `Temporalio::Worker::ActivityExecutor::ThreadPool` by default. Activities run in a thread pool. + +```ruby +# Default: activities run in thread pool +worker = Temporalio::Worker.new( + client: client, + task_queue: 'my-queue', + workflows: [MyWorkflow], + activities: [MyActivity], + activity_executors: { + default: Temporalio::Worker::ActivityExecutor::ThreadPool.new(max_threads: 20) + } +) +``` + +Fiber-based execution is also possible for IO-bound activities using Ruby's fiber scheduler. + +## Rails Integration + +### ActiveRecord Considerations + +Never pass ActiveRecord models directly to Temporal workflows or activities. Serialize to plain data structures. + +```ruby +# BAD - Passing AR model +client.execute_workflow( + ProcessOrderWorkflow, + Order.find(42), # Don't pass AR objects! + id: 'order-42', + task_queue: 'orders' +) + +# GOOD - Pass serializable data +client.execute_workflow( + ProcessOrderWorkflow, + { id: 42, total: order.total, status: order.status }, + id: 'order-42', + task_queue: 'orders' +) +``` + +### Zeitwerk and Autoloading + +Rails autoloading can result in unexpected I/O during replay. `config.eager_load` must be enabled or Workflows must explicitly require code dependencies before they are executed. Usually the easiest place to do that is when starting the worker, and requiring all activities and workflows that the worker needs *before* starting the worker. For example: + +```ruby +require "temporal_client" +require "temporalio/worker" +require "workflows/shopping_cart_activities.rb" +require "workflows/shopping_cart_workflow.rb" + +worker = Temporalio::Worker.new( + client: TemporalClient.instance, + task_queue: TemporalClient.task_queue, + activities: [ + Workflows::ShoppingCartActivities::FetchProducts, + ... + ], + workflows: [ Workflows::ShoppingCartWorkflow ] +) +worker.run +``` + +### Forking Considerations + +If using a forking server (Puma, Unicorn), workers must be created **after** the fork. Connections established before fork are not safe to share across processes. + +```ruby +# In Puma config (puma.rb) +before_worker_boot do + # Create Temporal client and worker AFTER fork + client = Temporalio::Client.connect('localhost:7233') + worker = Temporalio::Worker.new( + client: client, + task_queue: 'my-queue', + workflows: [MyWorkflow], + activities: [MyActivity] + ) + Thread.new { worker.run } +end +``` diff --git a/plugins/temporal-developer/skills/temporal-developer/references/ruby/data-handling.md b/plugins/temporal-developer/skills/temporal-developer/references/ruby/data-handling.md new file mode 100644 index 0000000..27e744b --- /dev/null +++ b/plugins/temporal-developer/skills/temporal-developer/references/ruby/data-handling.md @@ -0,0 +1,191 @@ +# Ruby SDK Data Handling + +## Overview + +Data converters serialize and deserialize workflow/activity inputs and outputs. The `Temporalio::Converters` module provides the conversion pipeline. + +## Default Data Converter + +The default converter handles types in this order: + +1. `nil` - null payload +2. Bytes - `String` with `ASCII_8BIT` encoding +3. Protobuf - objects implementing `Google::Protobuf::MessageExts` +4. JSON - everything else, via Ruby's `JSON` module + +Note: symbol keys become strings on deserialization. `create_additions: true` by default. + +## ActiveModel Integration + +Use the `ActiveModelJSONSupport` mixin to make ActiveModel objects work with Temporal's JSON serialization: + +```ruby +module ActiveModelJSONSupport + extend ActiveSupport::Concern + include ActiveModel::Serializers::JSON + + included do + def as_json(*) + super.merge(::JSON.create_id => self.class.name) + end + + def to_json(*args) + as_json.to_json(*args) + end + + def self.json_create(object) + object = object.dup + object.delete(::JSON.create_id) + new(**object.symbolize_keys) + end + end +end +``` + +Include it in any ActiveModel class to make it serializable by Temporal: + +```ruby +class OrderInput + include ActiveModel::Model + include ActiveModelJSONSupport + + attr_accessor :order_id, :items, :total +end +``` + +## Custom Data Conversion + +```ruby +converter = Temporalio::Converters::DataConverter.new( + payload_converter: my_payload_converter, + payload_codec: my_payload_codec, + failure_converter: my_failure_converter +) + +client = Temporalio::Client.connect( + 'localhost:7233', + 'default', + data_converter: converter +) +``` + +## Converter Hints + +Ruby-specific feature for guiding deserialization to the correct type: + +```ruby +class MyWorkflow + workflow_arg_hint MyClass + workflow_result_hint MyClass + + workflow_update :my_update, arg_hints: [MyClass] + + def execute(input) + # input is deserialized as MyClass + end +end +``` + +Custom converters use these hints to know the target deserialization type. + +## Payload Encryption + +Implement a `PayloadCodec` with `encode` and `decode`: + +```ruby +class EncryptionCodec + def encode(payloads) + payloads.map { |p| encrypt(p) } + end + + def decode(payloads) + payloads.map { |p| decrypt(p) } + end + + private + + def encrypt(payload) + # encryption logic + end + + def decrypt(payload) + # decryption logic + end +end + +converter = Temporalio::Converters::DataConverter.new( + payload_codec: EncryptionCodec.new +) +``` + +## Search Attributes + +Define a search attribute key: + +```ruby +key = Temporalio::SearchAttributes::Key.new( + 'CustomerId', + Temporalio::SearchAttributes::IndexedValueType::KEYWORD +) +``` + +Set at workflow start: + +```ruby +client.start_workflow( + MyWorkflow, + 'arg', + id: 'wf-1', + task_queue: 'my-queue', + search_attributes: Temporalio::SearchAttributes.new({ key => 'customer-123' }) +) +``` + +Upsert from a workflow: + +```ruby +Temporalio::Workflow.upsert_search_attributes({ key => 'new-value' }) +``` + +### Querying Workflows by Search Attributes + +```ruby +client.list_workflows("CustomerId = 'customer-123'") +``` + +## Workflow Memo + +Set at workflow start: + +```ruby +client.start_workflow( + MyWorkflow, + 'arg', + id: 'wf-1', + task_queue: 'my-queue', + memo: { 'region' => 'us-east', 'priority' => 'high' } +) +``` + +Read from within a workflow: + +```ruby +region = Temporalio::Workflow.memo['region'] +``` + +## Deterministic APIs for Values + +Use these instead of standard Ruby equivalents inside workflows: + +```ruby +Temporalio::Workflow.uuid # deterministic UUID +Temporalio::Workflow.random # deterministic random number +Temporalio::Workflow.now # deterministic current time +``` + +## Best Practices + +- Use dedicated model classes for Temporal data, not ActiveRecord models. +- Keep payloads small; store large data externally and pass references. +- Encrypt sensitive data with a `PayloadCodec`. +- Use `Temporalio::Workflow.uuid`, `.random`, and `.now` inside workflows for determinism. diff --git a/plugins/temporal-developer/skills/temporal-developer/references/ruby/determinism-protection.md b/plugins/temporal-developer/skills/temporal-developer/references/ruby/determinism-protection.md new file mode 100644 index 0000000..7cf5970 --- /dev/null +++ b/plugins/temporal-developer/skills/temporal-developer/references/ruby/determinism-protection.md @@ -0,0 +1,135 @@ +# Ruby Workflow Determinism Protection + +## Overview + +The Ruby SDK uses two mechanisms to enforce workflow determinism: + +1. **Illegal Call Tracing** -- `TracePoint`-based interception of forbidden method calls on the workflow fiber. +2. **Durable Fiber Scheduler** -- a custom `Fiber::Scheduler` that makes fiber operations deterministic. + +This differs from Python's sandbox (`SandboxedWorkflowRunner`) and TypeScript's V8 isolate sandbox. Ruby's approach is runtime tracing, not code isolation. + +## How Illegal Call Tracing Works + +A `TracePoint` is installed on the workflow fiber thread. On every `:call` and `:c_call` event, the SDK checks the receiver class and method name against a configurable set of illegal calls. + +```ruby +# Internally, the SDK does something like: +TracePoint.new(:call, :c_call) do |tp| + if illegal?(tp.defined_class, tp.method_id) + raise Temporalio::Workflow::NondeterminismError, + "Illegal call: #{tp.defined_class}##{tp.method_id}" + end +end +``` + +Key behaviors: + +- Raises `Temporalio::Workflow::NondeterminismError` on violation. +- Detects transitive calls -- a gem calling `IO.read` deep in its internals will still be caught. +- Only active on the workflow fiber, not on activity threads or other fibers. + +## Forbidden Operations in Workflows + +Default forbidden operations: + +- `Kernel.sleep` -- use `Temporalio::Workflow.sleep` +- `Time.now` (no args) -- use `Temporalio::Workflow.now` +- `Thread.new` -- not allowed in workflows +- `IO.*` -- all IO class methods (`IO.read`, `IO.write`, `IO.pipe`, etc.) +- `Socket.*` -- all socket operations +- `Net::HTTP.*` -- all HTTP client calls +- `Random.*` -- use `Temporalio::Workflow.random` +- `SecureRandom.*` -- use `Temporalio::Workflow.uuid` for UUIDs +- `Timeout.timeout` -- use `Temporalio::Workflow.sleep` with cancellation +- `Mutex` / `synchronize` -- use an explicit `Temporalio::Workflow::Mutex` + +Note: `Time.new('2000-12-31')` with arguments IS deterministic and allowed. Only `Time.now` (wall-clock) is forbidden. + +## Disabling Illegal Call Tracing + +Use `Temporalio::Workflow::Unsafe.illegal_call_tracing_disabled` when third-party code is known safe: + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + # Third-party gem that does harmless Time.now internally + result = Temporalio::Workflow::Unsafe.illegal_call_tracing_disabled do + SomeGem.format_data(input) + end + result + end +end +``` + +The block disables tracing only for its duration. Keep it as narrow as possible. + +## Customizing Illegal Calls + +Pass `illegal_workflow_calls:` to `Temporalio::Worker.new`: + +```ruby +worker = Temporalio::Worker.new( + client: client, + task_queue: 'my-queue', + workflows: [MyWorkflow], + illegal_workflow_calls: Temporalio::Worker.default_illegal_workflow_calls.merge( + 'MyInternalClass' => :all, + 'AnotherClass' => { dangerous_method: true } + ) +) +``` + +Default set available via: + +```ruby +Temporalio::Worker.default_illegal_workflow_calls +# => { 'Kernel' => { sleep: true }, 'IO' => :all, ... } +``` + +Hash format: + +- `{ 'ClassName' => :all }` -- block all methods on the class. +- `{ 'ClassName' => { method_name: true } }` -- block specific methods. + +## Common Issues + +### Third-party gems triggering NondeterminismError + +Gems that call `IO`, `Time.now`, or `Socket` internally will trigger errors even if you don't call those methods directly. The correct fix is context-dependent and requires understanding and possibly debugging of the situation. + +**Fix 1:** The call to a gem genuinely is doing IO, side effects, or other non-deterministic things. Then just like with other Temporal Workflow code, it should be moved into an activity. + +**Fix 2:** Escape hatch: for code that needs IO to run within the workflow, use `io_enabled`. You should very seriously consider why IO is needed in the workflow and not in an activity. If you use this escape hatch to create non-determinism issues, you might later face non-determinism errors. + +```ruby +Temporalio::Workflow::Unsafe.io_enabled do + config = YAML.load_file('config.yml') +end +``` + +**Fix 3:** Escape hatch: for code that triggers the illegal call tracing but doesn't cause actual non-determinism issues, you can disable `illegal_call_tracing_disabled`. Again, you must be sure that you are semantically correct that there is no non-determinism involved. + +```ruby +Temporalio::Workflow::Unsafe.illegal_call_tracing_disabled do + ThirdPartyGem.safe_pure_computation(data) +end +``` + +### Durable scheduler conflicts + +If a gem requires its own fiber scheduler behavior, disable the durable scheduler for that block: + +```ruby +Temporalio::Workflow::Unsafe.durable_scheduler_disabled do + some_fiber_aware_gem.call +end +``` + +## Best Practices + +- Use `Temporalio::Workflow.sleep`, `.now`, `.random`, `.uuid`, `.logger` for all workflow-level operations. +- Never perform IO, network calls, or file access in workflow code -- delegate to activities. +- Use `illegal_call_tracing_disabled` sparingly and only when you are certain the code is deterministic. +- Wrap side-effect-only code in `unless Temporalio::Workflow::Unsafe.replaying?` to avoid duplicate emissions during replay. +- Prefer activities over `io_enabled` blocks -- activities have proper retry, timeout, and heartbeat semantics. diff --git a/plugins/temporal-developer/skills/temporal-developer/references/ruby/determinism.md b/plugins/temporal-developer/skills/temporal-developer/references/ruby/determinism.md new file mode 100644 index 0000000..4e0f959 --- /dev/null +++ b/plugins/temporal-developer/skills/temporal-developer/references/ruby/determinism.md @@ -0,0 +1,63 @@ +# Ruby SDK Determinism + +## Overview + +The Ruby SDK enforces workflow determinism through **Illegal Call Tracing** (via `TracePoint`) and a **Durable Fiber Scheduler**. This is not a sandbox like Python's `SandboxedWorkflowRunner`; instead, it intercepts illegal calls at runtime on the workflow fiber. + +## Why Determinism Matters: History Replay + +Temporal re-executes workflow code from the beginning on recovery (worker restart, continue-as-new, etc.). Commands already recorded in history are matched against replayed commands. If the code produces different commands on replay, the workflow fails with a non-determinism error. + +All workflow code must therefore be deterministic: same input and history must produce the same sequence of commands every time. + +## SDK Protection: Illegal Call Tracing + +The Ruby SDK installs a `TracePoint` on the workflow fiber thread. Every method call is checked against a configurable set of illegal calls. If a forbidden method is invoked, the SDK raises `Temporalio::Workflow::NondeterminismError`. + +Configuration is via the `illegal_workflow_calls` parameter on `Temporalio::Worker.new`. The default set is available at: + +```ruby +Temporalio::Worker.default_illegal_workflow_calls +``` + +## Forbidden Operations in Workflows + +The following are forbidden inside workflow code by default: + +- `Kernel.sleep` -- blocks the fiber non-deterministically +- `Time.now` (without args) -- returns wall-clock time +- `Thread.new` -- spawns non-deterministic OS threads +- `IO` operations (`IO.read`, `IO.write`, `File.open`, etc.) +- `Random.rand` / `SecureRandom` -- non-deterministic randomness +- `Process` calls (`Process.spawn`, `Process.exec`, etc.) +- Network calls (`Net::HTTP`, `Socket`, etc.) +- `Mutex` / `synchronize` + +## Safe Builtin Alternatives to Common Non Deterministic Things + +| Forbidden | Safe Alternative | +|-----------|------------------| +| `Kernel.sleep(n)` | `Temporalio::Workflow.sleep(n)` | +| `Time.now` | `Temporalio::Workflow.now` | +| `Random.rand` / `SecureRandom` | `Temporalio::Workflow.random.rand(100)` | +| `SecureRandom.uuid` | `Temporalio::Workflow.uuid` | +| `Logger.new` / `puts` | `Temporalio::Workflow.logger.info(...)` | +| `Mutex` / `synchronize` | explicit `Temporalio::Workflow::Mutex` | + +## Testing Replay Compatibility + +Use `Temporalio::Worker::WorkflowReplayer` to verify that workflow code is replay-compatible against recorded histories. See `testing.md` for details. + +```ruby +replayer = Temporalio::Worker::WorkflowReplayer.new( + workflows: [MyWorkflow] +) +replayer.replay_workflow(workflow_history) +``` + +## Best Practices + +- Use `Temporalio::Workflow.sleep`, `.now`, `.random`, `.uuid`, `.logger` instead of stdlib equivalents. +- Delegate all I/O, network calls, and side effects to activities. +- Test with `WorkflowReplayer` against saved histories before deploying workflow changes. +- Use `Temporalio::Workflow.logger` for all logging inside workflows -- it is replay-aware and suppresses duplicate logs during replay. diff --git a/plugins/temporal-developer/skills/temporal-developer/references/ruby/error-handling.md b/plugins/temporal-developer/skills/temporal-developer/references/ruby/error-handling.md new file mode 100644 index 0000000..a66f350 --- /dev/null +++ b/plugins/temporal-developer/skills/temporal-developer/references/ruby/error-handling.md @@ -0,0 +1,103 @@ +# Ruby SDK Error Handling + +## Overview + +Application errors use `Temporalio::Error::ApplicationError`. Retry behavior is configured via `Temporalio::RetryPolicy`. + +## Application Errors + +```ruby +raise Temporalio::Error::ApplicationError.new('message', type: 'ErrorType') +``` + +In activities, any Ruby exception is automatically converted to an `ApplicationError`. + +## Non-Retryable Errors + +```ruby +raise Temporalio::Error::ApplicationError.new( + 'message', + non_retryable: true +) +``` + +Override the retry interval with `next_retry_delay:`: + +```ruby +raise Temporalio::Error::ApplicationError.new( + 'message', + next_retry_delay: 30 +) +``` + +## Handling Activity Errors + +```ruby +begin + Temporalio::Workflow.execute_activity(MyActivity, 'arg', start_to_close_timeout: 10) +rescue Temporalio::Error::ActivityError => e + # Let cancellation propagate so the workflow is canceled, not failed. + # Temporalio::Error.canceled? is true for a CanceledError, or an + # ActivityError/ChildWorkflowError whose cause is a CanceledError. + raise if Temporalio::Error.canceled?(e) + + Temporalio::Workflow.logger.error("Activity failed: #{e.message}") + # Deliberately fail the workflow. Only raising an ApplicationError fails a + # workflow; other exceptions only fail/retry the workflow task. + raise Temporalio::Error::ApplicationError.new('Workflow failed due to activity error') +end +``` + +## Retry Policy Configuration + +```ruby +retry_policy: Temporalio::RetryPolicy.new( + max_interval: 60, + max_attempts: 5, + non_retryable_error_types: ['ValidationError'] +) +``` + +Only set retry policies when you have a domain-specific reason. Prefer the defaults otherwise. + +## Timeout Configuration + +```ruby +Temporalio::Workflow.execute_activity( + MyActivity, + 'arg', + start_to_close_timeout: 30, + schedule_to_close_timeout: 300, + heartbeat_timeout: 10 +) +``` + +All timeout values are in seconds (numeric). + +## Workflow Failure + +Only `Temporalio::Error::ApplicationError` causes a workflow failure. Other exceptions cause a workflow task failure, which Temporal retries automatically. + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + if some_condition + raise Temporalio::Error::ApplicationError.new( + 'Cannot process order', + type: 'BusinessError' + ) + end + 'success' + end +end +``` + +**Note:** Do not use `non_retryable:` with `ApplicationError` inside a workflow (as opposed to an activity). + +## Best Practices + +- Use specific error types (`type:` parameter) for differentiation. +- Mark permanent failures as `non_retryable: true`. +- Set retry policies only when defaults are insufficient. +- Log errors before re-raising. +- Design activities for idempotency so retries are safe. diff --git a/plugins/temporal-developer/skills/temporal-developer/references/ruby/gotchas.md b/plugins/temporal-developer/skills/temporal-developer/references/ruby/gotchas.md new file mode 100644 index 0000000..c2e962c --- /dev/null +++ b/plugins/temporal-developer/skills/temporal-developer/references/ruby/gotchas.md @@ -0,0 +1,253 @@ +# Ruby Gotchas + +Ruby-specific mistakes and anti-patterns. See also [Common Gotchas](references/core/gotchas.md) for language-agnostic concepts. + +## File Organization + +Unlike Python, Ruby doesn't reload workflow files (no sandbox). Still best practice to separate workflows and activities for clarity and maintainability. + +```ruby +# BAD - Everything in one file +# app.rb +class MyWorkflow < Temporalio::Workflow::Definition + def execute(name) + Temporalio::Workflow.execute_activity( + MyActivity, + name, + start_to_close_timeout: 30 + ) + end +end + +class MyActivity < Temporalio::Activity::Definition + def execute(name) + # Heavy I/O, external calls, etc. + end +end +``` + +```ruby +# GOOD - Separate files +# workflows/my_workflow.rb +require 'temporalio/workflow' + +class MyWorkflow < Temporalio::Workflow::Definition + def execute(name) + Temporalio::Workflow.execute_activity( + MyActivity, + name, + start_to_close_timeout: 30 + ) + end +end + +# activities/my_activity.rb +require 'temporalio/activity' + +class MyActivity < Temporalio::Activity::Definition + def execute(name) + # Heavy I/O, external calls, etc. + end +end + +# worker.rb +require_relative 'workflows/my_workflow' +require_relative 'activities/my_activity' +``` + +## Wrong Retry Classification + +Transient network errors should be retried. Authentication errors should not be. See `references/ruby/error-handling.md` to understand how to classify errors with `non_retryable: true` and `non_retryable_error_types`. + + +## Heartbeating + +### Forgetting to Heartbeat Long Activities + +```ruby +# BAD - No heartbeat, can't detect stuck activities +class ProcessLargeFile < Temporalio::Activity::Definition + def execute(path) + File.foreach(path).each_slice(1000) do |chunk| + process(chunk) # Takes hours, no heartbeat + end + end +end +``` + +```ruby +# GOOD - Regular heartbeats with progress +class ProcessLargeFile < Temporalio::Activity::Definition + def execute(path) + File.foreach(path).each_slice(1000).with_index do |chunk, i| + Temporalio::Activity::Context.current.heartbeat("Processing chunk #{i}") + process(chunk) + end + end +end +``` + +### Heartbeat Timeout Too Short + +```ruby +# BAD - Heartbeat timeout shorter than processing time between heartbeats +Temporalio::Workflow.execute_activity( + ProcessChunk, + start_to_close_timeout: 1800, + heartbeat_timeout: 10 # Too short! +) + +# GOOD - Heartbeat timeout allows for processing variance +Temporalio::Workflow.execute_activity( + ProcessChunk, + start_to_close_timeout: 1800, + heartbeat_timeout: 120 +) +``` + +Set heartbeat timeout as high as acceptable for your use case -- each heartbeat counts as an action. + +## Cancellation + +### Not Handling Workflow Cancellation + +```ruby +# BAD - Cleanup doesn't run on cancellation +class BadWorkflow < Temporalio::Workflow::Definition + def execute + Temporalio::Workflow.execute_activity(AcquireResource, start_to_close_timeout: 300) + Temporalio::Workflow.execute_activity(DoWork, start_to_close_timeout: 300) + Temporalio::Workflow.execute_activity(ReleaseResource, start_to_close_timeout: 300) # Never runs if cancelled! + end +end +``` + +```ruby +# GOOD - Use ensure with detached cancellation for cleanup +class GoodWorkflow < Temporalio::Workflow::Definition + def execute + Temporalio::Workflow.execute_activity(AcquireResource, start_to_close_timeout: 300) + Temporalio::Workflow.execute_activity(DoWork, start_to_close_timeout: 300) + ensure + # Create a detached cancellation (not tied to workflow cancellation) + # so cleanup activity runs even after workflow is cancelled + detached_cancel, _cancel_proc = Temporalio::Cancellation.new + Temporalio::Workflow.execute_activity( + ReleaseResource, + start_to_close_timeout: 300, + cancellation: detached_cancel + ) + end +end +``` + +### Not Handling Activity Cancellation + +Activities must **opt in** to receive cancellation. This requires: + +1. **Heartbeating** -- cancellation is delivered via the heartbeat response +2. **Catching the cancellation exception** -- `Temporalio::Error::CancelledError` is raised when a heartbeat detects cancellation + +```ruby +# BAD - Activity ignores cancellation +class LongActivity < Temporalio::Activity::Definition + def execute + do_expensive_work # Runs to completion even if cancelled + end +end +``` + +```ruby +# GOOD - Heartbeat and handle cancellation +class LongActivity < Temporalio::Activity::Definition + def execute + items.each do |item| + Temporalio::Activity::Context.current.heartbeat + process(item) + end + rescue Temporalio::Error::CancelledError + cleanup + raise + end +end +``` + +## Testing + +### Not Testing Failures + +Make sure workflows work as expected under failure paths, not just happy paths. See `references/ruby/testing.md` for more info. + +### Not Testing Replay + +Replay tests help you detect hidden sources of non-determinism in your workflow code and should be considered in addition to standard testing. See `references/ruby/testing.md` for more info. + +## Timers and Sleep + +### Using Kernel.sleep + +```ruby +# BAD - Kernel.sleep raises NondeterminismError +class BadWorkflow < Temporalio::Workflow::Definition + def execute + sleep(60) # NondeterminismError! + Kernel.sleep(60) # NondeterminismError! + end +end +``` + +```ruby +# GOOD - Use Temporalio::Workflow.sleep for durable timers +class GoodWorkflow < Temporalio::Workflow::Definition + def execute + Temporalio::Workflow.sleep(60) # Deterministic, durable timer + end +end +``` + +**Why this matters:** `Kernel.sleep` uses the system clock, which differs between original execution and replay. `Temporalio::Workflow.sleep` creates a durable timer in the event history, ensuring consistent behavior during replay. + +## Illegal Call Tracing Gotchas + +### Third-Party Gems Triggering NondeterminismError + +The Ruby SDK uses `TracePoint`-based illegal call tracing on the workflow fiber. Any gem that internally uses `Thread`, `IO`, `Socket`, `Net::HTTP`, or other forbidden operations will trigger `NondeterminismError` -- even if the call is deep in the gem's internals. + +```ruby +# BAD - Logging gem that uses Thread internally +class MyWorkflow < Temporalio::Workflow::Definition + def execute + SomeFancyLogger.info("Starting workflow") # NondeterminismError if gem uses Thread.new! + end +end +``` + +### Fix: Disable Illegal Call Tracing for Specific Code + +```ruby +# Wrap non-deterministic but safe code +Temporalio::Workflow::Unsafe.illegal_call_tracing_disabled do + # Code here won't trigger NondeterminismError + SomeFancyLogger.info("Starting workflow") +end +``` + +For code that performs IO that you know is safe and want to allow: + +```ruby +# Disable the durable scheduler for IO operations +Temporalio::Workflow::Unsafe.durable_scheduler_disabled do + # IO operations allowed here +end +``` + +### Side Effects and Replay Safety + +Always check replaying status before performing side effects in workflows: + +```ruby +unless Temporalio::Workflow::Unsafe.replaying? + # Only runs during original execution, not replay + Temporalio::Workflow.logger.info("Processing started") +end +``` diff --git a/plugins/temporal-developer/skills/temporal-developer/references/ruby/observability.md b/plugins/temporal-developer/skills/temporal-developer/references/ruby/observability.md new file mode 100644 index 0000000..bfe9107 --- /dev/null +++ b/plugins/temporal-developer/skills/temporal-developer/references/ruby/observability.md @@ -0,0 +1,81 @@ +# Ruby SDK Observability + +## Overview + +Temporal Ruby SDK provides logging, metrics, tracing, and visibility for monitoring workflows and activities. + +## Logging + +### Workflow Logging (Replay-Safe) + +```ruby +Temporalio::Workflow.logger.info("Processing order") +Temporalio::Workflow.logger.warn("Retrying with fallback") +Temporalio::Workflow.logger.error("Order failed: #{reason}") +``` + +### Activity Logging + +```ruby +Temporalio::Activity::Context.current.logger.info("Sending email") +Temporalio::Activity::Context.current.logger.warn("Slow response: #{elapsed}s") +``` + +Do not use `puts` or `print` in workflows. They are not replay-safe and produce duplicate output on replay. + +### Customizing Logger Configuration + +```ruby +require 'logger' + +# The logger set on the client is used by Temporalio::Workflow.logger and +# Temporalio::Activity::Context.current.logger. Defaults to stdout at WARN. +client = Temporalio::Client.connect( + 'localhost:7233', 'my-namespace', + logger: Logger.new($stdout, level: Logger::INFO) +) +``` + +## Metrics + +### Enabling SDK Metrics + +Configure telemetry via `Temporalio::Runtime`: + +```ruby +prometheus_options = Temporalio::Runtime::PrometheusMetricsOptions.new( + bind_address: '0.0.0.0:9000' +) + +metrics_options = Temporalio::Runtime::MetricsOptions.new( + prometheus: prometheus_options +) + +telemetry_options = Temporalio::Runtime::TelemetryOptions.new( + metrics: metrics_options +) + +runtime = Temporalio::Runtime.new(telemetry: telemetry_options) +Temporalio::Runtime.default = runtime +``` + +Set the default runtime **before** creating any clients or workers. + +### Key SDK Metrics + +- `temporal_workflow_completed` - workflow completions +- `temporal_workflow_failed` - workflow failures +- `temporal_activity_execution_latency` - activity duration +- `temporal_sticky_cache_hit` - workflow cache hits +- `temporal_workflow_task_execution_latency` - workflow task duration + +## Search Attributes (Visibility) + +See the Search Attributes section of `references/ruby/data-handling.md` + +## Best Practices + +- Use `Temporalio::Workflow.logger` in workflows, `Temporalio::Activity::Context.current.logger` in activities. +- Configure Prometheus metrics for production deployments. +- Use Search Attributes for workflow visibility and filtering. +- Never use `puts` or `print` in workflow code. diff --git a/plugins/temporal-developer/skills/temporal-developer/references/ruby/patterns.md b/plugins/temporal-developer/skills/temporal-developer/references/ruby/patterns.md new file mode 100644 index 0000000..0bfa33c --- /dev/null +++ b/plugins/temporal-developer/skills/temporal-developer/references/ruby/patterns.md @@ -0,0 +1,403 @@ +# Ruby SDK Patterns + +## Signals + +```ruby +class OrderWorkflow < Temporalio::Workflow::Definition + def initialize + @approved = false + @items = [] + end + + workflow_signal + def approve + @approved = true + end + + workflow_signal + def add_item(item) + @items << item + end + + def execute + Temporalio::Workflow.wait_condition { @approved } + "Processed #{@items.length} items" + end +end +``` + +### Dynamic Signal Handlers + +For handling signals with names not known at compile time. Use cases for this pattern are rare — most workflows should use statically defined signal handlers. + +```ruby +class DynamicSignalWorkflow < Temporalio::Workflow::Definition + def initialize + @signals = {} + end + + workflow_signal dynamic: true, raw_args: true + def dynamic_signal(signal_name, *args) + @signals[signal_name] ||= [] + @signals[signal_name] << Temporalio::Workflow.payload_converter.from_payload(args.first) + end +end +``` + +## Queries + +**Important:** Queries must NOT modify workflow state or have side effects. + +```ruby +class StatusWorkflow < Temporalio::Workflow::Definition + def initialize + @status = 'pending' + @progress = 0 + end + + # Shorthand for simple attribute readers + workflow_query_attr_reader :status, :progress + + def execute + @status = 'running' + 100.times do |i| + @progress = i + Temporalio::Workflow.execute_activity( + ProcessItem, i, + start_to_close_timeout: 60 + ) + end + @status = 'completed' + 'done' + end +end +``` + +### Dynamic Query Handlers + +For handling queries with names not known at compile time. Use cases for this pattern are rare — most workflows should use statically defined query handlers. + +```ruby +workflow_query dynamic: true, raw_args: true +def dynamic_query(query_name, *args) + if query_name == 'get_field' + field_name = Temporalio::Workflow.payload_converter.from_payload(args.first) + instance_variable_get(:"@#{field_name}") + end +end +``` + +## Updates + +```ruby +class OrderWorkflow < Temporalio::Workflow::Definition + def initialize + @items = [] + end + + workflow_update + def add_item(item) + @items << item + @items.length # Returns new count to caller + end + + workflow_update_validator(:add_item) + def validate_add_item(item) + raise 'Item cannot be empty' if item.nil? || item.empty? + raise 'Order is full' if @items.length >= 100 + end +end +``` + +**Important:** Validators must NOT mutate workflow state or do anything blocking (no activities, sleeps, or other commands). They are read-only, similar to query handlers. Raise an exception to reject the update; return `nil` to accept. + +## Child Workflows + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute(orders) + results = [] + orders.each do |order| + result = Temporalio::Workflow.execute_child_workflow( + ProcessOrderWorkflow, order, + id: "order-#{order.id}", + parent_close_policy: Temporalio::Workflow::ParentClosePolicy::ABANDON + ) + results << result + end + results + end +end +``` + +### Child Workflow Options + +```ruby +Temporalio::Workflow.execute_child_workflow( + ChildWorkflow, arg, + id: 'child-1', + parent_close_policy: Temporalio::Workflow::ParentClosePolicy::ABANDON, + cancellation_type: Temporalio::Workflow::ChildWorkflowCancellationType::WAIT_CANCELLATION_COMPLETED, + execution_timeout: 3600, + run_timeout: 1800 +) +``` + +## Handles to External Workflows + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute(target_workflow_id) + handle = Temporalio::Workflow.external_workflow_handle(target_workflow_id) + + # Signal the external workflow + handle.signal(TargetWorkflow.data_ready, data_payload) + + # Or cancel it + handle.cancel + end +end +``` + +## Parallel Execution + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute(items) + futures = items.map do |item| + Temporalio::Workflow::Future.new do + Temporalio::Workflow.execute_activity( + ProcessItem, item, + start_to_close_timeout: 300 + ) + end + end + Temporalio::Workflow::Future.all_of(*futures).wait + results = futures.map(&:result) + results + end +end +``` + +## Continue-as-New + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute(state) + loop do + state = process_batch(state) + + return 'done' if state.complete? + + # Continue with fresh history before hitting limits + if Temporalio::Workflow.continue_as_new_suggested + raise Temporalio::Workflow::ContinueAsNewError.new(state) + end + end + end +end +``` + +## Saga Pattern (Compensations) + +**Important:** Compensation activities should be idempotent - they may be retried (as with ALL activities). + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute(order) + compensations = [] + + begin + # Save compensation before running the activity, because: + # 1. reserve_inventory starts running + # 2. it successfully reserves inventory + # 3. but then fails for some other reason (timeout, reporting metrics, etc.) + # 4. the activity failed, but the effect (reserved inventory) already happened + # So the compensation must handle both reserved and unreserved states. + compensations << lambda { |cancellation| + Temporalio::Workflow.execute_activity( + ReleaseInventoryIfReserved, order, + start_to_close_timeout: 300, + cancellation: cancellation + ) + } + Temporalio::Workflow.execute_activity( + ReserveInventory, order, + start_to_close_timeout: 300 + ) + + compensations << lambda { |cancellation| + Temporalio::Workflow.execute_activity( + RefundPaymentIfCharged, order, + start_to_close_timeout: 300, + cancellation: cancellation + ) + } + Temporalio::Workflow.execute_activity( + ChargePayment, order, + start_to_close_timeout: 300 + ) + + Temporalio::Workflow.execute_activity( + ShipOrder, order, + start_to_close_timeout: 300 + ) + + 'Order completed' + + rescue => e + Temporalio::Workflow.logger.error("Order failed: #{e}, running compensations") + # Use a detached cancellation so compensations still run even if the workflow + # was canceled (the workflow's own cancellation is already canceled by then). + detached_cancel, = Temporalio::Cancellation.new + compensations.reverse.each do |compensate| + begin + compensate.call(detached_cancel) + rescue => comp_err + Temporalio::Workflow.logger.error("Compensation failed: #{comp_err}") + end + end + raise + end + end +end +``` + +## Cancellation (Token-based) + +Ruby uses `Temporalio::Cancellation` tokens. + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + # The workflow's cancellation token + workflow_cancel = Temporalio::Workflow.cancellation + + begin + Temporalio::Workflow.execute_activity( + LongRunningActivity, + start_to_close_timeout: 3600, + cancellation: workflow_cancel + ) + 'completed' + ensure + # Create a detached cancellation for cleanup + # (not tied to workflow cancellation) + cancel, _cancel_proc = Temporalio::Cancellation.new + Temporalio::Workflow.execute_activity( + CleanupActivity, + start_to_close_timeout: 300, + cancellation: cancel + ) + end + end +end +``` + +## Wait Condition with Timeout + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + @approved = false + + # Wait for approval with 24-hour timeout + # Returns false on timeout (no exception raised) + if Temporalio::Workflow.wait_condition(timeout: 86400) { @approved } + 'approved' + else + 'auto-rejected due to timeout' + end + end +end +``` + +## Waiting for All Handlers to Finish + +Signal and update handlers should generally be non-async (avoid running activities from them). Otherwise, the workflow may complete before handlers finish their execution. However, making handlers non-async sometimes requires workarounds that add complexity. + +When async handlers are necessary, use `wait_condition { all_handlers_finished }` at the end of your workflow (or before continue-as-new) to prevent completion until all pending handlers complete. + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + # ... main workflow logic ... + + # Before exiting, wait for all handlers to finish + Temporalio::Workflow.wait_condition { Temporalio::Workflow.all_handlers_finished? } + 'done' + end +end +``` + +## Activity Heartbeat Details + +### WHY: +- **Support activity cancellation** - Cancellations are delivered via heartbeat; activities that don't heartbeat won't know they've been cancelled +- **Resume progress after worker failure** - Heartbeat details persist across retries + +### WHEN: +- **Cancellable activities** - Any activity that should respond to cancellation +- **Long-running activities** - Track progress for resumability +- **Checkpointing** - Save progress periodically + +```ruby +class ProcessLargeFile < Temporalio::Activity::Definition + def execute(file_path) + context = Temporalio::Activity::Context.current + + # Get heartbeat details from previous attempt (if any) + heartbeat_details = context.info.heartbeat_details + start_line = heartbeat_details&.first || 0 + + begin + File.foreach(file_path).with_index do |line, i| + next if i < start_line + + process_line(line) + + # Heartbeat with progress + # If cancelled, heartbeat raises Temporalio::Error::CanceledError + context.heartbeat(i + 1) + end + + 'completed' + rescue Temporalio::Error::CanceledError + cleanup + raise + end + end +end +``` + +## Timers + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + Temporalio::Workflow.sleep(3600) + + 'Timer fired' + end +end +``` + +## Local Activities + +**Purpose**: Reduce latency for short, lightweight operations by skipping the task queue. ONLY use these when necessary for performance. Do NOT use these by default, as they are not durable and distributed. + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + result = Temporalio::Workflow.execute_local_activity( + QuickLookup, 'key', + start_to_close_timeout: 5 + ) + result + end +end +``` + +## Using ActiveModel + +See `references/ruby/data-handling.md`. diff --git a/plugins/temporal-developer/skills/temporal-developer/references/ruby/ruby.md b/plugins/temporal-developer/skills/temporal-developer/references/ruby/ruby.md new file mode 100644 index 0000000..bc7f9e0 --- /dev/null +++ b/plugins/temporal-developer/skills/temporal-developer/references/ruby/ruby.md @@ -0,0 +1,146 @@ +# Temporal Ruby SDK Reference + +## Overview + +The Temporal Ruby SDK (`temporalio` gem) provides a class-based approach to building durable workflows. Ruby 3.3+ required. Workflows run using a Durable Fiber Scheduler for determinism protection, with Illegal Call Tracing via `TracePoint` to detect non-deterministic operations. + +## Quick Demo of Temporal + +**Add Dependency on Temporal:** Add `temporalio` to your Gemfile or install directly with `gem install temporalio`. + +**say_hello_activity.rb** - Activity definition: +```ruby +require 'temporalio/activity' + +class SayHelloActivity < Temporalio::Activity::Definition + def execute(name) + "Hello, #{name}!" + end +end +``` + +**say_hello_workflow.rb** - Workflow definition: +```ruby +require 'temporalio/workflow' + +class SayHelloWorkflow < Temporalio::Workflow::Definition + def execute(name) + Temporalio::Workflow.execute_activity( + SayHelloActivity, + name, + schedule_to_close_timeout: 30 + ) + end +end +``` + +**worker.rb** - Worker setup (imports activity and workflow, runs indefinitely and processes tasks): +```ruby +require 'temporalio/client' +require 'temporalio/worker' +require_relative 'say_hello_activity' +require_relative 'say_hello_workflow' + +# Create client connected to server at the given address +# This is the default port for `temporal server start-dev` +client = Temporalio::Client.connect('localhost:7233', 'default') + +# Create and run the worker +worker = Temporalio::Worker.new( + client: client, + task_queue: 'my-task-queue', + workflows: [SayHelloWorkflow], + activities: [SayHelloActivity] +) +worker.run +``` + +**Start the dev server:** Start `temporal server start-dev` in the background. + +**Start the worker:** Start `ruby worker.rb` in the background. + +**execute_workflow.rb** - Start a workflow execution: +```ruby +require 'temporalio/client' +require 'securerandom' +require_relative 'say_hello_workflow' + +# Create client connected to server at the given address +client = Temporalio::Client.connect('localhost:7233', 'default') + +# Execute a workflow +result = client.execute_workflow( + SayHelloWorkflow, + 'my name', + id: SecureRandom.uuid, + task_queue: 'my-task-queue' +) + +puts "Result: #{result}" +``` + +**Run the workflow:** Run `ruby execute_workflow.rb`. Should output: `Result: Hello, my name!`. + +## Key Concepts + +### Workflow Definition +- Subclass `Temporalio::Workflow::Definition` +- Define `def execute(args)` as the entry point +- Use `Temporalio::Workflow.execute_activity` to call activities +- Define signals, queries, and updates via class-level DSL methods + +### Activity Definition +- Subclass `Temporalio::Activity::Definition` +- Define `def execute(args)` as the entry point +- Activities contain all non-deterministic and side-effectful code +- Can access `Temporalio::Activity::Context.current` for heartbeating + +### Worker Setup +- Connect client with `Temporalio::Client.connect` +- Create worker with `Temporalio::Worker.new(client:, task_queue:, workflows:, activities:)` +- Run with `worker.run` + +### Determinism + +**Workflow code must be deterministic!** The Ruby SDK uses a Durable Fiber Scheduler and Illegal Call Tracing (via Ruby's `TracePoint`) to detect non-deterministic operations at runtime. All sources of non-determinism should either use Temporal-provided alternatives or be defined in Activities. Read `references/core/determinism.md` and `references/ruby/determinism.md` to understand more. + +## File Organization Best Practice + +**Keep Workflow definitions in separate files from Activity definitions.** Unlike Python, Ruby does not have a sandbox reloading concern, but separating workflows and activities is still good practice for clarity and maintainability. Use `require_relative` to import between files. + +``` +my_temporal_app/ +├── workflows/ +│ └── say_hello_workflow.rb # Only Workflow classes +├── activities/ +│ └── say_hello_activity.rb # Only Activity classes +├── worker.rb # Worker setup, requires both +└── execute_workflow.rb # Client code to start workflows +``` + +## Common Pitfalls + +1. **Using `sleep` instead of `Temporalio::Workflow.sleep`** - Standard `sleep` is non-deterministic and will be flagged by Illegal Call Tracing; use the Temporal-provided version +2. **Using `Time.now` instead of `Temporalio::Workflow.now`** - Same issue; `Time.now` is non-deterministic in workflow context +3. **Third-party gems triggering illegal calls** - Gems that perform I/O, use threads, or call system time will be caught by `TracePoint` tracing; move that logic to activities +4. **Using `puts`/`Logger` in workflows** - Use `Temporalio::Workflow.logger` instead for replay-safe logging +5. **Not heartbeating long activities** - Long-running activities need `Temporalio::Activity::Context.current.heartbeat` +6. **Mixing Workflows and Activities in same file** - Bad structure; keep them separated for clarity + +## Writing Tests + +See `references/ruby/testing.md` for info on writing tests. + +## Additional Resources + +### Reference Files +- **`references/ruby/patterns.md`** - Signals, queries, child workflows, saga pattern, etc. +- **`references/ruby/determinism.md`** - Durable Fiber Scheduler behavior, safe alternatives, history replay +- **`references/ruby/determinism-protection.md`** - Illegal Call Tracing via TracePoint, forbidden operations, runtime detection +- **`references/ruby/versioning.md`** - Patching API, workflow type versioning, Worker Versioning +- **`references/ruby/testing.md`** - Test environments, time-skipping, activity mocking +- **`references/ruby/error-handling.md`** - ApplicationError, retry policies, non-retryable errors, idempotency +- **`references/ruby/data-handling.md`** - Data converters, payload encryption +- **`references/ruby/observability.md`** - Logging, metrics, tracing, Search Attributes +- **`references/ruby/gotchas.md`** - Ruby-specific mistakes and anti-patterns +- **`references/ruby/advanced-features.md`** - Schedules, worker tuning, and more diff --git a/plugins/temporal-developer/skills/temporal-developer/references/ruby/testing.md b/plugins/temporal-developer/skills/temporal-developer/references/ruby/testing.md new file mode 100644 index 0000000..5135b78 --- /dev/null +++ b/plugins/temporal-developer/skills/temporal-developer/references/ruby/testing.md @@ -0,0 +1,234 @@ +# Ruby SDK Testing + +## Overview + +The Temporal Ruby SDK provides testing utilities compatible with any Ruby test framework (minitest is commonly used). The two main testing classes are `Temporalio::Testing::WorkflowEnvironment` for end-to-end workflow testing and `Temporalio::Testing::ActivityEnvironment` for isolated activity testing. + +## Workflow Test Environment + +The core pattern: +1. Start a test `WorkflowEnvironment` with `start_local` +2. Create a Worker in that environment with your Workflows and Activities registered +3. Execute the Workflow using the environment's client +4. Assert on the result + +```ruby +require 'minitest/autorun' +require 'securerandom' +require 'temporalio/testing/workflow_environment' +require 'temporalio/worker' + +require_relative '../workflows/my_workflow' +require_relative '../activities/my_activity' + +class MyWorkflowTest < Minitest::Test + def test_workflow_returns_expected_result + Temporalio::Testing::WorkflowEnvironment.start_local do |env| + task_queue = SecureRandom.uuid + + worker = Temporalio::Worker.new( + client: env.client, + task_queue: task_queue, + workflows: [MyWorkflow], + activities: [MyActivity] + ) + + worker.run do + result = env.client.execute_workflow( + MyWorkflow, + 'input-arg', + id: SecureRandom.uuid, + task_queue: task_queue + ) + + assert_equal 'expected output', result + end + end + end +end +``` + +For workflows with long durations (timers, sleeps), use `start_time_skipping` instead of `start_local`: + +```ruby +Temporalio::Testing::WorkflowEnvironment.start_time_skipping do |env| + # Timers are automatically skipped +end +``` + +## Mocking Activities + +Create fake activity classes with the same activity name as the real ones. Pass them to the Worker instead of the real activities: + +```ruby +class FakeComposeGreetingActivity < Temporalio::Activity::Definition + activity_name 'ComposeGreetingActivity' + + def execute(input) + 'mocked greeting' + end +end + +class MyWorkflowMockTest < Minitest::Test + def test_workflow_with_mocked_activity + Temporalio::Testing::WorkflowEnvironment.start_local do |env| + task_queue = SecureRandom.uuid + + worker = Temporalio::Worker.new( + client: env.client, + task_queue: task_queue, + workflows: [MyWorkflow], + activities: [FakeComposeGreetingActivity] + ) + + worker.run do + result = env.client.execute_workflow( + MyWorkflow, + 'test-input', + id: SecureRandom.uuid, + task_queue: task_queue + ) + + assert_equal 'mocked greeting', result + end + end + end +end +``` + +## Testing Signals and Queries + +Use `start_workflow` to get a handle, then interact via signal/query methods: + +```ruby +class SignalQueryTest < Minitest::Test + def test_signal_and_query + Temporalio::Testing::WorkflowEnvironment.start_local do |env| + task_queue = SecureRandom.uuid + + worker = Temporalio::Worker.new( + client: env.client, + task_queue: task_queue, + workflows: [MyWorkflow], + activities: [MyActivity] + ) + + worker.run do + handle = env.client.start_workflow( + MyWorkflow, + id: SecureRandom.uuid, + task_queue: task_queue + ) + + # Send a signal + handle.signal(MyWorkflow.my_signal, 'signal-data') + + # Query workflow state + status = handle.query(MyWorkflow.get_status) + assert_equal 'expected-status', status + + # Wait for completion + result = handle.result + assert_equal 'done', result + end + end + end +end +``` + +## Testing Failure Cases + +Test workflows that encounter errors using activities that raise exceptions: + +```ruby +class FailingActivity < Temporalio::Activity::Definition + activity_name 'MyActivity' + + def execute(input) + raise Temporalio::Error::ApplicationError.new('Simulated failure', non_retryable: true) + end +end + +class FailureTest < Minitest::Test + def test_workflow_handles_activity_failure + Temporalio::Testing::WorkflowEnvironment.start_local do |env| + task_queue = SecureRandom.uuid + + worker = Temporalio::Worker.new( + client: env.client, + task_queue: task_queue, + workflows: [MyWorkflow], + activities: [FailingActivity] + ) + + worker.run do + assert_raises(Temporalio::Error::WorkflowFailureError) do + env.client.execute_workflow( + MyWorkflow, + 'input', + id: SecureRandom.uuid, + task_queue: task_queue + ) + end + end + end + end +end +``` + +## Workflow Replay Testing + +Use `WorkflowReplayer` to verify that workflow code changes remain compatible with existing histories: + +```ruby +require 'temporalio/worker/workflow_replayer' +require 'temporalio/workflow_history' + +class ReplayTest < Minitest::Test + def test_replay_from_json + json = File.read('test/fixtures/my_workflow_history.json') + replayer = Temporalio::Worker::WorkflowReplayer.new(workflows: [MyWorkflow]) + + # Replay a single workflow history + replayer.replay_workflow( + Temporalio::WorkflowHistory.from_history_json(json) + ) + end + + def test_replay_bulk + histories = Dir['test/fixtures/histories/*.json'].map do |path| + Temporalio::WorkflowHistory.from_history_json(File.read(path)) + end + + replayer = Temporalio::Worker::WorkflowReplayer.new(workflows: [MyWorkflow]) + + # Replay multiple histories - raises on nondeterminism + replayer.replay_workflows(histories) + end +end +``` + +## Activity Testing + +Use `ActivityEnvironment` to test activities in isolation without a full Temporal server: + +```ruby +require 'temporalio/testing/activity_environment' + +class ActivityTest < Minitest::Test + def test_activity_returns_greeting + env = Temporalio::Testing::ActivityEnvironment.new + result = env.run(MyActivity, 'World') + assert_equal 'Hello, World!', result + end +end +``` + +## Best Practices + +1. **Use `start_local` for most tests** - provides a real Temporal environment without external dependencies +2. **Use `start_time_skipping` for timer tests** - automatically skips timers rather than waiting +3. **Mock external dependencies** - create fake activity classes with `activity_name` matching the real activity +4. **Test replay compatibility** - add replay tests when changing workflow code to catch nondeterminism errors early +5. **Use unique IDs per test** - use `SecureRandom.uuid` for workflow IDs and task queue names to avoid conflicts +6. **Test signals and queries explicitly** - use `start_workflow` to get a handle rather than `execute_workflow` diff --git a/plugins/temporal-developer/skills/temporal-developer/references/ruby/versioning.md b/plugins/temporal-developer/skills/temporal-developer/references/ruby/versioning.md new file mode 100644 index 0000000..ba8f315 --- /dev/null +++ b/plugins/temporal-developer/skills/temporal-developer/references/ruby/versioning.md @@ -0,0 +1,317 @@ +# Ruby SDK Versioning + +For conceptual overview, see `references/core/versioning.md`. + +## Patching API + +### The patched() Method + +`Temporalio::Workflow.patched('my-patch')` returns `true`/`false` to branch between new and old code paths: + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + if Temporalio::Workflow.patched('my-patch') + # New code path + Temporalio::Workflow.execute_activity( + PostPatchActivity, + start_to_close_timeout: 100 + ) + else + # Old code path (for replay of existing workflows) + Temporalio::Workflow.execute_activity( + PrePatchActivity, + start_to_close_timeout: 100 + ) + end + end +end +``` + +**How it works:** +- For new executions: `patched()` returns `true` and records a marker in the Workflow history +- For replay with the marker: `patched()` returns `true` (history includes this patch) +- For replay without the marker: `patched()` returns `false` (history predates this patch) + +**Note:** The `patched()` return value is memoized per patch ID. This means you cannot reliably use `patched()` in loops—it will return the same value every iteration. Workaround: append a sequence number to the patch ID for each iteration (e.g., `"my-change-#{i}"`). + +### Three-Step Patching Process + +**Warning:** Failing to follow this process correctly will result in non-determinism errors for in-flight workflows. + +**Step 1: Patch in New Code** + +Add the patch with both old and new code paths: + +```ruby +class OrderWorkflow < Temporalio::Workflow::Definition + def execute(order) + if Temporalio::Workflow.patched('add-fraud-check') + # New: Run fraud check before payment + Temporalio::Workflow.execute_activity( + CheckFraudActivity, + order, + start_to_close_timeout: 120 + ) + end + + # Original payment logic runs for both paths + Temporalio::Workflow.execute_activity( + ProcessPaymentActivity, + order, + start_to_close_timeout: 300 + ) + end +end +``` + +**Step 2: Deprecate the Patch** + +Once all pre-patch Workflow Executions have completed, remove the old code and use `deprecate_patch()`: + +```ruby +class OrderWorkflow < Temporalio::Workflow::Definition + def execute(order) + Temporalio::Workflow.deprecate_patch('add-fraud-check') + + # Only new code remains + Temporalio::Workflow.execute_activity( + CheckFraudActivity, + order, + start_to_close_timeout: 120 + ) + + Temporalio::Workflow.execute_activity( + ProcessPaymentActivity, + order, + start_to_close_timeout: 300 + ) + end +end +``` + +**Step 3: Remove the Patch** + +After all workflows with the deprecated patch marker have completed, remove the `deprecate_patch()` call entirely: + +```ruby +class OrderWorkflow < Temporalio::Workflow::Definition + def execute(order) + Temporalio::Workflow.execute_activity( + CheckFraudActivity, + order, + start_to_close_timeout: 120 + ) + + Temporalio::Workflow.execute_activity( + ProcessPaymentActivity, + order, + start_to_close_timeout: 300 + ) + end +end +``` + +### Query Filters for Finding Workflows by Version + +```bash +# Find running workflows with a specific patch +temporal workflow list --query \ + 'WorkflowType = "OrderWorkflow" AND ExecutionStatus = "Running" AND TemporalChangeVersion = "add-fraud-check"' + +# Find running workflows without any patch (pre-patch versions) +temporal workflow list --query \ + 'WorkflowType = "OrderWorkflow" AND ExecutionStatus = "Running" AND TemporalChangeVersion IS NULL' +``` + +## Workflow Type Versioning + +For incompatible changes, create a new Workflow Type by duplicating the class: + +```ruby +class MyWorkflow < Temporalio::Workflow::Definition + def execute + # Original implementation + Temporalio::Workflow.execute_activity( + OriginalActivity, + start_to_close_timeout: 100 + ) + end +end + +class MyWorkflowV2 < Temporalio::Workflow::Definition + def execute + # New implementation with incompatible changes + Temporalio::Workflow.execute_activity( + NewActivity, + start_to_close_timeout: 100 + ) + end +end +``` + +Register both with the Worker: + +```ruby +worker = Temporalio::Worker.new( + client: client, + task_queue: 'my-task-queue', + workflows: [MyWorkflow, MyWorkflowV2], + activities: [OriginalActivity, NewActivity] +) +``` + +Update client code to start new workflows with the new type: + +```ruby +# Old workflows continue on MyWorkflow +# New workflows use MyWorkflowV2 +handle = client.start_workflow( + MyWorkflowV2, + input, + id: SecureRandom.uuid, + task_queue: 'my-task-queue' +) +``` + +Check for open executions before removing the old type: + +```bash +temporal workflow list --query 'WorkflowType = "MyWorkflow" AND ExecutionStatus = "Running"' +``` + +## Worker Versioning + +Worker Versioning manages versions at the deployment level, allowing multiple Worker versions to run simultaneously. Requires Ruby SDK v0.5.0+. + +### Key Concepts + +**Worker Deployment**: A logical service grouping similar Workers together (e.g., "order-processor"). All versions of your code live under this umbrella. + +**Worker Deployment Version**: A specific snapshot of your code identified by a deployment name and Build ID (e.g., "order-processor:v1.0" or "order-processor:abc123"). + +### Configuring Workers for Versioning + +```ruby +worker = Temporalio::Worker.new( + client: client, + task_queue: 'my-task-queue', + workflows: [MyWorkflow], + activities: [MyActivity], + deployment_options: Temporalio::Worker::DeploymentOptions.new( + version: Temporalio::WorkerDeploymentVersion.new( + deployment_name: 'my-service', + build_id: 'v1.0.0' # or git commit hash + ), + use_worker_versioning: true + ) +) +``` + +### PINNED vs AUTO_UPGRADE Behaviors + +**PINNED Behavior** + +Workflows stay locked to their original Worker version. Set on the workflow definition: + +```ruby +class StableWorkflow < Temporalio::Workflow::Definition + workflow_versioning_behavior :pinned + + def execute + Temporalio::Workflow.execute_activity( + ProcessOrderActivity, + start_to_close_timeout: 300 + ) + end +end +``` + +**When to use PINNED:** +- Short-running workflows (minutes to hours) +- Consistency is critical (e.g., financial transactions) +- You want to eliminate version compatibility complexity +- Building new applications and want simplest development experience + +**AUTO_UPGRADE Behavior** + +Workflows can move to newer versions: + +```ruby +class LongRunningWorkflow < Temporalio::Workflow::Definition + workflow_versioning_behavior :auto_upgrade + + def execute + # This workflow may be picked up by a newer Worker version + Temporalio::Workflow.execute_activity( + ProcessActivity, + start_to_close_timeout: 300 + ) + end +end +``` + +**When to use AUTO_UPGRADE:** +- Long-running workflows (weeks or months) +- Workflows need to benefit from bug fixes during execution +- Migrating from traditional rolling deployments +- You are already using patching APIs for version transitions + +**Important:** AUTO_UPGRADE workflows still need patching to handle version transitions safely since they can move between Worker versions. + +### Worker Configuration with Default Behavior + +```ruby +worker = Temporalio::Worker.new( + client: client, + task_queue: 'orders-task-queue', + workflows: [OrderWorkflow], + activities: [ProcessOrderActivity], + deployment_options: Temporalio::Worker::DeploymentOptions.new( + version: Temporalio::WorkerDeploymentVersion.new( + deployment_name: 'order-service', + build_id: ENV.fetch('BUILD_ID') + ), + use_worker_versioning: true, + default_versioning_behavior: Temporalio::VersioningBehavior::PINNED + ) +) +``` + +### Deployment Strategies + +**Blue-Green Deployments** + +Maintain two environments and switch traffic between them: +1. Deploy new code to idle environment +2. Run tests and validation +3. Switch traffic to new environment +4. Keep old environment for instant rollback + +**Rainbow Deployments** + +Multiple versions run simultaneously: +- New workflows use latest version +- Existing workflows complete on their original version +- Add new versions alongside existing ones +- Gradually sunset old versions as workflows complete + +This works well with Kubernetes where you manage multiple ReplicaSets running different Worker versions. + +### Querying Workflows by Worker Version + +```bash +# Find workflows on a specific Worker version +temporal workflow list --query \ + 'TemporalWorkerDeploymentVersion = "my-service:v1.0.0" AND ExecutionStatus = "Running"' +``` + +## Best Practices + +1. **Check for open executions** before removing old code paths +2. **Use descriptive patch IDs** that explain the change (e.g., "add-fraud-check" not "patch-1") +3. **Deploy patches incrementally**: patch, deprecate, remove +4. **Use PINNED for short workflows** to simplify version management +5. **Use AUTO_UPGRADE with patching** for long-running workflows that need updates +6. **Generate Build IDs from code** (git hash) to ensure changes produce new versions +7. **Avoid rolling deployments** for high-availability services with long-running workflows diff --git a/plugins/temporal-developer/skills/temporal-developer/references/typescript/advanced-features.md b/plugins/temporal-developer/skills/temporal-developer/references/typescript/advanced-features.md index ed9817d..29d1738 100644 --- a/plugins/temporal-developer/skills/temporal-developer/references/typescript/advanced-features.md +++ b/plugins/temporal-developer/skills/temporal-developer/references/typescript/advanced-features.md @@ -102,6 +102,41 @@ const worker = await Worker.create({ - `shutdownGraceTime`: Time to wait for in-progress work before forced shutdown - `maxCachedWorkflows`: Number of workflows to keep in cache (reduces replay on cache hit) +## Preload Modules + +`preloadModules` is a `string[]` bundler option that loads a list of modules once during reusable V8 context bootstrap; preloaded modules are then shared across workflows executing in the same V8 context. It is only beneficial when `reuseV8Context` is enabled, which is the default (`@default true`). + +**Ahead-of-time bundling via `BundleOptions`:** + +```typescript +import { bundleWorkflowCode } from '@temporalio/worker'; + +const { code } = await bundleWorkflowCode({ + workflowsPath: require.resolve('./workflows'), + preloadModules: ['lodash', './workflow-helpers'], +}); +``` + +**Startup bundling via `WorkerOptions.bundlerOptions`:** + +```typescript +const worker = await Worker.create({ + taskQueue: 'my-queue', + workflowsPath: require.resolve('./workflows'), + activities, + bundlerOptions: { + preloadModules: ['lodash', './workflow-helpers'], + }, +}); +``` + +**Constraints:** + +- **`preloadModules` is only beneficial when `reuseV8Context` is enabled (default `true`). ** If `reuseV8Context` is disabled, leave the list empty. +- **Module top-level code runs once, before any workflow activator exists. ** Only preload modules whose initialization is safe to execute that early. +- **Preloading a module that internally stores per-workflow state will leak context across workflows and cause non-deterministic behavior. ** Remove such modules from `preloadModules`. +- **A module listed in both `preloadModules` and `ignoreModules` fails the bundle with `Cannot preload modules that are also ignored: ''`. ** Remove the module from one of the two lists. + ## Sinks Sinks allow workflows to emit events for side effects (logging, metrics).