steerdev 0.4.27__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 (57) hide show
  1. steerdev-0.4.27.dist-info/METADATA +224 -0
  2. steerdev-0.4.27.dist-info/RECORD +57 -0
  3. steerdev-0.4.27.dist-info/WHEEL +4 -0
  4. steerdev-0.4.27.dist-info/entry_points.txt +2 -0
  5. steerdev_agent/__init__.py +10 -0
  6. steerdev_agent/api/__init__.py +32 -0
  7. steerdev_agent/api/activity.py +278 -0
  8. steerdev_agent/api/agents.py +145 -0
  9. steerdev_agent/api/client.py +158 -0
  10. steerdev_agent/api/commands.py +399 -0
  11. steerdev_agent/api/configs.py +238 -0
  12. steerdev_agent/api/context.py +306 -0
  13. steerdev_agent/api/events.py +294 -0
  14. steerdev_agent/api/hooks.py +178 -0
  15. steerdev_agent/api/implementation_plan.py +408 -0
  16. steerdev_agent/api/messages.py +231 -0
  17. steerdev_agent/api/prd.py +281 -0
  18. steerdev_agent/api/runs.py +526 -0
  19. steerdev_agent/api/sessions.py +403 -0
  20. steerdev_agent/api/specs.py +321 -0
  21. steerdev_agent/api/tasks.py +659 -0
  22. steerdev_agent/api/workflow_runs.py +351 -0
  23. steerdev_agent/api/workflows.py +191 -0
  24. steerdev_agent/cli.py +2254 -0
  25. steerdev_agent/config/__init__.py +19 -0
  26. steerdev_agent/config/models.py +236 -0
  27. steerdev_agent/config/platform.py +272 -0
  28. steerdev_agent/config/settings.py +62 -0
  29. steerdev_agent/daemon.py +675 -0
  30. steerdev_agent/executor/__init__.py +64 -0
  31. steerdev_agent/executor/base.py +121 -0
  32. steerdev_agent/executor/claude.py +328 -0
  33. steerdev_agent/executor/stream.py +163 -0
  34. steerdev_agent/git/__init__.py +1 -0
  35. steerdev_agent/handlers/__init__.py +5 -0
  36. steerdev_agent/handlers/prd.py +533 -0
  37. steerdev_agent/integration.py +334 -0
  38. steerdev_agent/prompt/__init__.py +10 -0
  39. steerdev_agent/prompt/builder.py +263 -0
  40. steerdev_agent/prompt/templates.py +422 -0
  41. steerdev_agent/py.typed +0 -0
  42. steerdev_agent/runner.py +829 -0
  43. steerdev_agent/setup/__init__.py +5 -0
  44. steerdev_agent/setup/claude_setup.py +560 -0
  45. steerdev_agent/setup/templates/claude_md_section.md +140 -0
  46. steerdev_agent/setup/templates/settings.json +69 -0
  47. steerdev_agent/setup/templates/skills/activity/SKILL.md +160 -0
  48. steerdev_agent/setup/templates/skills/context/SKILL.md +122 -0
  49. steerdev_agent/setup/templates/skills/git-workflow/SKILL.md +218 -0
  50. steerdev_agent/setup/templates/skills/progress-logging/SKILL.md +211 -0
  51. steerdev_agent/setup/templates/skills/specs-management/SKILL.md +161 -0
  52. steerdev_agent/setup/templates/skills/task-management/SKILL.md +343 -0
  53. steerdev_agent/setup/templates/steerdev.yaml +51 -0
  54. steerdev_agent/version.py +149 -0
  55. steerdev_agent/workflow/__init__.py +10 -0
  56. steerdev_agent/workflow/executor.py +494 -0
  57. steerdev_agent/workflow/memory.py +185 -0
@@ -0,0 +1,51 @@
1
+ # SteerDev Agent Configuration
2
+
3
+ agent:
4
+ # Model to use (e.g., claude-sonnet-4-20250514)
5
+ model: null
6
+
7
+ # Maximum number of agent turns per task
8
+ max_turns: null
9
+
10
+ # Maximum execution time in seconds (default: 1 hour)
11
+ timeout_seconds: 3600
12
+
13
+ # Workflow ID to use for multi-phase task execution
14
+ # If not set, tasks run as single-phase execution
15
+ workflow_id: null
16
+
17
+ api:
18
+ # API endpoint for steerdev.com
19
+ api_endpoint: "https://steerdev.com/api/v1"
20
+
21
+ # Environment variable name for API key
22
+ api_key_env: STEERDEV_API_KEY
23
+
24
+ # Environment variable name for project ID
25
+ project_id_env: STEERDEV_PROJECT_ID
26
+
27
+ events:
28
+ # Number of events to batch before sending
29
+ batch_size: 10
30
+
31
+ # Maximum seconds between flushes
32
+ flush_interval_seconds: 5.0
33
+
34
+ executor:
35
+ # Executor type: claude (future: codex, aider)
36
+ type: claude
37
+
38
+ # Permission mode for the executor
39
+ permission_mode: dangerously-skip-permissions
40
+
41
+ # Tools to allow (empty = all allowed)
42
+ allowed_tools: []
43
+
44
+ # Tools to disallow
45
+ disallowed_tools: []
46
+
47
+ # Path to MCP config file
48
+ mcp_config: null
49
+
50
+ worktrees:
51
+ enabled: false
@@ -0,0 +1,149 @@
1
+ """Version management utilities for SteerDev Agent.
2
+
3
+ Provides functions to get and manage the project version from pyproject.toml.
4
+ """
5
+
6
+ import subprocess
7
+ import tomllib
8
+ from pathlib import Path
9
+
10
+
11
+ def get_project_root() -> Path:
12
+ """Get the project root directory (where pyproject.toml lives)."""
13
+ return Path(__file__).parent.parent.parent
14
+
15
+
16
+ def get_version() -> str:
17
+ """Get the current project version from pyproject.toml.
18
+
19
+ First tries to use `uv version --short`, then falls back to reading
20
+ pyproject.toml directly.
21
+
22
+ Returns:
23
+ The current version string (e.g., "0.1.0")
24
+ """
25
+ try:
26
+ result = subprocess.run(
27
+ ["uv", "version", "--short"],
28
+ capture_output=True,
29
+ text=True,
30
+ check=True,
31
+ cwd=get_project_root(),
32
+ )
33
+ return result.stdout.strip()
34
+ except (subprocess.CalledProcessError, FileNotFoundError):
35
+ # Fallback: read directly from pyproject.toml
36
+ pyproject_path = get_project_root() / "pyproject.toml"
37
+ if pyproject_path.exists():
38
+ with open(pyproject_path, "rb") as f:
39
+ data = tomllib.load(f)
40
+ return data.get("project", {}).get("version", "unknown")
41
+ return "unknown"
42
+
43
+
44
+ def bump_version(
45
+ bump_type: str,
46
+ dry_run: bool = False,
47
+ ) -> tuple[str, str]:
48
+ """Bump the project version.
49
+
50
+ Args:
51
+ bump_type: Type of bump (major, minor, patch, stable, alpha, beta, rc, post, dev)
52
+ dry_run: If True, don't actually change the version
53
+
54
+ Returns:
55
+ Tuple of (old_version, new_version)
56
+
57
+ Raises:
58
+ ValueError: If bump_type is invalid
59
+ RuntimeError: If version bump fails
60
+ """
61
+ valid_bumps = [
62
+ "major",
63
+ "minor",
64
+ "patch",
65
+ "stable",
66
+ "alpha",
67
+ "beta",
68
+ "rc",
69
+ "post",
70
+ "dev",
71
+ ]
72
+ if bump_type not in valid_bumps:
73
+ raise ValueError(
74
+ f"Invalid bump type '{bump_type}'. Valid options: {', '.join(valid_bumps)}"
75
+ )
76
+
77
+ old_version = get_version()
78
+
79
+ cmd = ["uv", "version", "--bump", bump_type]
80
+ if dry_run:
81
+ cmd.append("--dry-run")
82
+
83
+ try:
84
+ subprocess.run(
85
+ cmd,
86
+ capture_output=True,
87
+ text=True,
88
+ check=True,
89
+ cwd=get_project_root(),
90
+ )
91
+ except subprocess.CalledProcessError as e:
92
+ raise RuntimeError(f"Failed to bump version: {e.stderr}") from e
93
+
94
+ new_version = get_version() if not dry_run else _predict_new_version(old_version, bump_type)
95
+ return old_version, new_version
96
+
97
+
98
+ def set_version(version: str, dry_run: bool = False) -> str:
99
+ """Set the project version to a specific value.
100
+
101
+ Args:
102
+ version: The version string to set (e.g., "1.2.3")
103
+ dry_run: If True, don't actually change the version
104
+
105
+ Returns:
106
+ The new version string
107
+
108
+ Raises:
109
+ RuntimeError: If setting version fails
110
+ """
111
+ cmd = ["uv", "version", version]
112
+ if dry_run:
113
+ cmd.append("--dry-run")
114
+
115
+ try:
116
+ subprocess.run(
117
+ cmd,
118
+ capture_output=True,
119
+ text=True,
120
+ check=True,
121
+ cwd=get_project_root(),
122
+ )
123
+ except subprocess.CalledProcessError as e:
124
+ raise RuntimeError(f"Failed to set version: {e.stderr}") from e
125
+
126
+ return version if not dry_run else get_version()
127
+
128
+
129
+ def _predict_new_version(current: str, bump_type: str) -> str:
130
+ """Predict the new version after a bump (for dry-run display)."""
131
+ parts = current.split(".")
132
+ if len(parts) < 3:
133
+ return f"{current} -> ?"
134
+
135
+ try:
136
+ major, minor, patch_str = parts[0], parts[1], parts[2]
137
+ # Handle versions like "0.1.0a1" or "0.1.0-alpha"
138
+ patch = int("".join(c for c in patch_str if c.isdigit()))
139
+
140
+ if bump_type == "major":
141
+ return f"{int(major) + 1}.0.0"
142
+ elif bump_type == "minor":
143
+ return f"{major}.{int(minor) + 1}.0"
144
+ elif bump_type == "patch":
145
+ return f"{major}.{minor}.{patch + 1}"
146
+ else:
147
+ return f"{current} -> (uv will calculate)"
148
+ except (ValueError, IndexError):
149
+ return f"{current} -> ?"
@@ -0,0 +1,10 @@
1
+ """Workflow orchestration module for multi-phase task execution."""
2
+
3
+ from steerdev_agent.workflow.executor import WorkflowExecutor
4
+ from steerdev_agent.workflow.memory import PhaseMemory, WorkflowMemoryManager
5
+
6
+ __all__ = [
7
+ "PhaseMemory",
8
+ "WorkflowExecutor",
9
+ "WorkflowMemoryManager",
10
+ ]
@@ -0,0 +1,494 @@
1
+ """Workflow executor for multi-phase task execution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from string import Template
7
+ from typing import Any
8
+
9
+ from loguru import logger
10
+ from rich.console import Console
11
+
12
+ from steerdev_agent.api.events import EventsClient
13
+ from steerdev_agent.api.sessions import SessionCreateRequest, SessionsClient
14
+ from steerdev_agent.api.workflow_runs import WorkflowRunResponse, WorkflowRunsClient
15
+ from steerdev_agent.api.workflows import WorkflowPhaseResponse, WorkflowsClient
16
+ from steerdev_agent.config.models import ExecutorConfig
17
+ from steerdev_agent.executor import ExecutorFactory
18
+ from steerdev_agent.executor.base import EventType, StreamEvent
19
+ from steerdev_agent.workflow.memory import PhaseMemory, WorkflowMemoryManager
20
+
21
+ console = Console()
22
+
23
+
24
+ class WorkflowExecutorError(Exception):
25
+ """Error during workflow execution."""
26
+
27
+
28
+ class WorkflowExecutor:
29
+ """Executes workflow phases sequentially.
30
+
31
+ Handles the complete lifecycle of multi-phase workflow execution:
32
+ 1. Fetch workflow definition from API
33
+ 2. Start workflow run via API
34
+ 3. Execute each phase in order
35
+ 4. Build prompts with accumulated context
36
+ 5. Track phase completion and handle failures
37
+ """
38
+
39
+ def __init__(
40
+ self,
41
+ working_directory: str | Path,
42
+ api_key: str | None = None,
43
+ executor_config: ExecutorConfig | None = None,
44
+ model: str | None = None,
45
+ max_turns: int | None = None,
46
+ dry_run: bool = False,
47
+ project_id: str | None = None,
48
+ agent_type: str = "claude",
49
+ ) -> None:
50
+ """Initialize the workflow executor.
51
+
52
+ Args:
53
+ working_directory: Directory to run agents in.
54
+ api_key: API key for steerdev.com.
55
+ executor_config: Configuration for the agent executor.
56
+ model: Model override for agents.
57
+ max_turns: Maximum turns per phase.
58
+ dry_run: If True, print commands without executing.
59
+ project_id: Project ID for session tracking.
60
+ agent_type: Agent type for session tracking.
61
+ """
62
+ self.working_directory = Path(working_directory)
63
+ self._api_key = api_key
64
+ self._executor_config = executor_config or ExecutorConfig()
65
+ self.model = model
66
+ self.max_turns = max_turns
67
+ self.dry_run = dry_run
68
+ self.project_id = project_id
69
+ self.agent_type = agent_type
70
+
71
+ self._memory_manager = WorkflowMemoryManager(working_directory)
72
+ self._workflows_client: WorkflowsClient | None = None
73
+ self._runs_client: WorkflowRunsClient | None = None
74
+ self._sessions_client: SessionsClient | None = None
75
+ self._events_sent = 0
76
+
77
+ async def _get_workflows_client(self) -> WorkflowsClient:
78
+ """Get or create workflows client."""
79
+ if self._workflows_client is None:
80
+ self._workflows_client = WorkflowsClient(api_key=self._api_key)
81
+ return self._workflows_client
82
+
83
+ async def _get_runs_client(self) -> WorkflowRunsClient:
84
+ """Get or create workflow runs client."""
85
+ if self._runs_client is None:
86
+ self._runs_client = WorkflowRunsClient(api_key=self._api_key)
87
+ return self._runs_client
88
+
89
+ async def _get_sessions_client(self) -> SessionsClient:
90
+ """Get or create sessions client."""
91
+ if self._sessions_client is None:
92
+ self._sessions_client = SessionsClient(api_key=self._api_key)
93
+ return self._sessions_client
94
+
95
+ async def close(self) -> None:
96
+ """Close all clients."""
97
+ if self._workflows_client:
98
+ await self._workflows_client.close()
99
+ self._workflows_client = None
100
+ if self._runs_client:
101
+ await self._runs_client.close()
102
+ self._runs_client = None
103
+ if self._sessions_client:
104
+ await self._sessions_client.close()
105
+ self._sessions_client = None
106
+
107
+ def _build_phase_prompt(
108
+ self,
109
+ phase: WorkflowPhaseResponse,
110
+ task_context: dict[str, Any],
111
+ accumulated_context: dict[str, Any],
112
+ ) -> str:
113
+ """Build the prompt for a phase.
114
+
115
+ Args:
116
+ phase: Phase definition with prompt template.
117
+ task_context: Context from the task being executed.
118
+ accumulated_context: Merged context from previous phases.
119
+
120
+ Returns:
121
+ Rendered prompt string.
122
+ """
123
+ # Combine all available context for template substitution
124
+ template_vars = {
125
+ **task_context,
126
+ **accumulated_context,
127
+ "phase_name": phase.name,
128
+ "phase_type": phase.phase_type,
129
+ }
130
+
131
+ # Use phase prompt template if available
132
+ if phase.prompt_template:
133
+ try:
134
+ template = Template(phase.prompt_template)
135
+ return template.safe_substitute(template_vars)
136
+ except (KeyError, ValueError) as e:
137
+ logger.warning(f"Template substitution failed: {e}, using raw template")
138
+ return phase.prompt_template
139
+
140
+ # Default prompt if no template
141
+ return f"""Execute the '{phase.name}' phase for this task.
142
+
143
+ Task: {task_context.get("task_title", "Unknown")}
144
+
145
+ Task Description:
146
+ {task_context.get("task_prompt", "No description provided")}
147
+
148
+ Previous Phase Context:
149
+ {accumulated_context if accumulated_context else "No previous context"}
150
+
151
+ Instructions:
152
+ Complete this phase according to its type: {phase.phase_type}
153
+ """
154
+
155
+ async def _stream_event_to_api(self, events_client: EventsClient, event: StreamEvent) -> None:
156
+ """Stream an event to the API.
157
+
158
+ Args:
159
+ events_client: The events client to use.
160
+ event: The event to stream.
161
+ """
162
+ await events_client.add_event(
163
+ event_type=event.event_type.value,
164
+ data=event.data,
165
+ raw_json=event.raw_json,
166
+ timestamp=event.timestamp,
167
+ )
168
+ self._events_sent += 1
169
+
170
+ async def execute_phase(
171
+ self,
172
+ workflow_run: WorkflowRunResponse,
173
+ phase: WorkflowPhaseResponse,
174
+ task_context: dict[str, Any],
175
+ run_id: str,
176
+ ) -> dict[str, Any]:
177
+ """Execute a single phase.
178
+
179
+ Args:
180
+ workflow_run: The workflow run being executed.
181
+ phase: Phase to execute.
182
+ task_context: Task context data.
183
+ run_id: Agent run ID for tracking.
184
+
185
+ Returns:
186
+ Phase execution result with output_context.
187
+ """
188
+ console.print(f"\n[bold cyan]Phase: {phase.name}[/bold cyan]")
189
+ console.print(f"[dim]Type: {phase.phase_type}[/dim]")
190
+
191
+ # Build accumulated context from previous phases
192
+ accumulated_context = self._memory_manager.build_accumulated_context(
193
+ workflow_run.workflow_id, workflow_run.id
194
+ )
195
+
196
+ # Build phase prompt
197
+ prompt = self._build_phase_prompt(phase, task_context, accumulated_context)
198
+
199
+ if self.dry_run:
200
+ console.print(f"[dim]Would execute with prompt:[/dim]\n{prompt[:200]}...")
201
+ return {"success": True, "output_context": {}}
202
+
203
+ # Create session for this phase (for event tracking)
204
+ session_id: str | None = None
205
+ events_client: EventsClient | None = None
206
+
207
+ if self.project_id:
208
+ sessions_client = await self._get_sessions_client()
209
+ task_id = task_context.get("task_id")
210
+
211
+ request = SessionCreateRequest(
212
+ project_id=self.project_id,
213
+ task_id=task_id,
214
+ agent_type=self.agent_type,
215
+ prompt=prompt,
216
+ working_directory=str(self.working_directory),
217
+ metadata={
218
+ "workflow_id": workflow_run.workflow_id,
219
+ "workflow_run_id": workflow_run.id,
220
+ "phase_id": phase.id,
221
+ "phase_name": phase.name,
222
+ "phase_type": phase.phase_type,
223
+ },
224
+ )
225
+
226
+ session = await sessions_client.create_session(request)
227
+ if session:
228
+ session_id = session.id
229
+ logger.info(f"Phase session created: {session_id}")
230
+
231
+ # Initialize events client
232
+ events_client = EventsClient(
233
+ session_id=session_id,
234
+ api_key=self._api_key,
235
+ )
236
+ await events_client.start()
237
+
238
+ # Mark session as running
239
+ await sessions_client.mark_running(session_id)
240
+ else:
241
+ logger.warning("Failed to create phase session, events will not be streamed")
242
+
243
+ # Create and run executor
244
+ executor = ExecutorFactory.create(
245
+ config=self._executor_config,
246
+ working_directory=str(self.working_directory),
247
+ model=self.model,
248
+ max_turns=self.max_turns,
249
+ dry_run=self.dry_run,
250
+ )
251
+
252
+ try:
253
+ await executor.start(prompt)
254
+ console.print("[dim]Agent started, streaming output...[/dim]")
255
+
256
+ # Stream and collect events
257
+ result_summary = ""
258
+ async for event in executor.stream_events():
259
+ # Stream event to API if we have an events client
260
+ if events_client:
261
+ await self._stream_event_to_api(events_client, event)
262
+
263
+ if event.event_type == EventType.ASSISTANT:
264
+ message = event.data.get("message", {})
265
+ # Handle both dict and string message formats
266
+ if isinstance(message, dict):
267
+ content = message.get("content", "")
268
+ else:
269
+ content = str(message) if message else ""
270
+ if isinstance(content, str) and content:
271
+ # Capture last assistant message as summary
272
+ result_summary = content[:500]
273
+ preview = content[:100] + "..." if len(content) > 100 else content
274
+ console.print(f"[cyan]Assistant:[/cyan] {preview}")
275
+
276
+ if event.event_type == EventType.RESULT:
277
+ result_data = event.data.get("result", {})
278
+ # Handle both dict and string result formats
279
+ if isinstance(result_data, dict):
280
+ result_summary = result_data.get("summary", result_summary)
281
+ elif isinstance(result_data, str) and result_data:
282
+ result_summary = result_data[:500]
283
+
284
+ # Wait for completion
285
+ exit_code = await executor.wait()
286
+
287
+ # Get agent session ID for resume capability
288
+ agent_session_id = executor.session_id
289
+
290
+ if exit_code != 0:
291
+ stderr = await executor.get_stderr()
292
+ error_msg = stderr.strip() if stderr else f"Process exited with code {exit_code}"
293
+
294
+ # Mark session as failed
295
+ if session_id and self.project_id:
296
+ sessions_client = await self._get_sessions_client()
297
+ await sessions_client.mark_failed(
298
+ session_id,
299
+ metadata={"error": error_msg, "exit_code": exit_code},
300
+ )
301
+
302
+ return {
303
+ "success": False,
304
+ "error": error_msg,
305
+ "exit_code": exit_code,
306
+ }
307
+
308
+ # Mark session as completed
309
+ if session_id and self.project_id:
310
+ sessions_client = await self._get_sessions_client()
311
+ await sessions_client.mark_completed(
312
+ session_id,
313
+ agent_session_id=agent_session_id,
314
+ )
315
+
316
+ # Build output context from phase execution
317
+ output_context = {
318
+ f"{phase.phase_type}_completed": True,
319
+ f"{phase.phase_type}_summary": result_summary,
320
+ }
321
+
322
+ # Save phase memory
323
+ memory = PhaseMemory(
324
+ phase_id=phase.id,
325
+ phase_name=phase.name,
326
+ phase_type=phase.phase_type,
327
+ input_context=accumulated_context,
328
+ output_context=output_context,
329
+ result_summary=result_summary,
330
+ )
331
+ self._memory_manager.save_phase_memory(
332
+ workflow_run.workflow_id, workflow_run.id, memory
333
+ )
334
+
335
+ return {
336
+ "success": True,
337
+ "output_context": output_context,
338
+ "result_summary": result_summary,
339
+ "session_id": session_id,
340
+ "events_sent": self._events_sent,
341
+ }
342
+
343
+ except Exception as e:
344
+ logger.error(f"Phase execution failed: {e}")
345
+
346
+ # Mark session as failed
347
+ if session_id and self.project_id:
348
+ sessions_client = await self._get_sessions_client()
349
+ await sessions_client.mark_failed(
350
+ session_id,
351
+ metadata={"error": str(e)},
352
+ )
353
+
354
+ return {"success": False, "error": str(e)}
355
+
356
+ finally:
357
+ # Close events client (flushes remaining events)
358
+ if events_client:
359
+ await events_client.close()
360
+ logger.info(
361
+ f"Events flushed for phase {phase.name}, total sent: {self._events_sent}"
362
+ )
363
+
364
+ if executor.is_running:
365
+ await executor.stop()
366
+
367
+ async def execute_workflow(
368
+ self,
369
+ workflow_id: str,
370
+ task_context: dict[str, Any],
371
+ run_id: str,
372
+ ) -> dict[str, Any]:
373
+ """Execute a complete workflow.
374
+
375
+ Args:
376
+ workflow_id: Workflow ID to execute.
377
+ task_context: Context from the task being executed.
378
+ run_id: Agent run ID for tracking.
379
+
380
+ Returns:
381
+ Workflow execution result.
382
+ """
383
+ console.print("\n[bold blue]Starting Workflow[/bold blue]")
384
+ console.print(f"[dim]Workflow ID: {workflow_id}[/dim]")
385
+
386
+ try:
387
+ # Fetch workflow definition
388
+ workflows_client = await self._get_workflows_client()
389
+ workflow = await workflows_client.get_workflow(workflow_id)
390
+
391
+ if not workflow:
392
+ raise WorkflowExecutorError(f"Workflow not found: {workflow_id}")
393
+
394
+ console.print(f"[bold]{workflow.name}[/bold]")
395
+ console.print(f"[dim]Phases: {len(workflow.phases)}[/dim]")
396
+
397
+ # Sort phases by order
398
+ phases = sorted(workflow.phases, key=lambda p: p.phase_order)
399
+
400
+ if not phases:
401
+ raise WorkflowExecutorError("Workflow has no phases defined")
402
+
403
+ # Start workflow run via API (skip in dry run)
404
+ runs_client = await self._get_runs_client()
405
+ workflow_run: WorkflowRunResponse | None = None
406
+
407
+ if not self.dry_run:
408
+ workflow_run = await runs_client.start_workflow(
409
+ workflow_id=workflow_id,
410
+ run_id=run_id,
411
+ initial_context=task_context,
412
+ )
413
+
414
+ if not workflow_run:
415
+ raise WorkflowExecutorError("Failed to start workflow run")
416
+
417
+ console.print(f"[dim]Workflow Run ID: {workflow_run.id}[/dim]")
418
+ else:
419
+ # Create a mock workflow run for dry run
420
+ from datetime import UTC, datetime
421
+
422
+ workflow_run = WorkflowRunResponse(
423
+ id="dry-run-workflow",
424
+ workflow_id=workflow_id,
425
+ workflow_name=workflow.name,
426
+ project_id="dry-run-project",
427
+ status="running",
428
+ total_phases=len(phases),
429
+ created_at=datetime.now(UTC).isoformat(),
430
+ updated_at=datetime.now(UTC).isoformat(),
431
+ )
432
+
433
+ # Execute each phase
434
+ phases_completed = 0
435
+ phases_failed = 0
436
+
437
+ for phase in phases:
438
+ result = await self.execute_phase(
439
+ workflow_run=workflow_run,
440
+ phase=phase,
441
+ task_context=task_context,
442
+ run_id=run_id,
443
+ )
444
+
445
+ if result.get("success"):
446
+ phases_completed += 1
447
+ console.print(f"[green]Phase '{phase.name}' completed[/green]")
448
+
449
+ # Advance phase via API (skip in dry run)
450
+ if not self.dry_run:
451
+ await runs_client.advance_phase(
452
+ workflow_run_id=workflow_run.id,
453
+ output_context=result.get("output_context"),
454
+ result_summary=result.get("result_summary"),
455
+ )
456
+ else:
457
+ phases_failed += 1
458
+ error_msg = result.get("error", "Unknown error")
459
+ console.print(f"[red]Phase '{phase.name}' failed: {error_msg}[/red]")
460
+
461
+ # Report failure via API (skip in dry run)
462
+ if not self.dry_run:
463
+ await runs_client.fail_phase(
464
+ workflow_run_id=workflow_run.id,
465
+ error_message=error_msg,
466
+ )
467
+
468
+ # Check if phase is required
469
+ if phase.is_required:
470
+ console.print("[red]Required phase failed, stopping workflow[/red]")
471
+ break
472
+
473
+ # Only cleanup memory on complete success to preserve failed runs for debugging
474
+ workflow_success = phases_failed == 0
475
+
476
+ if workflow_success:
477
+ self._memory_manager.cleanup_run(workflow_run.workflow_id, workflow_run.id)
478
+ else:
479
+ run_path = self._memory_manager._get_run_path(
480
+ workflow_run.workflow_id, workflow_run.id
481
+ )
482
+ logger.info(f"Preserving workflow memory for debugging: {run_path}")
483
+
484
+ return {
485
+ "success": workflow_success,
486
+ "workflow_id": workflow_id,
487
+ "workflow_run_id": workflow_run.id,
488
+ "phases_completed": phases_completed,
489
+ "phases_failed": phases_failed,
490
+ "total_phases": len(phases),
491
+ }
492
+
493
+ finally:
494
+ await self.close()