ralphx 0.3.5__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.
ralphx/core/auth.py CHANGED
@@ -128,7 +128,7 @@ class AuthStatus(BaseModel):
128
128
  """Authentication status."""
129
129
 
130
130
  connected: bool
131
- scope: Optional[Literal["project", "global"]] = None
131
+ scope: Optional[Literal["project", "global", "account"]] = None
132
132
  email: Optional[str] = None # User's email address
133
133
  subscription_type: Optional[str] = None
134
134
  rate_limit_tier: Optional[str] = None
@@ -357,6 +357,7 @@ def swap_credentials_for_account(
357
357
  }
358
358
  CLAUDE_CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
359
359
  CLAUDE_CREDENTIALS_PATH.write_text(json.dumps(creds_data, indent=2))
360
+ os.chmod(CLAUDE_CREDENTIALS_PATH, 0o600) # Restrict to owner-only read/write
360
361
  has_creds = True
361
362
 
362
363
  # Update last_used_at for the account
@@ -711,7 +712,17 @@ async def refresh_token_if_needed(
711
712
  if now < account["expires_at"] - 300:
712
713
  return True # Token assumed valid based on expiry
713
714
 
714
- return await _do_token_refresh(account, project_id)
715
+ # Use lock to prevent concurrent refresh operations.
716
+ # Re-read account inside lock in case another process already refreshed.
717
+ async with _token_refresh_lock():
718
+ # Re-fetch account to get latest token state
719
+ fresh_account = db.get_effective_account(project_id)
720
+ if not fresh_account:
721
+ return False
722
+ # Check if another process already refreshed while we waited for lock
723
+ if int(time.time()) < fresh_account["expires_at"] - 300:
724
+ return True
725
+ return await _do_token_refresh(fresh_account, project_id)
715
726
 
716
727
 
717
728
  async def force_refresh_token(project_id: Optional[str] = None) -> dict:
@@ -733,7 +744,13 @@ async def force_refresh_token(project_id: Optional[str] = None) -> dict:
733
744
  return {"success": False, "error": "No refresh token available"}
734
745
 
735
746
  try:
736
- success = await _do_token_refresh(account, project_id)
747
+ # Use lock to prevent concurrent refresh operations.
748
+ # Re-read account inside lock in case another process already refreshed.
749
+ async with _token_refresh_lock():
750
+ fresh_account = db.get_effective_account(project_id)
751
+ if not fresh_account or not fresh_account.get("refresh_token"):
752
+ return {"success": False, "error": "No account or refresh token available"}
753
+ success = await _do_token_refresh(fresh_account, project_id)
737
754
  if success:
738
755
  return {"success": True, "message": "Token refreshed successfully"}
739
756
  else:
@@ -780,12 +797,16 @@ async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
780
797
  # Token was refreshed by another process, skip
781
798
  continue
782
799
 
800
+ # Use fresh_account's refresh_token (not the pre-lock stale one)
801
+ # Another process may have refreshed and changed the token while we waited
802
+ refresh_token = fresh_account["refresh_token"] if fresh_account else account["refresh_token"]
803
+
783
804
  async with httpx.AsyncClient() as client:
784
805
  resp = await client.post(
785
806
  TOKEN_URL,
786
807
  json={
787
808
  "grant_type": "refresh_token",
788
- "refresh_token": account["refresh_token"],
809
+ "refresh_token": refresh_token,
789
810
  "client_id": CLIENT_ID,
790
811
  },
791
812
  headers={
@@ -799,9 +820,11 @@ async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
799
820
  new_expires_at = int(time.time()) + tokens.get("expires_in", 28800)
800
821
 
801
822
  # Build update dict - capture subscription/plan info if present
823
+ # Use refresh_token (the fresh value used for this request) as fallback,
824
+ # not account["refresh_token"] which is the pre-lock stale copy
802
825
  update_data = {
803
826
  "access_token": tokens["access_token"],
804
- "refresh_token": tokens.get("refresh_token", account["refresh_token"]),
827
+ "refresh_token": tokens.get("refresh_token", refresh_token),
805
828
  "expires_at": new_expires_at,
806
829
  "consecutive_failures": 0,
807
830
  }
@@ -830,10 +853,11 @@ async def refresh_all_expiring_tokens(buffer_seconds: int = 7200) -> dict:
830
853
  )
831
854
  else:
832
855
  result["failed"] += 1
833
- # Track consecutive failures
856
+ # Track consecutive failures (use fresh_account for accurate count)
857
+ failure_count = (fresh_account or account).get("consecutive_failures", 0)
834
858
  db.update_account(
835
859
  account["id"],
836
- consecutive_failures=account.get("consecutive_failures", 0) + 1,
860
+ consecutive_failures=failure_count + 1,
837
861
  )
838
862
  auth_log.warning(
839
863
  "token_refresh_failed",
@@ -937,6 +961,7 @@ def swap_credentials_for_loop(
937
961
  }
938
962
  CLAUDE_CREDENTIALS_PATH.parent.mkdir(parents=True, exist_ok=True)
939
963
  CLAUDE_CREDENTIALS_PATH.write_text(json.dumps(creds_data, indent=2))
964
+ os.chmod(CLAUDE_CREDENTIALS_PATH, 0o600) # Restrict to owner-only read/write
940
965
  has_creds = True
941
966
 
942
967
  try:
ralphx/core/executor.py CHANGED
@@ -23,7 +23,7 @@ from enum import Enum
23
23
  from pathlib import Path
24
24
  from typing import Any, AsyncIterator, Callable, Optional
25
25
 
26
- from ralphx.adapters.base import ExecutionResult, LLMAdapter
26
+ from ralphx.adapters.base import AdapterEvent, ExecutionResult, LLMAdapter, StreamEvent
27
27
  from ralphx.adapters.claude_cli import ClaudeCLIAdapter
28
28
  from ralphx.core.dependencies import DependencyGraph, order_items_by_dependency
29
29
  from ralphx.core.project_db import ProjectDatabase
@@ -47,6 +47,7 @@ class ExecutorEvent(str, Enum):
47
47
  GIT_COMMIT = "git_commit" # Git commit after successful iteration
48
48
  ERROR = "error"
49
49
  WARNING = "warning"
50
+ INFO = "info"
50
51
  HEARTBEAT = "heartbeat"
51
52
  RUN_PAUSED = "run_paused"
52
53
  RUN_RESUMED = "run_resumed"
@@ -123,6 +124,7 @@ class LoopExecutor:
123
124
  batch_size: int = 10,
124
125
  consume_from_step_id: Optional[int] = None,
125
126
  architecture_first: bool = False,
127
+ context_from_steps: Optional[list[int]] = None,
126
128
  ):
127
129
  """Initialize the executor.
128
130
 
@@ -142,6 +144,8 @@ class LoopExecutor:
142
144
  consume_from_step_id: For consumer loops, the step ID to consume items from.
143
145
  architecture_first: If True, prioritize foundational stories (FND, DBM, SEC, ARC)
144
146
  for new codebases. Batches them together for initial build.
147
+ context_from_steps: For generator loops, additional step IDs whose items
148
+ should be included as existing context (avoid duplicates).
145
149
  """
146
150
  self.project = project
147
151
  self.config = loop_config
@@ -149,6 +153,7 @@ class LoopExecutor:
149
153
  self.workflow_id = workflow_id
150
154
  self.step_id = step_id
151
155
  self._consume_from_step_id = consume_from_step_id
156
+ self._context_from_steps = context_from_steps or []
152
157
  # Create adapter with per-loop settings path for permission templates
153
158
  # Pass project_id for credential lookup (project-scoped auth)
154
159
  if adapter is None:
@@ -403,15 +408,24 @@ class LoopExecutor:
403
408
  mode_name = list(modes.keys())[0]
404
409
  return mode_name, modes[mode_name]
405
410
 
406
- def _resolve_loop_resource_content(self, resource: dict) -> Optional[str]:
411
+ def _resolve_loop_resource_content(self, resource: dict, _depth: int = 0) -> Optional[str]:
407
412
  """Resolve content for a loop resource based on its source_type.
408
413
 
409
414
  Args:
410
415
  resource: Loop resource dict from database.
416
+ _depth: Internal recursion depth counter (max 5 to prevent cycles).
411
417
 
412
418
  Returns:
413
419
  Resolved content string, or None if unable to resolve.
414
420
  """
421
+ if _depth > 5:
422
+ import logging
423
+ logging.getLogger(__name__).warning(
424
+ f"[RESOLVE] Max recursion depth reached resolving loop resource "
425
+ f"(possible circular loop_ref). Resource: {resource.get('name', 'unknown')}"
426
+ )
427
+ return None
428
+
415
429
  source_type = resource.get("source_type", "")
416
430
 
417
431
  if source_type == "system":
@@ -425,22 +439,30 @@ class LoopExecutor:
425
439
  return None
426
440
 
427
441
  elif source_type == "project_file":
428
- # Load from project file path
442
+ # Load from project file path (must stay within project directory)
429
443
  source_path = resource.get("source_path")
430
444
  if source_path:
431
- file_path = Path(self.project.path) / source_path
445
+ file_path = (Path(self.project.path) / source_path).resolve()
446
+ project_root = Path(self.project.path).resolve()
447
+ # Prevent path traversal outside project directory
448
+ if not str(file_path).startswith(str(project_root) + "/") and file_path != project_root:
449
+ import logging
450
+ logging.getLogger(__name__).warning(
451
+ f"[RESOLVE] Path traversal blocked: {source_path} resolves outside project"
452
+ )
453
+ return None
432
454
  if file_path.exists():
433
455
  return file_path.read_text()
434
456
  return None
435
457
 
436
458
  elif source_type == "loop_ref":
437
- # Load from another loop's resource (recursively)
459
+ # Load from another loop's resource (recursively, with depth limit)
438
460
  source_loop = resource.get("source_loop")
439
461
  source_resource_id = resource.get("source_resource_id")
440
462
  if source_loop and source_resource_id:
441
463
  source_resource = self.db.get_loop_resource(source_resource_id)
442
464
  if source_resource:
443
- return self._resolve_loop_resource_content(source_resource)
465
+ return self._resolve_loop_resource_content(source_resource, _depth=_depth + 1)
444
466
  return None
445
467
 
446
468
  elif source_type == "project_resource":
@@ -528,7 +550,7 @@ class LoopExecutor:
528
550
 
529
551
  return "\n".join(sections)
530
552
 
531
- def _load_prompt_template(self, mode: Mode) -> str:
553
+ def _load_prompt_template(self, mode: Mode, loop_resources: Optional[list[dict]] = None) -> str:
532
554
  """Load prompt template for a mode.
533
555
 
534
556
  Priority order:
@@ -539,12 +561,14 @@ class LoopExecutor:
539
561
 
540
562
  Args:
541
563
  mode: Mode configuration.
564
+ loop_resources: Pre-loaded loop resources (avoids redundant DB query).
542
565
 
543
566
  Returns:
544
567
  Prompt template content.
545
568
  """
546
569
  # Priority 1: Check for loop-level LOOP_TEMPLATE resource
547
- loop_resources = self._load_loop_resources()
570
+ if loop_resources is None:
571
+ loop_resources = self._load_loop_resources()
548
572
  for resource in loop_resources:
549
573
  if (resource.get("resource_type") == "loop_template" and
550
574
  resource.get("injection_position") == "template_body"):
@@ -627,11 +651,12 @@ class LoopExecutor:
627
651
  Returns:
628
652
  Complete prompt with all resources and tracking marker.
629
653
  """
630
- template = self._load_prompt_template(mode)
631
-
632
654
  # Load loop-specific resources first (from loop_resources table)
655
+ # Pass to _load_prompt_template to avoid redundant DB query
633
656
  loop_resources = self._load_loop_resources()
634
657
 
658
+ template = self._load_prompt_template(mode, loop_resources=loop_resources)
659
+
635
660
  # Also load project-level resources as fallback
636
661
  resource_manager = ResourceManager(self.project.path, db=self.db)
637
662
  resource_set = resource_manager.load_for_loop(self.config, mode_name)
@@ -692,6 +717,17 @@ class LoopExecutor:
692
717
  escaped_design_doc = self._escape_template_vars(design_doc_content)
693
718
  template = template.replace("{DESIGN_DOC}", escaped_design_doc)
694
719
 
720
+ # Substitute {{design_doc}} (RalphX style) with actual design doc content
721
+ # Note: {{design_doc}} may have already been used as a position marker for
722
+ # after_design_doc resources above, but the marker text itself remains.
723
+ # Replace it with the actual design doc content (or empty string if none).
724
+ if "{{design_doc}}" in template:
725
+ if design_doc_content:
726
+ escaped_design_doc = self._escape_template_vars(design_doc_content)
727
+ template = template.replace("{{design_doc}}", escaped_design_doc)
728
+ else:
729
+ template = template.replace("{{design_doc}}", "")
730
+
695
731
  # BEFORE_TASK: Insert before the main task instruction
696
732
  # Look for {{task}} marker or insert near the end
697
733
  if before_task:
@@ -701,10 +737,21 @@ class LoopExecutor:
701
737
  # Append before the final section
702
738
  template = template + "\n\n" + before_task
703
739
 
704
- # AFTER_TASK: Append at the end
705
- if after_task:
740
+ # AFTER_TASK: Append at the end, unless the template has a dedicated
741
+ # {{custom_context}} placeholder (which serves the same purpose)
742
+ if "{{custom_context}}" in template:
743
+ # Template has a designated place for custom context - substitute there
744
+ template = template.replace("{{custom_context}}", after_task or "")
745
+ elif after_task:
746
+ # No placeholder - append at end
706
747
  template = template + "\n\n" + after_task
707
748
 
749
+ # Substitute {{backlog_status}} for hybrid loops
750
+ # Shows current item counts to help mode detection
751
+ if "{{backlog_status}}" in template:
752
+ backlog_status = self._build_backlog_status()
753
+ template = template.replace("{{backlog_status}}", backlog_status)
754
+
708
755
  # Inject generator loop context (existing stories, category stats, inputs)
709
756
  # This MUST happen before any variable substitution
710
757
  if self._is_generator_loop():
@@ -776,7 +823,7 @@ class LoopExecutor:
776
823
  # Support both {{implemented_summary}} (RalphX style) and {IMPLEMENTED_SUMMARY} (hank-rcm style)
777
824
  if self._is_consumer_loop():
778
825
  if "{{implemented_summary}}" in template or "{IMPLEMENTED_SUMMARY}" in template:
779
- impl_summary = self._build_implemented_summary()
826
+ impl_summary = self._escape_template_vars(self._build_implemented_summary())
780
827
  template = template.replace("{{implemented_summary}}", impl_summary)
781
828
  template = template.replace("{IMPLEMENTED_SUMMARY}", impl_summary)
782
829
 
@@ -807,8 +854,49 @@ class LoopExecutor:
807
854
  )
808
855
  template = template + marker
809
856
 
857
+ # Warn if prompt exceeds reasonable size (200K chars ~ 50K tokens)
858
+ prompt_size = len(template)
859
+ if prompt_size > 200_000:
860
+ self._emit_event(
861
+ ExecutorEvent.WARNING,
862
+ f"Prompt is very large ({prompt_size:,} chars, ~{prompt_size // 4:,} tokens). "
863
+ f"This may exceed the model's context window. Consider reducing resource sizes.",
864
+ )
865
+
810
866
  return template
811
867
 
868
+ def _build_backlog_status(self) -> str:
869
+ """Build backlog status summary for hybrid loop templates.
870
+
871
+ Shows the current state of work items to help the hybrid template
872
+ decide whether to generate new items or implement existing ones.
873
+
874
+ Returns:
875
+ Status string describing pending/completed item counts.
876
+ """
877
+ try:
878
+ # Get all items for this workflow step
879
+ all_items, total = self.db.list_work_items(
880
+ workflow_id=self.workflow_id,
881
+ source_step_id=self.step_id,
882
+ limit=10000,
883
+ )
884
+
885
+ if total == 0:
886
+ return "Backlog is EMPTY. No work items exist yet."
887
+
888
+ # Count by status
889
+ pending = sum(1 for i in all_items if i.get("status") == "pending")
890
+ completed = sum(1 for i in all_items if i.get("status") in ("completed", "processed"))
891
+ in_progress = sum(1 for i in all_items if i.get("status") == "in_progress")
892
+
893
+ return (
894
+ f"Backlog has {total} items: "
895
+ f"{pending} pending, {in_progress} in progress, {completed} completed."
896
+ )
897
+ except Exception:
898
+ return "Backlog status unavailable."
899
+
812
900
  def _is_consumer_loop(self) -> bool:
813
901
  """Check if this loop consumes items from another loop.
814
902
 
@@ -873,6 +961,15 @@ class LoopExecutor:
873
961
  limit=10000, # Get all existing items
874
962
  )
875
963
 
964
+ # Also include items from linked context steps (cross-step visibility)
965
+ for ctx_step_id in self._context_from_steps:
966
+ ctx_items, _ = self.db.list_work_items(
967
+ workflow_id=self.workflow_id,
968
+ source_step_id=ctx_step_id,
969
+ limit=10000,
970
+ )
971
+ existing_items.extend(ctx_items)
972
+
876
973
  # 2. Build category stats with next available ID
877
974
  category_stats: dict[str, dict] = {}
878
975
  for item in existing_items:
@@ -920,10 +1017,13 @@ class LoopExecutor:
920
1017
  json.dumps(category_stats, indent=2)
921
1018
  )
922
1019
 
1020
+ # Escape inputs_list to prevent template injection from filenames
1021
+ inputs_list_escaped = self._escape_template_vars(inputs_list)
1022
+
923
1023
  template = template.replace("{{existing_stories}}", existing_stories_json)
924
1024
  template = template.replace("{{category_stats}}", category_stats_json)
925
1025
  template = template.replace("{{total_stories}}", str(len(existing_items)))
926
- template = template.replace("{{inputs_list}}", inputs_list)
1026
+ template = template.replace("{{inputs_list}}", inputs_list_escaped)
927
1027
 
928
1028
  return template
929
1029
 
@@ -1637,9 +1737,13 @@ class LoopExecutor:
1637
1737
  if self._is_consumer_loop() and not self._batch_mode:
1638
1738
  json_schema = IMPLEMENTATION_STATUS_SCHEMA
1639
1739
 
1740
+ # Mutable holder for session_id (set by INIT event before other events)
1741
+ session_id_holder: list[Optional[str]] = [None]
1742
+
1640
1743
  # Callback to register session immediately when it starts
1641
1744
  # This enables live streaming in the UI before execution completes
1642
1745
  def register_session_early(session_id: str) -> None:
1746
+ session_id_holder[0] = session_id
1643
1747
  if self._run:
1644
1748
  self.db.create_session(
1645
1749
  session_id=session_id,
@@ -1649,6 +1753,55 @@ class LoopExecutor:
1649
1753
  status="running",
1650
1754
  )
1651
1755
 
1756
+ # Callback to persist events to DB for history/debugging
1757
+ def persist_event(event: StreamEvent) -> None:
1758
+ sid = session_id_holder[0]
1759
+ if not sid:
1760
+ return
1761
+ try:
1762
+ if event.type == AdapterEvent.INIT:
1763
+ self.db.add_session_event(
1764
+ session_id=sid,
1765
+ event_type="init",
1766
+ raw_data=event.data if event.data else None,
1767
+ )
1768
+ elif event.type == AdapterEvent.TEXT and event.text:
1769
+ self.db.add_session_event(
1770
+ session_id=sid,
1771
+ event_type="text",
1772
+ content=event.text,
1773
+ )
1774
+ elif event.type == AdapterEvent.TOOL_USE:
1775
+ self.db.add_session_event(
1776
+ session_id=sid,
1777
+ event_type="tool_call",
1778
+ tool_name=event.tool_name,
1779
+ tool_input=event.tool_input,
1780
+ )
1781
+ elif event.type == AdapterEvent.TOOL_RESULT:
1782
+ self.db.add_session_event(
1783
+ session_id=sid,
1784
+ event_type="tool_result",
1785
+ tool_name=event.tool_name,
1786
+ tool_result=event.tool_result[:1000] if event.tool_result else None,
1787
+ )
1788
+ elif event.type == AdapterEvent.ERROR:
1789
+ self.db.add_session_event(
1790
+ session_id=sid,
1791
+ event_type="error",
1792
+ error_message=event.error_message,
1793
+ )
1794
+ elif event.type == AdapterEvent.COMPLETE:
1795
+ self.db.add_session_event(
1796
+ session_id=sid,
1797
+ event_type="complete",
1798
+ )
1799
+ except Exception as exc:
1800
+ import logging
1801
+ logging.getLogger(__name__).debug(
1802
+ f"[PERSIST] Failed to persist {event.type} event for session {sid}: {exc}"
1803
+ )
1804
+
1652
1805
  exec_result = await self.adapter.execute(
1653
1806
  prompt=prompt,
1654
1807
  model=mode.model,
@@ -1656,6 +1809,7 @@ class LoopExecutor:
1656
1809
  timeout=mode.timeout,
1657
1810
  json_schema=json_schema,
1658
1811
  on_session_start=register_session_early,
1812
+ on_event=persist_event,
1659
1813
  )
1660
1814
 
1661
1815
  result.session_id = exec_result.session_id
@@ -1945,8 +2099,8 @@ class LoopExecutor:
1945
2099
  generator_done = True
1946
2100
  stop_reason = f"Generator signaled completion after {self._iteration} iterations, {self._items_generated} items"
1947
2101
 
1948
- elif effective_max <= 0 and self._no_items_streak >= 3:
1949
- # Unlimited mode (-1 or 0) and 3 consecutive empty iterations
2102
+ elif self._no_items_streak >= 3:
2103
+ # 3 consecutive empty iterations extraction is exhausted
1950
2104
  generator_done = True
1951
2105
  stop_reason = f"Generator exhausted (3 empty iterations), {self._items_generated} items generated"
1952
2106
 
@@ -178,6 +178,19 @@ Return your findings as JSON:
178
178
  - Avoid duplicating stories that already exist
179
179
  - Flag any ambiguous requirements for clarification
180
180
  - Mark infrastructure/architecture stories with category "INFRA" or "ARCH"
181
+
182
+ ## Completion Signal
183
+
184
+ When you have exhausted all stories from the documents and have nothing new to add,
185
+ return an empty stories array and output `[GENERATION_COMPLETE]` at the end of your response:
186
+
187
+ ```json
188
+ {"stories": [], "notes": "All requirements have been extracted."}
189
+ ```
190
+
191
+ [GENERATION_COMPLETE]
192
+
193
+ Do NOT emit this signal if you are returning new stories in this response.
181
194
  """
182
195
 
183
196
  PLANNING_RESEARCH_PROMPT = """# Research Mode
@@ -290,6 +303,19 @@ Total: {{total_stories}}
290
303
  4. If web search returns nothing useful, return empty stories array with explanation
291
304
  5. DO NOT duplicate existing stories - check IDs and titles carefully
292
305
  6. Focus on GAPS - things genuinely missing, not rephrasing existing stories
306
+
307
+ ## Completion Signal
308
+
309
+ When you have exhausted all gaps and have nothing new to add,
310
+ return an empty stories array and output `[GENERATION_COMPLETE]` at the end of your response:
311
+
312
+ ```json
313
+ {"stories": [], "gaps_found": [], "notes": "All gaps have been addressed."}
314
+ ```
315
+
316
+ [GENERATION_COMPLETE]
317
+
318
+ Do NOT emit this signal if you are returning new stories in this response.
293
319
  """
294
320
 
295
321