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
codeframe/core/tasks.py
ADDED
|
@@ -0,0 +1,1022 @@
|
|
|
1
|
+
"""Task management for CodeFRAME v2.
|
|
2
|
+
|
|
3
|
+
Handles task CRUD operations, status transitions, and task generation from PRD.
|
|
4
|
+
|
|
5
|
+
This module is headless - no FastAPI or HTTP dependencies.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import json
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
import threading
|
|
13
|
+
import uuid
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from typing import Optional
|
|
17
|
+
from urllib.parse import urlparse
|
|
18
|
+
|
|
19
|
+
from codeframe.core.state_machine import TaskStatus, validate_transition
|
|
20
|
+
from codeframe.core.workspace import Workspace, get_db_connection
|
|
21
|
+
from codeframe.core.prd import PrdRecord
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _utc_now() -> datetime:
|
|
27
|
+
"""Get current UTC time as timezone-aware datetime."""
|
|
28
|
+
return datetime.now(timezone.utc)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class Task:
|
|
33
|
+
"""Represents a task in the workspace.
|
|
34
|
+
|
|
35
|
+
Attributes:
|
|
36
|
+
id: Unique task identifier (UUID)
|
|
37
|
+
workspace_id: Workspace this task belongs to
|
|
38
|
+
prd_id: Optional PRD this task was generated from
|
|
39
|
+
title: Task title/summary
|
|
40
|
+
description: Detailed task description
|
|
41
|
+
status: Current task status (from state machine)
|
|
42
|
+
priority: Task priority (0 = highest)
|
|
43
|
+
created_at: When the task was created
|
|
44
|
+
updated_at: When the task was last modified
|
|
45
|
+
depends_on: List of task IDs this task depends on (default: empty)
|
|
46
|
+
estimated_hours: Estimated hours to complete the task (optional)
|
|
47
|
+
complexity_score: Complexity rating 1-5 (optional)
|
|
48
|
+
uncertainty_level: Uncertainty level: 'low', 'medium', 'high' (optional)
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
id: str
|
|
52
|
+
workspace_id: str
|
|
53
|
+
prd_id: Optional[str]
|
|
54
|
+
title: str
|
|
55
|
+
description: str
|
|
56
|
+
status: TaskStatus
|
|
57
|
+
priority: int
|
|
58
|
+
created_at: datetime
|
|
59
|
+
updated_at: datetime
|
|
60
|
+
depends_on: list[str] = field(default_factory=list)
|
|
61
|
+
estimated_hours: Optional[float] = None
|
|
62
|
+
complexity_score: Optional[int] = None
|
|
63
|
+
uncertainty_level: Optional[str] = None
|
|
64
|
+
github_issue_number: Optional[int] = None
|
|
65
|
+
parent_id: Optional[str] = None
|
|
66
|
+
lineage: list[str] = field(default_factory=list)
|
|
67
|
+
is_leaf: bool = True
|
|
68
|
+
hierarchical_id: Optional[str] = None
|
|
69
|
+
requirement_ids: list[str] = field(default_factory=list)
|
|
70
|
+
external_url: Optional[str] = None
|
|
71
|
+
auto_close_github_issue: bool = False
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def create(
|
|
75
|
+
workspace: Workspace,
|
|
76
|
+
title: str,
|
|
77
|
+
description: str = "",
|
|
78
|
+
status: TaskStatus = TaskStatus.BACKLOG,
|
|
79
|
+
priority: int = 0,
|
|
80
|
+
prd_id: Optional[str] = None,
|
|
81
|
+
depends_on: Optional[list[str]] = None,
|
|
82
|
+
estimated_hours: Optional[float] = None,
|
|
83
|
+
complexity_score: Optional[int] = None,
|
|
84
|
+
uncertainty_level: Optional[str] = None,
|
|
85
|
+
parent_id: Optional[str] = None,
|
|
86
|
+
lineage: Optional[list[str]] = None,
|
|
87
|
+
is_leaf: bool = True,
|
|
88
|
+
hierarchical_id: Optional[str] = None,
|
|
89
|
+
requirement_ids: Optional[list[str]] = None,
|
|
90
|
+
github_issue_number: Optional[int] = None,
|
|
91
|
+
external_url: Optional[str] = None,
|
|
92
|
+
auto_close_github_issue: bool = False,
|
|
93
|
+
) -> Task:
|
|
94
|
+
"""Create a new task.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
workspace: Target workspace
|
|
98
|
+
title: Task title
|
|
99
|
+
description: Task description
|
|
100
|
+
status: Initial status (default BACKLOG)
|
|
101
|
+
priority: Task priority (default 0)
|
|
102
|
+
prd_id: Optional source PRD ID
|
|
103
|
+
depends_on: Optional list of task IDs this task depends on
|
|
104
|
+
estimated_hours: Optional time estimate in hours
|
|
105
|
+
complexity_score: Optional complexity rating 1-5
|
|
106
|
+
uncertainty_level: Optional uncertainty level ('low', 'medium', 'high')
|
|
107
|
+
parent_id: Optional parent task ID for tree structure
|
|
108
|
+
lineage: Optional list of ancestor descriptions
|
|
109
|
+
is_leaf: Whether this is a leaf/executable task (default True)
|
|
110
|
+
hierarchical_id: Optional display ID like "1.2.3"
|
|
111
|
+
requirement_ids: Optional list of PROOF9 requirement IDs this task implements
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Created Task
|
|
115
|
+
"""
|
|
116
|
+
task_id = str(uuid.uuid4())
|
|
117
|
+
now = _utc_now().isoformat()
|
|
118
|
+
depends_on_list = depends_on or []
|
|
119
|
+
lineage_list = lineage or []
|
|
120
|
+
requirement_ids_list = requirement_ids or []
|
|
121
|
+
|
|
122
|
+
conn = get_db_connection(workspace)
|
|
123
|
+
try:
|
|
124
|
+
cursor = conn.cursor()
|
|
125
|
+
cursor.execute(
|
|
126
|
+
"""
|
|
127
|
+
INSERT INTO tasks (id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, parent_id, lineage, is_leaf, hierarchical_id, created_at, updated_at, requirement_ids, github_issue_number, external_url, auto_close_github_issue)
|
|
128
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
129
|
+
""",
|
|
130
|
+
(task_id, workspace.id, prd_id, title, description, status.value, priority, json.dumps(depends_on_list), estimated_hours, complexity_score, uncertainty_level, parent_id, json.dumps(lineage_list), 1 if is_leaf else 0, hierarchical_id, now, now, json.dumps(requirement_ids_list), github_issue_number, external_url, 1 if auto_close_github_issue else 0),
|
|
131
|
+
)
|
|
132
|
+
conn.commit()
|
|
133
|
+
finally:
|
|
134
|
+
conn.close()
|
|
135
|
+
|
|
136
|
+
return Task(
|
|
137
|
+
id=task_id,
|
|
138
|
+
workspace_id=workspace.id,
|
|
139
|
+
prd_id=prd_id,
|
|
140
|
+
title=title,
|
|
141
|
+
description=description,
|
|
142
|
+
status=status,
|
|
143
|
+
priority=priority,
|
|
144
|
+
depends_on=depends_on_list,
|
|
145
|
+
estimated_hours=estimated_hours,
|
|
146
|
+
complexity_score=complexity_score,
|
|
147
|
+
uncertainty_level=uncertainty_level,
|
|
148
|
+
parent_id=parent_id,
|
|
149
|
+
lineage=lineage_list,
|
|
150
|
+
is_leaf=is_leaf,
|
|
151
|
+
hierarchical_id=hierarchical_id,
|
|
152
|
+
requirement_ids=requirement_ids_list,
|
|
153
|
+
github_issue_number=github_issue_number,
|
|
154
|
+
external_url=external_url,
|
|
155
|
+
auto_close_github_issue=auto_close_github_issue,
|
|
156
|
+
created_at=datetime.fromisoformat(now),
|
|
157
|
+
updated_at=datetime.fromisoformat(now),
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def get(workspace: Workspace, task_id: str) -> Optional[Task]:
|
|
162
|
+
"""Get a task by ID.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
workspace: Workspace to query
|
|
166
|
+
task_id: Task identifier
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
Task if found, None otherwise
|
|
170
|
+
"""
|
|
171
|
+
conn = get_db_connection(workspace)
|
|
172
|
+
cursor = conn.cursor()
|
|
173
|
+
|
|
174
|
+
cursor.execute(
|
|
175
|
+
"""
|
|
176
|
+
SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number, parent_id, lineage, is_leaf, hierarchical_id, requirement_ids, external_url, auto_close_github_issue
|
|
177
|
+
FROM tasks
|
|
178
|
+
WHERE workspace_id = ? AND id = ?
|
|
179
|
+
""",
|
|
180
|
+
(workspace.id, task_id),
|
|
181
|
+
)
|
|
182
|
+
row = cursor.fetchone()
|
|
183
|
+
conn.close()
|
|
184
|
+
|
|
185
|
+
if not row:
|
|
186
|
+
return None
|
|
187
|
+
|
|
188
|
+
return _row_to_task(row)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def get_by_external_url(
|
|
192
|
+
workspace: Workspace, external_url: str
|
|
193
|
+
) -> Optional[Task]:
|
|
194
|
+
"""Get a task previously imported from a given external (issue) URL.
|
|
195
|
+
|
|
196
|
+
Used for duplicate-import protection (issue #565). Keying on the full issue
|
|
197
|
+
URL — not just the issue number — keeps de-duplication correct when a
|
|
198
|
+
workspace is reconnected to a different repository (where the same issue
|
|
199
|
+
number refers to a different issue).
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
workspace: Workspace to query
|
|
203
|
+
external_url: The issue's ``html_url`` to look up
|
|
204
|
+
|
|
205
|
+
Returns:
|
|
206
|
+
The matching Task if one exists, otherwise None
|
|
207
|
+
"""
|
|
208
|
+
conn = get_db_connection(workspace)
|
|
209
|
+
try:
|
|
210
|
+
cursor = conn.cursor()
|
|
211
|
+
cursor.execute(
|
|
212
|
+
"""
|
|
213
|
+
SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number, parent_id, lineage, is_leaf, hierarchical_id, requirement_ids, external_url, auto_close_github_issue
|
|
214
|
+
FROM tasks
|
|
215
|
+
WHERE workspace_id = ? AND external_url = ?
|
|
216
|
+
LIMIT 1
|
|
217
|
+
""",
|
|
218
|
+
(workspace.id, external_url),
|
|
219
|
+
)
|
|
220
|
+
row = cursor.fetchone()
|
|
221
|
+
finally:
|
|
222
|
+
conn.close()
|
|
223
|
+
|
|
224
|
+
if not row:
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
return _row_to_task(row)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def update_auto_close(
|
|
231
|
+
workspace: Workspace,
|
|
232
|
+
task_id: str,
|
|
233
|
+
auto_close: bool,
|
|
234
|
+
) -> Task:
|
|
235
|
+
"""Update whether the linked GitHub issue should close when the task is DONE.
|
|
236
|
+
|
|
237
|
+
Args:
|
|
238
|
+
workspace: Target workspace
|
|
239
|
+
task_id: Task to update
|
|
240
|
+
auto_close: New auto-close setting
|
|
241
|
+
|
|
242
|
+
Returns:
|
|
243
|
+
Updated Task
|
|
244
|
+
|
|
245
|
+
Raises:
|
|
246
|
+
ValueError: If task not found
|
|
247
|
+
"""
|
|
248
|
+
task = get(workspace, task_id)
|
|
249
|
+
if not task:
|
|
250
|
+
raise ValueError(f"Task not found: {task_id}")
|
|
251
|
+
|
|
252
|
+
now = _utc_now().isoformat()
|
|
253
|
+
|
|
254
|
+
conn = get_db_connection(workspace)
|
|
255
|
+
try:
|
|
256
|
+
cursor = conn.cursor()
|
|
257
|
+
cursor.execute(
|
|
258
|
+
"""
|
|
259
|
+
UPDATE tasks
|
|
260
|
+
SET auto_close_github_issue = ?, updated_at = ?
|
|
261
|
+
WHERE workspace_id = ? AND id = ?
|
|
262
|
+
""",
|
|
263
|
+
(1 if auto_close else 0, now, workspace.id, task_id),
|
|
264
|
+
)
|
|
265
|
+
conn.commit()
|
|
266
|
+
finally:
|
|
267
|
+
conn.close()
|
|
268
|
+
|
|
269
|
+
task.auto_close_github_issue = auto_close
|
|
270
|
+
task.updated_at = datetime.fromisoformat(now)
|
|
271
|
+
|
|
272
|
+
return task
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def list_tasks(
|
|
276
|
+
workspace: Workspace,
|
|
277
|
+
status: Optional[TaskStatus] = None,
|
|
278
|
+
limit: int = 100,
|
|
279
|
+
) -> list[Task]:
|
|
280
|
+
"""List tasks in a workspace.
|
|
281
|
+
|
|
282
|
+
Args:
|
|
283
|
+
workspace: Workspace to query
|
|
284
|
+
status: Optional status filter
|
|
285
|
+
limit: Maximum tasks to return
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
List of Tasks
|
|
289
|
+
"""
|
|
290
|
+
conn = get_db_connection(workspace)
|
|
291
|
+
cursor = conn.cursor()
|
|
292
|
+
|
|
293
|
+
if status:
|
|
294
|
+
cursor.execute(
|
|
295
|
+
"""
|
|
296
|
+
SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number, parent_id, lineage, is_leaf, hierarchical_id, requirement_ids, external_url, auto_close_github_issue
|
|
297
|
+
FROM tasks
|
|
298
|
+
WHERE workspace_id = ? AND status = ?
|
|
299
|
+
ORDER BY priority ASC, created_at ASC
|
|
300
|
+
LIMIT ?
|
|
301
|
+
""",
|
|
302
|
+
(workspace.id, status.value, limit),
|
|
303
|
+
)
|
|
304
|
+
else:
|
|
305
|
+
cursor.execute(
|
|
306
|
+
"""
|
|
307
|
+
SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number, parent_id, lineage, is_leaf, hierarchical_id, requirement_ids, external_url, auto_close_github_issue
|
|
308
|
+
FROM tasks
|
|
309
|
+
WHERE workspace_id = ?
|
|
310
|
+
ORDER BY priority ASC, created_at ASC
|
|
311
|
+
LIMIT ?
|
|
312
|
+
""",
|
|
313
|
+
(workspace.id, limit),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
rows = cursor.fetchall()
|
|
317
|
+
conn.close()
|
|
318
|
+
|
|
319
|
+
return [_row_to_task(row) for row in rows]
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def list_by_status(workspace: Workspace) -> dict[TaskStatus, list[Task]]:
|
|
323
|
+
"""List tasks grouped by status.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
workspace: Workspace to query
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
Dict mapping status to list of tasks
|
|
330
|
+
"""
|
|
331
|
+
all_tasks = list_tasks(workspace)
|
|
332
|
+
result: dict[TaskStatus, list[Task]] = {status: [] for status in TaskStatus}
|
|
333
|
+
|
|
334
|
+
for task in all_tasks:
|
|
335
|
+
result[task.status].append(task)
|
|
336
|
+
|
|
337
|
+
return result
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def update_status(
|
|
341
|
+
workspace: Workspace,
|
|
342
|
+
task_id: str,
|
|
343
|
+
new_status: TaskStatus,
|
|
344
|
+
) -> Task:
|
|
345
|
+
"""Update a task's status.
|
|
346
|
+
|
|
347
|
+
Validates the transition against the state machine.
|
|
348
|
+
|
|
349
|
+
Args:
|
|
350
|
+
workspace: Target workspace
|
|
351
|
+
task_id: Task to update
|
|
352
|
+
new_status: New status
|
|
353
|
+
|
|
354
|
+
Returns:
|
|
355
|
+
Updated Task
|
|
356
|
+
|
|
357
|
+
Raises:
|
|
358
|
+
ValueError: If task not found
|
|
359
|
+
InvalidTransitionError: If transition not allowed
|
|
360
|
+
"""
|
|
361
|
+
task = get(workspace, task_id)
|
|
362
|
+
if not task:
|
|
363
|
+
raise ValueError(f"Task not found: {task_id}")
|
|
364
|
+
|
|
365
|
+
# Validate transition
|
|
366
|
+
validate_transition(task.status, new_status)
|
|
367
|
+
|
|
368
|
+
now = _utc_now().isoformat()
|
|
369
|
+
|
|
370
|
+
conn = get_db_connection(workspace)
|
|
371
|
+
try:
|
|
372
|
+
cursor = conn.cursor()
|
|
373
|
+
cursor.execute(
|
|
374
|
+
"""
|
|
375
|
+
UPDATE tasks
|
|
376
|
+
SET status = ?, updated_at = ?
|
|
377
|
+
WHERE workspace_id = ? AND id = ?
|
|
378
|
+
""",
|
|
379
|
+
(new_status.value, now, workspace.id, task_id),
|
|
380
|
+
)
|
|
381
|
+
conn.commit()
|
|
382
|
+
finally:
|
|
383
|
+
conn.close()
|
|
384
|
+
|
|
385
|
+
task.status = new_status
|
|
386
|
+
task.updated_at = datetime.fromisoformat(now)
|
|
387
|
+
|
|
388
|
+
# On completion, best-effort close the linked GitHub issue when opted in
|
|
389
|
+
# (issue #565). Placed here — the single chokepoint every DONE transition
|
|
390
|
+
# flows through (HTTP, CLI, agent/batch via runtime.complete_run) — so the
|
|
391
|
+
# behavior is consistent regardless of how the task was completed.
|
|
392
|
+
if new_status == TaskStatus.DONE:
|
|
393
|
+
_dispatch_github_autoclose(workspace, task)
|
|
394
|
+
|
|
395
|
+
return task
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
def _repo_from_issue_url(url: Optional[str]) -> Optional[str]:
|
|
399
|
+
"""Extract ``owner/repo`` from a GitHub issue ``html_url``.
|
|
400
|
+
|
|
401
|
+
e.g. ``https://github.com/acme/app/issues/12`` -> ``"acme/app"``. Returns
|
|
402
|
+
``None`` when the URL is missing or not in the expected issue-URL shape.
|
|
403
|
+
"""
|
|
404
|
+
if not url:
|
|
405
|
+
return None
|
|
406
|
+
try:
|
|
407
|
+
parts = urlparse(url).path.strip("/").split("/")
|
|
408
|
+
except (ValueError, AttributeError):
|
|
409
|
+
return None
|
|
410
|
+
# .../{owner}/{repo}/issues/{number}
|
|
411
|
+
if len(parts) >= 4 and parts[-2] == "issues":
|
|
412
|
+
return f"{parts[-4]}/{parts[-3]}"
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def autoclose_if_done(workspace: Workspace, task: Task) -> None:
|
|
417
|
+
"""Fire the GitHub auto-close when opting in on an already-DONE task (#565).
|
|
418
|
+
|
|
419
|
+
The normal path closes the issue on the DONE *transition*. But a user can
|
|
420
|
+
enable the auto-close toggle on a task that is already DONE — in which case
|
|
421
|
+
no transition occurs, so this is called explicitly to honor the intent.
|
|
422
|
+
Best-effort and fully guarded (no-op unless DONE + opted in + linked).
|
|
423
|
+
"""
|
|
424
|
+
if task.status == TaskStatus.DONE:
|
|
425
|
+
_dispatch_github_autoclose(workspace, task)
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def _dispatch_github_autoclose(workspace: Workspace, task: Task) -> None:
|
|
429
|
+
"""Best-effort close of the linked GitHub issue when a task is DONE (#565).
|
|
430
|
+
|
|
431
|
+
Mirrors the outbound-webhook dispatch pattern (``blockers._dispatch_*``):
|
|
432
|
+
fully guarded so a missing connection or any GitHub error never affects the
|
|
433
|
+
task transition. The repo is taken from the task's own ``external_url`` (its
|
|
434
|
+
source repo) — NOT the workspace's current connection — so completing an
|
|
435
|
+
older imported task always closes the right issue even after the workspace
|
|
436
|
+
is reconnected to a different repository. The PAT comes from the machine-wide
|
|
437
|
+
credential store.
|
|
438
|
+
"""
|
|
439
|
+
if not task.auto_close_github_issue or task.github_issue_number is None:
|
|
440
|
+
return
|
|
441
|
+
repo = _repo_from_issue_url(task.external_url)
|
|
442
|
+
if repo is None:
|
|
443
|
+
logger.info(
|
|
444
|
+
"Skipping GitHub auto-close for issue #%s: no source repo on task.",
|
|
445
|
+
task.github_issue_number,
|
|
446
|
+
)
|
|
447
|
+
return
|
|
448
|
+
try:
|
|
449
|
+
from codeframe.core.credentials import CredentialManager, CredentialProvider
|
|
450
|
+
|
|
451
|
+
pat = CredentialManager().get_credential(CredentialProvider.GIT_GITHUB)
|
|
452
|
+
if not pat:
|
|
453
|
+
logger.info(
|
|
454
|
+
"Skipping GitHub auto-close for issue #%s: no stored PAT.",
|
|
455
|
+
task.github_issue_number,
|
|
456
|
+
)
|
|
457
|
+
return
|
|
458
|
+
_close_issue_background(pat, repo, task.github_issue_number)
|
|
459
|
+
except Exception: # noqa: BLE001 - must never break the task transition
|
|
460
|
+
logger.warning(
|
|
461
|
+
"Failed to dispatch GitHub auto-close for issue #%s",
|
|
462
|
+
task.github_issue_number,
|
|
463
|
+
exc_info=True,
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
# Bounded timeout for the auto-close call so a hung GitHub request never stalls
|
|
468
|
+
# a short-lived CLI process for long at exit.
|
|
469
|
+
_AUTOCLOSE_TIMEOUT = 10.0
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
async def _safe_close_issue(pat: str, repo: str, issue_number: int) -> None:
|
|
473
|
+
"""Close the issue, swallowing every error (best-effort, off the hot path)."""
|
|
474
|
+
try:
|
|
475
|
+
from codeframe.core.github_issues_service import close_issue
|
|
476
|
+
|
|
477
|
+
await close_issue(
|
|
478
|
+
pat,
|
|
479
|
+
repo,
|
|
480
|
+
issue_number,
|
|
481
|
+
comment="Completed via CodeFRAME",
|
|
482
|
+
timeout=_AUTOCLOSE_TIMEOUT,
|
|
483
|
+
)
|
|
484
|
+
except Exception: # noqa: BLE001 - background best-effort
|
|
485
|
+
logger.warning(
|
|
486
|
+
"GitHub auto-close of issue #%s failed", issue_number, exc_info=True
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _close_issue_background(pat: str, repo: str, issue_number: int) -> None:
|
|
491
|
+
"""Run the auto-close off the caller's path (#565).
|
|
492
|
+
|
|
493
|
+
Context-aware, mirroring ``WebhookNotificationService.send_event_background``:
|
|
494
|
+
|
|
495
|
+
* **Async (server)**: there is a running event loop (the FastAPI handler),
|
|
496
|
+
so schedule the close on it and return immediately. No thread is created,
|
|
497
|
+
so nothing is left to join at process shutdown.
|
|
498
|
+
* **Sync (CLI / agent worker thread)**: no running loop, so run the close to
|
|
499
|
+
completion on a **non-daemon** thread. Unlike a fire-and-forget
|
|
500
|
+
notification, leaving the issue open is a real failure, so a short-lived
|
|
501
|
+
CLI process waits for the close at interpreter exit (bounded by
|
|
502
|
+
``_AUTOCLOSE_TIMEOUT``) instead of abandoning it.
|
|
503
|
+
"""
|
|
504
|
+
try:
|
|
505
|
+
loop = asyncio.get_running_loop()
|
|
506
|
+
except RuntimeError:
|
|
507
|
+
threading.Thread(
|
|
508
|
+
target=lambda: asyncio.run(
|
|
509
|
+
_safe_close_issue(pat, repo, issue_number)
|
|
510
|
+
),
|
|
511
|
+
daemon=False,
|
|
512
|
+
name=f"gh-autoclose-{issue_number}",
|
|
513
|
+
).start()
|
|
514
|
+
else:
|
|
515
|
+
loop.create_task(_safe_close_issue(pat, repo, issue_number))
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def update(
|
|
519
|
+
workspace: Workspace,
|
|
520
|
+
task_id: str,
|
|
521
|
+
title: Optional[str] = None,
|
|
522
|
+
description: Optional[str] = None,
|
|
523
|
+
priority: Optional[int] = None,
|
|
524
|
+
) -> Task:
|
|
525
|
+
"""Update a task's title, description, or priority.
|
|
526
|
+
|
|
527
|
+
Only provided fields are updated; others are left unchanged.
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
workspace: Target workspace
|
|
531
|
+
task_id: Task to update
|
|
532
|
+
title: New title (optional)
|
|
533
|
+
description: New description (optional)
|
|
534
|
+
priority: New priority (optional)
|
|
535
|
+
|
|
536
|
+
Returns:
|
|
537
|
+
Updated Task
|
|
538
|
+
|
|
539
|
+
Raises:
|
|
540
|
+
ValueError: If task not found
|
|
541
|
+
"""
|
|
542
|
+
task = get(workspace, task_id)
|
|
543
|
+
if not task:
|
|
544
|
+
raise ValueError(f"Task not found: {task_id}")
|
|
545
|
+
|
|
546
|
+
# Build update query dynamically
|
|
547
|
+
updates = []
|
|
548
|
+
params = []
|
|
549
|
+
|
|
550
|
+
if title is not None:
|
|
551
|
+
updates.append("title = ?")
|
|
552
|
+
params.append(title)
|
|
553
|
+
task.title = title
|
|
554
|
+
|
|
555
|
+
if description is not None:
|
|
556
|
+
updates.append("description = ?")
|
|
557
|
+
params.append(description)
|
|
558
|
+
task.description = description
|
|
559
|
+
|
|
560
|
+
if priority is not None:
|
|
561
|
+
updates.append("priority = ?")
|
|
562
|
+
params.append(priority)
|
|
563
|
+
task.priority = priority
|
|
564
|
+
|
|
565
|
+
if not updates:
|
|
566
|
+
# Nothing to update
|
|
567
|
+
return task
|
|
568
|
+
|
|
569
|
+
now = _utc_now().isoformat()
|
|
570
|
+
updates.append("updated_at = ?")
|
|
571
|
+
params.append(now)
|
|
572
|
+
|
|
573
|
+
# Add WHERE clause params
|
|
574
|
+
params.extend([workspace.id, task_id])
|
|
575
|
+
|
|
576
|
+
conn = get_db_connection(workspace)
|
|
577
|
+
try:
|
|
578
|
+
cursor = conn.cursor()
|
|
579
|
+
cursor.execute(
|
|
580
|
+
f"""
|
|
581
|
+
UPDATE tasks
|
|
582
|
+
SET {', '.join(updates)}
|
|
583
|
+
WHERE workspace_id = ? AND id = ?
|
|
584
|
+
""",
|
|
585
|
+
params,
|
|
586
|
+
)
|
|
587
|
+
conn.commit()
|
|
588
|
+
finally:
|
|
589
|
+
conn.close()
|
|
590
|
+
|
|
591
|
+
task.updated_at = datetime.fromisoformat(now)
|
|
592
|
+
|
|
593
|
+
return task
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def update_depends_on(
|
|
597
|
+
workspace: Workspace,
|
|
598
|
+
task_id: str,
|
|
599
|
+
depends_on: list[str],
|
|
600
|
+
) -> Task:
|
|
601
|
+
"""Update a task's dependencies.
|
|
602
|
+
|
|
603
|
+
Args:
|
|
604
|
+
workspace: Target workspace
|
|
605
|
+
task_id: Task to update
|
|
606
|
+
depends_on: List of task IDs this task depends on
|
|
607
|
+
|
|
608
|
+
Returns:
|
|
609
|
+
Updated Task
|
|
610
|
+
|
|
611
|
+
Raises:
|
|
612
|
+
ValueError: If task not found or circular dependency detected
|
|
613
|
+
"""
|
|
614
|
+
task = get(workspace, task_id)
|
|
615
|
+
if not task:
|
|
616
|
+
raise ValueError(f"Task not found: {task_id}")
|
|
617
|
+
|
|
618
|
+
# Validate dependencies exist and check for self-reference
|
|
619
|
+
for dep_id in depends_on:
|
|
620
|
+
if dep_id == task_id:
|
|
621
|
+
raise ValueError(f"Task cannot depend on itself: {task_id}")
|
|
622
|
+
dep_task = get(workspace, dep_id)
|
|
623
|
+
if not dep_task:
|
|
624
|
+
raise ValueError(f"Dependency task not found: {dep_id}")
|
|
625
|
+
|
|
626
|
+
now = _utc_now().isoformat()
|
|
627
|
+
|
|
628
|
+
conn = get_db_connection(workspace)
|
|
629
|
+
cursor = conn.cursor()
|
|
630
|
+
|
|
631
|
+
cursor.execute(
|
|
632
|
+
"""
|
|
633
|
+
UPDATE tasks
|
|
634
|
+
SET depends_on = ?, updated_at = ?
|
|
635
|
+
WHERE workspace_id = ? AND id = ?
|
|
636
|
+
""",
|
|
637
|
+
(json.dumps(depends_on), now, workspace.id, task_id),
|
|
638
|
+
)
|
|
639
|
+
conn.commit()
|
|
640
|
+
conn.close()
|
|
641
|
+
|
|
642
|
+
task.depends_on = depends_on
|
|
643
|
+
task.updated_at = datetime.fromisoformat(now)
|
|
644
|
+
|
|
645
|
+
return task
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def update_requirement_ids(
|
|
649
|
+
workspace: Workspace,
|
|
650
|
+
task_id: str,
|
|
651
|
+
requirement_ids: list[str],
|
|
652
|
+
) -> Task:
|
|
653
|
+
"""Update a task's linked PROOF9 requirement IDs.
|
|
654
|
+
|
|
655
|
+
Args:
|
|
656
|
+
workspace: Target workspace
|
|
657
|
+
task_id: Task to update
|
|
658
|
+
requirement_ids: List of PROOF9 requirement IDs this task implements
|
|
659
|
+
|
|
660
|
+
Returns:
|
|
661
|
+
Updated Task
|
|
662
|
+
|
|
663
|
+
Raises:
|
|
664
|
+
ValueError: If task not found
|
|
665
|
+
"""
|
|
666
|
+
task = get(workspace, task_id)
|
|
667
|
+
if not task:
|
|
668
|
+
raise ValueError(f"Task not found: {task_id}")
|
|
669
|
+
|
|
670
|
+
now = _utc_now().isoformat()
|
|
671
|
+
|
|
672
|
+
conn = get_db_connection(workspace)
|
|
673
|
+
cursor = conn.cursor()
|
|
674
|
+
cursor.execute(
|
|
675
|
+
"""
|
|
676
|
+
UPDATE tasks
|
|
677
|
+
SET requirement_ids = ?, updated_at = ?
|
|
678
|
+
WHERE workspace_id = ? AND id = ?
|
|
679
|
+
""",
|
|
680
|
+
(json.dumps(requirement_ids), now, workspace.id, task_id),
|
|
681
|
+
)
|
|
682
|
+
conn.commit()
|
|
683
|
+
conn.close()
|
|
684
|
+
|
|
685
|
+
task.requirement_ids = requirement_ids
|
|
686
|
+
task.updated_at = datetime.fromisoformat(now)
|
|
687
|
+
|
|
688
|
+
return task
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def get_dependents(workspace: Workspace, task_id: str) -> list[Task]:
|
|
692
|
+
"""Get all tasks that depend on the given task.
|
|
693
|
+
|
|
694
|
+
Args:
|
|
695
|
+
workspace: Workspace to query
|
|
696
|
+
task_id: Task ID to find dependents for
|
|
697
|
+
|
|
698
|
+
Returns:
|
|
699
|
+
List of Tasks that have task_id in their depends_on list
|
|
700
|
+
"""
|
|
701
|
+
all_tasks = list_tasks(workspace)
|
|
702
|
+
return [t for t in all_tasks if task_id in t.depends_on]
|
|
703
|
+
|
|
704
|
+
|
|
705
|
+
def delete(workspace: Workspace, task_id: str) -> bool:
|
|
706
|
+
"""Delete a task by ID.
|
|
707
|
+
|
|
708
|
+
Args:
|
|
709
|
+
workspace: Target workspace
|
|
710
|
+
task_id: Task ID to delete
|
|
711
|
+
|
|
712
|
+
Returns:
|
|
713
|
+
True if task was deleted, False if not found
|
|
714
|
+
|
|
715
|
+
Note:
|
|
716
|
+
This does NOT remove the task from other tasks' depends_on lists.
|
|
717
|
+
Use delete_cascade() if you need to clean up dependencies.
|
|
718
|
+
"""
|
|
719
|
+
conn = get_db_connection(workspace)
|
|
720
|
+
cursor = conn.cursor()
|
|
721
|
+
|
|
722
|
+
cursor.execute(
|
|
723
|
+
"""
|
|
724
|
+
DELETE FROM tasks
|
|
725
|
+
WHERE workspace_id = ? AND id = ?
|
|
726
|
+
""",
|
|
727
|
+
(workspace.id, task_id),
|
|
728
|
+
)
|
|
729
|
+
deleted = cursor.rowcount > 0
|
|
730
|
+
conn.commit()
|
|
731
|
+
conn.close()
|
|
732
|
+
|
|
733
|
+
return deleted
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def delete_all(workspace: Workspace) -> int:
|
|
737
|
+
"""Delete all tasks in a workspace.
|
|
738
|
+
|
|
739
|
+
Args:
|
|
740
|
+
workspace: Target workspace
|
|
741
|
+
|
|
742
|
+
Returns:
|
|
743
|
+
Number of tasks deleted
|
|
744
|
+
"""
|
|
745
|
+
conn = get_db_connection(workspace)
|
|
746
|
+
cursor = conn.cursor()
|
|
747
|
+
|
|
748
|
+
cursor.execute(
|
|
749
|
+
"""
|
|
750
|
+
DELETE FROM tasks
|
|
751
|
+
WHERE workspace_id = ?
|
|
752
|
+
""",
|
|
753
|
+
(workspace.id,),
|
|
754
|
+
)
|
|
755
|
+
deleted_count = cursor.rowcount
|
|
756
|
+
conn.commit()
|
|
757
|
+
conn.close()
|
|
758
|
+
|
|
759
|
+
return deleted_count
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def count_by_status(workspace: Workspace) -> dict[str, int]:
|
|
763
|
+
"""Count tasks by status.
|
|
764
|
+
|
|
765
|
+
Args:
|
|
766
|
+
workspace: Workspace to query
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
Dict mapping status string to count
|
|
770
|
+
"""
|
|
771
|
+
conn = get_db_connection(workspace)
|
|
772
|
+
cursor = conn.cursor()
|
|
773
|
+
|
|
774
|
+
cursor.execute(
|
|
775
|
+
"""
|
|
776
|
+
SELECT status, COUNT(*) as count
|
|
777
|
+
FROM tasks
|
|
778
|
+
WHERE workspace_id = ?
|
|
779
|
+
GROUP BY status
|
|
780
|
+
""",
|
|
781
|
+
(workspace.id,),
|
|
782
|
+
)
|
|
783
|
+
rows = cursor.fetchall()
|
|
784
|
+
conn.close()
|
|
785
|
+
|
|
786
|
+
return {row[0]: row[1] for row in rows}
|
|
787
|
+
|
|
788
|
+
|
|
789
|
+
def generate_from_prd(
|
|
790
|
+
workspace: Workspace,
|
|
791
|
+
prd: PrdRecord,
|
|
792
|
+
use_llm: bool = True,
|
|
793
|
+
) -> list[Task]:
|
|
794
|
+
"""Generate tasks from a PRD.
|
|
795
|
+
|
|
796
|
+
Uses LLM to decompose the PRD into actionable tasks.
|
|
797
|
+
Falls back to simple extraction if LLM is unavailable.
|
|
798
|
+
|
|
799
|
+
Args:
|
|
800
|
+
workspace: Target workspace
|
|
801
|
+
prd: PRD to generate tasks from
|
|
802
|
+
use_llm: Whether to use LLM for generation (default True)
|
|
803
|
+
|
|
804
|
+
Returns:
|
|
805
|
+
List of created Tasks
|
|
806
|
+
"""
|
|
807
|
+
if use_llm:
|
|
808
|
+
try:
|
|
809
|
+
tasks_data = _generate_tasks_with_llm(prd.content)
|
|
810
|
+
except json.JSONDecodeError as e:
|
|
811
|
+
# Invalid JSON from LLM response — fall back to simple extraction
|
|
812
|
+
print(f"LLM generation failed ({e}), using simple extraction")
|
|
813
|
+
tasks_data = _extract_tasks_simple(prd.content)
|
|
814
|
+
except ValueError:
|
|
815
|
+
raise # Config errors (missing API key) should fail loudly
|
|
816
|
+
except Exception as e:
|
|
817
|
+
# Fall back to simple extraction
|
|
818
|
+
print(f"LLM generation failed ({e}), using simple extraction")
|
|
819
|
+
tasks_data = _extract_tasks_simple(prd.content)
|
|
820
|
+
else:
|
|
821
|
+
tasks_data = _extract_tasks_simple(prd.content)
|
|
822
|
+
|
|
823
|
+
created_tasks = []
|
|
824
|
+
for i, task_data in enumerate(tasks_data):
|
|
825
|
+
task = create(
|
|
826
|
+
workspace=workspace,
|
|
827
|
+
title=task_data["title"],
|
|
828
|
+
description=task_data.get("description", ""),
|
|
829
|
+
status=TaskStatus.BACKLOG,
|
|
830
|
+
priority=i, # Priority based on order
|
|
831
|
+
prd_id=prd.id,
|
|
832
|
+
complexity_score=task_data.get("complexity"),
|
|
833
|
+
estimated_hours=task_data.get("estimated_hours"),
|
|
834
|
+
uncertainty_level=task_data.get("uncertainty"),
|
|
835
|
+
)
|
|
836
|
+
created_tasks.append(task)
|
|
837
|
+
|
|
838
|
+
# Resolve title-based dependencies to task IDs
|
|
839
|
+
title_to_id = {t.title: t.id for t in created_tasks}
|
|
840
|
+
for task_data, task in zip(tasks_data, created_tasks):
|
|
841
|
+
dep_titles = task_data.get("depends_on_titles", [])
|
|
842
|
+
dep_ids = [title_to_id[t] for t in dep_titles if t in title_to_id]
|
|
843
|
+
if dep_ids:
|
|
844
|
+
update_depends_on(workspace, task.id, dep_ids)
|
|
845
|
+
|
|
846
|
+
return created_tasks
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
def _generate_tasks_with_llm(prd_content: str) -> list[dict]:
|
|
850
|
+
"""Use LLM to generate tasks from PRD content.
|
|
851
|
+
|
|
852
|
+
Args:
|
|
853
|
+
prd_content: PRD text
|
|
854
|
+
|
|
855
|
+
Returns:
|
|
856
|
+
List of task dicts with rich metadata fields
|
|
857
|
+
"""
|
|
858
|
+
# Use the LLM adapter for provider-agnostic access
|
|
859
|
+
from codeframe.adapters.llm import get_provider, Purpose
|
|
860
|
+
|
|
861
|
+
provider = get_provider()
|
|
862
|
+
|
|
863
|
+
prompt = f"""Analyze the following PRD and generate a list of actionable development tasks.
|
|
864
|
+
|
|
865
|
+
For each task, provide:
|
|
866
|
+
1. "title": Clear, specific title (under 80 characters)
|
|
867
|
+
2. "description": What needs to be done
|
|
868
|
+
3. "depends_on_titles": List of other task titles this depends on (empty list if none)
|
|
869
|
+
4. "complexity": Complexity score 1-5 (1=trivial, 5=very complex)
|
|
870
|
+
5. "estimated_hours": Estimated hours to complete (float)
|
|
871
|
+
6. "uncertainty": "low", "medium", or "high"
|
|
872
|
+
7. "files_to_modify": List of file paths likely to be modified (best guess)
|
|
873
|
+
|
|
874
|
+
Order tasks by logical dependency/priority.
|
|
875
|
+
|
|
876
|
+
Return ONLY a JSON array of objects with these fields.
|
|
877
|
+
|
|
878
|
+
PRD:
|
|
879
|
+
{prd_content}"""
|
|
880
|
+
|
|
881
|
+
response = provider.complete(
|
|
882
|
+
messages=[{"role": "user", "content": prompt}],
|
|
883
|
+
purpose=Purpose.GENERATION,
|
|
884
|
+
max_tokens=2000,
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
# Extract JSON from response
|
|
888
|
+
response_text = response.content.strip()
|
|
889
|
+
|
|
890
|
+
# Try to find JSON array in response
|
|
891
|
+
json_match = re.search(r"\[[\s\S]*\]", response_text)
|
|
892
|
+
if json_match:
|
|
893
|
+
tasks_raw = json.loads(json_match.group())
|
|
894
|
+
else:
|
|
895
|
+
tasks_raw = json.loads(response_text)
|
|
896
|
+
|
|
897
|
+
# Validate and extract rich fields
|
|
898
|
+
validated = []
|
|
899
|
+
for task in tasks_raw:
|
|
900
|
+
if not isinstance(task, dict) or "title" not in task:
|
|
901
|
+
continue
|
|
902
|
+
|
|
903
|
+
# Extract rich fields with defaults and validation
|
|
904
|
+
complexity = task.get("complexity")
|
|
905
|
+
if complexity is not None:
|
|
906
|
+
complexity = max(1, min(5, int(complexity)))
|
|
907
|
+
|
|
908
|
+
estimated_hours = task.get("estimated_hours")
|
|
909
|
+
if estimated_hours is not None:
|
|
910
|
+
estimated_hours = max(0.1, float(estimated_hours))
|
|
911
|
+
|
|
912
|
+
uncertainty = task.get("uncertainty")
|
|
913
|
+
if uncertainty not in ("low", "medium", "high"):
|
|
914
|
+
uncertainty = None
|
|
915
|
+
|
|
916
|
+
files = task.get("files_to_modify", [])
|
|
917
|
+
desc = str(task.get("description", ""))[:2000]
|
|
918
|
+
if files:
|
|
919
|
+
desc += "\n\nFiles to modify: " + ", ".join(str(f) for f in files)
|
|
920
|
+
|
|
921
|
+
validated.append({
|
|
922
|
+
"title": str(task["title"])[:200],
|
|
923
|
+
"description": desc,
|
|
924
|
+
"depends_on_titles": task.get("depends_on_titles", []),
|
|
925
|
+
"complexity": complexity,
|
|
926
|
+
"estimated_hours": estimated_hours,
|
|
927
|
+
"uncertainty": uncertainty,
|
|
928
|
+
})
|
|
929
|
+
|
|
930
|
+
return validated
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _extract_tasks_simple(prd_content: str) -> list[dict]:
|
|
934
|
+
"""Simple task extraction without LLM.
|
|
935
|
+
|
|
936
|
+
Extracts bullet points and numbered items as tasks.
|
|
937
|
+
|
|
938
|
+
Args:
|
|
939
|
+
prd_content: PRD text
|
|
940
|
+
|
|
941
|
+
Returns:
|
|
942
|
+
List of task dicts
|
|
943
|
+
"""
|
|
944
|
+
tasks = []
|
|
945
|
+
|
|
946
|
+
# Find bullet points and numbered items
|
|
947
|
+
patterns = [
|
|
948
|
+
r"^[-*]\s+(.+)$", # Bullet points
|
|
949
|
+
r"^\d+\.\s+(.+)$", # Numbered items
|
|
950
|
+
r"^#{2,3}\s+(.+)$", # H2/H3 headings as potential features
|
|
951
|
+
]
|
|
952
|
+
|
|
953
|
+
for pattern in patterns:
|
|
954
|
+
matches = re.findall(pattern, prd_content, re.MULTILINE)
|
|
955
|
+
for match in matches:
|
|
956
|
+
title = match.strip()
|
|
957
|
+
# Skip very short items or ones that look like headers
|
|
958
|
+
if len(title) > 10 and not title.endswith(":"):
|
|
959
|
+
tasks.append({
|
|
960
|
+
"title": title[:200],
|
|
961
|
+
"description": "",
|
|
962
|
+
})
|
|
963
|
+
|
|
964
|
+
# Deduplicate by title
|
|
965
|
+
seen = set()
|
|
966
|
+
unique_tasks = []
|
|
967
|
+
for task in tasks:
|
|
968
|
+
if task["title"] not in seen:
|
|
969
|
+
seen.add(task["title"])
|
|
970
|
+
unique_tasks.append(task)
|
|
971
|
+
|
|
972
|
+
return unique_tasks[:20] # Limit to 20 tasks
|
|
973
|
+
|
|
974
|
+
|
|
975
|
+
def _row_to_task(row: tuple) -> Task:
|
|
976
|
+
"""Convert a database row to a Task object.
|
|
977
|
+
|
|
978
|
+
Row columns: id, workspace_id, prd_id, title, description, status, priority,
|
|
979
|
+
depends_on, estimated_hours, complexity_score, uncertainty_level,
|
|
980
|
+
created_at, updated_at, github_issue_number, parent_id, lineage,
|
|
981
|
+
is_leaf, hierarchical_id, requirement_ids, external_url,
|
|
982
|
+
auto_close_github_issue
|
|
983
|
+
"""
|
|
984
|
+
# Parse depends_on from JSON string (default to empty list if null)
|
|
985
|
+
depends_on_raw = row[7]
|
|
986
|
+
depends_on = json.loads(depends_on_raw) if depends_on_raw else []
|
|
987
|
+
|
|
988
|
+
# Parse lineage from JSON string (default to empty list if null)
|
|
989
|
+
lineage_raw = row[15] if len(row) > 15 else None
|
|
990
|
+
lineage = json.loads(lineage_raw) if lineage_raw else []
|
|
991
|
+
|
|
992
|
+
# Parse is_leaf from integer (default to True if null)
|
|
993
|
+
is_leaf_raw = row[16] if len(row) > 16 else 1
|
|
994
|
+
is_leaf = bool(is_leaf_raw) if is_leaf_raw is not None else True
|
|
995
|
+
|
|
996
|
+
# Parse requirement_ids from JSON string (default to empty list if null)
|
|
997
|
+
requirement_ids_raw = row[18] if len(row) > 18 else None
|
|
998
|
+
requirement_ids = json.loads(requirement_ids_raw) if requirement_ids_raw else []
|
|
999
|
+
|
|
1000
|
+
return Task(
|
|
1001
|
+
id=row[0],
|
|
1002
|
+
workspace_id=row[1],
|
|
1003
|
+
prd_id=row[2],
|
|
1004
|
+
title=row[3],
|
|
1005
|
+
description=row[4],
|
|
1006
|
+
status=TaskStatus(row[5]),
|
|
1007
|
+
priority=row[6],
|
|
1008
|
+
depends_on=depends_on,
|
|
1009
|
+
estimated_hours=row[8],
|
|
1010
|
+
complexity_score=row[9],
|
|
1011
|
+
uncertainty_level=row[10],
|
|
1012
|
+
created_at=datetime.fromisoformat(row[11]),
|
|
1013
|
+
updated_at=datetime.fromisoformat(row[12]),
|
|
1014
|
+
github_issue_number=row[13] if len(row) > 13 else None,
|
|
1015
|
+
parent_id=row[14] if len(row) > 14 else None,
|
|
1016
|
+
lineage=lineage,
|
|
1017
|
+
is_leaf=is_leaf,
|
|
1018
|
+
hierarchical_id=row[17] if len(row) > 17 else None,
|
|
1019
|
+
requirement_ids=requirement_ids,
|
|
1020
|
+
external_url=row[19] if len(row) > 19 else None,
|
|
1021
|
+
auto_close_github_issue=bool(row[20]) if len(row) > 20 and row[20] is not None else False,
|
|
1022
|
+
)
|