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/core/resources.py CHANGED
@@ -225,6 +225,12 @@ class ResourceManager:
225
225
  """
226
226
  file_path = self._resources_path / resource_data["file_path"]
227
227
 
228
+ # Security: verify path stays within resources directory
229
+ resolved = file_path.resolve()
230
+ resources_root = self._resources_path.resolve()
231
+ if not str(resolved).startswith(str(resources_root) + "/") and resolved != resources_root:
232
+ return None
233
+
228
234
  if not file_path.exists():
229
235
  return None
230
236
 
@@ -426,8 +432,19 @@ class ResourceManager:
426
432
  else:
427
433
  file_name = name
428
434
 
435
+ # Security: prevent path traversal in resource names
436
+ # Only allow simple filenames (alphanumeric, hyphens, underscores, dots)
437
+ if ".." in file_name or "/" in file_name or "\\" in file_name or "\0" in file_name:
438
+ raise ValueError(f"Invalid resource name: {name} (path traversal characters not allowed)")
439
+
429
440
  file_path = self.get_resources_path(resource_type) / f"{file_name}.md"
430
441
 
442
+ # Verify resolved path stays within resources directory
443
+ resolved = file_path.resolve()
444
+ resources_root = self.get_resources_path(resource_type).resolve()
445
+ if not str(resolved).startswith(str(resources_root) + "/") and resolved != resources_root:
446
+ raise ValueError(f"Invalid resource name: {name} (resolves outside resources directory)")
447
+
431
448
  # Ensure directory exists
432
449
  file_path.parent.mkdir(parents=True, exist_ok=True)
433
450
 
@@ -483,6 +500,11 @@ class ResourceManager:
483
500
  # Update file content if provided
484
501
  if content is not None:
485
502
  file_path = self._resources_path / resource["file_path"]
503
+ # Security: verify path stays within resources directory
504
+ resolved = file_path.resolve()
505
+ resources_root = self._resources_path.resolve()
506
+ if not str(resolved).startswith(str(resources_root) + "/") and resolved != resources_root:
507
+ return False
486
508
  file_path.parent.mkdir(parents=True, exist_ok=True)
487
509
  file_path.write_text(content, encoding="utf-8")
488
510
 
@@ -519,8 +541,12 @@ class ResourceManager:
519
541
  # Delete file if requested
520
542
  if delete_file:
521
543
  file_path = self._resources_path / resource["file_path"]
522
- if file_path.exists():
523
- file_path.unlink()
544
+ # Security: verify path stays within resources directory
545
+ resolved = file_path.resolve()
546
+ resources_root = self._resources_path.resolve()
547
+ if str(resolved).startswith(str(resources_root) + "/") or resolved == resources_root:
548
+ if file_path.exists():
549
+ file_path.unlink()
524
550
 
525
551
  # Delete database entry
526
552
  return self.db.delete_resource(resource_id)
ralphx/core/session.py CHANGED
@@ -27,6 +27,8 @@ class SessionEventType(str, Enum):
27
27
  TEXT = "text"
28
28
  TOOL_CALL = "tool_call"
29
29
  TOOL_RESULT = "tool_result"
30
+ THINKING = "thinking"
31
+ USAGE = "usage"
30
32
  ERROR = "error"
31
33
  COMPLETE = "complete"
32
34
  UNKNOWN = "unknown"
@@ -42,6 +44,8 @@ class SessionEvent:
42
44
  tool_name: Optional[str] = None
43
45
  tool_input: Optional[dict] = None
44
46
  tool_result: Optional[str] = None
47
+ thinking: Optional[str] = None
48
+ usage: Optional[dict] = None
45
49
  error_message: Optional[str] = None
46
50
  raw_data: dict = field(default_factory=dict)
47
51
 
@@ -327,6 +331,9 @@ class SessionTailer:
327
331
  def _parse_event(self, data: dict) -> SessionEvent:
328
332
  """Parse event data into a SessionEvent.
329
333
 
334
+ Handles both stream-json format (content_block_delta) and
335
+ session JSONL format (assistant/user message pairs).
336
+
330
337
  Args:
331
338
  data: Parsed JSON data.
332
339
 
@@ -335,14 +342,21 @@ class SessionTailer:
335
342
  """
336
343
  msg_type = data.get("type", "")
337
344
 
338
- # Init event
339
- if msg_type == "init" or "session_id" in data:
345
+ # Session JSONL: queue-operation is the first line with sessionId
346
+ if msg_type == "queue-operation":
347
+ return SessionEvent(
348
+ type=SessionEventType.INIT,
349
+ raw_data=data,
350
+ )
351
+
352
+ # Stream-json: init event
353
+ if msg_type == "init" or (msg_type == "system" and "session_id" in data):
340
354
  return SessionEvent(
341
355
  type=SessionEventType.INIT,
342
356
  raw_data=data,
343
357
  )
344
358
 
345
- # Text content
359
+ # Stream-json: text content delta
346
360
  if msg_type == "content_block_delta":
347
361
  delta = data.get("delta", {})
348
362
  if delta.get("type") == "text_delta":
@@ -352,7 +366,7 @@ class SessionTailer:
352
366
  raw_data=data,
353
367
  )
354
368
 
355
- # Tool call start
369
+ # Stream-json: tool call start
356
370
  if msg_type == "content_block_start":
357
371
  content = data.get("content_block", {})
358
372
  if content.get("type") == "tool_use":
@@ -363,7 +377,7 @@ class SessionTailer:
363
377
  raw_data=data,
364
378
  )
365
379
 
366
- # Tool result
380
+ # Stream-json: tool result
367
381
  if msg_type == "tool_result":
368
382
  return SessionEvent(
369
383
  type=SessionEventType.TOOL_RESULT,
@@ -380,31 +394,69 @@ class SessionTailer:
380
394
  raw_data=data,
381
395
  )
382
396
 
383
- # Message complete
397
+ # Stream-json: message complete
384
398
  if msg_type == "message_stop":
385
399
  return SessionEvent(
386
400
  type=SessionEventType.COMPLETE,
387
401
  raw_data=data,
388
402
  )
389
403
 
390
- # Assistant message with content array
391
- # Claude Code session format: content is at data["message"]["content"]
404
+ # Session JSONL: assistant message with content array
392
405
  if msg_type == "assistant":
393
406
  message = data.get("message", {})
394
407
  content = message.get("content") if message else data.get("content")
408
+ usage = message.get("usage") if message else None
409
+ is_error = data.get("isApiErrorMessage", False)
410
+
411
+ if is_error:
412
+ error_text = ""
413
+ if isinstance(content, list):
414
+ for block in content:
415
+ if block.get("type") == "text":
416
+ error_text = block.get("text", "")
417
+ return SessionEvent(
418
+ type=SessionEventType.ERROR,
419
+ error_message=error_text or data.get("error", "API error"),
420
+ raw_data=data,
421
+ )
422
+
395
423
  if isinstance(content, list):
424
+ # Return the first meaningful event (text > tool_use > thinking)
396
425
  for block in content:
397
- if block.get("type") == "text":
426
+ block_type = block.get("type")
427
+ if block_type == "text":
398
428
  return SessionEvent(
399
429
  type=SessionEventType.TEXT,
400
430
  text=block.get("text", ""),
431
+ usage=usage,
401
432
  raw_data=data,
402
433
  )
403
- if block.get("type") == "tool_use":
434
+ if block_type == "tool_use":
404
435
  return SessionEvent(
405
436
  type=SessionEventType.TOOL_CALL,
406
437
  tool_name=block.get("name"),
407
438
  tool_input=block.get("input", {}),
439
+ usage=usage,
440
+ raw_data=data,
441
+ )
442
+ if block_type == "thinking":
443
+ return SessionEvent(
444
+ type=SessionEventType.THINKING,
445
+ thinking=block.get("thinking", ""),
446
+ usage=usage,
447
+ raw_data=data,
448
+ )
449
+
450
+ # Session JSONL: user message with tool results
451
+ if msg_type == "user":
452
+ message = data.get("message", {})
453
+ content = message.get("content", [])
454
+ if isinstance(content, list):
455
+ for block in content:
456
+ if block.get("type") == "tool_result":
457
+ return SessionEvent(
458
+ type=SessionEventType.TOOL_RESULT,
459
+ tool_result=str(block.get("content", ""))[:1000],
408
460
  raw_data=data,
409
461
  )
410
462
 
ralphx/core/templates.py CHANGED
@@ -1,25 +1,70 @@
1
1
  """
2
2
  Base loop templates for quick-start configuration.
3
3
 
4
+ Templates map 1:1 to workflow step processing types:
5
+ - design_doc: Interactive planning with web research
6
+ - extractgen_requirements: Extract user stories from design documents
7
+ - webgen_requirements: Discover requirements via web research
8
+ - implementation: Implement user stories with code changes
9
+
4
10
  Templates are global, read-only, and shipped with RalphX.
5
11
  Users can copy template config into their loop, then modify as needed.
6
12
  """
7
13
 
8
14
  from typing import Optional
9
15
 
10
- # Base loop templates
16
+ # Base loop templates — one per processing_type
11
17
  TEMPLATES: dict[str, dict] = {
18
+ "design_doc": {
19
+ "name": "design_doc",
20
+ "display_name": "Design Document",
21
+ "description": "Interactive planning chat to build a design document with web research",
22
+ "type": "generator",
23
+ "category": "planning",
24
+ "default_tools": ["Read", "Glob", "Grep", "WebSearch", "WebFetch"],
25
+ "config": {
26
+ "name": "design_doc",
27
+ "display_name": "Design Document",
28
+ "type": "generator",
29
+ "description": "Build a comprehensive design document through interactive planning",
30
+ "item_types": {
31
+ "output": {
32
+ "singular": "artifact",
33
+ "plural": "artifacts",
34
+ "description": "Design document and guardrails",
35
+ }
36
+ },
37
+ "modes": [
38
+ {
39
+ "name": "default",
40
+ "description": "Interactive planning with web research",
41
+ "model": "claude-sonnet-4-20250514",
42
+ "timeout": 300,
43
+ "tools": ["Read", "Glob", "Grep", "WebSearch", "WebFetch"],
44
+ "prompt_template": "prompts/planning.md",
45
+ }
46
+ ],
47
+ "mode_selection": {"strategy": "fixed", "fixed_mode": "default"},
48
+ "limits": {
49
+ "max_iterations": 100,
50
+ "max_runtime_seconds": 28800,
51
+ "max_consecutive_errors": 5,
52
+ "cooldown_between_iterations": 5,
53
+ },
54
+ },
55
+ },
12
56
  "extractgen_requirements": {
13
57
  "name": "extractgen_requirements",
14
- "display_name": "Extract Requirements Loop",
15
- "description": "Discover and document user stories from design documents or web research",
58
+ "display_name": "Extract Requirements",
59
+ "description": "Extract user stories from design documents",
16
60
  "type": "generator",
17
61
  "category": "discovery",
62
+ "default_tools": ["Read", "Glob", "Grep"],
18
63
  "config": {
19
64
  "name": "extractgen_requirements",
20
- "display_name": "Extract Requirements Loop",
65
+ "display_name": "Extract Requirements",
21
66
  "type": "generator",
22
- "description": "Discover and document user stories from design documents",
67
+ "description": "Extract and generate user stories from design documents",
23
68
  "item_types": {
24
69
  "output": {
25
70
  "singular": "story",
@@ -33,7 +78,7 @@ TEMPLATES: dict[str, dict] = {
33
78
  "description": "Fast extraction from design docs (no web search)",
34
79
  "model": "claude-sonnet-4-20250514",
35
80
  "timeout": 180,
36
- "tools": [],
81
+ "tools": ["Read", "Glob", "Grep"],
37
82
  "prompt_template": "prompts/extractgen_requirements_turbo.md",
38
83
  },
39
84
  {
@@ -41,7 +86,7 @@ TEMPLATES: dict[str, dict] = {
41
86
  "description": "Thorough web research for best practices",
42
87
  "model": "claude-sonnet-4-20250514",
43
88
  "timeout": 900,
44
- "tools": ["web_search"],
89
+ "tools": ["Read", "Glob", "Grep", "WebSearch", "WebFetch"],
45
90
  "prompt_template": "prompts/extractgen_requirements_deep.md",
46
91
  },
47
92
  ],
@@ -63,6 +108,7 @@ TEMPLATES: dict[str, dict] = {
63
108
  "description": "Discover missing requirements through web research on domain best practices",
64
109
  "type": "generator",
65
110
  "category": "discovery",
111
+ "default_tools": ["Read", "Glob", "Grep", "WebSearch", "WebFetch"],
66
112
  "config": {
67
113
  "name": "webgen_requirements",
68
114
  "display_name": "Web-Generated Requirements",
@@ -81,7 +127,7 @@ TEMPLATES: dict[str, dict] = {
81
127
  "description": "Web research for best practices and gaps",
82
128
  "model": "claude-sonnet-4-20250514",
83
129
  "timeout": 900,
84
- "tools": ["web_search"],
130
+ "tools": ["Read", "Glob", "Grep", "WebSearch", "WebFetch"],
85
131
  "prompt_template": "prompts/webgen_requirements.md",
86
132
  }
87
133
  ],
@@ -96,13 +142,14 @@ TEMPLATES: dict[str, dict] = {
96
142
  },
97
143
  "implementation": {
98
144
  "name": "implementation",
99
- "display_name": "Implementation Loop",
145
+ "display_name": "Implementation",
100
146
  "description": "Implement user stories one at a time with test verification",
101
147
  "type": "consumer",
102
148
  "category": "execution",
149
+ "default_tools": ["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
103
150
  "config": {
104
151
  "name": "implementation",
105
- "display_name": "Implementation Loop",
152
+ "display_name": "Implementation",
106
153
  "type": "consumer",
107
154
  "description": "Implement user stories with automated testing",
108
155
  "item_types": {
@@ -124,7 +171,7 @@ TEMPLATES: dict[str, dict] = {
124
171
  "description": "Implement one story per iteration",
125
172
  "model": "claude-sonnet-4-20250514",
126
173
  "timeout": 1800,
127
- "tools": ["file_read", "file_write", "shell"],
174
+ "tools": ["Read", "Write", "Edit", "Bash", "Glob", "Grep"],
128
175
  "prompt_template": "prompts/implementation.md",
129
176
  }
130
177
  ],
@@ -136,82 +183,6 @@ TEMPLATES: dict[str, dict] = {
136
183
  },
137
184
  },
138
185
  },
139
- "simple_generator": {
140
- "name": "simple_generator",
141
- "display_name": "Simple Generator",
142
- "description": "Basic content generation loop for creating items",
143
- "type": "generator",
144
- "category": "generation",
145
- "config": {
146
- "name": "generator",
147
- "display_name": "Content Generator",
148
- "type": "generator",
149
- "description": "Generate content items in a loop",
150
- "item_types": {
151
- "output": {
152
- "singular": "item",
153
- "plural": "items",
154
- "description": "Generated content",
155
- }
156
- },
157
- "modes": [
158
- {
159
- "name": "default",
160
- "description": "Generate content",
161
- "model": "claude-sonnet-4-20250514",
162
- "timeout": 300,
163
- "tools": [],
164
- "prompt_template": "prompts/generate.md",
165
- }
166
- ],
167
- "mode_selection": {"strategy": "fixed", "fixed_mode": "default"},
168
- "limits": {
169
- "max_iterations": 10,
170
- "max_consecutive_errors": 3,
171
- },
172
- },
173
- },
174
- "reviewer": {
175
- "name": "reviewer",
176
- "display_name": "Review Loop",
177
- "description": "Process and review existing items (validate, transform, enhance)",
178
- "type": "consumer",
179
- "category": "processing",
180
- "config": {
181
- "name": "reviewer",
182
- "display_name": "Review Loop",
183
- "type": "consumer",
184
- "description": "Review and validate existing items",
185
- "item_types": {
186
- "input": {
187
- "singular": "item",
188
- "plural": "items",
189
- "source": "generator",
190
- "description": "Items to review",
191
- },
192
- "output": {
193
- "singular": "review",
194
- "plural": "reviews",
195
- "description": "Review results and recommendations",
196
- },
197
- },
198
- "modes": [
199
- {
200
- "name": "default",
201
- "description": "Review one item per iteration",
202
- "model": "claude-sonnet-4-20250514",
203
- "timeout": 300,
204
- "tools": [],
205
- "prompt_template": "prompts/review.md",
206
- }
207
- ],
208
- "mode_selection": {"strategy": "fixed", "fixed_mode": "default"},
209
- "limits": {
210
- "max_iterations": 0, # Process all items
211
- "max_consecutive_errors": 5,
212
- },
213
- },
214
- },
215
186
  }
216
187
 
217
188
 
@@ -227,6 +198,21 @@ def get_template(name: str) -> Optional[dict]:
227
198
  return TEMPLATES.get(name)
228
199
 
229
200
 
201
+ def get_default_tools(processing_type: str) -> Optional[list[str]]:
202
+ """Get default tools for a processing type.
203
+
204
+ Args:
205
+ processing_type: Step processing type (design_doc, extractgen_requirements, etc.)
206
+
207
+ Returns:
208
+ List of tool names, or None if not found.
209
+ """
210
+ template = TEMPLATES.get(processing_type)
211
+ if template:
212
+ return template.get("default_tools")
213
+ return None
214
+
215
+
230
216
  def list_templates() -> list[dict]:
231
217
  """List all available templates.
232
218
 
@@ -240,6 +226,7 @@ def list_templates() -> list[dict]:
240
226
  "description": t["description"],
241
227
  "type": t["type"],
242
228
  "category": t["category"],
229
+ "default_tools": t.get("default_tools"),
243
230
  }
244
231
  for t in TEMPLATES.values()
245
232
  ]
@@ -6,10 +6,13 @@ step transitions.
6
6
  """
7
7
 
8
8
  import asyncio
9
+ import logging
9
10
  import uuid
10
11
  from pathlib import Path
11
12
  from typing import Any, Callable, Optional
12
13
 
14
+ _logger = logging.getLogger(__name__)
15
+
13
16
  from ralphx.core.executor import LoopExecutor
14
17
  from ralphx.core.loop import LoopLoader
15
18
  from ralphx.core.project import Project
@@ -50,6 +53,7 @@ class WorkflowExecutor:
50
53
  self.workflow_id = workflow_id
51
54
  self._on_step_change = on_step_change
52
55
  self._running_executors: dict[int, LoopExecutor] = {}
56
+ self._running_tasks: dict[int, asyncio.Task] = {}
53
57
 
54
58
  def get_workflow(self) -> Optional[dict]:
55
59
  """Get the current workflow."""
@@ -161,6 +165,9 @@ class WorkflowExecutor:
161
165
  # Create new loop for this step
162
166
  loop_config = self._create_step_loop(step, loop_name, loop_type)
163
167
 
168
+ # Save loop_name to the step record so the UI can find logs
169
+ self.db.update_workflow_step(step_id, loop_name=loop_name)
170
+
164
171
  if not loop_config:
165
172
  raise ValueError(f"Failed to create loop config for step {step_id}")
166
173
 
@@ -188,6 +195,9 @@ class WorkflowExecutor:
188
195
  # Check if architecture-first mode is enabled
189
196
  architecture_first = step_config.get("architecture_first", False)
190
197
 
198
+ # Get cross-step context links (for generator loops)
199
+ context_from_steps = step_config.get("context_from_steps") or []
200
+
191
201
  # Create and start executor
192
202
  executor = LoopExecutor(
193
203
  project=self.project,
@@ -197,12 +207,14 @@ class WorkflowExecutor:
197
207
  step_id=step_id,
198
208
  consume_from_step_id=consume_from_step_id,
199
209
  architecture_first=architecture_first,
210
+ context_from_steps=context_from_steps,
200
211
  )
201
212
 
202
213
  self._running_executors[step_id] = executor
203
214
 
204
- # Run executor in background
205
- asyncio.create_task(self._run_loop_and_advance(executor, step))
215
+ # Run executor in background (store task reference to prevent GC and enable cancellation)
216
+ task = asyncio.create_task(self._run_loop_and_advance(executor, step))
217
+ self._running_tasks[step_id] = task
206
218
 
207
219
  async def _run_loop_and_advance(
208
220
  self, executor: LoopExecutor, step: dict
@@ -241,8 +253,25 @@ class WorkflowExecutor:
241
253
  if auto_advance:
242
254
  await self.complete_step(step_id)
243
255
 
256
+ except Exception:
257
+ _logger.exception(
258
+ "Unhandled exception in background loop execution for step %s",
259
+ step_id,
260
+ )
261
+ # Mark step as failed so the error is visible in the UI
262
+ try:
263
+ self.db.update_workflow_step(
264
+ step_id,
265
+ status="error",
266
+ artifacts={"error": "Unexpected executor crash - check logs"},
267
+ )
268
+ self._emit_step_change(step["step_number"], "error")
269
+ except Exception:
270
+ _logger.exception("Failed to mark step %s as errored", step_id)
271
+
244
272
  finally:
245
273
  self._running_executors.pop(step_id, None)
274
+ self._running_tasks.pop(step_id, None)
246
275
 
247
276
  def _create_step_loop(
248
277
  self, step: dict, loop_name: str, loop_type: str
@@ -268,6 +297,7 @@ class WorkflowExecutor:
268
297
  max_iterations = step_config.get("max_iterations")
269
298
  cooldown = step_config.get("cooldown_between_iterations")
270
299
  max_errors = step_config.get("max_consecutive_errors")
300
+ tools = step_config.get("allowedTools")
271
301
 
272
302
  # Generate YAML config based on loop type
273
303
  if loop_type == "generator":
@@ -278,6 +308,7 @@ class WorkflowExecutor:
278
308
  max_iterations=max_iterations,
279
309
  cooldown_between_iterations=cooldown,
280
310
  max_consecutive_errors=max_errors,
311
+ tools=tools,
281
312
  )
282
313
  else:
283
314
  config_yaml = generate_simple_implementation_config(
@@ -287,6 +318,7 @@ class WorkflowExecutor:
287
318
  max_iterations=max_iterations,
288
319
  cooldown_between_iterations=cooldown,
289
320
  max_consecutive_errors=max_errors,
321
+ tools=tools,
290
322
  )
291
323
 
292
324
  # Save to database
@@ -610,7 +642,7 @@ class WorkflowExecutor:
610
642
 
611
643
  # Stop running executors
612
644
  for executor in self._running_executors.values():
613
- executor.stop()
645
+ await executor.stop()
614
646
 
615
647
  self.db.update_workflow(self.workflow_id, status="paused")
616
648
  return self.get_workflow()
@@ -243,7 +243,7 @@ def get_stop_reason(
243
243
  if sessions:
244
244
  last_session = sessions[-1]
245
245
  events = project_db.get_session_events(
246
- session_id=last_session["id"],
246
+ session_id=last_session.get("session_id", last_session.get("id")),
247
247
  event_type="error",
248
248
  limit=5,
249
249
  )
@@ -73,8 +73,8 @@ def list_runs(
73
73
  "workflow_id": r.get("workflow_id"),
74
74
  "step_id": r.get("step_id"),
75
75
  "status": r["status"],
76
- "current_iteration": r.get("current_iteration"),
77
- "current_mode": r.get("current_mode"),
76
+ "iterations_completed": r.get("iterations_completed", 0),
77
+ "items_generated": r.get("items_generated", 0),
78
78
  "executor_pid": r.get("executor_pid"),
79
79
  "started_at": r.get("started_at"),
80
80
  "completed_at": r.get("completed_at"),
@@ -117,8 +117,8 @@ def get_run(
117
117
  "workflow_id": run.get("workflow_id"),
118
118
  "step_id": run.get("step_id"),
119
119
  "status": run["status"],
120
- "current_iteration": run.get("current_iteration"),
121
- "current_mode": run.get("current_mode"),
120
+ "iterations_completed": run.get("iterations_completed", 0),
121
+ "items_generated": run.get("items_generated", 0),
122
122
  "executor_pid": run.get("executor_pid"),
123
123
  "started_at": run.get("started_at"),
124
124
  "completed_at": run.get("completed_at"),
@@ -130,11 +130,11 @@ def get_run(
130
130
  sessions = project_db.list_sessions(run_id=run_id)
131
131
  result["sessions"] = [
132
132
  {
133
- "id": s["id"],
133
+ "id": s.get("session_id", s.get("id")),
134
+ "iteration": s.get("iteration"),
134
135
  "status": s.get("status"),
135
136
  "started_at": s.get("started_at"),
136
- "completed_at": s.get("completed_at"),
137
- "event_count": s.get("event_count", 0),
137
+ "duration_seconds": s.get("duration_seconds"),
138
138
  }
139
139
  for s in sessions
140
140
  ]
@@ -146,7 +146,6 @@ def get_run(
146
146
  def get_logs(
147
147
  slug: str,
148
148
  level: Optional[str] = None,
149
- category: Optional[str] = None,
150
149
  run_id: Optional[str] = None,
151
150
  session_id: Optional[str] = None,
152
151
  search: Optional[str] = None,
@@ -170,7 +169,6 @@ def get_logs(
170
169
  try:
171
170
  logs, total = project_db.get_logs(
172
171
  level=level,
173
- category=category,
174
172
  run_id=run_id,
175
173
  session_id=session_id,
176
174
  search=search,
@@ -190,10 +188,8 @@ def get_logs(
190
188
  "id": log.get("id"),
191
189
  "timestamp": log.get("timestamp"),
192
190
  "level": log.get("level"),
193
- "category": log.get("category"),
194
191
  "message": scrub_sensitive_data(log.get("message", "")),
195
192
  "run_id": log.get("run_id"),
196
- "session_id": log.get("session_id"),
197
193
  }
198
194
  for log in logs
199
195
  ],
@@ -311,13 +307,12 @@ def list_sessions(
311
307
  return PaginatedResult(
312
308
  items=[
313
309
  {
314
- "id": s["id"],
310
+ "id": s.get("session_id", s.get("id")),
315
311
  "run_id": s.get("run_id"),
312
+ "iteration": s.get("iteration"),
316
313
  "status": s.get("status"),
317
314
  "started_at": s.get("started_at"),
318
- "completed_at": s.get("completed_at"),
319
- "event_count": s.get("event_count", 0),
320
- "item_id": s.get("item_id"),
315
+ "duration_seconds": s.get("duration_seconds"),
321
316
  }
322
317
  for s in paginated
323
318
  ],
@@ -432,7 +427,6 @@ def get_monitoring_tools() -> list[ToolDefinition]:
432
427
  properties={
433
428
  "slug": prop_string("Project slug"),
434
429
  "level": prop_enum("Filter by log level", ["debug", "info", "warning", "error"]),
435
- "category": prop_string("Filter by category"),
436
430
  "run_id": prop_string("Filter by run ID"),
437
431
  "session_id": prop_string("Filter by session ID"),
438
432
  "search": prop_string("Search text in messages"),