ralphx 0.3.4__py3-none-any.whl → 0.4.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.
- ralphx/__init__.py +1 -1
- ralphx/adapters/base.py +10 -2
- ralphx/adapters/claude_cli.py +222 -82
- ralphx/api/routes/auth.py +780 -98
- ralphx/api/routes/config.py +3 -56
- ralphx/api/routes/export_import.py +6 -9
- ralphx/api/routes/loops.py +4 -4
- ralphx/api/routes/planning.py +882 -19
- ralphx/api/routes/resources.py +528 -6
- ralphx/api/routes/stream.py +58 -56
- ralphx/api/routes/templates.py +2 -2
- ralphx/api/routes/workflows.py +258 -47
- ralphx/cli.py +4 -1
- ralphx/core/auth.py +372 -172
- ralphx/core/database.py +588 -164
- ralphx/core/executor.py +170 -19
- ralphx/core/loop.py +15 -2
- ralphx/core/loop_templates.py +29 -3
- ralphx/core/planning_iteration_executor.py +633 -0
- ralphx/core/planning_service.py +119 -24
- ralphx/core/preview.py +9 -25
- ralphx/core/project_db.py +864 -121
- ralphx/core/project_export.py +1 -5
- ralphx/core/project_import.py +14 -29
- ralphx/core/resources.py +28 -2
- ralphx/core/sample_project.py +1 -5
- ralphx/core/templates.py +9 -9
- ralphx/core/workflow_executor.py +32 -3
- ralphx/core/workflow_export.py +4 -7
- ralphx/core/workflow_import.py +3 -27
- ralphx/mcp/__init__.py +6 -2
- ralphx/mcp/registry.py +3 -3
- ralphx/mcp/tools/diagnostics.py +1 -1
- ralphx/mcp/tools/monitoring.py +10 -16
- ralphx/mcp/tools/workflows.py +115 -33
- ralphx/mcp_server.py +6 -2
- ralphx/static/assets/index-BuLI7ffn.css +1 -0
- ralphx/static/assets/index-DWvlqOTb.js +264 -0
- ralphx/static/assets/index-DWvlqOTb.js.map +1 -0
- ralphx/static/index.html +2 -2
- ralphx/templates/loop_templates/consumer.md +2 -2
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/METADATA +33 -12
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/RECORD +45 -44
- ralphx/static/assets/index-CcRDyY3b.css +0 -1
- ralphx/static/assets/index-CcxfTosc.js +0 -251
- ralphx/static/assets/index-CcxfTosc.js.map +0 -1
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/WHEEL +0 -0
- {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
"""Planning Iteration Executor for RalphX.
|
|
2
|
+
|
|
3
|
+
Replaces the chat-based planning paradigm with prompt-driven iteration loops.
|
|
4
|
+
Users provide a single prompt + iteration count, system runs N iterations
|
|
5
|
+
automatically, each refining the design document.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import difflib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import re
|
|
13
|
+
from datetime import datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import AsyncIterator, Optional
|
|
16
|
+
|
|
17
|
+
from ralphx.adapters.base import AdapterEvent, StreamEvent
|
|
18
|
+
from ralphx.adapters.claude_cli import ClaudeCLIAdapter
|
|
19
|
+
from ralphx.core.project import Project
|
|
20
|
+
from ralphx.core.project_db import ProjectDatabase
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
# ============================================================================
|
|
25
|
+
# Configuration
|
|
26
|
+
# ============================================================================
|
|
27
|
+
|
|
28
|
+
# Allowed tools for iteration mode (research + file editing)
|
|
29
|
+
DEFAULT_ITERATION_TOOLS = [
|
|
30
|
+
"WebSearch", # Research best practices, technologies
|
|
31
|
+
"WebFetch", # Deep dive into specific URLs
|
|
32
|
+
"Read", # Read project files for context
|
|
33
|
+
"Glob", # Find relevant files
|
|
34
|
+
"Grep", # Search file contents
|
|
35
|
+
"Edit", # Edit the design document in place
|
|
36
|
+
"Write", # Write/rewrite the design document
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
# Timeouts and limits
|
|
40
|
+
TIMEOUT_PER_ITERATION = 300 # 5 minutes per iteration
|
|
41
|
+
COOLDOWN_BETWEEN_ITERATIONS = 5 # 5 seconds between iterations
|
|
42
|
+
MAX_ITERATIONS = 10 # Maximum iterations allowed
|
|
43
|
+
HEARTBEAT_INTERVAL = 15 # Heartbeat every 15 seconds
|
|
44
|
+
|
|
45
|
+
# ============================================================================
|
|
46
|
+
# SSE Event Types
|
|
47
|
+
# ============================================================================
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class SSEEventType:
|
|
51
|
+
"""Server-Sent Event types for iteration streaming."""
|
|
52
|
+
|
|
53
|
+
ITERATION_START = "iteration_start"
|
|
54
|
+
TOOL_USE = "tool_use"
|
|
55
|
+
TOOL_RESULT = "tool_result"
|
|
56
|
+
CONTENT = "content"
|
|
57
|
+
DESIGN_DOC_UPDATED = "design_doc_updated"
|
|
58
|
+
HEARTBEAT = "heartbeat"
|
|
59
|
+
ITERATION_COMPLETE = "iteration_complete"
|
|
60
|
+
ERROR = "error"
|
|
61
|
+
CANCELLED = "cancelled"
|
|
62
|
+
DONE = "done"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ============================================================================
|
|
66
|
+
# Iteration Prompt Template
|
|
67
|
+
# ============================================================================
|
|
68
|
+
|
|
69
|
+
ITERATION_PROMPT_TEMPLATE = """You are refining a design document. This is iteration {current} of {total}.
|
|
70
|
+
|
|
71
|
+
## User's Guidance
|
|
72
|
+
{user_prompt}
|
|
73
|
+
|
|
74
|
+
## Design Document
|
|
75
|
+
The design document is at: {design_doc_file}
|
|
76
|
+
Read it, then use Edit to make targeted changes based on your research.
|
|
77
|
+
|
|
78
|
+
## Instructions
|
|
79
|
+
1. Read the design document file at the path above
|
|
80
|
+
2. Use tools (WebSearch, Read, etc.) to research as needed
|
|
81
|
+
3. Use Edit to make targeted changes to the design document file
|
|
82
|
+
4. If the document doesn't exist yet, use Write to create it
|
|
83
|
+
5. Provide a brief summary of changes in <changes_summary>...</changes_summary> tags
|
|
84
|
+
|
|
85
|
+
Do NOT output the entire document in your response. Edit the file directly using the Edit tool.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
NO_DOC_PLACEHOLDER = """(No design document exists yet. Create a comprehensive design document based on the user's guidance above.
|
|
89
|
+
|
|
90
|
+
Structure your document with clear sections:
|
|
91
|
+
- Overview / Problem Statement
|
|
92
|
+
- Goals and Requirements
|
|
93
|
+
- Technical Approach
|
|
94
|
+
- Key Components
|
|
95
|
+
- Implementation Plan
|
|
96
|
+
- Open Questions / Considerations)"""
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ============================================================================
|
|
100
|
+
# Executor Class
|
|
101
|
+
# ============================================================================
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class PlanningIterationExecutor:
|
|
105
|
+
"""Executes N iterations of design doc refinement.
|
|
106
|
+
|
|
107
|
+
Each iteration:
|
|
108
|
+
1. Writes the current design doc to a file for Claude to edit in place
|
|
109
|
+
2. Builds a prompt referencing the file path (not the doc content)
|
|
110
|
+
3. Streams Claude response (with Edit/Write tools)
|
|
111
|
+
4. Re-reads the file to capture Claude's edits, saves to DB
|
|
112
|
+
5. Records iteration metrics
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
project: Project,
|
|
118
|
+
pdb: ProjectDatabase,
|
|
119
|
+
session_id: str,
|
|
120
|
+
project_id: Optional[str] = None,
|
|
121
|
+
design_doc_path: Optional[str] = None,
|
|
122
|
+
):
|
|
123
|
+
"""Initialize the executor.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
project: Project object with path.
|
|
127
|
+
pdb: Project database for persistence.
|
|
128
|
+
session_id: Planning session ID.
|
|
129
|
+
project_id: Optional project ID for credentials.
|
|
130
|
+
design_doc_path: Absolute path to design doc file for file-based editing.
|
|
131
|
+
"""
|
|
132
|
+
self.project = project
|
|
133
|
+
self.pdb = pdb
|
|
134
|
+
self.session_id = session_id
|
|
135
|
+
self.project_id = project_id
|
|
136
|
+
self.design_doc_path = design_doc_path
|
|
137
|
+
self._adapter: Optional[ClaudeCLIAdapter] = None
|
|
138
|
+
self._cancelled = False
|
|
139
|
+
|
|
140
|
+
def cancel(self) -> None:
|
|
141
|
+
"""Request cancellation of the execution loop."""
|
|
142
|
+
self._cancelled = True
|
|
143
|
+
|
|
144
|
+
async def _check_cancelled(self) -> bool:
|
|
145
|
+
"""Check if cancellation has been requested.
|
|
146
|
+
|
|
147
|
+
Also checks the database for external cancellation.
|
|
148
|
+
"""
|
|
149
|
+
if self._cancelled:
|
|
150
|
+
return True
|
|
151
|
+
|
|
152
|
+
# Check database for cancelled status
|
|
153
|
+
session = self.pdb.get_planning_session(self.session_id)
|
|
154
|
+
if session and session.get("run_status") == "cancelled":
|
|
155
|
+
self._cancelled = True
|
|
156
|
+
return True
|
|
157
|
+
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
def _load_design_doc(self) -> str:
|
|
161
|
+
"""Load the current design document from session artifacts.
|
|
162
|
+
|
|
163
|
+
Returns:
|
|
164
|
+
The design doc content, or empty string if none exists.
|
|
165
|
+
"""
|
|
166
|
+
session = self.pdb.get_planning_session(self.session_id)
|
|
167
|
+
if not session:
|
|
168
|
+
return ""
|
|
169
|
+
|
|
170
|
+
artifacts = session.get("artifacts") or {}
|
|
171
|
+
return artifacts.get("design_doc", "")
|
|
172
|
+
|
|
173
|
+
def _save_design_doc(self, content: str) -> None:
|
|
174
|
+
"""Save the design document to session artifacts.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
content: The updated design doc content.
|
|
178
|
+
"""
|
|
179
|
+
session = self.pdb.get_planning_session(self.session_id)
|
|
180
|
+
if not session:
|
|
181
|
+
return
|
|
182
|
+
|
|
183
|
+
artifacts = session.get("artifacts") or {}
|
|
184
|
+
artifacts["design_doc"] = content
|
|
185
|
+
self.pdb.update_planning_session(self.session_id, artifacts=artifacts)
|
|
186
|
+
|
|
187
|
+
def _build_iteration_prompt(
|
|
188
|
+
self,
|
|
189
|
+
user_prompt: str,
|
|
190
|
+
design_doc_file: str,
|
|
191
|
+
current: int,
|
|
192
|
+
total: int,
|
|
193
|
+
) -> str:
|
|
194
|
+
"""Build the prompt for an iteration.
|
|
195
|
+
|
|
196
|
+
Args:
|
|
197
|
+
user_prompt: User's guidance for this session.
|
|
198
|
+
design_doc_file: Path to the design doc file on disk.
|
|
199
|
+
current: Current iteration number (1-indexed).
|
|
200
|
+
total: Total iterations requested.
|
|
201
|
+
|
|
202
|
+
Returns:
|
|
203
|
+
Full prompt string.
|
|
204
|
+
"""
|
|
205
|
+
return ITERATION_PROMPT_TEMPLATE.format(
|
|
206
|
+
current=current,
|
|
207
|
+
total=total,
|
|
208
|
+
user_prompt=user_prompt,
|
|
209
|
+
design_doc_file=design_doc_file,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _extract_design_doc(self, response: str) -> Optional[str]:
|
|
213
|
+
"""Extract the design document from Claude's response.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
response: Full response text from Claude.
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Extracted design doc content, or None if not found.
|
|
220
|
+
"""
|
|
221
|
+
# Look for <design_doc>...</design_doc> tags
|
|
222
|
+
match = re.search(
|
|
223
|
+
r"<design_doc>(.*?)</design_doc>",
|
|
224
|
+
response,
|
|
225
|
+
re.DOTALL | re.IGNORECASE,
|
|
226
|
+
)
|
|
227
|
+
if match:
|
|
228
|
+
return match.group(1).strip()
|
|
229
|
+
|
|
230
|
+
# Fallback: if response looks like a design doc (has markdown headers),
|
|
231
|
+
# use the whole response
|
|
232
|
+
if len(response) > 200 and any(h in response for h in ["# ", "## "]):
|
|
233
|
+
logger.warning(
|
|
234
|
+
"No <design_doc> tags found, using response as design doc (has markdown)"
|
|
235
|
+
)
|
|
236
|
+
return response.strip()
|
|
237
|
+
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
def _extract_summary(self, response: str) -> Optional[str]:
|
|
241
|
+
"""Extract the changes summary from Claude's response.
|
|
242
|
+
|
|
243
|
+
Args:
|
|
244
|
+
response: Full response text from Claude.
|
|
245
|
+
|
|
246
|
+
Returns:
|
|
247
|
+
Extracted summary, or None if not found.
|
|
248
|
+
"""
|
|
249
|
+
match = re.search(
|
|
250
|
+
r"<changes_summary>(.*?)</changes_summary>",
|
|
251
|
+
response,
|
|
252
|
+
re.DOTALL | re.IGNORECASE,
|
|
253
|
+
)
|
|
254
|
+
if match:
|
|
255
|
+
return match.group(1).strip()
|
|
256
|
+
return None
|
|
257
|
+
|
|
258
|
+
def _calculate_diff(
|
|
259
|
+
self, old_doc: str, new_doc: str
|
|
260
|
+
) -> tuple[int, int]:
|
|
261
|
+
"""Calculate characters added and removed using sequence matching.
|
|
262
|
+
|
|
263
|
+
Uses difflib to compute actual additions and removals, not just
|
|
264
|
+
length difference. This correctly handles replacements (e.g., 100
|
|
265
|
+
chars replaced with 100 different chars shows both adds and removes).
|
|
266
|
+
|
|
267
|
+
Args:
|
|
268
|
+
old_doc: Previous document content.
|
|
269
|
+
new_doc: New document content.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Tuple of (chars_added, chars_removed).
|
|
273
|
+
"""
|
|
274
|
+
if not old_doc and not new_doc:
|
|
275
|
+
return 0, 0
|
|
276
|
+
if not old_doc:
|
|
277
|
+
return len(new_doc), 0
|
|
278
|
+
if not new_doc:
|
|
279
|
+
return 0, len(old_doc)
|
|
280
|
+
|
|
281
|
+
old_lines = old_doc.splitlines(keepends=True)
|
|
282
|
+
new_lines = new_doc.splitlines(keepends=True)
|
|
283
|
+
|
|
284
|
+
chars_added = 0
|
|
285
|
+
chars_removed = 0
|
|
286
|
+
|
|
287
|
+
for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(
|
|
288
|
+
None, old_lines, new_lines
|
|
289
|
+
).get_opcodes():
|
|
290
|
+
if tag == "replace":
|
|
291
|
+
chars_removed += sum(len(line) for line in old_lines[i1:i2])
|
|
292
|
+
chars_added += sum(len(line) for line in new_lines[j1:j2])
|
|
293
|
+
elif tag == "delete":
|
|
294
|
+
chars_removed += sum(len(line) for line in old_lines[i1:i2])
|
|
295
|
+
elif tag == "insert":
|
|
296
|
+
chars_added += sum(len(line) for line in new_lines[j1:j2])
|
|
297
|
+
|
|
298
|
+
return chars_added, chars_removed
|
|
299
|
+
|
|
300
|
+
def _compute_unified_diff(self, old_doc: str, new_doc: str) -> str:
|
|
301
|
+
"""Compute a unified diff between old and new document content.
|
|
302
|
+
|
|
303
|
+
Args:
|
|
304
|
+
old_doc: Previous document content.
|
|
305
|
+
new_doc: New document content.
|
|
306
|
+
|
|
307
|
+
Returns:
|
|
308
|
+
Unified diff string.
|
|
309
|
+
"""
|
|
310
|
+
return "".join(
|
|
311
|
+
difflib.unified_diff(
|
|
312
|
+
old_doc.splitlines(keepends=True),
|
|
313
|
+
new_doc.splitlines(keepends=True),
|
|
314
|
+
fromfile="before",
|
|
315
|
+
tofile="after",
|
|
316
|
+
)
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def _resolve_doc_path(self) -> Path:
|
|
320
|
+
"""Resolve the design doc file path.
|
|
321
|
+
|
|
322
|
+
Uses the configured design_doc_path if set, otherwise defaults to
|
|
323
|
+
a session-specific file under .ralphx/resources/.
|
|
324
|
+
"""
|
|
325
|
+
if self.design_doc_path:
|
|
326
|
+
return Path(self.design_doc_path)
|
|
327
|
+
# Default: project/.ralphx/resources/design-doc-<session>.md
|
|
328
|
+
return Path(self.project.path) / ".ralphx" / "resources" / f"design-doc-iteration-{self.session_id}.md"
|
|
329
|
+
|
|
330
|
+
async def run(
|
|
331
|
+
self,
|
|
332
|
+
prompt: str,
|
|
333
|
+
iterations: int,
|
|
334
|
+
model: str = "opus",
|
|
335
|
+
tools: Optional[list[str]] = None,
|
|
336
|
+
) -> AsyncIterator[dict]:
|
|
337
|
+
"""Run the iteration loop.
|
|
338
|
+
|
|
339
|
+
Args:
|
|
340
|
+
prompt: User's guidance prompt.
|
|
341
|
+
iterations: Number of iterations to run.
|
|
342
|
+
model: Model to use (default: opus for design docs).
|
|
343
|
+
tools: Tools to enable (default: DEFAULT_ITERATION_TOOLS).
|
|
344
|
+
|
|
345
|
+
Yields:
|
|
346
|
+
SSE event dicts as execution progresses.
|
|
347
|
+
"""
|
|
348
|
+
if tools is None:
|
|
349
|
+
tools = DEFAULT_ITERATION_TOOLS
|
|
350
|
+
|
|
351
|
+
iterations = min(iterations, MAX_ITERATIONS)
|
|
352
|
+
|
|
353
|
+
# Resolve the file path Claude will edit directly
|
|
354
|
+
doc_file_path = self._resolve_doc_path()
|
|
355
|
+
|
|
356
|
+
# Update session status to running
|
|
357
|
+
self.pdb.update_planning_session(
|
|
358
|
+
self.session_id,
|
|
359
|
+
run_status="running",
|
|
360
|
+
current_iteration=0,
|
|
361
|
+
iterations_completed=0,
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
completed_iterations = 0
|
|
365
|
+
last_event_time = asyncio.get_event_loop().time()
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
for i in range(1, iterations + 1):
|
|
369
|
+
# Check for cancellation before each iteration
|
|
370
|
+
if await self._check_cancelled():
|
|
371
|
+
yield {
|
|
372
|
+
"type": SSEEventType.CANCELLED,
|
|
373
|
+
"iterations_completed": completed_iterations,
|
|
374
|
+
}
|
|
375
|
+
self.pdb.update_planning_session(
|
|
376
|
+
self.session_id,
|
|
377
|
+
run_status="cancelled",
|
|
378
|
+
iterations_completed=completed_iterations,
|
|
379
|
+
)
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
# Update current iteration
|
|
383
|
+
self.pdb.update_planning_session(
|
|
384
|
+
self.session_id,
|
|
385
|
+
current_iteration=i,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# Create iteration record
|
|
389
|
+
iteration_record = self.pdb.create_planning_iteration(
|
|
390
|
+
session_id=self.session_id,
|
|
391
|
+
iteration_number=i,
|
|
392
|
+
status="running",
|
|
393
|
+
)
|
|
394
|
+
iteration_id = iteration_record["id"] if iteration_record else None
|
|
395
|
+
if iteration_id:
|
|
396
|
+
self.pdb.start_planning_iteration(iteration_id)
|
|
397
|
+
|
|
398
|
+
yield {
|
|
399
|
+
"type": SSEEventType.ITERATION_START,
|
|
400
|
+
"iteration": i,
|
|
401
|
+
"total": iterations,
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
# Load current design doc and write to file for Claude to edit
|
|
405
|
+
old_doc = self._load_design_doc()
|
|
406
|
+
doc_file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
407
|
+
if old_doc:
|
|
408
|
+
doc_file_path.write_text(old_doc)
|
|
409
|
+
elif doc_file_path.exists():
|
|
410
|
+
old_doc = doc_file_path.read_text()
|
|
411
|
+
|
|
412
|
+
# Build iteration prompt (references file path, not doc content)
|
|
413
|
+
full_prompt = self._build_iteration_prompt(
|
|
414
|
+
prompt, str(doc_file_path), i, iterations
|
|
415
|
+
)
|
|
416
|
+
|
|
417
|
+
# Create adapter for this iteration
|
|
418
|
+
self._adapter = ClaudeCLIAdapter(
|
|
419
|
+
project_path=Path(self.project.path),
|
|
420
|
+
project_id=self.project_id,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# Stream Claude response
|
|
424
|
+
response_text = ""
|
|
425
|
+
tool_calls: list[dict] = []
|
|
426
|
+
error_message: Optional[str] = None
|
|
427
|
+
|
|
428
|
+
try:
|
|
429
|
+
async for event in self._adapter.stream(
|
|
430
|
+
prompt=full_prompt,
|
|
431
|
+
model=model,
|
|
432
|
+
tools=tools,
|
|
433
|
+
timeout=TIMEOUT_PER_ITERATION,
|
|
434
|
+
):
|
|
435
|
+
last_event_time = asyncio.get_event_loop().time()
|
|
436
|
+
|
|
437
|
+
if event.type == AdapterEvent.TEXT:
|
|
438
|
+
text = event.text or ""
|
|
439
|
+
response_text += text
|
|
440
|
+
yield {
|
|
441
|
+
"type": SSEEventType.CONTENT,
|
|
442
|
+
"text": text,
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
elif event.type == AdapterEvent.TOOL_USE:
|
|
446
|
+
tool_call = {
|
|
447
|
+
"tool": event.tool_name,
|
|
448
|
+
"input_preview": str(event.tool_input)[:100],
|
|
449
|
+
"start_time": datetime.utcnow().isoformat(),
|
|
450
|
+
}
|
|
451
|
+
tool_calls.append(tool_call)
|
|
452
|
+
yield {
|
|
453
|
+
"type": SSEEventType.TOOL_USE,
|
|
454
|
+
"tool": event.tool_name,
|
|
455
|
+
"input": event.tool_input,
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
elif event.type == AdapterEvent.TOOL_RESULT:
|
|
459
|
+
# Update the last tool call with result
|
|
460
|
+
if tool_calls:
|
|
461
|
+
tool_calls[-1]["duration_ms"] = 0 # Could calculate
|
|
462
|
+
result_preview = str(event.tool_result or "")[:200]
|
|
463
|
+
if len(str(event.tool_result or "")) > 200:
|
|
464
|
+
result_preview += "..."
|
|
465
|
+
yield {
|
|
466
|
+
"type": SSEEventType.TOOL_RESULT,
|
|
467
|
+
"tool": event.tool_name,
|
|
468
|
+
"result": result_preview,
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
elif event.type == AdapterEvent.ERROR:
|
|
472
|
+
error_message = event.error_message
|
|
473
|
+
break
|
|
474
|
+
|
|
475
|
+
elif event.type == AdapterEvent.COMPLETE:
|
|
476
|
+
break
|
|
477
|
+
|
|
478
|
+
except asyncio.TimeoutError:
|
|
479
|
+
error_message = f"Iteration {i} timed out"
|
|
480
|
+
logger.warning(f"Iteration {i} timed out after {TIMEOUT_PER_ITERATION}s")
|
|
481
|
+
|
|
482
|
+
except Exception as e:
|
|
483
|
+
error_message = f"Error in iteration {i}: {str(e)}"
|
|
484
|
+
logger.warning(f"Error in iteration {i}: {str(e)}", exc_info=True)
|
|
485
|
+
|
|
486
|
+
# Process iteration result
|
|
487
|
+
if error_message:
|
|
488
|
+
if iteration_id:
|
|
489
|
+
self.pdb.fail_planning_iteration(iteration_id, error_message)
|
|
490
|
+
yield {
|
|
491
|
+
"type": SSEEventType.ERROR,
|
|
492
|
+
"message": error_message,
|
|
493
|
+
"iteration": i,
|
|
494
|
+
}
|
|
495
|
+
# Continue to next iteration on non-fatal errors
|
|
496
|
+
continue
|
|
497
|
+
|
|
498
|
+
# Re-read the file to get Claude's edits
|
|
499
|
+
summary = self._extract_summary(response_text)
|
|
500
|
+
new_doc = ""
|
|
501
|
+
if doc_file_path.exists():
|
|
502
|
+
try:
|
|
503
|
+
new_doc = doc_file_path.read_text()
|
|
504
|
+
except Exception as e:
|
|
505
|
+
logger.warning(f"Failed to read design doc file: {e}")
|
|
506
|
+
|
|
507
|
+
if new_doc and new_doc != old_doc:
|
|
508
|
+
# Calculate diff
|
|
509
|
+
chars_added, chars_removed = self._calculate_diff(old_doc, new_doc)
|
|
510
|
+
diff_text = self._compute_unified_diff(old_doc or "", new_doc)
|
|
511
|
+
|
|
512
|
+
# Save the updated design doc to DB artifacts
|
|
513
|
+
self._save_design_doc(new_doc)
|
|
514
|
+
|
|
515
|
+
yield {
|
|
516
|
+
"type": SSEEventType.DESIGN_DOC_UPDATED,
|
|
517
|
+
"chars_added": chars_added,
|
|
518
|
+
"chars_removed": chars_removed,
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
# Complete iteration record
|
|
522
|
+
if iteration_id:
|
|
523
|
+
self.pdb.complete_planning_iteration(
|
|
524
|
+
iteration_id,
|
|
525
|
+
chars_added=chars_added,
|
|
526
|
+
chars_removed=chars_removed,
|
|
527
|
+
tool_calls=tool_calls[:10], # Limit stored tool calls
|
|
528
|
+
summary=summary,
|
|
529
|
+
diff_text=diff_text,
|
|
530
|
+
doc_before=old_doc or "",
|
|
531
|
+
doc_after=new_doc,
|
|
532
|
+
)
|
|
533
|
+
elif new_doc == old_doc and old_doc:
|
|
534
|
+
# File unchanged — Claude may not have edited it
|
|
535
|
+
# Still count as completed if no error occurred
|
|
536
|
+
if iteration_id:
|
|
537
|
+
self.pdb.complete_planning_iteration(
|
|
538
|
+
iteration_id,
|
|
539
|
+
chars_added=0,
|
|
540
|
+
chars_removed=0,
|
|
541
|
+
tool_calls=tool_calls[:10],
|
|
542
|
+
summary=summary or "No changes made",
|
|
543
|
+
diff_text="",
|
|
544
|
+
doc_before=old_doc or "",
|
|
545
|
+
doc_after=old_doc or "",
|
|
546
|
+
)
|
|
547
|
+
else:
|
|
548
|
+
# No doc file at all — fallback: try extracting from response text
|
|
549
|
+
fallback_doc = self._extract_design_doc(response_text)
|
|
550
|
+
if fallback_doc:
|
|
551
|
+
self._save_design_doc(fallback_doc)
|
|
552
|
+
doc_file_path.write_text(fallback_doc)
|
|
553
|
+
chars_added, chars_removed = self._calculate_diff(old_doc, fallback_doc)
|
|
554
|
+
diff_text = self._compute_unified_diff(old_doc or "", fallback_doc)
|
|
555
|
+
yield {
|
|
556
|
+
"type": SSEEventType.DESIGN_DOC_UPDATED,
|
|
557
|
+
"chars_added": chars_added,
|
|
558
|
+
"chars_removed": chars_removed,
|
|
559
|
+
}
|
|
560
|
+
if iteration_id:
|
|
561
|
+
self.pdb.complete_planning_iteration(
|
|
562
|
+
iteration_id,
|
|
563
|
+
chars_added=chars_added,
|
|
564
|
+
chars_removed=chars_removed,
|
|
565
|
+
tool_calls=tool_calls[:10],
|
|
566
|
+
summary=summary,
|
|
567
|
+
diff_text=diff_text,
|
|
568
|
+
doc_before=old_doc or "",
|
|
569
|
+
doc_after=fallback_doc,
|
|
570
|
+
)
|
|
571
|
+
else:
|
|
572
|
+
if iteration_id:
|
|
573
|
+
self.pdb.fail_planning_iteration(
|
|
574
|
+
iteration_id,
|
|
575
|
+
"No design doc changes detected",
|
|
576
|
+
)
|
|
577
|
+
yield {
|
|
578
|
+
"type": SSEEventType.ERROR,
|
|
579
|
+
"message": "No design doc changes detected",
|
|
580
|
+
"iteration": i,
|
|
581
|
+
}
|
|
582
|
+
continue
|
|
583
|
+
|
|
584
|
+
completed_iterations = i
|
|
585
|
+
|
|
586
|
+
yield {
|
|
587
|
+
"type": SSEEventType.ITERATION_COMPLETE,
|
|
588
|
+
"iteration": i,
|
|
589
|
+
"iteration_id": iteration_id,
|
|
590
|
+
"summary": summary or "Updated design document",
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
# Update session progress
|
|
594
|
+
self.pdb.update_planning_session(
|
|
595
|
+
self.session_id,
|
|
596
|
+
iterations_completed=completed_iterations,
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
# Cooldown between iterations (unless this is the last one)
|
|
600
|
+
if i < iterations:
|
|
601
|
+
await asyncio.sleep(COOLDOWN_BETWEEN_ITERATIONS)
|
|
602
|
+
|
|
603
|
+
# All iterations complete — mark run as completed but keep session active
|
|
604
|
+
# so user can review results and explicitly complete the planning step
|
|
605
|
+
self.pdb.update_planning_session(
|
|
606
|
+
self.session_id,
|
|
607
|
+
run_status="completed",
|
|
608
|
+
iterations_completed=completed_iterations,
|
|
609
|
+
)
|
|
610
|
+
|
|
611
|
+
yield {
|
|
612
|
+
"type": SSEEventType.DONE,
|
|
613
|
+
"iterations_completed": completed_iterations,
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
except Exception as e:
|
|
617
|
+
logger.error(f"Fatal error in iteration executor: {e}", exc_info=True)
|
|
618
|
+
self.pdb.update_planning_session(
|
|
619
|
+
self.session_id,
|
|
620
|
+
run_status="error",
|
|
621
|
+
error_message=str(e), # Internal DB record keeps full error
|
|
622
|
+
iterations_completed=completed_iterations,
|
|
623
|
+
)
|
|
624
|
+
yield {
|
|
625
|
+
"type": SSEEventType.ERROR,
|
|
626
|
+
"message": "Execution failed unexpectedly. Check server logs for details.",
|
|
627
|
+
"fatal": True,
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
async def stop(self) -> None:
|
|
631
|
+
"""Stop any running Claude process."""
|
|
632
|
+
if self._adapter:
|
|
633
|
+
await self._adapter.stop()
|