planar 0.5.0__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.
- planar/.__init__.py.un~ +0 -0
- planar/._version.py.un~ +0 -0
- planar/.app.py.un~ +0 -0
- planar/.cli.py.un~ +0 -0
- planar/.config.py.un~ +0 -0
- planar/.context.py.un~ +0 -0
- planar/.db.py.un~ +0 -0
- planar/.di.py.un~ +0 -0
- planar/.engine.py.un~ +0 -0
- planar/.files.py.un~ +0 -0
- planar/.log_context.py.un~ +0 -0
- planar/.log_metadata.py.un~ +0 -0
- planar/.logging.py.un~ +0 -0
- planar/.object_registry.py.un~ +0 -0
- planar/.otel.py.un~ +0 -0
- planar/.server.py.un~ +0 -0
- planar/.session.py.un~ +0 -0
- planar/.sqlalchemy.py.un~ +0 -0
- planar/.task_local.py.un~ +0 -0
- planar/.test_app.py.un~ +0 -0
- planar/.test_config.py.un~ +0 -0
- planar/.test_object_config.py.un~ +0 -0
- planar/.test_sqlalchemy.py.un~ +0 -0
- planar/.test_utils.py.un~ +0 -0
- planar/.util.py.un~ +0 -0
- planar/.utils.py.un~ +0 -0
- planar/__init__.py +26 -0
- planar/_version.py +1 -0
- planar/ai/.__init__.py.un~ +0 -0
- planar/ai/._models.py.un~ +0 -0
- planar/ai/.agent.py.un~ +0 -0
- planar/ai/.agent_utils.py.un~ +0 -0
- planar/ai/.events.py.un~ +0 -0
- planar/ai/.files.py.un~ +0 -0
- planar/ai/.models.py.un~ +0 -0
- planar/ai/.providers.py.un~ +0 -0
- planar/ai/.pydantic_ai.py.un~ +0 -0
- planar/ai/.pydantic_ai_agent.py.un~ +0 -0
- planar/ai/.pydantic_ai_provider.py.un~ +0 -0
- planar/ai/.step.py.un~ +0 -0
- planar/ai/.test_agent.py.un~ +0 -0
- planar/ai/.test_agent_serialization.py.un~ +0 -0
- planar/ai/.test_providers.py.un~ +0 -0
- planar/ai/.utils.py.un~ +0 -0
- planar/ai/__init__.py +15 -0
- planar/ai/agent.py +457 -0
- planar/ai/agent_utils.py +205 -0
- planar/ai/models.py +140 -0
- planar/ai/providers.py +1088 -0
- planar/ai/test_agent.py +1298 -0
- planar/ai/test_agent_serialization.py +229 -0
- planar/ai/test_providers.py +463 -0
- planar/ai/utils.py +102 -0
- planar/app.py +494 -0
- planar/cli.py +282 -0
- planar/config.py +544 -0
- planar/db/.db.py.un~ +0 -0
- planar/db/__init__.py +17 -0
- planar/db/alembic/env.py +136 -0
- planar/db/alembic/script.py.mako +28 -0
- planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
- planar/db/alembic.ini +128 -0
- planar/db/db.py +318 -0
- planar/files/.config.py.un~ +0 -0
- planar/files/.local.py.un~ +0 -0
- planar/files/.local_filesystem.py.un~ +0 -0
- planar/files/.model.py.un~ +0 -0
- planar/files/.models.py.un~ +0 -0
- planar/files/.s3.py.un~ +0 -0
- planar/files/.storage.py.un~ +0 -0
- planar/files/.test_files.py.un~ +0 -0
- planar/files/__init__.py +2 -0
- planar/files/models.py +162 -0
- planar/files/storage/.__init__.py.un~ +0 -0
- planar/files/storage/.base.py.un~ +0 -0
- planar/files/storage/.config.py.un~ +0 -0
- planar/files/storage/.context.py.un~ +0 -0
- planar/files/storage/.local_directory.py.un~ +0 -0
- planar/files/storage/.test_local_directory.py.un~ +0 -0
- planar/files/storage/.test_s3.py.un~ +0 -0
- planar/files/storage/base.py +61 -0
- planar/files/storage/config.py +44 -0
- planar/files/storage/context.py +15 -0
- planar/files/storage/local_directory.py +188 -0
- planar/files/storage/s3.py +220 -0
- planar/files/storage/test_local_directory.py +162 -0
- planar/files/storage/test_s3.py +299 -0
- planar/files/test_files.py +283 -0
- planar/human/.human.py.un~ +0 -0
- planar/human/.test_human.py.un~ +0 -0
- planar/human/__init__.py +2 -0
- planar/human/human.py +458 -0
- planar/human/models.py +80 -0
- planar/human/test_human.py +385 -0
- planar/logging/.__init__.py.un~ +0 -0
- planar/logging/.attributes.py.un~ +0 -0
- planar/logging/.formatter.py.un~ +0 -0
- planar/logging/.logger.py.un~ +0 -0
- planar/logging/.otel.py.un~ +0 -0
- planar/logging/.tracer.py.un~ +0 -0
- planar/logging/__init__.py +10 -0
- planar/logging/attributes.py +54 -0
- planar/logging/context.py +14 -0
- planar/logging/formatter.py +113 -0
- planar/logging/logger.py +114 -0
- planar/logging/otel.py +51 -0
- planar/modeling/.mixin.py.un~ +0 -0
- planar/modeling/.storage.py.un~ +0 -0
- planar/modeling/__init__.py +0 -0
- planar/modeling/field_helpers.py +59 -0
- planar/modeling/json_schema_generator.py +94 -0
- planar/modeling/mixins/__init__.py +10 -0
- planar/modeling/mixins/auditable.py +52 -0
- planar/modeling/mixins/test_auditable.py +97 -0
- planar/modeling/mixins/test_timestamp.py +134 -0
- planar/modeling/mixins/test_uuid_primary_key.py +52 -0
- planar/modeling/mixins/timestamp.py +53 -0
- planar/modeling/mixins/uuid_primary_key.py +19 -0
- planar/modeling/orm/.planar_base_model.py.un~ +0 -0
- planar/modeling/orm/__init__.py +18 -0
- planar/modeling/orm/planar_base_entity.py +29 -0
- planar/modeling/orm/query_filter_builder.py +122 -0
- planar/modeling/orm/reexports.py +15 -0
- planar/object_config/.object_config.py.un~ +0 -0
- planar/object_config/__init__.py +11 -0
- planar/object_config/models.py +114 -0
- planar/object_config/object_config.py +378 -0
- planar/object_registry.py +100 -0
- planar/registry_items.py +65 -0
- planar/routers/.__init__.py.un~ +0 -0
- planar/routers/.agents_router.py.un~ +0 -0
- planar/routers/.crud.py.un~ +0 -0
- planar/routers/.decision.py.un~ +0 -0
- planar/routers/.event.py.un~ +0 -0
- planar/routers/.file_attachment.py.un~ +0 -0
- planar/routers/.files.py.un~ +0 -0
- planar/routers/.files_router.py.un~ +0 -0
- planar/routers/.human.py.un~ +0 -0
- planar/routers/.info.py.un~ +0 -0
- planar/routers/.models.py.un~ +0 -0
- planar/routers/.object_config_router.py.un~ +0 -0
- planar/routers/.rule.py.un~ +0 -0
- planar/routers/.test_object_config_router.py.un~ +0 -0
- planar/routers/.test_workflow_router.py.un~ +0 -0
- planar/routers/.workflow.py.un~ +0 -0
- planar/routers/__init__.py +13 -0
- planar/routers/agents_router.py +197 -0
- planar/routers/entity_router.py +143 -0
- planar/routers/event.py +91 -0
- planar/routers/files.py +142 -0
- planar/routers/human.py +151 -0
- planar/routers/info.py +131 -0
- planar/routers/models.py +170 -0
- planar/routers/object_config_router.py +133 -0
- planar/routers/rule.py +108 -0
- planar/routers/test_agents_router.py +174 -0
- planar/routers/test_object_config_router.py +367 -0
- planar/routers/test_routes_security.py +169 -0
- planar/routers/test_rule_router.py +470 -0
- planar/routers/test_workflow_router.py +274 -0
- planar/routers/workflow.py +468 -0
- planar/rules/.decorator.py.un~ +0 -0
- planar/rules/.runner.py.un~ +0 -0
- planar/rules/.test_rules.py.un~ +0 -0
- planar/rules/__init__.py +23 -0
- planar/rules/decorator.py +184 -0
- planar/rules/models.py +355 -0
- planar/rules/rule_configuration.py +191 -0
- planar/rules/runner.py +64 -0
- planar/rules/test_rules.py +750 -0
- planar/scaffold_templates/app/__init__.py.j2 +0 -0
- planar/scaffold_templates/app/db/entities.py.j2 +11 -0
- planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
- planar/scaffold_templates/main.py.j2 +13 -0
- planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
- planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
- planar/scaffold_templates/pyproject.toml.j2 +10 -0
- planar/security/.jwt_middleware.py.un~ +0 -0
- planar/security/auth_context.py +148 -0
- planar/security/authorization.py +388 -0
- planar/security/default_policies.cedar +77 -0
- planar/security/jwt_middleware.py +116 -0
- planar/security/security_context.py +18 -0
- planar/security/tests/test_authorization_context.py +78 -0
- planar/security/tests/test_cedar_basics.py +41 -0
- planar/security/tests/test_cedar_policies.py +158 -0
- planar/security/tests/test_jwt_principal_context.py +179 -0
- planar/session.py +40 -0
- planar/sse/.constants.py.un~ +0 -0
- planar/sse/.example.html.un~ +0 -0
- planar/sse/.hub.py.un~ +0 -0
- planar/sse/.model.py.un~ +0 -0
- planar/sse/.proxy.py.un~ +0 -0
- planar/sse/constants.py +1 -0
- planar/sse/example.html +126 -0
- planar/sse/hub.py +216 -0
- planar/sse/model.py +8 -0
- planar/sse/proxy.py +257 -0
- planar/task_local.py +37 -0
- planar/test_app.py +51 -0
- planar/test_cli.py +372 -0
- planar/test_config.py +512 -0
- planar/test_object_config.py +527 -0
- planar/test_object_registry.py +14 -0
- planar/test_sqlalchemy.py +158 -0
- planar/test_utils.py +105 -0
- planar/testing/.client.py.un~ +0 -0
- planar/testing/.memory_storage.py.un~ +0 -0
- planar/testing/.planar_test_client.py.un~ +0 -0
- planar/testing/.predictable_tracer.py.un~ +0 -0
- planar/testing/.synchronizable_tracer.py.un~ +0 -0
- planar/testing/.test_memory_storage.py.un~ +0 -0
- planar/testing/.workflow_observer.py.un~ +0 -0
- planar/testing/__init__.py +0 -0
- planar/testing/memory_storage.py +78 -0
- planar/testing/planar_test_client.py +54 -0
- planar/testing/synchronizable_tracer.py +153 -0
- planar/testing/test_memory_storage.py +143 -0
- planar/testing/workflow_observer.py +73 -0
- planar/utils.py +70 -0
- planar/workflows/.__init__.py.un~ +0 -0
- planar/workflows/.builtin_steps.py.un~ +0 -0
- planar/workflows/.concurrency_tracing.py.un~ +0 -0
- planar/workflows/.context.py.un~ +0 -0
- planar/workflows/.contrib.py.un~ +0 -0
- planar/workflows/.decorators.py.un~ +0 -0
- planar/workflows/.durable_test.py.un~ +0 -0
- planar/workflows/.errors.py.un~ +0 -0
- planar/workflows/.events.py.un~ +0 -0
- planar/workflows/.exceptions.py.un~ +0 -0
- planar/workflows/.execution.py.un~ +0 -0
- planar/workflows/.human.py.un~ +0 -0
- planar/workflows/.lock.py.un~ +0 -0
- planar/workflows/.misc.py.un~ +0 -0
- planar/workflows/.model.py.un~ +0 -0
- planar/workflows/.models.py.un~ +0 -0
- planar/workflows/.notifications.py.un~ +0 -0
- planar/workflows/.orchestrator.py.un~ +0 -0
- planar/workflows/.runtime.py.un~ +0 -0
- planar/workflows/.serialization.py.un~ +0 -0
- planar/workflows/.step.py.un~ +0 -0
- planar/workflows/.step_core.py.un~ +0 -0
- planar/workflows/.sub_workflow_runner.py.un~ +0 -0
- planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
- planar/workflows/.test_concurrency.py.un~ +0 -0
- planar/workflows/.test_concurrency_detection.py.un~ +0 -0
- planar/workflows/.test_human.py.un~ +0 -0
- planar/workflows/.test_lock_timeout.py.un~ +0 -0
- planar/workflows/.test_orchestrator.py.un~ +0 -0
- planar/workflows/.test_race_conditions.py.un~ +0 -0
- planar/workflows/.test_serialization.py.un~ +0 -0
- planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
- planar/workflows/.test_workflow.py.un~ +0 -0
- planar/workflows/.tracing.py.un~ +0 -0
- planar/workflows/.types.py.un~ +0 -0
- planar/workflows/.util.py.un~ +0 -0
- planar/workflows/.utils.py.un~ +0 -0
- planar/workflows/.workflow.py.un~ +0 -0
- planar/workflows/.workflow_wrapper.py.un~ +0 -0
- planar/workflows/.wrappers.py.un~ +0 -0
- planar/workflows/__init__.py +42 -0
- planar/workflows/context.py +44 -0
- planar/workflows/contrib.py +190 -0
- planar/workflows/decorators.py +217 -0
- planar/workflows/events.py +185 -0
- planar/workflows/exceptions.py +34 -0
- planar/workflows/execution.py +198 -0
- planar/workflows/lock.py +229 -0
- planar/workflows/misc.py +5 -0
- planar/workflows/models.py +154 -0
- planar/workflows/notifications.py +96 -0
- planar/workflows/orchestrator.py +383 -0
- planar/workflows/query.py +256 -0
- planar/workflows/serialization.py +409 -0
- planar/workflows/step_core.py +373 -0
- planar/workflows/step_metadata.py +357 -0
- planar/workflows/step_testing_utils.py +86 -0
- planar/workflows/sub_workflow_runner.py +191 -0
- planar/workflows/test_concurrency_detection.py +120 -0
- planar/workflows/test_lock_timeout.py +140 -0
- planar/workflows/test_serialization.py +1195 -0
- planar/workflows/test_suspend_deserialization.py +231 -0
- planar/workflows/test_workflow.py +1967 -0
- planar/workflows/tracing.py +106 -0
- planar/workflows/wrappers.py +41 -0
- planar-0.5.0.dist-info/METADATA +285 -0
- planar-0.5.0.dist-info/RECORD +289 -0
- planar-0.5.0.dist-info/WHEEL +4 -0
- planar-0.5.0.dist-info/entry_points.txt +3 -0
planar/human/human.py
ADDED
@@ -0,0 +1,458 @@
|
|
1
|
+
"""
|
2
|
+
Human-in-the-loop step implementation for Planar workflows.
|
3
|
+
|
4
|
+
This module provides the Human class for creating human task instances,
|
5
|
+
along with supporting entities and functions for managing human tasks.
|
6
|
+
"""
|
7
|
+
|
8
|
+
from datetime import datetime, timedelta
|
9
|
+
from typing import Any, Dict, Optional, Type, overload
|
10
|
+
from uuid import UUID
|
11
|
+
|
12
|
+
from pydantic import BaseModel
|
13
|
+
from sqlmodel import col, select
|
14
|
+
|
15
|
+
from planar.human.models import HumanTask, HumanTaskResult, HumanTaskStatus
|
16
|
+
from planar.logging import get_logger
|
17
|
+
from planar.session import get_session
|
18
|
+
from planar.utils import utc_now
|
19
|
+
from planar.workflows import as_step
|
20
|
+
from planar.workflows.context import get_context
|
21
|
+
from planar.workflows.contrib import wait_for_event
|
22
|
+
from planar.workflows.events import emit_event
|
23
|
+
from planar.workflows.models import StepType
|
24
|
+
|
25
|
+
logger = get_logger(__name__)
|
26
|
+
|
27
|
+
|
28
|
+
class Timeout:
|
29
|
+
"""Helper class for defining timeout periods for human tasks."""
|
30
|
+
|
31
|
+
def __init__(self, duration: timedelta):
|
32
|
+
"""
|
33
|
+
Initialize timeout with a duration.
|
34
|
+
|
35
|
+
Args:
|
36
|
+
duration: The timeout duration as a timedelta
|
37
|
+
"""
|
38
|
+
self.duration = duration
|
39
|
+
|
40
|
+
def get_seconds(self) -> float:
|
41
|
+
"""
|
42
|
+
Get the timeout duration in seconds.
|
43
|
+
|
44
|
+
Returns:
|
45
|
+
Timeout duration in seconds
|
46
|
+
"""
|
47
|
+
return self.duration.total_seconds()
|
48
|
+
|
49
|
+
def get_timedelta(self) -> timedelta:
|
50
|
+
"""
|
51
|
+
Get the timeout duration as a timedelta.
|
52
|
+
|
53
|
+
Returns:
|
54
|
+
Timeout duration as a timedelta
|
55
|
+
"""
|
56
|
+
return self.duration
|
57
|
+
|
58
|
+
|
59
|
+
class Human[TInput: BaseModel, TOutput: BaseModel]:
|
60
|
+
"""
|
61
|
+
Human-in-the-loop task for workflows.
|
62
|
+
|
63
|
+
Creates a callable task object that:
|
64
|
+
1. Creates a HumanTask record
|
65
|
+
2. Suspends workflow using event system
|
66
|
+
3. Returns structured data when human completes the task
|
67
|
+
"""
|
68
|
+
|
69
|
+
def __init__(
|
70
|
+
self,
|
71
|
+
name: str,
|
72
|
+
title: str,
|
73
|
+
output_type: Type[TOutput],
|
74
|
+
description: Optional[str] = None,
|
75
|
+
input_type: Type[TInput] | None = None,
|
76
|
+
timeout: Optional[Timeout] = None,
|
77
|
+
):
|
78
|
+
"""
|
79
|
+
Initialize a human task definition.
|
80
|
+
|
81
|
+
Args:
|
82
|
+
name: Unique identifier for this task
|
83
|
+
title: Human-readable title
|
84
|
+
output_type: Pydantic model for expected output (required)
|
85
|
+
description: Detailed task description (optional)
|
86
|
+
input_type: Pydantic model for input data (optional)
|
87
|
+
timeout: Maximum time to wait for human input (optional)
|
88
|
+
"""
|
89
|
+
self.name = name
|
90
|
+
self.title = title
|
91
|
+
self.description = description
|
92
|
+
self.input_type = input_type
|
93
|
+
self.output_type = output_type
|
94
|
+
self.timeout = timeout
|
95
|
+
|
96
|
+
if self.input_type and not issubclass(self.input_type, BaseModel):
|
97
|
+
raise ValueError("input_type must be a Pydantic model or None")
|
98
|
+
if not issubclass(self.output_type, BaseModel):
|
99
|
+
raise ValueError("output_type must be a Pydantic model")
|
100
|
+
|
101
|
+
@overload
|
102
|
+
async def __call__(
|
103
|
+
self,
|
104
|
+
input_data: TInput,
|
105
|
+
message: str | None = None,
|
106
|
+
suggested_data: TOutput | None = None,
|
107
|
+
) -> HumanTaskResult[TOutput]: ...
|
108
|
+
|
109
|
+
@overload
|
110
|
+
async def __call__(
|
111
|
+
self, *, message: str, suggested_data: TOutput | None = None
|
112
|
+
) -> HumanTaskResult[TOutput]: ...
|
113
|
+
|
114
|
+
async def __call__(
|
115
|
+
self,
|
116
|
+
input_data: TInput | None = None,
|
117
|
+
message: str | None = None,
|
118
|
+
suggested_data: TOutput | None = None,
|
119
|
+
) -> HumanTaskResult[TOutput]:
|
120
|
+
logger.debug(
|
121
|
+
"human task called",
|
122
|
+
task_name=self.name,
|
123
|
+
has_input=input_data is not None,
|
124
|
+
has_message=message is not None,
|
125
|
+
has_suggestion=suggested_data is not None,
|
126
|
+
)
|
127
|
+
if self.output_type is None:
|
128
|
+
raise ValueError("output_type must be provided")
|
129
|
+
run_step = as_step(
|
130
|
+
self.run_step,
|
131
|
+
step_type=StepType.HUMAN_IN_THE_LOOP,
|
132
|
+
display_name=self.name,
|
133
|
+
return_type=HumanTaskResult[self.output_type],
|
134
|
+
)
|
135
|
+
return await run_step(input_data, message, suggested_data)
|
136
|
+
|
137
|
+
async def run_step(
|
138
|
+
self,
|
139
|
+
input_data: TInput | None = None,
|
140
|
+
message: str | None = None,
|
141
|
+
suggested_data: TOutput | None = None,
|
142
|
+
) -> HumanTaskResult[TOutput]:
|
143
|
+
"""
|
144
|
+
Create a human task and wait for completion.
|
145
|
+
|
146
|
+
Can be called with either (or both of):
|
147
|
+
1. A Pydantic model instance of input_type
|
148
|
+
2. A context message string for display to the human
|
149
|
+
|
150
|
+
Args:
|
151
|
+
input_data: Context data for the human task
|
152
|
+
message: Optional message to display to the human
|
153
|
+
suggested_data: Optional pre-filled data conforming to output_type
|
154
|
+
|
155
|
+
Returns:
|
156
|
+
HumanTaskResult containing the human's response
|
157
|
+
"""
|
158
|
+
logger.debug("human task run_step executing", task_name=self.name)
|
159
|
+
if input_data is None and message is None:
|
160
|
+
logger.warning(
|
161
|
+
"human task called without input_data or message", task_name=self.name
|
162
|
+
)
|
163
|
+
raise ValueError("Either input_data or message must be provided")
|
164
|
+
|
165
|
+
# Create task in database
|
166
|
+
logger.debug("creating human task record", task_name=self.name)
|
167
|
+
task_id = await as_step(
|
168
|
+
self._create_task,
|
169
|
+
step_type=StepType.HUMAN_IN_THE_LOOP,
|
170
|
+
display_name="Create Human Task",
|
171
|
+
)(input_data, message, suggested_data)
|
172
|
+
logger.info("human task record created", task_name=self.name, task_id=task_id)
|
173
|
+
|
174
|
+
# Wait for task completion event
|
175
|
+
event_key = f"human_task_completed:{task_id}"
|
176
|
+
max_wait_seconds = self.timeout.get_seconds() if self.timeout else -1
|
177
|
+
logger.debug(
|
178
|
+
"waiting for event",
|
179
|
+
event_key=event_key,
|
180
|
+
task_name=self.name,
|
181
|
+
timeout_seconds=max_wait_seconds,
|
182
|
+
)
|
183
|
+
# TODO: Catch timeout exception on event, expire human task and raise timeout error
|
184
|
+
event_data = await wait_for_event(
|
185
|
+
event_key=event_key, max_wait_time=max_wait_seconds
|
186
|
+
)
|
187
|
+
logger.info("event received for task", event_key=event_key, task_name=self.name)
|
188
|
+
|
189
|
+
# Return structured result
|
190
|
+
return HumanTaskResult(
|
191
|
+
task_id=task_id,
|
192
|
+
output=self.output_type.model_validate(event_data["output_data"]),
|
193
|
+
completed_at=datetime.fromisoformat(event_data["completed_at"]),
|
194
|
+
)
|
195
|
+
|
196
|
+
async def _create_task(
|
197
|
+
self,
|
198
|
+
input_data: TInput | None = None,
|
199
|
+
message: str | None = None,
|
200
|
+
suggested_data: TOutput | None = None,
|
201
|
+
) -> UUID:
|
202
|
+
"""
|
203
|
+
Create the human task record in the database.
|
204
|
+
This is a separate step for replay safety.
|
205
|
+
|
206
|
+
Args:
|
207
|
+
input_data: Context data for the human task
|
208
|
+
message: Optional message to display to the human
|
209
|
+
suggested_data: Optional pre-filled data conforming to output_type
|
210
|
+
|
211
|
+
Returns:
|
212
|
+
UUID of the created human task
|
213
|
+
"""
|
214
|
+
logger.debug("human task _create_task executing", task_name=self.name)
|
215
|
+
# Get workflow context
|
216
|
+
ctx = get_context()
|
217
|
+
session = get_session()
|
218
|
+
|
219
|
+
if input_data is not None:
|
220
|
+
if isinstance(input_data, BaseModel):
|
221
|
+
if self.input_type and not isinstance(input_data, self.input_type):
|
222
|
+
logger.warning(
|
223
|
+
"input type mismatch for human task",
|
224
|
+
task_name=self.name,
|
225
|
+
expected_type=self.input_type,
|
226
|
+
got_type=type(input_data),
|
227
|
+
)
|
228
|
+
raise ValueError(
|
229
|
+
f"Input must be of type {self.input_type}, but got {type(input_data)}"
|
230
|
+
)
|
231
|
+
|
232
|
+
# Create HumanTask record
|
233
|
+
task = HumanTask(
|
234
|
+
name=self.name,
|
235
|
+
title=self.title,
|
236
|
+
description=self.description,
|
237
|
+
workflow_id=ctx.workflow.id,
|
238
|
+
workflow_name=ctx.workflow.function_name,
|
239
|
+
input_schema=self.input_type.model_json_schema()
|
240
|
+
if self.input_type
|
241
|
+
else None,
|
242
|
+
input_data=input_data.model_dump(mode="json") if input_data else None,
|
243
|
+
message=message,
|
244
|
+
output_schema=self.output_type.model_json_schema(),
|
245
|
+
suggested_data=suggested_data.model_dump(mode="json")
|
246
|
+
if suggested_data
|
247
|
+
else None,
|
248
|
+
deadline=self._calculate_deadline(),
|
249
|
+
status=HumanTaskStatus.PENDING,
|
250
|
+
)
|
251
|
+
|
252
|
+
# Persist to database
|
253
|
+
session.add(task)
|
254
|
+
await session.commit()
|
255
|
+
logger.info(
|
256
|
+
"human task persisted to database", task_name=self.name, task_id=task.id
|
257
|
+
)
|
258
|
+
|
259
|
+
return task.id
|
260
|
+
|
261
|
+
def _calculate_deadline(self) -> Optional[datetime]:
|
262
|
+
"""
|
263
|
+
Calculate the task deadline based on timeout.
|
264
|
+
|
265
|
+
Returns:
|
266
|
+
Deadline as a UTC datetime or None if no timeout
|
267
|
+
"""
|
268
|
+
if not self.timeout:
|
269
|
+
return None
|
270
|
+
|
271
|
+
return utc_now() + self.timeout.get_timedelta()
|
272
|
+
|
273
|
+
|
274
|
+
async def complete_human_task(
|
275
|
+
task_id: UUID, output_data: Dict[str, Any], completed_by: Optional[str] = None
|
276
|
+
) -> None:
|
277
|
+
"""
|
278
|
+
Complete a human task and trigger workflow resumption.
|
279
|
+
|
280
|
+
Args:
|
281
|
+
task_id: The task to complete
|
282
|
+
output_data: The human's response
|
283
|
+
completed_by: Optional identifier for who completed the task
|
284
|
+
|
285
|
+
Raises:
|
286
|
+
ValueError: If task not found or not in pending status
|
287
|
+
"""
|
288
|
+
logger.debug("completing human task", task_id=task_id, completed_by=completed_by)
|
289
|
+
# Find the task
|
290
|
+
session = get_session()
|
291
|
+
task = await session.get(HumanTask, task_id)
|
292
|
+
if not task:
|
293
|
+
logger.warning("human task not found for completion", task_id=task_id)
|
294
|
+
raise ValueError(f"Task {task_id} not found")
|
295
|
+
|
296
|
+
# Validate task can be completed
|
297
|
+
if task.status != HumanTaskStatus.PENDING:
|
298
|
+
logger.warning(
|
299
|
+
"attempt to complete human task not in pending status",
|
300
|
+
task_id=task_id,
|
301
|
+
status=task.status,
|
302
|
+
)
|
303
|
+
raise ValueError(f"Task {task_id} is not pending (status: {task.status})")
|
304
|
+
|
305
|
+
# TODO: Validate output against schema
|
306
|
+
# This would validate output_data against task.output_schema
|
307
|
+
|
308
|
+
# Update task
|
309
|
+
completed_at = utc_now()
|
310
|
+
task.status = HumanTaskStatus.COMPLETED
|
311
|
+
task.output_data = output_data
|
312
|
+
task.completed_at = completed_at
|
313
|
+
task.completed_by = completed_by or "anonymous"
|
314
|
+
session.add(task)
|
315
|
+
await session.commit()
|
316
|
+
logger.info("human task marked as completed", task_id=task_id)
|
317
|
+
|
318
|
+
# Emit completion event to resume workflow
|
319
|
+
event_key = f"human_task_completed:{task_id}"
|
320
|
+
logger.debug(
|
321
|
+
"emitting completion event for task", event_key=event_key, task_id=task_id
|
322
|
+
)
|
323
|
+
await emit_event(
|
324
|
+
event_key=event_key,
|
325
|
+
payload={
|
326
|
+
"task_id": str(task_id),
|
327
|
+
"output_data": output_data,
|
328
|
+
"completed_at": completed_at.isoformat(),
|
329
|
+
},
|
330
|
+
workflow_id=task.workflow_id,
|
331
|
+
)
|
332
|
+
|
333
|
+
|
334
|
+
async def cancel_human_task(
|
335
|
+
task_id: UUID, reason: str = "cancelled", cancelled_by: Optional[str] = None
|
336
|
+
) -> None:
|
337
|
+
"""
|
338
|
+
Cancel a pending human task.
|
339
|
+
|
340
|
+
Args:
|
341
|
+
task_id: The task to cancel
|
342
|
+
reason: Reason for cancellation
|
343
|
+
cancelled_by: Optional identifier for who cancelled the task
|
344
|
+
|
345
|
+
Raises:
|
346
|
+
ValueError: If task not found or not in pending status
|
347
|
+
"""
|
348
|
+
logger.debug(
|
349
|
+
"cancelling human task",
|
350
|
+
task_id=task_id,
|
351
|
+
reason=reason,
|
352
|
+
cancelled_by=cancelled_by,
|
353
|
+
)
|
354
|
+
# Find the task
|
355
|
+
session = get_session()
|
356
|
+
task = await session.get(HumanTask, task_id)
|
357
|
+
if not task:
|
358
|
+
logger.warning("human task not found for cancellation", task_id=task_id)
|
359
|
+
raise ValueError(f"Task {task_id} not found")
|
360
|
+
|
361
|
+
# Validate task can be cancelled
|
362
|
+
if task.status != HumanTaskStatus.PENDING:
|
363
|
+
logger.warning(
|
364
|
+
"attempt to cancel human task not in pending status",
|
365
|
+
task_id=task_id,
|
366
|
+
status=task.status,
|
367
|
+
)
|
368
|
+
raise ValueError(f"Task {task_id} is not pending (status: {task.status})")
|
369
|
+
|
370
|
+
# Update task
|
371
|
+
cancelled_at = utc_now()
|
372
|
+
task.status = HumanTaskStatus.CANCELLED
|
373
|
+
task.completed_at = cancelled_at
|
374
|
+
task.completed_by = cancelled_by or "system"
|
375
|
+
# Store cancellation reason in output_data
|
376
|
+
task.output_data = {"cancelled": True, "reason": reason}
|
377
|
+
session.add(task)
|
378
|
+
await session.commit()
|
379
|
+
logger.info("human task marked as cancelled", task_id=task_id)
|
380
|
+
|
381
|
+
# Emit cancellation event to resume workflow
|
382
|
+
event_key = (
|
383
|
+
f"human_task_completed:{task_id}" # Note: Uses same event key as completion
|
384
|
+
)
|
385
|
+
logger.debug(
|
386
|
+
"emitting cancellation event for task", event_key=event_key, task_id=task_id
|
387
|
+
)
|
388
|
+
await emit_event(
|
389
|
+
event_key=event_key,
|
390
|
+
payload={
|
391
|
+
"task_id": str(task_id),
|
392
|
+
"output_data": {"cancelled": True, "reason": reason},
|
393
|
+
"completed_at": cancelled_at.isoformat(),
|
394
|
+
},
|
395
|
+
workflow_id=task.workflow_id,
|
396
|
+
)
|
397
|
+
|
398
|
+
|
399
|
+
async def get_human_tasks(
|
400
|
+
status: Optional[HumanTaskStatus] = None,
|
401
|
+
workflow_id: Optional[UUID] = None,
|
402
|
+
limit: int = 100,
|
403
|
+
offset: int = 0,
|
404
|
+
) -> list[HumanTask]:
|
405
|
+
"""
|
406
|
+
Get human tasks matching the given filters.
|
407
|
+
|
408
|
+
Args:
|
409
|
+
status: Filter by task status
|
410
|
+
workflow_id: Filter by workflow ID
|
411
|
+
limit: Maximum number of tasks to return
|
412
|
+
offset: Offset for pagination
|
413
|
+
|
414
|
+
Returns:
|
415
|
+
List of human tasks
|
416
|
+
"""
|
417
|
+
logger.debug(
|
418
|
+
"getting human tasks",
|
419
|
+
status=status,
|
420
|
+
limit=limit,
|
421
|
+
offset=offset,
|
422
|
+
)
|
423
|
+
session = get_session()
|
424
|
+
query = select(HumanTask)
|
425
|
+
|
426
|
+
if status:
|
427
|
+
query = query.where(col(HumanTask.status) == status)
|
428
|
+
|
429
|
+
if workflow_id:
|
430
|
+
query = query.where(col(HumanTask.workflow_id) == workflow_id)
|
431
|
+
|
432
|
+
# Order by creation time, newest first
|
433
|
+
query = query.order_by(col(HumanTask.created_at).desc())
|
434
|
+
query = query.offset(offset).limit(limit)
|
435
|
+
|
436
|
+
tasks = list((await session.exec(query)).all())
|
437
|
+
logger.debug("found human tasks matching criteria", count=len(tasks))
|
438
|
+
return tasks
|
439
|
+
|
440
|
+
|
441
|
+
async def get_human_task(task_id: UUID) -> HumanTask | None:
|
442
|
+
"""
|
443
|
+
Get a human task by ID.
|
444
|
+
|
445
|
+
Args:
|
446
|
+
task_id: The task ID
|
447
|
+
|
448
|
+
Returns:
|
449
|
+
The human task or None if not found
|
450
|
+
"""
|
451
|
+
logger.debug("getting human task by id", task_id=task_id)
|
452
|
+
session = get_session()
|
453
|
+
task = await session.get(HumanTask, task_id)
|
454
|
+
if task:
|
455
|
+
logger.debug("found human task", task_id=task_id)
|
456
|
+
else:
|
457
|
+
logger.debug("human task not found", task_id=task_id)
|
458
|
+
return task
|
planar/human/models.py
ADDED
@@ -0,0 +1,80 @@
|
|
1
|
+
from datetime import datetime
|
2
|
+
from enum import Enum
|
3
|
+
from typing import Any, Optional
|
4
|
+
from uuid import UUID
|
5
|
+
|
6
|
+
from pydantic import BaseModel
|
7
|
+
from sqlmodel import JSON, Column, Field
|
8
|
+
|
9
|
+
from planar.db import PlanarInternalBase
|
10
|
+
from planar.modeling.field_helpers import JsonSchema
|
11
|
+
from planar.modeling.mixins import TimestampMixin
|
12
|
+
from planar.modeling.mixins.auditable import AuditableMixin
|
13
|
+
from planar.modeling.mixins.uuid_primary_key import UUIDPrimaryKeyMixin
|
14
|
+
|
15
|
+
|
16
|
+
class HumanTaskStatus(str, Enum):
|
17
|
+
"""Status values for human tasks."""
|
18
|
+
|
19
|
+
PENDING = "pending"
|
20
|
+
COMPLETED = "completed"
|
21
|
+
CANCELLED = "cancelled"
|
22
|
+
EXPIRED = "expired"
|
23
|
+
|
24
|
+
|
25
|
+
class HumanTask(
|
26
|
+
UUIDPrimaryKeyMixin, AuditableMixin, PlanarInternalBase, TimestampMixin, table=True
|
27
|
+
):
|
28
|
+
"""
|
29
|
+
Database model for human tasks that require input from a human operator.
|
30
|
+
|
31
|
+
Extends UUIDPrimaryKeyMixin which provides:
|
32
|
+
- id: Primary key
|
33
|
+
|
34
|
+
Extends AuditableMixin which provides:
|
35
|
+
- created_by, updated_by: Audit fields
|
36
|
+
|
37
|
+
And TimeStampMixin which provides:
|
38
|
+
- created_at, updated_at: Timestamp fields
|
39
|
+
"""
|
40
|
+
|
41
|
+
# Task identifying information
|
42
|
+
name: str = Field(index=True)
|
43
|
+
title: str
|
44
|
+
description: Optional[str] = None
|
45
|
+
|
46
|
+
# Workflow association
|
47
|
+
workflow_id: UUID = Field(index=True)
|
48
|
+
workflow_name: str
|
49
|
+
|
50
|
+
# Input data for context
|
51
|
+
input_schema: Optional[JsonSchema] = Field(default=None, sa_column=Column(JSON))
|
52
|
+
input_data: Optional[dict[str, Any]] = Field(default=None, sa_column=Column(JSON))
|
53
|
+
message: Optional[str] = Field(default=None)
|
54
|
+
|
55
|
+
# Schema for expected output
|
56
|
+
output_schema: JsonSchema = Field(sa_column=Column(JSON))
|
57
|
+
output_data: Optional[dict[str, Any]] = Field(default=None, sa_column=Column(JSON))
|
58
|
+
|
59
|
+
# Suggested data for the form (optional)
|
60
|
+
suggested_data: Optional[dict[str, Any]] = Field(
|
61
|
+
default=None, sa_column=Column(JSON)
|
62
|
+
)
|
63
|
+
|
64
|
+
# Task status
|
65
|
+
status: HumanTaskStatus = Field(default=HumanTaskStatus.PENDING)
|
66
|
+
|
67
|
+
# Completion tracking
|
68
|
+
completed_by: Optional[str] = None
|
69
|
+
completed_at: Optional[datetime] = None
|
70
|
+
|
71
|
+
# Time constraints
|
72
|
+
deadline: Optional[datetime] = None
|
73
|
+
|
74
|
+
|
75
|
+
class HumanTaskResult[TOutput: BaseModel](BaseModel):
|
76
|
+
"""Result of a completed human task."""
|
77
|
+
|
78
|
+
task_id: UUID
|
79
|
+
output: TOutput
|
80
|
+
completed_at: datetime
|