gobby 0.2.9__py3-none-any.whl → 0.2.11__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 (134) hide show
  1. gobby/__init__.py +1 -1
  2. gobby/adapters/__init__.py +6 -0
  3. gobby/adapters/base.py +11 -2
  4. gobby/adapters/claude_code.py +2 -2
  5. gobby/adapters/codex_impl/adapter.py +38 -43
  6. gobby/adapters/copilot.py +324 -0
  7. gobby/adapters/cursor.py +373 -0
  8. gobby/adapters/gemini.py +2 -26
  9. gobby/adapters/windsurf.py +359 -0
  10. gobby/agents/definitions.py +162 -2
  11. gobby/agents/isolation.py +33 -1
  12. gobby/agents/pty_reader.py +192 -0
  13. gobby/agents/registry.py +10 -1
  14. gobby/agents/runner.py +24 -8
  15. gobby/agents/sandbox.py +8 -3
  16. gobby/agents/session.py +4 -0
  17. gobby/agents/spawn.py +9 -2
  18. gobby/agents/spawn_executor.py +49 -61
  19. gobby/agents/spawners/command_builder.py +4 -4
  20. gobby/app_context.py +5 -0
  21. gobby/cli/__init__.py +4 -0
  22. gobby/cli/install.py +259 -4
  23. gobby/cli/installers/__init__.py +12 -0
  24. gobby/cli/installers/copilot.py +242 -0
  25. gobby/cli/installers/cursor.py +244 -0
  26. gobby/cli/installers/shared.py +3 -0
  27. gobby/cli/installers/windsurf.py +242 -0
  28. gobby/cli/pipelines.py +639 -0
  29. gobby/cli/sessions.py +3 -1
  30. gobby/cli/skills.py +209 -0
  31. gobby/cli/tasks/crud.py +6 -5
  32. gobby/cli/tasks/search.py +1 -1
  33. gobby/cli/ui.py +116 -0
  34. gobby/cli/workflows.py +38 -17
  35. gobby/config/app.py +5 -0
  36. gobby/config/skills.py +23 -2
  37. gobby/hooks/broadcaster.py +9 -0
  38. gobby/hooks/event_handlers/_base.py +6 -1
  39. gobby/hooks/event_handlers/_session.py +44 -130
  40. gobby/hooks/events.py +48 -0
  41. gobby/hooks/hook_manager.py +25 -3
  42. gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
  43. gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
  44. gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
  45. gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
  46. gobby/llm/__init__.py +14 -1
  47. gobby/llm/claude.py +217 -1
  48. gobby/llm/service.py +149 -0
  49. gobby/mcp_proxy/instructions.py +9 -27
  50. gobby/mcp_proxy/models.py +1 -0
  51. gobby/mcp_proxy/registries.py +56 -9
  52. gobby/mcp_proxy/server.py +6 -2
  53. gobby/mcp_proxy/services/tool_filter.py +7 -0
  54. gobby/mcp_proxy/services/tool_proxy.py +19 -1
  55. gobby/mcp_proxy/stdio.py +37 -21
  56. gobby/mcp_proxy/tools/agents.py +7 -0
  57. gobby/mcp_proxy/tools/hub.py +30 -1
  58. gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
  59. gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
  60. gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
  61. gobby/mcp_proxy/tools/orchestration/review.py +17 -4
  62. gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
  63. gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
  64. gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
  65. gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
  66. gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
  67. gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
  68. gobby/mcp_proxy/tools/skills/__init__.py +184 -30
  69. gobby/mcp_proxy/tools/spawn_agent.py +229 -14
  70. gobby/mcp_proxy/tools/tasks/_context.py +8 -0
  71. gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
  72. gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
  73. gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
  74. gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
  75. gobby/mcp_proxy/tools/tasks/_search.py +1 -1
  76. gobby/mcp_proxy/tools/workflows/__init__.py +9 -2
  77. gobby/mcp_proxy/tools/workflows/_lifecycle.py +12 -1
  78. gobby/mcp_proxy/tools/workflows/_query.py +45 -26
  79. gobby/mcp_proxy/tools/workflows/_terminal.py +39 -3
  80. gobby/mcp_proxy/tools/worktrees.py +54 -15
  81. gobby/memory/context.py +5 -5
  82. gobby/runner.py +108 -6
  83. gobby/servers/http.py +7 -1
  84. gobby/servers/routes/__init__.py +2 -0
  85. gobby/servers/routes/admin.py +44 -0
  86. gobby/servers/routes/mcp/endpoints/execution.py +18 -25
  87. gobby/servers/routes/mcp/hooks.py +10 -1
  88. gobby/servers/routes/pipelines.py +227 -0
  89. gobby/servers/websocket.py +314 -1
  90. gobby/sessions/analyzer.py +87 -1
  91. gobby/sessions/manager.py +5 -5
  92. gobby/sessions/transcripts/__init__.py +3 -0
  93. gobby/sessions/transcripts/claude.py +5 -0
  94. gobby/sessions/transcripts/codex.py +5 -0
  95. gobby/sessions/transcripts/gemini.py +5 -0
  96. gobby/skills/hubs/__init__.py +25 -0
  97. gobby/skills/hubs/base.py +234 -0
  98. gobby/skills/hubs/claude_plugins.py +328 -0
  99. gobby/skills/hubs/clawdhub.py +289 -0
  100. gobby/skills/hubs/github_collection.py +465 -0
  101. gobby/skills/hubs/manager.py +263 -0
  102. gobby/skills/hubs/skillhub.py +342 -0
  103. gobby/storage/memories.py +4 -4
  104. gobby/storage/migrations.py +95 -3
  105. gobby/storage/pipelines.py +367 -0
  106. gobby/storage/sessions.py +23 -4
  107. gobby/storage/skills.py +1 -1
  108. gobby/storage/tasks/_aggregates.py +2 -2
  109. gobby/storage/tasks/_lifecycle.py +4 -4
  110. gobby/storage/tasks/_models.py +7 -1
  111. gobby/storage/tasks/_queries.py +3 -3
  112. gobby/sync/memories.py +4 -3
  113. gobby/tasks/commits.py +48 -17
  114. gobby/workflows/actions.py +75 -0
  115. gobby/workflows/context_actions.py +246 -5
  116. gobby/workflows/definitions.py +119 -1
  117. gobby/workflows/detection_helpers.py +23 -11
  118. gobby/workflows/enforcement/task_policy.py +18 -0
  119. gobby/workflows/engine.py +20 -1
  120. gobby/workflows/evaluator.py +8 -5
  121. gobby/workflows/lifecycle_evaluator.py +57 -26
  122. gobby/workflows/loader.py +567 -30
  123. gobby/workflows/lobster_compat.py +147 -0
  124. gobby/workflows/pipeline_executor.py +801 -0
  125. gobby/workflows/pipeline_state.py +172 -0
  126. gobby/workflows/pipeline_webhooks.py +206 -0
  127. gobby/workflows/premature_stop.py +5 -0
  128. gobby/worktrees/git.py +135 -20
  129. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
  130. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/RECORD +134 -106
  131. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
  132. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
  133. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
  134. {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,801 @@
1
+ """Pipeline executor for running typed pipeline workflows."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import secrets
8
+ import shlex
9
+ from typing import TYPE_CHECKING, Any
10
+
11
+ from gobby.workflows.pipeline_state import (
12
+ ApprovalRequired,
13
+ ExecutionStatus,
14
+ PipelineExecution,
15
+ StepStatus,
16
+ )
17
+ from gobby.workflows.safe_evaluator import SafeExpressionEvaluator
18
+
19
+ if TYPE_CHECKING:
20
+ from gobby.storage.database import DatabaseProtocol
21
+ from gobby.storage.pipelines import LocalPipelineExecutionManager
22
+ from gobby.workflows.definitions import PipelineDefinition
23
+ from gobby.workflows.templates import TemplateEngine
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ # Type alias for event callback
29
+ PipelineEventCallback = Any # Callable[[str, str, dict], Awaitable[None]]
30
+
31
+
32
+ class PipelineExecutor:
33
+ """Executor for pipeline workflows with typed data flow between steps.
34
+
35
+ Handles:
36
+ - Creating and tracking execution records
37
+ - Iterating through steps in order
38
+ - Building context with inputs and step outputs
39
+ - Executing exec commands, prompts, and nested pipelines
40
+ - Webhook notifications
41
+ - WebSocket event broadcasting for real-time updates
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ db: DatabaseProtocol,
47
+ execution_manager: LocalPipelineExecutionManager,
48
+ llm_service: Any,
49
+ template_engine: TemplateEngine | None = None,
50
+ webhook_notifier: Any | None = None,
51
+ loader: Any | None = None,
52
+ event_callback: PipelineEventCallback | None = None,
53
+ ):
54
+ """Initialize the pipeline executor.
55
+
56
+ Args:
57
+ db: Database connection for transactions
58
+ execution_manager: Manager for pipeline execution records
59
+ llm_service: LLM service for prompt steps
60
+ template_engine: Optional template engine for variable substitution
61
+ webhook_notifier: Optional notifier for webhook callbacks
62
+ loader: Optional workflow loader for nested pipelines
63
+ event_callback: Optional async callback for broadcasting events.
64
+ Signature: async def callback(event: str, execution_id: str, **kwargs)
65
+ """
66
+ self.db = db
67
+ self.execution_manager = execution_manager
68
+ self.llm_service = llm_service
69
+ self.template_engine = template_engine
70
+ self.webhook_notifier = webhook_notifier
71
+ self.loader = loader
72
+ self.event_callback = event_callback
73
+
74
+ async def _emit_event(self, event: str, execution_id: str, **kwargs: Any) -> None:
75
+ """Emit a pipeline event via the callback if configured.
76
+
77
+ Args:
78
+ event: Event type (pipeline_started, step_completed, etc.)
79
+ execution_id: Pipeline execution ID
80
+ **kwargs: Additional event data
81
+ """
82
+ if self.event_callback:
83
+ try:
84
+ await self.event_callback(event, execution_id, **kwargs)
85
+ except Exception as e:
86
+ logger.warning(f"Failed to emit pipeline event {event}: {e}")
87
+
88
+ async def execute(
89
+ self,
90
+ pipeline: PipelineDefinition,
91
+ inputs: dict[str, Any],
92
+ project_id: str,
93
+ execution_id: str | None = None,
94
+ session_id: str | None = None,
95
+ ) -> PipelineExecution:
96
+ """Execute a pipeline workflow.
97
+
98
+ Args:
99
+ pipeline: The pipeline definition to execute
100
+ inputs: Input values for the pipeline
101
+ project_id: Project context for the execution
102
+ execution_id: Optional existing execution ID (for resuming)
103
+ session_id: Optional session that triggered the execution
104
+
105
+ Returns:
106
+ The completed PipelineExecution record
107
+ """
108
+ # 1. Create or load execution record
109
+ if execution_id:
110
+ execution = self.execution_manager.get_execution(execution_id)
111
+ if not execution:
112
+ raise ValueError(f"Execution {execution_id} not found")
113
+ else:
114
+ execution = self.execution_manager.create_execution(
115
+ pipeline_name=pipeline.name,
116
+ inputs_json=json.dumps(inputs),
117
+ session_id=session_id,
118
+ )
119
+
120
+ # 2. Update status to RUNNING
121
+ updated = self.execution_manager.update_execution_status(
122
+ execution_id=execution.id,
123
+ status=ExecutionStatus.RUNNING,
124
+ )
125
+ if updated:
126
+ execution = updated
127
+
128
+ # Emit pipeline_started event
129
+ await self._emit_event(
130
+ "pipeline_started",
131
+ execution.id,
132
+ pipeline_name=pipeline.name,
133
+ inputs=inputs,
134
+ step_count=len(pipeline.steps),
135
+ )
136
+
137
+ # 3. Build execution context
138
+ context: dict[str, Any] = {
139
+ "inputs": inputs,
140
+ "steps": {}, # Will hold step outputs as they complete
141
+ }
142
+
143
+ # Fetch existing steps if resuming
144
+ existing_steps = {}
145
+ if execution_id:
146
+ steps = self.execution_manager.get_steps_for_execution(execution_id)
147
+ existing_steps = {s.step_id: s for s in steps}
148
+
149
+ try:
150
+ # 4. Iterate through steps in order
151
+ for step in pipeline.steps:
152
+ # Check for existing execution
153
+ step_execution = existing_steps.get(step.id)
154
+
155
+ if step_execution:
156
+ # If completed, load output into context and skip
157
+ if step_execution.status == StepStatus.COMPLETED:
158
+ logger.info(f"Skipping completed step {step.id}")
159
+ output = None
160
+ if step_execution.output_json:
161
+ try:
162
+ output = json.loads(step_execution.output_json)
163
+ except json.JSONDecodeError:
164
+ output = step_execution.output_json
165
+ context["steps"][step.id] = {"output": output}
166
+ continue
167
+
168
+ # If skipped, just skip
169
+ if step_execution.status == StepStatus.SKIPPED:
170
+ logger.info(f"Skipping previously skipped step {step.id}")
171
+ continue
172
+
173
+ # If waiting approval, check if we should check gate again
174
+ # If we are resuming, it might have been approved
175
+ if step_execution.status == StepStatus.WAITING_APPROVAL:
176
+ # If the step is still marked as waiting approval in DB,
177
+ # checking the gate will just re-raise ApprovalRequired.
178
+ # If it was approved, status should be COMPLETED.
179
+ # So we can just proceed to check/execute.
180
+ pass
181
+
182
+ # Create new step execution if not exists
183
+ if not step_execution:
184
+ step_execution = self.execution_manager.create_step_execution(
185
+ execution_id=execution.id,
186
+ step_id=step.id,
187
+ input_json=json.dumps(context) if context else None,
188
+ )
189
+
190
+ # Check if step should run based on condition
191
+ if not self._should_run_step(step, context):
192
+ # Skip this step
193
+ self.execution_manager.update_step_execution(
194
+ step_execution_id=step_execution.id,
195
+ status=StepStatus.SKIPPED,
196
+ )
197
+ logger.info(f"Skipping step {step.id}: condition not met")
198
+
199
+ # Emit step_skipped event
200
+ await self._emit_event(
201
+ "step_skipped",
202
+ execution.id,
203
+ step_id=step.id,
204
+ step_name=getattr(step, "name", step.id),
205
+ reason="condition not met",
206
+ )
207
+ continue
208
+
209
+ # Update step status to RUNNING
210
+ self.execution_manager.update_step_execution(
211
+ step_execution_id=step_execution.id,
212
+ status=StepStatus.RUNNING,
213
+ )
214
+
215
+ # Emit step_started event
216
+ await self._emit_event(
217
+ "step_started",
218
+ execution.id,
219
+ step_id=step.id,
220
+ step_name=getattr(step, "name", step.id),
221
+ )
222
+
223
+ # Check for approval gate
224
+ await self._check_approval_gate(step, execution, step_execution, pipeline)
225
+
226
+ # Execute the step
227
+ step_output = await self._execute_step(step, context, project_id)
228
+
229
+ # Store step output in context for subsequent steps
230
+ context["steps"][step.id] = {"output": step_output}
231
+
232
+ # Update step with output and mark completed
233
+ self.execution_manager.update_step_execution(
234
+ step_execution_id=step_execution.id,
235
+ status=StepStatus.COMPLETED,
236
+ output_json=json.dumps(step_output) if step_output else None,
237
+ )
238
+
239
+ # Emit step_completed event
240
+ await self._emit_event(
241
+ "step_completed",
242
+ execution.id,
243
+ step_id=step.id,
244
+ step_name=getattr(step, "name", step.id),
245
+ output=step_output,
246
+ )
247
+
248
+ # 5. Mark execution as completed
249
+ outputs = self._build_outputs(pipeline, context)
250
+ completed = self.execution_manager.update_execution_status(
251
+ execution_id=execution.id,
252
+ status=ExecutionStatus.COMPLETED,
253
+ outputs_json=json.dumps(outputs),
254
+ )
255
+ if completed:
256
+ execution = completed
257
+
258
+ # Emit pipeline_completed event
259
+ await self._emit_event(
260
+ "pipeline_completed",
261
+ execution.id,
262
+ pipeline_name=pipeline.name,
263
+ outputs=outputs,
264
+ )
265
+
266
+ except ApprovalRequired:
267
+ # Don't treat approval as an error - just re-raise
268
+ raise
269
+
270
+ except Exception as e:
271
+ logger.error(f"Pipeline execution failed: {e}", exc_info=True)
272
+ failed = self.execution_manager.update_execution_status(
273
+ execution_id=execution.id,
274
+ status=ExecutionStatus.FAILED,
275
+ )
276
+ if failed:
277
+ execution = failed
278
+
279
+ # Emit pipeline_failed event
280
+ await self._emit_event(
281
+ "pipeline_failed",
282
+ execution.id,
283
+ pipeline_name=pipeline.name,
284
+ error=str(e),
285
+ )
286
+ raise
287
+
288
+ return execution
289
+
290
+ async def _execute_step(
291
+ self,
292
+ step: Any, # PipelineStep
293
+ context: dict[str, Any],
294
+ project_id: str,
295
+ ) -> Any:
296
+ """Execute a single pipeline step.
297
+
298
+ Args:
299
+ step: The step to execute
300
+ context: Current execution context with inputs and step outputs
301
+ project_id: Project context
302
+
303
+ Returns:
304
+ The step's output value
305
+ """
306
+ # Render any template variables in the step
307
+ rendered_step = self._render_step(step, context)
308
+
309
+ if step.exec:
310
+ # Execute shell command
311
+ return await self._execute_exec_step(rendered_step.exec, context)
312
+ elif step.prompt:
313
+ # Execute LLM prompt
314
+ return await self._execute_prompt_step(rendered_step.prompt, context)
315
+ elif step.invoke_pipeline:
316
+ # Execute nested pipeline
317
+ return await self._execute_nested_pipeline(
318
+ rendered_step.invoke_pipeline, context, project_id
319
+ )
320
+ else:
321
+ logger.warning(f"Step {step.id} has no action defined")
322
+ return None
323
+
324
+ def _render_step(self, step: Any, context: dict[str, Any]) -> Any:
325
+ """Render template variables in step fields.
326
+
327
+ Args:
328
+ step: The step to render
329
+ context: Context with variables for substitution
330
+
331
+ Returns:
332
+ Step with rendered fields
333
+ """
334
+ if not self.template_engine:
335
+ return step
336
+
337
+ template_engine = self.template_engine
338
+
339
+ import os
340
+ import re
341
+
342
+ # Build render context
343
+ render_context = {
344
+ "inputs": context.get("inputs", {}),
345
+ "steps": context.get("steps", {}),
346
+ "env": os.environ,
347
+ }
348
+
349
+ def render_string(s: str) -> str:
350
+ if not s:
351
+ return s
352
+ # Replace ${{ ... }} with {{ ... }} for Jinja2
353
+ # Use dotall to allow multi-line expressions
354
+ jinja_template = re.sub(r"\$\{\{(.*?)\}\}", r"{{\1}}", s, flags=re.DOTALL)
355
+
356
+ # If no changes (no ${{ }}), we might still want to run it through jinja
357
+ # if we wanted to support direct {{ }} syntax too.
358
+ # But the requirement highlights ${{ }}.
359
+ # If the user provides {{ }}, it will also be rendered by Jinja.
360
+
361
+ return template_engine.render(jinja_template, render_context)
362
+
363
+ # Create a copy of the step to avoid modifying the definition
364
+ rendered_step = step.model_copy()
365
+
366
+ try:
367
+ if rendered_step.exec:
368
+ rendered_step.exec = render_string(rendered_step.exec)
369
+
370
+ if rendered_step.prompt:
371
+ rendered_step.prompt = render_string(rendered_step.prompt)
372
+
373
+ # We could also render 'input' field if needed, but requirements mention exec and prompt
374
+
375
+ except Exception as e:
376
+ # If rendering fails, we log it but might want to let it bubble up
377
+ # or fail the step execution later.
378
+ # For now, let's allow the exception to bubble up so the step fails immediately.
379
+ raise ValueError(f"Failed to render step {step.id}: {e}") from e
380
+
381
+ return rendered_step
382
+
383
+ def _should_run_step(self, step: Any, context: dict[str, Any]) -> bool:
384
+ """Check if a step should run based on its condition.
385
+
386
+ Args:
387
+ step: The step to check
388
+ context: Current execution context
389
+
390
+ Returns:
391
+ True if step should run, False if it should be skipped
392
+ """
393
+ # No condition means always run
394
+ if not step.condition:
395
+ return True
396
+
397
+ try:
398
+ # Evaluate the condition using safe AST-based evaluator
399
+ # This avoids eval() security risks while supporting common expressions
400
+ eval_context = {
401
+ "inputs": context.get("inputs", {}),
402
+ "steps": context.get("steps", {}),
403
+ }
404
+ # Allow common helper functions for conditions
405
+ allowed_funcs: dict[str, Any] = {
406
+ "len": len,
407
+ "bool": bool,
408
+ "str": str,
409
+ "int": int,
410
+ }
411
+ evaluator = SafeExpressionEvaluator(eval_context, allowed_funcs)
412
+ return evaluator.evaluate(step.condition)
413
+ except Exception as e:
414
+ logger.warning(f"Condition evaluation failed for step {step.id}: {e}")
415
+ # Default to running the step if condition evaluation fails
416
+ return True
417
+
418
+ async def _check_approval_gate(
419
+ self,
420
+ step: Any,
421
+ execution: PipelineExecution,
422
+ step_execution: Any,
423
+ pipeline: Any,
424
+ ) -> None:
425
+ """Check if a step has an approval gate and handle it.
426
+
427
+ If the step requires approval, this method:
428
+ 1. Generates a unique approval token
429
+ 2. Updates the step and execution status to WAITING_APPROVAL
430
+ 3. Calls the webhook notifier if configured
431
+ 4. Raises ApprovalRequired to pause execution
432
+
433
+ Args:
434
+ step: The step to check for approval requirement
435
+ execution: The current pipeline execution record
436
+ step_execution: The current step execution record
437
+ pipeline: The pipeline definition (for webhook config)
438
+
439
+ Raises:
440
+ ApprovalRequired: If the step requires approval
441
+ """
442
+ # Check if step has approval gate
443
+ if not step.approval or not step.approval.required:
444
+ return
445
+
446
+ # Generate unique approval token
447
+ token = secrets.token_urlsafe(24)
448
+
449
+ # Get approval message
450
+ message = step.approval.message or f"Approval required for step '{step.id}'"
451
+
452
+ # Update step status to WAITING_APPROVAL and store token
453
+ self.execution_manager.update_step_execution(
454
+ step_execution_id=step_execution.id,
455
+ status=StepStatus.WAITING_APPROVAL,
456
+ approval_token=token,
457
+ )
458
+
459
+ # Update execution status to WAITING_APPROVAL
460
+ self.execution_manager.update_execution_status(
461
+ execution_id=execution.id,
462
+ status=ExecutionStatus.WAITING_APPROVAL,
463
+ resume_token=token,
464
+ )
465
+
466
+ # Call webhook notifier if configured
467
+ if self.webhook_notifier:
468
+ try:
469
+ await self.webhook_notifier.notify_approval_pending(
470
+ execution_id=execution.id,
471
+ step_id=step.id,
472
+ token=token,
473
+ message=message,
474
+ pipeline=pipeline,
475
+ )
476
+ except Exception as e:
477
+ logger.warning(f"Failed to send approval webhook: {e}")
478
+
479
+ # Emit approval_required event
480
+ await self._emit_event(
481
+ "approval_required",
482
+ execution.id,
483
+ step_id=step.id,
484
+ step_name=getattr(step, "name", step.id),
485
+ message=message,
486
+ token=token,
487
+ )
488
+
489
+ # Raise to pause execution
490
+ raise ApprovalRequired(
491
+ execution_id=execution.id,
492
+ step_id=step.id,
493
+ token=token,
494
+ message=message,
495
+ )
496
+
497
+ async def approve(
498
+ self,
499
+ token: str,
500
+ approved_by: str | None = None,
501
+ ) -> PipelineExecution:
502
+ """Approve a pipeline execution that is waiting for approval.
503
+
504
+ Args:
505
+ token: The approval token from the ApprovalRequired exception
506
+ approved_by: Optional identifier of the approver
507
+
508
+ Returns:
509
+ The updated PipelineExecution record
510
+
511
+ Raises:
512
+ ValueError: If the token is invalid or not found
513
+ """
514
+ # Find the step by approval token
515
+ step = self.execution_manager.get_step_by_approval_token(token)
516
+ if not step:
517
+ raise ValueError(f"Invalid approval token: {token}")
518
+
519
+ # Mark step as approved
520
+ self.execution_manager.update_step_execution(
521
+ step_execution_id=step.id,
522
+ status=StepStatus.COMPLETED,
523
+ approved_by=approved_by,
524
+ )
525
+
526
+ # Get the execution
527
+ execution = self.execution_manager.get_execution(step.execution_id)
528
+ if not execution:
529
+ raise ValueError(f"Execution {step.execution_id} not found")
530
+
531
+ # Resume execution
532
+ if self.loader:
533
+ try:
534
+ pipeline = self.loader.load_pipeline(execution.pipeline_name)
535
+ if pipeline:
536
+ inputs = {}
537
+ if execution.inputs_json:
538
+ try:
539
+ inputs = json.loads(execution.inputs_json)
540
+ except json.JSONDecodeError:
541
+ pass
542
+
543
+ # Execute (resume)
544
+ execution = await self.execute(
545
+ pipeline=pipeline,
546
+ inputs=inputs,
547
+ project_id=execution.project_id,
548
+ execution_id=execution.id,
549
+ )
550
+ except ApprovalRequired:
551
+ # Pipeline paused again for another approval - this is expected
552
+ # Refresh execution to get latest status
553
+ execution = self.execution_manager.get_execution(execution.id)
554
+ if not execution:
555
+ raise ValueError(
556
+ f"Execution {step.execution_id} not found after resume"
557
+ ) from None
558
+ except Exception as e:
559
+ logger.error(f"Failed to resume execution after approval: {e}", exc_info=True)
560
+ # Don't fail the approval if resume fails, but log it
561
+ else:
562
+ logger.warning("No loader configured, cannot resume execution automatically")
563
+
564
+ return execution
565
+
566
+ async def reject(
567
+ self,
568
+ token: str,
569
+ rejected_by: str | None = None,
570
+ ) -> PipelineExecution:
571
+ """Reject a pipeline execution that is waiting for approval.
572
+
573
+ Args:
574
+ token: The approval token from the ApprovalRequired exception
575
+ rejected_by: Optional identifier of the rejector
576
+
577
+ Returns:
578
+ The updated PipelineExecution record
579
+
580
+ Raises:
581
+ ValueError: If the token is invalid or not found
582
+ """
583
+ # Find the step by approval token
584
+ step = self.execution_manager.get_step_by_approval_token(token)
585
+ if not step:
586
+ raise ValueError(f"Invalid approval token: {token}")
587
+
588
+ # Mark step as failed
589
+ error_msg = "Rejected"
590
+ if rejected_by:
591
+ error_msg += f" by {rejected_by}"
592
+
593
+ self.execution_manager.update_step_execution(
594
+ step_execution_id=step.id,
595
+ status=StepStatus.FAILED,
596
+ error=error_msg,
597
+ )
598
+
599
+ # Set execution status to CANCELLED
600
+ execution = self.execution_manager.update_execution_status(
601
+ execution_id=step.execution_id,
602
+ status=ExecutionStatus.CANCELLED,
603
+ )
604
+
605
+ if not execution:
606
+ raise ValueError(f"Execution {step.execution_id} not found")
607
+
608
+ return execution
609
+
610
+ async def _execute_exec_step(self, command: str, context: dict[str, Any]) -> dict[str, Any]:
611
+ """Execute a shell command step.
612
+
613
+ Commands are parsed using shlex.split and executed via create_subprocess_exec
614
+ to avoid shell injection vulnerabilities. The command string is treated as a
615
+ space-separated list of arguments, not a shell script.
616
+
617
+ Note: Pipeline commands are defined by the pipeline author, not end users.
618
+ This is a defense-in-depth measure.
619
+
620
+ Args:
621
+ command: The command to execute (space-separated arguments)
622
+ context: Execution context
623
+
624
+ Returns:
625
+ Dict with stdout, stderr, exit_code
626
+ """
627
+ import asyncio
628
+
629
+ logger.info(f"Executing command: {command}")
630
+
631
+ try:
632
+ # Parse command into arguments to avoid shell injection
633
+ args = shlex.split(command)
634
+ if not args:
635
+ return {
636
+ "stdout": "",
637
+ "stderr": "Empty command",
638
+ "exit_code": 1,
639
+ }
640
+
641
+ # Run command without shell
642
+ proc = await asyncio.create_subprocess_exec(
643
+ *args,
644
+ stdout=asyncio.subprocess.PIPE,
645
+ stderr=asyncio.subprocess.PIPE,
646
+ )
647
+
648
+ stdout_bytes, stderr_bytes = await proc.communicate()
649
+
650
+ return {
651
+ "stdout": stdout_bytes.decode("utf-8", errors="replace"),
652
+ "stderr": stderr_bytes.decode("utf-8", errors="replace"),
653
+ "exit_code": proc.returncode or 0,
654
+ }
655
+
656
+ except Exception as e:
657
+ logger.error(f"Command execution failed: {e}", exc_info=True)
658
+ return {
659
+ "stdout": "",
660
+ "stderr": str(e),
661
+ "exit_code": 1,
662
+ }
663
+
664
+ async def _execute_prompt_step(self, prompt: str, context: dict[str, Any]) -> dict[str, Any]:
665
+ """Execute an LLM prompt step.
666
+
667
+ Args:
668
+ prompt: The prompt to send to the LLM
669
+ context: Execution context
670
+
671
+ Returns:
672
+ Dict with response text or error
673
+ """
674
+ logger.info("Executing prompt step")
675
+
676
+ try:
677
+ response = await self.llm_service.generate(prompt)
678
+ return {
679
+ "response": response,
680
+ }
681
+ except Exception as e:
682
+ logger.error(f"LLM prompt execution failed: {e}", exc_info=True)
683
+ return {
684
+ "response": "",
685
+ "error": str(e),
686
+ }
687
+
688
+ async def _execute_nested_pipeline(
689
+ self,
690
+ pipeline_name: str,
691
+ context: dict[str, Any],
692
+ project_id: str,
693
+ ) -> dict[str, Any]:
694
+ """Execute a nested pipeline.
695
+
696
+ Args:
697
+ pipeline_name: Name of the pipeline to invoke
698
+ context: Execution context (used as inputs)
699
+ project_id: Project context
700
+
701
+ Returns:
702
+ Dict with nested pipeline outputs
703
+ """
704
+ logger.info(f"Invoking nested pipeline: {pipeline_name}")
705
+
706
+ # Check if loader is available
707
+ if not self.loader:
708
+ logger.warning("No loader configured for nested pipeline execution")
709
+ return {
710
+ "pipeline": pipeline_name,
711
+ "error": "No loader configured for nested pipeline execution",
712
+ }
713
+
714
+ try:
715
+ # Load the nested pipeline
716
+ nested_pipeline = self.loader.load_pipeline(pipeline_name)
717
+
718
+ if not nested_pipeline:
719
+ return {
720
+ "pipeline": pipeline_name,
721
+ "error": f"Pipeline '{pipeline_name}' not found",
722
+ }
723
+
724
+ # Execute the nested pipeline recursively
725
+ # Use inputs from context as the nested pipeline's inputs
726
+ nested_inputs = context.get("inputs", {})
727
+
728
+ result = await self.execute(
729
+ pipeline=nested_pipeline,
730
+ inputs=nested_inputs,
731
+ project_id=project_id,
732
+ )
733
+
734
+ return {
735
+ "pipeline": pipeline_name,
736
+ "execution_id": result.id,
737
+ "status": result.status.value,
738
+ }
739
+
740
+ except Exception as e:
741
+ logger.error(f"Nested pipeline execution failed: {e}", exc_info=True)
742
+ return {
743
+ "pipeline": pipeline_name,
744
+ "error": str(e),
745
+ }
746
+
747
+ def _build_outputs(self, pipeline: Any, context: dict[str, Any]) -> dict[str, Any]:
748
+ """Build pipeline outputs from context.
749
+
750
+ Args:
751
+ pipeline: The pipeline definition
752
+ context: Final execution context
753
+
754
+ Returns:
755
+ Dict of output name -> value
756
+ """
757
+ outputs: dict[str, Any] = {}
758
+
759
+ for name, expr in pipeline.outputs.items():
760
+ if isinstance(expr, str) and expr.startswith("$"):
761
+ # Resolve $step.output reference
762
+ value = self._resolve_reference(expr, context)
763
+ outputs[name] = value
764
+ else:
765
+ outputs[name] = expr
766
+
767
+ return outputs
768
+
769
+ def _resolve_reference(self, ref: str, context: dict[str, Any]) -> Any:
770
+ """Resolve a $step.output reference from context.
771
+
772
+ Args:
773
+ ref: Reference string like "$step1.output" or "$step1.output.field"
774
+ context: Execution context
775
+
776
+ Returns:
777
+ The resolved value
778
+ """
779
+ import re
780
+
781
+ # Parse reference: $step_id.output[.field]
782
+ match = re.match(r"\$([a-zA-Z_][a-zA-Z0-9_]*)\.output(?:\.(.+))?", ref)
783
+ if not match:
784
+ return ref
785
+
786
+ step_id = match.group(1)
787
+ field_path = match.group(2)
788
+
789
+ # Get step output from context
790
+ step_data = context.get("steps", {}).get(step_id, {})
791
+ output = step_data.get("output")
792
+
793
+ if field_path and isinstance(output, dict):
794
+ # Navigate nested field path
795
+ for part in field_path.split("."):
796
+ if isinstance(output, dict):
797
+ output = output.get(part)
798
+ else:
799
+ break
800
+
801
+ return output