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.
Files changed (39) hide show
  1. ralphx/__init__.py +1 -1
  2. ralphx/adapters/base.py +18 -2
  3. ralphx/adapters/claude_cli.py +415 -350
  4. ralphx/api/routes/auth.py +105 -32
  5. ralphx/api/routes/items.py +4 -0
  6. ralphx/api/routes/loops.py +101 -15
  7. ralphx/api/routes/planning.py +866 -17
  8. ralphx/api/routes/resources.py +528 -6
  9. ralphx/api/routes/stream.py +161 -114
  10. ralphx/api/routes/templates.py +1 -0
  11. ralphx/api/routes/workflows.py +257 -25
  12. ralphx/core/auth.py +32 -7
  13. ralphx/core/checkpoint.py +118 -0
  14. ralphx/core/executor.py +292 -85
  15. ralphx/core/loop_templates.py +59 -14
  16. ralphx/core/planning_iteration_executor.py +633 -0
  17. ralphx/core/planning_service.py +11 -4
  18. ralphx/core/project_db.py +835 -85
  19. ralphx/core/resources.py +28 -2
  20. ralphx/core/session.py +62 -10
  21. ralphx/core/templates.py +74 -87
  22. ralphx/core/workflow_executor.py +35 -3
  23. ralphx/mcp/tools/diagnostics.py +1 -1
  24. ralphx/mcp/tools/monitoring.py +10 -16
  25. ralphx/mcp/tools/workflows.py +5 -5
  26. ralphx/models/loop.py +1 -1
  27. ralphx/models/session.py +5 -0
  28. ralphx/static/assets/index-DnihHetG.js +265 -0
  29. ralphx/static/assets/index-DnihHetG.js.map +1 -0
  30. ralphx/static/assets/index-nIDWmtzm.css +1 -0
  31. ralphx/static/index.html +2 -2
  32. ralphx/templates/loop_templates/consumer.md +2 -2
  33. {ralphx-0.3.5.dist-info → ralphx-0.4.1.dist-info}/METADATA +1 -1
  34. {ralphx-0.3.5.dist-info → ralphx-0.4.1.dist-info}/RECORD +36 -35
  35. ralphx/static/assets/index-0ovNnfOq.css +0 -1
  36. ralphx/static/assets/index-CY9s08ZB.js +0 -251
  37. ralphx/static/assets/index-CY9s08ZB.js.map +0 -1
  38. {ralphx-0.3.5.dist-info → ralphx-0.4.1.dist-info}/WHEEL +0 -0
  39. {ralphx-0.3.5.dist-info → ralphx-0.4.1.dist-info}/entry_points.txt +0 -0
ralphx/api/routes/auth.py CHANGED
@@ -93,6 +93,53 @@ class AssignAccountRequest(BaseModel):
93
93
  # ============================================================================
94
94
 
95
95
 
96
+ async def _fetch_account_profile(access_token: str) -> Optional[dict]:
97
+ """Fetch account profile from Anthropic API.
98
+
99
+ Returns subscription type, rate limit tier, and other account info.
100
+ """
101
+ try:
102
+ async with httpx.AsyncClient() as client:
103
+ response = await client.get(
104
+ "https://api.anthropic.com/api/oauth/profile",
105
+ headers={
106
+ "Authorization": f"Bearer {access_token}",
107
+ "Content-Type": "application/json",
108
+ "anthropic-beta": "oauth-2025-04-20",
109
+ },
110
+ timeout=10.0,
111
+ )
112
+
113
+ if response.status_code != 200:
114
+ logger.warning(f"Profile fetch failed: {response.status_code}")
115
+ return None
116
+
117
+ data = response.json()
118
+ org = data.get("organization", {})
119
+ account = data.get("account", {})
120
+
121
+ # Map organization_type to subscription type
122
+ org_type = org.get("organization_type")
123
+ subscription_map = {
124
+ "claude_max": "max",
125
+ "claude_pro": "pro",
126
+ "claude_enterprise": "enterprise",
127
+ "claude_team": "team",
128
+ }
129
+ subscription_type = subscription_map.get(org_type)
130
+
131
+ return {
132
+ "subscription_type": subscription_type,
133
+ "rate_limit_tier": org.get("rate_limit_tier"),
134
+ "has_extra_usage_enabled": org.get("has_extra_usage_enabled"),
135
+ "display_name": account.get("display_name"),
136
+ "full_name": account.get("full_name"),
137
+ }
138
+ except Exception as e:
139
+ logger.warning(f"Profile fetch error: {e}")
140
+ return None
141
+
142
+
96
143
  async def _fetch_account_usage(access_token: str) -> Optional[dict]:
97
144
  """Fetch usage data from Anthropic API."""
98
145
  try:
@@ -112,6 +159,7 @@ async def _fetch_account_usage(access_token: str) -> Optional[dict]:
112
159
  data = response.json()
113
160
  five_hour = data.get("five_hour", {})
114
161
  seven_day = data.get("seven_day", {})
162
+
115
163
  return {
116
164
  "five_hour": five_hour.get("utilization", 0),
117
165
  "seven_day": seven_day.get("utilization", 0),
@@ -184,6 +232,7 @@ from ralphx.core.auth import (
184
232
  AuthStatus,
185
233
  CLIENT_ID,
186
234
  TOKEN_URL,
235
+ _token_refresh_lock,
187
236
  get_auth_status,
188
237
  refresh_token_if_needed,
189
238
  force_refresh_token,
@@ -424,9 +473,20 @@ async def add_account(expected_email: Optional[str] = None):
424
473
 
425
474
  # Use store_oauth_tokens to properly serialize scopes to JSON
426
475
  account = store_oauth_tokens(tokens)
427
-
428
- # Fetch usage data immediately
429
476
  db = Database()
477
+
478
+ # Fetch profile to get subscription type
479
+ profile_data = await _fetch_account_profile(tokens["access_token"])
480
+ if profile_data:
481
+ update_fields = {}
482
+ if profile_data.get("subscription_type"):
483
+ update_fields["subscription_type"] = profile_data["subscription_type"]
484
+ if profile_data.get("rate_limit_tier"):
485
+ update_fields["rate_limit_tier"] = profile_data["rate_limit_tier"]
486
+ if update_fields:
487
+ db.update_account(account["id"], **update_fields)
488
+
489
+ # Fetch usage data
430
490
  usage_data = await _fetch_account_usage(tokens["access_token"])
431
491
  if usage_data:
432
492
  db.update_account_usage_cache(
@@ -565,45 +625,58 @@ async def refresh_account_token(account_id: int):
565
625
  return {"success": False, "message": "No refresh token available"}
566
626
 
567
627
  try:
568
- async with httpx.AsyncClient() as client:
569
- resp = await client.post(
570
- TOKEN_URL,
571
- json={
572
- "grant_type": "refresh_token",
573
- "refresh_token": account["refresh_token"],
574
- "client_id": CLIENT_ID,
575
- },
576
- headers={
577
- "Content-Type": "application/json",
578
- "anthropic-beta": "oauth-2025-04-20",
579
- },
580
- )
628
+ # Use token refresh lock to prevent race conditions with background
629
+ # refresh tasks or concurrent manual refresh requests
630
+ async with _token_refresh_lock():
631
+ # Re-fetch account inside lock to get latest refresh_token
632
+ # (another process may have refreshed while we waited for lock)
633
+ account = db.get_account(account_id)
634
+ if not account or not account.get("refresh_token"):
635
+ return {"success": False, "message": "No refresh token available"}
636
+
637
+ async with httpx.AsyncClient() as client:
638
+ resp = await client.post(
639
+ TOKEN_URL,
640
+ json={
641
+ "grant_type": "refresh_token",
642
+ "refresh_token": account["refresh_token"],
643
+ "client_id": CLIENT_ID,
644
+ },
645
+ headers={
646
+ "Content-Type": "application/json",
647
+ "anthropic-beta": "oauth-2025-04-20",
648
+ },
649
+ )
581
650
 
582
- if resp.status_code != 200:
583
- return {"success": False, "message": f"Token refresh failed: {resp.status_code}"}
651
+ if resp.status_code != 200:
652
+ return {"success": False, "message": f"Token refresh failed: {resp.status_code}"}
584
653
 
585
- tokens = resp.json()
586
- new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
654
+ tokens = resp.json()
655
+ new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
587
656
 
588
- # Build update dict with all available fields
589
- update_data = {
590
- "access_token": tokens["access_token"],
591
- "refresh_token": tokens.get("refresh_token", account["refresh_token"]),
592
- "expires_at": new_expires_at,
593
- }
657
+ # Build update dict with all available fields
658
+ update_data = {
659
+ "access_token": tokens["access_token"],
660
+ "refresh_token": tokens.get("refresh_token", account["refresh_token"]),
661
+ "expires_at": new_expires_at,
662
+ }
594
663
 
595
- # Capture subscription/plan info if present in refresh response
596
- if tokens.get("subscription_type"):
597
- update_data["subscription_type"] = tokens["subscription_type"]
598
- if tokens.get("rate_limit_tier"):
599
- update_data["rate_limit_tier"] = tokens["rate_limit_tier"]
664
+ db.update_account(account_id, **update_data)
600
665
 
601
- db.update_account(account_id, **update_data)
666
+ # Fetch profile outside the lock (uses the new access_token, not refresh_token)
667
+ profile_data = await _fetch_account_profile(tokens["access_token"])
668
+ subscription_type = None
669
+ if profile_data:
670
+ if profile_data.get("subscription_type"):
671
+ subscription_type = profile_data["subscription_type"]
672
+ db.update_account(account_id, subscription_type=subscription_type)
673
+ if profile_data.get("rate_limit_tier"):
674
+ db.update_account(account_id, rate_limit_tier=profile_data["rate_limit_tier"])
602
675
 
603
676
  return {
604
677
  "success": True,
605
678
  "expires_at": new_expires_at,
606
- "subscription_type": tokens.get("subscription_type"),
679
+ "subscription_type": subscription_type,
607
680
  }
608
681
  except Exception as e:
609
682
  return {"success": False, "message": str(e)}
@@ -139,6 +139,8 @@ async def list_items(
139
139
  source_step_id: Optional[int] = Query(None, description="Filter by source step"),
140
140
  limit: int = Query(50, ge=1, le=1000, description="Items per page"),
141
141
  offset: int = Query(0, ge=0, description="Offset for pagination"),
142
+ sort_by: str = Query("created_at", description="Column to sort by"),
143
+ sort_order: str = Query("desc", description="Sort order: asc or desc"),
142
144
  ):
143
145
  """List work items with optional filtering."""
144
146
  manager, project, project_db = get_project(slug)
@@ -151,6 +153,8 @@ async def list_items(
151
153
  source_step_id=source_step_id,
152
154
  limit=limit,
153
155
  offset=offset,
156
+ sort_by=sort_by,
157
+ sort_order=sort_order,
154
158
  )
155
159
 
156
160
  # Convert to response models
@@ -17,6 +17,7 @@ from ralphx.core.project_db import ProjectDatabase
17
17
  from ralphx.models.loop import LoopConfig, LoopType, ModeSelectionStrategy, ItemTypes
18
18
  from ralphx.models.run import Run, RunStatus
19
19
  from ralphx.core.logger import loop_log
20
+ from ralphx.core.checkpoint import kill_orphan_process
20
21
 
21
22
  router = APIRouter()
22
23
 
@@ -54,6 +55,9 @@ def detect_source_cycle(
54
55
  # Store for running loops
55
56
  _running_loops: dict[str, LoopExecutor] = {}
56
57
 
58
+ # Prevent concurrent stop attempts
59
+ _stopping_loops: set[str] = set()
60
+
57
61
  # Security: Validate loop names to prevent path traversal
58
62
  LOOP_NAME_PATTERN = re.compile(r'^[a-zA-Z0-9_-]+$')
59
63
 
@@ -428,34 +432,110 @@ async def start_loop(
428
432
 
429
433
  @router.post("/{slug}/loops/{loop_name}/stop")
430
434
  async def stop_loop(slug: str, loop_name: str):
431
- """Stop a running loop."""
432
- # Validate project exists first
433
- get_managers(slug)
435
+ """Stop a running loop.
436
+
437
+ Attempts to stop via executor if in memory, otherwise falls back
438
+ to killing via PID from database (for orphaned processes after
439
+ server restart/hot-reload).
440
+ """
441
+ manager, project, project_db = get_managers(slug)
434
442
 
435
443
  key = f"{slug}:{loop_name}"
436
- executor = _running_loops.get(key)
437
444
 
438
- if not executor:
439
- raise HTTPException(
440
- status_code=status.HTTP_404_NOT_FOUND,
441
- detail=f"Loop {loop_name} is not running",
442
- )
445
+ # Prevent concurrent stop attempts
446
+ if key in _stopping_loops:
447
+ return {"message": f"Stop already in progress for {loop_name}"}
443
448
 
444
- await executor.stop()
449
+ _stopping_loops.add(key)
450
+ try:
451
+ # Try 1: Stop via executor (normal case)
452
+ executor = _running_loops.get(key)
453
+ if executor:
454
+ await executor.stop()
455
+ return {
456
+ "message": f"Stop signal sent to {loop_name}",
457
+ "method": "executor",
458
+ }
459
+
460
+ # Try 2: Kill via PID (orphan case after server restart)
461
+ runs = project_db.list_runs(loop_name=loop_name, status=["running", "paused"])
462
+ if not runs:
463
+ raise HTTPException(
464
+ status_code=status.HTTP_404_NOT_FOUND,
465
+ detail=f"Loop {loop_name} is not running",
466
+ )
445
467
 
446
- return {"message": f"Stop signal sent to {loop_name}"}
468
+ # Get most recent running run
469
+ run = runs[0]
470
+ pid = run.get("executor_pid")
471
+
472
+ if not pid:
473
+ # No PID recorded - can't kill, just mark as aborted
474
+ project_db.update_run(
475
+ run["id"],
476
+ status="aborted",
477
+ completed_at=datetime.utcnow().isoformat(),
478
+ error_message="Stopped by user (no PID available for orphan process)",
479
+ )
480
+ return {
481
+ "message": f"Marked {loop_name} as aborted (no PID available)",
482
+ "method": "database_only",
483
+ "warning": "Process may still be running",
484
+ }
485
+
486
+ # Kill the orphan process
487
+ success, reason = await kill_orphan_process(pid)
488
+
489
+ # Update database regardless of kill result
490
+ if success:
491
+ error_msg = f"Killed orphan process (PID {pid}) after server restart"
492
+ if reason == "already_dead":
493
+ error_msg = f"Orphan process (PID {pid}) already terminated"
494
+ else:
495
+ error_msg = f"Could not kill orphan process (PID {pid}): {reason}"
496
+
497
+ project_db.update_run(
498
+ run["id"],
499
+ status="aborted",
500
+ completed_at=datetime.utcnow().isoformat(),
501
+ error_message=error_msg,
502
+ )
503
+
504
+ if success:
505
+ return {
506
+ "message": f"Stopped orphan process for {loop_name}",
507
+ "method": "pid_kill",
508
+ "pid": pid,
509
+ "detail": reason, # "killed" or "already_dead"
510
+ }
511
+ else:
512
+ return {
513
+ "message": f"Could not kill process {pid}, marked as aborted",
514
+ "method": "pid_kill_failed",
515
+ "pid": pid,
516
+ "reason": reason,
517
+ "warning": "Process may not have been our process (PID reuse)" if reason == "not_our_process" else None,
518
+ }
519
+ finally:
520
+ _stopping_loops.discard(key)
447
521
 
448
522
 
449
523
  @router.post("/{slug}/loops/{loop_name}/pause")
450
524
  async def pause_loop(slug: str, loop_name: str):
451
525
  """Pause a running loop."""
452
- # Validate project exists first
453
- get_managers(slug)
526
+ manager, project, project_db = get_managers(slug)
454
527
 
455
528
  key = f"{slug}:{loop_name}"
456
529
  executor = _running_loops.get(key)
457
530
 
458
531
  if not executor:
532
+ # Check if there's an orphan process
533
+ runs = project_db.list_runs(loop_name=loop_name, status=["running", "paused"])
534
+ if runs:
535
+ raise HTTPException(
536
+ status_code=status.HTTP_409_CONFLICT,
537
+ detail=f"Loop {loop_name} is running as orphan process (server restarted). Use stop to terminate it.",
538
+ )
459
539
  raise HTTPException(
460
540
  status_code=status.HTTP_404_NOT_FOUND,
461
541
  detail=f"Loop {loop_name} is not running",
@@ -469,13 +549,19 @@ async def pause_loop(slug: str, loop_name: str):
469
549
  @router.post("/{slug}/loops/{loop_name}/resume")
470
550
  async def resume_loop(slug: str, loop_name: str):
471
551
  """Resume a paused loop."""
472
- # Validate project exists first
473
- get_managers(slug)
552
+ manager, project, project_db = get_managers(slug)
474
553
 
475
554
  key = f"{slug}:{loop_name}"
476
555
  executor = _running_loops.get(key)
477
556
 
478
557
  if not executor:
558
+ # Check if there's an orphan process
559
+ runs = project_db.list_runs(loop_name=loop_name, status=["running", "paused"])
560
+ if runs:
561
+ raise HTTPException(
562
+ status_code=status.HTTP_409_CONFLICT,
563
+ detail=f"Loop {loop_name} is orphaned (server restarted). Use stop to terminate, then start again.",
564
+ )
479
565
  raise HTTPException(
480
566
  status_code=status.HTTP_404_NOT_FOUND,
481
567
  detail=f"Loop {loop_name} is not running",