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.
- gobby/__init__.py +1 -1
- gobby/adapters/__init__.py +6 -0
- gobby/adapters/base.py +11 -2
- gobby/adapters/claude_code.py +2 -2
- gobby/adapters/codex_impl/adapter.py +38 -43
- gobby/adapters/copilot.py +324 -0
- gobby/adapters/cursor.py +373 -0
- gobby/adapters/gemini.py +2 -26
- gobby/adapters/windsurf.py +359 -0
- gobby/agents/definitions.py +162 -2
- gobby/agents/isolation.py +33 -1
- gobby/agents/pty_reader.py +192 -0
- gobby/agents/registry.py +10 -1
- gobby/agents/runner.py +24 -8
- gobby/agents/sandbox.py +8 -3
- gobby/agents/session.py +4 -0
- gobby/agents/spawn.py +9 -2
- gobby/agents/spawn_executor.py +49 -61
- gobby/agents/spawners/command_builder.py +4 -4
- gobby/app_context.py +5 -0
- gobby/cli/__init__.py +4 -0
- gobby/cli/install.py +259 -4
- gobby/cli/installers/__init__.py +12 -0
- gobby/cli/installers/copilot.py +242 -0
- gobby/cli/installers/cursor.py +244 -0
- gobby/cli/installers/shared.py +3 -0
- gobby/cli/installers/windsurf.py +242 -0
- gobby/cli/pipelines.py +639 -0
- gobby/cli/sessions.py +3 -1
- gobby/cli/skills.py +209 -0
- gobby/cli/tasks/crud.py +6 -5
- gobby/cli/tasks/search.py +1 -1
- gobby/cli/ui.py +116 -0
- gobby/cli/workflows.py +38 -17
- gobby/config/app.py +5 -0
- gobby/config/skills.py +23 -2
- gobby/hooks/broadcaster.py +9 -0
- gobby/hooks/event_handlers/_base.py +6 -1
- gobby/hooks/event_handlers/_session.py +44 -130
- gobby/hooks/events.py +48 -0
- gobby/hooks/hook_manager.py +25 -3
- gobby/install/copilot/hooks/hook_dispatcher.py +203 -0
- gobby/install/cursor/hooks/hook_dispatcher.py +203 -0
- gobby/install/gemini/hooks/hook_dispatcher.py +8 -0
- gobby/install/windsurf/hooks/hook_dispatcher.py +205 -0
- gobby/llm/__init__.py +14 -1
- gobby/llm/claude.py +217 -1
- gobby/llm/service.py +149 -0
- gobby/mcp_proxy/instructions.py +9 -27
- gobby/mcp_proxy/models.py +1 -0
- gobby/mcp_proxy/registries.py +56 -9
- gobby/mcp_proxy/server.py +6 -2
- gobby/mcp_proxy/services/tool_filter.py +7 -0
- gobby/mcp_proxy/services/tool_proxy.py +19 -1
- gobby/mcp_proxy/stdio.py +37 -21
- gobby/mcp_proxy/tools/agents.py +7 -0
- gobby/mcp_proxy/tools/hub.py +30 -1
- gobby/mcp_proxy/tools/orchestration/cleanup.py +5 -5
- gobby/mcp_proxy/tools/orchestration/monitor.py +1 -1
- gobby/mcp_proxy/tools/orchestration/orchestrate.py +8 -3
- gobby/mcp_proxy/tools/orchestration/review.py +17 -4
- gobby/mcp_proxy/tools/orchestration/wait.py +7 -7
- gobby/mcp_proxy/tools/pipelines/__init__.py +254 -0
- gobby/mcp_proxy/tools/pipelines/_discovery.py +67 -0
- gobby/mcp_proxy/tools/pipelines/_execution.py +281 -0
- gobby/mcp_proxy/tools/sessions/_crud.py +4 -4
- gobby/mcp_proxy/tools/sessions/_handoff.py +1 -1
- gobby/mcp_proxy/tools/skills/__init__.py +184 -30
- gobby/mcp_proxy/tools/spawn_agent.py +229 -14
- gobby/mcp_proxy/tools/tasks/_context.py +8 -0
- gobby/mcp_proxy/tools/tasks/_crud.py +27 -1
- gobby/mcp_proxy/tools/tasks/_helpers.py +1 -1
- gobby/mcp_proxy/tools/tasks/_lifecycle.py +125 -8
- gobby/mcp_proxy/tools/tasks/_lifecycle_validation.py +2 -1
- gobby/mcp_proxy/tools/tasks/_search.py +1 -1
- gobby/mcp_proxy/tools/workflows/__init__.py +9 -2
- gobby/mcp_proxy/tools/workflows/_lifecycle.py +12 -1
- gobby/mcp_proxy/tools/workflows/_query.py +45 -26
- gobby/mcp_proxy/tools/workflows/_terminal.py +39 -3
- gobby/mcp_proxy/tools/worktrees.py +54 -15
- gobby/memory/context.py +5 -5
- gobby/runner.py +108 -6
- gobby/servers/http.py +7 -1
- gobby/servers/routes/__init__.py +2 -0
- gobby/servers/routes/admin.py +44 -0
- gobby/servers/routes/mcp/endpoints/execution.py +18 -25
- gobby/servers/routes/mcp/hooks.py +10 -1
- gobby/servers/routes/pipelines.py +227 -0
- gobby/servers/websocket.py +314 -1
- gobby/sessions/analyzer.py +87 -1
- gobby/sessions/manager.py +5 -5
- gobby/sessions/transcripts/__init__.py +3 -0
- gobby/sessions/transcripts/claude.py +5 -0
- gobby/sessions/transcripts/codex.py +5 -0
- gobby/sessions/transcripts/gemini.py +5 -0
- gobby/skills/hubs/__init__.py +25 -0
- gobby/skills/hubs/base.py +234 -0
- gobby/skills/hubs/claude_plugins.py +328 -0
- gobby/skills/hubs/clawdhub.py +289 -0
- gobby/skills/hubs/github_collection.py +465 -0
- gobby/skills/hubs/manager.py +263 -0
- gobby/skills/hubs/skillhub.py +342 -0
- gobby/storage/memories.py +4 -4
- gobby/storage/migrations.py +95 -3
- gobby/storage/pipelines.py +367 -0
- gobby/storage/sessions.py +23 -4
- gobby/storage/skills.py +1 -1
- gobby/storage/tasks/_aggregates.py +2 -2
- gobby/storage/tasks/_lifecycle.py +4 -4
- gobby/storage/tasks/_models.py +7 -1
- gobby/storage/tasks/_queries.py +3 -3
- gobby/sync/memories.py +4 -3
- gobby/tasks/commits.py +48 -17
- gobby/workflows/actions.py +75 -0
- gobby/workflows/context_actions.py +246 -5
- gobby/workflows/definitions.py +119 -1
- gobby/workflows/detection_helpers.py +23 -11
- gobby/workflows/enforcement/task_policy.py +18 -0
- gobby/workflows/engine.py +20 -1
- gobby/workflows/evaluator.py +8 -5
- gobby/workflows/lifecycle_evaluator.py +57 -26
- gobby/workflows/loader.py +567 -30
- gobby/workflows/lobster_compat.py +147 -0
- gobby/workflows/pipeline_executor.py +801 -0
- gobby/workflows/pipeline_state.py +172 -0
- gobby/workflows/pipeline_webhooks.py +206 -0
- gobby/workflows/premature_stop.py +5 -0
- gobby/worktrees/git.py +135 -20
- {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/METADATA +56 -22
- {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/RECORD +134 -106
- {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/WHEEL +0 -0
- {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/entry_points.txt +0 -0
- {gobby-0.2.9.dist-info → gobby-0.2.11.dist-info}/licenses/LICENSE.md +0 -0
- {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
|