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,218 @@
1
+ """Blocker detection — reusable pattern matching for agent blocker creation.
2
+
3
+ Classifies error text to determine whether a blocker should be created
4
+ (requires human intervention) or the agent should self-correct.
5
+
6
+ Pattern constants are defined here and imported by agent.py and react_agent.py.
7
+ """
8
+
9
+ from typing import Optional
10
+
11
+ # ---------------------------------------------------------------------------
12
+ # Pattern constants (authoritative location — imported by agent.py)
13
+ # ---------------------------------------------------------------------------
14
+
15
+ # TRUE requirements ambiguity - create blocker immediately
16
+ # These are situations where the agent genuinely cannot proceed without human input
17
+ REQUIREMENTS_AMBIGUITY_PATTERNS = [
18
+ # True requirements conflicts
19
+ "conflicting requirements",
20
+ "spec unclear",
21
+ "specification unclear",
22
+ "requirements conflict",
23
+ "contradictory requirements",
24
+ # Business logic requiring domain knowledge
25
+ "business decision",
26
+ "business logic unclear",
27
+ "domain knowledge required",
28
+ "stakeholder decision",
29
+ # Security policy ambiguity
30
+ "security policy unclear",
31
+ "compliance requirement unclear",
32
+ "regulatory requirement",
33
+ ]
34
+
35
+ # Access/credentials issues - always create blocker
36
+ # These truly require human intervention
37
+ ACCESS_PATTERNS = [
38
+ "permission denied",
39
+ "access denied",
40
+ "authentication required",
41
+ "api key", # Covers "api key missing", "api key not configured", etc.
42
+ "credentials", # Covers "credentials missing", "credentials required", etc.
43
+ "secret required",
44
+ "token required",
45
+ "unauthorized",
46
+ "forbidden",
47
+ ]
48
+
49
+ # External service issues - create blocker after retry
50
+ EXTERNAL_SERVICE_PATTERNS = [
51
+ "service unavailable",
52
+ "rate limited",
53
+ "quota exceeded",
54
+ "connection refused",
55
+ "timeout exceeded",
56
+ ]
57
+
58
+ # TACTICAL decisions - agent should resolve autonomously, NEVER block
59
+ # These patterns indicate the agent is asking about implementation details
60
+ # it should decide on its own using project preferences or best practices
61
+ TACTICAL_DECISION_PATTERNS = [
62
+ # Implementation choices
63
+ "which approach",
64
+ "should i use",
65
+ "multiple options",
66
+ "design decision",
67
+ "please clarify",
68
+ "need clarification",
69
+ # File handling
70
+ "file already exists",
71
+ "overwrite",
72
+ "should i create",
73
+ "should i delete",
74
+ # Tooling choices
75
+ "which version",
76
+ "which package",
77
+ "which framework",
78
+ "install method",
79
+ "package manager",
80
+ # Configuration choices
81
+ "which configuration",
82
+ "which setting",
83
+ "default value",
84
+ "fixture scope",
85
+ "loop scope",
86
+ # Generic decision patterns
87
+ "what do you",
88
+ "do you want",
89
+ "would you like",
90
+ "prefer",
91
+ ]
92
+
93
+ # Combined pattern for human input (requirements + access + external)
94
+ # NOTE: Tactical patterns are explicitly EXCLUDED - agent handles these autonomously
95
+ HUMAN_INPUT_PATTERNS = (
96
+ REQUIREMENTS_AMBIGUITY_PATTERNS + ACCESS_PATTERNS + EXTERNAL_SERVICE_PATTERNS
97
+ )
98
+
99
+ # Error patterns that are technical and the agent should self-correct
100
+ # These are coding/execution errors the agent can fix by trying a different approach
101
+ TECHNICAL_ERROR_PATTERNS = [
102
+ # File/path issues - agent can find correct path or create file
103
+ "file not found",
104
+ "no such file",
105
+ "directory not found",
106
+ "path does not exist",
107
+ "filenotfounderror",
108
+ # Import/module issues - agent can fix imports
109
+ "module not found",
110
+ "import error",
111
+ "no module named",
112
+ "cannot find module",
113
+ "modulenotfounderror",
114
+ # Syntax/code issues - agent can fix code
115
+ "syntax error",
116
+ "syntaxerror",
117
+ "indentation error",
118
+ "name error",
119
+ "nameerror",
120
+ "type error",
121
+ "typeerror",
122
+ "attribute error",
123
+ "attributeerror",
124
+ "undefined",
125
+ "not defined",
126
+ # Command execution issues - agent can try different command
127
+ "command not found",
128
+ "exit code",
129
+ "non-zero exit",
130
+ # General coding issues
131
+ "missing", # usually missing import, argument, etc.
132
+ "expected",
133
+ "invalid",
134
+ ]
135
+
136
+ # ---------------------------------------------------------------------------
137
+ # Classification functions
138
+ # ---------------------------------------------------------------------------
139
+
140
+
141
+ def classify_error_for_blocker(text: str) -> Optional[str]:
142
+ """Classify error text into a blocker category.
143
+
144
+ Returns:
145
+ "requirements" | "access" | "external_service" if a blocker is warranted.
146
+ None if the agent should handle it autonomously (tactical/technical/unknown).
147
+
148
+ Priority order:
149
+ 1. Tactical patterns → None (agent resolves autonomously)
150
+ 2. Requirements ambiguity → "requirements"
151
+ 3. Access/credentials → "access"
152
+ 4. External service → "external_service"
153
+ 5. Technical error patterns → None (agent self-corrects)
154
+ 6. No match → None
155
+ """
156
+ lower = text.lower()
157
+
158
+ # Tactical decisions — agent handles autonomously, never block
159
+ for pattern in TACTICAL_DECISION_PATTERNS:
160
+ if pattern in lower:
161
+ return None
162
+
163
+ # Requirements ambiguity — immediate blocker
164
+ for pattern in REQUIREMENTS_AMBIGUITY_PATTERNS:
165
+ if pattern in lower:
166
+ return "requirements"
167
+
168
+ # Access/credentials — immediate blocker
169
+ for pattern in ACCESS_PATTERNS:
170
+ if pattern in lower:
171
+ return "access"
172
+
173
+ # External service issues — deferred blocker (caller decides retry threshold)
174
+ for pattern in EXTERNAL_SERVICE_PATTERNS:
175
+ if pattern in lower:
176
+ return "external_service"
177
+
178
+ # Technical errors — agent self-corrects (checked after blocker categories
179
+ # since some technical patterns like "missing" overlap with access patterns)
180
+ for pattern in TECHNICAL_ERROR_PATTERNS:
181
+ if pattern in lower:
182
+ return None
183
+
184
+ return None
185
+
186
+
187
+ def should_create_blocker(text: str, attempt_count: int = 0) -> tuple[bool, str]:
188
+ """Decide whether to create a blocker for the given error text.
189
+
190
+ This is the retry-aware public API for blocker decisions. Use it in
191
+ contexts where the caller tracks attempt counts (e.g., verification
192
+ retry loops). For one-shot detection — such as scanning an LLM
193
+ text-only response — use :func:`classify_error_for_blocker` directly
194
+ and treat any non-None category as an immediate blocker.
195
+
196
+ Args:
197
+ text: Error or status text to evaluate.
198
+ attempt_count: Number of prior attempts. External service blockers
199
+ are only created when attempt_count > 1.
200
+
201
+ Returns:
202
+ (True, reason) if a blocker should be created.
203
+ (False, "") otherwise.
204
+ """
205
+ category = classify_error_for_blocker(text)
206
+
207
+ if category == "requirements":
208
+ return True, "Requirements ambiguity detected — human clarification needed"
209
+
210
+ if category == "access":
211
+ return True, "Access or credentials issue — human intervention needed"
212
+
213
+ if category == "external_service":
214
+ if attempt_count > 1:
215
+ return True, "External service issue persists after retries — human intervention needed"
216
+ return False, ""
217
+
218
+ return False, ""
@@ -0,0 +1,433 @@
1
+ """Blocker management for CodeFRAME v2.
2
+
3
+ Blockers represent human-in-the-loop questions that pause task execution
4
+ until answered by a human.
5
+
6
+ This module is headless - no FastAPI or HTTP dependencies.
7
+ """
8
+
9
+ import uuid
10
+ from dataclasses import dataclass
11
+ from datetime import datetime, timezone
12
+ from enum import Enum
13
+ from typing import Optional, Union
14
+
15
+ from codeframe.core.workspace import Workspace, get_db_connection
16
+ from codeframe.core import events, runtime, tasks
17
+ from codeframe.core.state_machine import TaskStatus
18
+
19
+
20
+ def _utc_now() -> datetime:
21
+ """Get current UTC time as timezone-aware datetime."""
22
+ return datetime.now(timezone.utc)
23
+
24
+
25
+ class BlockerStatus(str, Enum):
26
+ """Status of a blocker."""
27
+
28
+ OPEN = "OPEN"
29
+ ANSWERED = "ANSWERED"
30
+ RESOLVED = "RESOLVED"
31
+
32
+
33
+ class BlockerOrigin(str, Enum):
34
+ """Who created the blocker."""
35
+
36
+ SYSTEM = "system"
37
+ AGENT = "agent"
38
+ HUMAN = "human"
39
+
40
+
41
+ @dataclass
42
+ class Blocker:
43
+ """Represents a blocker (human-in-the-loop question).
44
+
45
+ Attributes:
46
+ id: Unique blocker identifier (UUID)
47
+ workspace_id: Workspace this blocker belongs to
48
+ task_id: Optional task this blocker is associated with
49
+ question: The question being asked
50
+ answer: The answer provided (if answered)
51
+ status: Current blocker status
52
+ created_at: When the blocker was created
53
+ answered_at: When the blocker was answered (if answered)
54
+ created_by: Origin of the blocker (system, agent, or human)
55
+ """
56
+
57
+ id: str
58
+ workspace_id: str
59
+ task_id: Optional[str]
60
+ question: str
61
+ answer: Optional[str]
62
+ status: BlockerStatus
63
+ created_at: datetime
64
+ answered_at: Optional[datetime]
65
+ created_by: BlockerOrigin = BlockerOrigin.HUMAN
66
+
67
+
68
+ def create(
69
+ workspace: Workspace,
70
+ question: str,
71
+ task_id: Optional[str] = None,
72
+ created_by: Union[BlockerOrigin, str] = BlockerOrigin.HUMAN,
73
+ ) -> Blocker:
74
+ """Create a new blocker.
75
+
76
+ Args:
77
+ workspace: Target workspace
78
+ question: The question to ask
79
+ task_id: Optional associated task ID
80
+ created_by: Origin of the blocker ("system", "agent", or "human")
81
+
82
+ Returns:
83
+ Created Blocker
84
+ """
85
+ origin = BlockerOrigin(created_by)
86
+ blocker_id = str(uuid.uuid4())
87
+ now = _utc_now().isoformat()
88
+
89
+ conn = get_db_connection(workspace)
90
+ cursor = conn.cursor()
91
+
92
+ cursor.execute(
93
+ """
94
+ INSERT INTO blockers (id, workspace_id, task_id, question, status, created_at, created_by)
95
+ VALUES (?, ?, ?, ?, ?, ?, ?)
96
+ """,
97
+ (blocker_id, workspace.id, task_id, question, BlockerStatus.OPEN.value, now, origin.value),
98
+ )
99
+ conn.commit()
100
+ conn.close()
101
+
102
+ blocker = Blocker(
103
+ id=blocker_id,
104
+ workspace_id=workspace.id,
105
+ task_id=task_id,
106
+ question=question,
107
+ answer=None,
108
+ status=BlockerStatus.OPEN,
109
+ created_at=datetime.fromisoformat(now),
110
+ answered_at=None,
111
+ created_by=origin,
112
+ )
113
+
114
+ # Emit blocker created event
115
+ events.emit_for_workspace(
116
+ workspace,
117
+ events.EventType.BLOCKER_CREATED,
118
+ {"blocker_id": blocker_id, "task_id": task_id, "question": question[:100]},
119
+ print_event=True,
120
+ )
121
+
122
+ # Outbound webhook dispatch (issue #560) — fire-and-forget, never blocks
123
+ # blocker creation. A misconfigured webhook must not break the workflow.
124
+ _dispatch_blocker_webhook(workspace, blocker_id, task_id)
125
+
126
+ return blocker
127
+
128
+
129
+ def _dispatch_blocker_webhook(
130
+ workspace: Workspace, blocker_id: str, task_id: Optional[str]
131
+ ) -> None:
132
+ """Best-effort outbound webhook for ``blocker.created``."""
133
+ try:
134
+ from codeframe.core.notifications_config import is_webhook_active
135
+ from codeframe.notifications.webhook import (
136
+ WebhookNotificationService,
137
+ format_blocker_payload,
138
+ )
139
+
140
+ url = is_webhook_active(workspace)
141
+ if url is None:
142
+ return
143
+ svc = WebhookNotificationService(webhook_url=url, timeout=5)
144
+ svc.send_event_background(format_blocker_payload(blocker_id, task_id))
145
+ except Exception:
146
+ import logging
147
+
148
+ logging.getLogger(__name__).warning(
149
+ "Failed to dispatch blocker.created webhook for %s", blocker_id,
150
+ exc_info=True,
151
+ )
152
+
153
+
154
+ def get(workspace: Workspace, blocker_id: str) -> Optional[Blocker]:
155
+ """Get a blocker by ID.
156
+
157
+ Supports partial ID matching.
158
+
159
+ Args:
160
+ workspace: Workspace to query
161
+ blocker_id: Blocker identifier (can be partial)
162
+
163
+ Returns:
164
+ Blocker if found, None otherwise
165
+ """
166
+ conn = get_db_connection(workspace)
167
+ cursor = conn.cursor()
168
+
169
+ # Try exact match first
170
+ cursor.execute(
171
+ """
172
+ SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at,
173
+ COALESCE(created_by, 'human') as created_by
174
+ FROM blockers
175
+ WHERE workspace_id = ? AND id = ?
176
+ """,
177
+ (workspace.id, blocker_id),
178
+ )
179
+ row = cursor.fetchone()
180
+
181
+ # If no exact match, try prefix match
182
+ if not row:
183
+ cursor.execute(
184
+ """
185
+ SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at,
186
+ COALESCE(created_by, 'human') as created_by
187
+ FROM blockers
188
+ WHERE workspace_id = ? AND id LIKE ?
189
+ """,
190
+ (workspace.id, f"{blocker_id}%"),
191
+ )
192
+ rows = cursor.fetchall()
193
+ if len(rows) == 1:
194
+ row = rows[0]
195
+ elif len(rows) > 1:
196
+ conn.close()
197
+ raise ValueError(f"Multiple blockers match '{blocker_id}'")
198
+
199
+ conn.close()
200
+
201
+ if not row:
202
+ return None
203
+
204
+ return _row_to_blocker(row)
205
+
206
+
207
+ def list_open(workspace: Workspace) -> list[Blocker]:
208
+ """List open blockers.
209
+
210
+ Args:
211
+ workspace: Workspace to query
212
+
213
+ Returns:
214
+ List of open Blockers, oldest first
215
+ """
216
+ return list_all(workspace, status=BlockerStatus.OPEN)
217
+
218
+
219
+ def list_all(
220
+ workspace: Workspace,
221
+ status: Optional[BlockerStatus] = None,
222
+ task_id: Optional[str] = None,
223
+ limit: int = 100,
224
+ ) -> list[Blocker]:
225
+ """List blockers with optional filters.
226
+
227
+ Args:
228
+ workspace: Workspace to query
229
+ status: Optional status filter
230
+ task_id: Optional task filter
231
+ limit: Maximum blockers to return
232
+
233
+ Returns:
234
+ List of Blockers, oldest first
235
+ """
236
+ conn = get_db_connection(workspace)
237
+ cursor = conn.cursor()
238
+
239
+ query = """
240
+ SELECT id, workspace_id, task_id, question, answer, status, created_at, answered_at,
241
+ COALESCE(created_by, 'human') as created_by
242
+ FROM blockers
243
+ WHERE workspace_id = ?
244
+ """
245
+ params: list = [workspace.id]
246
+
247
+ if status:
248
+ query += " AND status = ?"
249
+ params.append(status.value)
250
+
251
+ if task_id:
252
+ query += " AND task_id = ?"
253
+ params.append(task_id)
254
+
255
+ query += " ORDER BY created_at ASC LIMIT ?"
256
+ params.append(limit)
257
+
258
+ cursor.execute(query, params)
259
+ rows = cursor.fetchall()
260
+ conn.close()
261
+
262
+ return [_row_to_blocker(row) for row in rows]
263
+
264
+
265
+ def list_for_task(workspace: Workspace, task_id: str) -> list[Blocker]:
266
+ """List all blockers for a specific task.
267
+
268
+ Args:
269
+ workspace: Workspace to query
270
+ task_id: Task to filter by
271
+
272
+ Returns:
273
+ List of Blockers for the task
274
+ """
275
+ return list_all(workspace, task_id=task_id)
276
+
277
+
278
+ def answer(workspace: Workspace, blocker_id: str, text: str) -> Blocker:
279
+ """Answer a blocker.
280
+
281
+ Args:
282
+ workspace: Target workspace
283
+ blocker_id: Blocker to answer (can be partial ID)
284
+ text: Answer text
285
+
286
+ Returns:
287
+ Updated Blocker
288
+
289
+ Raises:
290
+ ValueError: If blocker not found or already resolved
291
+ """
292
+ blocker = get(workspace, blocker_id)
293
+ if not blocker:
294
+ raise ValueError(f"Blocker not found: {blocker_id}")
295
+
296
+ if blocker.status == BlockerStatus.RESOLVED:
297
+ raise ValueError(f"Blocker already resolved: {blocker_id}")
298
+
299
+ now = _utc_now().isoformat()
300
+
301
+ conn = get_db_connection(workspace)
302
+ cursor = conn.cursor()
303
+
304
+ cursor.execute(
305
+ """
306
+ UPDATE blockers
307
+ SET answer = ?, status = ?, answered_at = ?
308
+ WHERE id = ?
309
+ """,
310
+ (text, BlockerStatus.ANSWERED.value, now, blocker.id),
311
+ )
312
+ conn.commit()
313
+ conn.close()
314
+
315
+ # Emit blocker answered event
316
+ events.emit_for_workspace(
317
+ workspace,
318
+ events.EventType.BLOCKER_ANSWERED,
319
+ {"blocker_id": blocker.id, "task_id": blocker.task_id},
320
+ print_event=True,
321
+ )
322
+
323
+ blocker.answer = text
324
+ blocker.status = BlockerStatus.ANSWERED
325
+ blocker.answered_at = datetime.fromisoformat(now)
326
+
327
+ # Automatically reset the associated task to READY so it can be restarted
328
+ # This eliminates the need for separate "work stop" and "work resume" commands
329
+ if blocker.task_id:
330
+ try:
331
+ # Stop the blocked run (marks it as FAILED)
332
+ active_run = runtime.get_active_run(workspace, blocker.task_id)
333
+ if active_run and active_run.status == runtime.RunStatus.BLOCKED:
334
+ runtime.stop_run(workspace, blocker.task_id)
335
+ # Task is now READY and can be restarted with `cf work start <id> --execute`
336
+ except ValueError:
337
+ # No active run found, just ensure task is READY
338
+ task = tasks.get(workspace, blocker.task_id)
339
+ if task and task.status == TaskStatus.BLOCKED:
340
+ tasks.update_status(workspace, blocker.task_id, TaskStatus.READY)
341
+
342
+ return blocker
343
+
344
+
345
+ def resolve(workspace: Workspace, blocker_id: str) -> Blocker:
346
+ """Mark a blocker as resolved.
347
+
348
+ Args:
349
+ workspace: Target workspace
350
+ blocker_id: Blocker to resolve (can be partial ID)
351
+
352
+ Returns:
353
+ Updated Blocker
354
+
355
+ Raises:
356
+ ValueError: If blocker not found or not answered
357
+ """
358
+ blocker = get(workspace, blocker_id)
359
+ if not blocker:
360
+ raise ValueError(f"Blocker not found: {blocker_id}")
361
+
362
+ if blocker.status == BlockerStatus.OPEN:
363
+ raise ValueError(f"Blocker must be answered before resolving: {blocker_id}")
364
+
365
+ if blocker.status == BlockerStatus.RESOLVED:
366
+ raise ValueError(f"Blocker already resolved: {blocker_id}")
367
+
368
+ conn = get_db_connection(workspace)
369
+ cursor = conn.cursor()
370
+
371
+ cursor.execute(
372
+ """
373
+ UPDATE blockers
374
+ SET status = ?
375
+ WHERE id = ?
376
+ """,
377
+ (BlockerStatus.RESOLVED.value, blocker.id),
378
+ )
379
+ conn.commit()
380
+ conn.close()
381
+
382
+ # Emit blocker resolved event
383
+ events.emit_for_workspace(
384
+ workspace,
385
+ events.EventType.BLOCKER_RESOLVED,
386
+ {"blocker_id": blocker.id, "task_id": blocker.task_id},
387
+ print_event=True,
388
+ )
389
+
390
+ blocker.status = BlockerStatus.RESOLVED
391
+ return blocker
392
+
393
+
394
+ def count_by_status(workspace: Workspace) -> dict[str, int]:
395
+ """Count blockers by status.
396
+
397
+ Args:
398
+ workspace: Workspace to query
399
+
400
+ Returns:
401
+ Dict mapping status string to count
402
+ """
403
+ conn = get_db_connection(workspace)
404
+ cursor = conn.cursor()
405
+
406
+ cursor.execute(
407
+ """
408
+ SELECT status, COUNT(*) as count
409
+ FROM blockers
410
+ WHERE workspace_id = ?
411
+ GROUP BY status
412
+ """,
413
+ (workspace.id,),
414
+ )
415
+ rows = cursor.fetchall()
416
+ conn.close()
417
+
418
+ return {row[0]: row[1] for row in rows}
419
+
420
+
421
+ def _row_to_blocker(row: tuple) -> Blocker:
422
+ """Convert a database row to a Blocker object."""
423
+ return Blocker(
424
+ id=row[0],
425
+ workspace_id=row[1],
426
+ task_id=row[2],
427
+ question=row[3],
428
+ answer=row[4],
429
+ status=BlockerStatus(row[5]),
430
+ created_at=datetime.fromisoformat(row[6]),
431
+ answered_at=datetime.fromisoformat(row[7]) if row[7] else None,
432
+ created_by=BlockerOrigin(row[8]),
433
+ )