pyworkflow-engine 0.1.7__py3-none-any.whl
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.
- dashboard/backend/app/__init__.py +1 -0
- dashboard/backend/app/config.py +32 -0
- dashboard/backend/app/controllers/__init__.py +6 -0
- dashboard/backend/app/controllers/run_controller.py +86 -0
- dashboard/backend/app/controllers/workflow_controller.py +33 -0
- dashboard/backend/app/dependencies/__init__.py +5 -0
- dashboard/backend/app/dependencies/storage.py +50 -0
- dashboard/backend/app/repositories/__init__.py +6 -0
- dashboard/backend/app/repositories/run_repository.py +80 -0
- dashboard/backend/app/repositories/workflow_repository.py +27 -0
- dashboard/backend/app/rest/__init__.py +8 -0
- dashboard/backend/app/rest/v1/__init__.py +12 -0
- dashboard/backend/app/rest/v1/health.py +33 -0
- dashboard/backend/app/rest/v1/runs.py +133 -0
- dashboard/backend/app/rest/v1/workflows.py +41 -0
- dashboard/backend/app/schemas/__init__.py +23 -0
- dashboard/backend/app/schemas/common.py +16 -0
- dashboard/backend/app/schemas/event.py +24 -0
- dashboard/backend/app/schemas/hook.py +25 -0
- dashboard/backend/app/schemas/run.py +54 -0
- dashboard/backend/app/schemas/step.py +28 -0
- dashboard/backend/app/schemas/workflow.py +31 -0
- dashboard/backend/app/server.py +87 -0
- dashboard/backend/app/services/__init__.py +6 -0
- dashboard/backend/app/services/run_service.py +240 -0
- dashboard/backend/app/services/workflow_service.py +155 -0
- dashboard/backend/main.py +18 -0
- docs/concepts/cancellation.mdx +362 -0
- docs/concepts/continue-as-new.mdx +434 -0
- docs/concepts/events.mdx +266 -0
- docs/concepts/fault-tolerance.mdx +370 -0
- docs/concepts/hooks.mdx +552 -0
- docs/concepts/limitations.mdx +167 -0
- docs/concepts/schedules.mdx +775 -0
- docs/concepts/sleep.mdx +312 -0
- docs/concepts/steps.mdx +301 -0
- docs/concepts/workflows.mdx +255 -0
- docs/guides/cli.mdx +942 -0
- docs/guides/configuration.mdx +560 -0
- docs/introduction.mdx +155 -0
- docs/quickstart.mdx +279 -0
- examples/__init__.py +1 -0
- examples/celery/__init__.py +1 -0
- examples/celery/durable/docker-compose.yml +55 -0
- examples/celery/durable/pyworkflow.config.yaml +12 -0
- examples/celery/durable/workflows/__init__.py +122 -0
- examples/celery/durable/workflows/basic.py +87 -0
- examples/celery/durable/workflows/batch_processing.py +102 -0
- examples/celery/durable/workflows/cancellation.py +273 -0
- examples/celery/durable/workflows/child_workflow_patterns.py +240 -0
- examples/celery/durable/workflows/child_workflows.py +202 -0
- examples/celery/durable/workflows/continue_as_new.py +260 -0
- examples/celery/durable/workflows/fault_tolerance.py +210 -0
- examples/celery/durable/workflows/hooks.py +211 -0
- examples/celery/durable/workflows/idempotency.py +112 -0
- examples/celery/durable/workflows/long_running.py +99 -0
- examples/celery/durable/workflows/retries.py +101 -0
- examples/celery/durable/workflows/schedules.py +209 -0
- examples/celery/transient/01_basic_workflow.py +91 -0
- examples/celery/transient/02_fault_tolerance.py +257 -0
- examples/celery/transient/__init__.py +20 -0
- examples/celery/transient/pyworkflow.config.yaml +25 -0
- examples/local/__init__.py +1 -0
- examples/local/durable/01_basic_workflow.py +94 -0
- examples/local/durable/02_file_storage.py +132 -0
- examples/local/durable/03_retries.py +169 -0
- examples/local/durable/04_long_running.py +119 -0
- examples/local/durable/05_event_log.py +145 -0
- examples/local/durable/06_idempotency.py +148 -0
- examples/local/durable/07_hooks.py +334 -0
- examples/local/durable/08_cancellation.py +233 -0
- examples/local/durable/09_child_workflows.py +198 -0
- examples/local/durable/10_child_workflow_patterns.py +265 -0
- examples/local/durable/11_continue_as_new.py +249 -0
- examples/local/durable/12_schedules.py +198 -0
- examples/local/durable/__init__.py +1 -0
- examples/local/transient/01_quick_tasks.py +87 -0
- examples/local/transient/02_retries.py +130 -0
- examples/local/transient/03_sleep.py +141 -0
- examples/local/transient/__init__.py +1 -0
- pyworkflow/__init__.py +256 -0
- pyworkflow/aws/__init__.py +68 -0
- pyworkflow/aws/context.py +234 -0
- pyworkflow/aws/handler.py +184 -0
- pyworkflow/aws/testing.py +310 -0
- pyworkflow/celery/__init__.py +41 -0
- pyworkflow/celery/app.py +198 -0
- pyworkflow/celery/scheduler.py +315 -0
- pyworkflow/celery/tasks.py +1746 -0
- pyworkflow/cli/__init__.py +132 -0
- pyworkflow/cli/__main__.py +6 -0
- pyworkflow/cli/commands/__init__.py +1 -0
- pyworkflow/cli/commands/hooks.py +640 -0
- pyworkflow/cli/commands/quickstart.py +495 -0
- pyworkflow/cli/commands/runs.py +773 -0
- pyworkflow/cli/commands/scheduler.py +130 -0
- pyworkflow/cli/commands/schedules.py +794 -0
- pyworkflow/cli/commands/setup.py +703 -0
- pyworkflow/cli/commands/worker.py +413 -0
- pyworkflow/cli/commands/workflows.py +1257 -0
- pyworkflow/cli/output/__init__.py +1 -0
- pyworkflow/cli/output/formatters.py +321 -0
- pyworkflow/cli/output/styles.py +121 -0
- pyworkflow/cli/utils/__init__.py +1 -0
- pyworkflow/cli/utils/async_helpers.py +30 -0
- pyworkflow/cli/utils/config.py +130 -0
- pyworkflow/cli/utils/config_generator.py +344 -0
- pyworkflow/cli/utils/discovery.py +53 -0
- pyworkflow/cli/utils/docker_manager.py +651 -0
- pyworkflow/cli/utils/interactive.py +364 -0
- pyworkflow/cli/utils/storage.py +115 -0
- pyworkflow/config.py +329 -0
- pyworkflow/context/__init__.py +63 -0
- pyworkflow/context/aws.py +230 -0
- pyworkflow/context/base.py +416 -0
- pyworkflow/context/local.py +930 -0
- pyworkflow/context/mock.py +381 -0
- pyworkflow/core/__init__.py +0 -0
- pyworkflow/core/exceptions.py +353 -0
- pyworkflow/core/registry.py +313 -0
- pyworkflow/core/scheduled.py +328 -0
- pyworkflow/core/step.py +494 -0
- pyworkflow/core/workflow.py +294 -0
- pyworkflow/discovery.py +248 -0
- pyworkflow/engine/__init__.py +0 -0
- pyworkflow/engine/events.py +879 -0
- pyworkflow/engine/executor.py +682 -0
- pyworkflow/engine/replay.py +273 -0
- pyworkflow/observability/__init__.py +19 -0
- pyworkflow/observability/logging.py +234 -0
- pyworkflow/primitives/__init__.py +33 -0
- pyworkflow/primitives/child_handle.py +174 -0
- pyworkflow/primitives/child_workflow.py +372 -0
- pyworkflow/primitives/continue_as_new.py +101 -0
- pyworkflow/primitives/define_hook.py +150 -0
- pyworkflow/primitives/hooks.py +97 -0
- pyworkflow/primitives/resume_hook.py +210 -0
- pyworkflow/primitives/schedule.py +545 -0
- pyworkflow/primitives/shield.py +96 -0
- pyworkflow/primitives/sleep.py +100 -0
- pyworkflow/runtime/__init__.py +21 -0
- pyworkflow/runtime/base.py +179 -0
- pyworkflow/runtime/celery.py +310 -0
- pyworkflow/runtime/factory.py +101 -0
- pyworkflow/runtime/local.py +706 -0
- pyworkflow/scheduler/__init__.py +9 -0
- pyworkflow/scheduler/local.py +248 -0
- pyworkflow/serialization/__init__.py +0 -0
- pyworkflow/serialization/decoder.py +146 -0
- pyworkflow/serialization/encoder.py +162 -0
- pyworkflow/storage/__init__.py +54 -0
- pyworkflow/storage/base.py +612 -0
- pyworkflow/storage/config.py +185 -0
- pyworkflow/storage/dynamodb.py +1315 -0
- pyworkflow/storage/file.py +827 -0
- pyworkflow/storage/memory.py +549 -0
- pyworkflow/storage/postgres.py +1161 -0
- pyworkflow/storage/schemas.py +486 -0
- pyworkflow/storage/sqlite.py +1136 -0
- pyworkflow/utils/__init__.py +0 -0
- pyworkflow/utils/duration.py +177 -0
- pyworkflow/utils/schedule.py +391 -0
- pyworkflow_engine-0.1.7.dist-info/METADATA +687 -0
- pyworkflow_engine-0.1.7.dist-info/RECORD +196 -0
- pyworkflow_engine-0.1.7.dist-info/WHEEL +5 -0
- pyworkflow_engine-0.1.7.dist-info/entry_points.txt +2 -0
- pyworkflow_engine-0.1.7.dist-info/licenses/LICENSE +21 -0
- pyworkflow_engine-0.1.7.dist-info/top_level.txt +5 -0
- tests/examples/__init__.py +0 -0
- tests/integration/__init__.py +0 -0
- tests/integration/test_cancellation.py +330 -0
- tests/integration/test_child_workflows.py +439 -0
- tests/integration/test_continue_as_new.py +428 -0
- tests/integration/test_dynamodb_storage.py +1146 -0
- tests/integration/test_fault_tolerance.py +369 -0
- tests/integration/test_schedule_storage.py +484 -0
- tests/unit/__init__.py +0 -0
- tests/unit/backends/__init__.py +1 -0
- tests/unit/backends/test_dynamodb_storage.py +1554 -0
- tests/unit/backends/test_postgres_storage.py +1281 -0
- tests/unit/backends/test_sqlite_storage.py +1460 -0
- tests/unit/conftest.py +41 -0
- tests/unit/test_cancellation.py +364 -0
- tests/unit/test_child_workflows.py +680 -0
- tests/unit/test_continue_as_new.py +441 -0
- tests/unit/test_event_limits.py +316 -0
- tests/unit/test_executor.py +320 -0
- tests/unit/test_fault_tolerance.py +334 -0
- tests/unit/test_hooks.py +495 -0
- tests/unit/test_registry.py +261 -0
- tests/unit/test_replay.py +420 -0
- tests/unit/test_schedule_schemas.py +285 -0
- tests/unit/test_schedule_utils.py +286 -0
- tests/unit/test_scheduled_workflow.py +274 -0
- tests/unit/test_step.py +353 -0
- tests/unit/test_workflow.py +243 -0
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Workflow run response schemas."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from pydantic import BaseModel
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class RunResponse(BaseModel):
|
|
10
|
+
"""Response model for a workflow run."""
|
|
11
|
+
|
|
12
|
+
run_id: str
|
|
13
|
+
workflow_name: str
|
|
14
|
+
status: str
|
|
15
|
+
created_at: datetime
|
|
16
|
+
started_at: datetime | None = None
|
|
17
|
+
completed_at: datetime | None = None
|
|
18
|
+
duration_seconds: float | None = None
|
|
19
|
+
error: str | None = None
|
|
20
|
+
recovery_attempts: int = 0
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RunDetailResponse(RunResponse):
|
|
24
|
+
"""Detailed response model for a workflow run."""
|
|
25
|
+
|
|
26
|
+
input_args: Any | None = None
|
|
27
|
+
input_kwargs: Any | None = None
|
|
28
|
+
result: Any | None = None
|
|
29
|
+
metadata: dict[str, Any] = {}
|
|
30
|
+
max_duration: str | None = None
|
|
31
|
+
max_recovery_attempts: int = 3
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class RunListResponse(BaseModel):
|
|
35
|
+
"""Response model for listing runs."""
|
|
36
|
+
|
|
37
|
+
items: list[RunResponse]
|
|
38
|
+
count: int
|
|
39
|
+
limit: int = 100
|
|
40
|
+
next_cursor: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class StartRunRequest(BaseModel):
|
|
44
|
+
"""Request model for starting a new workflow run."""
|
|
45
|
+
|
|
46
|
+
workflow_name: str
|
|
47
|
+
kwargs: dict[str, Any] = {}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class StartRunResponse(BaseModel):
|
|
51
|
+
"""Response model for a newly started workflow run."""
|
|
52
|
+
|
|
53
|
+
run_id: str
|
|
54
|
+
workflow_name: str
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Step execution response schemas."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class StepResponse(BaseModel):
|
|
9
|
+
"""Response model for a step execution."""
|
|
10
|
+
|
|
11
|
+
step_id: str
|
|
12
|
+
run_id: str
|
|
13
|
+
step_name: str
|
|
14
|
+
status: str
|
|
15
|
+
attempt: int = 1
|
|
16
|
+
max_retries: int = 3
|
|
17
|
+
created_at: datetime
|
|
18
|
+
started_at: datetime | None = None
|
|
19
|
+
completed_at: datetime | None = None
|
|
20
|
+
duration_seconds: float | None = None
|
|
21
|
+
error: str | None = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StepListResponse(BaseModel):
|
|
25
|
+
"""Response model for listing steps."""
|
|
26
|
+
|
|
27
|
+
items: list[StepResponse]
|
|
28
|
+
count: int
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Workflow-related response schemas."""
|
|
2
|
+
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class WorkflowParameter(BaseModel):
|
|
9
|
+
"""Response model for a workflow parameter."""
|
|
10
|
+
|
|
11
|
+
name: str
|
|
12
|
+
type: str # "string", "number", "boolean", "object", "array", "any"
|
|
13
|
+
required: bool
|
|
14
|
+
default: Any | None = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class WorkflowResponse(BaseModel):
|
|
18
|
+
"""Response model for a registered workflow."""
|
|
19
|
+
|
|
20
|
+
name: str
|
|
21
|
+
description: str | None = None
|
|
22
|
+
max_duration: str | None = None
|
|
23
|
+
tags: list[str] = []
|
|
24
|
+
parameters: list[WorkflowParameter] = []
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class WorkflowListResponse(BaseModel):
|
|
28
|
+
"""Response model for listing workflows."""
|
|
29
|
+
|
|
30
|
+
items: list[WorkflowResponse]
|
|
31
|
+
count: int
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"""FastAPI application factory."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
from contextlib import asynccontextmanager
|
|
5
|
+
|
|
6
|
+
from fastapi import FastAPI
|
|
7
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
8
|
+
|
|
9
|
+
from app.config import settings
|
|
10
|
+
from app.rest import router as api_router
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _initialize_pyworkflow() -> None:
|
|
14
|
+
"""Initialize pyworkflow configuration and discover workflows.
|
|
15
|
+
|
|
16
|
+
Priority:
|
|
17
|
+
1. If pyworkflow_config_path is set, load from that path (includes discovery)
|
|
18
|
+
2. Otherwise, load from pyworkflow.config.yaml in cwd and discover workflows
|
|
19
|
+
"""
|
|
20
|
+
import pyworkflow
|
|
21
|
+
|
|
22
|
+
if settings.pyworkflow_config_path:
|
|
23
|
+
# configure_from_yaml automatically discovers workflows
|
|
24
|
+
pyworkflow.configure_from_yaml(settings.pyworkflow_config_path)
|
|
25
|
+
else:
|
|
26
|
+
# Load config without discovery, then discover from cwd config
|
|
27
|
+
pyworkflow.get_config()
|
|
28
|
+
# Discover workflows from pyworkflow.config.yaml in cwd
|
|
29
|
+
pyworkflow.discover_workflows()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@asynccontextmanager
|
|
33
|
+
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
34
|
+
"""Application lifespan handler.
|
|
35
|
+
|
|
36
|
+
Startup: Initialize pyworkflow configuration
|
|
37
|
+
Shutdown: (cleanup if needed in future)
|
|
38
|
+
"""
|
|
39
|
+
# Startup
|
|
40
|
+
_initialize_pyworkflow()
|
|
41
|
+
|
|
42
|
+
# Reset cached storage instance to ensure fresh initialization
|
|
43
|
+
from app.dependencies.storage import get_storage, reset_storage_cache
|
|
44
|
+
|
|
45
|
+
reset_storage_cache()
|
|
46
|
+
|
|
47
|
+
# Initialize and connect storage backend
|
|
48
|
+
storage = await get_storage()
|
|
49
|
+
if hasattr(storage, "connect"):
|
|
50
|
+
await storage.connect()
|
|
51
|
+
if hasattr(storage, "initialize"):
|
|
52
|
+
await storage.initialize()
|
|
53
|
+
|
|
54
|
+
yield
|
|
55
|
+
|
|
56
|
+
# Shutdown - disconnect storage
|
|
57
|
+
if hasattr(storage, "disconnect"):
|
|
58
|
+
await storage.disconnect()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_app() -> FastAPI:
|
|
62
|
+
"""Create and configure the FastAPI application."""
|
|
63
|
+
app = FastAPI(
|
|
64
|
+
title="PyWorkflow Dashboard API",
|
|
65
|
+
description="REST API for monitoring PyWorkflow workflows",
|
|
66
|
+
version="0.1.0",
|
|
67
|
+
docs_url="/docs",
|
|
68
|
+
redoc_url="/redoc",
|
|
69
|
+
lifespan=lifespan,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
# Configure CORS
|
|
73
|
+
app.add_middleware(
|
|
74
|
+
CORSMiddleware,
|
|
75
|
+
allow_origins=settings.cors_origins,
|
|
76
|
+
allow_credentials=True,
|
|
77
|
+
allow_methods=["*"],
|
|
78
|
+
allow_headers=["*"],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Include API routes
|
|
82
|
+
app.include_router(api_router)
|
|
83
|
+
|
|
84
|
+
return app
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
app = create_app()
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Service layer for workflow run operations."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from datetime import UTC, datetime
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
import pyworkflow
|
|
8
|
+
from app.repositories.run_repository import RunRepository
|
|
9
|
+
from app.schemas.event import EventListResponse, EventResponse
|
|
10
|
+
from app.schemas.run import (
|
|
11
|
+
RunDetailResponse,
|
|
12
|
+
RunListResponse,
|
|
13
|
+
RunResponse,
|
|
14
|
+
StartRunRequest,
|
|
15
|
+
StartRunResponse,
|
|
16
|
+
)
|
|
17
|
+
from pyworkflow.storage.schemas import RunStatus, WorkflowRun
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RunService:
|
|
21
|
+
"""Service for workflow run-related business logic."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, repository: RunRepository):
|
|
24
|
+
"""Initialize with run repository.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
repository: RunRepository instance.
|
|
28
|
+
"""
|
|
29
|
+
self.repository = repository
|
|
30
|
+
|
|
31
|
+
async def list_runs(
|
|
32
|
+
self,
|
|
33
|
+
query: str | None = None,
|
|
34
|
+
status: str | None = None,
|
|
35
|
+
start_time: datetime | None = None,
|
|
36
|
+
end_time: datetime | None = None,
|
|
37
|
+
limit: int = 100,
|
|
38
|
+
cursor: str | None = None,
|
|
39
|
+
) -> RunListResponse:
|
|
40
|
+
"""List workflow runs with optional filtering and cursor-based pagination.
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
query: Case-insensitive search in workflow name and input kwargs.
|
|
44
|
+
status: Filter by status string.
|
|
45
|
+
start_time: Filter runs started at or after this time.
|
|
46
|
+
end_time: Filter runs started before this time.
|
|
47
|
+
limit: Maximum number of results.
|
|
48
|
+
cursor: Run ID to start after (for pagination).
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
RunListResponse with list of runs and next_cursor.
|
|
52
|
+
"""
|
|
53
|
+
status_enum = RunStatus(status) if status else None
|
|
54
|
+
|
|
55
|
+
runs, next_cursor = await self.repository.list_runs(
|
|
56
|
+
query=query,
|
|
57
|
+
status=status_enum,
|
|
58
|
+
start_time=start_time,
|
|
59
|
+
end_time=end_time,
|
|
60
|
+
limit=limit,
|
|
61
|
+
cursor=cursor,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
items = [self._run_to_response(run) for run in runs]
|
|
65
|
+
|
|
66
|
+
return RunListResponse(
|
|
67
|
+
items=items,
|
|
68
|
+
count=len(items),
|
|
69
|
+
limit=limit,
|
|
70
|
+
next_cursor=next_cursor,
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
async def get_run(self, run_id: str) -> RunDetailResponse | None:
|
|
74
|
+
"""Get detailed information about a workflow run.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
run_id: The run ID.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
RunDetailResponse if found, None otherwise.
|
|
81
|
+
"""
|
|
82
|
+
run = await self.repository.get_run(run_id)
|
|
83
|
+
|
|
84
|
+
if run is None:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
return self._run_to_detail_response(run)
|
|
88
|
+
|
|
89
|
+
async def get_events(self, run_id: str) -> EventListResponse:
|
|
90
|
+
"""Get all events for a workflow run.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
run_id: The run ID.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
EventListResponse with list of events.
|
|
97
|
+
"""
|
|
98
|
+
events = await self.repository.get_events(run_id)
|
|
99
|
+
|
|
100
|
+
items = [
|
|
101
|
+
EventResponse(
|
|
102
|
+
event_id=event.event_id,
|
|
103
|
+
run_id=event.run_id,
|
|
104
|
+
type=event.type.value,
|
|
105
|
+
timestamp=event.timestamp,
|
|
106
|
+
sequence=event.sequence,
|
|
107
|
+
data=event.data,
|
|
108
|
+
)
|
|
109
|
+
for event in events
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
return EventListResponse(
|
|
113
|
+
items=items,
|
|
114
|
+
count=len(items),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
def _run_to_response(self, run: WorkflowRun) -> RunResponse:
|
|
118
|
+
"""Convert WorkflowRun to RunResponse.
|
|
119
|
+
|
|
120
|
+
Args:
|
|
121
|
+
run: WorkflowRun instance.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
RunResponse instance.
|
|
125
|
+
"""
|
|
126
|
+
return RunResponse(
|
|
127
|
+
run_id=run.run_id,
|
|
128
|
+
workflow_name=run.workflow_name,
|
|
129
|
+
status=run.status.value,
|
|
130
|
+
created_at=run.created_at,
|
|
131
|
+
started_at=run.started_at,
|
|
132
|
+
completed_at=run.completed_at,
|
|
133
|
+
duration_seconds=self._calculate_duration(run.started_at, run.completed_at),
|
|
134
|
+
error=run.error,
|
|
135
|
+
recovery_attempts=run.recovery_attempts,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
def _run_to_detail_response(self, run: WorkflowRun) -> RunDetailResponse:
|
|
139
|
+
"""Convert WorkflowRun to RunDetailResponse.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
run: WorkflowRun instance.
|
|
143
|
+
|
|
144
|
+
Returns:
|
|
145
|
+
RunDetailResponse instance.
|
|
146
|
+
"""
|
|
147
|
+
# Parse JSON strings for input/result
|
|
148
|
+
input_args = self._safe_json_parse(run.input_args)
|
|
149
|
+
input_kwargs = self._safe_json_parse(run.input_kwargs)
|
|
150
|
+
result = self._safe_json_parse(run.result) if run.result else None
|
|
151
|
+
|
|
152
|
+
return RunDetailResponse(
|
|
153
|
+
run_id=run.run_id,
|
|
154
|
+
workflow_name=run.workflow_name,
|
|
155
|
+
status=run.status.value,
|
|
156
|
+
created_at=run.created_at,
|
|
157
|
+
started_at=run.started_at,
|
|
158
|
+
completed_at=run.completed_at,
|
|
159
|
+
duration_seconds=self._calculate_duration(run.started_at, run.completed_at),
|
|
160
|
+
error=run.error,
|
|
161
|
+
recovery_attempts=run.recovery_attempts,
|
|
162
|
+
input_args=input_args,
|
|
163
|
+
input_kwargs=input_kwargs,
|
|
164
|
+
result=result,
|
|
165
|
+
metadata=run.metadata,
|
|
166
|
+
max_duration=run.max_duration,
|
|
167
|
+
max_recovery_attempts=run.max_recovery_attempts,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
def _calculate_duration(
|
|
171
|
+
self,
|
|
172
|
+
started_at: datetime | None,
|
|
173
|
+
completed_at: datetime | None,
|
|
174
|
+
) -> float | None:
|
|
175
|
+
"""Calculate duration in seconds.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
started_at: Start timestamp.
|
|
179
|
+
completed_at: Completion timestamp.
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
Duration in seconds, or None if not calculable.
|
|
183
|
+
"""
|
|
184
|
+
if started_at is None:
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
end_time = completed_at or datetime.now(UTC)
|
|
188
|
+
|
|
189
|
+
# Handle timezone-naive datetimes
|
|
190
|
+
if started_at.tzinfo is None:
|
|
191
|
+
started_at = started_at.replace(tzinfo=UTC)
|
|
192
|
+
if end_time.tzinfo is None:
|
|
193
|
+
end_time = end_time.replace(tzinfo=UTC)
|
|
194
|
+
|
|
195
|
+
return (end_time - started_at).total_seconds()
|
|
196
|
+
|
|
197
|
+
def _safe_json_parse(self, value: str | None) -> Any:
|
|
198
|
+
"""Safely parse a JSON string.
|
|
199
|
+
|
|
200
|
+
Args:
|
|
201
|
+
value: JSON string or None.
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
Parsed value or the original string if parsing fails.
|
|
205
|
+
"""
|
|
206
|
+
if value is None:
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
try:
|
|
210
|
+
return json.loads(value)
|
|
211
|
+
except (json.JSONDecodeError, TypeError):
|
|
212
|
+
return value
|
|
213
|
+
|
|
214
|
+
async def start_run(self, request: StartRunRequest) -> StartRunResponse:
|
|
215
|
+
"""Start a new workflow run.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
request: The start run request containing workflow name and kwargs.
|
|
219
|
+
|
|
220
|
+
Returns:
|
|
221
|
+
StartRunResponse with run_id and workflow_name.
|
|
222
|
+
|
|
223
|
+
Raises:
|
|
224
|
+
ValueError: If workflow not found.
|
|
225
|
+
"""
|
|
226
|
+
# Get the workflow metadata
|
|
227
|
+
workflow_meta = pyworkflow.get_workflow(request.workflow_name)
|
|
228
|
+
if workflow_meta is None:
|
|
229
|
+
raise ValueError(f"Workflow '{request.workflow_name}' not found")
|
|
230
|
+
|
|
231
|
+
# Start the workflow using pyworkflow.start()
|
|
232
|
+
run_id = await pyworkflow.start(
|
|
233
|
+
workflow_meta.func,
|
|
234
|
+
**request.kwargs,
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
return StartRunResponse(
|
|
238
|
+
run_id=run_id,
|
|
239
|
+
workflow_name=request.workflow_name,
|
|
240
|
+
)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Service layer for workflow operations."""
|
|
2
|
+
|
|
3
|
+
import inspect
|
|
4
|
+
from typing import Any, get_origin, get_type_hints
|
|
5
|
+
|
|
6
|
+
from app.repositories.workflow_repository import WorkflowRepository
|
|
7
|
+
from app.schemas.workflow import (
|
|
8
|
+
WorkflowListResponse,
|
|
9
|
+
WorkflowParameter,
|
|
10
|
+
WorkflowResponse,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _get_type_name(type_hint: Any) -> str:
|
|
15
|
+
"""Convert a Python type hint to a simple type name for the frontend.
|
|
16
|
+
|
|
17
|
+
Args:
|
|
18
|
+
type_hint: The type hint to convert.
|
|
19
|
+
|
|
20
|
+
Returns:
|
|
21
|
+
A string representing the type ("string", "number", "boolean", "array", "object", "any").
|
|
22
|
+
"""
|
|
23
|
+
if type_hint is Any or type_hint is inspect.Parameter.empty:
|
|
24
|
+
return "any"
|
|
25
|
+
|
|
26
|
+
# Handle None type
|
|
27
|
+
if type_hint is type(None):
|
|
28
|
+
return "any"
|
|
29
|
+
|
|
30
|
+
# Get the origin for generic types (e.g., list[str] -> list)
|
|
31
|
+
origin = get_origin(type_hint)
|
|
32
|
+
if origin is not None:
|
|
33
|
+
if origin is list:
|
|
34
|
+
return "array"
|
|
35
|
+
if origin is dict:
|
|
36
|
+
return "object"
|
|
37
|
+
# Union types (Optional, etc.)
|
|
38
|
+
if origin is type(None):
|
|
39
|
+
return "any"
|
|
40
|
+
|
|
41
|
+
# Handle basic types
|
|
42
|
+
if hasattr(type_hint, "__name__"):
|
|
43
|
+
type_name = type_hint.__name__
|
|
44
|
+
if type_name == "str":
|
|
45
|
+
return "string"
|
|
46
|
+
if type_name in ("int", "float"):
|
|
47
|
+
return "number"
|
|
48
|
+
if type_name == "bool":
|
|
49
|
+
return "boolean"
|
|
50
|
+
if type_name == "list":
|
|
51
|
+
return "array"
|
|
52
|
+
if type_name == "dict":
|
|
53
|
+
return "object"
|
|
54
|
+
|
|
55
|
+
return "any"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _extract_workflow_parameters(func: Any) -> list[WorkflowParameter]:
|
|
59
|
+
"""Extract parameter information from a workflow function.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
func: The workflow function to inspect.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
List of WorkflowParameter objects.
|
|
66
|
+
"""
|
|
67
|
+
sig = inspect.signature(func)
|
|
68
|
+
params = []
|
|
69
|
+
|
|
70
|
+
# Try to get type hints
|
|
71
|
+
try:
|
|
72
|
+
hints = get_type_hints(func)
|
|
73
|
+
except Exception:
|
|
74
|
+
hints = {}
|
|
75
|
+
|
|
76
|
+
for param_name, param in sig.parameters.items():
|
|
77
|
+
# Skip *args and **kwargs
|
|
78
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
79
|
+
continue
|
|
80
|
+
|
|
81
|
+
type_hint = hints.get(param_name, Any)
|
|
82
|
+
has_default = param.default is not inspect.Parameter.empty
|
|
83
|
+
|
|
84
|
+
# Serialize the default value
|
|
85
|
+
default_value = None
|
|
86
|
+
if has_default:
|
|
87
|
+
default_value = param.default
|
|
88
|
+
|
|
89
|
+
param_info = WorkflowParameter(
|
|
90
|
+
name=param_name,
|
|
91
|
+
type=_get_type_name(type_hint),
|
|
92
|
+
required=not has_default,
|
|
93
|
+
default=default_value,
|
|
94
|
+
)
|
|
95
|
+
params.append(param_info)
|
|
96
|
+
|
|
97
|
+
return params
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class WorkflowService:
|
|
101
|
+
"""Service for workflow-related business logic."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, repository: WorkflowRepository):
|
|
104
|
+
"""Initialize with workflow repository.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
repository: WorkflowRepository instance.
|
|
108
|
+
"""
|
|
109
|
+
self.repository = repository
|
|
110
|
+
|
|
111
|
+
def list_workflows(self) -> WorkflowListResponse:
|
|
112
|
+
"""Get all registered workflows.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
WorkflowListResponse with list of workflows.
|
|
116
|
+
"""
|
|
117
|
+
workflows = self.repository.list_all()
|
|
118
|
+
|
|
119
|
+
items = [
|
|
120
|
+
WorkflowResponse(
|
|
121
|
+
name=name,
|
|
122
|
+
description=metadata.description,
|
|
123
|
+
max_duration=metadata.max_duration,
|
|
124
|
+
tags=metadata.tags or [],
|
|
125
|
+
parameters=_extract_workflow_parameters(metadata.original_func),
|
|
126
|
+
)
|
|
127
|
+
for name, metadata in workflows.items()
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
return WorkflowListResponse(
|
|
131
|
+
items=items,
|
|
132
|
+
count=len(items),
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
def get_workflow(self, name: str) -> WorkflowResponse | None:
|
|
136
|
+
"""Get a specific workflow by name.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
name: Workflow name.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
WorkflowResponse if found, None otherwise.
|
|
143
|
+
"""
|
|
144
|
+
metadata = self.repository.get_by_name(name)
|
|
145
|
+
|
|
146
|
+
if metadata is None:
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
return WorkflowResponse(
|
|
150
|
+
name=metadata.name,
|
|
151
|
+
description=metadata.description,
|
|
152
|
+
max_duration=metadata.max_duration,
|
|
153
|
+
tags=metadata.tags or [],
|
|
154
|
+
parameters=_extract_workflow_parameters(metadata.original_func),
|
|
155
|
+
)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""CLI entry point for the dashboard backend server."""
|
|
2
|
+
|
|
3
|
+
import uvicorn
|
|
4
|
+
from app.config import settings
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
"""Run the dashboard backend server."""
|
|
9
|
+
uvicorn.run(
|
|
10
|
+
"app.server:app",
|
|
11
|
+
host=settings.host,
|
|
12
|
+
port=settings.port,
|
|
13
|
+
reload=settings.debug,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if __name__ == "__main__":
|
|
18
|
+
main()
|