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.
Files changed (48) hide show
  1. ralphx/__init__.py +1 -1
  2. ralphx/adapters/base.py +10 -2
  3. ralphx/adapters/claude_cli.py +222 -82
  4. ralphx/api/routes/auth.py +780 -98
  5. ralphx/api/routes/config.py +3 -56
  6. ralphx/api/routes/export_import.py +6 -9
  7. ralphx/api/routes/loops.py +4 -4
  8. ralphx/api/routes/planning.py +882 -19
  9. ralphx/api/routes/resources.py +528 -6
  10. ralphx/api/routes/stream.py +58 -56
  11. ralphx/api/routes/templates.py +2 -2
  12. ralphx/api/routes/workflows.py +258 -47
  13. ralphx/cli.py +4 -1
  14. ralphx/core/auth.py +372 -172
  15. ralphx/core/database.py +588 -164
  16. ralphx/core/executor.py +170 -19
  17. ralphx/core/loop.py +15 -2
  18. ralphx/core/loop_templates.py +29 -3
  19. ralphx/core/planning_iteration_executor.py +633 -0
  20. ralphx/core/planning_service.py +119 -24
  21. ralphx/core/preview.py +9 -25
  22. ralphx/core/project_db.py +864 -121
  23. ralphx/core/project_export.py +1 -5
  24. ralphx/core/project_import.py +14 -29
  25. ralphx/core/resources.py +28 -2
  26. ralphx/core/sample_project.py +1 -5
  27. ralphx/core/templates.py +9 -9
  28. ralphx/core/workflow_executor.py +32 -3
  29. ralphx/core/workflow_export.py +4 -7
  30. ralphx/core/workflow_import.py +3 -27
  31. ralphx/mcp/__init__.py +6 -2
  32. ralphx/mcp/registry.py +3 -3
  33. ralphx/mcp/tools/diagnostics.py +1 -1
  34. ralphx/mcp/tools/monitoring.py +10 -16
  35. ralphx/mcp/tools/workflows.py +115 -33
  36. ralphx/mcp_server.py +6 -2
  37. ralphx/static/assets/index-BuLI7ffn.css +1 -0
  38. ralphx/static/assets/index-DWvlqOTb.js +264 -0
  39. ralphx/static/assets/index-DWvlqOTb.js.map +1 -0
  40. ralphx/static/index.html +2 -2
  41. ralphx/templates/loop_templates/consumer.md +2 -2
  42. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/METADATA +33 -12
  43. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/RECORD +45 -44
  44. ralphx/static/assets/index-CcRDyY3b.css +0 -1
  45. ralphx/static/assets/index-CcxfTosc.js +0 -251
  46. ralphx/static/assets/index-CcxfTosc.js.map +0 -1
  47. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/WHEEL +0 -0
  48. {ralphx-0.3.4.dist-info → ralphx-0.4.0.dist-info}/entry_points.txt +0 -0
@@ -34,7 +34,6 @@ class WorkflowSummary:
34
34
  """Summary of a workflow in the project."""
35
35
  id: str
36
36
  name: str
37
- namespace: str
38
37
  steps_count: int
39
38
  items_count: int
40
39
  resources_count: int
@@ -137,7 +136,6 @@ class ProjectExporter:
137
136
  summaries.append(WorkflowSummary(
138
137
  id=wf['id'],
139
138
  name=wf['name'],
140
- namespace=wf['namespace'],
141
139
  steps_count=wf_preview.steps_count,
142
140
  items_count=wf_preview.items_total,
143
141
  resources_count=wf_preview.resources_count,
@@ -231,7 +229,7 @@ class ProjectExporter:
231
229
  )
232
230
 
233
231
  for wf in workflows:
234
- wf_prefix = f"workflows/{wf['namespace']}/"
232
+ wf_prefix = f"workflows/{wf['id']}/"
235
233
 
236
234
  # Get workflow data
237
235
  steps = self.db.list_workflow_steps(wf['id'])
@@ -261,7 +259,6 @@ class ProjectExporter:
261
259
  'id': wf['id'],
262
260
  'template_id': wf.get('template_id'),
263
261
  'name': wf['name'],
264
- 'namespace': wf['namespace'],
265
262
  'status': 'draft',
266
263
  'current_step': 1,
267
264
  'created_at': wf.get('created_at'),
@@ -432,7 +429,6 @@ class ProjectExporter:
432
429
  {
433
430
  'id': w['id'],
434
431
  'name': w['name'],
435
- 'namespace': w['namespace'],
436
432
  }
437
433
  for w in workflows
438
434
  ],
@@ -30,7 +30,6 @@ class WorkflowPreviewInfo:
30
30
  """Preview info for a workflow in the project export."""
31
31
  id: str
32
32
  name: str
33
- namespace: str
34
33
  steps_count: int
35
34
  items_count: int
36
35
  resources_count: int
@@ -263,8 +262,13 @@ class ProjectImporter:
263
262
 
264
263
  # Get workflow info from manifest
265
264
  for wf_info in manifest.get('contents', {}).get('workflows', []):
266
- wf_namespace = wf_info['namespace']
267
- wf_prefix = f"workflows/{wf_namespace}/"
265
+ wf_id = wf_info['id']
266
+ # Support both new (workflow_id) and old (namespace) path formats
267
+ wf_prefix = f"workflows/{wf_id}/"
268
+ # Check for old namespace-based paths for backward compatibility
269
+ old_namespace = wf_info.get('namespace')
270
+ if old_namespace and f"workflows/{old_namespace}/workflow.json" in zf.namelist():
271
+ wf_prefix = f"workflows/{old_namespace}/"
268
272
 
269
273
  # Count items
270
274
  items_count = 0
@@ -304,7 +308,6 @@ class ProjectImporter:
304
308
  workflows_info.append(WorkflowPreviewInfo(
305
309
  id=wf_info['id'],
306
310
  name=wf_info['name'],
307
- namespace=wf_namespace,
308
311
  steps_count=steps_count,
309
312
  items_count=items_count,
310
313
  resources_count=resources_count,
@@ -354,7 +357,6 @@ class ProjectImporter:
354
357
  workflows=[WorkflowPreviewInfo(
355
358
  id=wf_info.get('id', ''),
356
359
  name=wf_info.get('name', ''),
357
- namespace=wf_info.get('namespace', ''),
358
360
  steps_count=contents.get('steps', 0),
359
361
  items_count=contents.get('items_total', 0),
360
362
  resources_count=contents.get('resources', 0),
@@ -484,8 +486,13 @@ class ProjectImporter:
484
486
  import hashlib
485
487
  import uuid
486
488
 
487
- wf_namespace = wf_info['namespace']
488
- wf_prefix = f"workflows/{wf_namespace}/"
489
+ wf_id = wf_info['id']
490
+ # Support both new (workflow_id) and old (namespace) path formats
491
+ wf_prefix = f"workflows/{wf_id}/"
492
+ # Check for old namespace-based paths for backward compatibility
493
+ old_namespace = wf_info.get('namespace')
494
+ if old_namespace and f"workflows/{old_namespace}/workflow.json" in zf.namelist():
495
+ wf_prefix = f"workflows/{old_namespace}/"
489
496
 
490
497
  # Read workflow data
491
498
  workflow_data = json.loads(zf.read(f"{wf_prefix}workflow.json").decode('utf-8'))
@@ -522,15 +529,10 @@ class ProjectImporter:
522
529
  hash_suffix = hashlib.md5(f"{old_id}-{uuid.uuid4().hex}".encode()).hexdigest()[:8]
523
530
  id_mapping[old_id] = f"{old_id}-{hash_suffix}"
524
531
 
525
- # Generate unique namespace
526
- base_namespace = workflow_data['workflow']['namespace']
527
- namespace = self._generate_unique_namespace(base_namespace)
528
-
529
532
  # Create workflow
530
533
  workflow = self.db.create_workflow(
531
534
  id=new_wf_id,
532
535
  name=workflow_data['workflow']['name'],
533
- namespace=namespace,
534
536
  template_id=workflow_data['workflow'].get('template_id'),
535
537
  status='draft',
536
538
  )
@@ -666,20 +668,3 @@ class ProjectImporter:
666
668
  id_mapping=id_mapping,
667
669
  warnings=warnings,
668
670
  )
669
-
670
- def _generate_unique_namespace(self, base_namespace: str) -> str:
671
- """Generate a unique namespace."""
672
- import uuid
673
-
674
- existing_workflows = self.db.list_workflows()
675
- existing_namespaces = {w['namespace'] for w in existing_workflows}
676
-
677
- if base_namespace not in existing_namespaces:
678
- return base_namespace
679
-
680
- for i in range(1, 1000):
681
- candidate = f"{base_namespace[:56]}-{i}"
682
- if candidate not in existing_namespaces:
683
- return candidate
684
-
685
- return f"{base_namespace[:50]}-{uuid.uuid4().hex[:8]}"
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
- if file_path.exists():
523
- file_path.unlink()
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)
@@ -124,14 +124,10 @@ def _create_workflow_with_stories(
124
124
  logger.warning(f"Template '{template_id}' not found")
125
125
  return None
126
126
 
127
- # Generate namespace
128
- namespace = f"excuse-gen-{uuid.uuid4().hex[:7]}"
129
-
130
- # Create workflow
127
+ # Create workflow (namespace parameter removed in schema v16)
131
128
  workflow = project_db.create_workflow(
132
129
  id=workflow_id,
133
130
  name="Build Excuse Generator",
134
- namespace=namespace,
135
131
  template_id=template_id,
136
132
  status="draft",
137
133
  )
ralphx/core/templates.py CHANGED
@@ -9,15 +9,15 @@ from typing import Optional
9
9
 
10
10
  # Base loop templates
11
11
  TEMPLATES: dict[str, dict] = {
12
- "research": {
13
- "name": "research",
14
- "display_name": "Research Loop",
12
+ "extractgen_requirements": {
13
+ "name": "extractgen_requirements",
14
+ "display_name": "Extract Requirements Loop",
15
15
  "description": "Discover and document user stories from design documents or web research",
16
16
  "type": "generator",
17
17
  "category": "discovery",
18
18
  "config": {
19
- "name": "research",
20
- "display_name": "Research Loop",
19
+ "name": "extractgen_requirements",
20
+ "display_name": "Extract Requirements Loop",
21
21
  "type": "generator",
22
22
  "description": "Discover and document user stories from design documents",
23
23
  "item_types": {
@@ -34,7 +34,7 @@ TEMPLATES: dict[str, dict] = {
34
34
  "model": "claude-sonnet-4-20250514",
35
35
  "timeout": 180,
36
36
  "tools": [],
37
- "prompt_template": "prompts/research_turbo.md",
37
+ "prompt_template": "prompts/extractgen_requirements_turbo.md",
38
38
  },
39
39
  {
40
40
  "name": "deep",
@@ -42,7 +42,7 @@ TEMPLATES: dict[str, dict] = {
42
42
  "model": "claude-sonnet-4-20250514",
43
43
  "timeout": 900,
44
44
  "tools": ["web_search"],
45
- "prompt_template": "prompts/research_deep.md",
45
+ "prompt_template": "prompts/extractgen_requirements_deep.md",
46
46
  },
47
47
  ],
48
48
  "mode_selection": {
@@ -109,7 +109,7 @@ TEMPLATES: dict[str, dict] = {
109
109
  "input": {
110
110
  "singular": "story",
111
111
  "plural": "stories",
112
- "source": "research",
112
+ "source": "extractgen_requirements",
113
113
  "description": "Stories to implement",
114
114
  },
115
115
  "output": {
@@ -219,7 +219,7 @@ def get_template(name: str) -> Optional[dict]:
219
219
  """Get a template by name.
220
220
 
221
221
  Args:
222
- name: Template name (e.g., 'research', 'implementation')
222
+ name: Template name (e.g., 'extractgen_requirements', 'implementation')
223
223
 
224
224
  Returns:
225
225
  Template dict or None if not found
@@ -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
@@ -610,7 +639,7 @@ class WorkflowExecutor:
610
639
 
611
640
  # Stop running executors
612
641
  for executor in self._running_executors.values():
613
- executor.stop()
642
+ await executor.stop()
614
643
 
615
644
  self.db.update_workflow(self.workflow_id, status="paused")
616
645
  return self.get_workflow()
@@ -63,7 +63,7 @@ class SecretMatch:
63
63
  class ExportPreview:
64
64
  """Preview of what will be exported."""
65
65
  workflow_name: str
66
- workflow_namespace: str
66
+ workflow_id: str
67
67
  steps_count: int
68
68
  items_total: int
69
69
  items_by_step: dict[int, int] # step_id -> count
@@ -155,7 +155,7 @@ class WorkflowExporter:
155
155
 
156
156
  return ExportPreview(
157
157
  workflow_name=workflow['name'],
158
- workflow_namespace=workflow['namespace'],
158
+ workflow_id=workflow['id'],
159
159
  steps_count=len(steps),
160
160
  items_total=total_items, # Show real count, not truncated count
161
161
  items_by_step=items_by_step,
@@ -247,8 +247,7 @@ class WorkflowExporter:
247
247
 
248
248
  # Generate filename
249
249
  timestamp = datetime.utcnow().strftime('%Y%m%d-%H%M%S')
250
- namespace = workflow['namespace']
251
- filename = f"workflow-{namespace}-{timestamp}.ralphx.zip"
250
+ filename = f"workflow-{workflow['id']}-{timestamp}.ralphx.zip"
252
251
 
253
252
  zip_bytes = zip_buffer.getvalue()
254
253
 
@@ -342,7 +341,7 @@ class WorkflowExporter:
342
341
  snippet=redacted[:50] + '...' if len(redacted) > 50 else redacted,
343
342
  ))
344
343
 
345
- # Scan workflow name and namespace (unlikely but check)
344
+ # Scan workflow name (unlikely but check)
346
345
  scan_text(workflow.get('name', ''), 'workflow.name')
347
346
 
348
347
  # Scan resources
@@ -383,7 +382,6 @@ class WorkflowExporter:
383
382
  'workflow': {
384
383
  'id': workflow['id'],
385
384
  'name': workflow['name'],
386
- 'namespace': workflow['namespace'],
387
385
  'template_id': workflow.get('template_id'),
388
386
  },
389
387
  'contents': {
@@ -447,7 +445,6 @@ class WorkflowExporter:
447
445
  'id': workflow['id'],
448
446
  'template_id': workflow.get('template_id'),
449
447
  'name': workflow['name'],
450
- 'namespace': workflow['namespace'],
451
448
  'status': 'draft', # Reset status on export
452
449
  'current_step': 1, # Reset to beginning
453
450
  'created_at': workflow.get('created_at'),
@@ -16,7 +16,7 @@ from enum import Enum
16
16
  from pathlib import Path
17
17
  from typing import Any, Optional
18
18
 
19
- from ralphx.core.project_db import PROJECT_SCHEMA_VERSION, ProjectDatabase, validate_namespace
19
+ from ralphx.core.project_db import PROJECT_SCHEMA_VERSION, ProjectDatabase
20
20
  from ralphx.core.workflow_export import EXPORT_FORMAT_NAME, EXPORT_FORMAT_VERSION
21
21
 
22
22
 
@@ -106,7 +106,7 @@ class ImportPreview:
106
106
  """Preview of what will be imported."""
107
107
  # Basic info
108
108
  workflow_name: str
109
- workflow_namespace: str
109
+ workflow_id: str
110
110
  exported_at: str
111
111
  ralphx_version: str
112
112
  schema_version: int
@@ -254,7 +254,7 @@ class WorkflowImporter:
254
254
 
255
255
  return ImportPreview(
256
256
  workflow_name=manifest['workflow']['name'],
257
- workflow_namespace=manifest['workflow']['namespace'],
257
+ workflow_id=manifest['workflow']['id'],
258
258
  exported_at=manifest['exported_at'],
259
259
  ralphx_version=manifest.get('ralphx_version', 'unknown'),
260
260
  schema_version=manifest.get('schema_version', 0),
@@ -736,10 +736,6 @@ class WorkflowImporter:
736
736
  old_wf_id = workflow_data['workflow']['id']
737
737
  new_wf_id = id_mapping[old_wf_id]
738
738
 
739
- # Generate new namespace (ensure unique)
740
- base_namespace = workflow_data['workflow']['namespace']
741
- namespace = self._generate_unique_namespace(base_namespace)
742
-
743
739
  # Update items with new IDs
744
740
  updated_items = self._update_references(items_data, id_mapping)
745
741
 
@@ -747,7 +743,6 @@ class WorkflowImporter:
747
743
  workflow = self.db.create_workflow(
748
744
  id=new_wf_id,
749
745
  name=workflow_data['workflow']['name'],
750
- namespace=namespace,
751
746
  template_id=workflow_data['workflow'].get('template_id'),
752
747
  status='draft',
753
748
  )
@@ -905,7 +900,6 @@ class WorkflowImporter:
905
900
  original_metadata = {
906
901
  'imported_from': {
907
902
  'original_workflow_id': old_wf_id,
908
- 'original_namespace': workflow_data['workflow']['namespace'],
909
903
  'export_timestamp': manifest.get('exported_at'),
910
904
  'export_version': manifest.get('ralphx_version'),
911
905
  },
@@ -1153,21 +1147,3 @@ class WorkflowImporter:
1153
1147
  id_mapping=step_id_mapping,
1154
1148
  warnings=warnings,
1155
1149
  )
1156
-
1157
- def _generate_unique_namespace(self, base_namespace: str) -> str:
1158
- """Generate a unique namespace based on the base."""
1159
- # Check if base namespace exists
1160
- existing_workflows = self.db.list_workflows()
1161
- existing_namespaces = {w['namespace'] for w in existing_workflows}
1162
-
1163
- if base_namespace not in existing_namespaces:
1164
- return base_namespace
1165
-
1166
- # Add suffix until unique
1167
- for i in range(1, 1000):
1168
- candidate = f"{base_namespace[:56]}-{i}"
1169
- if candidate not in existing_namespaces:
1170
- return candidate
1171
-
1172
- # Fallback with uuid
1173
- return f"{base_namespace[:50]}-{uuid.uuid4().hex[:8]}"
ralphx/mcp/__init__.py CHANGED
@@ -3,8 +3,12 @@
3
3
  This module provides a modular MCP server implementation that exposes RalphX
4
4
  functionality as tools that Claude Code can use.
5
5
 
6
- Usage:
7
- claude mcp add ralphx -- ralphx mcp
6
+ Usage (Linux/Mac):
7
+ claude mcp add ralphx -e PYTHONDONTWRITEBYTECODE=1 -- "$(which ralphx)" mcp
8
+ # Mac zsh: if "which" fails, run: conda init zsh && source ~/.zshrc
9
+
10
+ Usage (Windows - find path first with: where.exe ralphx):
11
+ claude mcp add ralphx -e PYTHONDONTWRITEBYTECODE=1 -- C:\\path\\to\\ralphx.exe mcp
8
12
  """
9
13
 
10
14
  from ralphx.mcp.server import MCPServer
ralphx/mcp/registry.py CHANGED
@@ -39,11 +39,11 @@ class ToolRegistry:
39
39
  """Check if a tool is registered."""
40
40
  return name in self._tools
41
41
 
42
- def call(self, name: str, **kwargs) -> Any:
42
+ def call(self, tool_name: str, **kwargs) -> Any:
43
43
  """Call a tool by name with arguments."""
44
- tool = self._tools.get(name)
44
+ tool = self._tools.get(tool_name)
45
45
  if not tool:
46
- raise KeyError(f"Unknown tool: {name}")
46
+ raise KeyError(f"Unknown tool: {tool_name}")
47
47
  return tool.handler(**kwargs)
48
48
 
49
49
  def get_definitions(self) -> list[dict]:
@@ -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["id"],
246
+ session_id=last_session.get("session_id", last_session.get("id")),
247
247
  event_type="error",
248
248
  limit=5,
249
249
  )
@@ -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
- "current_iteration": r.get("current_iteration"),
77
- "current_mode": r.get("current_mode"),
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
- "current_iteration": run.get("current_iteration"),
121
- "current_mode": run.get("current_mode"),
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["id"],
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
- "completed_at": s.get("completed_at"),
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["id"],
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
- "completed_at": s.get("completed_at"),
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"),