DBOS runtime (#157)

This commit is contained in:
Adrian Lyjak
2026-02-10 21:31:04 -05:00
committed by GitHub
parent 528d5623a3
commit 79159f0be0
38 changed files with 4531 additions and 80 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"llama-agents-dbos": minor
---
Add alternate DBOS runtime plugin for running workflows against a DBOS backend
+5
View File
@@ -25,8 +25,11 @@ jobs:
llama-agents-dev,
llama-agents-client,
llama-agents-server,
"llama-agents-dbos"
]
exclude:
- package: llama-agents-dbos
python-version: "3.9"
# Integration tests on 3.14 run in test-docker job with cross-package coverage
- package: llama-agents-integration-tests
python-version: "3.9"
@@ -56,6 +59,8 @@ jobs:
package_dir: packages/llama-agents-client
- package: llama-agents-server
package_dir: packages/llama-agents-server
- package: llama-agents-dbos
package_dir: packages/llama-agents-dbos
steps:
- uses: actions/checkout@v4
+3
View File
@@ -28,6 +28,9 @@ uv run dev -p server -p client
uv run dev -- -k test_name
```
For more advanced scenarios, you can always `cd packages/some-package` and use pytest directly. The dev tool just provides additional package level test parallelism, and more curated cross package test output to avoid context bloat.
### Linting & Formatting
```bash
uv run pre-commit run -a
+2
View File
@@ -0,0 +1,2 @@
.last_run_id
*.sqlite3
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env python3
"""
DBOS Counter Example
Simple looping workflow that increments a counter until it reaches 20.
Usage:
python -m examples.dbos_durability.counter_example # Start new
python -m examples.dbos_durability.counter_example --resume # Resume last
python -m examples.dbos_durability.counter_example --clean # Reset state
Try Ctrl+C mid-run to test resume behavior.
"""
from __future__ import annotations
import argparse
import asyncio
import os
import signal
import threading
import time
import uuid
from pathlib import Path
from dbos import DBOS
from llama_agents.dbos import DBOSRuntime
from pydantic import Field
from workflows import Context, Workflow, step
from workflows.events import Event, StartEvent, StopEvent
_DIR = Path(__file__).parent
_DB_FILE = _DIR / ".dbos_data.sqlite3"
_RUN_FILE = _DIR / ".last_run_id"
class Tick(Event):
count: int = Field(description="Current count")
class CounterResult(StopEvent):
final_count: int = Field(description="Final counter value")
class CounterWorkflow(Workflow):
"""Looping counter workflow - increments until reaching 20."""
@step
async def start(self, ctx: Context, ev: StartEvent) -> Tick:
await ctx.store.set("count", 0)
print("[Start] Initializing counter to 0")
return Tick(count=0)
@step
async def increment(self, ctx: Context, ev: Tick) -> Tick | CounterResult:
count = ev.count + 1
await ctx.store.set("count", count)
print(f"[Tick {count:2d}] count = {count}")
if count >= 20:
return CounterResult(final_count=count)
await asyncio.sleep(0.5)
return Tick(count=count)
def run(run_id: str) -> None:
"""Run the counter workflow."""
DBOS(
config={
"name": "counter-example",
"system_database_url": f"sqlite+pysqlite:///{_DB_FILE}?check_same_thread=false",
"run_admin_server": False,
}
)
runtime = DBOSRuntime()
workflow = CounterWorkflow(runtime=runtime)
runtime.launch()
interrupted = False
def handle_sigint(signum: int, frame: object) -> None:
nonlocal interrupted
if interrupted:
# Second Ctrl+C - force exit
os._exit(130)
interrupted = True
print("\nInterrupted - workflow state saved. Use --resume to continue.")
def delayed_exit() -> None:
time.sleep(0.1)
os._exit(130)
threading.Thread(target=delayed_exit, daemon=True).start()
# Install signal handler before running
signal.signal(signal.SIGINT, handle_sigint)
async def _run() -> None:
result = await workflow.run(run_id=run_id)
print(f"\nResult: final_count = {result.final_count}")
try:
asyncio.run(_run())
except (KeyboardInterrupt, SystemExit):
pass # Already handled by signal handler
finally:
if not interrupted:
try:
runtime.destroy()
except Exception:
pass
def main() -> None:
parser = argparse.ArgumentParser(description="DBOS Counter Example")
parser.add_argument("--resume", action="store_true", help="Resume last workflow")
parser.add_argument("--clean", action="store_true", help="Remove state files")
args = parser.parse_args()
if args.clean:
for f in [_DB_FILE, _RUN_FILE]:
if f.exists():
f.unlink()
print(f"Removed {f}")
return
if args.resume and _RUN_FILE.exists():
run_id = _RUN_FILE.read_text().strip()
print(f"Resuming: {run_id}")
else:
run_id = f"counter-{uuid.uuid4().hex[:8]}"
_RUN_FILE.write_text(run_id)
print(f"Starting: {run_id}")
run(run_id)
if __name__ == "__main__":
main()
+44
View File
@@ -0,0 +1,44 @@
# LlamaAgents DBOS Runtime
DBOS durable runtime plugin for LlamaIndex Workflows.
## Installation
```bash
pip install llama-agents-dbos
```
## Usage
```python
from llama_agents.dbos import DBOSRuntime
from dbos import DBOS, DBOSConfig
from workflows import Workflow, step, StartEvent, StopEvent
# Configure DBOS
config: DBOSConfig = {
"name": "my-app",
"system_database_url": "postgresql://...",
}
DBOS(config=config)
# Create runtime and workflow
runtime = DBOSRuntime()
class MyWorkflow(Workflow):
@step
async def my_step(self, ev: StartEvent) -> StopEvent:
return StopEvent(result="done")
workflow = MyWorkflow(runtime=runtime)
# Launch runtime and run workflow
runtime.launch()
result = await workflow.run()
```
## Features
- Durable workflow execution backed by DBOS
- Automatic step recording and replay
- Distributed workers and recovery support
+33
View File
@@ -0,0 +1,33 @@
"""Root conftest.py - shared test utilities for all test directories.
This file is discovered by pytest and provides common utilities
for both tests/ (SQLite) and tests_postgres/ (PostgreSQL).
"""
from __future__ import annotations
from pathlib import Path
from dbos import DBOSConfig
def make_test_dbos_config(
name: str,
db_path: Path,
) -> DBOSConfig:
"""Create a DBOS config for testing with sensible defaults (SQLite backend).
Args:
name: The application name for DBOS.
db_path: Path to the SQLite database file.
Returns:
A DBOSConfig dictionary ready for use with DBOS().
"""
system_db_url = f"sqlite+pysqlite:///{db_path}?check_same_thread=false"
return {
"name": name,
"system_database_url": system_db_url,
"run_admin_server": False,
"notification_listener_polling_interval_sec": 0.01,
}
+7
View File
@@ -0,0 +1,7 @@
{
"name": "llama-agents-dbos",
"version": "0.0.0",
"private": false,
"license": "MIT",
"scripts": {}
}
+43
View File
@@ -0,0 +1,43 @@
[build-system]
requires = ["uv_build>=0.9.10,<0.10.0"]
build-backend = "uv_build"
[dependency-groups]
dev = [
"basedpyright>=1.31.1",
"pytest>=8.4.0",
"pytest-asyncio>=1.0.0",
"pytest-cov>=6.1.1",
"pytest-timeout>=2.4.0",
"pytest-xdist>=3.0.0",
"ty>=0.0.1,<0.0.9"
]
[project]
name = "llama-agents-dbos"
version = "0.1.0"
description = "DBOS durable runtime plugin for LlamaIndex Workflows"
readme = "README.md"
license = "MIT"
requires-python = ">=3.9"
dependencies = [
"dbos>=2.11.0; python_full_version >= '3.10.0'",
"llama-index-workflows>=2.12.0,<3.0.0"
]
[tool.basedpyright]
typeCheckingMode = "standard"
pythonVersion = "3.10"
[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "module"
asyncio_default_test_loop_scope = "module"
testpaths = ["tests"]
addopts = "-nauto --timeout=30"
[tool.uv.build-backend]
module-name = "llama_agents.dbos"
[tool.uv.sources]
llama-index-workflows = {workspace = true}
@@ -0,0 +1,13 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""
DBOS plugin for LlamaIndex Workflows.
Provides durable workflow execution backed by DBOS with SQL state storage.
"""
from __future__ import annotations
from .runtime import DBOSRuntime
__all__ = ["DBOSRuntime"]
@@ -0,0 +1,3 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""Journal module for recording task completion order."""
@@ -0,0 +1,85 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""CRUD operations and table definitions for the workflow journal."""
from __future__ import annotations
from sqlalchemy import Column, Integer, MetaData, String, Table, text
from sqlalchemy.engine import Connection, Engine
JOURNAL_TABLE_NAME = "workflow_journal"
class JournalCrud:
"""Database operations for the workflow journal table.
Initialized with table configuration (name, schema), then provides
methods for inserting, loading, and migrating journal entries.
"""
def __init__(
self,
table_name: str = JOURNAL_TABLE_NAME,
schema: str | None = None,
) -> None:
self.table_name = table_name
self.schema = schema
@property
def _table_ref(self) -> str:
if self.schema:
return f"{self.schema}.{self.table_name}"
return self.table_name
def _define_table(self, metadata: MetaData) -> Table:
return Table(
self.table_name,
metadata,
Column("id", Integer, primary_key=True, autoincrement=True),
Column("run_id", String(255), nullable=False, index=True),
Column("seq_num", Integer, nullable=False),
Column("task_key", String(512), nullable=False),
)
def insert(
self,
conn: Connection,
run_id: str,
seq_num: int,
task_key: str,
) -> None:
"""Insert a new journal entry."""
conn.execute(
text(f"""
INSERT INTO {self._table_ref} (run_id, seq_num, task_key)
VALUES (:run_id, :seq_num, :task_key)
"""), # noqa: S608
{
"run_id": run_id,
"seq_num": seq_num,
"task_key": task_key,
},
)
def load(self, conn: Connection, run_id: str) -> list[str]:
"""Load journal entries for a run, ordered by sequence number."""
result = conn.execute(
text(f"""
SELECT task_key FROM {self._table_ref}
WHERE run_id = :run_id
ORDER BY seq_num ASC
"""), # noqa: S608
{"run_id": run_id},
)
return [row[0] for row in result.fetchall()]
def run_migrations(self, engine: Engine) -> None:
"""Create the journal table if it doesn't exist."""
metadata = MetaData(schema=self.schema)
table = self._define_table(metadata)
with engine.begin() as conn:
is_postgres = engine.dialect.name == "postgresql"
if is_postgres and self.schema:
conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {self.schema}")) # noqa: S608
table.create(bind=conn, checkfirst=True)
@@ -0,0 +1,97 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""TaskJournal for deterministic replay of task completion order."""
from __future__ import annotations
import asyncio
from typing import Any
from sqlalchemy.engine import Engine
from .crud import JournalCrud
class TaskJournal:
"""Records task completion order for deterministic replay.
Stores NamedTask string keys directly (e.g., "step_name:0", "__pull__:1").
During fresh execution, records which tasks complete and in what order.
During replay, returns the expected task key so the adapter can wait for
the specific task that completed in the original run.
Uses a dedicated workflow_journal table with one row per entry for efficient
append-only storage.
"""
def __init__(
self,
run_id: str,
engine: Engine | None = None,
crud: JournalCrud | None = None,
) -> None:
"""Initialize the task journal.
Args:
run_id: Workflow run ID for this journal.
engine: SQLAlchemy engine. If None, operates in-memory only.
crud: Journal CRUD operations. If None, uses default JournalCrud().
"""
self._run_id = run_id
self._engine = engine
self._crud = crud or JournalCrud()
self._entries: list[str] | None = None # Lazy loaded
self._replay_index: int = 0
async def _run_sync(self, fn: Any, *args: Any) -> Any:
"""Run a synchronous function in the default executor."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, fn, *args)
async def load(self) -> None:
"""Load journal from database. Idempotent - only loads once."""
if self._entries is not None:
return
if self._engine is None:
self._entries = []
return
def _load_sync() -> list[str]:
with self._engine.connect() as conn: # type: ignore[union-attr]
return self._crud.load(conn, self._run_id)
self._entries = await self._run_sync(_load_sync)
def is_replaying(self) -> bool:
"""True if there are more journal entries to replay."""
if self._entries is None:
return False
return self._replay_index < len(self._entries)
def next_expected_key(self) -> str | None:
"""Get the next expected task key during replay, or None if fresh execution."""
if self._entries is None or self._replay_index >= len(self._entries):
return None
return self._entries[self._replay_index]
async def record(self, key: str) -> None:
"""Record a task completion and persist to database."""
if self._entries is None:
self._entries = []
seq_num = len(self._entries)
self._entries.append(key)
self._replay_index += 1
if self._engine is not None:
def _insert_sync() -> None:
with self._engine.begin() as conn: # type: ignore[union-attr]
self._crud.insert(conn, self._run_id, seq_num, key)
await self._run_sync(_insert_sync)
def advance(self) -> None:
"""Advance replay index after processing a replayed task."""
self._replay_index += 1
@@ -0,0 +1,648 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""
DBOS Runtime for durable workflow execution.
This module provides the DBOSRuntime class for running LlamaIndex workflows
with durable execution backed by DBOS.
"""
from __future__ import annotations
import asyncio
import logging
import sys
import time
from dataclasses import dataclass
from typing import Any, AsyncGenerator, TypedDict
from llama_index_instrumentation.dispatcher import active_instrument_tags
from pydantic import BaseModel
from typing_extensions import Unpack
from workflows.context.serializers import BaseSerializer, JsonSerializer
from workflows.context.state_store import (
StateStore,
deserialize_state_from_dict,
infer_state_type,
)
from workflows.events import Event, StartEvent, StopEvent
from workflows.runtime.types.internal_state import BrokerState
from workflows.runtime.types.named_task import NamedTask
from workflows.runtime.types.plugin import (
ExternalRunAdapter,
InternalRunAdapter,
RegisteredWorkflow,
Runtime,
WaitResult,
WaitResultTick,
WaitResultTimeout,
)
from workflows.runtime.types.step_function import (
StepWorkerFunction,
as_step_worker_functions,
create_workflow_run_function,
)
from workflows.runtime.types.ticks import WorkflowTick
from workflows.workflow import Workflow
try:
from dbos import DBOS, SetWorkflowID
from dbos._dbos import _get_dbos_instance
except ImportError as e:
# if 3.9, give a detailed error that dbos is not supported on this version of python
if sys.version_info.major == 3 and sys.version_info.minor <= 9:
raise ImportError(
"dbos is not supported on Python 3.9. Please use Python 3.10 or higher."
f"Error: {e}"
) from e
raise
from sqlalchemy.engine import Engine
from .journal.crud import JOURNAL_TABLE_NAME, JournalCrud
from .journal.task_journal import TaskJournal
from .state_store import STATE_TABLE_NAME, SqlStateStore
logger = logging.getLogger(__name__)
class DBOSRuntimeConfig(TypedDict, total=False):
"""Configuration options for DBOSRuntime.
All fields are optional — defaults are resolved at launch time.
"""
polling_interval_sec: float
run_migrations_on_launch: bool
schema: str | None
state_table_name: str
journal_table_name: str
DEFAULT_STATE_TABLE_NAME = STATE_TABLE_NAME
DEFAULT_JOURNAL_TABLE_NAME = JOURNAL_TABLE_NAME
def _resolve_schema(config: DBOSRuntimeConfig, engine: Engine) -> str | None:
"""Resolve schema from config, falling back to dialect-based default.
If "schema" was explicitly provided (even as None), uses that value.
Otherwise, defaults to "dbos" for PostgreSQL and None for SQLite.
"""
if "schema" in config:
return config["schema"]
is_postgres = engine.dialect.name == "postgresql"
return "dbos" if is_postgres else None
# Very long timeout for unbounded waits - encourages workflow to sleep.
# DBOS's default 60s is too short and gets recorded to event logs.
_UNBOUNDED_WAIT_TIMEOUT_SECONDS = 60 * 60 * 24 # 1 day
@dataclass
class _DBOSInternalShutdown:
"""Internal signal sent via DBOS.send to wake blocked recv for shutdown."""
@DBOS.step()
def _durable_time() -> float:
"""
Get current timestamp, wrapped as a DBOS step so that it's snapshotted and replayed
This could be made more consistent if it got the timestamp from the DB.
"""
return time.time()
class DBOSRuntime(Runtime):
"""
DBOS-backed workflow runtime for durable execution.
Workflows are registered at launch() time with stable names,
enabling distributed workers and recovery.
State is persisted to the database using SQL state stores,
enabling state recovery across process restarts.
"""
def __init__(self, **kwargs: Unpack[DBOSRuntimeConfig]) -> None:
"""Initialize the DBOS runtime.
Args:
**kwargs: Configuration options. See DBOSRuntimeConfig for details.
polling_interval_sec: Interval for polling workflow results. Default 1.0.
run_migrations_on_launch: Auto-run migrations on launch(). Default True.
schema: Database schema name. Default: auto-detected at launch
("dbos" for PostgreSQL, None for SQLite). Pass None explicitly
to force no schema even on PostgreSQL.
state_table_name: State table name. Default "workflow_state".
journal_table_name: Journal table name. Default "workflow_journal".
"""
self.config: DBOSRuntimeConfig = dict(kwargs) # type: ignore[assignment]
# Workflow tracking state
self._tracked_workflows: list[Workflow] = []
self._tracked_workflow_ids: set[int] = set() # Track by id for dedup
self._registered: dict[int, RegisteredWorkflow] = {} # keyed by id(workflow)
self._dbos_launched = False
self._tasks: list[asyncio.Task[None]] = []
self._sql_engine: Engine | None = None
self._migrations_run = False
def _track_task(self, task: asyncio.Task[None]) -> None:
self._tasks.append(task)
task.add_done_callback(self._tasks.remove)
def track_workflow(self, workflow: Workflow) -> None:
"""Track a workflow for registration at launch time.
If launch() was already called, registers the workflow immediately.
This allows late registration for testing scenarios.
"""
if self._dbos_launched:
# Already launched - register immediately
registered = self.register(workflow)
self._registered[id(workflow)] = registered
else:
wf_id = id(workflow)
if wf_id not in self._tracked_workflow_ids:
self._tracked_workflows.append(workflow)
self._tracked_workflow_ids.add(wf_id)
def get_registered(self, workflow: Workflow) -> RegisteredWorkflow | None:
"""Get the registered workflow if available."""
return self._registered.get(id(workflow))
def register(self, workflow: Workflow) -> RegisteredWorkflow:
"""
Wrap workflow with DBOS decorators.
Called at launch() time for each tracked workflow.
Uses workflow.workflow_name for stable DBOS registration names.
"""
# Use workflow's name directly
name = workflow.workflow_name
# Create DBOS-wrapped control loop with stable name
@DBOS.workflow(name=f"{name}.control_loop")
async def _dbos_control_loop(
init_state: BrokerState,
start_event: StartEvent | None = None,
tags: dict[str, Any] = {},
) -> StopEvent:
workflow_run_fn = create_workflow_run_function(workflow)
return await workflow_run_fn(init_state, start_event, tags)
# Wrap steps with stable names
wrapped_steps: dict[str, StepWorkerFunction] = {
step_name: DBOS.step(name=f"{name}.{step_name}")(step)
for step_name, step in as_step_worker_functions(workflow).items()
}
return RegisteredWorkflow(
workflow=workflow, workflow_run_fn=_dbos_control_loop, steps=wrapped_steps
)
def _get_sql_engine(self) -> Engine:
"""Get the SQLAlchemy engine from DBOS for state storage.
Uses DBOS's app database if configured, otherwise falls back to sys database.
Returns:
SQLAlchemy Engine for state storage.
Raises:
RuntimeError: If no database is available.
"""
if self._sql_engine is not None:
return self._sql_engine
dbos = _get_dbos_instance()
# Try app database first, fall back to system database
app_db = dbos._app_db
if app_db is not None:
self._sql_engine = app_db.engine
return self._sql_engine
# Fall back to system database
sys_db = dbos._sys_db
self._sql_engine = sys_db.engine
return self._sql_engine
def run_migrations(self) -> None:
"""Run database migrations for workflow state and journal tables.
Creates the workflow_state and workflow_journal tables if they don't exist.
Idempotent - safe to call multiple times.
Can be called explicitly before launch() when run_migrations_on_launch=False,
allowing for custom migration timing (e.g., during application startup).
Requires DBOS to be launched first (calls _get_sql_engine internally).
"""
if self._migrations_run:
return
engine = self._get_sql_engine()
schema = _resolve_schema(self.config, engine)
state_table = self.config.get("state_table_name", DEFAULT_STATE_TABLE_NAME)
journal_table = self.config.get(
"journal_table_name", DEFAULT_JOURNAL_TABLE_NAME
)
SqlStateStore.run_migrations(engine, table_name=state_table, schema=schema)
# Create workflow_journal table
journal_crud = JournalCrud(table_name=journal_table, schema=schema)
journal_crud.run_migrations(engine)
self._migrations_run = True
logger.info("Database migrations completed (workflow_state, workflow_journal)")
def run_workflow(
self,
run_id: str,
workflow: Workflow,
init_state: BrokerState,
start_event: StartEvent | None = None,
serialized_state: dict[str, Any] | None = None,
serializer: BaseSerializer | None = None,
adapter_state: dict[str, Any] | None = None,
) -> ExternalRunAdapter:
"""Set up a workflow run with SQL-backed state storage.
State is persisted to the database, enabling recovery across
process restarts and distributed execution.
Args:
run_id: Unique identifier for this workflow run.
workflow: The workflow to run.
init_state: Initial broker state for the control loop.
start_event: Optional start event to kick off the workflow.
serialized_state: Optional pre-populated state from InMemoryStateStore.to_dict().
If provided, this state is written to the database before the workflow
starts, allowing workflows to begin with pre-set initial values.
serializer: Serializer for state data. Defaults to JsonSerializer.
adapter_state: Optional adapter state (unused for DBOS).
"""
if not self._dbos_launched:
raise RuntimeError(
"DBOS runtime not launched. Call runtime.launch() before running workflows."
)
registered = self.get_registered(workflow)
if registered is None:
raise RuntimeError(
"DBOSRuntime workflows must be registered before running. Did you forget to call runtime.launch()?"
)
# Capture values needed in the async task closure
engine = self._get_sql_engine()
active_serializer = serializer or JsonSerializer()
async def _run_workflow() -> None:
with SetWorkflowID(run_id):
# Write initial state to DB before starting workflow (non-blocking to caller)
if serialized_state:
store = SqlStateStore(
run_id=run_id,
engine=engine,
state_type=infer_state_type(workflow),
serializer=active_serializer,
schema=_resolve_schema(self.config, engine),
table_name=self.config.get(
"state_table_name", DEFAULT_STATE_TABLE_NAME
),
)
# Deserialize and save the initial state
state = deserialize_state_from_dict(
serialized_state,
active_serializer,
state_type=infer_state_type(workflow),
)
await store.set_state(state)
try:
await DBOS.start_workflow_async(
registered.workflow_run_fn,
init_state,
start_event,
active_instrument_tags.get(),
)
except Exception as e:
logger.error(
f"Failed to submit work to DBOS for {run_id} with start event: {start_event} and init state: {init_state}. Error: {e}",
exc_info=True,
)
raise e
# Create startup task and pass to adapter so it can await workflow readiness
startup_task = asyncio.create_task(_run_workflow())
self._track_task(startup_task)
return ExternalDBOSAdapter(
run_id,
self.config.get("polling_interval_sec", 1.0),
startup_task,
)
def get_internal_adapter(self, workflow: Workflow) -> InternalRunAdapter:
if not self._dbos_launched:
raise RuntimeError(
"DBOS runtime not launched. Call runtime.launch() before running workflows."
)
run_id = DBOS.workflow_id
if run_id is None:
raise RuntimeError(
"No current run id. Must be called within a workflow run."
)
# Infer state_type from the workflow for typed state support
state_type = infer_state_type(workflow)
engine = self._get_sql_engine()
return InternalDBOSAdapter(
run_id,
engine,
state_type,
schema=_resolve_schema(self.config, engine),
state_table_name=self.config.get(
"state_table_name", DEFAULT_STATE_TABLE_NAME
),
journal_table_name=self.config.get(
"journal_table_name", DEFAULT_JOURNAL_TABLE_NAME
),
)
def get_external_adapter(self, run_id: str) -> ExternalRunAdapter:
if not self._dbos_launched:
raise RuntimeError(
"DBOS runtime not launched. Call runtime.launch() before running workflows."
)
return ExternalDBOSAdapter(run_id, self.config.get("polling_interval_sec", 1.0))
def launch(self) -> None:
"""
Launch DBOS and register all tracked workflows.
Must be called before running any workflows.
Runs database migrations unless run_migrations_on_launch=False.
"""
if self._dbos_launched:
return # Already launched
# Register each pending workflow with DBOS
for workflow in self._tracked_workflows:
# Register with DBOS (this applies decorators)
registered = self.register(workflow)
self._registered[id(workflow)] = registered
# Launch DBOS runtime
DBOS.launch()
self._dbos_launched = True
# Run migrations after DBOS is launched (if configured)
if self.config.get("run_migrations_on_launch", True):
self.run_migrations()
def destroy(self, destroy_dbos: bool = True) -> None:
"""Clean up DBOS runtime resources.
Args:
destroy_dbos: If True (default), also calls DBOS.destroy().
Set to False when DBOS lifecycle is managed externally
(e.g., shared across multiple runtimes in tests).
"""
self._tracked_workflows.clear()
self._tracked_workflow_ids.clear()
self._registered.clear()
self._dbos_launched = False
self._sql_engine = None
self._migrations_run = False
for task in self._tasks:
if not task.done():
task.cancel()
if destroy_dbos:
DBOS.destroy()
_IO_STREAM_PUBLISHED_EVENTS_NAME = "published_events"
_IO_STREAM_TICK_TOPIC = "ticks"
class InternalDBOSAdapter(InternalRunAdapter):
"""
Internal DBOS adapter for the workflow control loop.
- send_event sends ticks via DBOS.send (using run_in_executor to escape step context)
- wait_receive receives ticks via DBOS.recv_async
- write_to_event_stream publishes events via DBOS streams
- get_now returns a durable timestamp
- close sends shutdown signal to wake blocked recv
- wait_for_next_task coordinates task completion ordering for deterministic replay
"""
def __init__(
self,
run_id: str,
engine: Engine,
state_type: type[BaseModel] | None = None,
schema: str | None = None,
state_table_name: str = DEFAULT_STATE_TABLE_NAME,
journal_table_name: str = DEFAULT_JOURNAL_TABLE_NAME,
) -> None:
self._run_id = run_id
self._engine = engine
self._state_type = state_type
self._schema = schema
self._state_table_name = state_table_name
self._journal_table_name = journal_table_name
self._closed = False
self._state_store: SqlStateStore[Any] | None = None
# Journal for deterministic task ordering - lazily initialized
self._journal: TaskJournal | None = None
@property
def run_id(self) -> str:
return self._run_id
async def write_to_event_stream(self, event: Event) -> None:
await DBOS.write_stream_async(_IO_STREAM_PUBLISHED_EVENTS_NAME, event)
async def get_now(self) -> float:
return _durable_time()
async def send_event(self, tick: WorkflowTick) -> None:
# Use run_in_executor to escape DBOS step context.
# DBOS yells at you for writing to the event stream from a step (since is not idempotent)
# However that's the expected semantics of llama index workflow steps, so it's ok.
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None,
lambda: DBOS.send(self._run_id, tick, topic=_IO_STREAM_TICK_TOPIC),
)
async def wait_receive(
self,
timeout_seconds: float | None = None,
) -> WaitResult:
"""Wait for tick using DBOS.recv_async with timeout.
For bounded waits (timeout_seconds specified), uses the specified timeout.
For unbounded waits (timeout_seconds=None), loops with very long timeouts
(hours) to encourage the workflow to sleep. Uses shutdown signal from
close() to exit cleanly - raises CancelledError on shutdown.
"""
if self._closed:
raise asyncio.CancelledError("Adapter closed")
# Timeout 1x per day at least. This will just cause a wakeup loop of the control loop.
result = await DBOS.recv_async(
_IO_STREAM_TICK_TOPIC,
timeout_seconds=timeout_seconds or _UNBOUNDED_WAIT_TIMEOUT_SECONDS,
)
if result is None:
return WaitResultTimeout()
if isinstance(result, _DBOSInternalShutdown):
self._closed = True
raise asyncio.CancelledError("Adapter closed")
return WaitResultTick(tick=result)
async def close(self) -> None:
"""Signal shutdown by sending internal message to wake blocked recv."""
if self._closed:
return
self._closed = True
loop = asyncio.get_running_loop()
await loop.run_in_executor(
None,
lambda: DBOS.send(
self._run_id, _DBOSInternalShutdown(), topic=_IO_STREAM_TICK_TOPIC
),
)
def _get_or_create_state_store(self) -> SqlStateStore[Any]:
"""Get or lazily create the state store."""
if self._state_store is None:
self._state_store = SqlStateStore(
run_id=self._run_id,
engine=self._engine,
state_type=self._state_type,
schema=self._schema,
table_name=self._state_table_name,
)
return self._state_store
def get_state_store(self) -> StateStore[Any] | None:
return self._get_or_create_state_store()
def _get_or_create_journal(self) -> TaskJournal:
"""Get or lazily create the task journal."""
if self._journal is None:
crud = JournalCrud(table_name=self._journal_table_name, schema=self._schema)
self._journal = TaskJournal(self._run_id, self._engine, crud)
return self._journal
async def wait_for_next_task(
self,
task_set: list[NamedTask],
timeout: float | None = None,
) -> asyncio.Task[Any] | None:
"""Wait for and return the next task that should complete.
During replay, waits for the specific task that completed in the original run.
During fresh execution, waits for any task and records the completion order.
Args:
task_set: List of NamedTasks with stable string keys for identification
timeout: Timeout in seconds, None for no timeout
Returns:
The completed task, or None on timeout.
"""
tasks = NamedTask.all_tasks(task_set)
if not tasks:
return None
journal = self._get_or_create_journal()
await journal.load()
expected_key = journal.next_expected_key()
if expected_key is not None:
# Replay mode: wait for specific task
target_task = NamedTask.find_by_key(task_set, expected_key)
if target_task is None:
logger.warning(
f"Non-deterministic execution detected during replay! "
f"Expected task {expected_key} not in set yet. "
f"Falling back to awaiting all tasks."
)
else:
try:
await asyncio.wait_for(asyncio.shield(target_task), timeout=timeout)
except (asyncio.TimeoutError, TimeoutError):
return None
journal.advance()
return target_task
# Fresh execution: wait for first, record it
done, _ = await asyncio.wait(
tasks, timeout=timeout, return_when=asyncio.FIRST_COMPLETED
)
if not done:
return None
completed = done.pop()
key = NamedTask.get_key(task_set, completed)
await journal.record(key)
return completed
class ExternalDBOSAdapter(ExternalRunAdapter):
"""
External DBOS adapter for workflow interaction.
- send_event puts ticks into the shared mailbox queue
- stream_published_events reads from DBOS streams
- close is a no-op
"""
def __init__(
self,
run_id: str,
polling_interval_sec: float = 1.0,
startup_task: asyncio.Task[None] | None = None,
) -> None:
self._run_id = run_id
self._polling_interval_sec = polling_interval_sec
self._startup_task = startup_task # None means workflow already started
@property
def run_id(self) -> str:
"""Get the workflow run ID."""
return self._run_id
async def send_event(self, tick: WorkflowTick) -> None:
await DBOS.send_async(self._run_id, tick, topic=_IO_STREAM_TICK_TOPIC)
async def stream_published_events(self) -> AsyncGenerator[Event, None]:
await self._ensure_workflow_started()
async for event in DBOS.read_stream_async(self.run_id, "published_events"):
yield event
async def get_result(self) -> StopEvent:
await self._ensure_workflow_started()
handle = await DBOS.retrieve_workflow_async(self.run_id)
return await handle.get_result(polling_interval_sec=self._polling_interval_sec)
async def _ensure_workflow_started(self) -> None:
"""Wait for the workflow startup task to complete if one was provided."""
if self._startup_task is not None:
await self._startup_task
self._startup_task = None # Clear after awaiting
@@ -0,0 +1,588 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""
SQL-backed StateStore implementations for durable workflow state.
Provides PostgreSQL and SQLite state stores that persist workflow state
to a database, enabling durable and distributed workflow execution.
"""
from __future__ import annotations
import asyncio
import functools
import json
import logging
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from typing import (
Any,
AsyncGenerator,
Callable,
Generic,
Literal,
Type,
)
from pydantic import BaseModel, ConfigDict, Field, ValidationError
from sqlalchemy import (
Column,
Connection,
DateTime,
MetaData,
String,
Table,
Text,
select,
text,
)
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.engine import Engine
from typing_extensions import TypeVar
from workflows.context.serializers import BaseSerializer, JsonSerializer
from workflows.context.state_store import (
MAX_DEPTH,
DictState,
InMemorySerializedState,
assign_path_step,
deserialize_state_from_dict,
traverse_path_step,
)
logger = logging.getLogger(__name__)
MODEL_T = TypeVar("MODEL_T", bound=BaseModel)
class SqlSerializedState(BaseModel):
"""Serialized state referencing a database row (from SqlStateStore)."""
model_config = ConfigDict(populate_by_name=True)
store_type: Literal["sql"] = "sql"
run_id: str
db_schema: str | None = Field(default=None, alias="schema")
# Note: No state_data - actual data lives in the database
def parse_serialized_state(
data: dict[str, Any],
) -> InMemorySerializedState | SqlSerializedState:
"""Parse raw dict into appropriate format type.
Args:
data: Serialized state payload from to_dict().
Returns:
InMemorySerializedState or SqlSerializedState based on store_type.
Raises:
ValueError: If store_type is unknown.
"""
store_type = data.get("store_type")
if store_type == "sql":
return SqlSerializedState.model_validate(data)
elif store_type == "in_memory" or store_type is None:
# Backwards compat: missing store_type = InMemory
return InMemorySerializedState.model_validate(data)
else:
raise ValueError(f"Unknown store_type: {store_type}")
def _utc_now() -> datetime:
"""Get current UTC timestamp."""
return datetime.now(timezone.utc)
STATE_TABLE_NAME = "workflow_state"
def _state_columns() -> list[Column]:
"""Return fresh Column instances for the workflow_state table.
Must return new instances each call because SQLAlchemy Column objects
can't be shared across Table instances.
"""
return [
Column("run_id", String(255), primary_key=True),
Column("state_json", Text, nullable=False),
Column("created_at", DateTime(timezone=True), nullable=False),
Column("updated_at", DateTime(timezone=True), nullable=False),
]
class SqlStateStore(Generic[MODEL_T]):
"""
SQL-backed StateStore implementation.
Persists workflow state to a database table. Supports PostgreSQL and SQLite
dialects with automatic detection based on the engine.
Thread-safety is achieved through database-level locking during
transactional edits via the `edit_state` context manager.
"""
known_unserializable_keys = ("memory",)
state_type: Type[MODEL_T]
def __init__(
self,
run_id: str,
state_type: Type[MODEL_T] | None = None,
engine: Engine | None = None,
serializer: BaseSerializer | None = None,
schema: str | None = None,
table_name: str = STATE_TABLE_NAME,
) -> None:
self._run_id = run_id
self.state_type = state_type or DictState # type: ignore[assignment]
self._engine = engine
self._serializer = serializer or JsonSerializer()
self._schema = schema
self._table_name = table_name
self._metadata = MetaData(schema=self._schema)
self._table = self._define_table()
self._initialized = False
self._pending_state: dict[str, Any] | None = None
@property
def run_id(self) -> str:
"""Get the workflow run ID."""
return self._run_id
@property
def engine(self) -> Engine:
"""Get the SQLAlchemy engine, raising if not set."""
if self._engine is None:
raise RuntimeError(
"Engine not set. Provide an engine at construction or set it before use."
)
return self._engine
@engine.setter
def engine(self, engine: Engine) -> None:
"""Set the SQLAlchemy engine."""
self._engine = engine
@property
def _is_postgres(self) -> bool:
"""Check if the engine is PostgreSQL."""
return self.engine.dialect.name == "postgresql"
@property
def _table_ref(self) -> str:
"""Get the fully qualified table reference."""
if self._schema:
return f"{self._schema}.{self._table_name}"
return self._table_name
def _define_table(self) -> Table:
"""Define the workflow_state table schema."""
return Table(
self._table_name,
self._metadata,
*_state_columns(),
)
@classmethod
def run_migrations(
cls,
engine: Engine,
table_name: str = STATE_TABLE_NAME,
schema: str | None = None,
) -> None:
"""Create schema and table if they don't exist."""
metadata = MetaData(schema=schema)
table = Table(
table_name,
metadata,
*_state_columns(),
)
is_postgres = engine.dialect.name == "postgresql"
with engine.begin() as conn:
if is_postgres and schema:
conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {schema}")) # noqa: S608
table.create(bind=conn, checkfirst=True)
@functools.cached_property
def _lock(self) -> asyncio.Lock:
"""Lazy lock for Python 3.14+ compatibility."""
return asyncio.Lock()
async def _run_sync(self, fn: Callable[..., Any], *args: Any) -> Any:
"""Run a synchronous function in the default executor."""
loop = asyncio.get_event_loop()
return await loop.run_in_executor(None, fn, *args)
def _ensure_initialized(self) -> None:
"""Ensure the table exists and apply any pending state."""
if self._initialized:
return
self._run_instance_migrations()
self._initialized = True
# Apply any pending state from InMemory format deserialization
if self._pending_state is not None:
self._apply_pending_state_sync()
def _apply_pending_state_sync(self) -> None:
"""Write pending state to database (called after migrations)."""
if self._pending_state is None:
return
serialized_state = self._pending_state
self._pending_state = None
state = deserialize_state_from_dict(
serialized_state, self._serializer, state_type=self.state_type
)
state_json = self._serialize_state(state) # type: ignore[arg-type]
with self.engine.begin() as conn:
self._upsert_state(conn, state_json, _utc_now())
def _run_instance_migrations(self) -> None:
"""Create schema and table if they don't exist (instance-level)."""
with self.engine.begin() as conn:
if self._is_postgres and self._schema:
conn.execute(text(f"CREATE SCHEMA IF NOT EXISTS {self._schema}")) # noqa: S608
self._table.create(bind=conn, checkfirst=True)
def _lock_row_for_update(self, conn: Connection) -> dict[str, Any] | None:
"""Lock and return row data for this run_id."""
for_update = "FOR UPDATE" if self._is_postgres else ""
result = conn.execute(
text(f"""
SELECT state_json
FROM {self._table_ref}
WHERE run_id = :run_id
{for_update}
"""), # noqa: S608
{"run_id": self._run_id},
)
row = result.fetchone()
if row is None:
return None
return {"state_json": row[0]}
def _upsert_state(
self,
conn: Connection,
state_json: str,
now: datetime,
) -> None:
"""Perform database-specific upsert operation."""
if self._is_postgres:
stmt = pg_insert(self._table).values(
run_id=self._run_id,
state_json=state_json,
created_at=now,
updated_at=now,
)
stmt = stmt.on_conflict_do_update(
index_elements=["run_id"],
set_={
"state_json": stmt.excluded.state_json,
"updated_at": stmt.excluded.updated_at,
},
)
conn.execute(stmt)
else:
# SQLite upsert
conn.execute(
text(f"""
INSERT INTO {self._table_ref}
(run_id, state_json, created_at, updated_at)
VALUES (:run_id, :state_json, :created_at, :updated_at)
ON CONFLICT (run_id) DO UPDATE SET
state_json = excluded.state_json,
updated_at = excluded.updated_at
"""), # noqa: S608
{
"run_id": self._run_id,
"state_json": state_json,
"created_at": now.isoformat(),
"updated_at": now.isoformat(),
},
)
def _serialize_state(self, state: MODEL_T) -> str:
"""Serialize state model to JSON string."""
if isinstance(state, DictState):
serialized_data: dict[str, Any] = {}
for key, value in state.items():
try:
serialized_data[key] = self._serializer.serialize(value)
except Exception:
if key in self.known_unserializable_keys:
logger.warning(f"Skipping unserializable key: {key}")
continue
raise
return json.dumps({"_data": serialized_data})
return self._serializer.serialize(state)
def _deserialize_state(self, state_json: str) -> MODEL_T:
"""Deserialize state from JSON string."""
if issubclass(self.state_type, DictState):
data = json.loads(state_json)
deserialized = {
k: self._serializer.deserialize(v)
for k, v in data.get("_data", {}).items()
}
return DictState(_data=deserialized) # type: ignore[return-value]
return self._serializer.deserialize(state_json)
def _create_default_state(self) -> MODEL_T:
"""Create a default instance of the state type."""
return self.state_type()
def _load_state_sync(self) -> MODEL_T:
"""Load state from database synchronously."""
self._ensure_initialized()
with self.engine.connect() as conn:
result = conn.execute(
select(self._table.c.state_json).where(
self._table.c.run_id == self._run_id
)
)
row = result.fetchone()
if row is None:
state = self._create_default_state()
self._save_state_sync(state, conn)
conn.commit()
return state
return self._deserialize_state(row[0])
def _save_state_sync(self, state: MODEL_T, conn: Connection) -> None:
"""Save state to database synchronously."""
now = _utc_now()
self._upsert_state(conn, self._serialize_state(state), now)
async def get_state(self) -> MODEL_T:
"""Return a copy of the current state model."""
state = await self._run_sync(self._load_state_sync)
return state.model_copy()
async def set_state(self, state: MODEL_T) -> None:
"""Replace or merge into the current state model."""
def _set_state_sync() -> None:
self._ensure_initialized()
with self.engine.begin() as conn:
result = conn.execute(
select(self._table.c.state_json).where(
self._table.c.run_id == self._run_id
)
)
row = result.fetchone()
if row is None:
self._save_state_sync(state, conn)
return
current_state = self._deserialize_state(row[0])
current_type = type(current_state)
new_type = type(state)
if isinstance(state, current_type):
self._save_state_sync(state, conn)
elif issubclass(current_type, new_type):
parent_data = state.model_dump()
merged = current_type.model_validate(
{**current_state.model_dump(), **parent_data}
)
self._save_state_sync(merged, conn)
else:
raise ValueError(
f"State must be of type {current_type.__name__} or parent, "
f"got {new_type.__name__}"
)
await self._run_sync(_set_state_sync)
async def get(self, path: str, default: Any = ...) -> Any:
"""Get a nested value using dot-separated paths."""
state = await self._run_sync(self._load_state_sync)
segments = path.split(".") if path else []
if len(segments) > MAX_DEPTH:
raise ValueError(f"Path length exceeds {MAX_DEPTH} segments")
try:
value: Any = state
for segment in segments:
value = traverse_path_step(value, segment)
except Exception:
if default is not ...:
return default
raise ValueError(f"Path '{path}' not found in state")
return value
async def set(self, path: str, value: Any) -> None:
"""Set a nested value using dot-separated paths."""
if not path:
raise ValueError("Path cannot be empty")
segments = path.split(".")
if len(segments) > MAX_DEPTH:
raise ValueError(f"Path length exceeds {MAX_DEPTH} segments")
async with self.edit_state() as state:
current: Any = state
for segment in segments[:-1]:
try:
current = traverse_path_step(current, segment)
except (KeyError, AttributeError, IndexError, TypeError):
intermediate: Any = {}
assign_path_step(current, segment, intermediate)
current = intermediate
assign_path_step(current, segments[-1], value)
async def clear(self) -> None:
"""Reset the state to its type defaults."""
try:
await self.set_state(self._create_default_state())
except ValidationError:
raise ValueError("State must have defaults for all fields")
@asynccontextmanager
async def edit_state(self) -> AsyncGenerator[MODEL_T, None]:
"""Edit state transactionally under a database lock."""
def _edit_with_lock() -> tuple[
MODEL_T, Callable[[MODEL_T], None], Callable[[], None]
]:
self._ensure_initialized()
conn = self.engine.connect()
trans = conn.begin()
finalized = False
try:
row_data = self._lock_row_for_update(conn)
if row_data is None:
state = self._create_default_state()
else:
state = self._deserialize_state(row_data["state_json"])
def commit_fn(updated_state: MODEL_T) -> None:
nonlocal finalized
if finalized:
return
try:
self._save_state_sync(updated_state, conn)
trans.commit()
finally:
finalized = True
conn.close()
def rollback_fn() -> None:
nonlocal finalized
if finalized:
return
try:
if trans.is_active:
trans.rollback()
finally:
finalized = True
conn.close()
return state, commit_fn, rollback_fn
except Exception:
trans.rollback()
conn.close()
raise
async with self._lock:
state, commit_fn, rollback_fn = await self._run_sync(_edit_with_lock)
try:
yield state
await self._run_sync(commit_fn, state)
except Exception:
try:
await self._run_sync(rollback_fn)
except Exception:
logger.exception("Failed to rollback edit_state transaction")
raise
def to_dict(self, serializer: BaseSerializer) -> dict[str, Any]:
"""Serialize state store metadata for persistence.
Returns a SqlSerializedState payload that can be restored by from_dict().
The actual state data lives in the database, so this only serializes
connection metadata (run_id, schema).
"""
payload = SqlSerializedState.model_validate(
{
"run_id": self._run_id,
"schema": self._schema,
}
)
return payload.model_dump(by_alias=True)
@classmethod
def from_dict(
cls,
serialized_state: dict[str, Any],
serializer: BaseSerializer,
state_type: type[BaseModel] = DictState,
run_id: str | None = None,
) -> SqlStateStore[Any]:
"""Restore a state store from serialized payload.
Handles both InMemorySerializedState and SqlSerializedState formats:
- InMemorySerializedState: Stores the serialized data internally and
writes it to the database when the engine is first used (via
_ensure_initialized). This enables restoring state from in-memory
format into a SQL-backed store.
- SqlSerializedState: Creates a store pointing at the existing database
row. If a different run_id is provided, the store will use that run_id
(data copying must be handled separately if needed).
Note: The engine must be set separately after restoration.
Args:
serialized_state: Payload from to_dict() of either store type.
serializer: Serializer for data handling.
state_type: The state model type for deserialization.
run_id: Optional override run_id. If not provided, uses the run_id
from SqlSerializedState or generates one for InMemory format.
Returns:
A new SqlStateStore instance configured from the payload.
Raises:
ValueError: If serialized_state is empty.
"""
import uuid
if not serialized_state:
raise ValueError("Cannot restore SqlStateStore from empty dict")
parsed = parse_serialized_state(serialized_state)
if isinstance(parsed, InMemorySerializedState):
# InMemory format: store data internally, apply when engine is set
effective_run_id = run_id or str(uuid.uuid4())
store = cls(
run_id=effective_run_id,
state_type=state_type, # type: ignore[arg-type]
serializer=serializer,
)
# Store the serialized state to apply when engine is available
store._pending_state = serialized_state
return store
else:
# SqlSerializedState format: create store pointing at existing row
effective_run_id = run_id or parsed.run_id
schema = parsed.db_schema
return cls(
run_id=effective_run_id,
state_type=state_type, # type: ignore[arg-type]
serializer=serializer,
schema=schema,
)
@@ -0,0 +1,21 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
from __future__ import annotations
import pytest
from llama_agents.dbos.journal.crud import JournalCrud
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.pool import StaticPool
@pytest.fixture
def sqlite_engine() -> Engine:
"""Create an in-memory SQLite engine with journal table."""
engine = create_engine(
"sqlite:///:memory:",
connect_args={"check_same_thread": False},
poolclass=StaticPool,
)
JournalCrud().run_migrations(engine)
return engine
+295
View File
@@ -0,0 +1,295 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""Subprocess runner for workflow tests with DBOS isolation.
This module provides a CLI runner for executing workflows in isolated
subprocesses, supporting interrupt/resume testing and human-in-the-loop
response simulation.
Usage:
python /path/to/packages/llama-agents-dbos/tests/fixtures/runner.py \
--workflow "tests.fixtures.workflows.hitl:TestWorkflow" \
--db-url "sqlite+pysqlite:///path/to/db" \
--run-id "test-001" \
--config '{"interrupt_on": "AskInputEvent"}'
Config modes:
- interrupt_on: Interrupt when event type is seen (uses os._exit(0))
- String form: "EventName" - interrupt on any instance of EventName
- Dict form: {"event": "EventName", "condition": {"field": value}}
- interrupt only when type matches AND all condition fields match
- respond: Respond to InputRequiredEvent subtypes with specified events
- run-to-completion: Empty config or omit both fields
"""
from __future__ import annotations
import argparse
import asyncio
import importlib
import json
import os
import sys
from pathlib import Path
from types import ModuleType
from typing import Any
# Add package source directories to sys.path for imports
# Runner is at: packages/llama-agents-dbos/tests/fixtures/runner.py
# We need to add:
# - packages/llama-agents-dbos/src for llama_agents.dbos
# - packages/llama-index-workflows/src for workflows.*
# - packages/llama-agents-dbos (parent of tests/) so tests.fixtures.workflows.* can be imported
TESTS_DIR = Path(__file__).parent.parent
DBOS_PACKAGE_DIR = TESTS_DIR.parent
DBOS_PACKAGE_SRC_PATH = str(DBOS_PACKAGE_DIR / "src")
WORKFLOWS_PACKAGE_SRC_PATH = str(
DBOS_PACKAGE_DIR.parent / "llama-index-workflows" / "src"
)
# Insert at front of path so these packages take precedence
# Add the parent of tests/ so "import tests.fixtures.workflows..." works
sys.path.insert(0, str(DBOS_PACKAGE_DIR))
sys.path.insert(0, DBOS_PACKAGE_SRC_PATH)
sys.path.insert(0, WORKFLOWS_PACKAGE_SRC_PATH)
from dbos import DBOS, DBOSConfig # noqa: E402
from llama_agents.dbos import DBOSRuntime # noqa: E402
from workflows.context import Context # noqa: E402
from workflows.events import Event, InputRequiredEvent, StartEvent # noqa: E402
from workflows.workflow import Workflow # noqa: E402
def import_workflow(path: str) -> tuple[type[Workflow], ModuleType]:
"""Import a workflow class from a module path.
Args:
path: Module path with class name, e.g., "tests.fixtures.workflows.hitl:TestWorkflow"
Returns:
Tuple of (workflow_class, module) for accessing classes defined in the module.
Raises:
ValueError: If path format is invalid.
ImportError: If module cannot be imported.
AttributeError: If class not found in module.
"""
if ":" not in path:
raise ValueError(
f"Invalid workflow path format: {path}. Expected 'module.path:ClassName'"
)
module_path, class_name = path.rsplit(":", 1)
module = importlib.import_module(module_path)
workflow_class = getattr(module, class_name)
if not (isinstance(workflow_class, type) and issubclass(workflow_class, Workflow)):
raise TypeError(f"{class_name} is not a Workflow subclass")
return workflow_class, module
def get_event_class_by_name(module: ModuleType, name: str) -> type[Event] | None:
"""Find an event class in a module by its name.
Searches through all attributes of the module to find an Event subclass
with a matching class name.
Args:
module: The module to search in.
name: The class name to find.
Returns:
The event class if found, None otherwise.
"""
for attr_name in dir(module):
attr = getattr(module, attr_name)
if isinstance(attr, type) and issubclass(attr, Event) and attr.__name__ == name:
return attr
return None
def parse_config(config_json: str | None) -> dict[str, Any]:
"""Parse the JSON config string.
Args:
config_json: JSON string with configuration, or None.
Returns:
Parsed config dict, or empty dict if None.
"""
if not config_json:
return {}
return json.loads(config_json)
def setup_dbos(db_url: str, app_name: str = "test-workflow") -> DBOSRuntime:
"""Set up DBOS with the given database URL.
Args:
db_url: SQLite database URL.
app_name: Application name for DBOS config.
Returns:
Configured DBOSRuntime instance.
"""
config: DBOSConfig = {
"name": app_name,
"system_database_url": db_url,
"run_admin_server": False,
"notification_listener_polling_interval_sec": 0.01,
}
DBOS(config=config)
return DBOSRuntime(polling_interval_sec=0.01)
async def run_workflow(
workflow_path: str,
db_url: str,
run_id: str,
config: dict[str, Any],
) -> None:
"""Run the workflow with the specified configuration.
Args:
workflow_path: Module path with class name.
db_url: SQLite database URL.
run_id: Unique run ID for the workflow.
config: Configuration dict with interrupt_on and/or respond settings.
"""
# Import workflow and get module for event class lookup
workflow_class, module = import_workflow(workflow_path)
# Parse config options
interrupt_on_config = config.get("interrupt_on")
respond_config = config.get("respond", {})
# Resolve interrupt config (can be string or dict with condition)
interrupt_event_class: type[Event] | None = None
interrupt_condition: dict[str, Any] | None = None
if interrupt_on_config:
if isinstance(interrupt_on_config, str):
interrupt_event_name = interrupt_on_config
else:
interrupt_event_name = interrupt_on_config.get("event")
interrupt_condition = interrupt_on_config.get("condition")
interrupt_event_class = get_event_class_by_name(module, interrupt_event_name)
if interrupt_event_class is None:
print(
f"ERROR:ValueError:Event class '{interrupt_event_name}' not found in module"
)
sys.exit(1)
# Build response event mapping: {trigger_class: (response_class, fields)}
response_map: dict[type[Event], tuple[type[Event], dict[str, Any]]] = {}
for trigger_name, response_info in respond_config.items():
trigger_class = get_event_class_by_name(module, trigger_name)
if trigger_class is None:
print(
f"ERROR:ValueError:Trigger event class '{trigger_name}' not found in module"
)
sys.exit(1)
response_event_name = response_info.get("event")
response_fields = response_info.get("fields", {})
response_class = get_event_class_by_name(module, response_event_name)
if response_class is None:
print(
f"ERROR:ValueError:Response event class '{response_event_name}' not found in module"
)
sys.exit(1)
# Both trigger_class and response_class are narrowed after sys.exit(1) guards
assert trigger_class is not None
assert response_class is not None
response_map[trigger_class] = (response_class, response_fields)
# Set up DBOS and runtime
runtime = setup_dbos(db_url)
# Create workflow instance and launch
wf = workflow_class(runtime=runtime)
runtime.launch()
try:
ctx = Context(wf)
handler = ctx._workflow_run(wf, StartEvent(), run_id=run_id)
async for event in handler.stream_events():
event_name = type(event).__name__
print(f"EVENT:{event_name}", flush=True)
# Check for interrupt condition
if interrupt_event_class is not None and isinstance(
event, interrupt_event_class
):
# Check condition fields if present
should_interrupt = True
if interrupt_condition:
for field, expected_value in interrupt_condition.items():
actual_value = getattr(event, field, None)
if actual_value != expected_value:
should_interrupt = False
break
if should_interrupt:
print("INTERRUPTING", flush=True)
os._exit(0)
# Check for response condition (InputRequiredEvent subtypes)
if isinstance(event, InputRequiredEvent):
for trigger_class, (response_class, fields) in response_map.items():
if isinstance(event, trigger_class):
if handler.ctx:
response_event = response_class(**fields)
handler.ctx.send_event(response_event)
break
result = await handler
print(f"RESULT:{result}", flush=True)
print("SUCCESS", flush=True)
except Exception as e:
print(f"ERROR:{type(e).__name__}:{e}", flush=True)
raise
finally:
runtime.destroy()
def main() -> None:
"""Entry point for the subprocess runner."""
parser = argparse.ArgumentParser(
description="Run workflows in isolated subprocesses for testing"
)
parser.add_argument(
"--workflow",
required=True,
help="Module path with class name (e.g., 'tests.fixtures.workflows.hitl:TestWorkflow')",
)
parser.add_argument(
"--db-url",
required=True,
help="SQLite database URL",
)
parser.add_argument(
"--run-id",
required=True,
help="Unique run ID for the workflow",
)
parser.add_argument(
"--config",
default=None,
help="JSON string with configuration",
)
args = parser.parse_args()
config = parse_config(args.config)
asyncio.run(
run_workflow(
workflow_path=args.workflow,
db_url=args.db_url,
run_id=args.run_id,
config=config,
)
)
if __name__ == "__main__":
main()
@@ -0,0 +1,39 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""Chained workflow fixture with StepOneEvent, StepTwoEvent, and ChainedWorkflow."""
from __future__ import annotations
from pydantic import Field
from workflows.context import Context
from workflows.decorators import step
from workflows.events import Event, StartEvent, StopEvent
from workflows.workflow import Workflow
class StepOneEvent(Event):
value: str = Field(default="one")
class StepTwoEvent(Event):
value: str = Field(default="two")
class ChainedWorkflow(Workflow):
@step
async def step_one(self, ctx: Context, ev: StartEvent) -> StepOneEvent:
await ctx.store.set("step_one", True)
print("STEP:one:complete", flush=True)
return StepOneEvent()
@step
async def step_two(self, ctx: Context, ev: StepOneEvent) -> StepTwoEvent:
await ctx.store.set("step_two", True)
print("STEP:two:complete", flush=True)
return StepTwoEvent()
@step
async def step_three(self, ctx: Context, ev: StepTwoEvent) -> StopEvent:
await ctx.store.set("step_three", True)
print("STEP:three:complete", flush=True)
return StopEvent(result="done")
@@ -0,0 +1,45 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""Concurrent workers workflow fixture with num_workers=2."""
from __future__ import annotations
import asyncio
import random
from pydantic import Field
from workflows.context import Context
from workflows.decorators import step
from workflows.events import Event, StartEvent, StopEvent
from workflows.workflow import Workflow
class WorkItem(Event):
item_id: int = Field(default=0)
class WorkDone(Event):
item_id: int = Field(default=0)
class ConcurrentWorkersWorkflow(Workflow):
@step
async def dispatch(self, ctx: Context, ev: StartEvent) -> WorkItem:
# Dispatch work items that will be processed by concurrent workers
ctx.send_event(WorkItem(item_id=1))
ctx.send_event(WorkItem(item_id=2))
print("STEP:dispatch:complete", flush=True)
return WorkItem(item_id=0)
@step(num_workers=2)
async def worker(self, ctx: Context, ev: WorkItem) -> WorkDone:
# Variable processing time for each item
await asyncio.sleep(random.uniform(0.01, 0.05))
print(f"STEP:worker:{ev.item_id}:complete", flush=True)
return WorkDone(item_id=ev.item_id)
@step
async def finish(self, ctx: Context, ev: WorkDone) -> StopEvent:
# First WorkDone to arrive ends the workflow
print(f"STEP:finish:{ev.item_id}:complete", flush=True)
return StopEvent(result={"first_done": ev.item_id})
@@ -0,0 +1,33 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""Basic HITL workflow fixture with AskInputEvent and UserInput events."""
from __future__ import annotations
from pydantic import Field
from workflows.context import Context
from workflows.decorators import step
from workflows.events import Event, InputRequiredEvent, StartEvent, StopEvent
from workflows.workflow import Workflow
class AskInputEvent(InputRequiredEvent):
prefix: str = Field(default="Enter: ")
class UserInput(Event):
response: str = Field(default="")
class TestWorkflow(Workflow):
@step
async def ask(self, ctx: Context, ev: StartEvent) -> AskInputEvent:
await ctx.store.set("asked", True)
print("STEP:ask:complete", flush=True)
return AskInputEvent()
@step
async def process(self, ctx: Context, ev: UserInput) -> StopEvent:
await ctx.store.set("processed", ev.response)
print("STEP:process:complete", flush=True)
return StopEvent(result={"response": ev.response})
@@ -0,0 +1,48 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""Parallel workflow fixture with ResultAEvent, ResultBEvent, and ParallelWorkflow."""
from __future__ import annotations
import asyncio
import random
from pydantic import Field
from workflows.context import Context
from workflows.decorators import step
from workflows.events import Event, StartEvent, StopEvent
from workflows.workflow import Workflow
class ResultAEvent(Event):
value: str = Field(default="")
class ResultBEvent(Event):
value: str = Field(default="")
class ParallelWorkflow(Workflow):
@step
async def branch_a(self, ctx: Context, ev: StartEvent) -> ResultAEvent:
# Variable processing time - may complete before or after branch_b
await asyncio.sleep(random.uniform(0.01, 0.05))
print("STEP:branch_a:complete", flush=True)
return ResultAEvent(value="a_result")
@step
async def branch_b(self, ctx: Context, ev: StartEvent) -> ResultBEvent:
# Variable processing time - may complete before or after branch_a
await asyncio.sleep(random.uniform(0.01, 0.05))
print("STEP:branch_b:complete", flush=True)
return ResultBEvent(value="b_result")
@step
async def finish_a(self, ctx: Context, ev: ResultAEvent) -> StopEvent:
print("STEP:finish_a:complete", flush=True)
return StopEvent(result={"winner": "a", "value": ev.value})
@step
async def finish_b(self, ctx: Context, ev: ResultBEvent) -> StopEvent:
print("STEP:finish_b:complete", flush=True)
return StopEvent(result={"winner": "b", "value": ev.value})
@@ -0,0 +1,44 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""Sequential HITL workflow fixture with ProcessedEvent and WaitForInputEvent."""
from __future__ import annotations
import asyncio
import random
from pydantic import Field
from workflows.context import Context
from workflows.decorators import step
from workflows.events import Event, InputRequiredEvent, StartEvent, StopEvent
from workflows.workflow import Workflow
class ProcessedEvent(Event):
value: str = Field(default="")
class WaitForInputEvent(InputRequiredEvent):
prompt: str = Field(default="")
class UserContinueEvent(Event):
continue_value: str = Field(default="")
class SequentialHITLWorkflow(Workflow):
@step
async def process(self, ctx: Context, ev: StartEvent) -> ProcessedEvent:
await asyncio.sleep(random.uniform(0.01, 0.05))
print("STEP:process:complete", flush=True)
return ProcessedEvent(value="processed")
@step
async def ask_user(self, ctx: Context, ev: ProcessedEvent) -> WaitForInputEvent:
print("STEP:ask_user:triggering_wait", flush=True)
return WaitForInputEvent(prompt=f"Got {ev.value}")
@step
async def finalize(self, ctx: Context, ev: UserContinueEvent) -> StopEvent:
print(f"STEP:finalize:complete:{ev.continue_value}", flush=True)
return StopEvent(result={"continue": ev.continue_value})
@@ -0,0 +1,67 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""Streaming workflow for interrupt/resume testing.
Like streaming_stress but collects all WorkDone events before completing,
ensuring stream events (including the interrupt signal) are visible before
the workflow can finish.
"""
from __future__ import annotations
import asyncio
from pydantic import Field
from workflows.context import Context
from workflows.decorators import step
from workflows.events import Event, StartEvent, StopEvent
from workflows.workflow import Workflow
class ProgressEvent(Event):
progress: int = Field(default=0)
class WorkItem(Event):
item_id: int = Field(default=0)
class WorkDone(Event):
item_id: int = Field(default=0)
class FanOutComplete(Event):
pass
class StreamingInterruptWorkflow(Workflow):
@step
async def fan_out(self, ctx: Context, ev: StartEvent) -> FanOutComplete:
for i in range(15):
ctx.write_event_to_stream(ProgressEvent(progress=i))
ctx.send_event(WorkItem(item_id=i))
print("STEP:fan_out:dispatched_15_items", flush=True)
ctx.write_event_to_stream(ProgressEvent(progress=999))
return FanOutComplete()
@step(num_workers=4)
async def process_work(self, ctx: Context, ev: WorkItem) -> WorkDone:
await asyncio.sleep(0.01)
ctx.write_event_to_stream(ProgressEvent(progress=100 + ev.item_id))
print(f"STEP:process_work:{ev.item_id}:complete", flush=True)
return WorkDone(item_id=ev.item_id)
@step
async def after_fanout(self, ctx: Context, ev: FanOutComplete) -> None:
print("STEP:after_fanout:complete", flush=True)
return None
@step
async def collect(self, ctx: Context, ev: WorkDone) -> StopEvent | None:
# Wait for all workers to finish, preventing early completion
# that could race with stream event consumption
results = ctx.collect_events(ev, [WorkDone] * 15)
if results is None:
return None
print("STEP:collect:all_done", flush=True)
return StopEvent(result={"collected": len(results)})
@@ -0,0 +1,64 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""Streaming stress workflow fixture with many concurrent stream writes."""
from __future__ import annotations
import asyncio
from pydantic import Field
from workflows.context import Context
from workflows.decorators import step
from workflows.events import Event, StartEvent, StopEvent
from workflows.workflow import Workflow
class ProgressEvent(Event):
progress: int = Field(default=0)
class WorkItem(Event):
item_id: int = Field(default=0)
class WorkDone(Event):
item_id: int = Field(default=0)
total_processed: int = Field(default=0)
class FanOutComplete(Event):
pass
class StreamingStressWorkflow(Workflow):
@step
async def fan_out(self, ctx: Context, ev: StartEvent) -> FanOutComplete:
# Fire many stream writes and internal events concurrently
# This creates many background tasks that call DBOS operations
for i in range(15):
ctx.write_event_to_stream(ProgressEvent(progress=i))
ctx.send_event(WorkItem(item_id=i))
print("STEP:fan_out:dispatched_15_items", flush=True)
# Write completion signal to stream for interrupt tests
ctx.write_event_to_stream(ProgressEvent(progress=999))
return FanOutComplete()
@step(num_workers=4)
async def process_work(self, ctx: Context, ev: WorkItem) -> WorkDone:
# Each worker also writes to stream, creating more concurrent DBOS ops
await asyncio.sleep(0.01) # Small delay to increase interleaving
ctx.write_event_to_stream(ProgressEvent(progress=100 + ev.item_id))
print(f"STEP:process_work:{ev.item_id}:complete", flush=True)
return WorkDone(item_id=ev.item_id)
@step
async def after_fanout(self, ctx: Context, ev: FanOutComplete) -> None:
# Consume FanOutComplete, don't trigger anything
print("STEP:after_fanout:complete", flush=True)
return None
@step
async def collect(self, ctx: Context, ev: WorkDone) -> StopEvent:
# First WorkDone ends the workflow
print(f"STEP:collect:{ev.item_id}:complete", flush=True)
return StopEvent(result={"first_done": ev.item_id})
@@ -0,0 +1,48 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""Three-step HITL workflow fixture with name and quest input events."""
from __future__ import annotations
from pydantic import Field
from workflows.context import Context
from workflows.decorators import step
from workflows.events import Event, InputRequiredEvent, StartEvent, StopEvent
from workflows.workflow import Workflow
class NameInputEvent(InputRequiredEvent):
prefix: str = Field(default="Name: ")
class NameResponseEvent(Event):
response: str = Field(default="")
class QuestInputEvent(InputRequiredEvent):
prefix: str = Field(default="Quest: ")
class QuestResponseEvent(Event):
response: str = Field(default="")
class HITLWorkflow(Workflow):
@step
async def ask_name(self, ctx: Context, ev: StartEvent) -> NameInputEvent:
await ctx.store.set("asked_name", True)
print("STEP:ask_name:complete", flush=True)
return NameInputEvent()
@step
async def ask_quest(self, ctx: Context, ev: NameResponseEvent) -> QuestInputEvent:
await ctx.store.set("name", ev.response)
print(f"STEP:ask_quest:got_name={ev.response}", flush=True)
print("STEP:ask_quest:complete", flush=True)
return QuestInputEvent()
@step
async def complete(self, ctx: Context, ev: QuestResponseEvent) -> StopEvent:
name = await ctx.store.get("name", default="unknown")
print(f"STEP:complete:got_quest={ev.response}", flush=True)
return StopEvent(result={"name": name, "quest": ev.response})
@@ -0,0 +1,423 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""Test DBOS determinism with subprocess isolation and real interruption.
This test spawns subprocesses to properly isolate DBOS state and simulate
real Ctrl+C interruptions during workflow execution.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
from typing import Any
import pytest
RUNNER_PATH = str(Path(__file__).parent / "fixtures" / "runner.py")
def log_on_failure(result: subprocess.CompletedProcess[str], label: str) -> None:
if result.returncode != 0:
print(f"\n=== {label} FAILED ===")
print(f"stdout: {result.stdout}")
print(f"stderr: {result.stderr}")
@pytest.fixture
def test_db_path(tmp_path: Path) -> Path:
"""Create a temporary database path."""
return tmp_path / "dbos_test.sqlite3"
def run_scenario(
workflow: str,
db_url: str,
run_id: str,
config: dict[str, Any] | None = None,
timeout: float = 30.0,
) -> subprocess.CompletedProcess[str]:
"""Run a workflow scenario in a subprocess.
Args:
workflow: Module path with class name (e.g., "tests.fixtures.workflows.hitl:TestWorkflow")
db_url: SQLite database URL
run_id: Unique run ID for the workflow
config: Optional config dict with interrupt_on and/or respond settings
timeout: Subprocess timeout in seconds
Returns:
CompletedProcess with stdout and stderr captured.
"""
cmd = [
sys.executable,
RUNNER_PATH,
"--workflow",
workflow,
"--db-url",
db_url,
"--run-id",
run_id,
]
if config:
cmd.extend(["--config", json.dumps(config)])
return subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
def assert_no_determinism_errors(result: subprocess.CompletedProcess[str]) -> None:
"""Check subprocess result for crashes and DBOS determinism errors."""
combined = result.stdout + result.stderr
# Check for non-zero exit code (catches segfaults, killed processes, etc.)
if result.returncode != 0:
pytest.fail(
f"Subprocess exited with code {result.returncode}\n"
f"stdout: {result.stdout}\n"
f"stderr: {result.stderr}"
)
# Catch any unhandled Python exception
if "Traceback (most recent call last)" in combined:
pytest.fail(
f"Subprocess exception!\nstdout: {result.stdout}\nstderr: {result.stderr}"
)
# Check for DBOS-specific determinism errors
if "DBOSUnexpectedStepError" in combined or "Error 11" in combined:
pytest.fail(
f"DBOS determinism error on resume!\n"
f"stdout: {result.stdout}\n"
f"stderr: {result.stderr}"
)
# =============================================================================
# Test 1: Basic interrupt/resume with input events
# =============================================================================
def test_determinism_on_resume_after_interrupt(test_db_path: Path) -> None:
"""Test that resuming an interrupted workflow doesn't hit determinism errors."""
run_id = "test-determinism-001"
db_url = f"sqlite+pysqlite:///{test_db_path}?check_same_thread=false"
result1 = run_scenario(
workflow="tests.fixtures.workflows.hitl:TestWorkflow",
db_url=db_url,
run_id=run_id,
config={"interrupt_on": "AskInputEvent"},
)
log_on_failure(result1, "initial run")
assert "STEP:ask:complete" in result1.stdout, "First step should complete"
assert "INTERRUPTING" in result1.stdout, "Should have interrupted"
result2 = run_scenario(
workflow="tests.fixtures.workflows.hitl:TestWorkflow",
db_url=db_url,
run_id=run_id,
config={
"respond": {
"AskInputEvent": {
"event": "UserInput",
"fields": {"response": "test_input"},
}
}
},
)
log_on_failure(result2, "resume")
assert_no_determinism_errors(result2)
assert "SUCCESS" in result2.stdout, (
f"Resume should succeed. stdout: {result2.stdout}, stderr: {result2.stderr}"
)
# =============================================================================
# Test 2: Chained steps determinism
# =============================================================================
def test_chained_steps_determinism_on_resume(test_db_path: Path) -> None:
"""Test determinism with chained steps that trigger each other."""
run_id = "test-chained-001"
db_url = f"sqlite+pysqlite:///{test_db_path}?check_same_thread=false"
result1 = run_scenario(
workflow="tests.fixtures.workflows.chained:ChainedWorkflow",
db_url=db_url,
run_id=run_id,
config={"interrupt_on": "StepTwoEvent"},
)
log_on_failure(result1, "initial run")
assert "STEP:one:complete" in result1.stdout, "Step one should complete"
result2 = run_scenario(
workflow="tests.fixtures.workflows.chained:ChainedWorkflow",
db_url=db_url,
run_id=run_id,
)
log_on_failure(result2, "resume")
assert_no_determinism_errors(result2)
# =============================================================================
# Test 3: Three-step HITL pattern
# =============================================================================
def test_hitl_three_step_determinism(test_db_path: Path) -> None:
"""Test the exact HITL pattern with three steps and input events."""
run_id = "test-hitl-three-001"
db_url = f"sqlite+pysqlite:///{test_db_path}?check_same_thread=false"
result1 = run_scenario(
workflow="tests.fixtures.workflows.three_step_hitl:HITLWorkflow",
db_url=db_url,
run_id=run_id,
config={
"respond": {
"NameInputEvent": {
"event": "NameResponseEvent",
"fields": {"response": "Alice"},
}
},
"interrupt_on": "QuestInputEvent",
},
)
log_on_failure(result1, "initial run")
assert "STEP:ask_name:complete" in result1.stdout, "ask_name should complete"
assert "STEP:ask_quest" in result1.stdout, "ask_quest should start"
assert "INTERRUPTING" in result1.stdout, "Should interrupt at quest"
result2 = run_scenario(
workflow="tests.fixtures.workflows.three_step_hitl:HITLWorkflow",
db_url=db_url,
run_id=run_id,
config={
"respond": {
"NameInputEvent": {
"event": "NameResponseEvent",
"fields": {"response": "Alice"},
},
"QuestInputEvent": {
"event": "QuestResponseEvent",
"fields": {"response": "seek the grail"},
},
},
},
)
log_on_failure(result2, "resume")
assert_no_determinism_errors(result2)
assert "SUCCESS" in result2.stdout, (
f"Resume should succeed.\nstdout: {result2.stdout}\nstderr: {result2.stderr}"
)
# =============================================================================
# Test 4: Parallel steps - two steps triggered by StartEvent
# =============================================================================
def test_parallel_steps_determinism(test_db_path: Path) -> None:
"""Test determinism with parallel steps completing in non-deterministic order."""
run_id = "test-parallel-001"
db_url = f"sqlite+pysqlite:///{test_db_path}?check_same_thread=false"
result1 = run_scenario(
workflow="tests.fixtures.workflows.parallel:ParallelWorkflow",
db_url=db_url,
run_id=run_id,
)
log_on_failure(result1, "parallel run")
assert "SUCCESS" in result1.stdout, (
f"Should complete successfully.\nstdout: {result1.stdout}\nstderr: {result1.stderr}"
)
assert_no_determinism_errors(result1)
# =============================================================================
# Test 5: Concurrent workers on same step (num_workers=2)
# =============================================================================
def test_concurrent_workers_determinism(test_db_path: Path) -> None:
"""Test determinism with multiple workers on same step (num_workers > 1)."""
run_id = "test-concurrent-workers-001"
db_url = f"sqlite+pysqlite:///{test_db_path}?check_same_thread=false"
result1 = run_scenario(
workflow="tests.fixtures.workflows.concurrent_workers:ConcurrentWorkersWorkflow",
db_url=db_url,
run_id=run_id,
)
log_on_failure(result1, "concurrent workers run")
assert "SUCCESS" in result1.stdout, (
f"Should complete successfully.\nstdout: {result1.stdout}\nstderr: {result1.stderr}"
)
assert_no_determinism_errors(result1)
# =============================================================================
# Test 6: Sequential steps with HITL
# =============================================================================
def test_sequential_hitl_interrupt_resume(test_db_path: Path) -> None:
"""Test sequential steps with HITL interrupt and resume."""
run_id = "test-seq-hitl-001"
db_url = f"sqlite+pysqlite:///{test_db_path}?check_same_thread=false"
result1 = run_scenario(
workflow="tests.fixtures.workflows.sequential_hitl:SequentialHITLWorkflow",
db_url=db_url,
run_id=run_id,
config={"interrupt_on": "WaitForInputEvent"},
)
log_on_failure(result1, "initial run")
assert "STEP:process:complete" in result1.stdout
assert "INTERRUPTING" in result1.stdout
result2 = run_scenario(
workflow="tests.fixtures.workflows.sequential_hitl:SequentialHITLWorkflow",
db_url=db_url,
run_id=run_id,
config={
"respond": {
"WaitForInputEvent": {
"event": "UserContinueEvent",
"fields": {"continue_value": "user_input"},
}
}
},
)
log_on_failure(result2, "resume")
assert_no_determinism_errors(result2)
assert "SUCCESS" in result2.stdout, (
f"Resume should succeed.\nstdout: {result2.stdout}\nstderr: {result2.stderr}"
)
# =============================================================================
# Stress tests - run scenarios multiple times to catch flaky timing issues
# =============================================================================
@pytest.mark.parametrize("iteration", range(5))
def test_parallel_steps_stress(test_db_path: Path, iteration: int) -> None:
"""Stress test parallel steps - run 5 times to catch timing issues."""
run_id = f"test-parallel-stress-{iteration}"
db_url = f"sqlite+pysqlite:///{test_db_path}?check_same_thread=false"
result = run_scenario(
workflow="tests.fixtures.workflows.parallel:ParallelWorkflow",
db_url=db_url,
run_id=run_id,
)
assert "SUCCESS" in result.stdout, (
f"Iteration {iteration} failed.\nstdout: {result.stdout}\nstderr: {result.stderr}"
)
assert_no_determinism_errors(result)
@pytest.mark.parametrize("iteration", range(5))
def test_concurrent_workers_stress(test_db_path: Path, iteration: int) -> None:
"""Stress test concurrent workers - run 5 times to catch timing issues."""
run_id = f"test-concurrent-stress-{iteration}"
db_url = f"sqlite+pysqlite:///{test_db_path}?check_same_thread=false"
result = run_scenario(
workflow="tests.fixtures.workflows.concurrent_workers:ConcurrentWorkersWorkflow",
db_url=db_url,
run_id=run_id,
)
assert "SUCCESS" in result.stdout, (
f"Iteration {iteration} failed.\nstdout: {result.stdout}\nstderr: {result.stderr}"
)
assert_no_determinism_errors(result)
# =============================================================================
# Test 7: Streaming stress test
# =============================================================================
def test_streaming_stress_determinism(test_db_path: Path) -> None:
"""Test determinism with many concurrent stream writes and send_event calls."""
run_id = "test-streaming-stress-001"
db_url = f"sqlite+pysqlite:///{test_db_path}?check_same_thread=false"
result = run_scenario(
workflow="tests.fixtures.workflows.streaming_stress:StreamingStressWorkflow",
db_url=db_url,
run_id=run_id,
)
log_on_failure(result, "streaming stress")
assert "SUCCESS" in result.stdout, (
f"Should complete successfully.\nstdout: {result.stdout}\nstderr: {result.stderr}"
)
assert_no_determinism_errors(result)
def test_streaming_interrupt_resume(test_db_path: Path) -> None:
"""Test interrupt/resume with many concurrent stream writes in flight."""
run_id = "test-streaming-interrupt-001"
db_url = f"sqlite+pysqlite:///{test_db_path}?check_same_thread=false"
result1 = run_scenario(
workflow="tests.fixtures.workflows.streaming_interrupt:StreamingInterruptWorkflow",
db_url=db_url,
run_id=run_id,
config={
"interrupt_on": {"event": "ProgressEvent", "condition": {"progress": 999}}
},
)
log_on_failure(result1, "initial run")
assert "STEP:fan_out:dispatched_15_items" in result1.stdout, (
"Fan out should complete"
)
assert "INTERRUPTING" in result1.stdout, "Should have interrupted"
result2 = run_scenario(
workflow="tests.fixtures.workflows.streaming_interrupt:StreamingInterruptWorkflow",
db_url=db_url,
run_id=run_id,
)
log_on_failure(result2, "resume")
assert_no_determinism_errors(result2)
assert "SUCCESS" in result2.stdout, (
f"Resume should succeed.\nstdout: {result2.stdout}\nstderr: {result2.stderr}"
)
@pytest.mark.parametrize("iteration", range(5))
def test_streaming_stress_repeated(test_db_path: Path, iteration: int) -> None:
"""Stress test streaming - run 5 times to catch timing issues."""
run_id = f"test-streaming-repeated-{iteration}"
db_url = f"sqlite+pysqlite:///{test_db_path}?check_same_thread=false"
result = run_scenario(
workflow="tests.fixtures.workflows.streaming_stress:StreamingStressWorkflow",
db_url=db_url,
run_id=run_id,
)
assert "SUCCESS" in result.stdout, (
f"Iteration {iteration} failed.\nstdout: {result.stdout}\nstderr: {result.stderr}"
)
assert_no_determinism_errors(result)
@@ -0,0 +1,395 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""DBOS-specific runtime tests for adapter behavior.
These tests focus on the internal mechanics of the DBOS adapter,
particularly around run_id matching and state store availability.
"""
from __future__ import annotations
import asyncio
from contextlib import suppress
from pathlib import Path
from typing import Any, Generator, cast
from unittest.mock import patch
import pytest
from dbos import DBOS, DBOSConfig
from llama_agents.dbos import DBOSRuntime
from llama_agents.dbos.journal.task_journal import TaskJournal
from llama_agents.dbos.runtime import InternalDBOSAdapter
from llama_agents.dbos.state_store import (
SqlSerializedState,
SqlStateStore,
parse_serialized_state,
)
from pydantic import Field
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.pool import QueuePool
from workflows.context import Context
from workflows.context.state_store import InMemorySerializedState
from workflows.decorators import step
from workflows.events import Event, StartEvent, StopEvent
from workflows.runtime.types.named_task import NamedTask
from workflows.testing import WorkflowTestRunner
from workflows.workflow import Workflow
@pytest.fixture(scope="module")
def dbos_config(tmp_path_factory: pytest.TempPathFactory) -> DBOSConfig:
"""Create DBOS config with a fresh SQLite database."""
db_file = tmp_path_factory.mktemp("dbos") / "dbos_debug_test.sqlite3"
system_db_url = f"sqlite+pysqlite:///{db_file}?check_same_thread=false"
return {
"name": "workflows-dbos-debug",
"system_database_url": system_db_url,
"run_admin_server": False,
} # type: ignore[return-value]
@pytest.fixture(scope="module")
def dbos_runtime(dbos_config: DBOSConfig) -> Generator[DBOSRuntime, None, None]:
"""Module-scoped DBOS runtime with fast polling for tests."""
DBOS(config=dbos_config)
runtime = DBOSRuntime(polling_interval_sec=0.01)
try:
yield runtime
finally:
runtime.destroy()
class DebugEvent(Event):
captured_run_id: str = Field(default="")
captured_dbos_workflow_id: str = Field(default="")
state_store_available: bool = Field(default=False)
class RunIdCaptureWorkflow(Workflow):
"""Workflow that captures run_id info for debugging."""
@step
async def capture_ids(self, ev: StartEvent) -> StopEvent:
dbos_workflow_id = DBOS.workflow_id or "None"
return StopEvent(result={"dbos_workflow_id": dbos_workflow_id})
class StateStoreAccessWorkflow(Workflow):
"""Workflow that attempts to access state store."""
@step
async def access_store(self, ctx: Context, ev: StartEvent) -> StopEvent:
dbos_workflow_id = DBOS.workflow_id or "None"
try:
await ctx.store.set("test_key", "test_value")
value = await ctx.store.get("test_key")
store_works = value == "test_value"
except Exception as e:
return StopEvent(
result={
"dbos_workflow_id": dbos_workflow_id,
"store_works": False,
"error": str(e),
}
)
return StopEvent(
result={
"dbos_workflow_id": dbos_workflow_id,
"store_works": store_works,
}
)
class StateStoreCounterWorkflow(Workflow):
"""Workflow that increments a counter in state store."""
@step
async def increment(self, ctx: Context, ev: StartEvent) -> StopEvent:
cur = await ctx.store.get("counter", default=0)
await ctx.store.set("counter", cur + 1)
return StopEvent(result=cur + 1)
@pytest.mark.asyncio
async def test_dbos_workflow_id_available(dbos_runtime: DBOSRuntime) -> None:
"""Verify DBOS.workflow_id is set inside workflow execution."""
wf = RunIdCaptureWorkflow(runtime=dbos_runtime)
dbos_runtime.launch()
r = await WorkflowTestRunner(wf).run()
result = r.result
assert result["dbos_workflow_id"] != "None", (
"DBOS.workflow_id should be set inside workflow"
)
@pytest.mark.asyncio
async def test_state_store_access_in_step(dbos_runtime: DBOSRuntime) -> None:
"""Test whether state store is accessible inside a workflow step."""
wf = StateStoreAccessWorkflow(runtime=dbos_runtime)
dbos_runtime.launch()
r = await WorkflowTestRunner(wf).run()
result = r.result
assert result["store_works"], (
f"State store should be accessible. Got error: {result.get('error', 'unknown')}"
)
@pytest.mark.asyncio
async def test_internal_adapter_run_id_matches(dbos_runtime: DBOSRuntime) -> None:
"""Verify internal adapter run_id matches DBOS.workflow_id."""
captured_ids: dict[str, Any] = {}
class IdTracingWorkflow(Workflow):
@step
async def trace_ids(self, ev: StartEvent) -> StopEvent:
captured_ids["dbos_workflow_id"] = DBOS.workflow_id
internal_adapter = dbos_runtime.get_internal_adapter(self)
captured_ids["adapter_run_id"] = internal_adapter.run_id
store = internal_adapter.get_state_store()
captured_ids["state_store_found"] = store is not None
return StopEvent(result="done")
wf = IdTracingWorkflow(runtime=dbos_runtime)
dbos_runtime.launch()
await WorkflowTestRunner(wf).run()
assert captured_ids["adapter_run_id"] == captured_ids["dbos_workflow_id"], (
f"Adapter run_id '{captured_ids['adapter_run_id']}' should match "
f"DBOS.workflow_id '{captured_ids['dbos_workflow_id']}'"
)
assert captured_ids["state_store_found"], "State store should be available"
@pytest.mark.asyncio
async def test_external_run_id_vs_internal(dbos_runtime: DBOSRuntime) -> None:
"""Compare external adapter run_id with what's seen internally."""
internal_run_id: str | None = None
class CompareWorkflow(Workflow):
@step
async def capture(self, ev: StartEvent) -> StopEvent:
nonlocal internal_run_id
internal_run_id = DBOS.workflow_id
return StopEvent(result="done")
wf = CompareWorkflow(runtime=dbos_runtime)
dbos_runtime.launch()
handler = wf.run()
external_run_id = handler.run_id
await handler
assert external_run_id == internal_run_id, (
f"External run_id '{external_run_id}' should match "
f"internal DBOS.workflow_id '{internal_run_id}'"
)
@pytest.mark.asyncio
async def test_state_store_lazy_creation(dbos_runtime: DBOSRuntime) -> None:
"""Test that state store is lazily created by the internal adapter."""
store_info: dict[str, Any] = {}
class LazyStoreWorkflow(Workflow):
@step
async def check_store(self, ctx: Context, ev: StartEvent) -> StopEvent:
internal_adapter = dbos_runtime.get_internal_adapter(self)
# First call should create the store
store1 = internal_adapter.get_state_store()
store_info["first_store_id"] = id(store1)
store_info["first_store_exists"] = store1 is not None
# Second call should return the same store
store2 = internal_adapter.get_state_store()
store_info["second_store_id"] = id(store2)
store_info["same_store"] = store1 is store2
# Store should work
await ctx.store.set("lazy_key", "lazy_value")
value = await ctx.store.get("lazy_key")
store_info["store_works"] = value == "lazy_value"
return StopEvent(result="done")
wf = LazyStoreWorkflow(runtime=dbos_runtime)
dbos_runtime.launch()
await WorkflowTestRunner(wf).run()
assert store_info["first_store_exists"], "Store should be created on first access"
assert store_info["same_store"], "Same store instance should be returned"
assert store_info["store_works"], "Store should be functional"
@pytest.mark.asyncio
async def test_run_workflow_does_not_create_store(dbos_runtime: DBOSRuntime) -> None:
"""Verify run_workflow doesn't eagerly create a state store."""
call_log: list[dict[str, Any]] = []
original_run_workflow = dbos_runtime.run_workflow
def patched_run_workflow(*args: Any, **kwargs: Any) -> Any:
call_log.append({"run_id": kwargs.get("run_id")})
return original_run_workflow(*args, **kwargs)
class SimpleWf(Workflow):
@step
async def do_it(self, ev: StartEvent) -> StopEvent:
return StopEvent(result="done")
wf = SimpleWf(runtime=dbos_runtime)
dbos_runtime.launch()
with patch.object(dbos_runtime, "run_workflow", patched_run_workflow):
handler = wf.run()
await handler
assert len(call_log) == 1, "run_workflow should be called exactly once"
@pytest.mark.asyncio
async def test_replay_wait_for_next_task_timeout_returns_none(
sqlite_engine: Engine,
) -> None:
"""Replay wait timeout should return None and not raise."""
run_id = "replay-timeout-run"
journal = TaskJournal(run_id, sqlite_engine)
await journal.load()
await journal.record("step_a:0")
adapter = InternalDBOSAdapter(run_id=run_id, engine=sqlite_engine)
task = asyncio.create_task(asyncio.sleep(5.0))
try:
result = await adapter.wait_for_next_task(
[NamedTask.worker("step_a", 0, task)],
timeout=0.01,
)
assert result is None
finally:
task.cancel()
with suppress(asyncio.CancelledError):
await task
# ============================================================================
# SqlSerializedState and parse_serialized_state Tests
# ============================================================================
def test_parse_serialized_state_sql_store_type() -> None:
"""Test that store_type='sql' parses as SqlSerializedState."""
serialized = {
"store_type": "sql",
"run_id": "run-12345",
"schema": "public",
}
result = parse_serialized_state(serialized)
assert isinstance(result, SqlSerializedState)
assert result.store_type == "sql"
assert result.run_id == "run-12345"
assert result.db_schema == "public"
def test_parse_serialized_state_sql_with_null_schema() -> None:
"""Test that SqlSerializedState accepts null schema."""
serialized = {
"store_type": "sql",
"run_id": "run-67890",
"schema": None,
}
result = parse_serialized_state(serialized)
assert isinstance(result, SqlSerializedState)
assert result.db_schema is None
def test_parse_serialized_state_in_memory_format() -> None:
"""Test that in_memory format is still handled."""
serialized = {
"store_type": "in_memory",
"state_type": "DictState",
"state_module": "workflows.context.state_store",
"state_data": {"_data": {"counter": 42}},
}
result = parse_serialized_state(serialized)
assert isinstance(result, InMemorySerializedState)
assert result.store_type == "in_memory"
def test_parse_serialized_state_unknown_store_type_raises() -> None:
"""Test that unknown store_type raises ValueError."""
serialized = {
"store_type": "redis", # Unknown store type
"state_type": "SomeState",
"state_module": "some.module",
}
with pytest.raises(ValueError, match="Unknown store_type"):
parse_serialized_state(serialized)
@pytest.mark.asyncio
async def test_edit_state_rolls_back_and_closes_on_error(sqlite_engine: Engine) -> None:
"""Errors inside edit_state should rollback/close and leave store usable."""
store = SqlStateStore(run_id="state-run", engine=sqlite_engine)
with pytest.raises(RuntimeError, match="boom"):
async with store.edit_state() as state:
state["transient"] = "value"
raise RuntimeError("boom")
assert await store.get("transient", default=None) is None
await store.set("after", "ok")
assert await store.get("after") == "ok"
with sqlite_engine.connect() as conn:
raw = conn.connection
assert not bool(getattr(raw, "in_transaction", False))
@pytest.mark.asyncio
async def test_edit_state_failure_releases_checked_out_connection(
tmp_path: Path,
) -> None:
"""Failed edit_state should not leak checked-out pooled connections."""
db_file = tmp_path / "state.sqlite3"
engine = create_engine(
f"sqlite:///{db_file}",
connect_args={"check_same_thread": False},
poolclass=QueuePool,
)
store = SqlStateStore(run_id="pool-run", engine=engine)
pool = cast(QueuePool, engine.pool)
assert pool.checkedout() == 0
with pytest.raises(ValueError, match="force failure"):
async with store.edit_state() as state:
state["x"] = 1
raise ValueError("force failure")
assert pool.checkedout() == 0
await store.set("y", 2)
assert await store.get("y") == 2
assert pool.checkedout() == 0
@@ -0,0 +1,194 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""Unit tests for TaskJournal class."""
from __future__ import annotations
import pytest
from llama_agents.dbos.journal.task_journal import TaskJournal
from sqlalchemy.engine import Engine
@pytest.mark.asyncio
async def test_fresh_journal_has_no_entries(sqlite_engine: Engine) -> None:
"""Fresh journal returns None for next_expected_key."""
journal = TaskJournal("test-run", sqlite_engine)
await journal.load()
assert journal.next_expected_key() is None
assert not journal.is_replaying()
@pytest.mark.asyncio
async def test_record_adds_entry(sqlite_engine: Engine) -> None:
"""Recording a key adds it to the journal."""
journal = TaskJournal("test-run", sqlite_engine)
await journal.load()
await journal.record("step_a:0")
# Verify by loading a new journal for the same run
journal2 = TaskJournal("test-run", sqlite_engine)
await journal2.load()
assert journal2.next_expected_key() == "step_a:0"
@pytest.mark.asyncio
async def test_record_multiple_entries(sqlite_engine: Engine) -> None:
"""Multiple records append to journal in order."""
journal = TaskJournal("test-run", sqlite_engine)
await journal.load()
await journal.record("step_a:0")
await journal.record("__pull__:0")
await journal.record("step_b:1")
# Verify order by loading a new journal
journal2 = TaskJournal("test-run", sqlite_engine)
await journal2.load()
assert journal2.next_expected_key() == "step_a:0"
journal2.advance()
assert journal2.next_expected_key() == "__pull__:0"
journal2.advance()
assert journal2.next_expected_key() == "step_b:1"
journal2.advance()
assert journal2.next_expected_key() is None
@pytest.mark.asyncio
async def test_replay_returns_entries_in_order(sqlite_engine: Engine) -> None:
"""Replaying journal returns entries in recorded order."""
# Set up initial data
journal1 = TaskJournal("replay-run", sqlite_engine)
await journal1.load()
await journal1.record("step_a:0")
await journal1.record("step_b:1")
await journal1.record("__pull__:2")
# Load fresh journal and replay
journal = TaskJournal("replay-run", sqlite_engine)
await journal.load()
assert journal.is_replaying()
assert journal.next_expected_key() == "step_a:0"
journal.advance()
assert journal.next_expected_key() == "step_b:1"
journal.advance()
assert journal.next_expected_key() == "__pull__:2"
journal.advance()
assert journal.next_expected_key() is None
assert not journal.is_replaying()
@pytest.mark.asyncio
async def test_load_is_idempotent(sqlite_engine: Engine) -> None:
"""Calling load() multiple times doesn't reset state."""
# Set up initial data
journal1 = TaskJournal("idempotent-run", sqlite_engine)
await journal1.load()
await journal1.record("step_a:0")
# Load and advance
journal = TaskJournal("idempotent-run", sqlite_engine)
await journal.load()
journal.advance()
assert journal.next_expected_key() is None
# Load again - should not reset
await journal.load()
assert journal.next_expected_key() is None
@pytest.mark.asyncio
async def test_none_engine_works_in_memory() -> None:
"""Journal works without engine (in-memory only)."""
journal = TaskJournal("memory-run", engine=None)
await journal.load()
assert journal.next_expected_key() is None
await journal.record("step_a:0")
await journal.record("step_b:1")
# New journal with same run_id but no engine won't see the entries
journal2 = TaskJournal("memory-run", engine=None)
await journal2.load()
assert journal2.next_expected_key() is None
@pytest.mark.asyncio
async def test_record_advances_index(sqlite_engine: Engine) -> None:
"""Recording advances the replay index to stay in sync."""
journal = TaskJournal("index-run", sqlite_engine)
await journal.load()
# After recording, index should advance
await journal.record("step_a:0")
# Record another
await journal.record("step_b:1")
# The journal's internal state shows 2 entries recorded
assert journal._entries == ["step_a:0", "step_b:1"]
assert journal._replay_index == 2 # We've advanced past both
@pytest.mark.asyncio
async def test_mixed_replay_and_fresh_execution(sqlite_engine: Engine) -> None:
"""Journal transitions from replay to fresh execution correctly."""
# Set up initial data
journal1 = TaskJournal("mixed-run", sqlite_engine)
await journal1.load()
await journal1.record("step_a:0")
# Load fresh journal
journal = TaskJournal("mixed-run", sqlite_engine)
await journal.load()
# Replay the existing entry
assert journal.next_expected_key() == "step_a:0"
journal.advance()
# Now fresh execution
assert journal.next_expected_key() is None
await journal.record("step_b:1")
# Verify both entries persisted
journal2 = TaskJournal("mixed-run", sqlite_engine)
await journal2.load()
assert journal2.next_expected_key() == "step_a:0"
journal2.advance()
assert journal2.next_expected_key() == "step_b:1"
@pytest.mark.asyncio
async def test_empty_journal_is_valid(sqlite_engine: Engine) -> None:
"""Empty journal (no entries) is a valid state."""
journal = TaskJournal("empty-run", sqlite_engine)
await journal.load()
assert journal.next_expected_key() is None
assert not journal.is_replaying()
@pytest.mark.asyncio
async def test_run_id_isolation(sqlite_engine: Engine) -> None:
"""Journals with different run_ids are isolated."""
journal1 = TaskJournal("run-1", sqlite_engine)
await journal1.load()
await journal1.record("step_a:0")
journal2 = TaskJournal("run-2", sqlite_engine)
await journal2.load()
await journal2.record("step_b:0")
# Each journal sees only its own entries
check1 = TaskJournal("run-1", sqlite_engine)
await check1.load()
assert check1.next_expected_key() == "step_a:0"
check2 = TaskJournal("run-2", sqlite_engine)
await check2.load()
assert check2.next_expected_key() == "step_b:0"
@@ -24,7 +24,8 @@ readme = "README.md"
requires-python = ">=3.9"
dependencies = [
"llama-index-core>=0.14.13",
"llama-index-workflows"
"llama-index-workflows",
"llama-agents-dbos; python_full_version > '3.9'"
]
[tool.basedpyright]
@@ -55,4 +56,4 @@ filterwarnings = [
[tool.uv.sources]
llama-index-workflows = {workspace = true}
llama-index-workflows-dbos = {workspace = true}
llama-agents-dbos = {workspace = true}
@@ -1,16 +1,26 @@
"""Runtime matrix tests - testing workflows against both BasicRuntime and DBOSRuntime.
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""Runtime matrix tests - testing workflows against BasicRuntime and DBOSRuntime.
All workflow classes are defined at module level so they can be registered with
DBOS once at module initialization time, avoiding repeated init/destroy cycles.
Note: The dbos-postgres variant requires Docker to be available and is marked
with the 'docker' pytest marker. Run with `pytest -m docker` to include it.
"""
from __future__ import annotations
import asyncio
from typing import AsyncGenerator, Optional, Union
from pathlib import Path
from typing import Any, AsyncGenerator, Generator
import pytest
from pydantic import Field
from dbos import DBOS, DBOSConfig
from llama_agents.dbos import DBOSRuntime
from pydantic import BaseModel, Field
from testcontainers.postgres import PostgresContainer
from workflows.context import Context
from workflows.decorators import step
from workflows.errors import WorkflowTimeoutError
@@ -29,18 +39,88 @@ from workflows.workflow import Workflow
# -- Fixtures --
@pytest.fixture(
params=[
def _get_runtime_params() -> list[Any]:
"""Get runtime parameters for the test matrix.
Includes:
- basic: BasicRuntime (fast, no dependencies)
- dbos: DBOSRuntime with SQLite backend (fast, no Docker)
- dbos-postgres: DBOSRuntime with PostgreSQL backend (requires Docker)
Note: The dbos-postgres variant is marked with the 'docker' marker and
requires Docker to be running. It only runs when explicitly requested
via `pytest -m docker`.
"""
return [
pytest.param("basic", id="basic"),
pytest.param("dbos", id="dbos"),
pytest.param("dbos-postgres", marks=pytest.mark.docker, id="dbos-postgres"),
]
)
@pytest.fixture(scope="module")
def postgres_container() -> Generator[PostgresContainer, None, None]:
"""Module-scoped PostgreSQL container for DBOS tests.
This fixture is only used when dbos-postgres runtime is requested.
Requires Docker to be running.
"""
with PostgresContainer("postgres:16", driver=None) as postgres:
yield postgres
@pytest.fixture(scope="module")
def dbos_runtime_sqlite(
tmp_path_factory: pytest.TempPathFactory,
) -> Generator[DBOSRuntime, None, None]:
"""Module-scoped DBOS runtime with SQLite backend."""
db_file: Path = tmp_path_factory.mktemp("dbos") / "dbos_test.sqlite3"
system_db_url: str = f"sqlite+pysqlite:///{db_file}?check_same_thread=false"
config: DBOSConfig = {
"name": "workflows-py-dbostest",
"system_database_url": system_db_url,
"run_admin_server": False,
"notification_listener_polling_interval_sec": 0.01,
}
DBOS(config=config)
runtime = DBOSRuntime(polling_interval_sec=0.01)
try:
yield runtime
finally:
runtime.destroy()
@pytest.fixture(scope="module")
def dbos_runtime_postgres(
postgres_container: PostgresContainer,
) -> Generator[DBOSRuntime, None, None]:
"""Module-scoped DBOS runtime with PostgreSQL backend."""
connection_url = postgres_container.get_connection_url()
config: DBOSConfig = {
"name": "wf-dbos-pg-test", # Must be <= 30 chars
"system_database_url": connection_url,
"run_admin_server": False,
"notification_listener_polling_interval_sec": 0.01,
}
DBOS(config=config)
runtime = DBOSRuntime(polling_interval_sec=0.01)
try:
yield runtime
finally:
runtime.destroy()
@pytest.fixture(params=_get_runtime_params())
async def runtime(
request: pytest.FixtureRequest,
) -> AsyncGenerator[Runtime, None]:
"""Yield an unlaunched runtime.
For DBOS, returns the module-scoped runtime (already created, not yet launched).
Each test must call runtime.launch() after creating workflows.
For DBOS variants, returns the module-scoped runtime (already created, not yet
launched). Each test must call runtime.launch() after creating workflows.
Note: Only one DBOS variant can be used per test run since DBOS is a singleton.
Use TEST_DBOS_POSTGRES=1 to run with PostgreSQL instead of the default SQLite.
"""
if request.param == "basic":
rt = BasicRuntime()
@@ -48,6 +128,12 @@ async def runtime(
yield rt
finally:
rt.destroy()
elif request.param == "dbos":
dbos_rt: DBOSRuntime = request.getfixturevalue("dbos_runtime_sqlite")
yield dbos_rt
elif request.param == "dbos-postgres":
dbos_rt = request.getfixturevalue("dbos_runtime_postgres")
yield dbos_rt
# -- Shared event types --
@@ -174,7 +260,7 @@ class NumWorkersWorkflow(Workflow):
@step
async def original_step(
self, ctx: Context, ev: StartEvent
) -> Union[OneTestEvent, LastEvent]:
) -> OneTestEvent | LastEvent:
await ctx.store.set("num_to_collect", 3)
ctx.send_event(OneTestEvent(test_param="test1"))
ctx.send_event(OneTestEvent(test_param="test2"))
@@ -189,7 +275,7 @@ class NumWorkersWorkflow(Workflow):
@step
async def final_step(
self, ctx: Context, ev: Union[AnotherTestEvent, LastEvent]
self, ctx: Context, ev: AnotherTestEvent | LastEvent
) -> StopEvent:
n = await ctx.store.get("num_to_collect")
events = ctx.collect_events(ev, [AnotherTestEvent] * n)
@@ -201,6 +287,10 @@ class NumWorkersWorkflow(Workflow):
class CustomEventsWorkflow(Workflow):
@step
async def start_step(self, ev: MyStart) -> OneTestEvent:
# Small delay to avoid DBOS read_stream_async race condition where
# the workflow completes before the stream reader starts polling.
# See thoughts/shared/bugs/dbos-read-stream-race.md
await asyncio.sleep(0.05)
return OneTestEvent()
@step
@@ -340,55 +430,69 @@ async def test_workflow_step_send_event(runtime: Runtime) -> None:
@pytest.mark.asyncio
async def test_workflow_num_workers(runtime: Runtime) -> None:
signal = asyncio.Event()
"""Test that num_workers limits concurrent step executions.
This test verifies that:
1. A step with num_workers=5 can process up to 5 events concurrently
2. All 5 workers can run simultaneously (they synchronize to prove concurrency)
3. The workflow completes successfully with all events processed
"""
num_workers = 5
num_events = 10
# Track max concurrent executions
lock = asyncio.Lock()
counter = 0
current_workers = 0
max_concurrent = 0
# Barrier to ensure all workers reach this point before any proceed
barrier_count = 0
barrier_event = asyncio.Event()
async def await_count(count: int) -> None:
nonlocal counter
async with lock:
counter += 1
if counter == count:
signal.set()
return
await signal.wait()
class LocalNumWorkersWorkflow(Workflow):
class NumWorkersWorkflow(Workflow):
@step
async def original_step(
self, ctx: Context, ev: StartEvent
) -> Union[OneTestEvent, LastEvent]:
await ctx.store.set("num_to_collect", 3)
# Send test4 first to ensure it's pulled from receive_queue
# before test_step workers complete. Events are pulled one per
# iteration, so ordering in receive_queue determines delivery order.
ctx.send_event(AnotherTestEvent(another_test_param="test4"))
ctx.send_event(OneTestEvent(test_param="test1"))
ctx.send_event(OneTestEvent(test_param="test2"))
ctx.send_event(OneTestEvent(test_param="test3"))
return LastEvent()
async def fan_out(self, ctx: Context, ev: StartEvent) -> OneTestEvent:
# Send more events than num_workers to test queuing
for i in range(num_events):
ctx.send_event(OneTestEvent(test_param=str(i)))
return None # type: ignore
@step(num_workers=num_workers)
async def worker_step(self, ev: OneTestEvent) -> AnotherTestEvent:
nonlocal current_workers, max_concurrent, barrier_count
async with lock:
current_workers += 1
max_concurrent = max(max_concurrent, current_workers)
barrier_count += 1
if barrier_count == num_workers:
# All workers have arrived, release them
barrier_event.set()
# Wait for all workers to arrive (proves concurrency)
await barrier_event.wait()
async with lock:
current_workers -= 1
@step(num_workers=3)
async def test_step(self, ev: OneTestEvent) -> AnotherTestEvent:
await await_count(3)
return AnotherTestEvent(another_test_param=ev.test_param)
@step
async def final_step(
self, ctx: Context, ev: Union[AnotherTestEvent, LastEvent]
) -> Optional[StopEvent]:
n = await ctx.store.get("num_to_collect")
events = ctx.collect_events(ev, [AnotherTestEvent] * n)
async def collect_step(
self, ctx: Context, ev: AnotherTestEvent
) -> StopEvent | None:
events = ctx.collect_events(ev, [AnotherTestEvent] * num_events)
if events is None:
return None
return StopEvent(result=[ev.another_test_param for ev in events])
return StopEvent(result=[e.another_test_param for e in events])
workflow = LocalNumWorkersWorkflow(timeout=10, runtime=runtime)
workflow = NumWorkersWorkflow(timeout=10, runtime=runtime)
runtime.launch()
r = await WorkflowTestRunner(workflow).run()
assert "test4" in set(r.result)
assert len({"test1", "test2", "test3"} - set(r.result)) == 1
# Verify all events were processed
assert len(r.result) == num_events
assert set(r.result) == {str(i) for i in range(num_events)}
# Verify we achieved the expected concurrency (all 5 workers ran together)
assert max_concurrent == num_workers
@pytest.mark.asyncio
@@ -491,3 +595,208 @@ async def test_streaming_task_timeout(runtime: Runtime) -> None:
with pytest.raises(WorkflowTimeoutError, match="Operation timed out"):
await r
# -- Workflow State Tests --
class StatefulWorkflow(Workflow):
"""Workflow that accumulates state across steps."""
@step
async def step1(self, ctx: Context, ev: StartEvent) -> OneTestEvent:
await ctx.store.set("step1_ran", True)
await ctx.store.set("counter", 1)
return OneTestEvent()
@step
async def step2(self, ctx: Context, ev: OneTestEvent) -> StopEvent:
await ctx.store.set("step2_ran", True)
counter = await ctx.store.get("counter")
await ctx.store.set("counter", counter + 1)
final_counter = await ctx.store.get("counter")
return StopEvent(result={"counter": final_counter})
class NestedStateWorkflow(Workflow):
"""Workflow that uses nested state paths."""
@step
async def process(self, ctx: Context, ev: StartEvent) -> StopEvent:
await ctx.store.set("user", {"name": "Alice", "profile": {"level": 1}})
await ctx.store.set("user.profile.level", 2)
level = await ctx.store.get("user.profile.level")
name = await ctx.store.get("user.name")
return StopEvent(result={"name": name, "level": level})
@pytest.mark.asyncio
async def test_workflow_state_basic(runtime: Runtime) -> None:
"""Test basic state operations within a workflow."""
wf = CounterWorkflow(runtime=runtime)
runtime.launch()
result = await WorkflowTestRunner(wf).run()
assert result.result == 1
@pytest.mark.asyncio
async def test_workflow_state_across_steps(runtime: Runtime) -> None:
"""Test state persistence across multiple workflow steps."""
wf = StatefulWorkflow(runtime=runtime)
runtime.launch()
result = await WorkflowTestRunner(wf).run()
assert result.result == {"counter": 2}
@pytest.mark.asyncio
async def test_workflow_nested_state(runtime: Runtime) -> None:
"""Test nested state path access within workflows."""
wf = NestedStateWorkflow(runtime=runtime)
runtime.launch()
result = await WorkflowTestRunner(wf).run()
assert result.result == {"name": "Alice", "level": 2}
@pytest.mark.asyncio
async def test_workflow_state_multiple_runs(runtime: Runtime) -> None:
"""Test that each workflow run has isolated state."""
wf = CounterWorkflow(runtime=runtime)
runtime.launch()
runner = WorkflowTestRunner(wf)
# Run multiple times - each should start fresh
results = await asyncio.gather(
runner.run(),
runner.run(),
runner.run(),
)
# Each run should have counter=1 (not accumulating)
for r in results:
assert r.result == 1
# -- Typed State Tests --
class TypedState(BaseModel):
"""Custom typed state for workflow testing."""
counter: int = 0
name: str = "default"
items: list[str] = Field(default_factory=list)
class TypeStateStopEvent(StopEvent):
state_type: str
initial_counter: int
final_counter: int
final_name: str
class TypedStateWorkflow(Workflow):
"""Workflow that uses typed state via Context[TypedState]."""
@step
async def process(self, ctx: Context[TypedState], ev: StartEvent) -> StopEvent:
# Access typed state
state = await ctx.store.get_state()
# Verify we got the right type
state_type_name = type(state).__name__
# Modify state using typed fields
await ctx.store.set("counter", state.counter + 1)
await ctx.store.set("name", "modified")
final_state = await ctx.store.get_state()
return TypeStateStopEvent(
state_type=state_type_name,
initial_counter=state.counter,
final_counter=final_state.counter,
final_name=final_state.name,
)
@pytest.mark.asyncio
async def test_typed_state_workflow(runtime: Runtime) -> None:
"""Test workflow with typed state Context[TypedState].
This verifies that:
1. The state type is correctly inferred from Context[T] annotation
2. The state is created with the correct type
3. Typed field access works correctly
"""
wf = TypedStateWorkflow(runtime=runtime)
runtime.launch()
result = await WorkflowTestRunner(wf).run()
# The state should be TypedState, not DictState
assert result.result.state_type == "TypedState", (
f"Expected TypedState but got {result.result.state_type}. "
"State type inference may not be working."
)
assert result.result.initial_counter == 0
assert result.result.final_counter == 1
assert result.result.final_name == "modified"
class TypedStateWithDefaultsWorkflow(Workflow):
"""Workflow that verifies typed state has correct defaults."""
@step
async def check_defaults(
self, ctx: Context[TypedState], ev: StartEvent
) -> StopEvent:
state = await ctx.store.get_state()
return StopEvent(
result={
"counter": state.counter,
"name": state.name,
"items": state.items,
}
)
@pytest.mark.asyncio
async def test_typed_state_defaults(runtime: Runtime) -> None:
"""Test that typed state is initialized with correct defaults."""
wf = TypedStateWithDefaultsWorkflow(runtime=runtime)
runtime.launch()
result = await WorkflowTestRunner(wf).run()
assert result.result["counter"] == 0
assert result.result["name"] == "default"
assert result.result["items"] == []
@pytest.mark.asyncio
async def test_typed_state_with_initial_values(runtime: Runtime) -> None:
"""Test that initial state values are passed through to the workflow.
This verifies that:
1. State can be set before running the workflow
2. The initial values are correctly used (not replaced with defaults)
3. Modifications build on the initial values
"""
wf = TypedStateWorkflow(runtime=runtime)
runtime.launch()
# Create a context and set initial state with counter=1 (default is 0)
ctx = Context(wf)
await ctx.store.set("counter", 1)
result = await WorkflowTestRunner(wf).run(ctx=ctx)
# If initial state wasn't passed through, initial_counter would be 0
# and final_counter would be 1 (from default 0 + 1)
assert result.result.initial_counter == 1, (
f"Expected initial_counter=1 but got {result.result.initial_counter}. "
"Initial state was not passed through to the workflow."
)
assert result.result.final_counter == 2, (
f"Expected final_counter=2 but got {result.result.final_counter}. "
"State modification did not build on initial value."
)
@@ -0,0 +1,504 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 LlamaIndex Inc.
"""State store matrix tests - testing StateStore implementations.
Tests the StateStore protocol across InMemoryStateStore and SqlStateStore
(with both SQLite and PostgreSQL engines) to ensure consistent behavior.
"""
from __future__ import annotations
import asyncio
from pathlib import Path
from typing import Any, AsyncGenerator, Generator
import pytest
from llama_agents.dbos.state_store import SqlStateStore
from pydantic import (
BaseModel,
ConfigDict,
ValidationError,
field_serializer,
field_validator,
)
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from testcontainers.postgres import PostgresContainer
from workflows.context.serializers import JsonSerializer
from workflows.context.state_store import DictState, InMemoryStateStore, StateStore
# -- Custom state types for testing --
class MyRandomObject:
"""Non-Pydantic object that requires custom serialization."""
def __init__(self, name: str) -> None:
self.name = name
class PydanticObject(BaseModel):
"""Simple Pydantic model for nested state testing."""
name: str
class MyState(BaseModel):
"""Custom typed state with serialization logic."""
model_config = ConfigDict(
arbitrary_types_allowed=True,
validate_assignment=True,
strict=True,
)
my_obj: MyRandomObject
pydantic_obj: PydanticObject
name: str
age: int
@field_serializer("my_obj", when_used="always")
def serialize_my_obj(self, my_obj: MyRandomObject) -> str:
return my_obj.name
@field_validator("my_obj", mode="before")
@classmethod
def deserialize_my_obj(cls, v: str | MyRandomObject) -> MyRandomObject:
if isinstance(v, MyRandomObject):
return v
if isinstance(v, str):
return MyRandomObject(v)
raise ValueError(f"Invalid type for my_obj: {type(v)}")
# -- Fixtures --
@pytest.fixture(scope="module")
def postgres_container() -> Generator[PostgresContainer, None, None]:
"""Module-scoped PostgreSQL container for state store tests.
Requires Docker to be running.
"""
with PostgresContainer("postgres:16", driver=None) as postgres:
yield postgres
@pytest.fixture(scope="module")
def postgres_engine(
postgres_container: PostgresContainer,
) -> Generator[Engine, None, None]:
"""Module-scoped PostgreSQL engine for state store tests."""
# Get connection URL and convert to use psycopg (psycopg3) driver
connection_url = postgres_container.get_connection_url()
# Replace postgresql:// or postgresql+psycopg2:// with postgresql+psycopg://
if "postgresql+psycopg2://" in connection_url:
connection_url = connection_url.replace(
"postgresql+psycopg2://", "postgresql+psycopg://"
)
elif connection_url.startswith("postgresql://"):
connection_url = connection_url.replace(
"postgresql://", "postgresql+psycopg://", 1
)
engine = create_engine(connection_url)
yield engine
engine.dispose()
@pytest.fixture(scope="module")
def sqlite_engine(
tmp_path_factory: pytest.TempPathFactory,
) -> Generator[Engine, None, None]:
"""Module-scoped SQLite engine for state store tests."""
db_file: Path = tmp_path_factory.mktemp("state_store") / "test.sqlite3"
engine = create_engine(f"sqlite:///{db_file}?check_same_thread=false")
yield engine
engine.dispose()
def _get_store_params() -> list[Any]:
"""Get store type parameters for the test matrix."""
return [
pytest.param("in_memory", id="in_memory"),
pytest.param("sqlite", id="sqlite"),
pytest.param("postgres", marks=pytest.mark.docker, id="postgres"),
]
def _get_sql_params() -> list[Any]:
"""Get SQL-only backend parameters for persistence/isolation tests."""
return [
pytest.param("sqlite", id="sqlite"),
pytest.param("postgres", marks=pytest.mark.docker, id="postgres"),
]
@pytest.fixture(params=_get_store_params())
async def state_store(
request: pytest.FixtureRequest,
sqlite_engine: Engine,
) -> AsyncGenerator[StateStore[DictState], None]:
"""Parametrized fixture yielding a fresh StateStore for each test."""
# Use unique run_id per test to avoid state bleeding
run_id = f"test-{id(request)}"
if request.param == "in_memory":
yield InMemoryStateStore(DictState())
elif request.param == "sqlite":
store = SqlStateStore(run_id=run_id, engine=sqlite_engine)
yield store
elif request.param == "postgres":
pg_engine: Engine = request.getfixturevalue("postgres_engine")
store = SqlStateStore(run_id=run_id, engine=pg_engine, schema="dbos")
yield store
@pytest.fixture(params=_get_sql_params())
async def sql_engine_and_schema(
request: pytest.FixtureRequest,
sqlite_engine: Engine,
) -> AsyncGenerator[tuple[Engine, str | None], None]:
"""Parametrized fixture yielding (engine, schema) for SQL backend tests."""
if request.param == "sqlite":
yield sqlite_engine, None
elif request.param == "postgres":
pg_engine: Engine = request.getfixturevalue("postgres_engine")
yield pg_engine, "dbos"
@pytest.fixture(params=_get_store_params())
async def custom_state_store(
request: pytest.FixtureRequest,
sqlite_engine: Engine,
) -> AsyncGenerator[StateStore[MyState], None]:
"""Parametrized fixture yielding a StateStore with custom typed state."""
run_id = f"test-custom-{id(request)}"
initial_state = MyState(
my_obj=MyRandomObject("llama-index"),
pydantic_obj=PydanticObject(name="llama-index"),
name="John",
age=30,
)
if request.param == "in_memory":
yield InMemoryStateStore(initial_state)
elif request.param == "sqlite":
store = SqlStateStore(
run_id=run_id,
state_type=MyState,
engine=sqlite_engine,
)
await store.set_state(initial_state)
yield store
elif request.param == "postgres":
pg_engine: Engine = request.getfixturevalue("postgres_engine")
store = SqlStateStore(
run_id=run_id,
state_type=MyState,
engine=pg_engine,
schema="dbos",
)
await store.set_state(initial_state)
yield store
# -- Basic Operations Tests --
@pytest.mark.asyncio
async def test_get_set_basic_values(state_store: StateStore[DictState]) -> None:
"""Test basic get/set operations with simple values."""
await state_store.set("name", "John")
await state_store.set("age", 30)
assert await state_store.get("name") == "John"
assert await state_store.get("age") == 30
@pytest.mark.asyncio
async def test_get_with_default(state_store: StateStore[DictState]) -> None:
"""Test get with default value for missing keys."""
result = await state_store.get("nonexistent", default=None)
assert result is None
result = await state_store.get("missing", default="fallback")
assert result == "fallback"
@pytest.mark.asyncio
async def test_get_missing_raises(state_store: StateStore[DictState]) -> None:
"""Test that get raises ValueError for missing key without default."""
with pytest.raises(ValueError, match="not found"):
await state_store.get("nonexistent")
@pytest.mark.asyncio
async def test_nested_get_set(state_store: StateStore[DictState]) -> None:
"""Test nested path access with dot notation."""
await state_store.set("nested", {"a": "b"})
assert await state_store.get("nested.a") == "b"
await state_store.set("nested.a", "c")
assert await state_store.get("nested.a") == "c"
@pytest.mark.asyncio
async def test_get_state_returns_copy(state_store: StateStore[DictState]) -> None:
"""Test that get_state returns a copy, not the original."""
await state_store.set("value", 1)
state1 = await state_store.get_state()
state2 = await state_store.get_state()
# Should be equal but not the same object
assert state1.model_dump() == state2.model_dump()
@pytest.mark.asyncio
async def test_set_state_replaces(state_store: StateStore[DictState]) -> None:
"""Test that set_state replaces the entire state."""
await state_store.set("old_key", "old_value")
new_state = DictState()
new_state["new_key"] = "new_value"
await state_store.set_state(new_state)
assert await state_store.get("new_key") == "new_value"
# Old key should be gone or inaccessible
result = await state_store.get("old_key", default=None)
assert result is None
@pytest.mark.asyncio
async def test_clear_resets_state(state_store: StateStore[DictState]) -> None:
"""Test that clear resets to default state."""
await state_store.set("name", "Jane")
await state_store.set("age", 25)
await state_store.clear()
assert await state_store.get("name", default=None) is None
assert await state_store.get("age", default=None) is None
# -- edit_state Context Manager Tests --
@pytest.mark.asyncio
async def test_edit_state_basic(state_store: StateStore[DictState]) -> None:
"""Test basic edit_state context manager usage."""
await state_store.set("counter", 0)
async with state_store.edit_state() as state:
current = state.get("counter", 0)
state["counter"] = current + 1
assert await state_store.get("counter") == 1
@pytest.mark.asyncio
async def test_edit_state_multiple_changes(state_store: StateStore[DictState]) -> None:
"""Test multiple changes within a single edit_state."""
async with state_store.edit_state() as state:
state["a"] = 1
state["b"] = 2
state["c"] = {"nested": "value"}
assert await state_store.get("a") == 1
assert await state_store.get("b") == 2
assert await state_store.get("c.nested") == "value"
@pytest.mark.asyncio
async def test_edit_state_exception_handling(
state_store: StateStore[DictState],
) -> None:
"""Test that exceptions in edit_state don't corrupt state."""
await state_store.set("value", "original")
with pytest.raises(ValueError, match="intentional"):
async with state_store.edit_state() as state:
state["value"] = "modified"
raise ValueError("intentional error")
# State should remain unchanged after exception
# Note: behavior may vary - InMemory commits on context exit, SQL rolls back
# This test documents the expected behavior
# -- Custom Typed State Tests --
@pytest.mark.asyncio
async def test_custom_state_type(custom_state_store: StateStore[MyState]) -> None:
"""Test state store with custom Pydantic model."""
state = await custom_state_store.get_state()
assert isinstance(state, MyState)
assert state.name == "John"
assert state.age == 30
assert state.my_obj.name == "llama-index"
@pytest.mark.asyncio
async def test_custom_state_set_values(custom_state_store: StateStore[MyState]) -> None:
"""Test setting values on custom typed state."""
await custom_state_store.set("name", "Jane")
await custom_state_store.set("age", 25)
assert await custom_state_store.get("name") == "Jane"
assert await custom_state_store.get("age") == 25
# Original custom fields should still be accessible
state = await custom_state_store.get_state()
assert state.my_obj.name == "llama-index"
@pytest.mark.asyncio
async def test_custom_state_validation(custom_state_store: StateStore[MyState]) -> None:
"""Test that Pydantic validation is enforced on custom state."""
# MyState has strict=True, so setting age to string should fail
with pytest.raises(ValidationError):
await custom_state_store.set("age", "not a number")
# -- Serialization Tests --
@pytest.mark.asyncio
async def test_to_dict_from_dict_roundtrip(state_store: StateStore[DictState]) -> None:
"""Test serialization roundtrip with to_dict/from_dict."""
await state_store.set("name", "John")
await state_store.set("age", 30)
serializer = JsonSerializer()
data = state_store.to_dict(serializer)
# For InMemoryStateStore, from_dict restores the full state
# For SqlStateStore, from_dict returns metadata (engine must be set separately)
if isinstance(state_store, InMemoryStateStore):
restored = InMemoryStateStore.from_dict(data, serializer)
assert await restored.get("name") == "John"
assert await restored.get("age") == 30
# -- SQL Backend Tests (parameterized across SQLite and PostgreSQL) --
@pytest.mark.asyncio
async def test_sql_persistence(
sql_engine_and_schema: tuple[Engine, str | None],
) -> None:
"""Test that state persists across store instances."""
engine, schema = sql_engine_and_schema
run_id = "persistence-test"
store1 = SqlStateStore(run_id=run_id, engine=engine, schema=schema)
await store1.set("persistent_key", "persistent_value")
store2 = SqlStateStore(run_id=run_id, engine=engine, schema=schema)
result = await store2.get("persistent_key")
assert result == "persistent_value"
@pytest.mark.asyncio
async def test_sql_isolation(
sql_engine_and_schema: tuple[Engine, str | None],
) -> None:
"""Test that different run_ids have isolated state."""
engine, schema = sql_engine_and_schema
store1 = SqlStateStore(run_id="run-1", engine=engine, schema=schema)
store2 = SqlStateStore(run_id="run-2", engine=engine, schema=schema)
await store1.set("key", "value1")
await store2.set("key", "value2")
assert await store1.get("key") == "value1"
assert await store2.get("key") == "value2"
@pytest.mark.asyncio
async def test_sql_concurrent_edits(
sql_engine_and_schema: tuple[Engine, str | None],
) -> None:
"""Test concurrent edit_state calls are serialized correctly."""
engine, schema = sql_engine_and_schema
run_id = "concurrent-test"
store = SqlStateStore(run_id=run_id, engine=engine, schema=schema)
await store.set("counter", 0)
async def increment() -> None:
async with store.edit_state() as state:
current = state.get("counter", 0)
await asyncio.sleep(0.01) # Simulate some work
state["counter"] = current + 1
await asyncio.gather(*[increment() for _ in range(5)])
result = await store.get("counter")
assert result == 5
@pytest.mark.asyncio
async def test_sql_custom_state_persistence(
sql_engine_and_schema: tuple[Engine, str | None],
) -> None:
"""Test that custom typed state persists correctly."""
engine, schema = sql_engine_and_schema
run_id = "custom-persistence-test"
initial_state = MyState(
my_obj=MyRandomObject("persisted"),
pydantic_obj=PydanticObject(name="persisted"),
name="Original",
age=100,
)
store1 = SqlStateStore(
run_id=run_id,
state_type=MyState,
engine=engine,
schema=schema,
)
await store1.set_state(initial_state)
await store1.set("name", "Modified")
store2 = SqlStateStore(
run_id=run_id,
state_type=MyState,
engine=engine,
schema=schema,
)
state = await store2.get_state()
assert state.name == "Modified"
assert state.my_obj.name == "persisted"
# -- PostgreSQL-Specific Tests --
@pytest.mark.docker
@pytest.mark.asyncio
async def test_postgres_uses_dbos_schema(postgres_engine: Engine) -> None:
"""Test that SqlStateStore with schema='dbos' creates the table in the dbos schema."""
run_id = "pg-schema-test"
store = SqlStateStore(run_id=run_id, engine=postgres_engine, schema="dbos")
# Trigger table creation by accessing state
await store.set("test", "value")
# Verify the table was created in the dbos schema
with postgres_engine.connect() as conn:
result = conn.exec_driver_sql(
"""
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'dbos'
AND table_name = 'workflow_state'
)
"""
)
exists = result.scalar()
assert exists is True
@@ -623,7 +623,9 @@ def deserialize_dict_state_data(
def deserialize_state_from_dict(
serialized_state: dict[str, Any], serializer: "BaseSerializer"
serialized_state: dict[str, Any],
serializer: "BaseSerializer",
state_type: type[BaseModel] | None = None,
) -> BaseModel:
"""Deserialize state from a serialized payload.
@@ -634,6 +636,9 @@ def deserialize_state_from_dict(
serialized_state: The payload from to_dict(), containing state_data,
state_type, and state_module.
serializer: Strategy to decode stored values.
state_type: Optional explicit state type. When provided, uses
issubclass to determine if it's DictState. When omitted, falls
back to reading state_type from the dict.
Returns:
The deserialized state model instance.
@@ -645,7 +650,16 @@ def deserialize_state_from_dict(
state_type_name = serialized_state.get("state_type", "DictState")
if state_type_name == "DictState":
return deserialize_dict_state_data(state_data, serializer)
_data_serialized = state_data.get("_data", {})
deserialized_data = {}
for key, value in _data_serialized.items():
try:
deserialized_data[key] = serializer.deserialize(value)
except Exception as e:
raise ValueError(
f"Failed to deserialize state value for key {key}: {e}"
)
return DictState(_data=deserialized_data)
else:
return serializer.deserialize(state_data)
@@ -134,6 +134,11 @@ class _ControlLoopRunner:
self._task_keys: dict[asyncio.Task[TickStepResult], tuple[str, int]] = {}
# Whether a TickIdleCheck is currently in tick_buffer
self._idle_check_pending = False
# Gate to prevent workers from executing their step function until the
# main loop is parked in wait_for_next_task. This ensures deterministic
# DBOS step ordering by preventing interleaving of worker steps with
# main loop _durable_time() calls.
self._worker_gate = asyncio.Event()
def schedule_tick(self, tick: WorkflowTick, at_time: float) -> None:
"""Schedule a tick to be processed at a specific time."""
@@ -184,6 +189,11 @@ class _ControlLoopRunner:
snapshot = worker.shared_state
step_fn: StepWorkerFunction = self.step_workers[command.step_name]
# Wait for the main loop to signal it's safe to execute.
# This prevents worker DBOS steps from interleaving with
# main loop _durable_time() calls.
await self._worker_gate.wait()
result = await step_fn(
state=snapshot,
step_name=command.step_name,
@@ -256,6 +266,9 @@ class _ControlLoopRunner:
async def cleanup_tasks(self) -> None:
"""Cancel and cleanup all running worker tasks."""
# Open the gate so workers waiting on it can receive cancellation
self._worker_gate.set()
# Signal adapter to stop waiting
try:
await self.adapter.close()
@@ -367,10 +380,15 @@ class _ControlLoopRunner:
]
named_tasks.append(NamedTask.pull(pull_sequence, pull_task))
# Wait for next task completion (adapter controls ordering for replay)
completed_task = await self.adapter.wait_for_next_task(
named_tasks, timeout
)
# Open the gate so workers can execute their step functions,
# then wait for next task completion.
self._worker_gate.set()
try:
completed_task = await self.adapter.wait_for_next_task(
named_tasks, timeout
)
finally:
self._worker_gate.clear()
if completed_task is None:
# Timeout - process scheduled ticks
@@ -5,7 +5,7 @@
Full state store protocol tests are in the integration test package
(llama-index-integration-tests/tests/test_state_store_matrix.py),
which tests InMemoryStateStore alongside SqliteStateStore.
which tests InMemoryStateStore alongside SqlStateStore.
These tests provide fast feedback during development of the base package.
"""
+16 -8
View File
@@ -61,7 +61,7 @@ root = "packages/llama-agents-integration-tests"
pythonVersion = "3.13"
[[tool.basedpyright.executionEnvironments]]
root = "packages/llama-index-workflows-dbos"
root = "packages/llama-agents-dbos"
pythonVersion = "3.10"
[[tool.basedpyright.executionEnvironments]]
@@ -136,13 +136,21 @@ llama-index-utils-workflow = {workspace = true}
llama-index-workflows = {workspace = true}
llama-agents-client = {workspace = true}
llama-agents-server = {workspace = true}
llama-agents-dbos = {workspace = true}
[tool.uv.workspace]
members = [
"docs/api_docs",
"packages/llama-agents-integration-tests",
"packages/llama-index-utils-workflow",
"packages/llama-index-workflows",
"packages/llama-agents-client",
"packages/llama-agents-server"
exclude = [
]
members = [
# core
"packages/llama-index-workflows",
# extensions
"packages/llama-agents-client",
"packages/llama-agents-server",
"packages/llama-index-utils-workflow",
# integrations
"packages/llama-agents-dbos",
# internal
"docs/api_docs",
"packages/llama-agents-integration-tests"
]
Generated
+181 -17
View File
@@ -5,13 +5,15 @@ resolution-markers = [
"python_full_version >= '3.11'",
"python_full_version == '3.10.*'",
"python_full_version >= '3.9.2' and python_full_version < '3.10'",
"python_full_version < '3.9.2'",
"python_full_version > '3.9' and python_full_version < '3.9.2'",
"python_full_version <= '3.9'",
]
[manifest]
members = [
"docs",
"llama-agents-client",
"llama-agents-dbos",
"llama-agents-dev",
"llama-agents-integration-tests",
"llama-agents-server",
@@ -190,6 +192,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f5/10/6c25ed6de94c49f88a91fa5018cb4c0f3625f31d5be9f771ebe5cc7cd506/aiosqlite-0.21.0-py3-none-any.whl", hash = "sha256:2549cf4057f95f53dcba16f2b64e8e2791d7e1adedb13197dd8ed77bb226d7d0", size = 15792, upload-time = "2025-02-03T07:30:13.6Z" },
]
[[package]]
name = "annotated-doc"
version = "0.0.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -502,7 +513,8 @@ version = "8.1.8"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9.2' and python_full_version < '3.10'",
"python_full_version < '3.9.2'",
"python_full_version > '3.9' and python_full_version < '3.9.2'",
"python_full_version <= '3.9'",
]
dependencies = [
{ name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
@@ -543,7 +555,8 @@ version = "7.10.7"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9.2' and python_full_version < '3.10'",
"python_full_version < '3.9.2'",
"python_full_version > '3.9' and python_full_version < '3.9.2'",
"python_full_version <= '3.9'",
]
sdist = { url = "https://files.pythonhosted.org/packages/51/26/d22c300112504f5f9a9fd2297ce33c35f3d353e4aeb987c8419453b2a7c2/coverage-7.10.7.tar.gz", hash = "sha256:f4ab143ab113be368a3e9b795f9cd7906c5ef407d6173fe9675a902e1fffc239", size = 827704, upload-time = "2025-09-21T20:03:56.815Z" }
wheels = [
@@ -827,6 +840,23 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" },
]
[[package]]
name = "dbos"
version = "2.12.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "psycopg", version = "3.3.2", source = { registry = "https://pypi.org/simple" }, extra = ["binary"], marker = "python_full_version >= '3.10'" },
{ name = "python-dateutil", marker = "python_full_version >= '3.10'" },
{ name = "pyyaml", marker = "python_full_version >= '3.10'" },
{ name = "sqlalchemy", marker = "python_full_version >= '3.10'" },
{ name = "typer-slim", marker = "python_full_version >= '3.10'" },
{ name = "websockets", marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0f/2a/e79f0cee1e8cc515b4d896025e0a4dbd9953dd7de4b11033cc9d0b4c1100/dbos-2.12.0.tar.gz", hash = "sha256:ce2d7aefc8564acdd7da10aae403bc86068869f9d17c4e3040ff31ff9a4cc46f", size = 236357, upload-time = "2026-02-09T17:20:04.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/02/5d/82057b4d2a3c4950dc7759f64c34041bf4a1e1848de22cb5b58e3d0206a6/dbos-2.12.0-py3-none-any.whl", hash = "sha256:362e685a2c8db9d6dd9a293f3ecd4552228a3c1901c03b674c3f6782ee72dcc3", size = 146926, upload-time = "2026-02-09T17:20:03.413Z" },
]
[[package]]
name = "decorator"
version = "5.2.1"
@@ -968,7 +998,8 @@ version = "3.19.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9.2' and python_full_version < '3.10'",
"python_full_version < '3.9.2'",
"python_full_version > '3.9' and python_full_version < '3.9.2'",
"python_full_version <= '3.9'",
]
sdist = { url = "https://files.pythonhosted.org/packages/40/bb/0ab3e58d22305b6f5440629d20683af28959bf793d98d11950e305c1c326/filelock-3.19.1.tar.gz", hash = "sha256:66eda1888b0171c998b35be2bcc0f6d75c388a7ce20c3f3f37aa8e96c2dddf58", size = 17687, upload-time = "2025-08-14T16:56:03.016Z" }
wheels = [
@@ -1383,7 +1414,8 @@ version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9.2' and python_full_version < '3.10'",
"python_full_version < '3.9.2'",
"python_full_version > '3.9' and python_full_version < '3.9.2'",
"python_full_version <= '3.9'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" }
wheels = [
@@ -1409,7 +1441,8 @@ version = "8.18.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9.2' and python_full_version < '3.10'",
"python_full_version < '3.9.2'",
"python_full_version > '3.9' and python_full_version < '3.9.2'",
"python_full_version <= '3.9'",
]
dependencies = [
{ name = "colorama", marker = "python_full_version < '3.10' and sys_platform == 'win32'" },
@@ -1632,6 +1665,43 @@ dev = [
{ name = "pytest-xdist", specifier = ">=3.8.0" },
]
[[package]]
name = "llama-agents-dbos"
version = "0.1.0"
source = { editable = "packages/llama-agents-dbos" }
dependencies = [
{ name = "dbos", marker = "python_full_version >= '3.10'" },
{ name = "llama-index-workflows" },
]
[package.dev-dependencies]
dev = [
{ name = "basedpyright" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
{ name = "pytest-timeout" },
{ name = "pytest-xdist" },
{ name = "ty" },
]
[package.metadata]
requires-dist = [
{ name = "dbos", marker = "python_full_version >= '3.10'", specifier = ">=2.11.0" },
{ name = "llama-index-workflows", editable = "packages/llama-index-workflows" },
]
[package.metadata.requires-dev]
dev = [
{ name = "basedpyright", specifier = ">=1.31.1" },
{ name = "pytest", specifier = ">=8.4.0" },
{ name = "pytest-asyncio", specifier = ">=1.0.0" },
{ name = "pytest-cov", specifier = ">=6.1.1" },
{ name = "pytest-timeout", specifier = ">=2.4.0" },
{ name = "pytest-xdist", specifier = ">=3.0.0" },
{ name = "ty", specifier = ">=0.0.1,<0.0.9" },
]
[[package]]
name = "llama-agents-dev"
version = "0.1.0"
@@ -1685,6 +1755,7 @@ name = "llama-agents-integration-tests"
version = "0.1.0"
source = { editable = "packages/llama-agents-integration-tests" }
dependencies = [
{ name = "llama-agents-dbos", marker = "python_full_version > '3.9'" },
{ name = "llama-index-core" },
{ name = "llama-index-workflows" },
]
@@ -1708,6 +1779,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "llama-agents-dbos", marker = "python_full_version > '3.9'", editable = "packages/llama-agents-dbos" },
{ name = "llama-index-core", specifier = ">=0.14.13" },
{ name = "llama-index-workflows", editable = "packages/llama-index-workflows" },
]
@@ -1938,7 +2010,8 @@ version = "3.9"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9.2' and python_full_version < '3.10'",
"python_full_version < '3.9.2'",
"python_full_version > '3.9' and python_full_version < '3.9.2'",
"python_full_version <= '3.9'",
]
dependencies = [
{ name = "importlib-metadata", marker = "python_full_version < '3.10'" },
@@ -1967,7 +2040,8 @@ version = "3.0.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9.2' and python_full_version < '3.10'",
"python_full_version < '3.9.2'",
"python_full_version > '3.9' and python_full_version < '3.9.2'",
"python_full_version <= '3.9'",
]
dependencies = [
{ name = "mdurl", marker = "python_full_version < '3.10'" },
@@ -2314,7 +2388,8 @@ version = "1.18.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9.2' and python_full_version < '3.10'",
"python_full_version < '3.9.2'",
"python_full_version > '3.9' and python_full_version < '3.9.2'",
"python_full_version <= '3.9'",
]
dependencies = [
{ name = "griffe", marker = "python_full_version < '3.10'" },
@@ -2535,7 +2610,8 @@ version = "3.2.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9.2' and python_full_version < '3.10'",
"python_full_version < '3.9.2'",
"python_full_version > '3.9' and python_full_version < '3.9.2'",
"python_full_version <= '3.9'",
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/80/a84676339aaae2f1cfdf9f418701dd634aef9cc76f708ef55c36ff39c3ca/networkx-3.2.1.tar.gz", hash = "sha256:9f1bb5cf3409bf324e0a722c20bdb4c20ee39bf1c30ce8ae499c8502b0b5e0c6", size = 2073928, upload-time = "2023-10-28T08:41:39.364Z" }
wheels = [
@@ -2613,7 +2689,8 @@ version = "2.0.2"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9.2' and python_full_version < '3.10'",
"python_full_version < '3.9.2'",
"python_full_version > '3.9' and python_full_version < '3.9.2'",
"python_full_version <= '3.9'",
]
sdist = { url = "https://files.pythonhosted.org/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz", hash = "sha256:883c987dee1880e2a864ab0dc9892292582510604156762362d9326444636e78", size = 18902015, upload-time = "2024-08-26T20:19:40.945Z" }
wheels = [
@@ -2866,7 +2943,8 @@ version = "11.3.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9.2' and python_full_version < '3.10'",
"python_full_version < '3.9.2'",
"python_full_version > '3.9' and python_full_version < '3.9.2'",
"python_full_version <= '3.9'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
wheels = [
@@ -3085,7 +3163,8 @@ version = "4.4.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9.2' and python_full_version < '3.10'",
"python_full_version < '3.9.2'",
"python_full_version > '3.9' and python_full_version < '3.9.2'",
"python_full_version <= '3.9'",
]
sdist = { url = "https://files.pythonhosted.org/packages/23/e8/21db9c9987b0e728855bd57bff6984f67952bea55d6f75e055c46b5383e8/platformdirs-4.4.0.tar.gz", hash = "sha256:ca753cf4d81dc309bc67b0ea38fd15dc97bc30ce419a7f58d13eb3bf14c4febf", size = 21634, upload-time = "2025-08-26T14:32:04.268Z" }
wheels = [
@@ -3277,7 +3356,8 @@ version = "3.2.13"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9.2' and python_full_version < '3.10'",
"python_full_version < '3.9.2'",
"python_full_version > '3.9' and python_full_version < '3.9.2'",
"python_full_version <= '3.9'",
]
dependencies = [
{ name = "typing-extensions", marker = "python_full_version < '3.10'" },
@@ -3321,7 +3401,8 @@ version = "3.2.13"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9.2' and python_full_version < '3.10'",
"python_full_version < '3.9.2'",
"python_full_version > '3.9' and python_full_version < '3.9.2'",
"python_full_version <= '3.9'",
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/16/325f72b7ebdb906bd6cca6c0caea5b8fd7092c4686237c5669fe3f3cc7f2/psycopg_binary-3.2.13-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9e25eb65494955c0dabdcd7097b004cbd70b982cf3cbc7186c2e854f788677a9", size = 4013642, upload-time = "2025-11-21T22:29:43.39Z" },
@@ -4052,7 +4133,8 @@ version = "3.3.3"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.9.2' and python_full_version < '3.10'",
"python_full_version < '3.9.2'",
"python_full_version > '3.9' and python_full_version < '3.9.2'",
"python_full_version <= '3.9'",
]
dependencies = [
{ name = "cryptography", marker = "python_full_version < '3.10'" },
@@ -4227,7 +4309,8 @@ name = "testcontainers"
version = "4.13.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.9.2'",
"python_full_version > '3.9' and python_full_version < '3.9.2'",
"python_full_version <= '3.9'",
]
dependencies = [
{ name = "docker", marker = "python_full_version < '3.9.2'" },
@@ -4569,6 +4652,19 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/1c/0d8454ff0f0f258737ecfe84f6e508729191d29663b404832f98fa5626b7/ty-0.0.8-py3-none-win_arm64.whl", hash = "sha256:ec74f022f315bede478ecae1277a01ab618e6500c1d68450d7883f5cd6ed554a", size = 9636374, upload-time = "2025-12-29T13:50:16.344Z" },
]
[[package]]
name = "typer-slim"
version = "0.21.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-doc", marker = "python_full_version >= '3.10'" },
{ name = "click", version = "8.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a5/ca/0d9d822fd8a4c7e830cba36a2557b070d4b4a9558a0460377a61f8fb315d/typer_slim-0.21.2.tar.gz", hash = "sha256:78f20d793036a62aaf9c3798306142b08261d4b2a941c6e463081239f062a2f9", size = 120497, upload-time = "2026-02-10T19:33:45.836Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/54/03/e09325cfc40a33a82b31ba1a3f1d97e85246736856a45a43b19fcb48b1c2/typer_slim-0.21.2-py3-none-any.whl", hash = "sha256:4705082bb6c66c090f60e47c8be09a93158c139ce0aa98df7c6c47e723395e5f", size = 56790, upload-time = "2026-02-10T19:33:47.221Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
@@ -4738,6 +4834,74 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" },
]
[[package]]
name = "websockets"
version = "16.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" },
{ url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" },
{ url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" },
{ url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" },
{ url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" },
{ url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" },
{ url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" },
{ url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
{ url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
{ url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
{ url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
{ url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
{ url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
{ url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
]
[[package]]
name = "wrapt"
version = "2.0.0"