codeframe-ai 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,1098 @@
1
+ """V2 Task execution router - delegates to core modules.
2
+
3
+ This module provides v2-style API endpoints for task management that delegate
4
+ to core/runtime.py and core/conductor.py. It uses the v2 Workspace model and
5
+ is designed to work alongside the v1 tasks router during migration.
6
+
7
+ Key differences from v1:
8
+ - Uses Workspace (path-based) instead of project_id
9
+ - Delegates to core/runtime and core/conductor functions
10
+ - No LeadAgent dependency for execution
11
+ - Uses conductor.start_batch() for parallel execution
12
+
13
+ The v1 router (tasks.py) remains for backwards compatibility with
14
+ existing web UI until Phase 3 (Web UI Rebuild).
15
+ """
16
+
17
+ import logging
18
+ import threading
19
+ from typing import Any, Literal, Optional
20
+
21
+ from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Query, Request
22
+ from fastapi.responses import StreamingResponse
23
+ from pydantic import BaseModel, Field, model_validator
24
+
25
+ from codeframe.core.workspace import Workspace
26
+ from codeframe.lib.rate_limiter import rate_limit_ai, rate_limit_standard
27
+ from codeframe.core import runtime, tasks, conductor, streaming
28
+ from codeframe.core.runtime import RunStatus
29
+ from codeframe.core.state_machine import (
30
+ InvalidTransitionError,
31
+ TaskStatus,
32
+ can_transition,
33
+ )
34
+ from codeframe.ui.dependencies import get_v2_workspace
35
+ from codeframe.ui.response_models import api_error, ErrorCodes
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ router = APIRouter(prefix="/api/v2/tasks", tags=["tasks-v2"])
40
+
41
+
42
+ # ============================================================================
43
+ # Request/Response Models
44
+ # ============================================================================
45
+
46
+
47
+ class ApproveTasksRequest(BaseModel):
48
+ """Request for task approval."""
49
+
50
+ excluded_task_ids: list[str] = Field(
51
+ default_factory=list,
52
+ description="Task IDs to exclude from approval",
53
+ )
54
+ start_execution: bool = Field(
55
+ default=False,
56
+ description="Whether to start batch execution after approval",
57
+ )
58
+ engine: str = Field(
59
+ "react",
60
+ description="Execution engine: 'react' (default, ReAct loop) or 'plan' (legacy step-based)",
61
+ )
62
+
63
+ @model_validator(mode="after")
64
+ def _validate_engine(self) -> "ApproveTasksRequest":
65
+ valid = ("plan", "react")
66
+ if self.engine not in valid:
67
+ raise ValueError(f"engine must be one of: {', '.join(valid)}")
68
+ return self
69
+
70
+
71
+ class ApproveTasksResponse(BaseModel):
72
+ """Response for task approval."""
73
+
74
+ success: bool
75
+ approved_count: int
76
+ excluded_count: int
77
+ approved_task_ids: list[str]
78
+ excluded_task_ids: list[str]
79
+ batch_id: Optional[str] = None
80
+ message: str
81
+
82
+
83
+ class AssignmentStatusResponse(BaseModel):
84
+ """Response for assignment status check."""
85
+
86
+ pending_count: int
87
+ executing_count: int
88
+ can_assign: bool
89
+ reason: str
90
+
91
+
92
+ class StartExecutionRequest(BaseModel):
93
+ """Request for starting task execution."""
94
+
95
+ task_ids: Optional[list[str]] = Field(
96
+ None,
97
+ description="Specific task IDs to execute (defaults to all READY tasks)",
98
+ )
99
+ strategy: str = Field(
100
+ "serial",
101
+ description="Execution strategy: serial, parallel, or auto",
102
+ )
103
+ max_parallel: int = Field(
104
+ 4,
105
+ ge=1,
106
+ le=10,
107
+ description="Maximum parallel workers (for parallel/auto strategy)",
108
+ )
109
+ retry_count: int = Field(
110
+ 0,
111
+ ge=0,
112
+ le=5,
113
+ description="Number of retries for failed tasks",
114
+ )
115
+ engine: str = Field(
116
+ "react",
117
+ description="Execution engine: 'react' (default, ReAct loop) or 'plan' (legacy step-based)",
118
+ )
119
+
120
+ @model_validator(mode="after")
121
+ def _validate_engine(self) -> "StartExecutionRequest":
122
+ valid = ("plan", "react")
123
+ if self.engine not in valid:
124
+ raise ValueError(f"engine must be one of: {', '.join(valid)}")
125
+ return self
126
+
127
+
128
+ class StartExecutionResponse(BaseModel):
129
+ """Response for starting execution."""
130
+
131
+ success: bool
132
+ batch_id: str
133
+ task_count: int
134
+ strategy: str
135
+ engine: str
136
+ message: str
137
+
138
+
139
+ class TaskResponse(BaseModel):
140
+ """Response for a single task."""
141
+
142
+ id: str
143
+ title: str
144
+ description: str
145
+ status: str
146
+ priority: int
147
+ depends_on: list[str] = []
148
+ requirement_ids: list[str] = []
149
+ estimated_hours: Optional[float] = None
150
+ created_at: Optional[str] = None
151
+ updated_at: Optional[str] = None
152
+ # GitHub issue traceability (#565)
153
+ github_issue_number: Optional[int] = None
154
+ external_url: Optional[str] = None
155
+ auto_close_github_issue: bool = False
156
+
157
+ @classmethod
158
+ def from_task(cls, task: tasks.Task) -> "TaskResponse":
159
+ """Build a response from a core ``Task`` (single source of mapping)."""
160
+ return cls(
161
+ id=task.id,
162
+ title=task.title,
163
+ description=task.description,
164
+ status=task.status.value,
165
+ priority=task.priority,
166
+ depends_on=task.depends_on,
167
+ requirement_ids=task.requirement_ids,
168
+ estimated_hours=task.estimated_hours,
169
+ created_at=task.created_at.isoformat() if task.created_at else None,
170
+ updated_at=task.updated_at.isoformat() if task.updated_at else None,
171
+ github_issue_number=task.github_issue_number,
172
+ external_url=task.external_url,
173
+ auto_close_github_issue=task.auto_close_github_issue,
174
+ )
175
+
176
+
177
+ class TaskListResponse(BaseModel):
178
+ """Response for task list."""
179
+
180
+ tasks: list[TaskResponse]
181
+ total: int
182
+ by_status: dict[str, int]
183
+
184
+
185
+ class UpdateTaskRequest(BaseModel):
186
+ """Request for updating a task."""
187
+
188
+ title: Optional[str] = Field(None, min_length=1, description="New task title")
189
+ description: Optional[str] = Field(None, description="New task description")
190
+ priority: Optional[int] = Field(None, ge=0, description="New task priority (0 = highest)")
191
+ status: Optional[str] = Field(None, description="New task status (use for manual transitions)")
192
+ auto_close_github_issue: Optional[bool] = Field(
193
+ None,
194
+ description="Whether to close the linked GitHub issue when this task is DONE (#565)",
195
+ )
196
+
197
+
198
+ # ============================================================================
199
+ # Task List/Status Endpoints
200
+ # ============================================================================
201
+
202
+
203
+ @router.get("", response_model=TaskListResponse)
204
+ @rate_limit_standard()
205
+ async def list_tasks(
206
+ request: Request,
207
+ status: Optional[str] = Query(None, description="Filter by status (BACKLOG, READY, IN_PROGRESS, DONE, BLOCKED, FAILED)"),
208
+ limit: int = Query(100, ge=1, le=1000),
209
+ workspace: Workspace = Depends(get_v2_workspace),
210
+ ) -> TaskListResponse:
211
+ """List tasks in the workspace.
212
+
213
+ Args:
214
+ status: Optional status filter
215
+ limit: Maximum tasks to return
216
+ workspace: v2 Workspace
217
+
218
+ Returns:
219
+ List of tasks with counts by status
220
+ """
221
+ # Parse status filter
222
+ status_filter = None
223
+ if status:
224
+ try:
225
+ status_filter = TaskStatus(status.upper())
226
+ except ValueError:
227
+ raise HTTPException(
228
+ status_code=400,
229
+ detail=api_error(
230
+ f"Invalid status: {status}",
231
+ ErrorCodes.VALIDATION_ERROR,
232
+ f"Valid values: {[s.value for s in TaskStatus]}",
233
+ ),
234
+ )
235
+
236
+ # Get tasks
237
+ task_list = tasks.list_tasks(workspace, status=status_filter, limit=limit)
238
+
239
+ # Get counts by status
240
+ status_counts = tasks.count_by_status(workspace)
241
+
242
+ return TaskListResponse(
243
+ tasks=[TaskResponse.from_task(t) for t in task_list],
244
+ total=len(task_list),
245
+ by_status=status_counts,
246
+ )
247
+
248
+
249
+ @router.get("/{task_id}", response_model=TaskResponse)
250
+ @rate_limit_standard()
251
+ async def get_task(
252
+ request: Request,
253
+ task_id: str,
254
+ workspace: Workspace = Depends(get_v2_workspace),
255
+ ) -> TaskResponse:
256
+ """Get a specific task by ID.
257
+
258
+ Args:
259
+ task_id: Task ID
260
+ workspace: v2 Workspace
261
+
262
+ Returns:
263
+ Task details
264
+
265
+ Raises:
266
+ HTTPException: 404 if task not found
267
+ """
268
+ task = tasks.get(workspace, task_id)
269
+ if not task:
270
+ raise HTTPException(
271
+ status_code=404,
272
+ detail=api_error("Task not found", ErrorCodes.NOT_FOUND, f"No task with id {task_id}"),
273
+ )
274
+
275
+ return TaskResponse.from_task(task)
276
+
277
+
278
+ @router.patch("/{task_id}", response_model=TaskResponse)
279
+ @rate_limit_standard()
280
+ async def update_task(
281
+ request: Request,
282
+ task_id: str,
283
+ body: UpdateTaskRequest,
284
+ workspace: Workspace = Depends(get_v2_workspace),
285
+ ) -> TaskResponse:
286
+ """Update a task's title, description, priority, or status.
287
+
288
+ Only provided fields are updated; others are left unchanged.
289
+
290
+ Args:
291
+ request: HTTP request for rate limiting
292
+ task_id: Task ID to update
293
+ body: Update request with fields to change
294
+ workspace: v2 Workspace
295
+
296
+ Returns:
297
+ Updated task
298
+
299
+ Raises:
300
+ HTTPException:
301
+ - 404: Task not found
302
+ - 400: Invalid status or status transition
303
+ """
304
+ try:
305
+ # Validate everything that can reject the request BEFORE any mutation, so
306
+ # a rejected PATCH (bad status, illegal transition, missing task) never
307
+ # leaves a side effect. (#565)
308
+ current = None
309
+ if body.status or body.auto_close_github_issue is not None:
310
+ current = tasks.get(workspace, task_id)
311
+ if current is None:
312
+ raise HTTPException(
313
+ status_code=404,
314
+ detail=api_error(
315
+ "Task not found",
316
+ ErrorCodes.NOT_FOUND,
317
+ f"No task with id {task_id}",
318
+ ),
319
+ )
320
+
321
+ new_status: Optional[TaskStatus] = None
322
+ if body.status:
323
+ try:
324
+ new_status = TaskStatus(body.status.upper())
325
+ except ValueError:
326
+ raise HTTPException(
327
+ status_code=400,
328
+ detail=api_error(
329
+ f"Invalid status: {body.status}",
330
+ ErrorCodes.VALIDATION_ERROR,
331
+ f"Valid values: {[s.value for s in TaskStatus]}",
332
+ ),
333
+ )
334
+ if not can_transition(current.status, new_status):
335
+ raise HTTPException(
336
+ status_code=400,
337
+ detail=api_error(
338
+ "Invalid status transition",
339
+ ErrorCodes.INVALID_STATE,
340
+ f"Cannot transition from {current.status.value} to "
341
+ f"{new_status.value}",
342
+ ),
343
+ )
344
+
345
+ # Persist the auto-close preference FIRST so the DONE transition below
346
+ # sees the value requested in THIS PATCH. That makes a combined request
347
+ # behave correctly both ways: {status: DONE, opt-in} closes the issue and
348
+ # {status: DONE, opt-OUT} does not — core reads the freshly-saved flag.
349
+ original_auto_close = (
350
+ current.auto_close_github_issue
351
+ if (current is not None and body.auto_close_github_issue is not None)
352
+ else None
353
+ )
354
+ if body.auto_close_github_issue is not None:
355
+ tasks.update_auto_close(
356
+ workspace, task_id, body.auto_close_github_issue
357
+ )
358
+
359
+ # Apply the (pre-validated) status transition. Core closes the linked
360
+ # issue here when the task is opted in and transitions to DONE.
361
+ if new_status is not None:
362
+ try:
363
+ tasks.update_status(workspace, task_id, new_status)
364
+ except InvalidTransitionError:
365
+ # Pre-validation passed, so the task state changed concurrently
366
+ # between the check and the apply. Roll back the flag we just
367
+ # persisted so this failed request leaves no side effect.
368
+ if original_auto_close is not None:
369
+ tasks.update_auto_close(workspace, task_id, original_auto_close)
370
+ raise HTTPException(
371
+ status_code=409,
372
+ detail=api_error(
373
+ "Task state changed concurrently; please retry.",
374
+ ErrorCodes.CONFLICT,
375
+ ),
376
+ )
377
+
378
+ # Late opt-in: enabling the flag (false -> true) on a task that is ALREADY
379
+ # DONE — with no status transition in this request — won't trigger core's
380
+ # transition-based close, so fire it explicitly here.
381
+ if (
382
+ body.auto_close_github_issue
383
+ and original_auto_close is False
384
+ and new_status is None
385
+ ):
386
+ tasks.autoclose_if_done(workspace, tasks.get(workspace, task_id))
387
+
388
+ # Update remaining fields; re-reads current state so the returned task
389
+ # reflects every change (including the auto-close flag above).
390
+ task = tasks.update(
391
+ workspace,
392
+ task_id,
393
+ title=body.title,
394
+ description=body.description,
395
+ priority=body.priority,
396
+ )
397
+
398
+ return TaskResponse.from_task(task)
399
+
400
+ except ValueError as e:
401
+ error_msg = str(e)
402
+ if "not found" in error_msg.lower():
403
+ raise HTTPException(
404
+ status_code=404,
405
+ detail=api_error("Task not found", ErrorCodes.NOT_FOUND, error_msg),
406
+ )
407
+ raise HTTPException(
408
+ status_code=400,
409
+ detail=api_error("Invalid request", ErrorCodes.INVALID_REQUEST, error_msg),
410
+ )
411
+ except HTTPException:
412
+ raise
413
+ except Exception as e:
414
+ logger.error(f"Failed to update task {task_id}: {e}", exc_info=True)
415
+ raise HTTPException(
416
+ status_code=500,
417
+ detail=api_error("Update failed", ErrorCodes.EXECUTION_FAILED, str(e)),
418
+ )
419
+
420
+
421
+ @router.delete("/{task_id}")
422
+ @rate_limit_standard()
423
+ async def delete_task(
424
+ request: Request,
425
+ task_id: str,
426
+ workspace: Workspace = Depends(get_v2_workspace),
427
+ ) -> dict:
428
+ """Delete a task.
429
+
430
+ Args:
431
+ task_id: Task ID to delete
432
+ workspace: v2 Workspace
433
+
434
+ Returns:
435
+ Deletion confirmation
436
+
437
+ Raises:
438
+ HTTPException: 404 if task not found
439
+ """
440
+ deleted = tasks.delete(workspace, task_id)
441
+
442
+ if not deleted:
443
+ raise HTTPException(
444
+ status_code=404,
445
+ detail=api_error("Task not found", ErrorCodes.NOT_FOUND, f"No task with id {task_id}"),
446
+ )
447
+
448
+ return {
449
+ "success": True,
450
+ "message": f"Task {task_id[:8]} deleted successfully",
451
+ }
452
+
453
+
454
+ # ============================================================================
455
+ # Task Approval Endpoints
456
+ # ============================================================================
457
+
458
+
459
+ @router.post("/approve", response_model=ApproveTasksResponse)
460
+ @rate_limit_standard()
461
+ async def approve_tasks_endpoint(
462
+ request: Request,
463
+ body: ApproveTasksRequest,
464
+ background_tasks: BackgroundTasks,
465
+ workspace: Workspace = Depends(get_v2_workspace),
466
+ ) -> ApproveTasksResponse:
467
+ """Approve tasks and optionally start execution.
468
+
469
+ Transitions BACKLOG tasks to READY status (excluding specified tasks).
470
+ Optionally triggers batch execution for approved tasks.
471
+
472
+ This is the v2 equivalent of POST /api/projects/{id}/tasks/approve.
473
+
474
+ Args:
475
+ request: Approval request with exclusions and execution flag
476
+ background_tasks: FastAPI background tasks
477
+ workspace: v2 Workspace
478
+
479
+ Returns:
480
+ Approval result with counts and optional batch ID
481
+ """
482
+ try:
483
+ # Approve tasks (transition BACKLOG → READY)
484
+ result = runtime.approve_tasks(
485
+ workspace,
486
+ excluded_task_ids=body.excluded_task_ids,
487
+ )
488
+
489
+ batch_id = None
490
+ message = f"Approved {result.approved_count} task(s)."
491
+
492
+ if result.approved_count == 0:
493
+ return ApproveTasksResponse(
494
+ success=True,
495
+ approved_count=0,
496
+ excluded_count=result.excluded_count,
497
+ approved_task_ids=[],
498
+ excluded_task_ids=result.excluded_task_ids,
499
+ batch_id=None,
500
+ message="No tasks to approve (no BACKLOG tasks found).",
501
+ )
502
+
503
+ # Optionally start execution
504
+ if body.start_execution:
505
+ batch = conductor.start_batch(
506
+ workspace,
507
+ task_ids=result.approved_task_ids,
508
+ strategy="serial",
509
+ max_parallel=4,
510
+ on_failure="continue",
511
+ engine=body.engine,
512
+ )
513
+ batch_id = batch.id
514
+ message = f"Approved {result.approved_count} task(s) and started execution (batch {batch_id[:8]})."
515
+
516
+ return ApproveTasksResponse(
517
+ success=True,
518
+ approved_count=result.approved_count,
519
+ excluded_count=result.excluded_count,
520
+ approved_task_ids=result.approved_task_ids,
521
+ excluded_task_ids=result.excluded_task_ids,
522
+ batch_id=batch_id,
523
+ message=message,
524
+ )
525
+
526
+ except Exception as e:
527
+ logger.error(f"Failed to approve tasks: {e}", exc_info=True)
528
+ raise HTTPException(
529
+ status_code=500,
530
+ detail=api_error("Approval failed", ErrorCodes.EXECUTION_FAILED, str(e)),
531
+ )
532
+
533
+
534
+ @router.get("/assignment-status", response_model=AssignmentStatusResponse)
535
+ @rate_limit_standard()
536
+ async def get_assignment_status(
537
+ request: Request,
538
+ workspace: Workspace = Depends(get_v2_workspace),
539
+ ) -> AssignmentStatusResponse:
540
+ """Check if tasks can be assigned for execution.
541
+
542
+ Returns the current execution status and whether new tasks can be assigned.
543
+
544
+ This is the v2 equivalent of checking before POST /api/projects/{id}/tasks/assign.
545
+
546
+ Args:
547
+ workspace: v2 Workspace
548
+
549
+ Returns:
550
+ Assignment status with pending/executing counts
551
+ """
552
+ status = runtime.check_assignment_status(workspace)
553
+ return AssignmentStatusResponse(
554
+ pending_count=status.pending_count,
555
+ executing_count=status.executing_count,
556
+ can_assign=status.can_assign,
557
+ reason=status.reason,
558
+ )
559
+
560
+
561
+ # ============================================================================
562
+ # Task Execution Endpoints
563
+ # ============================================================================
564
+
565
+
566
+ @router.post("/execute", response_model=StartExecutionResponse)
567
+ @rate_limit_ai()
568
+ async def start_execution(
569
+ request: Request,
570
+ body: StartExecutionRequest,
571
+ workspace: Workspace = Depends(get_v2_workspace),
572
+ ) -> StartExecutionResponse:
573
+ """Start task execution.
574
+
575
+ Triggers batch execution for specified tasks or all READY tasks.
576
+
577
+ This is the v2 equivalent of POST /api/projects/{id}/tasks/assign.
578
+
579
+ Args:
580
+ request: HTTP request for rate limiting
581
+ body: Execution request with task IDs and strategy
582
+ workspace: v2 Workspace
583
+
584
+ Returns:
585
+ Execution result with batch ID
586
+
587
+ Raises:
588
+ HTTPException:
589
+ - 400: No tasks to execute or execution already in progress
590
+ - 500: Execution error
591
+ """
592
+ try:
593
+ # Check assignment status first
594
+ status = runtime.check_assignment_status(workspace)
595
+ if not status.can_assign:
596
+ raise HTTPException(
597
+ status_code=400,
598
+ detail=api_error("Cannot execute", ErrorCodes.INVALID_STATE, status.reason),
599
+ )
600
+
601
+ # Get task IDs
602
+ task_ids = body.task_ids or runtime.get_ready_task_ids(workspace)
603
+ if not task_ids:
604
+ raise HTTPException(
605
+ status_code=400,
606
+ detail=api_error(
607
+ "No tasks to execute",
608
+ ErrorCodes.INVALID_REQUEST,
609
+ "Approve tasks first with POST /api/v2/tasks/approve",
610
+ ),
611
+ )
612
+
613
+ # Start batch execution
614
+ batch = conductor.start_batch(
615
+ workspace,
616
+ task_ids=task_ids,
617
+ strategy=body.strategy,
618
+ max_parallel=body.max_parallel,
619
+ max_retries=body.retry_count,
620
+ on_failure="continue",
621
+ engine=body.engine,
622
+ )
623
+
624
+ return StartExecutionResponse(
625
+ success=True,
626
+ batch_id=batch.id,
627
+ task_count=len(task_ids),
628
+ strategy=body.strategy,
629
+ engine=body.engine,
630
+ message=f"Started execution for {len(task_ids)} task(s) (batch {batch.id[:8]}).",
631
+ )
632
+
633
+ except HTTPException:
634
+ raise
635
+ except Exception as e:
636
+ logger.error(f"Failed to start execution: {e}", exc_info=True)
637
+ raise HTTPException(
638
+ status_code=500,
639
+ detail=api_error("Execution failed", ErrorCodes.EXECUTION_FAILED, str(e)),
640
+ )
641
+
642
+
643
+ @router.post("/{task_id}/start")
644
+ @rate_limit_ai()
645
+ async def start_single_task(
646
+ request: Request,
647
+ task_id: str,
648
+ execute: bool = Query(False, description="Run agent execution (requires ANTHROPIC_API_KEY)"),
649
+ dry_run: bool = Query(False, description="Preview changes without making them"),
650
+ verbose: bool = Query(False, description="Show detailed progress output"),
651
+ engine: Literal["plan", "react"] = Query("react", description="Execution engine: 'react' (default, ReAct loop) or 'plan' (legacy step-based)"),
652
+ workspace: Workspace = Depends(get_v2_workspace),
653
+ ) -> dict[str, Any]:
654
+ """Start a single task run.
655
+
656
+ Creates a run record and optionally executes the agent.
657
+
658
+ This is the v2 equivalent of `cf work start <task-id>`.
659
+
660
+ Args:
661
+ task_id: Task to start
662
+ execute: Whether to run agent execution
663
+ dry_run: Preview mode (no actual changes)
664
+ verbose: Show detailed output
665
+ workspace: v2 Workspace
666
+
667
+ Returns:
668
+ Run details
669
+
670
+ Raises:
671
+ HTTPException:
672
+ - 400: Task already has active run
673
+ - 404: Task not found
674
+ - 500: Execution error
675
+ """
676
+ try:
677
+ # Start the run
678
+ run = runtime.start_task_run(workspace, task_id)
679
+
680
+ result = {
681
+ "success": True,
682
+ "run_id": run.id,
683
+ "task_id": task_id,
684
+ "status": run.status.value,
685
+ "message": f"Started run {run.id[:8]} for task {task_id[:8]}.",
686
+ }
687
+
688
+ if execute:
689
+ from codeframe.ui.routers.streaming_v2 import get_event_publisher
690
+ from codeframe.core.models import ErrorEvent
691
+
692
+ publisher = get_event_publisher()
693
+
694
+ def _run_agent():
695
+ try:
696
+ runtime.execute_agent(
697
+ workspace,
698
+ run,
699
+ dry_run=dry_run,
700
+ verbose=verbose,
701
+ event_publisher=publisher,
702
+ engine=engine,
703
+ )
704
+ except Exception as exc:
705
+ logger.error(f"Background agent failed for task {task_id}: {exc}", exc_info=True)
706
+ publisher.publish_sync(
707
+ task_id,
708
+ ErrorEvent(
709
+ task_id=task_id,
710
+ error=str(exc),
711
+ error_type=type(exc).__name__,
712
+ ),
713
+ )
714
+ publisher.complete_task_sync(task_id)
715
+
716
+ thread = threading.Thread(target=_run_agent, daemon=True)
717
+ thread.start()
718
+
719
+ result["status"] = "executing"
720
+ result["message"] = f"Execution started in background for task {task_id[:8]}. Connect to GET /{task_id}/stream for events."
721
+
722
+ return result
723
+
724
+ except ValueError as e:
725
+ error_msg = str(e)
726
+ if "not found" in error_msg.lower():
727
+ raise HTTPException(
728
+ status_code=404,
729
+ detail=api_error("Task not found", ErrorCodes.NOT_FOUND, error_msg),
730
+ )
731
+ raise HTTPException(
732
+ status_code=400,
733
+ detail=api_error("Invalid request", ErrorCodes.INVALID_REQUEST, error_msg),
734
+ )
735
+ except Exception as e:
736
+ logger.error(f"Failed to start task {task_id}: {e}", exc_info=True)
737
+ raise HTTPException(
738
+ status_code=500,
739
+ detail=api_error("Start failed", ErrorCodes.EXECUTION_FAILED, str(e)),
740
+ )
741
+
742
+
743
+ @router.post("/{task_id}/stop")
744
+ @rate_limit_standard()
745
+ async def stop_task(
746
+ request: Request,
747
+ task_id: str,
748
+ workspace: Workspace = Depends(get_v2_workspace),
749
+ ) -> dict[str, Any]:
750
+ """Stop a running task.
751
+
752
+ Marks the run as failed and transitions task back to READY.
753
+
754
+ This is the v2 equivalent of `cf work stop <task-id>`.
755
+
756
+ Args:
757
+ task_id: Task to stop
758
+ workspace: v2 Workspace
759
+
760
+ Returns:
761
+ Stop result
762
+
763
+ Raises:
764
+ HTTPException:
765
+ - 400: No active run for task
766
+ - 404: Task not found
767
+ """
768
+ try:
769
+ run = runtime.stop_run(workspace, task_id)
770
+ return {
771
+ "success": True,
772
+ "run_id": run.id,
773
+ "task_id": task_id,
774
+ "status": run.status.value,
775
+ "message": f"Stopped run {run.id[:8]} for task {task_id[:8]}.",
776
+ }
777
+ except ValueError as e:
778
+ error_msg = str(e)
779
+ if "not found" in error_msg.lower():
780
+ raise HTTPException(
781
+ status_code=404,
782
+ detail=api_error("Run not found", ErrorCodes.NOT_FOUND, error_msg),
783
+ )
784
+ raise HTTPException(
785
+ status_code=400,
786
+ detail=api_error("Cannot stop", ErrorCodes.INVALID_STATE, error_msg),
787
+ )
788
+
789
+
790
+ @router.post("/{task_id}/resume")
791
+ @rate_limit_ai()
792
+ async def resume_task(
793
+ request: Request,
794
+ task_id: str,
795
+ workspace: Workspace = Depends(get_v2_workspace),
796
+ ) -> dict[str, Any]:
797
+ """Resume a blocked task.
798
+
799
+ This is the v2 equivalent of `cf work resume <task-id>`.
800
+
801
+ Args:
802
+ task_id: Task to resume
803
+ workspace: v2 Workspace
804
+
805
+ Returns:
806
+ Resume result
807
+
808
+ Raises:
809
+ HTTPException:
810
+ - 400: Run not blocked
811
+ - 404: No active run for task
812
+ """
813
+ try:
814
+ run = runtime.resume_run(workspace, task_id)
815
+ return {
816
+ "success": True,
817
+ "run_id": run.id,
818
+ "task_id": task_id,
819
+ "status": run.status.value,
820
+ "message": f"Resumed run {run.id[:8]} for task {task_id[:8]}.",
821
+ }
822
+ except ValueError as e:
823
+ error_msg = str(e)
824
+ if "not found" in error_msg.lower():
825
+ raise HTTPException(
826
+ status_code=404,
827
+ detail=api_error("Run not found", ErrorCodes.NOT_FOUND, error_msg),
828
+ )
829
+ raise HTTPException(
830
+ status_code=400,
831
+ detail=api_error("Cannot resume", ErrorCodes.INVALID_STATE, error_msg),
832
+ )
833
+
834
+
835
+ # ============================================================================
836
+ # Streaming Endpoints
837
+ # ============================================================================
838
+
839
+
840
+ @router.get("/{task_id}/output")
841
+ @rate_limit_standard()
842
+ async def stream_task_output_lines(
843
+ request: Request,
844
+ task_id: str,
845
+ tail: int = Query(0, ge=0, le=1000, description="Show last N lines before streaming"),
846
+ workspace: Workspace = Depends(get_v2_workspace),
847
+ ) -> StreamingResponse:
848
+ """Stream raw output lines from a running task.
849
+
850
+ Returns Server-Sent Events (SSE) with raw text output lines.
851
+ This is the API equivalent of `cf work follow <task-id>`.
852
+
853
+ For structured JSON execution events (progress, output, blocker,
854
+ completion, error), use GET /{task_id}/stream instead.
855
+
856
+ Event types:
857
+ - `line`: A line of output from the task
858
+ - `info`: Informational message (e.g., "showing last N lines")
859
+ - `error`: Error message
860
+ - `done`: Stream completed (task finished or no more output)
861
+
862
+ Args:
863
+ task_id: Task to stream output from
864
+ tail: Number of historical lines to show before live streaming
865
+ workspace: v2 Workspace
866
+
867
+ Returns:
868
+ SSE stream of task output
869
+
870
+ Raises:
871
+ HTTPException:
872
+ - 404: Task not found or no run exists
873
+ """
874
+ # Get the task first to validate it exists
875
+ task = tasks.get(workspace, task_id)
876
+ if not task:
877
+ raise HTTPException(
878
+ status_code=404,
879
+ detail=api_error("Task not found", ErrorCodes.NOT_FOUND, f"No task with id {task_id}"),
880
+ )
881
+
882
+ # Get the active run for this task
883
+ run = runtime.get_active_run(workspace, task_id)
884
+
885
+ # If no active run, try to find the most recent completed run
886
+ if not run:
887
+ run = runtime.get_latest_run(workspace, task_id)
888
+
889
+ if not run:
890
+ raise HTTPException(
891
+ status_code=404,
892
+ detail=api_error(
893
+ "No run found",
894
+ ErrorCodes.NOT_FOUND,
895
+ f"No run exists for task {task_id}. Start the task first with POST /{task_id}/start",
896
+ ),
897
+ )
898
+
899
+ def generate_events():
900
+ """Generator that yields SSE events."""
901
+ run_id = run.id
902
+ current_line = 0
903
+
904
+ # Check if output exists
905
+ if not streaming.run_output_exists(workspace, run_id):
906
+ yield "event: info\ndata: Waiting for output...\n\n"
907
+
908
+ # If tail requested, show historical lines first
909
+ if tail > 0:
910
+ lines, total = streaming.get_latest_lines_with_count(workspace, run_id, tail)
911
+ if lines:
912
+ skipped = total - len(lines)
913
+ if skipped > 0:
914
+ yield f"event: info\ndata: (skipped {skipped} lines, showing last {len(lines)})\n\n"
915
+
916
+ for line in lines:
917
+ # Remove trailing newline for SSE format
918
+ yield f"event: line\ndata: {line.rstrip()}\n\n"
919
+
920
+ current_line = total
921
+
922
+ # Stream new lines as they appear
923
+ # Use a reasonable timeout to allow the connection to close gracefully
924
+ for line in streaming.tail_run_output(
925
+ workspace,
926
+ run_id,
927
+ since_line=current_line,
928
+ poll_interval=0.5,
929
+ max_wait=300.0, # 5 minute timeout
930
+ ):
931
+ yield f"event: line\ndata: {line.rstrip()}\n\n"
932
+
933
+ yield "event: done\ndata: Stream ended\n\n"
934
+
935
+ return StreamingResponse(
936
+ generate_events(),
937
+ media_type="text/event-stream",
938
+ headers={
939
+ "Cache-Control": "no-cache",
940
+ "Connection": "keep-alive",
941
+ "X-Accel-Buffering": "no", # Disable nginx buffering
942
+ },
943
+ )
944
+
945
+
946
+ @router.get("/{task_id}/stream")
947
+ async def stream_task_events(
948
+ request: Request,
949
+ task_id: str,
950
+ workspace: Workspace = Depends(get_v2_workspace),
951
+ ) -> StreamingResponse:
952
+ """Stream structured execution events for a task via SSE.
953
+
954
+ Returns Server-Sent Events with JSON-formatted ExecutionEvent payloads.
955
+ Compatible with browser EventSource (no custom auth headers required).
956
+
957
+ Event types (in data.event_type):
958
+ - ``progress``: Phase/step transitions
959
+ - ``output``: stdout/stderr lines
960
+ - ``blocker``: Human input needed
961
+ - ``completion``: Task finished (success/failure/blocked)
962
+ - ``error``: Execution error
963
+ - ``heartbeat``: Keep-alive
964
+
965
+ For raw text output lines (cf work follow equivalent),
966
+ use GET /{task_id}/output instead.
967
+ """
968
+ task = tasks.get(workspace, task_id)
969
+ if not task:
970
+ raise HTTPException(
971
+ status_code=404,
972
+ detail=api_error("Task not found", ErrorCodes.NOT_FOUND, f"No task with id {task_id}"),
973
+ )
974
+
975
+ from codeframe.ui.routers.streaming_v2 import (
976
+ event_stream_generator,
977
+ format_sse_event,
978
+ get_event_publisher,
979
+ )
980
+ from codeframe.core.models import CompletionEvent, ProgressEvent
981
+
982
+ publisher = get_event_publisher()
983
+
984
+ # Check if the task already has a terminal run — if so, send a synthetic
985
+ # completion event immediately instead of waiting for events that will
986
+ # never arrive (the agent is done, the EventPublisher has no buffering).
987
+ run = runtime.get_active_run(workspace, task_id)
988
+ latest_run = runtime.get_latest_run(workspace, task_id) if not run else None
989
+ already_terminal = (
990
+ latest_run is not None
991
+ and latest_run.status in (RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.BLOCKED)
992
+ ) if not run else False
993
+
994
+ async def _generate():
995
+ # Always send an initial progress event so the browser's EventSource
996
+ # fires onmessage and the client transitions from CONNECTING state.
997
+ yield format_sse_event(
998
+ ProgressEvent(
999
+ task_id=task_id,
1000
+ phase="connected",
1001
+ step=0,
1002
+ total_steps=0,
1003
+ message="Stream connected",
1004
+ )
1005
+ )
1006
+
1007
+ if already_terminal:
1008
+ # Task already finished — emit a synthetic completion event.
1009
+ status_map = {
1010
+ RunStatus.COMPLETED: "completed",
1011
+ RunStatus.FAILED: "failed",
1012
+ RunStatus.BLOCKED: "blocked",
1013
+ }
1014
+ duration = 0.0
1015
+ if latest_run.started_at and latest_run.completed_at:
1016
+ duration = (latest_run.completed_at - latest_run.started_at).total_seconds()
1017
+ yield format_sse_event(
1018
+ CompletionEvent(
1019
+ task_id=task_id,
1020
+ status=status_map[latest_run.status],
1021
+ duration_seconds=duration,
1022
+ )
1023
+ )
1024
+ return
1025
+
1026
+ # Live stream — subscribe to the EventPublisher for real-time events.
1027
+ async for chunk in event_stream_generator(task_id, publisher, request):
1028
+ yield chunk
1029
+
1030
+ return StreamingResponse(
1031
+ _generate(),
1032
+ media_type="text/event-stream",
1033
+ headers={
1034
+ "Cache-Control": "no-cache",
1035
+ "Connection": "keep-alive",
1036
+ "X-Accel-Buffering": "no",
1037
+ },
1038
+ )
1039
+
1040
+
1041
+ @router.get("/{task_id}/run")
1042
+ @rate_limit_standard()
1043
+ async def get_task_run(
1044
+ request: Request,
1045
+ task_id: str,
1046
+ workspace: Workspace = Depends(get_v2_workspace),
1047
+ ) -> dict[str, Any]:
1048
+ """Get the current or most recent run for a task.
1049
+
1050
+ This is the v2 equivalent of `cf work status <task-id>`.
1051
+
1052
+ Args:
1053
+ task_id: Task to get run status for
1054
+ workspace: v2 Workspace
1055
+
1056
+ Returns:
1057
+ Run details including status, timing, and output availability
1058
+
1059
+ Raises:
1060
+ HTTPException:
1061
+ - 404: Task not found or no run exists
1062
+ """
1063
+ # Get the task first to validate it exists
1064
+ task = tasks.get(workspace, task_id)
1065
+ if not task:
1066
+ raise HTTPException(
1067
+ status_code=404,
1068
+ detail=api_error("Task not found", ErrorCodes.NOT_FOUND, f"No task with id {task_id}"),
1069
+ )
1070
+
1071
+ # Get the active run, or fall back to latest run
1072
+ run = runtime.get_active_run(workspace, task_id)
1073
+ if not run:
1074
+ run = runtime.get_latest_run(workspace, task_id)
1075
+
1076
+ if not run:
1077
+ raise HTTPException(
1078
+ status_code=404,
1079
+ detail=api_error(
1080
+ "No run found",
1081
+ ErrorCodes.NOT_FOUND,
1082
+ f"No run exists for task {task_id}",
1083
+ ),
1084
+ )
1085
+
1086
+ # Check if output exists
1087
+ has_output = streaming.run_output_exists(workspace, run.id)
1088
+
1089
+ return {
1090
+ "success": True,
1091
+ "run_id": run.id,
1092
+ "task_id": task_id,
1093
+ "status": run.status.value,
1094
+ "started_at": run.started_at.isoformat() if run.started_at else None,
1095
+ "completed_at": run.completed_at.isoformat() if run.completed_at else None,
1096
+ "has_output": has_output,
1097
+ "message": f"Run {run.id[:8]} is {run.status.value}",
1098
+ }