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,229 @@
|
|
|
1
|
+
"""LLM-based dependency analyzer for batch execution.
|
|
2
|
+
|
|
3
|
+
Uses an LLM to analyze task descriptions and infer dependencies
|
|
4
|
+
between tasks based on:
|
|
5
|
+
- File paths mentioned in descriptions
|
|
6
|
+
- Sequential language ("After X", "Once Y is done")
|
|
7
|
+
- Logical dependencies implied by task content
|
|
8
|
+
- PRD structure (sections often imply order)
|
|
9
|
+
|
|
10
|
+
This module is headless - no FastAPI or HTTP dependencies.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
from typing import Optional
|
|
17
|
+
|
|
18
|
+
from codeframe.core import tasks as task_module
|
|
19
|
+
from codeframe.core.workspace import Workspace
|
|
20
|
+
from codeframe.core.tasks import Task
|
|
21
|
+
from codeframe.adapters.llm.base import Purpose
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
DEPENDENCY_ANALYSIS_SYSTEM_PROMPT = """You are a software project task analyzer. Your job is to analyze a list of development tasks and identify dependencies between them.
|
|
25
|
+
|
|
26
|
+
A task depends on another task when:
|
|
27
|
+
1. It modifies code that the other task creates
|
|
28
|
+
2. It builds upon functionality the other task implements
|
|
29
|
+
3. The task description explicitly mentions doing something "after" or "once" another task is done
|
|
30
|
+
4. It tests or validates code from another task
|
|
31
|
+
5. It uses data structures, APIs, or interfaces created by another task
|
|
32
|
+
|
|
33
|
+
When analyzing dependencies:
|
|
34
|
+
- Be conservative - only mark dependencies that are clearly implied
|
|
35
|
+
- A task should NOT depend on another just because they work on similar areas
|
|
36
|
+
- Order in the original list does NOT imply dependency
|
|
37
|
+
- Tasks that can be done independently should have no dependencies
|
|
38
|
+
|
|
39
|
+
Return your analysis as a JSON object mapping each task ID to an array of task IDs it depends on.
|
|
40
|
+
Tasks with no dependencies should have an empty array."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def analyze_dependencies(
|
|
44
|
+
workspace: Workspace,
|
|
45
|
+
task_ids: list[str],
|
|
46
|
+
provider: Optional[object] = None,
|
|
47
|
+
) -> dict[str, list[str]]:
|
|
48
|
+
"""Use LLM to infer task dependencies.
|
|
49
|
+
|
|
50
|
+
Analyzes task descriptions to identify which tasks depend on others.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
workspace: Workspace containing the tasks
|
|
54
|
+
task_ids: List of task IDs to analyze
|
|
55
|
+
provider: Optional LLM provider (creates default if not provided)
|
|
56
|
+
|
|
57
|
+
Returns:
|
|
58
|
+
Dict mapping task_id -> list of task_ids it depends on
|
|
59
|
+
|
|
60
|
+
Raises:
|
|
61
|
+
ValueError: If analysis fails or returns invalid data
|
|
62
|
+
"""
|
|
63
|
+
if not task_ids:
|
|
64
|
+
return {}
|
|
65
|
+
|
|
66
|
+
# Load tasks
|
|
67
|
+
task_list = []
|
|
68
|
+
for tid in task_ids:
|
|
69
|
+
task = task_module.get(workspace, tid)
|
|
70
|
+
if task:
|
|
71
|
+
task_list.append(task)
|
|
72
|
+
|
|
73
|
+
if not task_list:
|
|
74
|
+
return {}
|
|
75
|
+
|
|
76
|
+
# Use only IDs from successfully loaded tasks to prevent references to missing tasks
|
|
77
|
+
valid_ids = [t.id for t in task_list]
|
|
78
|
+
|
|
79
|
+
# Build prompt
|
|
80
|
+
prompt = _build_analysis_prompt(task_list)
|
|
81
|
+
|
|
82
|
+
# Get or create LLM provider
|
|
83
|
+
if provider is None:
|
|
84
|
+
provider = _get_default_provider()
|
|
85
|
+
|
|
86
|
+
# Call LLM
|
|
87
|
+
response = provider.complete(
|
|
88
|
+
messages=[{"role": "user", "content": prompt}],
|
|
89
|
+
purpose=Purpose.PLANNING,
|
|
90
|
+
system=DEPENDENCY_ANALYSIS_SYSTEM_PROMPT,
|
|
91
|
+
max_tokens=2048,
|
|
92
|
+
temperature=0.0,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Parse response - use valid_ids (loaded tasks) not original task_ids
|
|
96
|
+
dependencies = _parse_dependency_response(response.content, valid_ids)
|
|
97
|
+
|
|
98
|
+
return dependencies
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _build_analysis_prompt(tasks: list[Task]) -> str:
|
|
102
|
+
"""Build the dependency analysis prompt.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
tasks: List of tasks to analyze
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
Formatted prompt string
|
|
109
|
+
"""
|
|
110
|
+
lines = ["Analyze the following tasks and identify dependencies between them:", ""]
|
|
111
|
+
|
|
112
|
+
for i, task in enumerate(tasks):
|
|
113
|
+
lines.append(f"## Task {i + 1}")
|
|
114
|
+
lines.append(f"ID: {task.id}")
|
|
115
|
+
lines.append(f"Title: {task.title}")
|
|
116
|
+
if task.description:
|
|
117
|
+
# Limit description length
|
|
118
|
+
desc = task.description[:1000]
|
|
119
|
+
lines.append(f"Description: {desc}")
|
|
120
|
+
lines.append("")
|
|
121
|
+
|
|
122
|
+
lines.append("---")
|
|
123
|
+
lines.append("")
|
|
124
|
+
lines.append("For each task ID, list the IDs of tasks it depends on (must complete first).")
|
|
125
|
+
lines.append("Return as JSON: {\"task_id\": [\"dependency_id\", ...], ...}")
|
|
126
|
+
lines.append("Tasks with no dependencies should have an empty array [].")
|
|
127
|
+
|
|
128
|
+
return "\n".join(lines)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _parse_dependency_response(
|
|
132
|
+
content: str,
|
|
133
|
+
valid_task_ids: list[str],
|
|
134
|
+
) -> dict[str, list[str]]:
|
|
135
|
+
"""Parse LLM response into dependency mapping.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
content: Raw LLM response content
|
|
139
|
+
valid_task_ids: List of valid task IDs (for validation)
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Dict mapping task_id -> list of dependency task_ids
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ValueError: If response cannot be parsed
|
|
146
|
+
"""
|
|
147
|
+
# Try to extract JSON from response
|
|
148
|
+
# LLM might wrap it in markdown code blocks
|
|
149
|
+
json_match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", content)
|
|
150
|
+
if json_match:
|
|
151
|
+
json_str = json_match.group(1)
|
|
152
|
+
else:
|
|
153
|
+
# Try to find raw JSON object
|
|
154
|
+
json_match = re.search(r"\{[\s\S]*\}", content)
|
|
155
|
+
if json_match:
|
|
156
|
+
json_str = json_match.group(0)
|
|
157
|
+
else:
|
|
158
|
+
raise ValueError(f"Could not find JSON in response: {content[:200]}")
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
raw_deps = json.loads(json_str)
|
|
162
|
+
except json.JSONDecodeError as e:
|
|
163
|
+
raise ValueError(f"Invalid JSON in response: {e}")
|
|
164
|
+
|
|
165
|
+
if not isinstance(raw_deps, dict):
|
|
166
|
+
raise ValueError(f"Expected dict, got {type(raw_deps)}")
|
|
167
|
+
|
|
168
|
+
# Validate and filter
|
|
169
|
+
valid_ids_set = set(valid_task_ids)
|
|
170
|
+
result: dict[str, list[str]] = {}
|
|
171
|
+
|
|
172
|
+
for task_id, deps in raw_deps.items():
|
|
173
|
+
if task_id not in valid_ids_set:
|
|
174
|
+
continue # Skip unknown task IDs
|
|
175
|
+
|
|
176
|
+
if not isinstance(deps, list):
|
|
177
|
+
deps = []
|
|
178
|
+
|
|
179
|
+
# Filter to only valid dependency IDs, exclude self-reference
|
|
180
|
+
valid_deps = [
|
|
181
|
+
d for d in deps
|
|
182
|
+
if d in valid_ids_set and d != task_id
|
|
183
|
+
]
|
|
184
|
+
result[task_id] = valid_deps
|
|
185
|
+
|
|
186
|
+
# Ensure all task IDs have an entry (even if empty)
|
|
187
|
+
for tid in valid_task_ids:
|
|
188
|
+
if tid not in result:
|
|
189
|
+
result[tid] = []
|
|
190
|
+
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def apply_inferred_dependencies(
|
|
195
|
+
workspace: Workspace,
|
|
196
|
+
dependencies: dict[str, list[str]],
|
|
197
|
+
) -> None:
|
|
198
|
+
"""Apply inferred dependencies to tasks in the workspace.
|
|
199
|
+
|
|
200
|
+
Updates each task's depends_on field with the inferred dependencies.
|
|
201
|
+
Only updates tasks that have non-empty dependency lists to preserve
|
|
202
|
+
any existing manual/explicit dependencies.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
workspace: Workspace containing the tasks
|
|
206
|
+
dependencies: Dict mapping task_id -> list of dependency task_ids
|
|
207
|
+
"""
|
|
208
|
+
for task_id, deps in dependencies.items():
|
|
209
|
+
# Only update when there are inferred dependencies to apply
|
|
210
|
+
# Empty list means no dependencies found - don't clear existing ones
|
|
211
|
+
if deps:
|
|
212
|
+
task_module.update_depends_on(workspace, task_id, deps)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _get_default_provider():
|
|
216
|
+
"""Get the default Anthropic LLM provider.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
AnthropicProvider instance
|
|
220
|
+
|
|
221
|
+
Raises:
|
|
222
|
+
ValueError: If ANTHROPIC_API_KEY not set
|
|
223
|
+
"""
|
|
224
|
+
api_key = os.getenv("ANTHROPIC_API_KEY")
|
|
225
|
+
if not api_key:
|
|
226
|
+
raise ValueError("ANTHROPIC_API_KEY environment variable not set")
|
|
227
|
+
|
|
228
|
+
from codeframe.adapters.llm.anthropic import AnthropicProvider
|
|
229
|
+
return AnthropicProvider(api_key=api_key)
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
"""Task dependency graph analysis for batch execution.
|
|
2
|
+
|
|
3
|
+
Provides DAG construction, cycle detection, topological sorting, and
|
|
4
|
+
execution group generation for parallel task execution.
|
|
5
|
+
|
|
6
|
+
This module is headless - no FastAPI or HTTP dependencies.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Optional
|
|
11
|
+
|
|
12
|
+
from codeframe.core.workspace import Workspace
|
|
13
|
+
from codeframe.core import tasks as task_module
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class CycleDetectedError(Exception):
|
|
17
|
+
"""Raised when a circular dependency is detected in the task graph."""
|
|
18
|
+
|
|
19
|
+
def __init__(self, cycle: list[str]):
|
|
20
|
+
self.cycle = cycle
|
|
21
|
+
cycle_str = " -> ".join(cycle)
|
|
22
|
+
super().__init__(f"Circular dependency detected: {cycle_str}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ExecutionPlan:
|
|
27
|
+
"""Represents an execution plan for a set of tasks.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
groups: List of task ID groups. Tasks within the same group can
|
|
31
|
+
run in parallel. Groups must be executed sequentially.
|
|
32
|
+
task_order: Flat list of task IDs in topological order.
|
|
33
|
+
graph: Dict mapping task_id -> list of task_ids it depends on.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
groups: list[list[str]]
|
|
37
|
+
task_order: list[str]
|
|
38
|
+
graph: dict[str, list[str]]
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def total_tasks(self) -> int:
|
|
42
|
+
"""Total number of tasks in the plan."""
|
|
43
|
+
return len(self.task_order)
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def num_groups(self) -> int:
|
|
47
|
+
"""Number of execution groups."""
|
|
48
|
+
return len(self.groups)
|
|
49
|
+
|
|
50
|
+
def can_run_parallel(self) -> bool:
|
|
51
|
+
"""Check if any groups have more than one task (parallelizable)."""
|
|
52
|
+
return any(len(group) > 1 for group in self.groups)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_graph(
|
|
56
|
+
workspace: Workspace,
|
|
57
|
+
task_ids: list[str],
|
|
58
|
+
) -> dict[str, list[str]]:
|
|
59
|
+
"""Build a dependency graph for the given tasks.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
workspace: Workspace containing the tasks
|
|
63
|
+
task_ids: List of task IDs to include in the graph
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Dict mapping task_id -> list of dependency task_ids
|
|
67
|
+
|
|
68
|
+
Note:
|
|
69
|
+
Only includes dependencies that are within the provided task_ids.
|
|
70
|
+
External dependencies are filtered out.
|
|
71
|
+
"""
|
|
72
|
+
task_id_set = set(task_ids)
|
|
73
|
+
graph: dict[str, list[str]] = {}
|
|
74
|
+
|
|
75
|
+
for task_id in task_ids:
|
|
76
|
+
task = task_module.get(workspace, task_id)
|
|
77
|
+
if task:
|
|
78
|
+
# Only include dependencies that are in our task set
|
|
79
|
+
deps = [d for d in task.depends_on if d in task_id_set]
|
|
80
|
+
graph[task_id] = deps
|
|
81
|
+
else:
|
|
82
|
+
graph[task_id] = []
|
|
83
|
+
|
|
84
|
+
return graph
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def detect_cycle(graph: dict[str, list[str]]) -> Optional[list[str]]:
|
|
88
|
+
"""Detect if the graph contains a cycle.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
graph: Dependency graph (task_id -> list of dependencies)
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
List of task IDs forming a cycle, or None if no cycle exists.
|
|
95
|
+
The cycle list starts and ends with the same task ID.
|
|
96
|
+
"""
|
|
97
|
+
# States: 0 = unvisited, 1 = visiting, 2 = visited
|
|
98
|
+
state: dict[str, int] = {node: 0 for node in graph}
|
|
99
|
+
parent: dict[str, Optional[str]] = {node: None for node in graph}
|
|
100
|
+
|
|
101
|
+
def dfs(node: str, path: list[str]) -> Optional[list[str]]:
|
|
102
|
+
state[node] = 1 # visiting
|
|
103
|
+
path.append(node)
|
|
104
|
+
|
|
105
|
+
for dep in graph.get(node, []):
|
|
106
|
+
if dep not in state:
|
|
107
|
+
continue # dependency not in our graph
|
|
108
|
+
|
|
109
|
+
if state[dep] == 1: # back edge - cycle found
|
|
110
|
+
# Find where the cycle starts
|
|
111
|
+
cycle_start = path.index(dep)
|
|
112
|
+
cycle = path[cycle_start:] + [dep]
|
|
113
|
+
return cycle
|
|
114
|
+
|
|
115
|
+
if state[dep] == 0: # unvisited
|
|
116
|
+
parent[dep] = node
|
|
117
|
+
result = dfs(dep, path)
|
|
118
|
+
if result:
|
|
119
|
+
return result
|
|
120
|
+
|
|
121
|
+
state[node] = 2 # visited
|
|
122
|
+
path.pop()
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
for node in graph:
|
|
126
|
+
if state[node] == 0:
|
|
127
|
+
cycle = dfs(node, [])
|
|
128
|
+
if cycle:
|
|
129
|
+
return cycle
|
|
130
|
+
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def topological_sort(graph: dict[str, list[str]]) -> list[str]:
|
|
135
|
+
"""Perform topological sort on the dependency graph.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
graph: Dependency graph (task_id -> list of dependencies)
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
List of task IDs in topological order (dependencies first).
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
CycleDetectedError: If the graph contains a cycle.
|
|
145
|
+
"""
|
|
146
|
+
cycle = detect_cycle(graph)
|
|
147
|
+
if cycle:
|
|
148
|
+
raise CycleDetectedError(cycle)
|
|
149
|
+
|
|
150
|
+
# Kahn's algorithm for topological sort
|
|
151
|
+
# graph[node] = deps means node depends on deps
|
|
152
|
+
# So deps must come before node
|
|
153
|
+
# in_degree[node] = number of dependencies that node has
|
|
154
|
+
in_degree = {node: len(graph.get(node, [])) for node in graph}
|
|
155
|
+
|
|
156
|
+
# Start with nodes that have no dependencies
|
|
157
|
+
queue = [node for node in graph if in_degree[node] == 0]
|
|
158
|
+
result = []
|
|
159
|
+
|
|
160
|
+
while queue:
|
|
161
|
+
# Take a node with no remaining dependencies
|
|
162
|
+
node = queue.pop(0)
|
|
163
|
+
result.append(node)
|
|
164
|
+
|
|
165
|
+
# For each node that depends on this node, reduce its in_degree
|
|
166
|
+
for other_node in graph:
|
|
167
|
+
if node in graph.get(other_node, []):
|
|
168
|
+
in_degree[other_node] -= 1
|
|
169
|
+
if in_degree[other_node] == 0:
|
|
170
|
+
queue.append(other_node)
|
|
171
|
+
|
|
172
|
+
if len(result) != len(graph):
|
|
173
|
+
# This shouldn't happen if detect_cycle works correctly
|
|
174
|
+
raise CycleDetectedError(["unknown cycle"])
|
|
175
|
+
|
|
176
|
+
return result
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def group_by_level(graph: dict[str, list[str]]) -> list[list[str]]:
|
|
180
|
+
"""Group tasks by dependency level for parallel execution.
|
|
181
|
+
|
|
182
|
+
Tasks at the same level have no dependencies on each other and
|
|
183
|
+
can be executed in parallel. Levels must be executed sequentially.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
graph: Dependency graph (task_id -> list of dependencies)
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
List of groups, where each group contains task IDs that can
|
|
190
|
+
run in parallel. Groups are ordered by execution order.
|
|
191
|
+
|
|
192
|
+
Raises:
|
|
193
|
+
CycleDetectedError: If the graph contains a cycle.
|
|
194
|
+
"""
|
|
195
|
+
cycle = detect_cycle(graph)
|
|
196
|
+
if cycle:
|
|
197
|
+
raise CycleDetectedError(cycle)
|
|
198
|
+
|
|
199
|
+
if not graph:
|
|
200
|
+
return []
|
|
201
|
+
|
|
202
|
+
# Calculate the level of each node (longest path from a root)
|
|
203
|
+
levels: dict[str, int] = {}
|
|
204
|
+
|
|
205
|
+
def calculate_level(node: str) -> int:
|
|
206
|
+
if node in levels:
|
|
207
|
+
return levels[node]
|
|
208
|
+
|
|
209
|
+
deps = graph.get(node, [])
|
|
210
|
+
if not deps:
|
|
211
|
+
levels[node] = 0
|
|
212
|
+
else:
|
|
213
|
+
# Level is 1 + max level of dependencies
|
|
214
|
+
# Only consider deps that are in the graph; if none are, treat as level 0
|
|
215
|
+
dep_levels = [calculate_level(dep) for dep in deps if dep in graph]
|
|
216
|
+
max_dep_level = max(dep_levels, default=-1)
|
|
217
|
+
levels[node] = max_dep_level + 1
|
|
218
|
+
|
|
219
|
+
return levels[node]
|
|
220
|
+
|
|
221
|
+
for node in graph:
|
|
222
|
+
calculate_level(node)
|
|
223
|
+
|
|
224
|
+
# Group by level
|
|
225
|
+
max_level = max(levels.values()) if levels else 0
|
|
226
|
+
groups: list[list[str]] = [[] for _ in range(max_level + 1)]
|
|
227
|
+
|
|
228
|
+
for node, level in levels.items():
|
|
229
|
+
groups[level].append(node)
|
|
230
|
+
|
|
231
|
+
# Remove empty groups and return
|
|
232
|
+
return [g for g in groups if g]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def create_execution_plan(
|
|
236
|
+
workspace: Workspace,
|
|
237
|
+
task_ids: list[str],
|
|
238
|
+
) -> ExecutionPlan:
|
|
239
|
+
"""Create an execution plan for the given tasks.
|
|
240
|
+
|
|
241
|
+
Analyzes task dependencies to produce an execution plan with:
|
|
242
|
+
- Topologically sorted task order
|
|
243
|
+
- Parallel execution groups (tasks that can run concurrently)
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
workspace: Workspace containing the tasks
|
|
247
|
+
task_ids: List of task IDs to plan execution for
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
ExecutionPlan with groups, task_order, and graph
|
|
251
|
+
|
|
252
|
+
Raises:
|
|
253
|
+
CycleDetectedError: If there's a circular dependency
|
|
254
|
+
"""
|
|
255
|
+
if not task_ids:
|
|
256
|
+
return ExecutionPlan(groups=[], task_order=[], graph={})
|
|
257
|
+
|
|
258
|
+
graph = build_graph(workspace, task_ids)
|
|
259
|
+
task_order = topological_sort(graph)
|
|
260
|
+
groups = group_by_level(graph)
|
|
261
|
+
|
|
262
|
+
return ExecutionPlan(
|
|
263
|
+
groups=groups,
|
|
264
|
+
task_order=task_order,
|
|
265
|
+
graph=graph,
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def validate_dependencies(
|
|
270
|
+
workspace: Workspace,
|
|
271
|
+
task_ids: list[str],
|
|
272
|
+
) -> tuple[bool, Optional[str]]:
|
|
273
|
+
"""Validate that the task dependencies form a valid DAG.
|
|
274
|
+
|
|
275
|
+
Args:
|
|
276
|
+
workspace: Workspace containing the tasks
|
|
277
|
+
task_ids: List of task IDs to validate
|
|
278
|
+
|
|
279
|
+
Returns:
|
|
280
|
+
Tuple of (is_valid, error_message). If valid, error_message is None.
|
|
281
|
+
"""
|
|
282
|
+
try:
|
|
283
|
+
graph = build_graph(workspace, task_ids)
|
|
284
|
+
cycle = detect_cycle(graph)
|
|
285
|
+
if cycle:
|
|
286
|
+
cycle_str = " -> ".join(cycle)
|
|
287
|
+
return False, f"Circular dependency: {cycle_str}"
|
|
288
|
+
return True, None
|
|
289
|
+
except Exception as e:
|
|
290
|
+
return False, str(e)
|