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/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
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
583
|
-
|
|
651
|
+
if resp.status_code != 200:
|
|
652
|
+
return {"success": False, "message": f"Token refresh failed: {resp.status_code}"}
|
|
584
653
|
|
|
585
|
-
|
|
586
|
-
|
|
654
|
+
tokens = resp.json()
|
|
655
|
+
new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
|
|
587
656
|
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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":
|
|
679
|
+
"subscription_type": subscription_type,
|
|
607
680
|
}
|
|
608
681
|
except Exception as e:
|
|
609
682
|
return {"success": False, "message": str(e)}
|
ralphx/api/routes/items.py
CHANGED
|
@@ -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
|
ralphx/api/routes/loops.py
CHANGED
|
@@ -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
|
-
|
|
433
|
-
|
|
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
|
-
|
|
439
|
-
|
|
440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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",
|