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
@@ -1,5 +1,9 @@
1
1
  """Resource management API routes."""
2
2
 
3
+ import os
4
+ import shutil
5
+ from datetime import datetime
6
+ from pathlib import Path
3
7
  from typing import Optional
4
8
 
5
9
  from fastapi import APIRouter, HTTPException, Query, status
@@ -114,6 +118,9 @@ async def list_resources(
114
118
  """List all resources for a project."""
115
119
  manager, project, resource_manager = get_project_and_resources(slug)
116
120
 
121
+ # Auto-sync from filesystem to ensure files on disk are reflected
122
+ resource_manager.sync_from_filesystem()
123
+
117
124
  # Validate resource_type if provided
118
125
  rt = None
119
126
  if resource_type:
@@ -197,12 +204,18 @@ async def create_resource(slug: str, data: ResourceCreate):
197
204
  ip = validate_injection_position(data.injection_position)
198
205
 
199
206
  # Create resource (creates file and db entry)
200
- resource = resource_manager.create_resource(
201
- name=data.name,
202
- resource_type=rt,
203
- content=data.content,
204
- injection_position=ip,
205
- )
207
+ try:
208
+ resource = resource_manager.create_resource(
209
+ name=data.name,
210
+ resource_type=rt,
211
+ content=data.content,
212
+ injection_position=ip,
213
+ )
214
+ except ValueError as e:
215
+ raise HTTPException(
216
+ status_code=status.HTTP_400_BAD_REQUEST,
217
+ detail=str(e),
218
+ )
206
219
 
207
220
  return ResourceResponse(
208
221
  id=resource["id"],
@@ -329,3 +342,512 @@ async def list_resource_types():
329
342
  for p in InjectionPosition
330
343
  ],
331
344
  }
345
+
346
+
347
+ # =============================================================================
348
+ # Design Doc File Operations (for interactive design_doc steps)
349
+ # These work directly with files, not database resources
350
+ # =============================================================================
351
+
352
+
353
+ class DesignDocFileInfo(BaseModel):
354
+ """Info about a design doc file."""
355
+
356
+ path: str # Relative path like "design_doc/RCM_DESIGN.md"
357
+ name: str # Just the filename
358
+ size: int # Bytes
359
+ modified: str # ISO timestamp
360
+
361
+
362
+ class DesignDocFileContent(BaseModel):
363
+ """Design doc file with content."""
364
+
365
+ path: str
366
+ name: str
367
+ size: int
368
+ modified: str
369
+ content: str
370
+
371
+
372
+ class DesignDocBackup(BaseModel):
373
+ """Info about a backup file."""
374
+
375
+ path: str
376
+ name: str
377
+ size: int
378
+ created: str # ISO timestamp
379
+
380
+
381
+ class SaveDesignDocRequest(BaseModel):
382
+ """Request to save design doc content."""
383
+
384
+ content: str
385
+
386
+
387
+ class SaveDesignDocResponse(BaseModel):
388
+ """Response after saving design doc."""
389
+
390
+ path: str
391
+ backup_path: Optional[str] = None
392
+ size: int
393
+
394
+
395
+ def get_project_path(slug: str) -> Path:
396
+ """Get project path or raise 404."""
397
+ manager = get_manager()
398
+ project = manager.get_project(slug)
399
+ if not project:
400
+ raise HTTPException(
401
+ status_code=status.HTTP_404_NOT_FOUND,
402
+ detail=f"Project not found: {slug}",
403
+ )
404
+ return Path(project.path)
405
+
406
+
407
+ def _validate_safe_filename(name: str, label: str = "file name") -> None:
408
+ """Validate that a filename is safe (no path traversal, null bytes, etc.).
409
+
410
+ Args:
411
+ name: The filename to validate.
412
+ label: Human-readable label for error messages.
413
+
414
+ Raises:
415
+ HTTPException: If the filename is unsafe.
416
+ """
417
+ if not name or ".." in name or "/" in name or "\\" in name or "\0" in name or name.startswith("."):
418
+ raise HTTPException(
419
+ status_code=status.HTTP_400_BAD_REQUEST,
420
+ detail=f"Invalid {label}",
421
+ )
422
+
423
+
424
+ def _validate_path_containment(file_path: Path, allowed_root: Path) -> None:
425
+ """Verify that a resolved path stays within the allowed root directory.
426
+
427
+ Args:
428
+ file_path: The path to validate.
429
+ allowed_root: The directory the path must stay within.
430
+
431
+ Raises:
432
+ HTTPException: If the path escapes the allowed root.
433
+ """
434
+ resolved = file_path.resolve()
435
+ root_resolved = allowed_root.resolve()
436
+ if not resolved.is_relative_to(root_resolved):
437
+ raise HTTPException(
438
+ status_code=status.HTTP_400_BAD_REQUEST,
439
+ detail="Invalid file path",
440
+ )
441
+
442
+
443
+ def get_design_doc_folder(project_path: Path) -> Path:
444
+ """Get the design_doc resources folder."""
445
+ return project_path / ".ralphx" / "resources" / "design_doc"
446
+
447
+
448
+ def get_backups_folder(project_path: Path) -> Path:
449
+ """Get the backups folder, creating if needed."""
450
+ backups = project_path / ".ralphx" / "resources" / "design_doc" / "backups"
451
+ backups.mkdir(parents=True, exist_ok=True)
452
+ return backups
453
+
454
+
455
+ @router.get("/{slug}/design-doc-files", response_model=list[DesignDocFileInfo])
456
+ async def list_design_doc_files(slug: str):
457
+ """List all .md files in the design_doc resources folder."""
458
+ project_path = get_project_path(slug)
459
+ design_doc_folder = get_design_doc_folder(project_path)
460
+
461
+ if not design_doc_folder.exists():
462
+ return []
463
+
464
+ files = []
465
+ for file_path in design_doc_folder.glob("*.md"):
466
+ if file_path.is_file():
467
+ stat = file_path.stat()
468
+ files.append(
469
+ DesignDocFileInfo(
470
+ path=f"design_doc/{file_path.name}",
471
+ name=file_path.name,
472
+ size=stat.st_size,
473
+ modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
474
+ )
475
+ )
476
+
477
+ # Sort by modified time, newest first
478
+ files.sort(key=lambda f: f.modified, reverse=True)
479
+ return files
480
+
481
+
482
+ @router.get("/{slug}/design-doc-files/{file_name}", response_model=DesignDocFileContent)
483
+ async def get_design_doc_file(slug: str, file_name: str):
484
+ """Read a design doc file's content."""
485
+ project_path = get_project_path(slug)
486
+ design_doc_folder = get_design_doc_folder(project_path)
487
+
488
+ # Security: prevent path traversal and null bytes
489
+ _validate_safe_filename(file_name, "file name")
490
+
491
+ file_path = design_doc_folder / file_name
492
+ _validate_path_containment(file_path, design_doc_folder)
493
+
494
+ if not file_path.exists() or not file_path.is_file():
495
+ raise HTTPException(
496
+ status_code=status.HTTP_404_NOT_FOUND,
497
+ detail=f"File not found: {file_name}",
498
+ )
499
+
500
+ stat = file_path.stat()
501
+ content = file_path.read_text(encoding="utf-8")
502
+
503
+ return DesignDocFileContent(
504
+ path=f"design_doc/{file_name}",
505
+ name=file_name,
506
+ size=stat.st_size,
507
+ modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
508
+ content=content,
509
+ )
510
+
511
+
512
+ @router.post("/{slug}/design-doc-files/{file_name}/save", response_model=SaveDesignDocResponse)
513
+ async def save_design_doc_file(slug: str, file_name: str, data: SaveDesignDocRequest):
514
+ """Save design doc file with automatic backup.
515
+
516
+ If the file exists, creates a timestamped backup in the backups/ folder
517
+ before overwriting.
518
+ """
519
+ project_path = get_project_path(slug)
520
+ design_doc_folder = get_design_doc_folder(project_path)
521
+
522
+ # Security: prevent path traversal and null bytes
523
+ _validate_safe_filename(file_name, "file name")
524
+
525
+ # Ensure folder exists
526
+ design_doc_folder.mkdir(parents=True, exist_ok=True)
527
+
528
+ file_path = design_doc_folder / file_name
529
+ _validate_path_containment(file_path, design_doc_folder)
530
+ backup_path_str = None
531
+
532
+ # Create backup if file exists
533
+ if file_path.exists():
534
+ backups_folder = get_backups_folder(project_path)
535
+ timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
536
+ backup_name = f"{file_path.stem}.{timestamp}{file_path.suffix}"
537
+ backup_path = backups_folder / backup_name
538
+
539
+ shutil.copy2(file_path, backup_path)
540
+ backup_path_str = f"design_doc/backups/{backup_name}"
541
+
542
+ # Keep only last 10 backups for this file
543
+ _cleanup_old_backups(backups_folder, file_path.stem, max_backups=10)
544
+
545
+ # Write new content
546
+ file_path.write_text(data.content, encoding="utf-8")
547
+
548
+ return SaveDesignDocResponse(
549
+ path=f"design_doc/{file_name}",
550
+ backup_path=backup_path_str,
551
+ size=len(data.content.encode("utf-8")),
552
+ )
553
+
554
+
555
+ @router.post("/{slug}/design-doc-files/create", response_model=DesignDocFileInfo)
556
+ async def create_design_doc_file(slug: str, name: str = Query(..., description="File name without .md extension")):
557
+ """Create a new empty design doc file."""
558
+ project_path = get_project_path(slug)
559
+ design_doc_folder = get_design_doc_folder(project_path)
560
+
561
+ # Sanitize name
562
+ safe_name = "".join(c for c in name if c.isalnum() or c in "-_").strip()
563
+ if not safe_name:
564
+ raise HTTPException(
565
+ status_code=status.HTTP_400_BAD_REQUEST,
566
+ detail="Invalid file name",
567
+ )
568
+
569
+ file_name = f"{safe_name}.md"
570
+ file_path = design_doc_folder / file_name
571
+
572
+ if file_path.exists():
573
+ raise HTTPException(
574
+ status_code=status.HTTP_409_CONFLICT,
575
+ detail=f"File already exists: {file_name}",
576
+ )
577
+
578
+ # Create folder and file
579
+ design_doc_folder.mkdir(parents=True, exist_ok=True)
580
+ file_path.write_text(f"# {name}\n\n", encoding="utf-8")
581
+
582
+ stat = file_path.stat()
583
+ return DesignDocFileInfo(
584
+ path=f"design_doc/{file_name}",
585
+ name=file_name,
586
+ size=stat.st_size,
587
+ modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
588
+ )
589
+
590
+
591
+ @router.get("/{slug}/design-doc-files/{file_name}/backups", response_model=list[DesignDocBackup])
592
+ async def list_design_doc_backups(slug: str, file_name: str):
593
+ """List backups for a design doc file."""
594
+ # Security: prevent path traversal and null bytes
595
+ _validate_safe_filename(file_name, "file name")
596
+
597
+ project_path = get_project_path(slug)
598
+ backups_folder = get_backups_folder(project_path)
599
+
600
+ # Get the stem (filename without extension)
601
+ # Sanitize stem to prevent glob injection (only allow alphanumeric, hyphens, underscores)
602
+ stem = Path(file_name).stem
603
+ safe_stem = "".join(c for c in stem if c.isalnum() or c in "-_")
604
+ if not safe_stem:
605
+ return []
606
+
607
+ backups = []
608
+ for backup_path in backups_folder.glob(f"{safe_stem}.*.md"):
609
+ if backup_path.is_file():
610
+ stat = backup_path.stat()
611
+ backups.append(
612
+ DesignDocBackup(
613
+ path=f"design_doc/backups/{backup_path.name}",
614
+ name=backup_path.name,
615
+ size=stat.st_size,
616
+ created=datetime.fromtimestamp(stat.st_mtime).isoformat(),
617
+ )
618
+ )
619
+
620
+ # Sort by created time, newest first
621
+ backups.sort(key=lambda b: b.created, reverse=True)
622
+ return backups
623
+
624
+
625
+ @router.get("/{slug}/design-doc-files/backups/{backup_name}", response_model=DesignDocFileContent)
626
+ async def get_design_doc_backup(slug: str, backup_name: str):
627
+ """Read a backup file's content."""
628
+ project_path = get_project_path(slug)
629
+ backups_folder = get_backups_folder(project_path)
630
+
631
+ # Security: prevent path traversal and null bytes
632
+ _validate_safe_filename(backup_name, "backup name")
633
+
634
+ backup_path = backups_folder / backup_name
635
+ _validate_path_containment(backup_path, backups_folder)
636
+
637
+ if not backup_path.exists() or not backup_path.is_file():
638
+ raise HTTPException(
639
+ status_code=status.HTTP_404_NOT_FOUND,
640
+ detail=f"Backup not found: {backup_name}",
641
+ )
642
+
643
+ stat = backup_path.stat()
644
+ content = backup_path.read_text(encoding="utf-8")
645
+
646
+ return DesignDocFileContent(
647
+ path=f"design_doc/backups/{backup_name}",
648
+ name=backup_name,
649
+ size=stat.st_size,
650
+ modified=datetime.fromtimestamp(stat.st_mtime).isoformat(),
651
+ content=content,
652
+ )
653
+
654
+
655
+ def _cleanup_old_backups(backups_folder: Path, stem: str, max_backups: int = 10):
656
+ """Remove old backups, keeping only the most recent max_backups."""
657
+ backups = list(backups_folder.glob(f"{stem}.*.md"))
658
+ if len(backups) <= max_backups:
659
+ return
660
+
661
+ # Sort by modification time, oldest first
662
+ backups.sort(key=lambda p: p.stat().st_mtime)
663
+
664
+ # Remove oldest backups
665
+ for backup in backups[: len(backups) - max_backups]:
666
+ backup.unlink()
667
+
668
+
669
+ # =============================================================================
670
+ # Design Doc Diff and Restore Operations
671
+ # =============================================================================
672
+
673
+
674
+ class DiffLine(BaseModel):
675
+ """A single line in a diff."""
676
+
677
+ line: str
678
+ type: str # 'add', 'remove', 'context'
679
+
680
+
681
+ class DiffResult(BaseModel):
682
+ """Result of comparing two versions."""
683
+
684
+ left_path: str
685
+ right_path: str
686
+ left_size: int
687
+ right_size: int
688
+ chars_added: int
689
+ chars_removed: int
690
+ diff_html: str # Unified diff with syntax highlighting
691
+ diff_lines: list[DiffLine] # Structured diff for custom rendering
692
+
693
+
694
+ class RestoreBackupRequest(BaseModel):
695
+ """Request to restore a backup."""
696
+
697
+ backup_name: str
698
+
699
+
700
+ class RestoreBackupResponse(BaseModel):
701
+ """Response after restoring a backup."""
702
+
703
+ success: bool
704
+ backup_created: Optional[str] = None # New backup of current state
705
+
706
+
707
+ @router.get("/{slug}/design-doc-files/{file_name}/diff", response_model=DiffResult)
708
+ async def diff_design_doc_versions(
709
+ slug: str,
710
+ file_name: str,
711
+ left: str = Query(..., description="Left version: 'current' or backup filename"),
712
+ right: str = Query(..., description="Right version: 'current' or backup filename"),
713
+ ):
714
+ """Compare two versions of a design document.
715
+
716
+ Returns a unified diff with change statistics.
717
+ """
718
+ import difflib
719
+
720
+ project_path = get_project_path(slug)
721
+ design_doc_folder = get_design_doc_folder(project_path)
722
+ backups_folder = get_backups_folder(project_path)
723
+
724
+ # Security: prevent path traversal and null bytes
725
+ _validate_safe_filename(file_name, "file name")
726
+ if left != "current":
727
+ _validate_safe_filename(left, "left version")
728
+ if right != "current":
729
+ _validate_safe_filename(right, "right version")
730
+
731
+ # Resolve left version
732
+ if left == "current":
733
+ left_path = design_doc_folder / file_name
734
+ left_label = "current"
735
+ else:
736
+ left_path = backups_folder / left
737
+ _validate_path_containment(left_path, backups_folder)
738
+ left_label = left
739
+
740
+ # Resolve right version
741
+ if right == "current":
742
+ right_path = design_doc_folder / file_name
743
+ right_label = "current"
744
+ else:
745
+ right_path = backups_folder / right
746
+ _validate_path_containment(right_path, backups_folder)
747
+ right_label = right
748
+
749
+ if not left_path.exists():
750
+ raise HTTPException(
751
+ status_code=status.HTTP_404_NOT_FOUND,
752
+ detail=f"Left version not found: {left}",
753
+ )
754
+ if not right_path.exists():
755
+ raise HTTPException(
756
+ status_code=status.HTTP_404_NOT_FOUND,
757
+ detail=f"Right version not found: {right}",
758
+ )
759
+
760
+ left_content = left_path.read_text(encoding="utf-8")
761
+ right_content = right_path.read_text(encoding="utf-8")
762
+
763
+ # Generate unified diff
764
+ diff = list(
765
+ difflib.unified_diff(
766
+ left_content.splitlines(keepends=True),
767
+ right_content.splitlines(keepends=True),
768
+ fromfile=left_label,
769
+ tofile=right_label,
770
+ )
771
+ )
772
+
773
+ # Calculate stats
774
+ chars_added = sum(
775
+ len(line) - 1
776
+ for line in diff
777
+ if line.startswith("+") and not line.startswith("+++")
778
+ )
779
+ chars_removed = sum(
780
+ len(line) - 1
781
+ for line in diff
782
+ if line.startswith("-") and not line.startswith("---")
783
+ )
784
+
785
+ # Build structured diff lines
786
+ diff_lines = []
787
+ for line in diff:
788
+ if line.startswith("+") and not line.startswith("+++"):
789
+ diff_lines.append(DiffLine(line=line.rstrip("\n"), type="add"))
790
+ elif line.startswith("-") and not line.startswith("---"):
791
+ diff_lines.append(DiffLine(line=line.rstrip("\n"), type="remove"))
792
+ elif line.startswith("@@"):
793
+ diff_lines.append(DiffLine(line=line.rstrip("\n"), type="hunk"))
794
+ elif not line.startswith("+++") and not line.startswith("---"):
795
+ diff_lines.append(DiffLine(line=line.rstrip("\n"), type="context"))
796
+
797
+ return DiffResult(
798
+ left_path=left_label,
799
+ right_path=right_label,
800
+ left_size=len(left_content),
801
+ right_size=len(right_content),
802
+ chars_added=chars_added,
803
+ chars_removed=chars_removed,
804
+ diff_html="".join(diff),
805
+ diff_lines=diff_lines,
806
+ )
807
+
808
+
809
+ @router.post(
810
+ "/{slug}/design-doc-files/{file_name}/restore",
811
+ response_model=RestoreBackupResponse,
812
+ )
813
+ async def restore_design_doc_backup(
814
+ slug: str,
815
+ file_name: str,
816
+ request: RestoreBackupRequest,
817
+ ):
818
+ """Restore a design doc from a backup.
819
+
820
+ Creates a backup of current state before restoring.
821
+ """
822
+ project_path = get_project_path(slug)
823
+ design_doc_folder = get_design_doc_folder(project_path)
824
+ backups_folder = get_backups_folder(project_path)
825
+
826
+ # Security: prevent path traversal and null bytes
827
+ _validate_safe_filename(file_name, "file name")
828
+ _validate_safe_filename(request.backup_name, "backup name")
829
+
830
+ backup_path = backups_folder / request.backup_name
831
+ current_path = design_doc_folder / file_name
832
+ _validate_path_containment(backup_path, backups_folder)
833
+ _validate_path_containment(current_path, design_doc_folder)
834
+
835
+ if not backup_path.exists():
836
+ raise HTTPException(
837
+ status_code=status.HTTP_404_NOT_FOUND,
838
+ detail=f"Backup not found: {request.backup_name}",
839
+ )
840
+
841
+ # Backup current if it exists
842
+ new_backup = None
843
+ if current_path.exists():
844
+ timestamp = datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
845
+ new_backup = f"{Path(file_name).stem}.{timestamp}.md"
846
+ backups_folder.mkdir(parents=True, exist_ok=True)
847
+ shutil.copy2(current_path, backups_folder / new_backup)
848
+
849
+ # Restore from backup
850
+ design_doc_folder.mkdir(parents=True, exist_ok=True)
851
+ shutil.copy2(backup_path, current_path)
852
+
853
+ return RestoreBackupResponse(success=True, backup_created=new_backup)