licos-agent-runtime 0.3.4__tar.gz → 0.3.6__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.
Files changed (83) hide show
  1. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/PKG-INFO +2 -2
  2. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/pyproject.toml +2 -2
  3. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/app.py +105 -0
  4. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/run_config.py +1 -1
  5. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/service.py +129 -44
  6. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/stream_runner.py +31 -17
  7. licos_agent_runtime-0.3.6/src/licos_agent_runtime/workflow_checkpoint.py +194 -0
  8. licos_agent_runtime-0.3.6/src/licos_agent_runtime/workflow_durability.py +240 -0
  9. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_execution_errors.py +99 -0
  10. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_service_stream_protocol.py +29 -1
  11. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_stream_runner.py +58 -1
  12. licos_agent_runtime-0.3.6/tests/test_workflow_checkpoint.py +137 -0
  13. licos_agent_runtime-0.3.6/tests/test_workflow_durability.py +153 -0
  14. licos_agent_runtime-0.3.6/tests/test_workflow_durability_routes.py +75 -0
  15. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_workflow_protocol.py +2 -0
  16. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/.gitignore +0 -0
  17. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/README.md +0 -0
  18. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/__init__.py +0 -0
  19. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/__main__.py +0 -0
  20. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/agent_canvas.py +0 -0
  21. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/agent_config.py +0 -0
  22. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/agent_graph.py +0 -0
  23. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/agent_input.py +0 -0
  24. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/attachments.py +0 -0
  25. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/context.py +0 -0
  26. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/credit_availability.py +0 -0
  27. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/errors.py +0 -0
  28. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/event_store.py +0 -0
  29. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/event_subscriptions.py +0 -0
  30. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/graph_contract.py +0 -0
  31. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/graph_loader.py +0 -0
  32. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/harness_approval.py +0 -0
  33. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/harness_config.py +0 -0
  34. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/harness_execution.py +0 -0
  35. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/harness_planning.py +0 -0
  36. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/harness_verification.py +0 -0
  37. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/logging.py +0 -0
  38. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/model_identity.py +0 -0
  39. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/model_usage.py +0 -0
  40. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/observability.py +0 -0
  41. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/openai.py +0 -0
  42. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/output_files.py +0 -0
  43. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/parser.py +0 -0
  44. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/payload.py +0 -0
  45. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/project_agent.py +0 -0
  46. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/project_files.py +0 -0
  47. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/project_skills.py +0 -0
  48. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/runtime_config.py +0 -0
  49. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/session_files.py +0 -0
  50. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/session_memory.py +0 -0
  51. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/stream_protocol.py +0 -0
  52. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/telemetry.py +0 -0
  53. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/trace_records.py +0 -0
  54. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/verify.py +0 -0
  55. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/workflow_canvas.py +0 -0
  56. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/src/licos_agent_runtime/workflow_protocol.py +0 -0
  57. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_agent_config.py +0 -0
  58. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_agent_graph.py +0 -0
  59. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_app_cors.py +0 -0
  60. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_attachment_inputs.py +0 -0
  61. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_context.py +0 -0
  62. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_credit_availability.py +0 -0
  63. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_credit_availability_routes.py +0 -0
  64. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_deployment_layout.py +0 -0
  65. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_event_delivery_api.py +0 -0
  66. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_event_subscriptions.py +0 -0
  67. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_graph_contract.py +0 -0
  68. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_graph_loader.py +0 -0
  69. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_harness.py +0 -0
  70. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_model_identity.py +0 -0
  71. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_model_usage.py +0 -0
  72. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_observability.py +0 -0
  73. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_openai.py +0 -0
  74. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_parser.py +0 -0
  75. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_payload.py +0 -0
  76. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_project_files.py +0 -0
  77. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_project_skills.py +0 -0
  78. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_run_config.py +0 -0
  79. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_runtime_config.py +0 -0
  80. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_static_assets.py +0 -0
  81. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_stream_protocol.py +0 -0
  82. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_trace_records.py +0 -0
  83. {licos_agent_runtime-0.3.4 → licos_agent_runtime-0.3.6}/tests/test_verify.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: licos-agent-runtime
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: LICOS Python agent runtime for LangGraph/LangChain projects
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: fastapi<1,>=0.121
@@ -15,7 +15,7 @@ Requires-Dist: langgraph-sdk<1,>=0.3
15
15
  Requires-Dist: langgraph<2,>=1.1
16
16
  Requires-Dist: langsmith<1,>=0.4
17
17
  Requires-Dist: licos-dev-sdk>=0.4.9
18
- Requires-Dist: licos-platform-sdk>=0.5.7
18
+ Requires-Dist: licos-platform-sdk>=0.5.8
19
19
  Requires-Dist: pydantic<3,>=2.12
20
20
  Requires-Dist: pyyaml<7,>=6
21
21
  Requires-Dist: uvicorn<1,>=0.38
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "licos-agent-runtime"
7
- version = "0.3.4"
7
+ version = "0.3.6"
8
8
  description = "LICOS Python agent runtime for LangGraph/LangChain projects"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -21,7 +21,7 @@ dependencies = [
21
21
  "langgraph-checkpoint-postgres>=3.0,<4",
22
22
  "langsmith>=0.4,<1",
23
23
  "httpx>=0.27,<1",
24
- "licos-platform-sdk>=0.5.7",
24
+ "licos-platform-sdk>=0.5.8",
25
25
  "licos-dev-sdk>=0.4.9",
26
26
  "filelock>=3.16,<4",
27
27
  "PyYAML>=6,<7",
@@ -26,6 +26,8 @@ from .service import GraphService
26
26
  from .session_files import session_user_id
27
27
  from .stream_runner import RunOpt, agent_stream_handler, workflow_stream_handler
28
28
  from .trace_records import clear_run_records, get_run_record, list_run_records
29
+ from .workflow_durability import WorkflowInstanceConflict, WorkflowInstanceStore, load_workflow_durability_config
30
+ from .workflow_protocol import workflow_end_event, workflow_log_id, workflow_start_event
29
31
 
30
32
  logger = logging.getLogger(__name__)
31
33
 
@@ -52,6 +54,28 @@ def create_app(service: GraphService | None = None) -> FastAPI:
52
54
  subscriptions = EventSubscriptions(runtime_service)
53
55
  interactive_runs: dict[str, tuple[asyncio.Task, str, float]] = {}
54
56
 
57
+ def durable_store() -> WorkflowInstanceStore:
58
+ config = load_workflow_durability_config()
59
+ if config is None or is_agent_project():
60
+ raise HTTPException(status_code=404, detail="Durable WORKFLOW is not enabled")
61
+ return WorkflowInstanceStore(config)
62
+
63
+ async def prepare_durable(payload: dict[str, Any], ctx: Any) -> Any:
64
+ config = load_workflow_durability_config() if not is_agent_project() else None
65
+ if config is None:
66
+ return None
67
+ store = WorkflowInstanceStore(config)
68
+ try:
69
+ cached = await store.begin(payload, ctx)
70
+ except WorkflowInstanceConflict as exc:
71
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
72
+ except PermissionError as exc:
73
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
74
+ except ValueError as exc:
75
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
76
+ ctx.metadata["workflow_store"] = store
77
+ return cached
78
+
55
79
  @asynccontextmanager
56
80
  async def lifespan(app: FastAPI):
57
81
  try:
@@ -172,6 +196,9 @@ def create_app(service: GraphService | None = None) -> FastAPI:
172
196
  watch_task.cancel()
173
197
  runtime_service.approvals.unwatch(ctx.run_id)
174
198
  else:
199
+ cached = await prepare_durable(payload, ctx)
200
+ if cached is not None:
201
+ return cached
175
202
  result = await runtime_service.run_with_timeout(payload, ctx)
176
203
  if isinstance(result, dict) and result.get("status") == "timeout":
177
204
  status_code = 504
@@ -199,6 +226,8 @@ def create_app(service: GraphService | None = None) -> FastAPI:
199
226
  error_code = str(exc.code)
200
227
  error_message = str(exc)
201
228
  raise
229
+ except HTTPException:
230
+ raise
202
231
  except Exception as exc:
203
232
  status_code = 500
204
233
  error_response = runtime_service.error_classifier.get_error_response(
@@ -264,6 +293,16 @@ def create_app(service: GraphService | None = None) -> FastAPI:
264
293
  workspace_id=ctx.identity.workspace_id,
265
294
  )
266
295
 
296
+ cached = await prepare_durable(payload, ctx)
297
+ if cached is not None:
298
+ async def cached_events():
299
+ log_id = workflow_log_id()
300
+ yield runtime_service._sse_event(workflow_start_event(run_id=ctx.run_id, log_id=log_id))
301
+ yield runtime_service._sse_event(workflow_end_event(
302
+ run_id=ctx.run_id, log_id=log_id, output=cached, time_cost_ms=0,
303
+ ))
304
+ return StreamingResponse(cached_events(), media_type="text/event-stream")
305
+
267
306
  if is_agent_project():
268
307
  stream_generator = agent_stream_handler(
269
308
  payload=payload,
@@ -300,6 +339,72 @@ def create_app(service: GraphService | None = None) -> FastAPI:
300
339
  except PermissionError as exc:
301
340
  raise HTTPException(status_code=403, detail=str(exc)) from exc
302
341
 
342
+ @app.get("/workflow/instances/{instance_id}")
343
+ async def workflow_instance_status(instance_id: str, request: Request) -> dict[str, Any]:
344
+ ctx = new_context(method="workflow_instance_status", headers=request.headers)
345
+ request_context.set(ctx)
346
+ try:
347
+ row = await durable_store().get(instance_id, ctx)
348
+ return {
349
+ "instance_id": instance_id,
350
+ "status": row["status"],
351
+ "graph_version": row["graph_version"],
352
+ "last_run_id": row["last_run_id"],
353
+ "output": json.loads(row["output_data"]) if row["output_data"] else None,
354
+ "error": row["error_message"],
355
+ }
356
+ except LookupError as exc:
357
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
358
+ except PermissionError as exc:
359
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
360
+ except ValueError as exc:
361
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
362
+
363
+ @app.post("/workflow/instances/{instance_id}/resume")
364
+ async def workflow_instance_resume(instance_id: str, request: Request) -> dict[str, Any]:
365
+ ctx = new_context(method="workflow_resume", headers=request.headers)
366
+ request_context.set(ctx)
367
+ store = durable_store()
368
+ try:
369
+ await ensure_credit_available(
370
+ "GRAPH_RUN",
371
+ request_id=ctx.run_id,
372
+ current_user_id=ctx.identity.current_user_id,
373
+ current_user_org_id=ctx.identity.current_user_org_id,
374
+ owner_user_id=ctx.identity.user_id,
375
+ owner_org_id=ctx.identity.owner_org_id,
376
+ workspace_id=ctx.identity.workspace_id,
377
+ )
378
+ await store.claim_resume(instance_id, ctx)
379
+ ctx.metadata["workflow_store"] = store
380
+ return await runtime_service.run_with_timeout(None, ctx)
381
+ except WorkflowInstanceConflict as exc:
382
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
383
+ except LookupError as exc:
384
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
385
+ except PermissionError as exc:
386
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
387
+ except ValueError as exc:
388
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
389
+
390
+ @app.post("/workflow/instances/{instance_id}/cancel")
391
+ async def workflow_instance_cancel(instance_id: str, request: Request) -> dict[str, Any]:
392
+ ctx = new_context(method="workflow_instance_cancel", headers=request.headers)
393
+ request_context.set(ctx)
394
+ try:
395
+ result = await durable_store().cancel(instance_id, ctx)
396
+ if result["status"] == "cancelled" and result["run_id"] in runtime_service.running_tasks:
397
+ runtime_service.cancel_run(result["run_id"], ctx)
398
+ return result
399
+ except WorkflowInstanceConflict as exc:
400
+ raise HTTPException(status_code=409, detail=str(exc)) from exc
401
+ except LookupError as exc:
402
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
403
+ except PermissionError as exc:
404
+ raise HTTPException(status_code=403, detail=str(exc)) from exc
405
+ except ValueError as exc:
406
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
407
+
303
408
  @app.get("/agent/approvals/{run_id}")
304
409
  async def agent_approvals(run_id: str, request: Request) -> dict[str, Any]:
305
410
  ctx = new_context(method="approval_list", headers=request.headers)
@@ -9,7 +9,7 @@ def init_run_config(graph: Any, ctx: Context) -> dict[str, Any]:
9
9
  del graph
10
10
  return {
11
11
  "configurable": {
12
- "thread_id": ctx.run_id,
12
+ "thread_id": ctx.metadata.get("workflow_instance_id", ctx.run_id),
13
13
  "run_id": ctx.run_id,
14
14
  },
15
15
  "metadata": {
@@ -66,6 +66,7 @@ from .workflow_protocol import (
66
66
  workflow_start_event,
67
67
  )
68
68
  from .workflow_canvas import canvas_submit_response, workflow_canvas_response, workflow_node_code_locations
69
+ from .workflow_durability import WorkflowInstanceStore, load_workflow_durability_config
69
70
 
70
71
  logger = logging.getLogger(__name__)
71
72
 
@@ -200,7 +201,8 @@ class GraphService:
200
201
  self.agent_module = agent_module
201
202
  self.graph_module = graph_module
202
203
  self.running_tasks: dict[str, asyncio.Task] = {}
203
- self._run_owners: dict[str, str] = {}
204
+ self._run_owners: dict[str, tuple[str, str | None, str | None]] = {}
205
+ self._run_contexts: dict[str, Context] = {}
204
206
  self.error_classifier = ErrorClassifier()
205
207
  self._agent_stream_runner = AgentStreamRunner()
206
208
  self._workflow_stream_runner = WorkflowStreamRunner()
@@ -246,6 +248,8 @@ class GraphService:
246
248
 
247
249
  if self._graph is None:
248
250
  self._graph = get_graph_instance(self.graph_module, ctx)
251
+ if load_workflow_durability_config() and getattr(self._graph, "checkpointer", None) is None:
252
+ raise ValueError("Durable WORKFLOW must compile its graph with workflow_checkpointer()")
249
253
  return self._graph
250
254
 
251
255
  @staticmethod
@@ -270,15 +274,20 @@ class GraphService:
270
274
  stream_runner = self._get_stream_runner()
271
275
  yield from stream_runner.stream(payload, graph, run_config, ctx)
272
276
 
273
- async def run(self, payload: dict[str, Any], ctx: Context | None = None) -> dict[str, Any]:
277
+ async def run(self, payload: dict[str, Any] | None, ctx: Context | None = None) -> dict[str, Any]:
274
278
  ctx = ctx or new_context("run")
275
279
  run_id = ctx.run_id
276
280
  agent_project = is_agent_project()
281
+ workflow_store: WorkflowInstanceStore | None = ctx.metadata.get("workflow_store")
282
+ heartbeat_task: asyncio.Task[Any] | None = None
277
283
  recorder = RunTraceRecorder(ctx, project_kind="agent" if agent_project else "workflow")
278
284
  logger.info("Starting run: run_id=%s", run_id)
279
285
 
280
286
  context_token = request_context.set(ctx)
281
287
  try:
288
+ if workflow_store is not None:
289
+ await workflow_store.assert_running(ctx)
290
+ heartbeat_task = asyncio.create_task(workflow_store.heartbeat(ctx, asyncio.current_task()))
282
291
  if agent_project:
283
292
  request = payload
284
293
  snapshot = self._agent_snapshot(ctx)
@@ -302,8 +311,9 @@ class GraphService:
302
311
  ) if snapshot and snapshot.harness else await invocation
303
312
  turn.commit(extract_answer(result))
304
313
  else:
305
- payload = materialize_input_files(payload)
306
- recorder.set_root_input(payload)
314
+ if payload is not None:
315
+ payload = materialize_input_files(payload)
316
+ recorder.set_root_input(payload if payload is not None else {"resume": True})
307
317
  graph = self._get_graph(ctx)
308
318
  self._attach_trace_code_locations(recorder, graph)
309
319
  run_config = append_trace_callback(init_run_config(graph, ctx), recorder)
@@ -314,9 +324,13 @@ class GraphService:
314
324
  result = await enrich_workflow_output_file_urls(result, ctx)
315
325
  if isinstance(result, dict):
316
326
  result.setdefault("run_id", run_id)
327
+ if workflow_store is not None:
328
+ await workflow_store.update_status(ctx, "completed", output=result)
317
329
  _save_record_safely(recorder, output=result)
318
330
  return result
319
331
  response = {"run_id": run_id, "result": result}
332
+ if workflow_store is not None:
333
+ await workflow_store.update_status(ctx, "completed", output=response)
320
334
  _save_record_safely(recorder, output=response)
321
335
  return response
322
336
  if result is None:
@@ -329,11 +343,17 @@ class GraphService:
329
343
  _save_record_safely(recorder, output=response)
330
344
  return response
331
345
  except asyncio.CancelledError:
346
+ if workflow_store is not None:
347
+ status = ("cancelled" if ctx.metadata.get("workflow_cancel_requested") else
348
+ "failed" if ctx.metadata.get("workflow_timeout") else "interrupted")
349
+ await workflow_store.update_status(ctx, status, error="Execution timeout" if status == "failed" else None)
332
350
  logger.info("Run cancelled: run_id=%s", run_id)
333
351
  response = {"status": "cancelled", "run_id": run_id, "message": "Execution was cancelled"}
334
352
  _save_record_safely(recorder, status="error", output=response, error="cancelled")
335
353
  raise
336
354
  except Exception as exc:
355
+ if workflow_store is not None:
356
+ await workflow_store.update_status(ctx, "failed", error=str(exc))
337
357
  _save_record_safely(recorder, status="error", error=exc)
338
358
  err = self.error_classifier.classify(exc, {"node_name": "run", "run_id": run_id})
339
359
  logger.error(
@@ -344,8 +364,14 @@ class GraphService:
344
364
  )
345
365
  raise
346
366
  finally:
347
- request_context.reset(context_token)
348
- self.approvals.forget_run(run_id)
367
+ try:
368
+ if heartbeat_task is not None:
369
+ heartbeat_task.cancel()
370
+ with suppress(asyncio.CancelledError):
371
+ await heartbeat_task
372
+ finally:
373
+ request_context.reset(context_token)
374
+ self.approvals.forget_run(run_id)
349
375
  self.running_tasks.pop(run_id, None)
350
376
  telemetry.flush()
351
377
 
@@ -358,7 +384,7 @@ class GraphService:
358
384
  except Exception as exc:
359
385
  logger.warning("Failed to resolve workflow code locations: trace_id=%s error=%s", recorder.trace_id, exc)
360
386
 
361
- async def run_with_timeout(self, payload: dict[str, Any], ctx: Context) -> dict[str, Any]:
387
+ async def run_with_timeout(self, payload: dict[str, Any] | None, ctx: Context) -> dict[str, Any]:
362
388
  active = self.running_tasks.get(ctx.run_id)
363
389
  if active is not None and not active.done():
364
390
  raise ValueError(f"run_id is already active: {ctx.run_id}")
@@ -367,25 +393,30 @@ class GraphService:
367
393
  snapshot = self._agent_snapshot(ctx)
368
394
  if snapshot and snapshot.harness:
369
395
  timeout = min(timeout, snapshot.harness.execution.max_duration_seconds)
370
- if ctx.identity.user_token:
371
- self._run_owners[ctx.run_id] = session_user_id(ctx)
396
+ if ctx.identity.user_token:
397
+ self._run_owners[ctx.run_id] = self._run_owner(ctx)
398
+ self._run_contexts[ctx.run_id] = ctx
372
399
  task = asyncio.create_task(self.run(payload, ctx))
373
400
  self.running_tasks[ctx.run_id] = task
374
401
  try:
375
402
  completed, _ = await asyncio.wait({task}, timeout=timeout)
376
403
  if completed:
377
404
  return await task
405
+ ctx.metadata["workflow_timeout"] = True
378
406
  return {
379
407
  "status": "timeout",
380
408
  "run_id": ctx.run_id,
381
409
  "message": f"Execution timeout: exceeded {timeout:g} seconds",
382
410
  }
383
411
  finally:
384
- if not task.done():
385
- task.cancel()
386
- with suppress(asyncio.CancelledError):
387
- await task
388
- self._run_owners.pop(ctx.run_id, None)
412
+ try:
413
+ if not task.done():
414
+ task.cancel()
415
+ with suppress(asyncio.CancelledError):
416
+ await task
417
+ finally:
418
+ self._run_owners.pop(ctx.run_id, None)
419
+ self._run_contexts.pop(ctx.run_id, None)
389
420
 
390
421
  async def stream_sse(
391
422
  self,
@@ -446,7 +477,7 @@ class GraphService:
446
477
  recorder = RunTraceRecorder(ctx, project_kind="agent")
447
478
 
448
479
  if ctx.identity.user_token:
449
- self._run_owners[run_id] = session_user_id(ctx)
480
+ self._run_owners[run_id] = self._run_owner(ctx)
450
481
 
451
482
  yield self._sse_event(message_start_event(local_message_id, server_message_id))
452
483
 
@@ -598,35 +629,45 @@ class GraphService:
598
629
  run_opt: RunOpt,
599
630
  ) -> AsyncIterable[str]:
600
631
  run_id = ctx.run_id
632
+ workflow_store: WorkflowInstanceStore | None = ctx.metadata.get("workflow_store")
633
+ heartbeat_task: asyncio.Task[Any] | None = None
601
634
  log_id = workflow_log_id()
602
635
  start_perf = time.perf_counter()
603
636
  event_index = 0
604
- payload = materialize_input_files(payload)
605
637
  recorder = RunTraceRecorder(ctx, project_kind="workflow")
606
- recorder.set_root_input(payload)
607
638
  final_output: Any = {}
608
639
  stream_error: BaseException | str | None = None
609
640
  status_code = 200
610
641
  error_code: str | None = None
611
-
612
- yield self._sse_event(workflow_start_event(run_id=run_id, log_id=log_id), event_index)
613
- event_index += 1
642
+ saw_end = False
643
+ if ctx.identity.user_token:
644
+ self._run_owners[run_id] = self._run_owner(ctx)
645
+ self._run_contexts[run_id] = ctx
646
+ if workflow_store is not None:
647
+ heartbeat_task = asyncio.create_task(workflow_store.heartbeat(ctx, asyncio.current_task()))
614
648
 
615
649
  queue: asyncio.Queue[dict[str, Any] | BaseException | None] = asyncio.Queue()
616
650
 
617
651
  async def produce() -> None:
618
652
  try:
619
- async for event in self._workflow_platform_events(payload, ctx, run_opt, log_id, start_perf, recorder):
653
+ if workflow_store is not None:
654
+ await workflow_store.assert_running(ctx)
655
+ prepared_payload = materialize_input_files(payload)
656
+ recorder.set_root_input(prepared_payload)
657
+ async for event in self._workflow_platform_events(prepared_payload, ctx, run_opt, log_id, start_perf, recorder):
620
658
  await queue.put(event)
621
659
  except BaseException as exc:
622
660
  await queue.put(exc)
623
661
  finally:
624
662
  await queue.put(None)
625
663
 
664
+ context_token = request_context.set(ctx)
626
665
  producer = asyncio.create_task(produce())
627
666
  ping_interval = run_opt.workflow_ping_interval_seconds
628
667
 
629
668
  try:
669
+ yield self._sse_event(workflow_start_event(run_id=run_id, log_id=log_id), event_index)
670
+ event_index += 1
630
671
  while True:
631
672
  try:
632
673
  if ping_interval and ping_interval > 0:
@@ -646,6 +687,8 @@ class GraphService:
646
687
  code = stream_error_code(item) or WORKFLOW_DEFAULT_ERROR_CODE
647
688
  message = str(item) or item.__class__.__name__
648
689
  stream_error = item
690
+ if workflow_store is not None:
691
+ await workflow_store.update_status(ctx, "failed", error=message)
649
692
  status_code = 500
650
693
  error_code = str(code)
651
694
  yield self._sse_event(
@@ -657,38 +700,66 @@ class GraphService:
657
700
 
658
701
  if item.get("type") == "workflow_end":
659
702
  final_output = item.get("output", {})
703
+ if workflow_store is not None:
704
+ await workflow_store.update_status(ctx, "completed", output=final_output)
705
+ saw_end = True
660
706
  if item.get("type") == "error":
661
707
  stream_error = item.get("message") or "workflow error"
708
+ if workflow_store is not None:
709
+ await workflow_store.update_status(ctx, "failed", error=str(stream_error))
662
710
  status_code = 500
663
711
  error_code = str(item.get("code") or WORKFLOW_DEFAULT_ERROR_CODE)
664
712
  yield self._sse_event(item, event_index)
665
713
  event_index += 1
714
+ if item.get("type") in {"error", "workflow_end"}:
715
+ break
666
716
  except asyncio.CancelledError:
667
717
  producer.cancel()
668
- stream_error = "cancelled"
718
+ cancelled = workflow_store is None or bool(ctx.metadata.get("workflow_cancel_requested"))
719
+ stream_error = "cancelled" if cancelled else "interrupted"
720
+ if workflow_store is not None:
721
+ await workflow_store.update_status(ctx, "cancelled" if cancelled else "interrupted")
669
722
  status_code = 499
670
- error_code = "CANCELLED"
723
+ error_code = "CANCELLED" if cancelled else "INTERRUPTED"
671
724
  yield self._sse_event(
672
- workflow_error_event(run_id=run_id, log_id=log_id, code=1, message="cancelled"),
725
+ workflow_error_event(run_id=run_id, log_id=log_id, code=1, message=str(stream_error)),
673
726
  event_index,
674
727
  )
675
728
  finally:
676
- with suppress(asyncio.CancelledError):
677
- await producer
678
- if stream_error is not None:
679
- _save_record_safely(recorder, status="error", output=final_output, error=stream_error)
680
- else:
681
- _save_record_safely(recorder, output=final_output)
682
- observability.record_request_end(
683
- ctx,
684
- route="/stream_run",
685
- status_code=status_code,
686
- latency_ms=_elapsed_perf_ms(start_perf),
687
- error_code=error_code,
688
- error_message=stream_error,
689
- )
690
- self.running_tasks.pop(run_id, None)
691
- telemetry.flush()
729
+ try:
730
+ if not producer.done():
731
+ producer.cancel()
732
+ with suppress(asyncio.CancelledError):
733
+ await producer
734
+ if workflow_store is not None and not saw_end and stream_error is None:
735
+ stream_error = "workflow stream ended without a terminal event"
736
+ await workflow_store.update_status(ctx, "failed", error=str(stream_error))
737
+ finally:
738
+ try:
739
+ if heartbeat_task is not None:
740
+ heartbeat_task.cancel()
741
+ with suppress(asyncio.CancelledError):
742
+ await heartbeat_task
743
+ finally:
744
+ try:
745
+ if stream_error is not None:
746
+ _save_record_safely(recorder, status="error", output=final_output, error=stream_error)
747
+ else:
748
+ _save_record_safely(recorder, output=final_output)
749
+ observability.record_request_end(
750
+ ctx,
751
+ route="/stream_run",
752
+ status_code=status_code,
753
+ latency_ms=_elapsed_perf_ms(start_perf),
754
+ error_code=error_code,
755
+ error_message=stream_error,
756
+ )
757
+ finally:
758
+ self.running_tasks.pop(run_id, None)
759
+ self._run_owners.pop(run_id, None)
760
+ self._run_contexts.pop(run_id, None)
761
+ request_context.reset(context_token)
762
+ telemetry.flush()
692
763
 
693
764
  async def _workflow_platform_events(
694
765
  self,
@@ -754,6 +825,7 @@ class GraphService:
754
825
  code=WORKFLOW_DEFAULT_ERROR_CODE,
755
826
  message=str(error),
756
827
  )
828
+ return
757
829
 
758
830
  final_output = await enrich_workflow_output_file_urls(final_output, ctx)
759
831
  yield workflow_end_event(
@@ -806,6 +878,10 @@ class GraphService:
806
878
  return value.strip()
807
879
  return node_id or "workflow_node"
808
880
 
881
+ @staticmethod
882
+ def _run_owner(ctx: Context) -> tuple[str, str | None, str | None]:
883
+ return (session_user_id(ctx), ctx.identity.workspace_id, ctx.identity.project_id)
884
+
809
885
  def cancel_run(self, run_id: str, ctx: Context | None = None) -> dict[str, Any]:
810
886
  logger.info("Attempting to cancel run_id=%s", run_id)
811
887
  task = self.running_tasks.get(run_id)
@@ -821,12 +897,15 @@ class GraphService:
821
897
  "run_id": run_id,
822
898
  "message": "Task has already completed.",
823
899
  }
824
- if ctx is not None and is_agent_project():
900
+ if ctx is not None:
825
901
  if not ctx.identity.user_token:
826
- raise ValueError("AGENT cancellation requires an authenticated Bearer token")
902
+ raise ValueError("Cancellation requires an authenticated Bearer token")
827
903
  owner = self._run_owners.get(run_id)
828
- if owner is None or session_user_id(ctx) != owner:
829
- raise PermissionError("Only the requesting user may cancel this AGENT run")
904
+ if owner is None or self._run_owner(ctx) != owner:
905
+ raise PermissionError("Only the requesting user may cancel this run")
906
+ request_ctx = self._run_contexts.get(run_id)
907
+ if request_ctx is not None:
908
+ request_ctx.metadata["workflow_cancel_requested"] = True
830
909
  task.cancel()
831
910
  return {
832
911
  "status": "success",
@@ -897,6 +976,12 @@ class GraphService:
897
976
  parser = LangGraphParser(graph)
898
977
  result["nodes"] = parser.get_nodes_metadata()
899
978
  result["agent_config"] = read_agent_config()
979
+ else:
980
+ durability = load_workflow_durability_config()
981
+ result["durability"] = {
982
+ "enabled": durability is not None,
983
+ "graph_version": durability.graph_version if durability else None,
984
+ }
900
985
  return result
901
986
 
902
987
  def agent_config_info(self) -> dict[str, Any]:
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import asyncio
4
+ from contextlib import suppress
4
5
  from dataclasses import dataclass
5
6
  from typing import Any, AsyncIterable, Callable, Iterable
6
7
 
@@ -119,6 +120,8 @@ async def _registered_stream(
119
120
  run_opt: RunOpt | None = None,
120
121
  ) -> AsyncIterable[str]:
121
122
  queue: asyncio.Queue[str | None] = asyncio.Queue()
123
+ detached = False
124
+ durable = ctx.metadata.get("workflow_store") is not None
122
125
 
123
126
  async def produce() -> None:
124
127
  try:
@@ -126,28 +129,39 @@ async def _registered_stream(
126
129
  if run_opt is not None:
127
130
  kwargs["run_opt"] = run_opt
128
131
  async for item in stream_sse_func(payload, **kwargs):
129
- await queue.put(item)
132
+ if not detached:
133
+ await queue.put(item)
130
134
  except asyncio.CancelledError:
131
- await queue.put(sse_event_func({"status": "cancelled", "run_id": run_id}))
135
+ if not detached:
136
+ await queue.put(sse_event_func({"status": "cancelled", "run_id": run_id}))
132
137
  raise
133
138
  except Exception as exc:
134
- await queue.put(sse_event_func(error_classifier.get_error_response(exc, {"run_id": run_id})))
139
+ if not detached:
140
+ await queue.put(sse_event_func(error_classifier.get_error_response(exc, {"run_id": run_id})))
135
141
  finally:
136
- await queue.put(None)
142
+ if not detached:
143
+ await queue.put(None)
137
144
 
138
145
  task = asyncio.create_task(produce())
139
146
  register_task_func(run_id, task)
140
147
 
141
- while True:
142
- item = await queue.get()
143
- if item is None:
144
- break
145
- yield item
146
-
147
- if task.done():
148
- try:
149
- exception = task.exception()
150
- except asyncio.CancelledError:
151
- exception = None
152
- if exception is not None:
153
- raise exception
148
+ try:
149
+ while True:
150
+ item = await queue.get()
151
+ if item is None:
152
+ break
153
+ yield item
154
+
155
+ if task.done():
156
+ try:
157
+ exception = task.exception()
158
+ except asyncio.CancelledError:
159
+ exception = None
160
+ if exception is not None:
161
+ raise exception
162
+ finally:
163
+ detached = True
164
+ if not durable and not task.done():
165
+ task.cancel()
166
+ with suppress(asyncio.CancelledError):
167
+ await task