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.
Files changed (289) hide show
  1. planar/.__init__.py.un~ +0 -0
  2. planar/._version.py.un~ +0 -0
  3. planar/.app.py.un~ +0 -0
  4. planar/.cli.py.un~ +0 -0
  5. planar/.config.py.un~ +0 -0
  6. planar/.context.py.un~ +0 -0
  7. planar/.db.py.un~ +0 -0
  8. planar/.di.py.un~ +0 -0
  9. planar/.engine.py.un~ +0 -0
  10. planar/.files.py.un~ +0 -0
  11. planar/.log_context.py.un~ +0 -0
  12. planar/.log_metadata.py.un~ +0 -0
  13. planar/.logging.py.un~ +0 -0
  14. planar/.object_registry.py.un~ +0 -0
  15. planar/.otel.py.un~ +0 -0
  16. planar/.server.py.un~ +0 -0
  17. planar/.session.py.un~ +0 -0
  18. planar/.sqlalchemy.py.un~ +0 -0
  19. planar/.task_local.py.un~ +0 -0
  20. planar/.test_app.py.un~ +0 -0
  21. planar/.test_config.py.un~ +0 -0
  22. planar/.test_object_config.py.un~ +0 -0
  23. planar/.test_sqlalchemy.py.un~ +0 -0
  24. planar/.test_utils.py.un~ +0 -0
  25. planar/.util.py.un~ +0 -0
  26. planar/.utils.py.un~ +0 -0
  27. planar/__init__.py +26 -0
  28. planar/_version.py +1 -0
  29. planar/ai/.__init__.py.un~ +0 -0
  30. planar/ai/._models.py.un~ +0 -0
  31. planar/ai/.agent.py.un~ +0 -0
  32. planar/ai/.agent_utils.py.un~ +0 -0
  33. planar/ai/.events.py.un~ +0 -0
  34. planar/ai/.files.py.un~ +0 -0
  35. planar/ai/.models.py.un~ +0 -0
  36. planar/ai/.providers.py.un~ +0 -0
  37. planar/ai/.pydantic_ai.py.un~ +0 -0
  38. planar/ai/.pydantic_ai_agent.py.un~ +0 -0
  39. planar/ai/.pydantic_ai_provider.py.un~ +0 -0
  40. planar/ai/.step.py.un~ +0 -0
  41. planar/ai/.test_agent.py.un~ +0 -0
  42. planar/ai/.test_agent_serialization.py.un~ +0 -0
  43. planar/ai/.test_providers.py.un~ +0 -0
  44. planar/ai/.utils.py.un~ +0 -0
  45. planar/ai/__init__.py +15 -0
  46. planar/ai/agent.py +457 -0
  47. planar/ai/agent_utils.py +205 -0
  48. planar/ai/models.py +140 -0
  49. planar/ai/providers.py +1088 -0
  50. planar/ai/test_agent.py +1298 -0
  51. planar/ai/test_agent_serialization.py +229 -0
  52. planar/ai/test_providers.py +463 -0
  53. planar/ai/utils.py +102 -0
  54. planar/app.py +494 -0
  55. planar/cli.py +282 -0
  56. planar/config.py +544 -0
  57. planar/db/.db.py.un~ +0 -0
  58. planar/db/__init__.py +17 -0
  59. planar/db/alembic/env.py +136 -0
  60. planar/db/alembic/script.py.mako +28 -0
  61. planar/db/alembic/versions/3476068c153c_initial_system_tables_migration.py +339 -0
  62. planar/db/alembic.ini +128 -0
  63. planar/db/db.py +318 -0
  64. planar/files/.config.py.un~ +0 -0
  65. planar/files/.local.py.un~ +0 -0
  66. planar/files/.local_filesystem.py.un~ +0 -0
  67. planar/files/.model.py.un~ +0 -0
  68. planar/files/.models.py.un~ +0 -0
  69. planar/files/.s3.py.un~ +0 -0
  70. planar/files/.storage.py.un~ +0 -0
  71. planar/files/.test_files.py.un~ +0 -0
  72. planar/files/__init__.py +2 -0
  73. planar/files/models.py +162 -0
  74. planar/files/storage/.__init__.py.un~ +0 -0
  75. planar/files/storage/.base.py.un~ +0 -0
  76. planar/files/storage/.config.py.un~ +0 -0
  77. planar/files/storage/.context.py.un~ +0 -0
  78. planar/files/storage/.local_directory.py.un~ +0 -0
  79. planar/files/storage/.test_local_directory.py.un~ +0 -0
  80. planar/files/storage/.test_s3.py.un~ +0 -0
  81. planar/files/storage/base.py +61 -0
  82. planar/files/storage/config.py +44 -0
  83. planar/files/storage/context.py +15 -0
  84. planar/files/storage/local_directory.py +188 -0
  85. planar/files/storage/s3.py +220 -0
  86. planar/files/storage/test_local_directory.py +162 -0
  87. planar/files/storage/test_s3.py +299 -0
  88. planar/files/test_files.py +283 -0
  89. planar/human/.human.py.un~ +0 -0
  90. planar/human/.test_human.py.un~ +0 -0
  91. planar/human/__init__.py +2 -0
  92. planar/human/human.py +458 -0
  93. planar/human/models.py +80 -0
  94. planar/human/test_human.py +385 -0
  95. planar/logging/.__init__.py.un~ +0 -0
  96. planar/logging/.attributes.py.un~ +0 -0
  97. planar/logging/.formatter.py.un~ +0 -0
  98. planar/logging/.logger.py.un~ +0 -0
  99. planar/logging/.otel.py.un~ +0 -0
  100. planar/logging/.tracer.py.un~ +0 -0
  101. planar/logging/__init__.py +10 -0
  102. planar/logging/attributes.py +54 -0
  103. planar/logging/context.py +14 -0
  104. planar/logging/formatter.py +113 -0
  105. planar/logging/logger.py +114 -0
  106. planar/logging/otel.py +51 -0
  107. planar/modeling/.mixin.py.un~ +0 -0
  108. planar/modeling/.storage.py.un~ +0 -0
  109. planar/modeling/__init__.py +0 -0
  110. planar/modeling/field_helpers.py +59 -0
  111. planar/modeling/json_schema_generator.py +94 -0
  112. planar/modeling/mixins/__init__.py +10 -0
  113. planar/modeling/mixins/auditable.py +52 -0
  114. planar/modeling/mixins/test_auditable.py +97 -0
  115. planar/modeling/mixins/test_timestamp.py +134 -0
  116. planar/modeling/mixins/test_uuid_primary_key.py +52 -0
  117. planar/modeling/mixins/timestamp.py +53 -0
  118. planar/modeling/mixins/uuid_primary_key.py +19 -0
  119. planar/modeling/orm/.planar_base_model.py.un~ +0 -0
  120. planar/modeling/orm/__init__.py +18 -0
  121. planar/modeling/orm/planar_base_entity.py +29 -0
  122. planar/modeling/orm/query_filter_builder.py +122 -0
  123. planar/modeling/orm/reexports.py +15 -0
  124. planar/object_config/.object_config.py.un~ +0 -0
  125. planar/object_config/__init__.py +11 -0
  126. planar/object_config/models.py +114 -0
  127. planar/object_config/object_config.py +378 -0
  128. planar/object_registry.py +100 -0
  129. planar/registry_items.py +65 -0
  130. planar/routers/.__init__.py.un~ +0 -0
  131. planar/routers/.agents_router.py.un~ +0 -0
  132. planar/routers/.crud.py.un~ +0 -0
  133. planar/routers/.decision.py.un~ +0 -0
  134. planar/routers/.event.py.un~ +0 -0
  135. planar/routers/.file_attachment.py.un~ +0 -0
  136. planar/routers/.files.py.un~ +0 -0
  137. planar/routers/.files_router.py.un~ +0 -0
  138. planar/routers/.human.py.un~ +0 -0
  139. planar/routers/.info.py.un~ +0 -0
  140. planar/routers/.models.py.un~ +0 -0
  141. planar/routers/.object_config_router.py.un~ +0 -0
  142. planar/routers/.rule.py.un~ +0 -0
  143. planar/routers/.test_object_config_router.py.un~ +0 -0
  144. planar/routers/.test_workflow_router.py.un~ +0 -0
  145. planar/routers/.workflow.py.un~ +0 -0
  146. planar/routers/__init__.py +13 -0
  147. planar/routers/agents_router.py +197 -0
  148. planar/routers/entity_router.py +143 -0
  149. planar/routers/event.py +91 -0
  150. planar/routers/files.py +142 -0
  151. planar/routers/human.py +151 -0
  152. planar/routers/info.py +131 -0
  153. planar/routers/models.py +170 -0
  154. planar/routers/object_config_router.py +133 -0
  155. planar/routers/rule.py +108 -0
  156. planar/routers/test_agents_router.py +174 -0
  157. planar/routers/test_object_config_router.py +367 -0
  158. planar/routers/test_routes_security.py +169 -0
  159. planar/routers/test_rule_router.py +470 -0
  160. planar/routers/test_workflow_router.py +274 -0
  161. planar/routers/workflow.py +468 -0
  162. planar/rules/.decorator.py.un~ +0 -0
  163. planar/rules/.runner.py.un~ +0 -0
  164. planar/rules/.test_rules.py.un~ +0 -0
  165. planar/rules/__init__.py +23 -0
  166. planar/rules/decorator.py +184 -0
  167. planar/rules/models.py +355 -0
  168. planar/rules/rule_configuration.py +191 -0
  169. planar/rules/runner.py +64 -0
  170. planar/rules/test_rules.py +750 -0
  171. planar/scaffold_templates/app/__init__.py.j2 +0 -0
  172. planar/scaffold_templates/app/db/entities.py.j2 +11 -0
  173. planar/scaffold_templates/app/flows/process_invoice.py.j2 +67 -0
  174. planar/scaffold_templates/main.py.j2 +13 -0
  175. planar/scaffold_templates/planar.dev.yaml.j2 +34 -0
  176. planar/scaffold_templates/planar.prod.yaml.j2 +28 -0
  177. planar/scaffold_templates/pyproject.toml.j2 +10 -0
  178. planar/security/.jwt_middleware.py.un~ +0 -0
  179. planar/security/auth_context.py +148 -0
  180. planar/security/authorization.py +388 -0
  181. planar/security/default_policies.cedar +77 -0
  182. planar/security/jwt_middleware.py +116 -0
  183. planar/security/security_context.py +18 -0
  184. planar/security/tests/test_authorization_context.py +78 -0
  185. planar/security/tests/test_cedar_basics.py +41 -0
  186. planar/security/tests/test_cedar_policies.py +158 -0
  187. planar/security/tests/test_jwt_principal_context.py +179 -0
  188. planar/session.py +40 -0
  189. planar/sse/.constants.py.un~ +0 -0
  190. planar/sse/.example.html.un~ +0 -0
  191. planar/sse/.hub.py.un~ +0 -0
  192. planar/sse/.model.py.un~ +0 -0
  193. planar/sse/.proxy.py.un~ +0 -0
  194. planar/sse/constants.py +1 -0
  195. planar/sse/example.html +126 -0
  196. planar/sse/hub.py +216 -0
  197. planar/sse/model.py +8 -0
  198. planar/sse/proxy.py +257 -0
  199. planar/task_local.py +37 -0
  200. planar/test_app.py +51 -0
  201. planar/test_cli.py +372 -0
  202. planar/test_config.py +512 -0
  203. planar/test_object_config.py +527 -0
  204. planar/test_object_registry.py +14 -0
  205. planar/test_sqlalchemy.py +158 -0
  206. planar/test_utils.py +105 -0
  207. planar/testing/.client.py.un~ +0 -0
  208. planar/testing/.memory_storage.py.un~ +0 -0
  209. planar/testing/.planar_test_client.py.un~ +0 -0
  210. planar/testing/.predictable_tracer.py.un~ +0 -0
  211. planar/testing/.synchronizable_tracer.py.un~ +0 -0
  212. planar/testing/.test_memory_storage.py.un~ +0 -0
  213. planar/testing/.workflow_observer.py.un~ +0 -0
  214. planar/testing/__init__.py +0 -0
  215. planar/testing/memory_storage.py +78 -0
  216. planar/testing/planar_test_client.py +54 -0
  217. planar/testing/synchronizable_tracer.py +153 -0
  218. planar/testing/test_memory_storage.py +143 -0
  219. planar/testing/workflow_observer.py +73 -0
  220. planar/utils.py +70 -0
  221. planar/workflows/.__init__.py.un~ +0 -0
  222. planar/workflows/.builtin_steps.py.un~ +0 -0
  223. planar/workflows/.concurrency_tracing.py.un~ +0 -0
  224. planar/workflows/.context.py.un~ +0 -0
  225. planar/workflows/.contrib.py.un~ +0 -0
  226. planar/workflows/.decorators.py.un~ +0 -0
  227. planar/workflows/.durable_test.py.un~ +0 -0
  228. planar/workflows/.errors.py.un~ +0 -0
  229. planar/workflows/.events.py.un~ +0 -0
  230. planar/workflows/.exceptions.py.un~ +0 -0
  231. planar/workflows/.execution.py.un~ +0 -0
  232. planar/workflows/.human.py.un~ +0 -0
  233. planar/workflows/.lock.py.un~ +0 -0
  234. planar/workflows/.misc.py.un~ +0 -0
  235. planar/workflows/.model.py.un~ +0 -0
  236. planar/workflows/.models.py.un~ +0 -0
  237. planar/workflows/.notifications.py.un~ +0 -0
  238. planar/workflows/.orchestrator.py.un~ +0 -0
  239. planar/workflows/.runtime.py.un~ +0 -0
  240. planar/workflows/.serialization.py.un~ +0 -0
  241. planar/workflows/.step.py.un~ +0 -0
  242. planar/workflows/.step_core.py.un~ +0 -0
  243. planar/workflows/.sub_workflow_runner.py.un~ +0 -0
  244. planar/workflows/.sub_workflow_scheduler.py.un~ +0 -0
  245. planar/workflows/.test_concurrency.py.un~ +0 -0
  246. planar/workflows/.test_concurrency_detection.py.un~ +0 -0
  247. planar/workflows/.test_human.py.un~ +0 -0
  248. planar/workflows/.test_lock_timeout.py.un~ +0 -0
  249. planar/workflows/.test_orchestrator.py.un~ +0 -0
  250. planar/workflows/.test_race_conditions.py.un~ +0 -0
  251. planar/workflows/.test_serialization.py.un~ +0 -0
  252. planar/workflows/.test_suspend_deserialization.py.un~ +0 -0
  253. planar/workflows/.test_workflow.py.un~ +0 -0
  254. planar/workflows/.tracing.py.un~ +0 -0
  255. planar/workflows/.types.py.un~ +0 -0
  256. planar/workflows/.util.py.un~ +0 -0
  257. planar/workflows/.utils.py.un~ +0 -0
  258. planar/workflows/.workflow.py.un~ +0 -0
  259. planar/workflows/.workflow_wrapper.py.un~ +0 -0
  260. planar/workflows/.wrappers.py.un~ +0 -0
  261. planar/workflows/__init__.py +42 -0
  262. planar/workflows/context.py +44 -0
  263. planar/workflows/contrib.py +190 -0
  264. planar/workflows/decorators.py +217 -0
  265. planar/workflows/events.py +185 -0
  266. planar/workflows/exceptions.py +34 -0
  267. planar/workflows/execution.py +198 -0
  268. planar/workflows/lock.py +229 -0
  269. planar/workflows/misc.py +5 -0
  270. planar/workflows/models.py +154 -0
  271. planar/workflows/notifications.py +96 -0
  272. planar/workflows/orchestrator.py +383 -0
  273. planar/workflows/query.py +256 -0
  274. planar/workflows/serialization.py +409 -0
  275. planar/workflows/step_core.py +373 -0
  276. planar/workflows/step_metadata.py +357 -0
  277. planar/workflows/step_testing_utils.py +86 -0
  278. planar/workflows/sub_workflow_runner.py +191 -0
  279. planar/workflows/test_concurrency_detection.py +120 -0
  280. planar/workflows/test_lock_timeout.py +140 -0
  281. planar/workflows/test_serialization.py +1195 -0
  282. planar/workflows/test_suspend_deserialization.py +231 -0
  283. planar/workflows/test_workflow.py +1967 -0
  284. planar/workflows/tracing.py +106 -0
  285. planar/workflows/wrappers.py +41 -0
  286. planar-0.5.0.dist-info/METADATA +285 -0
  287. planar-0.5.0.dist-info/RECORD +289 -0
  288. planar-0.5.0.dist-info/WHEEL +4 -0
  289. 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