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.
- codeframe/__init__.py +11 -0
- codeframe/__main__.py +20 -0
- codeframe/adapters/__init__.py +5 -0
- codeframe/adapters/e2b/__init__.py +13 -0
- codeframe/adapters/e2b/adapter.py +342 -0
- codeframe/adapters/e2b/budget.py +71 -0
- codeframe/adapters/e2b/credential_scanner.py +134 -0
- codeframe/adapters/llm/__init__.py +92 -0
- codeframe/adapters/llm/anthropic.py +414 -0
- codeframe/adapters/llm/base.py +444 -0
- codeframe/adapters/llm/mock.py +281 -0
- codeframe/adapters/llm/openai.py +483 -0
- codeframe/agents/__init__.py +8 -0
- codeframe/agents/dependency_resolver.py +714 -0
- codeframe/auth/__init__.py +16 -0
- codeframe/auth/api_key_router.py +238 -0
- codeframe/auth/api_keys.py +156 -0
- codeframe/auth/dependencies.py +358 -0
- codeframe/auth/manager.py +178 -0
- codeframe/auth/models.py +30 -0
- codeframe/auth/router.py +93 -0
- codeframe/auth/schemas.py +15 -0
- codeframe/auth/scopes.py +53 -0
- codeframe/cli/__init__.py +12 -0
- codeframe/cli/__main__.py +20 -0
- codeframe/cli/api_client.py +275 -0
- codeframe/cli/app.py +5688 -0
- codeframe/cli/auth.py +122 -0
- codeframe/cli/auth_commands.py +958 -0
- codeframe/cli/commands/__init__.py +5 -0
- codeframe/cli/config_commands.py +79 -0
- codeframe/cli/dashboard_commands.py +67 -0
- codeframe/cli/engines_commands.py +205 -0
- codeframe/cli/env_commands.py +409 -0
- codeframe/cli/helpers.py +56 -0
- codeframe/cli/hooks_commands.py +208 -0
- codeframe/cli/import_commands.py +129 -0
- codeframe/cli/pr_commands.py +549 -0
- codeframe/cli/proof_commands.py +415 -0
- codeframe/cli/stats_commands.py +311 -0
- codeframe/cli/telemetry_runtime.py +153 -0
- codeframe/cli/validators.py +123 -0
- codeframe/config/rate_limits.py +165 -0
- codeframe/core/__init__.py +15 -0
- codeframe/core/adapters/__init__.py +43 -0
- codeframe/core/adapters/agent_adapter.py +114 -0
- codeframe/core/adapters/builtin.py +326 -0
- codeframe/core/adapters/claude_code.py +62 -0
- codeframe/core/adapters/codex.py +393 -0
- codeframe/core/adapters/git_utils.py +40 -0
- codeframe/core/adapters/kilocode.py +126 -0
- codeframe/core/adapters/opencode.py +48 -0
- codeframe/core/adapters/streaming_chat.py +483 -0
- codeframe/core/adapters/subprocess_adapter.py +213 -0
- codeframe/core/adapters/verification_wrapper.py +269 -0
- codeframe/core/agent.py +2183 -0
- codeframe/core/agents_config.py +569 -0
- codeframe/core/api_key_service.py +211 -0
- codeframe/core/artifacts.py +428 -0
- codeframe/core/blocker_detection.py +218 -0
- codeframe/core/blockers.py +433 -0
- codeframe/core/checkpoints.py +481 -0
- codeframe/core/conductor.py +2255 -0
- codeframe/core/config.py +827 -0
- codeframe/core/config_watcher.py +268 -0
- codeframe/core/context.py +542 -0
- codeframe/core/context_packager.py +234 -0
- codeframe/core/credentials.py +735 -0
- codeframe/core/dependency_analyzer.py +229 -0
- codeframe/core/dependency_graph.py +290 -0
- codeframe/core/diagnostic_agent.py +712 -0
- codeframe/core/diagnostics.py +616 -0
- codeframe/core/editor.py +556 -0
- codeframe/core/engine_registry.py +256 -0
- codeframe/core/engine_stats.py +231 -0
- codeframe/core/environment.py +697 -0
- codeframe/core/events.py +375 -0
- codeframe/core/executor.py +1005 -0
- codeframe/core/fix_tracker.py +480 -0
- codeframe/core/gates.py +1322 -0
- codeframe/core/git.py +477 -0
- codeframe/core/github_connect_service.py +178 -0
- codeframe/core/github_integration_config.py +118 -0
- codeframe/core/github_issues_service.py +449 -0
- codeframe/core/hooks.py +184 -0
- codeframe/core/importers/__init__.py +1 -0
- codeframe/core/importers/ralph.py +540 -0
- codeframe/core/installer.py +650 -0
- codeframe/core/models.py +1026 -0
- codeframe/core/notifications_config.py +183 -0
- codeframe/core/planner.py +437 -0
- codeframe/core/prd.py +670 -0
- codeframe/core/prd_discovery.py +1118 -0
- codeframe/core/prd_stress_test.py +499 -0
- codeframe/core/progress.py +126 -0
- codeframe/core/proof/__init__.py +34 -0
- codeframe/core/proof/capture.py +79 -0
- codeframe/core/proof/evidence.py +56 -0
- codeframe/core/proof/ledger.py +574 -0
- codeframe/core/proof/models.py +162 -0
- codeframe/core/proof/obligations.py +103 -0
- codeframe/core/proof/runner.py +233 -0
- codeframe/core/proof/scope.py +81 -0
- codeframe/core/proof/stubs.py +156 -0
- codeframe/core/quick_fixes.py +558 -0
- codeframe/core/react_agent.py +1650 -0
- codeframe/core/reconciliation.py +183 -0
- codeframe/core/replay.py +788 -0
- codeframe/core/review.py +285 -0
- codeframe/core/runtime.py +1134 -0
- codeframe/core/sandbox/__init__.py +27 -0
- codeframe/core/sandbox/context.py +98 -0
- codeframe/core/sandbox/worktree.py +20 -0
- codeframe/core/schedule.py +396 -0
- codeframe/core/stall_detector.py +71 -0
- codeframe/core/stall_monitor.py +134 -0
- codeframe/core/state_machine.py +121 -0
- codeframe/core/streaming.py +502 -0
- codeframe/core/task_tree.py +400 -0
- codeframe/core/tasks.py +1022 -0
- codeframe/core/telemetry.py +232 -0
- codeframe/core/templates.py +221 -0
- codeframe/core/tools.py +942 -0
- codeframe/core/workspace.py +887 -0
- codeframe/core/worktrees.py +276 -0
- codeframe/git/__init__.py +5 -0
- codeframe/git/github_integration.py +505 -0
- codeframe/lib/__init__.py +0 -0
- codeframe/lib/audit_logger.py +248 -0
- codeframe/lib/metrics_tracker.py +800 -0
- codeframe/lib/quality/__init__.py +7 -0
- codeframe/lib/quality/complexity_analyzer.py +316 -0
- codeframe/lib/quality/owasp_patterns.py +284 -0
- codeframe/lib/quality/security_scanner.py +250 -0
- codeframe/lib/rate_limiter.py +312 -0
- codeframe/notifications/__init__.py +0 -0
- codeframe/notifications/webhook.py +380 -0
- codeframe/planning/__init__.py +30 -0
- codeframe/planning/issue_generator.py +219 -0
- codeframe/planning/prd_template_functions.py +137 -0
- codeframe/planning/prd_templates.py +975 -0
- codeframe/planning/task_scheduler.py +511 -0
- codeframe/planning/task_templates.py +533 -0
- codeframe/platform_store/__init__.py +5 -0
- codeframe/platform_store/database.py +277 -0
- codeframe/platform_store/repositories/__init__.py +24 -0
- codeframe/platform_store/repositories/api_key_repository.py +245 -0
- codeframe/platform_store/repositories/audit_repository.py +67 -0
- codeframe/platform_store/repositories/base.py +295 -0
- codeframe/platform_store/repositories/interactive_sessions.py +165 -0
- codeframe/platform_store/repositories/token_repository.py +598 -0
- codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
- codeframe/platform_store/schema_manager.py +321 -0
- codeframe/templates/AGENTS.md.default +94 -0
- codeframe/tui/__init__.py +5 -0
- codeframe/tui/app.py +256 -0
- codeframe/tui/data_service.py +103 -0
- codeframe/ui/__init__.py +0 -0
- codeframe/ui/dependencies.py +103 -0
- codeframe/ui/models.py +999 -0
- codeframe/ui/response_models.py +201 -0
- codeframe/ui/routers/__init__.py +5 -0
- codeframe/ui/routers/_helpers.py +29 -0
- codeframe/ui/routers/batches_v2.py +315 -0
- codeframe/ui/routers/blockers_v2.py +320 -0
- codeframe/ui/routers/checkpoints_v2.py +310 -0
- codeframe/ui/routers/costs_v2.py +322 -0
- codeframe/ui/routers/diagnose_v2.py +225 -0
- codeframe/ui/routers/discovery_v2.py +417 -0
- codeframe/ui/routers/environment_v2.py +284 -0
- codeframe/ui/routers/events_v2.py +75 -0
- codeframe/ui/routers/gates_v2.py +166 -0
- codeframe/ui/routers/git_v2.py +284 -0
- codeframe/ui/routers/github_integrations_v2.py +532 -0
- codeframe/ui/routers/interactive_sessions_v2.py +238 -0
- codeframe/ui/routers/pr_v2.py +709 -0
- codeframe/ui/routers/prd_v2.py +695 -0
- codeframe/ui/routers/proof_v2.py +755 -0
- codeframe/ui/routers/review_v2.py +360 -0
- codeframe/ui/routers/schedule_v2.py +214 -0
- codeframe/ui/routers/session_chat_ws.py +354 -0
- codeframe/ui/routers/settings_v2.py +562 -0
- codeframe/ui/routers/streaming_v2.py +155 -0
- codeframe/ui/routers/tasks_v2.py +1098 -0
- codeframe/ui/routers/templates_v2.py +232 -0
- codeframe/ui/routers/terminal_ws.py +267 -0
- codeframe/ui/routers/workspace_v2.py +527 -0
- codeframe/ui/server.py +568 -0
- codeframe/ui/shared.py +241 -0
- codeframe/workspace/__init__.py +5 -0
- codeframe/workspace/manager.py +249 -0
- codeframe_ai-0.9.0.dist-info/METADATA +517 -0
- codeframe_ai-0.9.0.dist-info/RECORD +197 -0
- codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
- codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
- codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
- 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
|
+
}
|