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.
@@ -4,12 +4,15 @@ Planning sessions are interactive chat-based conversations with Claude
4
4
  for the planning step of workflows.
5
5
  """
6
6
 
7
+ import asyncio
8
+ import json
7
9
  import logging
8
10
  import sqlite3
9
11
  import uuid
12
+ from datetime import datetime, timedelta
10
13
  from typing import Any, Optional
11
14
 
12
- from fastapi import APIRouter, HTTPException, status
15
+ from fastapi import APIRouter, HTTPException, Query, status
13
16
  from fastapi.responses import StreamingResponse
14
17
  from pydantic import BaseModel, Field
15
18
 
@@ -48,6 +51,38 @@ class PlanningSessionResponse(BaseModel):
48
51
  updated_at: str
49
52
 
50
53
 
54
+ class PlanningSessionSummary(BaseModel):
55
+ """Summary of a planning session for list display."""
56
+
57
+ id: str
58
+ step_id: int
59
+ status: str # 'active', 'completed', 'interrupted'
60
+ message_count: int
61
+ first_user_message: Optional[str] = None # Truncated to 100 chars
62
+ created_at: str
63
+ updated_at: str
64
+ # Diff stats (if we have before/after snapshots)
65
+ chars_added: Optional[int] = None
66
+ chars_removed: Optional[int] = None
67
+ backup_created: Optional[str] = None # Backup filename if one was created
68
+
69
+
70
+ class PlanningSessionDetail(BaseModel):
71
+ """Full planning session with messages."""
72
+
73
+ id: str
74
+ workflow_id: str
75
+ step_id: int
76
+ status: str
77
+ messages: list[PlanningMessage]
78
+ artifacts: Optional[dict] = None
79
+ created_at: str
80
+ updated_at: str
81
+ # Snapshot info
82
+ initial_content_size: Optional[int] = None # Size when session started
83
+ final_content_size: Optional[int] = None # Size when session ended
84
+
85
+
51
86
  class SendMessageRequest(BaseModel):
52
87
  """Request model for sending a message to Claude."""
53
88
 
@@ -68,6 +103,95 @@ class ArtifactUpdate(BaseModel):
68
103
  guardrails: Optional[str] = None
69
104
 
70
105
 
106
+ # ============================================================================
107
+ # Iteration-Based Planning Models (v17)
108
+ # ============================================================================
109
+
110
+
111
+ class StartIterationRequest(BaseModel):
112
+ """Request model for starting an iteration session."""
113
+
114
+ prompt: str = Field(..., min_length=1, description="User's guidance for the iterations")
115
+ iterations: int = Field(default=3, ge=1, le=10, description="Number of iterations (1-10)")
116
+ model: str = Field(default="opus", description="Model to use")
117
+
118
+
119
+ class CancelIterationRequest(BaseModel):
120
+ """Request model for cancelling an iteration session."""
121
+
122
+ session_id: str = Field(..., description="Session ID to cancel")
123
+
124
+
125
+ class IterationResponse(BaseModel):
126
+ """Response model for iteration session."""
127
+
128
+ id: str
129
+ workflow_id: str
130
+ step_id: int
131
+ prompt: Optional[str] = None
132
+ iterations_requested: int
133
+ iterations_completed: int
134
+ current_iteration: int
135
+ run_status: str
136
+ is_legacy: bool
137
+ error_message: Optional[str] = None
138
+ artifacts: Optional[dict] = None
139
+ status: str
140
+ created_at: str
141
+ updated_at: str
142
+
143
+
144
+ class PlanningIterationSummary(BaseModel):
145
+ """Summary of a planning iteration."""
146
+
147
+ id: int
148
+ iteration_number: int
149
+ status: str
150
+ chars_added: int
151
+ chars_removed: int
152
+ summary: Optional[str] = None
153
+ started_at: Optional[str] = None
154
+ completed_at: Optional[str] = None
155
+
156
+
157
+ class DiffLine(BaseModel):
158
+ """A single line in a unified diff."""
159
+
160
+ line: str
161
+ type: str # 'add', 'remove', 'context', 'hunk'
162
+
163
+
164
+ class IterationDiffResponse(BaseModel):
165
+ """Response model for iteration diff."""
166
+
167
+ iteration_id: int
168
+ iteration_number: int
169
+ diff_text: Optional[str] = None
170
+ chars_added: int = 0
171
+ chars_removed: int = 0
172
+ diff_lines: list[DiffLine] = []
173
+
174
+
175
+ class IterationSessionSummary(BaseModel):
176
+ """Summary of an iteration session for list display."""
177
+
178
+ id: str
179
+ step_id: int
180
+ status: str
181
+ run_status: str
182
+ is_legacy: bool
183
+ prompt: Optional[str] = None # Full for iteration sessions, truncated for legacy
184
+ iterations_requested: int
185
+ iterations_completed: int
186
+ current_iteration: int = 0
187
+ created_at: str
188
+ updated_at: str
189
+ # Aggregate stats from iterations
190
+ total_chars_added: int = 0
191
+ total_chars_removed: int = 0
192
+ iterations: list[PlanningIterationSummary] = []
193
+
194
+
71
195
  # ============================================================================
72
196
  # Helper Functions
73
197
  # ============================================================================
@@ -195,11 +319,33 @@ async def get_planning_session(slug: str, workflow_id: str):
195
319
  session = pdb.get_planning_session_by_step(current_step["id"])
196
320
  if not session:
197
321
  session_id = f"ps-{uuid.uuid4().hex[:12]}"
322
+
323
+ # Check if step has a design_doc_path configured - load existing content
324
+ initial_artifacts = None
325
+ step_config = current_step.get("config") or {}
326
+ design_doc_path = step_config.get("design_doc_path")
327
+ if design_doc_path:
328
+ from pathlib import Path
329
+ doc_dir = Path(project["path"]) / ".ralphx" / "resources" / "design_doc"
330
+ doc_file = doc_dir / design_doc_path
331
+ # Security: verify path stays within design_doc directory
332
+ if (".." not in design_doc_path and "\0" not in design_doc_path
333
+ and doc_file.resolve().is_relative_to(doc_dir.resolve())
334
+ and doc_file.exists()):
335
+ try:
336
+ initial_artifacts = {"design_doc": doc_file.read_text()}
337
+ logger.info(f"Loaded existing design doc from {doc_file}")
338
+ except Exception as e:
339
+ logger.warning(f"Failed to load design doc {doc_file}: {e}")
340
+ elif ".." in design_doc_path or "\0" in design_doc_path:
341
+ logger.warning(f"Path traversal blocked in design_doc_path: {design_doc_path!r}")
342
+
198
343
  session = pdb.create_planning_session(
199
344
  id=session_id,
200
345
  workflow_id=workflow_id,
201
346
  step_id=current_step["id"],
202
347
  messages=[],
348
+ artifacts=initial_artifacts,
203
349
  )
204
350
 
205
351
  return _session_to_response(session)
@@ -276,8 +422,16 @@ async def stream_planning_response(slug: str, workflow_id: str):
276
422
  step = pdb.get_workflow_step(session["step_id"])
277
423
  step_config = step.get("config", {}) if step else {}
278
424
 
279
- # Extract configuration from step
280
- allowed_tools = step_config.get("allowedTools", [])
425
+ # Default tools for design_doc steps (matches PROCESSING_TYPES in workflows.py)
426
+ DEFAULT_DESIGN_DOC_TOOLS = ["WebSearch", "WebFetch", "Bash", "Read", "Glob", "Grep", "Edit", "Write"]
427
+
428
+ # Extract configuration from step, with defaults for design_doc
429
+ loop_type = step_config.get("loopType", "design_doc")
430
+ allowed_tools = step_config.get("allowedTools")
431
+ if allowed_tools is None and loop_type == "design_doc":
432
+ allowed_tools = DEFAULT_DESIGN_DOC_TOOLS
433
+ elif allowed_tools is None:
434
+ allowed_tools = []
281
435
  model = step_config.get("model", "opus") # Default to opus for design docs
282
436
  timeout = step_config.get("timeout", 180)
283
437
 
@@ -298,6 +452,9 @@ async def stream_planning_response(slug: str, workflow_id: str):
298
452
  messages = session.get("messages", [])
299
453
  accumulated = ""
300
454
 
455
+ error_occurred = False
456
+ error_message = None
457
+
301
458
  try:
302
459
  async for event in service.stream_response(
303
460
  messages,
@@ -309,34 +466,51 @@ async def stream_planning_response(slug: str, workflow_id: str):
309
466
  text = event.text or ""
310
467
  accumulated += text
311
468
  yield f"data: {json.dumps({'type': 'content', 'content': text})}\n\n"
469
+ elif event.type == AdapterEvent.TOOL_USE:
470
+ # Forward tool use events so frontend can show activity
471
+ yield f"data: {json.dumps({'type': 'tool_use', 'tool': event.tool_name, 'input': event.tool_input})}\n\n"
472
+ elif event.type == AdapterEvent.TOOL_RESULT:
473
+ # Forward tool result (truncated for display)
474
+ result_preview = str(event.tool_result or "")[:200]
475
+ if len(str(event.tool_result or "")) > 200:
476
+ result_preview += "..."
477
+ yield f"data: {json.dumps({'type': 'tool_result', 'tool': event.tool_name, 'result': result_preview})}\n\n"
312
478
  elif event.type == AdapterEvent.ERROR:
313
479
  logger.warning(f"Claude error: {event.error_message}")
314
- safe_message = _sanitize_error_message(event.error_message or "Claude error")
315
- yield f"data: {json.dumps({'type': 'error', 'message': safe_message})}\n\n"
316
- return
480
+ error_occurred = True
481
+ error_message = _sanitize_error_message(event.error_message or "Claude error")
482
+ # Don't return early - save accumulated content first
483
+ break
317
484
  elif event.type == AdapterEvent.COMPLETE:
318
485
  break
319
486
 
320
- # Add assistant message to session
321
- if accumulated:
487
+ except Exception as e:
488
+ # Log full error for debugging but sanitize for client
489
+ logger.warning(f"Error during streaming response: {e}", exc_info=True)
490
+ error_occurred = True
491
+ error_message = _sanitize_error_message(str(e))
492
+
493
+ # Always save accumulated content, even on error
494
+ # This preserves partial responses from Claude
495
+ if accumulated:
496
+ try:
322
497
  pdb.add_planning_message(
323
498
  session_id=session["id"],
324
499
  role="assistant",
325
500
  content=accumulated,
326
501
  )
502
+ except Exception as save_err:
503
+ logger.warning(f"Failed to save accumulated message: {save_err}")
327
504
 
328
- yield f"data: {json.dumps({'type': 'done'})}\n\n"
329
-
330
- except Exception as e:
331
- # Log full error for debugging but sanitize for client
332
- logger.warning(f"Error during streaming response: {e}", exc_info=True)
505
+ # Send error if one occurred (after saving content)
506
+ if error_occurred:
333
507
  try:
334
- # Sanitize error message to avoid leaking internal paths/details
335
- safe_message = _sanitize_error_message(str(e))
336
- yield f"data: {json.dumps({'type': 'error', 'message': safe_message})}\n\n"
508
+ yield f"data: {json.dumps({'type': 'error', 'message': error_message})}\n\n"
337
509
  except Exception:
338
510
  pass # Client disconnected
339
511
 
512
+ yield f"data: {json.dumps({'type': 'done'})}\n\n"
513
+
340
514
  return StreamingResponse(
341
515
  generate_response(),
342
516
  media_type="text/event-stream",
@@ -606,3 +780,678 @@ async def generate_artifacts(slug: str, workflow_id: str):
606
780
  "Connection": "keep-alive",
607
781
  },
608
782
  )
783
+
784
+
785
+ # ============================================================================
786
+ # Iteration-Based Planning Endpoints (v17)
787
+ # ============================================================================
788
+
789
+
790
+ def _session_to_iteration_response(session: dict) -> IterationResponse:
791
+ """Convert planning session to iteration response model."""
792
+ return IterationResponse(
793
+ id=session["id"],
794
+ workflow_id=session["workflow_id"],
795
+ step_id=session["step_id"],
796
+ prompt=session.get("prompt"),
797
+ iterations_requested=session.get("iterations_requested", 1),
798
+ iterations_completed=session.get("iterations_completed", 0),
799
+ current_iteration=session.get("current_iteration", 0),
800
+ run_status=session.get("run_status", "pending"),
801
+ is_legacy=session.get("is_legacy", False),
802
+ error_message=session.get("error_message"),
803
+ artifacts=session.get("artifacts"),
804
+ status=session["status"],
805
+ created_at=session["created_at"],
806
+ updated_at=session["updated_at"],
807
+ )
808
+
809
+
810
+ @router.post(
811
+ "/workflows/{workflow_id}/planning/iterate",
812
+ response_model=IterationResponse,
813
+ )
814
+ async def start_iteration_session(
815
+ slug: str, workflow_id: str, request: StartIterationRequest
816
+ ):
817
+ """Start a new iteration-based planning session.
818
+
819
+ Creates a new session and returns immediately. Use the stream endpoint
820
+ to receive progress events as iterations run.
821
+ """
822
+ pdb, project = _get_project_db(slug)
823
+
824
+ # Verify workflow exists
825
+ workflow = pdb.get_workflow(workflow_id)
826
+ if not workflow:
827
+ raise HTTPException(
828
+ status_code=status.HTTP_404_NOT_FOUND,
829
+ detail=f"Workflow '{workflow_id}' not found",
830
+ )
831
+
832
+ # Find the current interactive step
833
+ steps = pdb.list_workflow_steps(workflow_id)
834
+ current_step = None
835
+ for s in steps:
836
+ if s["step_number"] == workflow["current_step"]:
837
+ current_step = s
838
+ break
839
+
840
+ if not current_step:
841
+ raise HTTPException(
842
+ status_code=status.HTTP_400_BAD_REQUEST,
843
+ detail="No current step found",
844
+ )
845
+
846
+ if current_step["step_type"] != "interactive":
847
+ raise HTTPException(
848
+ status_code=status.HTTP_400_BAD_REQUEST,
849
+ detail=f"Current step '{current_step['name']}' is not interactive",
850
+ )
851
+
852
+ # Check for already running session (concurrency protection) with stale recovery
853
+ existing = pdb.get_running_planning_session(workflow_id)
854
+ if existing:
855
+ updated = datetime.fromisoformat(existing["updated_at"])
856
+ if datetime.utcnow() - updated > timedelta(minutes=10):
857
+ logger.warning(f"Auto-recovering stale session '{existing['id']}' (last updated: {existing['updated_at']})")
858
+ pdb.update_planning_session(
859
+ existing["id"], run_status="error", error_message="Session timed out (stale recovery)"
860
+ )
861
+ else:
862
+ raise HTTPException(
863
+ status_code=status.HTTP_409_CONFLICT,
864
+ detail=f"Session '{existing['id']}' is already running. Cancel it first.",
865
+ )
866
+
867
+ # Check if there's an existing active session to build upon
868
+ existing_session = pdb.get_planning_session_by_step(current_step["id"])
869
+ initial_artifacts = None
870
+
871
+ if existing_session:
872
+ # Preserve existing artifacts (design doc)
873
+ initial_artifacts = existing_session.get("artifacts")
874
+ # Mark old session as completed if it's active
875
+ if existing_session["status"] == "active":
876
+ pdb.update_planning_session(
877
+ existing_session["id"],
878
+ status="completed",
879
+ run_status="completed" if existing_session.get("run_status") == "running" else existing_session.get("run_status", "completed"),
880
+ )
881
+
882
+ # Also check step config for design_doc_path to load existing content
883
+ if not initial_artifacts:
884
+ step_config = current_step.get("config") or {}
885
+ design_doc_path = step_config.get("design_doc_path")
886
+ if design_doc_path:
887
+ from pathlib import Path
888
+ doc_dir = Path(project["path"]) / ".ralphx" / "resources" / "design_doc"
889
+ doc_file = doc_dir / design_doc_path
890
+ # Security: verify path stays within design_doc directory
891
+ if (".." not in design_doc_path and "\0" not in design_doc_path
892
+ and doc_file.resolve().is_relative_to(doc_dir.resolve())
893
+ and doc_file.exists()):
894
+ try:
895
+ initial_artifacts = {"design_doc": doc_file.read_text()}
896
+ logger.info(f"Loaded existing design doc from {doc_file}")
897
+ except Exception as e:
898
+ logger.warning(f"Failed to load design doc {doc_file}: {e}")
899
+ elif ".." in design_doc_path or "\0" in design_doc_path:
900
+ logger.warning(f"Path traversal blocked in design_doc_path: {design_doc_path!r}")
901
+
902
+ # Create new iteration session
903
+ session_id = f"ps-{uuid.uuid4().hex[:12]}"
904
+ session = pdb.create_planning_session(
905
+ id=session_id,
906
+ workflow_id=workflow_id,
907
+ step_id=current_step["id"],
908
+ messages=[], # Not used in iteration mode
909
+ artifacts=initial_artifacts,
910
+ status="active",
911
+ prompt=request.prompt,
912
+ iterations_requested=request.iterations,
913
+ run_status="pending",
914
+ is_legacy=False,
915
+ )
916
+
917
+ # Get step configuration for tools/model
918
+ step_config = current_step.get("config", {}) or {}
919
+ DEFAULT_DESIGN_DOC_TOOLS = ["WebSearch", "WebFetch", "Bash", "Read", "Glob", "Grep", "Edit", "Write"]
920
+ allowed_tools = step_config.get("allowedTools") or DEFAULT_DESIGN_DOC_TOOLS
921
+ model = step_config.get("model", "opus")
922
+
923
+ # Launch executor as background task
924
+ async def run_executor_background():
925
+ from ralphx.core.project import Project
926
+ from ralphx.core.planning_iteration_executor import PlanningIterationExecutor
927
+
928
+ project_obj = Project.from_dict(project)
929
+
930
+ # Resolve the design doc file path for file-based editing
931
+ from pathlib import Path as _Path
932
+ _doc_dir = _Path(project["path"]) / ".ralphx" / "resources"
933
+ _configured_path = step_config.get("design_doc_path")
934
+ if _configured_path:
935
+ _design_doc_dir = _doc_dir / "design_doc"
936
+ _doc_file = _design_doc_dir / _configured_path
937
+ # Security: verify path stays within design_doc directory
938
+ if (".." in _configured_path or "\0" in _configured_path
939
+ or not _doc_file.resolve().is_relative_to(_design_doc_dir.resolve())):
940
+ logger.warning(f"Path traversal blocked in design_doc_path for executor: {_configured_path!r}")
941
+ _doc_file = _doc_dir / f"design-doc-{workflow_id}.md"
942
+ else:
943
+ _doc_file = _doc_dir / f"design-doc-{workflow_id}.md"
944
+
945
+ executor = PlanningIterationExecutor(
946
+ project=project_obj,
947
+ pdb=pdb,
948
+ session_id=session_id,
949
+ project_id=project.get("id"),
950
+ design_doc_path=str(_doc_file),
951
+ )
952
+
953
+ try:
954
+ async for event in executor.run(
955
+ prompt=request.prompt,
956
+ iterations=request.iterations,
957
+ model=model,
958
+ tools=allowed_tools,
959
+ ):
960
+ # Persist every event to DB
961
+ pdb.add_planning_iteration_event(
962
+ session_id=session_id,
963
+ event_type=event.get("type", "unknown"),
964
+ iteration_number=event.get("iteration"),
965
+ content=event.get("text"),
966
+ tool_name=event.get("tool"),
967
+ tool_input=json.dumps(event.get("input"))[:1000] if event.get("input") else None,
968
+ tool_result=(event.get("result") or "")[:1000] if event.get("result") else None,
969
+ event_data=json.dumps(event),
970
+ )
971
+ except Exception as e:
972
+ logger.error(f"Background executor error for session {session_id}: {e}", exc_info=True)
973
+ try:
974
+ pdb.update_planning_session(session_id, run_status="error", error_message="Executor failed")
975
+ pdb.add_planning_iteration_event(
976
+ session_id=session_id,
977
+ event_type="error",
978
+ event_data=json.dumps({"type": "error", "message": "Execution failed unexpectedly", "fatal": True}),
979
+ )
980
+ except Exception:
981
+ pass
982
+
983
+ task = asyncio.create_task(run_executor_background(), name=f"planning-iteration-{session_id}")
984
+
985
+ def _on_task_done(t: asyncio.Task) -> None:
986
+ if t.cancelled():
987
+ logger.warning(f"Planning iteration task {session_id} was cancelled")
988
+ elif t.exception():
989
+ logger.error(f"Planning iteration task {session_id} failed: {t.exception()}")
990
+
991
+ task.add_done_callback(_on_task_done)
992
+
993
+ return _session_to_iteration_response(session)
994
+
995
+
996
+ @router.get("/workflows/{workflow_id}/planning/iterate/stream/{session_id}")
997
+ async def stream_iteration_progress(
998
+ slug: str,
999
+ workflow_id: str,
1000
+ session_id: str,
1001
+ after_event_id: int = Query(default=0, description="Resume from this event ID"),
1002
+ ):
1003
+ """Stream iteration progress via Server-Sent Events (DB-polling).
1004
+
1005
+ This endpoint polls the planning_iteration_events table for new events.
1006
+ Supports reconnection: pass after_event_id to resume from where you left off.
1007
+
1008
+ Events include:
1009
+ - iteration_start: {iteration: N, total: M}
1010
+ - tool_use: {tool: "WebSearch", input: {...}}
1011
+ - tool_result: {tool: "WebSearch", result: "..."}
1012
+ - content: {text: "..."} - Claude's response text
1013
+ - design_doc_updated: {chars_added: N, chars_removed: M}
1014
+ - heartbeat: {} - Periodic keepalive
1015
+ - iteration_complete: {iteration: N, summary: "..."}
1016
+ - error: {message: "..."}
1017
+ - cancelled: {iterations_completed: N}
1018
+ - done: {iterations_completed: N}
1019
+ """
1020
+ pdb, project = _get_project_db(slug)
1021
+
1022
+ # Verify session exists and belongs to this workflow
1023
+ session = pdb.get_planning_session(session_id)
1024
+ if not session:
1025
+ raise HTTPException(
1026
+ status_code=status.HTTP_404_NOT_FOUND,
1027
+ detail=f"Session '{session_id}' not found",
1028
+ )
1029
+
1030
+ if session["workflow_id"] != workflow_id:
1031
+ raise HTTPException(
1032
+ status_code=status.HTTP_404_NOT_FOUND,
1033
+ detail=f"Session '{session_id}' not found in workflow '{workflow_id}'",
1034
+ )
1035
+
1036
+ async def generate_stream():
1037
+ """Poll DB for events and stream as SSE."""
1038
+ last_id = after_event_id
1039
+
1040
+ # Immediate stale check before entering loop
1041
+ current = pdb.get_planning_session(session_id)
1042
+ if current and current.get("run_status") == "running":
1043
+ last_event_ts = pdb.get_latest_event_timestamp(session_id)
1044
+ # Fall back to created_at if no events exist yet
1045
+ check_ts = last_event_ts or current.get("created_at")
1046
+ if check_ts:
1047
+ elapsed = datetime.utcnow() - datetime.fromisoformat(check_ts)
1048
+ if elapsed > timedelta(minutes=7):
1049
+ pdb.update_planning_session(
1050
+ session_id, run_status="error",
1051
+ error_message="Session timed out (no activity)",
1052
+ )
1053
+ yield f"data: {json.dumps({'type': 'error', 'message': 'Session timed out (no activity)', 'fatal': True})}\n\n"
1054
+ return
1055
+
1056
+ while True:
1057
+ # Fetch new events from DB
1058
+ events = pdb.get_planning_iteration_events(session_id, after_id=last_id)
1059
+ for evt in events:
1060
+ last_id = evt["id"]
1061
+ if evt.get("event_data"):
1062
+ try:
1063
+ event_data = json.loads(evt["event_data"])
1064
+ except (json.JSONDecodeError, TypeError):
1065
+ event_data = {"type": evt["event_type"]}
1066
+ else:
1067
+ event_data = {"type": evt["event_type"]}
1068
+ event_data["_event_id"] = evt["id"]
1069
+ yield f"data: {json.dumps(event_data)}\n\n"
1070
+
1071
+ # Check if session reached terminal status
1072
+ current = pdb.get_planning_session(session_id)
1073
+
1074
+ # Check for stale session (no events for >7 min while supposedly running)
1075
+ if current and current.get("run_status") == "running":
1076
+ last_event_ts = pdb.get_latest_event_timestamp(session_id)
1077
+ check_ts = last_event_ts or (current.get("created_at") if current else None)
1078
+ if check_ts:
1079
+ elapsed = datetime.utcnow() - datetime.fromisoformat(check_ts)
1080
+ if elapsed > timedelta(minutes=7):
1081
+ pdb.update_planning_session(
1082
+ session_id, run_status="error",
1083
+ error_message="Session timed out (no activity)",
1084
+ )
1085
+ yield f"data: {json.dumps({'type': 'error', 'message': 'Session timed out (no activity)', 'fatal': True})}\n\n"
1086
+ break
1087
+
1088
+ if current and current.get("run_status") in ("completed", "error", "cancelled"):
1089
+ # Drain any remaining events
1090
+ final_events = pdb.get_planning_iteration_events(session_id, after_id=last_id)
1091
+ for evt in final_events:
1092
+ last_id = evt["id"]
1093
+ if evt.get("event_data"):
1094
+ try:
1095
+ event_data = json.loads(evt["event_data"])
1096
+ except (json.JSONDecodeError, TypeError):
1097
+ event_data = {"type": evt["event_type"]}
1098
+ else:
1099
+ event_data = {"type": evt["event_type"]}
1100
+ event_data["_event_id"] = evt["id"]
1101
+ yield f"data: {json.dumps(event_data)}\n\n"
1102
+ break
1103
+
1104
+ # Heartbeat to keep connection alive
1105
+ yield f"data: {json.dumps({'type': 'heartbeat'})}\n\n"
1106
+ await asyncio.sleep(0.5)
1107
+
1108
+ return StreamingResponse(
1109
+ generate_stream(),
1110
+ media_type="text/event-stream",
1111
+ headers={
1112
+ "Cache-Control": "no-cache",
1113
+ "Connection": "keep-alive",
1114
+ "X-Accel-Buffering": "no",
1115
+ },
1116
+ )
1117
+
1118
+
1119
+ @router.post(
1120
+ "/workflows/{workflow_id}/planning/iterate/cancel",
1121
+ response_model=IterationResponse,
1122
+ )
1123
+ async def cancel_iteration_session(
1124
+ slug: str, workflow_id: str, request: CancelIterationRequest
1125
+ ):
1126
+ """Cancel a running iteration session.
1127
+
1128
+ Marks the session as cancelled. The running iteration will complete
1129
+ but no further iterations will start.
1130
+ """
1131
+ pdb, project = _get_project_db(slug)
1132
+
1133
+ session = pdb.get_planning_session(request.session_id)
1134
+ if not session:
1135
+ raise HTTPException(
1136
+ status_code=status.HTTP_404_NOT_FOUND,
1137
+ detail=f"Session '{request.session_id}' not found",
1138
+ )
1139
+
1140
+ if session["workflow_id"] != workflow_id:
1141
+ raise HTTPException(
1142
+ status_code=status.HTTP_404_NOT_FOUND,
1143
+ detail=f"Session '{request.session_id}' not found in workflow '{workflow_id}'",
1144
+ )
1145
+
1146
+ if session.get("run_status") != "running":
1147
+ raise HTTPException(
1148
+ status_code=status.HTTP_400_BAD_REQUEST,
1149
+ detail="Session is not running",
1150
+ )
1151
+
1152
+ # Mark as cancelled (executor will pick this up)
1153
+ pdb.cancel_planning_session(request.session_id)
1154
+
1155
+ session = pdb.get_planning_session(request.session_id)
1156
+ return _session_to_iteration_response(session)
1157
+
1158
+
1159
+ @router.get(
1160
+ "/workflows/{workflow_id}/planning/iterate/{session_id}",
1161
+ response_model=IterationResponse,
1162
+ )
1163
+ async def get_iteration_session(slug: str, workflow_id: str, session_id: str):
1164
+ """Get details of an iteration session including progress."""
1165
+ pdb, project = _get_project_db(slug)
1166
+
1167
+ session = pdb.get_planning_session(session_id)
1168
+ if not session:
1169
+ raise HTTPException(
1170
+ status_code=status.HTTP_404_NOT_FOUND,
1171
+ detail=f"Session '{session_id}' not found",
1172
+ )
1173
+
1174
+ if session["workflow_id"] != workflow_id:
1175
+ raise HTTPException(
1176
+ status_code=status.HTTP_404_NOT_FOUND,
1177
+ detail=f"Session '{session_id}' not found in workflow '{workflow_id}'",
1178
+ )
1179
+
1180
+ return _session_to_iteration_response(session)
1181
+
1182
+
1183
+ @router.get("/workflows/{workflow_id}/planning/iterate/{session_id}/events")
1184
+ async def get_iteration_events(
1185
+ slug: str,
1186
+ workflow_id: str,
1187
+ session_id: str,
1188
+ after_id: int = Query(default=0, description="Only return events after this ID"),
1189
+ limit: int = Query(default=500, ge=1, le=1000),
1190
+ ):
1191
+ """Get persisted iteration events for a session.
1192
+
1193
+ Use after_id for pagination. Returns events ordered by ID ascending.
1194
+ """
1195
+ pdb, project = _get_project_db(slug)
1196
+
1197
+ session = pdb.get_planning_session(session_id)
1198
+ if not session:
1199
+ raise HTTPException(
1200
+ status_code=status.HTTP_404_NOT_FOUND,
1201
+ detail=f"Session '{session_id}' not found",
1202
+ )
1203
+
1204
+ if session["workflow_id"] != workflow_id:
1205
+ raise HTTPException(
1206
+ status_code=status.HTTP_404_NOT_FOUND,
1207
+ detail=f"Session '{session_id}' not found in workflow '{workflow_id}'",
1208
+ )
1209
+
1210
+ return pdb.get_planning_iteration_events(session_id, after_id=after_id, limit=limit)
1211
+
1212
+
1213
+ @router.get(
1214
+ "/workflows/{workflow_id}/planning/iterate/{session_id}/iterations",
1215
+ response_model=list[PlanningIterationSummary],
1216
+ )
1217
+ async def list_session_iterations(slug: str, workflow_id: str, session_id: str):
1218
+ """List all iterations for a session with their stats."""
1219
+ pdb, project = _get_project_db(slug)
1220
+
1221
+ session = pdb.get_planning_session(session_id)
1222
+ if not session:
1223
+ raise HTTPException(
1224
+ status_code=status.HTTP_404_NOT_FOUND,
1225
+ detail=f"Session '{session_id}' not found",
1226
+ )
1227
+
1228
+ if session["workflow_id"] != workflow_id:
1229
+ raise HTTPException(
1230
+ status_code=status.HTTP_404_NOT_FOUND,
1231
+ detail=f"Session '{session_id}' not found in workflow '{workflow_id}'",
1232
+ )
1233
+
1234
+ iterations = pdb.list_planning_iterations(session_id)
1235
+
1236
+ return [
1237
+ PlanningIterationSummary(
1238
+ id=it["id"],
1239
+ iteration_number=it["iteration_number"],
1240
+ status=it["status"],
1241
+ chars_added=it.get("chars_added", 0),
1242
+ chars_removed=it.get("chars_removed", 0),
1243
+ summary=it.get("summary"),
1244
+ started_at=it.get("started_at"),
1245
+ completed_at=it.get("completed_at"),
1246
+ )
1247
+ for it in iterations
1248
+ ]
1249
+
1250
+
1251
+ @router.get(
1252
+ "/workflows/{workflow_id}/planning/iterate/{session_id}/iterations/{iteration_id}/diff",
1253
+ response_model=IterationDiffResponse,
1254
+ )
1255
+ async def get_iteration_diff(
1256
+ slug: str, workflow_id: str, session_id: str, iteration_id: int
1257
+ ):
1258
+ """Get the unified diff for a specific iteration."""
1259
+ pdb, project = _get_project_db(slug)
1260
+
1261
+ session = pdb.get_planning_session(session_id)
1262
+ if not session:
1263
+ raise HTTPException(
1264
+ status_code=status.HTTP_404_NOT_FOUND,
1265
+ detail=f"Session '{session_id}' not found",
1266
+ )
1267
+
1268
+ if session["workflow_id"] != workflow_id:
1269
+ raise HTTPException(
1270
+ status_code=status.HTTP_404_NOT_FOUND,
1271
+ detail=f"Session '{session_id}' not found in workflow '{workflow_id}'",
1272
+ )
1273
+
1274
+ # Fetch the specific iteration directly (avoids loading all diffs)
1275
+ iteration = pdb.get_planning_iteration(iteration_id)
1276
+ if not iteration or iteration.get("session_id") != session_id:
1277
+ raise HTTPException(
1278
+ status_code=status.HTTP_404_NOT_FOUND,
1279
+ detail=f"Iteration '{iteration_id}' not found in session '{session_id}'",
1280
+ )
1281
+
1282
+ diff_text = iteration.get("diff_text")
1283
+
1284
+ # Fallback: compute diff on-the-fly from doc snapshots if diff_text is missing
1285
+ if not diff_text:
1286
+ doc_before = iteration.get("doc_before")
1287
+ doc_after = iteration.get("doc_after")
1288
+ if doc_before is not None and doc_after is not None:
1289
+ import difflib
1290
+
1291
+ diff_text = "\n".join(
1292
+ difflib.unified_diff(
1293
+ doc_before.splitlines(),
1294
+ doc_after.splitlines(),
1295
+ fromfile="before",
1296
+ tofile="after",
1297
+ lineterm="",
1298
+ )
1299
+ )
1300
+
1301
+ diff_lines: list[DiffLine] = []
1302
+
1303
+ if diff_text:
1304
+ for line in diff_text.splitlines():
1305
+ if line.startswith("+") and not line.startswith("+++"):
1306
+ diff_lines.append(DiffLine(line=line, type="add"))
1307
+ elif line.startswith("-") and not line.startswith("---"):
1308
+ diff_lines.append(DiffLine(line=line, type="remove"))
1309
+ elif line.startswith("@@"):
1310
+ diff_lines.append(DiffLine(line=line, type="hunk"))
1311
+ elif not line.startswith("+++") and not line.startswith("---"):
1312
+ diff_lines.append(DiffLine(line=line, type="context"))
1313
+
1314
+ return IterationDiffResponse(
1315
+ iteration_id=iteration["id"],
1316
+ iteration_number=iteration["iteration_number"],
1317
+ diff_text=diff_text,
1318
+ chars_added=iteration.get("chars_added", 0),
1319
+ chars_removed=iteration.get("chars_removed", 0),
1320
+ diff_lines=diff_lines,
1321
+ )
1322
+
1323
+
1324
+ # ============================================================================
1325
+ # Planning Session History Endpoints (updated for v17)
1326
+ # ============================================================================
1327
+
1328
+
1329
+ @router.get(
1330
+ "/workflows/{workflow_id}/planning/sessions",
1331
+ response_model=list[IterationSessionSummary],
1332
+ )
1333
+ async def list_workflow_planning_sessions(slug: str, workflow_id: str):
1334
+ """List all planning sessions for a workflow.
1335
+
1336
+ Returns sessions in reverse chronological order (newest first).
1337
+ Supports both legacy chat-based sessions and new iteration-based sessions.
1338
+ """
1339
+ pdb, project = _get_project_db(slug)
1340
+
1341
+ # Verify workflow exists
1342
+ workflow = pdb.get_workflow(workflow_id)
1343
+ if not workflow:
1344
+ raise HTTPException(
1345
+ status_code=status.HTTP_404_NOT_FOUND,
1346
+ detail=f"Workflow '{workflow_id}' not found",
1347
+ )
1348
+
1349
+ sessions = pdb.list_planning_sessions(workflow_id=workflow_id)
1350
+
1351
+ summaries = []
1352
+ for session in sessions:
1353
+ is_legacy = session.get("is_legacy", False)
1354
+
1355
+ # For legacy sessions, use first user message as prompt preview
1356
+ prompt_preview = session.get("prompt")
1357
+ if not prompt_preview and is_legacy:
1358
+ messages = session.get("messages", [])
1359
+ user_messages = [m for m in messages if m.get("role") == "user"]
1360
+ if user_messages:
1361
+ content = user_messages[0].get("content", "")
1362
+ prompt_preview = content[:100] if len(content) > 100 else content
1363
+
1364
+ # Get iterations for this session
1365
+ iterations = pdb.list_planning_iterations(session["id"])
1366
+ iteration_summaries = [
1367
+ PlanningIterationSummary(
1368
+ id=it["id"],
1369
+ iteration_number=it["iteration_number"],
1370
+ status=it["status"],
1371
+ chars_added=it.get("chars_added", 0),
1372
+ chars_removed=it.get("chars_removed", 0),
1373
+ summary=it.get("summary"),
1374
+ started_at=it.get("started_at"),
1375
+ completed_at=it.get("completed_at"),
1376
+ )
1377
+ for it in iterations
1378
+ ]
1379
+
1380
+ # Calculate totals from iterations
1381
+ total_chars_added = sum(it.get("chars_added", 0) for it in iterations)
1382
+ total_chars_removed = sum(it.get("chars_removed", 0) for it in iterations)
1383
+
1384
+ summaries.append(
1385
+ IterationSessionSummary(
1386
+ id=session["id"],
1387
+ step_id=session["step_id"],
1388
+ status=session["status"],
1389
+ run_status=session.get("run_status", "completed" if is_legacy else "pending"),
1390
+ is_legacy=is_legacy,
1391
+ prompt=prompt_preview,
1392
+ iterations_requested=session.get("iterations_requested", 0 if is_legacy else 1),
1393
+ iterations_completed=session.get("iterations_completed", 0),
1394
+ current_iteration=session.get("current_iteration", 0),
1395
+ created_at=session["created_at"],
1396
+ updated_at=session["updated_at"],
1397
+ total_chars_added=total_chars_added,
1398
+ total_chars_removed=total_chars_removed,
1399
+ iterations=iteration_summaries,
1400
+ )
1401
+ )
1402
+
1403
+ return summaries
1404
+
1405
+
1406
+ @router.get(
1407
+ "/workflows/{workflow_id}/planning/sessions/{session_id}",
1408
+ response_model=PlanningSessionDetail,
1409
+ )
1410
+ async def get_planning_session_detail(slug: str, workflow_id: str, session_id: str):
1411
+ """Get full details of a specific planning session including all messages."""
1412
+ pdb, project = _get_project_db(slug)
1413
+
1414
+ # Verify workflow exists
1415
+ workflow = pdb.get_workflow(workflow_id)
1416
+ if not workflow:
1417
+ raise HTTPException(
1418
+ status_code=status.HTTP_404_NOT_FOUND,
1419
+ detail=f"Workflow '{workflow_id}' not found",
1420
+ )
1421
+
1422
+ session = pdb.get_planning_session(session_id)
1423
+ if not session:
1424
+ raise HTTPException(
1425
+ status_code=status.HTTP_404_NOT_FOUND,
1426
+ detail=f"Session '{session_id}' not found",
1427
+ )
1428
+
1429
+ # Verify session belongs to this workflow
1430
+ if session["workflow_id"] != workflow_id:
1431
+ raise HTTPException(
1432
+ status_code=status.HTTP_404_NOT_FOUND,
1433
+ detail=f"Session '{session_id}' not found in workflow '{workflow_id}'",
1434
+ )
1435
+
1436
+ messages = [
1437
+ PlanningMessage(
1438
+ role=m["role"],
1439
+ content=m["content"],
1440
+ timestamp=m.get("timestamp", ""),
1441
+ metadata=m.get("metadata"),
1442
+ )
1443
+ for m in session.get("messages", [])
1444
+ ]
1445
+
1446
+ return PlanningSessionDetail(
1447
+ id=session["id"],
1448
+ workflow_id=session["workflow_id"],
1449
+ step_id=session["step_id"],
1450
+ status=session["status"],
1451
+ messages=messages,
1452
+ artifacts=session.get("artifacts"),
1453
+ created_at=session["created_at"],
1454
+ updated_at=session["updated_at"],
1455
+ initial_content_size=session.get("initial_content_size"),
1456
+ final_content_size=session.get("final_content_size"),
1457
+ )