codeframe-ai 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (197) hide show
  1. codeframe/__init__.py +11 -0
  2. codeframe/__main__.py +20 -0
  3. codeframe/adapters/__init__.py +5 -0
  4. codeframe/adapters/e2b/__init__.py +13 -0
  5. codeframe/adapters/e2b/adapter.py +342 -0
  6. codeframe/adapters/e2b/budget.py +71 -0
  7. codeframe/adapters/e2b/credential_scanner.py +134 -0
  8. codeframe/adapters/llm/__init__.py +92 -0
  9. codeframe/adapters/llm/anthropic.py +414 -0
  10. codeframe/adapters/llm/base.py +444 -0
  11. codeframe/adapters/llm/mock.py +281 -0
  12. codeframe/adapters/llm/openai.py +483 -0
  13. codeframe/agents/__init__.py +8 -0
  14. codeframe/agents/dependency_resolver.py +714 -0
  15. codeframe/auth/__init__.py +16 -0
  16. codeframe/auth/api_key_router.py +238 -0
  17. codeframe/auth/api_keys.py +156 -0
  18. codeframe/auth/dependencies.py +358 -0
  19. codeframe/auth/manager.py +178 -0
  20. codeframe/auth/models.py +30 -0
  21. codeframe/auth/router.py +93 -0
  22. codeframe/auth/schemas.py +15 -0
  23. codeframe/auth/scopes.py +53 -0
  24. codeframe/cli/__init__.py +12 -0
  25. codeframe/cli/__main__.py +20 -0
  26. codeframe/cli/api_client.py +275 -0
  27. codeframe/cli/app.py +5688 -0
  28. codeframe/cli/auth.py +122 -0
  29. codeframe/cli/auth_commands.py +958 -0
  30. codeframe/cli/commands/__init__.py +5 -0
  31. codeframe/cli/config_commands.py +79 -0
  32. codeframe/cli/dashboard_commands.py +67 -0
  33. codeframe/cli/engines_commands.py +205 -0
  34. codeframe/cli/env_commands.py +409 -0
  35. codeframe/cli/helpers.py +56 -0
  36. codeframe/cli/hooks_commands.py +208 -0
  37. codeframe/cli/import_commands.py +129 -0
  38. codeframe/cli/pr_commands.py +549 -0
  39. codeframe/cli/proof_commands.py +415 -0
  40. codeframe/cli/stats_commands.py +311 -0
  41. codeframe/cli/telemetry_runtime.py +153 -0
  42. codeframe/cli/validators.py +123 -0
  43. codeframe/config/rate_limits.py +165 -0
  44. codeframe/core/__init__.py +15 -0
  45. codeframe/core/adapters/__init__.py +43 -0
  46. codeframe/core/adapters/agent_adapter.py +114 -0
  47. codeframe/core/adapters/builtin.py +326 -0
  48. codeframe/core/adapters/claude_code.py +62 -0
  49. codeframe/core/adapters/codex.py +393 -0
  50. codeframe/core/adapters/git_utils.py +40 -0
  51. codeframe/core/adapters/kilocode.py +126 -0
  52. codeframe/core/adapters/opencode.py +48 -0
  53. codeframe/core/adapters/streaming_chat.py +483 -0
  54. codeframe/core/adapters/subprocess_adapter.py +213 -0
  55. codeframe/core/adapters/verification_wrapper.py +269 -0
  56. codeframe/core/agent.py +2183 -0
  57. codeframe/core/agents_config.py +569 -0
  58. codeframe/core/api_key_service.py +211 -0
  59. codeframe/core/artifacts.py +428 -0
  60. codeframe/core/blocker_detection.py +218 -0
  61. codeframe/core/blockers.py +433 -0
  62. codeframe/core/checkpoints.py +481 -0
  63. codeframe/core/conductor.py +2255 -0
  64. codeframe/core/config.py +827 -0
  65. codeframe/core/config_watcher.py +268 -0
  66. codeframe/core/context.py +542 -0
  67. codeframe/core/context_packager.py +234 -0
  68. codeframe/core/credentials.py +735 -0
  69. codeframe/core/dependency_analyzer.py +229 -0
  70. codeframe/core/dependency_graph.py +290 -0
  71. codeframe/core/diagnostic_agent.py +712 -0
  72. codeframe/core/diagnostics.py +616 -0
  73. codeframe/core/editor.py +556 -0
  74. codeframe/core/engine_registry.py +256 -0
  75. codeframe/core/engine_stats.py +231 -0
  76. codeframe/core/environment.py +697 -0
  77. codeframe/core/events.py +375 -0
  78. codeframe/core/executor.py +1005 -0
  79. codeframe/core/fix_tracker.py +480 -0
  80. codeframe/core/gates.py +1322 -0
  81. codeframe/core/git.py +477 -0
  82. codeframe/core/github_connect_service.py +178 -0
  83. codeframe/core/github_integration_config.py +118 -0
  84. codeframe/core/github_issues_service.py +449 -0
  85. codeframe/core/hooks.py +184 -0
  86. codeframe/core/importers/__init__.py +1 -0
  87. codeframe/core/importers/ralph.py +540 -0
  88. codeframe/core/installer.py +650 -0
  89. codeframe/core/models.py +1026 -0
  90. codeframe/core/notifications_config.py +183 -0
  91. codeframe/core/planner.py +437 -0
  92. codeframe/core/prd.py +670 -0
  93. codeframe/core/prd_discovery.py +1118 -0
  94. codeframe/core/prd_stress_test.py +499 -0
  95. codeframe/core/progress.py +126 -0
  96. codeframe/core/proof/__init__.py +34 -0
  97. codeframe/core/proof/capture.py +79 -0
  98. codeframe/core/proof/evidence.py +56 -0
  99. codeframe/core/proof/ledger.py +574 -0
  100. codeframe/core/proof/models.py +162 -0
  101. codeframe/core/proof/obligations.py +103 -0
  102. codeframe/core/proof/runner.py +233 -0
  103. codeframe/core/proof/scope.py +81 -0
  104. codeframe/core/proof/stubs.py +156 -0
  105. codeframe/core/quick_fixes.py +558 -0
  106. codeframe/core/react_agent.py +1650 -0
  107. codeframe/core/reconciliation.py +183 -0
  108. codeframe/core/replay.py +788 -0
  109. codeframe/core/review.py +285 -0
  110. codeframe/core/runtime.py +1134 -0
  111. codeframe/core/sandbox/__init__.py +27 -0
  112. codeframe/core/sandbox/context.py +98 -0
  113. codeframe/core/sandbox/worktree.py +20 -0
  114. codeframe/core/schedule.py +396 -0
  115. codeframe/core/stall_detector.py +71 -0
  116. codeframe/core/stall_monitor.py +134 -0
  117. codeframe/core/state_machine.py +121 -0
  118. codeframe/core/streaming.py +502 -0
  119. codeframe/core/task_tree.py +400 -0
  120. codeframe/core/tasks.py +1022 -0
  121. codeframe/core/telemetry.py +232 -0
  122. codeframe/core/templates.py +221 -0
  123. codeframe/core/tools.py +942 -0
  124. codeframe/core/workspace.py +887 -0
  125. codeframe/core/worktrees.py +276 -0
  126. codeframe/git/__init__.py +5 -0
  127. codeframe/git/github_integration.py +505 -0
  128. codeframe/lib/__init__.py +0 -0
  129. codeframe/lib/audit_logger.py +248 -0
  130. codeframe/lib/metrics_tracker.py +800 -0
  131. codeframe/lib/quality/__init__.py +7 -0
  132. codeframe/lib/quality/complexity_analyzer.py +316 -0
  133. codeframe/lib/quality/owasp_patterns.py +284 -0
  134. codeframe/lib/quality/security_scanner.py +250 -0
  135. codeframe/lib/rate_limiter.py +312 -0
  136. codeframe/notifications/__init__.py +0 -0
  137. codeframe/notifications/webhook.py +380 -0
  138. codeframe/planning/__init__.py +30 -0
  139. codeframe/planning/issue_generator.py +219 -0
  140. codeframe/planning/prd_template_functions.py +137 -0
  141. codeframe/planning/prd_templates.py +975 -0
  142. codeframe/planning/task_scheduler.py +511 -0
  143. codeframe/planning/task_templates.py +533 -0
  144. codeframe/platform_store/__init__.py +5 -0
  145. codeframe/platform_store/database.py +277 -0
  146. codeframe/platform_store/repositories/__init__.py +24 -0
  147. codeframe/platform_store/repositories/api_key_repository.py +245 -0
  148. codeframe/platform_store/repositories/audit_repository.py +67 -0
  149. codeframe/platform_store/repositories/base.py +295 -0
  150. codeframe/platform_store/repositories/interactive_sessions.py +165 -0
  151. codeframe/platform_store/repositories/token_repository.py +598 -0
  152. codeframe/platform_store/repositories/workspace_registry_repository.py +175 -0
  153. codeframe/platform_store/schema_manager.py +321 -0
  154. codeframe/templates/AGENTS.md.default +94 -0
  155. codeframe/tui/__init__.py +5 -0
  156. codeframe/tui/app.py +256 -0
  157. codeframe/tui/data_service.py +103 -0
  158. codeframe/ui/__init__.py +0 -0
  159. codeframe/ui/dependencies.py +103 -0
  160. codeframe/ui/models.py +999 -0
  161. codeframe/ui/response_models.py +201 -0
  162. codeframe/ui/routers/__init__.py +5 -0
  163. codeframe/ui/routers/_helpers.py +29 -0
  164. codeframe/ui/routers/batches_v2.py +315 -0
  165. codeframe/ui/routers/blockers_v2.py +320 -0
  166. codeframe/ui/routers/checkpoints_v2.py +310 -0
  167. codeframe/ui/routers/costs_v2.py +322 -0
  168. codeframe/ui/routers/diagnose_v2.py +225 -0
  169. codeframe/ui/routers/discovery_v2.py +417 -0
  170. codeframe/ui/routers/environment_v2.py +284 -0
  171. codeframe/ui/routers/events_v2.py +75 -0
  172. codeframe/ui/routers/gates_v2.py +166 -0
  173. codeframe/ui/routers/git_v2.py +284 -0
  174. codeframe/ui/routers/github_integrations_v2.py +532 -0
  175. codeframe/ui/routers/interactive_sessions_v2.py +238 -0
  176. codeframe/ui/routers/pr_v2.py +709 -0
  177. codeframe/ui/routers/prd_v2.py +695 -0
  178. codeframe/ui/routers/proof_v2.py +755 -0
  179. codeframe/ui/routers/review_v2.py +360 -0
  180. codeframe/ui/routers/schedule_v2.py +214 -0
  181. codeframe/ui/routers/session_chat_ws.py +354 -0
  182. codeframe/ui/routers/settings_v2.py +562 -0
  183. codeframe/ui/routers/streaming_v2.py +155 -0
  184. codeframe/ui/routers/tasks_v2.py +1098 -0
  185. codeframe/ui/routers/templates_v2.py +232 -0
  186. codeframe/ui/routers/terminal_ws.py +267 -0
  187. codeframe/ui/routers/workspace_v2.py +527 -0
  188. codeframe/ui/server.py +568 -0
  189. codeframe/ui/shared.py +241 -0
  190. codeframe/workspace/__init__.py +5 -0
  191. codeframe/workspace/manager.py +249 -0
  192. codeframe_ai-0.9.0.dist-info/METADATA +517 -0
  193. codeframe_ai-0.9.0.dist-info/RECORD +197 -0
  194. codeframe_ai-0.9.0.dist-info/WHEEL +5 -0
  195. codeframe_ai-0.9.0.dist-info/entry_points.txt +3 -0
  196. codeframe_ai-0.9.0.dist-info/licenses/LICENSE +661 -0
  197. codeframe_ai-0.9.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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)