pyworkflow-engine 0.2.7__tar.gz → 0.2.8__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/PKG-INFO +1 -1
- pyworkflow_engine-0.2.8/docs/concepts/stream-steps.mdx +264 -0
- pyworkflow_engine-0.2.8/docs/concepts/stream-workflows.mdx +221 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyproject.toml +1 -1
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/__init__.py +1 -1
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/storage/citus.py +25 -50
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/storage/postgres.py +23 -49
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow_engine.egg-info/SOURCES.txt +2 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/CLAUDE.md +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/DISTRIBUTED.md +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/LICENSE +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/MANIFEST.in +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/README.md +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/RELEASING.md +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/architecture.md +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/concepts/cancellation.mdx +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/concepts/continue-as-new.mdx +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/concepts/events.mdx +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/concepts/fault-tolerance.mdx +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/concepts/hooks.mdx +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/concepts/limitations.mdx +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/concepts/schedules.mdx +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/concepts/sleep.mdx +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/concepts/step-context.mdx +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/concepts/steps.mdx +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/concepts/workflows.mdx +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/conventions.md +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/guides/brokers.mdx +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/guides/cli.mdx +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/guides/configuration.mdx +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/harness-gaps.md +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/introduction.mdx +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/layers.md +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/docs/quickstart.mdx +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/docker-compose.yml +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/pyworkflow.config.yaml +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/workflows/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/workflows/basic.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/workflows/batch_processing.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/workflows/cancellation.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/workflows/child_workflow_from_step.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/workflows/child_workflow_patterns.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/workflows/child_workflows.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/workflows/continue_as_new.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/workflows/fault_tolerance.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/workflows/hooks.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/workflows/idempotency.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/workflows/long_running.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/workflows/retries.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/workflows/schedules.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/workflows/sleep_in_step.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/durable/workflows/step_context.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/transient/01_basic_workflow.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/transient/02_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/transient/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/celery/transient/pyworkflow.config.yaml +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/durable/01_basic_workflow.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/durable/02_file_storage.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/durable/03_retries.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/durable/04_long_running.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/durable/05_event_log.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/durable/06_idempotency.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/durable/07_hooks.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/durable/08_cancellation.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/durable/09_child_workflows.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/durable/10_child_workflow_patterns.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/durable/11_continue_as_new.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/durable/12_schedules.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/durable/13_step_context.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/durable/14_child_workflow_from_step.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/durable/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/transient/01_quick_tasks.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/transient/02_retries.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/transient/03_sleep.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/examples/local/transient/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/aws/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/aws/context.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/aws/handler.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/aws/testing.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/celery/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/celery/app.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/celery/loop.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/celery/scheduler.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/celery/singleton.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/celery/tasks.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/__main__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/commands/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/commands/hooks.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/commands/quickstart.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/commands/runs.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/commands/scheduler.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/commands/schedules.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/commands/setup.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/commands/worker.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/commands/workflows.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/output/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/output/formatters.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/output/styles.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/utils/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/utils/async_helpers.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/utils/config.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/utils/config_generator.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/utils/discovery.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/utils/docker_manager.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/utils/interactive.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/cli/utils/storage.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/config.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/context/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/context/aws.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/context/base.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/context/local.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/context/mock.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/context/step_context.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/core/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/core/exceptions.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/core/registry.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/core/scheduled.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/core/step.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/core/validation.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/core/workflow.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/discovery.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/engine/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/engine/events.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/engine/executor.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/engine/replay.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/observability/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/observability/logging.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/primitives/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/primitives/child_handle.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/primitives/child_workflow.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/primitives/continue_as_new.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/primitives/define_hook.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/primitives/hooks.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/primitives/resume_hook.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/primitives/schedule.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/primitives/shield.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/primitives/sleep.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/primitives/step_checkpoint.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/primitives/step_hook.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/runtime/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/runtime/base.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/runtime/celery.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/runtime/factory.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/runtime/local.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/scheduler/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/scheduler/local.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/serialization/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/serialization/decoder.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/serialization/encoder.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/storage/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/storage/base.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/storage/cassandra.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/storage/config.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/storage/dynamodb.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/storage/file.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/storage/memory.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/storage/migrations/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/storage/migrations/base.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/storage/mysql.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/storage/schemas.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/storage/sqlite.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/streams/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/streams/checkpoint.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/streams/consumer.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/streams/context.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/streams/decorator.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/streams/dispatcher.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/streams/emit.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/streams/registry.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/streams/runtime.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/streams/signal.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/streams/step_context.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/utils/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/utils/duration.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/utils/helpers.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/pyworkflow/utils/schedule.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/setup.cfg +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/integration/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/integration/test_cancellation.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/integration/test_cassandra_storage.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/integration/test_child_workflows.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/integration/test_continue_as_new.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/integration/test_dynamodb_storage.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/integration/test_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/integration/test_schedule_storage.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/integration/test_schema_migrations.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/integration/test_singleton.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/integration/test_stream_e2e.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/integration/test_workflow_suspended.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/backends/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/backends/test_cassandra_storage.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/backends/test_citus_storage.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/backends/test_dynamodb_storage.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/backends/test_postgres_storage.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/backends/test_sqlite_storage.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/conftest.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/storage/__init__.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/storage/test_migrations.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_cancellation.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_child_workflows.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_cli_worker.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_continue_as_new.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_emit.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_event_limits.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_executor.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_fault_tolerance.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_force_local.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_hooks.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_parent_run_id.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_primitives_from_steps.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_registry.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_replay.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_retention.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_schedule_schemas.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_schedule_utils.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_scheduled_workflow.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_signal.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_singleton.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_step.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_step_checkpoint.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_step_context.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_step_hook.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_stream_storage.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_stream_workflow.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_validation.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_workflow.py +0 -0
- {pyworkflow_engine-0.2.7 → pyworkflow_engine-0.2.8}/tests/unit/test_workflow_suspended.py +0 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Stream Steps'
|
|
3
|
+
description: 'Reactive, long-lived steps that subscribe to signals on a stream and resume on each arrival'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## What is a Stream Step?
|
|
7
|
+
|
|
8
|
+
A stream step is a long-lived reactive unit that subscribes to a named stream and runs in response to **signals**. Unlike a regular `@step` — which runs once when called from a workflow — a `@stream_step` is materialized as a subscription row when its parent [stream workflow](/concepts/stream-workflows) starts, and is then re-invoked each time a matching signal arrives, until it explicitly terminates.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from pyworkflow import stream_step
|
|
12
|
+
from pyworkflow.streams import get_current_signal, set_result, terminate
|
|
13
|
+
|
|
14
|
+
@stream_step(stream="agent_comms", signals=["task.assigned"])
|
|
15
|
+
async def worker():
|
|
16
|
+
signal = await get_current_signal()
|
|
17
|
+
if signal is None:
|
|
18
|
+
# First start — no signal yet, just register and wait.
|
|
19
|
+
return
|
|
20
|
+
|
|
21
|
+
task_id = signal.payload["task_id"]
|
|
22
|
+
result = await do_work(task_id)
|
|
23
|
+
|
|
24
|
+
await set_result({"task_id": task_id, "result": result})
|
|
25
|
+
await terminate()
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
## Key Characteristics
|
|
29
|
+
|
|
30
|
+
<CardGroup cols={2}>
|
|
31
|
+
<Card title="Signal-driven" icon="bolt">
|
|
32
|
+
The step body runs once on start and again on each matching signal arrival.
|
|
33
|
+
</Card>
|
|
34
|
+
<Card title="Stateful" icon="database">
|
|
35
|
+
Use `save_checkpoint()` / `get_checkpoint()` to carry state across resumes without rebuilding from scratch.
|
|
36
|
+
</Card>
|
|
37
|
+
<Card title="Suspendable" icon="pause">
|
|
38
|
+
`suspend()` parks the step and bubbles a `SuspensionSignal` up to the parent workflow — perfect for HITL.
|
|
39
|
+
</Card>
|
|
40
|
+
<Card title="Schema-aware" icon="shield-check">
|
|
41
|
+
Bind Pydantic models to signal types and the dispatcher validates payloads before delivery.
|
|
42
|
+
</Card>
|
|
43
|
+
</CardGroup>
|
|
44
|
+
|
|
45
|
+
## The Two Code Paths
|
|
46
|
+
|
|
47
|
+
A stream step has **two** code paths the runtime invokes at different times:
|
|
48
|
+
|
|
49
|
+
1. **`on_signal` callback** — runs on every matching signal arrival. It receives the raw `Signal` and a `StreamStepContext`. Its job is to decide whether to `resume()`, `cancel()`, `terminate()`, or `suspend()` — typically very lightweight.
|
|
50
|
+
2. **The lifecycle function** (the decorated function itself) — runs on first start and again on each explicit `ctx.resume()`. This is where the actual work happens.
|
|
51
|
+
|
|
52
|
+
If you don't pass an `on_signal`, the default callback (`_auto_resume_on_signal`) just calls `ctx.resume()` for every matching signal — which is the right thing 90% of the time.
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from pyworkflow import stream_step
|
|
56
|
+
from pyworkflow.streams import get_current_signal, terminate
|
|
57
|
+
|
|
58
|
+
# Custom on_signal: only resume on the high-priority variant.
|
|
59
|
+
async def gate_priority(signal, ctx):
|
|
60
|
+
if signal.payload.get("priority") == "high":
|
|
61
|
+
await ctx.resume()
|
|
62
|
+
# Otherwise: do nothing — the signal is acked and the step stays waiting.
|
|
63
|
+
|
|
64
|
+
@stream_step(
|
|
65
|
+
stream="agent_comms",
|
|
66
|
+
signals=["task.created"],
|
|
67
|
+
on_signal=gate_priority,
|
|
68
|
+
)
|
|
69
|
+
async def priority_worker():
|
|
70
|
+
signal = await get_current_signal()
|
|
71
|
+
if signal is None:
|
|
72
|
+
return
|
|
73
|
+
await handle_high_priority(signal.payload)
|
|
74
|
+
await terminate()
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## Defining Signals
|
|
78
|
+
|
|
79
|
+
The `signals=` argument accepts either a plain list of names or a dict mapping names to Pydantic schemas.
|
|
80
|
+
|
|
81
|
+
<Tabs>
|
|
82
|
+
<Tab title="List of names">
|
|
83
|
+
```python
|
|
84
|
+
@stream_step(
|
|
85
|
+
stream="agent_comms",
|
|
86
|
+
signals=["task.created", "task.updated", "task.cancelled"],
|
|
87
|
+
)
|
|
88
|
+
async def planner():
|
|
89
|
+
signal = await get_current_signal()
|
|
90
|
+
...
|
|
91
|
+
```
|
|
92
|
+
</Tab>
|
|
93
|
+
<Tab title="Pydantic schemas">
|
|
94
|
+
```python
|
|
95
|
+
from pydantic import BaseModel
|
|
96
|
+
|
|
97
|
+
class TaskAssigned(BaseModel):
|
|
98
|
+
task_id: str
|
|
99
|
+
assignee: str
|
|
100
|
+
priority: int
|
|
101
|
+
|
|
102
|
+
@stream_step(
|
|
103
|
+
stream="agent_comms",
|
|
104
|
+
signals={"task.assigned": TaskAssigned},
|
|
105
|
+
)
|
|
106
|
+
async def worker():
|
|
107
|
+
signal = await get_current_signal()
|
|
108
|
+
if signal:
|
|
109
|
+
# signal.payload is a validated dict matching TaskAssigned
|
|
110
|
+
await assign(signal.payload["task_id"], signal.payload["assignee"])
|
|
111
|
+
```
|
|
112
|
+
</Tab>
|
|
113
|
+
</Tabs>
|
|
114
|
+
|
|
115
|
+
<Note>
|
|
116
|
+
When a schema is bound to a signal type, payloads that fail validation are rejected by the dispatcher and the step is **not** invoked for that signal.
|
|
117
|
+
</Note>
|
|
118
|
+
|
|
119
|
+
## Lifecycle Primitives
|
|
120
|
+
|
|
121
|
+
These helpers, imported from `pyworkflow.streams`, are how the lifecycle function communicates with the dispatcher. They all set state that the dispatcher reads **after** the function returns — they don't immediately mutate the subscription row.
|
|
122
|
+
|
|
123
|
+
| Primitive | Purpose |
|
|
124
|
+
|---|---|
|
|
125
|
+
| `await get_current_signal()` | Returns the `Signal` that triggered this resume (or `None` on first start). |
|
|
126
|
+
| `await set_result(value)` | Attach a result payload to the subscription. Surfaced via `StreamWorkflowResult.get_result(...)`. |
|
|
127
|
+
| `await terminate()` | Mark the step `terminated` — it will not be invoked again. |
|
|
128
|
+
| `await suspend(reason, resume_signals=None)` | Mark the step `suspended` — bubbles a `SuspensionSignal` up to the parent workflow (HITL). |
|
|
129
|
+
| `await save_checkpoint(data)` | Persist a JSON-serializable dict for the next resume. |
|
|
130
|
+
| `await get_checkpoint()` | Load the most recently saved checkpoint dict (or `None`). |
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from pyworkflow.streams import (
|
|
134
|
+
get_current_signal,
|
|
135
|
+
get_checkpoint,
|
|
136
|
+
save_checkpoint,
|
|
137
|
+
set_result,
|
|
138
|
+
suspend,
|
|
139
|
+
terminate,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
@stream_step(stream="reviews", signals=["review.submitted", "human.approved"])
|
|
143
|
+
async def review_loop():
|
|
144
|
+
signal = await get_current_signal()
|
|
145
|
+
state = (await get_checkpoint()) or {"submissions": 0}
|
|
146
|
+
|
|
147
|
+
if signal is None:
|
|
148
|
+
return # initial start
|
|
149
|
+
|
|
150
|
+
if signal.signal_type == "review.submitted":
|
|
151
|
+
state["submissions"] += 1
|
|
152
|
+
await save_checkpoint(state)
|
|
153
|
+
|
|
154
|
+
if state["submissions"] >= 3:
|
|
155
|
+
# Park the step until a human reviews it.
|
|
156
|
+
await suspend(
|
|
157
|
+
reason="hitl:needs_review",
|
|
158
|
+
resume_signals=["human.approved"],
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
|
|
162
|
+
if signal.signal_type == "human.approved":
|
|
163
|
+
await set_result({"approved": True, "submissions": state["submissions"]})
|
|
164
|
+
await terminate()
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## The `Signal` Object
|
|
168
|
+
|
|
169
|
+
`get_current_signal()` returns a `Signal` dataclass:
|
|
170
|
+
|
|
171
|
+
```python
|
|
172
|
+
@dataclass
|
|
173
|
+
class Signal:
|
|
174
|
+
signal_id: str
|
|
175
|
+
stream_id: str
|
|
176
|
+
signal_type: str
|
|
177
|
+
payload: Any # dict (validated against schema if configured)
|
|
178
|
+
published_at: datetime
|
|
179
|
+
sequence: int | None # Per-stream ordering, assigned by storage
|
|
180
|
+
source_run_id: str | None # run_id of the workflow that called emit()
|
|
181
|
+
stream_run_id: str | None # Scoping key for this stream run
|
|
182
|
+
metadata: dict[str, Any]
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
The `sequence` field gives you a strict ordering across all signals on the stream — useful for deduping or detecting gaps. `source_run_id` is set automatically when `emit()` is called from inside a workflow context.
|
|
186
|
+
|
|
187
|
+
## Emitting Signals from a Step
|
|
188
|
+
|
|
189
|
+
Stream steps can emit signals back to the same or another stream — this is how multi-actor pipelines fan out work between participants.
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
from pyworkflow.streams import emit, get_current_signal, terminate
|
|
193
|
+
|
|
194
|
+
@stream_step(stream="orders", signals=["order.placed"])
|
|
195
|
+
async def fulfillment():
|
|
196
|
+
signal = await get_current_signal()
|
|
197
|
+
if signal is None:
|
|
198
|
+
return
|
|
199
|
+
|
|
200
|
+
order_id = signal.payload["order_id"]
|
|
201
|
+
await ship(order_id)
|
|
202
|
+
|
|
203
|
+
# Notify the billing stream that this order is ready to invoice.
|
|
204
|
+
await emit("billing", "order.shipped", {"order_id": order_id})
|
|
205
|
+
|
|
206
|
+
await terminate()
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
`emit()` automatically picks up the current `stream_run_id` from context, so signals stay scoped to the same run unless you pass an explicit override.
|
|
210
|
+
|
|
211
|
+
## The `StreamStepContext` (for `on_signal` callbacks)
|
|
212
|
+
|
|
213
|
+
When you pass a custom `on_signal=...`, the callback receives `(signal, ctx)` where `ctx` is a `StreamStepContext`:
|
|
214
|
+
|
|
215
|
+
| Method | Effect |
|
|
216
|
+
|---|---|
|
|
217
|
+
| `await ctx.resume()` | Trigger the lifecycle function with `signal` as the current signal. |
|
|
218
|
+
| `await ctx.terminate()` | Mark the step terminated without invoking the lifecycle function. |
|
|
219
|
+
| `await ctx.suspend(reason, resume_signals=None)` | Park the step; bubbles to the parent workflow as a `SuspensionSignal`. |
|
|
220
|
+
| `await ctx.cancel(reason=None)` | Cancel the step (terminal, with a reason). |
|
|
221
|
+
|
|
222
|
+
`ctx` also exposes `ctx.status`, `ctx.run_id`, and `ctx.stream_id` for inspection.
|
|
223
|
+
|
|
224
|
+
```python
|
|
225
|
+
async def cancel_on_kill(signal, ctx):
|
|
226
|
+
if signal.signal_type == "stream.kill":
|
|
227
|
+
await ctx.cancel(reason="received kill signal")
|
|
228
|
+
return
|
|
229
|
+
await ctx.resume()
|
|
230
|
+
|
|
231
|
+
@stream_step(
|
|
232
|
+
stream="agent_comms",
|
|
233
|
+
signals=["task.created", "stream.kill"],
|
|
234
|
+
on_signal=cancel_on_kill,
|
|
235
|
+
)
|
|
236
|
+
async def killable_worker():
|
|
237
|
+
...
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
## Terminal States
|
|
241
|
+
|
|
242
|
+
Every stream step subscription is in one of these states. The aggregate of all step states determines whether the parent stream workflow is `running`, `completed`, or `suspended` — see [Stream Workflows: Aggregate Lifecycle](/concepts/stream-workflows#aggregate-lifecycle).
|
|
243
|
+
|
|
244
|
+
| State | Meaning |
|
|
245
|
+
|---|---|
|
|
246
|
+
| `waiting` | Subscribed and parked, ready for the next matching signal. |
|
|
247
|
+
| `running` | Lifecycle function is currently executing on a worker. |
|
|
248
|
+
| `suspended` | `suspend()` was called — bubbles `SuspensionSignal` up to the parent. |
|
|
249
|
+
| `terminated` | `terminate()` was called — the step is done and will never run again. |
|
|
250
|
+
| `cancelled` | `cancel()` was called from an `on_signal` callback. |
|
|
251
|
+
|
|
252
|
+
## Best Practices
|
|
253
|
+
|
|
254
|
+
- **Keep `on_signal` callbacks lightweight.** They run for every signal, including ones you ignore — do filtering there, do work in the lifecycle function.
|
|
255
|
+
- **Always handle the `signal is None` case.** The first invocation has no triggering signal; that's the registration phase. Use it to seed state, not to do work.
|
|
256
|
+
- **Use `set_result()` instead of `save_checkpoint()` for output the parent needs.** `save_checkpoint` is for *internal* state across resumes; `set_result` is the parent-facing return value.
|
|
257
|
+
- **Call `terminate()` or `suspend()` when you're done.** A step that just `return`s without setting a terminal state stays in `waiting` and keeps the parent stream workflow alive.
|
|
258
|
+
- **Don't rely on globals.** Step lifecycles can run on different workers between resumes — persist anything you need via `save_checkpoint()`.
|
|
259
|
+
|
|
260
|
+
## Related Concepts
|
|
261
|
+
|
|
262
|
+
- [Stream Workflows](/concepts/stream-workflows) — the parent runtime that hosts and drives stream steps
|
|
263
|
+
- [Steps](/concepts/steps) — the regular, run-once step model for sequential workflows
|
|
264
|
+
- [Hooks](/concepts/hooks) — the suspension primitive that stream workflows use under the hood
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: 'Stream Workflows'
|
|
3
|
+
description: 'Long-lived, reactive workflows built from stream steps that react to signals on a named channel'
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
## What is a Stream Workflow?
|
|
7
|
+
|
|
8
|
+
A stream workflow is a **long-lived, reactive unit of orchestration** built on top of the signal pub/sub system. Where a `@workflow` runs top-to-bottom and completes, a `@stream_workflow` defines a named channel ("stream") that one or more `@stream_step`s subscribe to. Each step reacts to incoming signals, can suspend and be resumed by new signals, and can publish results back.
|
|
9
|
+
|
|
10
|
+
```python
|
|
11
|
+
from pyworkflow import stream_workflow, stream_step, emit, run_stream_workflow, workflow
|
|
12
|
+
|
|
13
|
+
@stream_workflow(name="agent_comms")
|
|
14
|
+
async def agent_comms():
|
|
15
|
+
"""Defines the 'agent_comms' channel. Body runs once on start."""
|
|
16
|
+
await emit("agent_comms", "agent.started", {"ts": "now"})
|
|
17
|
+
|
|
18
|
+
@stream_step(stream="agent_comms", signals=["task.assigned"])
|
|
19
|
+
async def worker():
|
|
20
|
+
from pyworkflow.streams import get_current_signal, set_result, terminate
|
|
21
|
+
signal = await get_current_signal()
|
|
22
|
+
if signal is None:
|
|
23
|
+
return # initial start, no signal yet
|
|
24
|
+
await set_result({"task_id": signal.payload["task_id"]})
|
|
25
|
+
await terminate()
|
|
26
|
+
|
|
27
|
+
@workflow()
|
|
28
|
+
async def run_agent(run_id: str):
|
|
29
|
+
# Drive the stream workflow from inside a durable @workflow.
|
|
30
|
+
result = await run_stream_workflow(agent_comms, stream_run_id=run_id)
|
|
31
|
+
return result.step_results
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Key Characteristics
|
|
35
|
+
|
|
36
|
+
<CardGroup cols={2}>
|
|
37
|
+
<Card title="Reactive" icon="bolt">
|
|
38
|
+
Steps react to signals arriving on the stream instead of running top-to-bottom.
|
|
39
|
+
</Card>
|
|
40
|
+
<Card title="Long-lived" icon="infinity">
|
|
41
|
+
A stream workflow stays alive until every subscribed step reaches a terminal state.
|
|
42
|
+
</Card>
|
|
43
|
+
<Card title="Durable Suspension" icon="pause">
|
|
44
|
+
The parent `@workflow` is released via `hook()` while steps wait for signals — no worker is pinned.
|
|
45
|
+
</Card>
|
|
46
|
+
<Card title="Scoped" icon="layer-group">
|
|
47
|
+
Each `stream_run_id` isolates its own set of subscriptions, signals, and results.
|
|
48
|
+
</Card>
|
|
49
|
+
</CardGroup>
|
|
50
|
+
|
|
51
|
+
## When to Use
|
|
52
|
+
|
|
53
|
+
Use a stream workflow when your process is **event-driven and multi-actor** — e.g. a planner step waiting for task results from N worker steps, a supervisor loop waiting on human-in-the-loop feedback, or long-running agents that react to external signals over hours or days.
|
|
54
|
+
|
|
55
|
+
Use a regular `@workflow` when your process is a linear sequence of steps that run to completion.
|
|
56
|
+
|
|
57
|
+
| Feature | `@workflow` | `@stream_workflow` |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| Execution model | Top-to-bottom | Signal-driven, reactive |
|
|
60
|
+
| Step activation | Explicit `await step()` | Subscription to signal types |
|
|
61
|
+
| Termination | Return from function | All steps reach terminal state |
|
|
62
|
+
| Suspension | `sleep` / `hook` | `hook` + signal arrival |
|
|
63
|
+
| Best for | Sequential business logic | Agents, multi-actor coordination, HITL loops |
|
|
64
|
+
|
|
65
|
+
## Defining a Stream Workflow
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from pyworkflow import stream_workflow, emit
|
|
69
|
+
|
|
70
|
+
@stream_workflow(name="order_pipeline")
|
|
71
|
+
async def order_pipeline():
|
|
72
|
+
"""
|
|
73
|
+
The body runs once at startup, after all @stream_step subscriptions
|
|
74
|
+
for this stream have been materialized. Use it to emit the initial
|
|
75
|
+
signal that kicks the pipeline off.
|
|
76
|
+
"""
|
|
77
|
+
await emit("order_pipeline", "pipeline.started", {})
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
<Note>
|
|
81
|
+
The stream name defaults to the function name when `name=` is omitted. Every `@stream_step` with matching `stream="..."` subscribes to this channel.
|
|
82
|
+
</Note>
|
|
83
|
+
|
|
84
|
+
## Running from a Parent Workflow
|
|
85
|
+
|
|
86
|
+
Stream workflows are driven from inside a regular `@workflow` via `run_stream_workflow()`. This is what gives them durability: the parent workflow records the stream run, suspends via `hook()` between signals, and the dispatcher resumes the parent once the stream reaches a terminal aggregate state.
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from pyworkflow import workflow, run_stream_workflow
|
|
90
|
+
from pyworkflow.streams import StreamWorkflowResult
|
|
91
|
+
|
|
92
|
+
@workflow()
|
|
93
|
+
async def agent_run(job_id: str):
|
|
94
|
+
async def init():
|
|
95
|
+
# Optional: seed initial state before the stream body runs
|
|
96
|
+
await seed_job(job_id)
|
|
97
|
+
|
|
98
|
+
result: StreamWorkflowResult = await run_stream_workflow(
|
|
99
|
+
order_pipeline,
|
|
100
|
+
stream_run_id=f"stream_{job_id}",
|
|
101
|
+
init=init,
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
# Read step outputs published via set_result()
|
|
105
|
+
planner_output = result.get_result("planner")
|
|
106
|
+
return {"job_id": job_id, "planner": planner_output}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Signature
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
await run_stream_workflow(
|
|
113
|
+
stream_workflow_func, # the @stream_workflow-decorated function
|
|
114
|
+
*,
|
|
115
|
+
stream_run_id: str, # unique scope key for this run
|
|
116
|
+
init: Callable | None = None, # optional async init callback
|
|
117
|
+
storage = None, # defaults to context / global config
|
|
118
|
+
) -> StreamWorkflowResult
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Aggregate Lifecycle
|
|
122
|
+
|
|
123
|
+
A stream run is in one of three aggregate states derived from its subscription rows:
|
|
124
|
+
|
|
125
|
+
| Aggregate | Condition | Parent workflow behavior |
|
|
126
|
+
|---|---|---|
|
|
127
|
+
| `running` | At least one step is `waiting` or `running` | Parent is suspended via `hook()`, worker released |
|
|
128
|
+
| `completed` | Every step is `terminated` | `run_stream_workflow` returns `StreamWorkflowResult(status="completed")` |
|
|
129
|
+
| `suspended` | At least one step `suspended`, none `waiting`/`running` | Parent re-raises `SuspensionSignal` (bubbles up HITL) |
|
|
130
|
+
|
|
131
|
+
```
|
|
132
|
+
run_stream_workflow(...)
|
|
133
|
+
│
|
|
134
|
+
▼
|
|
135
|
+
┌────────────────────────┐
|
|
136
|
+
│ Ensure subscriptions │
|
|
137
|
+
│ exist for every step │
|
|
138
|
+
└───────┬────────────────┘
|
|
139
|
+
│
|
|
140
|
+
▼
|
|
141
|
+
┌────────────────────────┐
|
|
142
|
+
│ Run init() + body() │ (first run only)
|
|
143
|
+
└───────┬────────────────┘
|
|
144
|
+
│
|
|
145
|
+
▼
|
|
146
|
+
┌────────────────────────┐
|
|
147
|
+
│ Compute aggregate │
|
|
148
|
+
└───────┬────────────────┘
|
|
149
|
+
│
|
|
150
|
+
├─ "completed" ──────► Return StreamWorkflowResult
|
|
151
|
+
│
|
|
152
|
+
├─ "suspended" ──────► Raise SuspensionSignal (HITL)
|
|
153
|
+
│
|
|
154
|
+
└─ "running"
|
|
155
|
+
│
|
|
156
|
+
▼
|
|
157
|
+
┌────────────────────────┐
|
|
158
|
+
│ await hook(...) │ Parent worker released
|
|
159
|
+
│ Record parent link │
|
|
160
|
+
└───────┬────────────────┘
|
|
161
|
+
│ ... signals flow, steps execute ...
|
|
162
|
+
│
|
|
163
|
+
▼
|
|
164
|
+
┌──────────────────────────────┐
|
|
165
|
+
│ Dispatcher sees terminal agg │
|
|
166
|
+
│ Calls resume_hook(token) │
|
|
167
|
+
└───────┬──────────────────────┘
|
|
168
|
+
│
|
|
169
|
+
▼
|
|
170
|
+
Parent re-enters run_stream_workflow,
|
|
171
|
+
recomputes aggregate, returns or re-raises.
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## StreamWorkflowResult
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
@dataclass
|
|
178
|
+
class StreamWorkflowResult:
|
|
179
|
+
status: str # "completed"
|
|
180
|
+
step_states: dict[str, str] # step_run_id -> final status
|
|
181
|
+
step_results: dict[str, Any] # step_run_id -> set_result(...) payload
|
|
182
|
+
|
|
183
|
+
def get_result(self, step_name: str) -> Any:
|
|
184
|
+
"""Look up a result by step name (matches against step_run_id)."""
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
Any value a step published via `await set_result(...)` in its lifecycle body is attached to the subscription row and surfaced here — the parent workflow does not need to read checkpoints.
|
|
188
|
+
|
|
189
|
+
## Backend Support
|
|
190
|
+
|
|
191
|
+
Stream workflows are fully supported on:
|
|
192
|
+
|
|
193
|
+
- **PostgreSQL** — the primary target (migrations v5/v6/v7 add the required columns)
|
|
194
|
+
- **Citus** — inherits from Postgres, with reference-table distribution
|
|
195
|
+
- **InMemory** — for unit tests
|
|
196
|
+
|
|
197
|
+
Other SQL backends (SQLite, MySQL) and NoSQL backends (DynamoDB, Cassandra) accept the stream API for signature compatibility but do **not** scope subscriptions by `stream_run_id`. Running concurrent stream runs on those backends will cross-contaminate subscription state — stick to Postgres/Citus for production streams.
|
|
198
|
+
|
|
199
|
+
## Scheduled Signals
|
|
200
|
+
|
|
201
|
+
Use `schedule_signal()` to emit a signal after a delay. A Celery beat task (`pyworkflow.streams.drain_scheduled_signals`, runs every 2s) polls the `scheduled_signals` table and emits due rows.
|
|
202
|
+
|
|
203
|
+
```python
|
|
204
|
+
from pyworkflow.streams import schedule_signal
|
|
205
|
+
|
|
206
|
+
await schedule_signal(
|
|
207
|
+
stream_id="agent_comms",
|
|
208
|
+
signal_type="supervisor.wakeup",
|
|
209
|
+
payload={"reason": "periodic_check"},
|
|
210
|
+
delay_seconds=300, # 5 minutes
|
|
211
|
+
stream_run_id="stream_job_42",
|
|
212
|
+
)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
This replaces in-process polling loops inside stream workflows — the scheduler is durable across worker restarts.
|
|
216
|
+
|
|
217
|
+
## Related Concepts
|
|
218
|
+
|
|
219
|
+
- [Stream Steps](/concepts/stream-steps) — the reactive units that subscribe to streams
|
|
220
|
+
- [Hooks](/concepts/hooks) — stream workflows use `hook()` internally for parent suspension
|
|
221
|
+
- [Workflows](/concepts/workflows) — stream workflows are driven from inside regular `@workflow`s
|
|
@@ -7,7 +7,7 @@ packages = [{include = "pyworkflow"}]
|
|
|
7
7
|
|
|
8
8
|
[project]
|
|
9
9
|
name = "pyworkflow-engine"
|
|
10
|
-
version = "0.2.
|
|
10
|
+
version = "0.2.8"
|
|
11
11
|
description = "A Python implementation of durable, event-sourced workflows inspired by Vercel Workflow"
|
|
12
12
|
readme = "README.md"
|
|
13
13
|
requires-python = ">=3.11"
|
|
@@ -116,22 +116,16 @@ class CitusMigrationRunner(PostgresMigrationRunner):
|
|
|
116
116
|
# so this is a no-op but we still record the version.
|
|
117
117
|
pass
|
|
118
118
|
elif migration.version == 5:
|
|
119
|
-
# V5: Add stream_run_id to stream_subscriptions + scheduled_signals table
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
AND column_name = 'stream_run_id'
|
|
130
|
-
) THEN
|
|
131
|
-
ALTER TABLE stream_subscriptions ADD COLUMN stream_run_id TEXT NULL;
|
|
132
|
-
END IF;
|
|
133
|
-
END $$
|
|
134
|
-
""")
|
|
119
|
+
# V5: Add stream_run_id to stream_subscriptions + scheduled_signals table.
|
|
120
|
+
# IMPORTANT: Use top-level ALTER (NOT inside a DO $$ block) so Citus
|
|
121
|
+
# propagates the DDL to all worker shards via the ProcessUtility hook.
|
|
122
|
+
# DDL inside PL/pgSQL DO blocks is executed via SPI and bypasses the
|
|
123
|
+
# hook, which would leave worker shards missing the column while the
|
|
124
|
+
# coordinator catalog appears correct.
|
|
125
|
+
await conn.execute(
|
|
126
|
+
"ALTER TABLE stream_subscriptions "
|
|
127
|
+
"ADD COLUMN IF NOT EXISTS stream_run_id TEXT NULL"
|
|
128
|
+
)
|
|
135
129
|
await conn.execute(
|
|
136
130
|
"CREATE INDEX IF NOT EXISTS idx_subscriptions_stream_run "
|
|
137
131
|
"ON stream_subscriptions(stream_id, stream_run_id)"
|
|
@@ -164,41 +158,22 @@ class CitusMigrationRunner(PostgresMigrationRunner):
|
|
|
164
158
|
if not already:
|
|
165
159
|
await conn.execute("SELECT create_reference_table('scheduled_signals')")
|
|
166
160
|
elif migration.version == 6:
|
|
167
|
-
# V6: Add parent_run_id + parent_hook_token to stream_subscriptions
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
AND column_name = 'parent_run_id'
|
|
178
|
-
) THEN
|
|
179
|
-
ALTER TABLE stream_subscriptions
|
|
180
|
-
ADD COLUMN parent_run_id TEXT NULL,
|
|
181
|
-
ADD COLUMN parent_hook_token TEXT NULL;
|
|
182
|
-
END IF;
|
|
183
|
-
END $$
|
|
184
|
-
""")
|
|
161
|
+
# V6: Add parent_run_id + parent_hook_token to stream_subscriptions.
|
|
162
|
+
# Top-level ALTERs so Citus propagates to all worker shards (see V5 note).
|
|
163
|
+
await conn.execute(
|
|
164
|
+
"ALTER TABLE stream_subscriptions "
|
|
165
|
+
"ADD COLUMN IF NOT EXISTS parent_run_id TEXT NULL"
|
|
166
|
+
)
|
|
167
|
+
await conn.execute(
|
|
168
|
+
"ALTER TABLE stream_subscriptions "
|
|
169
|
+
"ADD COLUMN IF NOT EXISTS parent_hook_token TEXT NULL"
|
|
170
|
+
)
|
|
185
171
|
elif migration.version == 7:
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
WHERE table_name = 'stream_subscriptions'
|
|
192
|
-
) AND NOT EXISTS (
|
|
193
|
-
SELECT 1 FROM information_schema.columns
|
|
194
|
-
WHERE table_name = 'stream_subscriptions'
|
|
195
|
-
AND column_name = 'result'
|
|
196
|
-
) THEN
|
|
197
|
-
ALTER TABLE stream_subscriptions
|
|
198
|
-
ADD COLUMN result JSONB NULL;
|
|
199
|
-
END IF;
|
|
200
|
-
END $$
|
|
201
|
-
""")
|
|
172
|
+
# V7: Add result column. Top-level ALTER for Citus propagation (see V5 note).
|
|
173
|
+
await conn.execute(
|
|
174
|
+
"ALTER TABLE stream_subscriptions "
|
|
175
|
+
"ADD COLUMN IF NOT EXISTS result JSONB NULL"
|
|
176
|
+
)
|
|
202
177
|
elif migration.up_func:
|
|
203
178
|
await migration.up_func(conn)
|
|
204
179
|
elif migration.up_sql and migration.up_sql != "SELECT 1":
|