ralphx 0.3.5__py3-none-any.whl → 0.4.1__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 +18 -2
- ralphx/adapters/claude_cli.py +415 -350
- ralphx/api/routes/auth.py +105 -32
- ralphx/api/routes/items.py +4 -0
- ralphx/api/routes/loops.py +101 -15
- ralphx/api/routes/planning.py +866 -17
- ralphx/api/routes/resources.py +528 -6
- ralphx/api/routes/stream.py +161 -114
- ralphx/api/routes/templates.py +1 -0
- ralphx/api/routes/workflows.py +257 -25
- ralphx/core/auth.py +32 -7
- ralphx/core/checkpoint.py +118 -0
- ralphx/core/executor.py +292 -85
- ralphx/core/loop_templates.py +59 -14
- ralphx/core/planning_iteration_executor.py +633 -0
- ralphx/core/planning_service.py +11 -4
- ralphx/core/project_db.py +835 -85
- ralphx/core/resources.py +28 -2
- ralphx/core/session.py +62 -10
- ralphx/core/templates.py +74 -87
- ralphx/core/workflow_executor.py +35 -3
- ralphx/mcp/tools/diagnostics.py +1 -1
- ralphx/mcp/tools/monitoring.py +10 -16
- ralphx/mcp/tools/workflows.py +5 -5
- ralphx/models/loop.py +1 -1
- ralphx/models/session.py +5 -0
- ralphx/static/assets/index-DnihHetG.js +265 -0
- ralphx/static/assets/index-DnihHetG.js.map +1 -0
- ralphx/static/assets/index-nIDWmtzm.css +1 -0
- ralphx/static/index.html +2 -2
- ralphx/templates/loop_templates/consumer.md +2 -2
- {ralphx-0.3.5.dist-info → ralphx-0.4.1.dist-info}/METADATA +1 -1
- {ralphx-0.3.5.dist-info → ralphx-0.4.1.dist-info}/RECORD +36 -35
- ralphx/static/assets/index-0ovNnfOq.css +0 -1
- ralphx/static/assets/index-CY9s08ZB.js +0 -251
- ralphx/static/assets/index-CY9s08ZB.js.map +0 -1
- {ralphx-0.3.5.dist-info → ralphx-0.4.1.dist-info}/WHEEL +0 -0
- {ralphx-0.3.5.dist-info → ralphx-0.4.1.dist-info}/entry_points.txt +0 -0
ralphx/core/resources.py
CHANGED
|
@@ -225,6 +225,12 @@ class ResourceManager:
|
|
|
225
225
|
"""
|
|
226
226
|
file_path = self._resources_path / resource_data["file_path"]
|
|
227
227
|
|
|
228
|
+
# Security: verify path stays within resources directory
|
|
229
|
+
resolved = file_path.resolve()
|
|
230
|
+
resources_root = self._resources_path.resolve()
|
|
231
|
+
if not str(resolved).startswith(str(resources_root) + "/") and resolved != resources_root:
|
|
232
|
+
return None
|
|
233
|
+
|
|
228
234
|
if not file_path.exists():
|
|
229
235
|
return None
|
|
230
236
|
|
|
@@ -426,8 +432,19 @@ class ResourceManager:
|
|
|
426
432
|
else:
|
|
427
433
|
file_name = name
|
|
428
434
|
|
|
435
|
+
# Security: prevent path traversal in resource names
|
|
436
|
+
# Only allow simple filenames (alphanumeric, hyphens, underscores, dots)
|
|
437
|
+
if ".." in file_name or "/" in file_name or "\\" in file_name or "\0" in file_name:
|
|
438
|
+
raise ValueError(f"Invalid resource name: {name} (path traversal characters not allowed)")
|
|
439
|
+
|
|
429
440
|
file_path = self.get_resources_path(resource_type) / f"{file_name}.md"
|
|
430
441
|
|
|
442
|
+
# Verify resolved path stays within resources directory
|
|
443
|
+
resolved = file_path.resolve()
|
|
444
|
+
resources_root = self.get_resources_path(resource_type).resolve()
|
|
445
|
+
if not str(resolved).startswith(str(resources_root) + "/") and resolved != resources_root:
|
|
446
|
+
raise ValueError(f"Invalid resource name: {name} (resolves outside resources directory)")
|
|
447
|
+
|
|
431
448
|
# Ensure directory exists
|
|
432
449
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
433
450
|
|
|
@@ -483,6 +500,11 @@ class ResourceManager:
|
|
|
483
500
|
# Update file content if provided
|
|
484
501
|
if content is not None:
|
|
485
502
|
file_path = self._resources_path / resource["file_path"]
|
|
503
|
+
# Security: verify path stays within resources directory
|
|
504
|
+
resolved = file_path.resolve()
|
|
505
|
+
resources_root = self._resources_path.resolve()
|
|
506
|
+
if not str(resolved).startswith(str(resources_root) + "/") and resolved != resources_root:
|
|
507
|
+
return False
|
|
486
508
|
file_path.parent.mkdir(parents=True, exist_ok=True)
|
|
487
509
|
file_path.write_text(content, encoding="utf-8")
|
|
488
510
|
|
|
@@ -519,8 +541,12 @@ class ResourceManager:
|
|
|
519
541
|
# Delete file if requested
|
|
520
542
|
if delete_file:
|
|
521
543
|
file_path = self._resources_path / resource["file_path"]
|
|
522
|
-
|
|
523
|
-
|
|
544
|
+
# Security: verify path stays within resources directory
|
|
545
|
+
resolved = file_path.resolve()
|
|
546
|
+
resources_root = self._resources_path.resolve()
|
|
547
|
+
if str(resolved).startswith(str(resources_root) + "/") or resolved == resources_root:
|
|
548
|
+
if file_path.exists():
|
|
549
|
+
file_path.unlink()
|
|
524
550
|
|
|
525
551
|
# Delete database entry
|
|
526
552
|
return self.db.delete_resource(resource_id)
|
ralphx/core/session.py
CHANGED
|
@@ -27,6 +27,8 @@ class SessionEventType(str, Enum):
|
|
|
27
27
|
TEXT = "text"
|
|
28
28
|
TOOL_CALL = "tool_call"
|
|
29
29
|
TOOL_RESULT = "tool_result"
|
|
30
|
+
THINKING = "thinking"
|
|
31
|
+
USAGE = "usage"
|
|
30
32
|
ERROR = "error"
|
|
31
33
|
COMPLETE = "complete"
|
|
32
34
|
UNKNOWN = "unknown"
|
|
@@ -42,6 +44,8 @@ class SessionEvent:
|
|
|
42
44
|
tool_name: Optional[str] = None
|
|
43
45
|
tool_input: Optional[dict] = None
|
|
44
46
|
tool_result: Optional[str] = None
|
|
47
|
+
thinking: Optional[str] = None
|
|
48
|
+
usage: Optional[dict] = None
|
|
45
49
|
error_message: Optional[str] = None
|
|
46
50
|
raw_data: dict = field(default_factory=dict)
|
|
47
51
|
|
|
@@ -327,6 +331,9 @@ class SessionTailer:
|
|
|
327
331
|
def _parse_event(self, data: dict) -> SessionEvent:
|
|
328
332
|
"""Parse event data into a SessionEvent.
|
|
329
333
|
|
|
334
|
+
Handles both stream-json format (content_block_delta) and
|
|
335
|
+
session JSONL format (assistant/user message pairs).
|
|
336
|
+
|
|
330
337
|
Args:
|
|
331
338
|
data: Parsed JSON data.
|
|
332
339
|
|
|
@@ -335,14 +342,21 @@ class SessionTailer:
|
|
|
335
342
|
"""
|
|
336
343
|
msg_type = data.get("type", "")
|
|
337
344
|
|
|
338
|
-
#
|
|
339
|
-
if msg_type == "
|
|
345
|
+
# Session JSONL: queue-operation is the first line with sessionId
|
|
346
|
+
if msg_type == "queue-operation":
|
|
347
|
+
return SessionEvent(
|
|
348
|
+
type=SessionEventType.INIT,
|
|
349
|
+
raw_data=data,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
# Stream-json: init event
|
|
353
|
+
if msg_type == "init" or (msg_type == "system" and "session_id" in data):
|
|
340
354
|
return SessionEvent(
|
|
341
355
|
type=SessionEventType.INIT,
|
|
342
356
|
raw_data=data,
|
|
343
357
|
)
|
|
344
358
|
|
|
345
|
-
#
|
|
359
|
+
# Stream-json: text content delta
|
|
346
360
|
if msg_type == "content_block_delta":
|
|
347
361
|
delta = data.get("delta", {})
|
|
348
362
|
if delta.get("type") == "text_delta":
|
|
@@ -352,7 +366,7 @@ class SessionTailer:
|
|
|
352
366
|
raw_data=data,
|
|
353
367
|
)
|
|
354
368
|
|
|
355
|
-
#
|
|
369
|
+
# Stream-json: tool call start
|
|
356
370
|
if msg_type == "content_block_start":
|
|
357
371
|
content = data.get("content_block", {})
|
|
358
372
|
if content.get("type") == "tool_use":
|
|
@@ -363,7 +377,7 @@ class SessionTailer:
|
|
|
363
377
|
raw_data=data,
|
|
364
378
|
)
|
|
365
379
|
|
|
366
|
-
#
|
|
380
|
+
# Stream-json: tool result
|
|
367
381
|
if msg_type == "tool_result":
|
|
368
382
|
return SessionEvent(
|
|
369
383
|
type=SessionEventType.TOOL_RESULT,
|
|
@@ -380,31 +394,69 @@ class SessionTailer:
|
|
|
380
394
|
raw_data=data,
|
|
381
395
|
)
|
|
382
396
|
|
|
383
|
-
#
|
|
397
|
+
# Stream-json: message complete
|
|
384
398
|
if msg_type == "message_stop":
|
|
385
399
|
return SessionEvent(
|
|
386
400
|
type=SessionEventType.COMPLETE,
|
|
387
401
|
raw_data=data,
|
|
388
402
|
)
|
|
389
403
|
|
|
390
|
-
#
|
|
391
|
-
# Claude Code session format: content is at data["message"]["content"]
|
|
404
|
+
# Session JSONL: assistant message with content array
|
|
392
405
|
if msg_type == "assistant":
|
|
393
406
|
message = data.get("message", {})
|
|
394
407
|
content = message.get("content") if message else data.get("content")
|
|
408
|
+
usage = message.get("usage") if message else None
|
|
409
|
+
is_error = data.get("isApiErrorMessage", False)
|
|
410
|
+
|
|
411
|
+
if is_error:
|
|
412
|
+
error_text = ""
|
|
413
|
+
if isinstance(content, list):
|
|
414
|
+
for block in content:
|
|
415
|
+
if block.get("type") == "text":
|
|
416
|
+
error_text = block.get("text", "")
|
|
417
|
+
return SessionEvent(
|
|
418
|
+
type=SessionEventType.ERROR,
|
|
419
|
+
error_message=error_text or data.get("error", "API error"),
|
|
420
|
+
raw_data=data,
|
|
421
|
+
)
|
|
422
|
+
|
|
395
423
|
if isinstance(content, list):
|
|
424
|
+
# Return the first meaningful event (text > tool_use > thinking)
|
|
396
425
|
for block in content:
|
|
397
|
-
|
|
426
|
+
block_type = block.get("type")
|
|
427
|
+
if block_type == "text":
|
|
398
428
|
return SessionEvent(
|
|
399
429
|
type=SessionEventType.TEXT,
|
|
400
430
|
text=block.get("text", ""),
|
|
431
|
+
usage=usage,
|
|
401
432
|
raw_data=data,
|
|
402
433
|
)
|
|
403
|
-
if
|
|
434
|
+
if block_type == "tool_use":
|
|
404
435
|
return SessionEvent(
|
|
405
436
|
type=SessionEventType.TOOL_CALL,
|
|
406
437
|
tool_name=block.get("name"),
|
|
407
438
|
tool_input=block.get("input", {}),
|
|
439
|
+
usage=usage,
|
|
440
|
+
raw_data=data,
|
|
441
|
+
)
|
|
442
|
+
if block_type == "thinking":
|
|
443
|
+
return SessionEvent(
|
|
444
|
+
type=SessionEventType.THINKING,
|
|
445
|
+
thinking=block.get("thinking", ""),
|
|
446
|
+
usage=usage,
|
|
447
|
+
raw_data=data,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
# Session JSONL: user message with tool results
|
|
451
|
+
if msg_type == "user":
|
|
452
|
+
message = data.get("message", {})
|
|
453
|
+
content = message.get("content", [])
|
|
454
|
+
if isinstance(content, list):
|
|
455
|
+
for block in content:
|
|
456
|
+
if block.get("type") == "tool_result":
|
|
457
|
+
return SessionEvent(
|
|
458
|
+
type=SessionEventType.TOOL_RESULT,
|
|
459
|
+
tool_result=str(block.get("content", ""))[:1000],
|
|
408
460
|
raw_data=data,
|
|
409
461
|
)
|
|
410
462
|
|
ralphx/core/templates.py
CHANGED
|
@@ -1,25 +1,70 @@
|
|
|
1
1
|
"""
|
|
2
2
|
Base loop templates for quick-start configuration.
|
|
3
3
|
|
|
4
|
+
Templates map 1:1 to workflow step processing types:
|
|
5
|
+
- design_doc: Interactive planning with web research
|
|
6
|
+
- extractgen_requirements: Extract user stories from design documents
|
|
7
|
+
- webgen_requirements: Discover requirements via web research
|
|
8
|
+
- implementation: Implement user stories with code changes
|
|
9
|
+
|
|
4
10
|
Templates are global, read-only, and shipped with RalphX.
|
|
5
11
|
Users can copy template config into their loop, then modify as needed.
|
|
6
12
|
"""
|
|
7
13
|
|
|
8
14
|
from typing import Optional
|
|
9
15
|
|
|
10
|
-
# Base loop templates
|
|
16
|
+
# Base loop templates — one per processing_type
|
|
11
17
|
TEMPLATES: dict[str, dict] = {
|
|
18
|
+
"design_doc": {
|
|
19
|
+
"name": "design_doc",
|
|
20
|
+
"display_name": "Design Document",
|
|
21
|
+
"description": "Interactive planning chat to build a design document with web research",
|
|
22
|
+
"type": "generator",
|
|
23
|
+
"category": "planning",
|
|
24
|
+
"default_tools": ["Read", "Glob", "Grep", "WebSearch", "WebFetch"],
|
|
25
|
+
"config": {
|
|
26
|
+
"name": "design_doc",
|
|
27
|
+
"display_name": "Design Document",
|
|
28
|
+
"type": "generator",
|
|
29
|
+
"description": "Build a comprehensive design document through interactive planning",
|
|
30
|
+
"item_types": {
|
|
31
|
+
"output": {
|
|
32
|
+
"singular": "artifact",
|
|
33
|
+
"plural": "artifacts",
|
|
34
|
+
"description": "Design document and guardrails",
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
"modes": [
|
|
38
|
+
{
|
|
39
|
+
"name": "default",
|
|
40
|
+
"description": "Interactive planning with web research",
|
|
41
|
+
"model": "claude-sonnet-4-20250514",
|
|
42
|
+
"timeout": 300,
|
|
43
|
+
"tools": ["Read", "Glob", "Grep", "WebSearch", "WebFetch"],
|
|
44
|
+
"prompt_template": "prompts/planning.md",
|
|
45
|
+
}
|
|
46
|
+
],
|
|
47
|
+
"mode_selection": {"strategy": "fixed", "fixed_mode": "default"},
|
|
48
|
+
"limits": {
|
|
49
|
+
"max_iterations": 100,
|
|
50
|
+
"max_runtime_seconds": 28800,
|
|
51
|
+
"max_consecutive_errors": 5,
|
|
52
|
+
"cooldown_between_iterations": 5,
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
12
56
|
"extractgen_requirements": {
|
|
13
57
|
"name": "extractgen_requirements",
|
|
14
|
-
"display_name": "Extract Requirements
|
|
15
|
-
"description": "
|
|
58
|
+
"display_name": "Extract Requirements",
|
|
59
|
+
"description": "Extract user stories from design documents",
|
|
16
60
|
"type": "generator",
|
|
17
61
|
"category": "discovery",
|
|
62
|
+
"default_tools": ["Read", "Glob", "Grep"],
|
|
18
63
|
"config": {
|
|
19
64
|
"name": "extractgen_requirements",
|
|
20
|
-
"display_name": "Extract Requirements
|
|
65
|
+
"display_name": "Extract Requirements",
|
|
21
66
|
"type": "generator",
|
|
22
|
-
"description": "
|
|
67
|
+
"description": "Extract and generate user stories from design documents",
|
|
23
68
|
"item_types": {
|
|
24
69
|
"output": {
|
|
25
70
|
"singular": "story",
|
|
@@ -33,7 +78,7 @@ TEMPLATES: dict[str, dict] = {
|
|
|
33
78
|
"description": "Fast extraction from design docs (no web search)",
|
|
34
79
|
"model": "claude-sonnet-4-20250514",
|
|
35
80
|
"timeout": 180,
|
|
36
|
-
"tools": [],
|
|
81
|
+
"tools": ["Read", "Glob", "Grep"],
|
|
37
82
|
"prompt_template": "prompts/extractgen_requirements_turbo.md",
|
|
38
83
|
},
|
|
39
84
|
{
|
|
@@ -41,7 +86,7 @@ TEMPLATES: dict[str, dict] = {
|
|
|
41
86
|
"description": "Thorough web research for best practices",
|
|
42
87
|
"model": "claude-sonnet-4-20250514",
|
|
43
88
|
"timeout": 900,
|
|
44
|
-
"tools": ["
|
|
89
|
+
"tools": ["Read", "Glob", "Grep", "WebSearch", "WebFetch"],
|
|
45
90
|
"prompt_template": "prompts/extractgen_requirements_deep.md",
|
|
46
91
|
},
|
|
47
92
|
],
|
|
@@ -63,6 +108,7 @@ TEMPLATES: dict[str, dict] = {
|
|
|
63
108
|
"description": "Discover missing requirements through web research on domain best practices",
|
|
64
109
|
"type": "generator",
|
|
65
110
|
"category": "discovery",
|
|
111
|
+
"default_tools": ["Read", "Glob", "Grep", "WebSearch", "WebFetch"],
|
|
66
112
|
"config": {
|
|
67
113
|
"name": "webgen_requirements",
|
|
68
114
|
"display_name": "Web-Generated Requirements",
|
|
@@ -81,7 +127,7 @@ TEMPLATES: dict[str, dict] = {
|
|
|
81
127
|
"description": "Web research for best practices and gaps",
|
|
82
128
|
"model": "claude-sonnet-4-20250514",
|
|
83
129
|
"timeout": 900,
|
|
84
|
-
"tools": ["
|
|
130
|
+
"tools": ["Read", "Glob", "Grep", "WebSearch", "WebFetch"],
|
|
85
131
|
"prompt_template": "prompts/webgen_requirements.md",
|
|
86
132
|
}
|
|
87
133
|
],
|
|
@@ -96,13 +142,14 @@ TEMPLATES: dict[str, dict] = {
|
|
|
96
142
|
},
|
|
97
143
|
"implementation": {
|
|
98
144
|
"name": "implementation",
|
|
99
|
-
"display_name": "Implementation
|
|
145
|
+
"display_name": "Implementation",
|
|
100
146
|
"description": "Implement user stories one at a time with test verification",
|
|
101
147
|
"type": "consumer",
|
|
102
148
|
"category": "execution",
|
|
149
|
+
"default_tools": ["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
|
|
103
150
|
"config": {
|
|
104
151
|
"name": "implementation",
|
|
105
|
-
"display_name": "Implementation
|
|
152
|
+
"display_name": "Implementation",
|
|
106
153
|
"type": "consumer",
|
|
107
154
|
"description": "Implement user stories with automated testing",
|
|
108
155
|
"item_types": {
|
|
@@ -124,7 +171,7 @@ TEMPLATES: dict[str, dict] = {
|
|
|
124
171
|
"description": "Implement one story per iteration",
|
|
125
172
|
"model": "claude-sonnet-4-20250514",
|
|
126
173
|
"timeout": 1800,
|
|
127
|
-
"tools": ["
|
|
174
|
+
"tools": ["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
|
|
128
175
|
"prompt_template": "prompts/implementation.md",
|
|
129
176
|
}
|
|
130
177
|
],
|
|
@@ -136,82 +183,6 @@ TEMPLATES: dict[str, dict] = {
|
|
|
136
183
|
},
|
|
137
184
|
},
|
|
138
185
|
},
|
|
139
|
-
"simple_generator": {
|
|
140
|
-
"name": "simple_generator",
|
|
141
|
-
"display_name": "Simple Generator",
|
|
142
|
-
"description": "Basic content generation loop for creating items",
|
|
143
|
-
"type": "generator",
|
|
144
|
-
"category": "generation",
|
|
145
|
-
"config": {
|
|
146
|
-
"name": "generator",
|
|
147
|
-
"display_name": "Content Generator",
|
|
148
|
-
"type": "generator",
|
|
149
|
-
"description": "Generate content items in a loop",
|
|
150
|
-
"item_types": {
|
|
151
|
-
"output": {
|
|
152
|
-
"singular": "item",
|
|
153
|
-
"plural": "items",
|
|
154
|
-
"description": "Generated content",
|
|
155
|
-
}
|
|
156
|
-
},
|
|
157
|
-
"modes": [
|
|
158
|
-
{
|
|
159
|
-
"name": "default",
|
|
160
|
-
"description": "Generate content",
|
|
161
|
-
"model": "claude-sonnet-4-20250514",
|
|
162
|
-
"timeout": 300,
|
|
163
|
-
"tools": [],
|
|
164
|
-
"prompt_template": "prompts/generate.md",
|
|
165
|
-
}
|
|
166
|
-
],
|
|
167
|
-
"mode_selection": {"strategy": "fixed", "fixed_mode": "default"},
|
|
168
|
-
"limits": {
|
|
169
|
-
"max_iterations": 10,
|
|
170
|
-
"max_consecutive_errors": 3,
|
|
171
|
-
},
|
|
172
|
-
},
|
|
173
|
-
},
|
|
174
|
-
"reviewer": {
|
|
175
|
-
"name": "reviewer",
|
|
176
|
-
"display_name": "Review Loop",
|
|
177
|
-
"description": "Process and review existing items (validate, transform, enhance)",
|
|
178
|
-
"type": "consumer",
|
|
179
|
-
"category": "processing",
|
|
180
|
-
"config": {
|
|
181
|
-
"name": "reviewer",
|
|
182
|
-
"display_name": "Review Loop",
|
|
183
|
-
"type": "consumer",
|
|
184
|
-
"description": "Review and validate existing items",
|
|
185
|
-
"item_types": {
|
|
186
|
-
"input": {
|
|
187
|
-
"singular": "item",
|
|
188
|
-
"plural": "items",
|
|
189
|
-
"source": "generator",
|
|
190
|
-
"description": "Items to review",
|
|
191
|
-
},
|
|
192
|
-
"output": {
|
|
193
|
-
"singular": "review",
|
|
194
|
-
"plural": "reviews",
|
|
195
|
-
"description": "Review results and recommendations",
|
|
196
|
-
},
|
|
197
|
-
},
|
|
198
|
-
"modes": [
|
|
199
|
-
{
|
|
200
|
-
"name": "default",
|
|
201
|
-
"description": "Review one item per iteration",
|
|
202
|
-
"model": "claude-sonnet-4-20250514",
|
|
203
|
-
"timeout": 300,
|
|
204
|
-
"tools": [],
|
|
205
|
-
"prompt_template": "prompts/review.md",
|
|
206
|
-
}
|
|
207
|
-
],
|
|
208
|
-
"mode_selection": {"strategy": "fixed", "fixed_mode": "default"},
|
|
209
|
-
"limits": {
|
|
210
|
-
"max_iterations": 0, # Process all items
|
|
211
|
-
"max_consecutive_errors": 5,
|
|
212
|
-
},
|
|
213
|
-
},
|
|
214
|
-
},
|
|
215
186
|
}
|
|
216
187
|
|
|
217
188
|
|
|
@@ -227,6 +198,21 @@ def get_template(name: str) -> Optional[dict]:
|
|
|
227
198
|
return TEMPLATES.get(name)
|
|
228
199
|
|
|
229
200
|
|
|
201
|
+
def get_default_tools(processing_type: str) -> Optional[list[str]]:
|
|
202
|
+
"""Get default tools for a processing type.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
processing_type: Step processing type (design_doc, extractgen_requirements, etc.)
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
List of tool names, or None if not found.
|
|
209
|
+
"""
|
|
210
|
+
template = TEMPLATES.get(processing_type)
|
|
211
|
+
if template:
|
|
212
|
+
return template.get("default_tools")
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
230
216
|
def list_templates() -> list[dict]:
|
|
231
217
|
"""List all available templates.
|
|
232
218
|
|
|
@@ -240,6 +226,7 @@ def list_templates() -> list[dict]:
|
|
|
240
226
|
"description": t["description"],
|
|
241
227
|
"type": t["type"],
|
|
242
228
|
"category": t["category"],
|
|
229
|
+
"default_tools": t.get("default_tools"),
|
|
243
230
|
}
|
|
244
231
|
for t in TEMPLATES.values()
|
|
245
232
|
]
|
ralphx/core/workflow_executor.py
CHANGED
|
@@ -6,10 +6,13 @@ step transitions.
|
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
8
|
import asyncio
|
|
9
|
+
import logging
|
|
9
10
|
import uuid
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import Any, Callable, Optional
|
|
12
13
|
|
|
14
|
+
_logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
13
16
|
from ralphx.core.executor import LoopExecutor
|
|
14
17
|
from ralphx.core.loop import LoopLoader
|
|
15
18
|
from ralphx.core.project import Project
|
|
@@ -50,6 +53,7 @@ class WorkflowExecutor:
|
|
|
50
53
|
self.workflow_id = workflow_id
|
|
51
54
|
self._on_step_change = on_step_change
|
|
52
55
|
self._running_executors: dict[int, LoopExecutor] = {}
|
|
56
|
+
self._running_tasks: dict[int, asyncio.Task] = {}
|
|
53
57
|
|
|
54
58
|
def get_workflow(self) -> Optional[dict]:
|
|
55
59
|
"""Get the current workflow."""
|
|
@@ -161,6 +165,9 @@ class WorkflowExecutor:
|
|
|
161
165
|
# Create new loop for this step
|
|
162
166
|
loop_config = self._create_step_loop(step, loop_name, loop_type)
|
|
163
167
|
|
|
168
|
+
# Save loop_name to the step record so the UI can find logs
|
|
169
|
+
self.db.update_workflow_step(step_id, loop_name=loop_name)
|
|
170
|
+
|
|
164
171
|
if not loop_config:
|
|
165
172
|
raise ValueError(f"Failed to create loop config for step {step_id}")
|
|
166
173
|
|
|
@@ -188,6 +195,9 @@ class WorkflowExecutor:
|
|
|
188
195
|
# Check if architecture-first mode is enabled
|
|
189
196
|
architecture_first = step_config.get("architecture_first", False)
|
|
190
197
|
|
|
198
|
+
# Get cross-step context links (for generator loops)
|
|
199
|
+
context_from_steps = step_config.get("context_from_steps") or []
|
|
200
|
+
|
|
191
201
|
# Create and start executor
|
|
192
202
|
executor = LoopExecutor(
|
|
193
203
|
project=self.project,
|
|
@@ -197,12 +207,14 @@ class WorkflowExecutor:
|
|
|
197
207
|
step_id=step_id,
|
|
198
208
|
consume_from_step_id=consume_from_step_id,
|
|
199
209
|
architecture_first=architecture_first,
|
|
210
|
+
context_from_steps=context_from_steps,
|
|
200
211
|
)
|
|
201
212
|
|
|
202
213
|
self._running_executors[step_id] = executor
|
|
203
214
|
|
|
204
|
-
# Run executor in background
|
|
205
|
-
asyncio.create_task(self._run_loop_and_advance(executor, step))
|
|
215
|
+
# Run executor in background (store task reference to prevent GC and enable cancellation)
|
|
216
|
+
task = asyncio.create_task(self._run_loop_and_advance(executor, step))
|
|
217
|
+
self._running_tasks[step_id] = task
|
|
206
218
|
|
|
207
219
|
async def _run_loop_and_advance(
|
|
208
220
|
self, executor: LoopExecutor, step: dict
|
|
@@ -241,8 +253,25 @@ class WorkflowExecutor:
|
|
|
241
253
|
if auto_advance:
|
|
242
254
|
await self.complete_step(step_id)
|
|
243
255
|
|
|
256
|
+
except Exception:
|
|
257
|
+
_logger.exception(
|
|
258
|
+
"Unhandled exception in background loop execution for step %s",
|
|
259
|
+
step_id,
|
|
260
|
+
)
|
|
261
|
+
# Mark step as failed so the error is visible in the UI
|
|
262
|
+
try:
|
|
263
|
+
self.db.update_workflow_step(
|
|
264
|
+
step_id,
|
|
265
|
+
status="error",
|
|
266
|
+
artifacts={"error": "Unexpected executor crash - check logs"},
|
|
267
|
+
)
|
|
268
|
+
self._emit_step_change(step["step_number"], "error")
|
|
269
|
+
except Exception:
|
|
270
|
+
_logger.exception("Failed to mark step %s as errored", step_id)
|
|
271
|
+
|
|
244
272
|
finally:
|
|
245
273
|
self._running_executors.pop(step_id, None)
|
|
274
|
+
self._running_tasks.pop(step_id, None)
|
|
246
275
|
|
|
247
276
|
def _create_step_loop(
|
|
248
277
|
self, step: dict, loop_name: str, loop_type: str
|
|
@@ -268,6 +297,7 @@ class WorkflowExecutor:
|
|
|
268
297
|
max_iterations = step_config.get("max_iterations")
|
|
269
298
|
cooldown = step_config.get("cooldown_between_iterations")
|
|
270
299
|
max_errors = step_config.get("max_consecutive_errors")
|
|
300
|
+
tools = step_config.get("allowedTools")
|
|
271
301
|
|
|
272
302
|
# Generate YAML config based on loop type
|
|
273
303
|
if loop_type == "generator":
|
|
@@ -278,6 +308,7 @@ class WorkflowExecutor:
|
|
|
278
308
|
max_iterations=max_iterations,
|
|
279
309
|
cooldown_between_iterations=cooldown,
|
|
280
310
|
max_consecutive_errors=max_errors,
|
|
311
|
+
tools=tools,
|
|
281
312
|
)
|
|
282
313
|
else:
|
|
283
314
|
config_yaml = generate_simple_implementation_config(
|
|
@@ -287,6 +318,7 @@ class WorkflowExecutor:
|
|
|
287
318
|
max_iterations=max_iterations,
|
|
288
319
|
cooldown_between_iterations=cooldown,
|
|
289
320
|
max_consecutive_errors=max_errors,
|
|
321
|
+
tools=tools,
|
|
290
322
|
)
|
|
291
323
|
|
|
292
324
|
# Save to database
|
|
@@ -610,7 +642,7 @@ class WorkflowExecutor:
|
|
|
610
642
|
|
|
611
643
|
# Stop running executors
|
|
612
644
|
for executor in self._running_executors.values():
|
|
613
|
-
executor.stop()
|
|
645
|
+
await executor.stop()
|
|
614
646
|
|
|
615
647
|
self.db.update_workflow(self.workflow_id, status="paused")
|
|
616
648
|
return self.get_workflow()
|
ralphx/mcp/tools/diagnostics.py
CHANGED
|
@@ -243,7 +243,7 @@ def get_stop_reason(
|
|
|
243
243
|
if sessions:
|
|
244
244
|
last_session = sessions[-1]
|
|
245
245
|
events = project_db.get_session_events(
|
|
246
|
-
session_id=last_session
|
|
246
|
+
session_id=last_session.get("session_id", last_session.get("id")),
|
|
247
247
|
event_type="error",
|
|
248
248
|
limit=5,
|
|
249
249
|
)
|
ralphx/mcp/tools/monitoring.py
CHANGED
|
@@ -73,8 +73,8 @@ def list_runs(
|
|
|
73
73
|
"workflow_id": r.get("workflow_id"),
|
|
74
74
|
"step_id": r.get("step_id"),
|
|
75
75
|
"status": r["status"],
|
|
76
|
-
"
|
|
77
|
-
"
|
|
76
|
+
"iterations_completed": r.get("iterations_completed", 0),
|
|
77
|
+
"items_generated": r.get("items_generated", 0),
|
|
78
78
|
"executor_pid": r.get("executor_pid"),
|
|
79
79
|
"started_at": r.get("started_at"),
|
|
80
80
|
"completed_at": r.get("completed_at"),
|
|
@@ -117,8 +117,8 @@ def get_run(
|
|
|
117
117
|
"workflow_id": run.get("workflow_id"),
|
|
118
118
|
"step_id": run.get("step_id"),
|
|
119
119
|
"status": run["status"],
|
|
120
|
-
"
|
|
121
|
-
"
|
|
120
|
+
"iterations_completed": run.get("iterations_completed", 0),
|
|
121
|
+
"items_generated": run.get("items_generated", 0),
|
|
122
122
|
"executor_pid": run.get("executor_pid"),
|
|
123
123
|
"started_at": run.get("started_at"),
|
|
124
124
|
"completed_at": run.get("completed_at"),
|
|
@@ -130,11 +130,11 @@ def get_run(
|
|
|
130
130
|
sessions = project_db.list_sessions(run_id=run_id)
|
|
131
131
|
result["sessions"] = [
|
|
132
132
|
{
|
|
133
|
-
"id": s
|
|
133
|
+
"id": s.get("session_id", s.get("id")),
|
|
134
|
+
"iteration": s.get("iteration"),
|
|
134
135
|
"status": s.get("status"),
|
|
135
136
|
"started_at": s.get("started_at"),
|
|
136
|
-
"
|
|
137
|
-
"event_count": s.get("event_count", 0),
|
|
137
|
+
"duration_seconds": s.get("duration_seconds"),
|
|
138
138
|
}
|
|
139
139
|
for s in sessions
|
|
140
140
|
]
|
|
@@ -146,7 +146,6 @@ def get_run(
|
|
|
146
146
|
def get_logs(
|
|
147
147
|
slug: str,
|
|
148
148
|
level: Optional[str] = None,
|
|
149
|
-
category: Optional[str] = None,
|
|
150
149
|
run_id: Optional[str] = None,
|
|
151
150
|
session_id: Optional[str] = None,
|
|
152
151
|
search: Optional[str] = None,
|
|
@@ -170,7 +169,6 @@ def get_logs(
|
|
|
170
169
|
try:
|
|
171
170
|
logs, total = project_db.get_logs(
|
|
172
171
|
level=level,
|
|
173
|
-
category=category,
|
|
174
172
|
run_id=run_id,
|
|
175
173
|
session_id=session_id,
|
|
176
174
|
search=search,
|
|
@@ -190,10 +188,8 @@ def get_logs(
|
|
|
190
188
|
"id": log.get("id"),
|
|
191
189
|
"timestamp": log.get("timestamp"),
|
|
192
190
|
"level": log.get("level"),
|
|
193
|
-
"category": log.get("category"),
|
|
194
191
|
"message": scrub_sensitive_data(log.get("message", "")),
|
|
195
192
|
"run_id": log.get("run_id"),
|
|
196
|
-
"session_id": log.get("session_id"),
|
|
197
193
|
}
|
|
198
194
|
for log in logs
|
|
199
195
|
],
|
|
@@ -311,13 +307,12 @@ def list_sessions(
|
|
|
311
307
|
return PaginatedResult(
|
|
312
308
|
items=[
|
|
313
309
|
{
|
|
314
|
-
"id": s
|
|
310
|
+
"id": s.get("session_id", s.get("id")),
|
|
315
311
|
"run_id": s.get("run_id"),
|
|
312
|
+
"iteration": s.get("iteration"),
|
|
316
313
|
"status": s.get("status"),
|
|
317
314
|
"started_at": s.get("started_at"),
|
|
318
|
-
"
|
|
319
|
-
"event_count": s.get("event_count", 0),
|
|
320
|
-
"item_id": s.get("item_id"),
|
|
315
|
+
"duration_seconds": s.get("duration_seconds"),
|
|
321
316
|
}
|
|
322
317
|
for s in paginated
|
|
323
318
|
],
|
|
@@ -432,7 +427,6 @@ def get_monitoring_tools() -> list[ToolDefinition]:
|
|
|
432
427
|
properties={
|
|
433
428
|
"slug": prop_string("Project slug"),
|
|
434
429
|
"level": prop_enum("Filter by log level", ["debug", "info", "warning", "error"]),
|
|
435
|
-
"category": prop_string("Filter by category"),
|
|
436
430
|
"run_id": prop_string("Filter by run ID"),
|
|
437
431
|
"session_id": prop_string("Filter by session ID"),
|
|
438
432
|
"search": prop_string("Search text in messages"),
|