Skip to content

Commit 45e8273

Browse files
Kasper Jungeclaude
authored andcommitted
refactor: extract run data types from engine into _run_types module
RunStatus, RunConfig, and RunState are data types used by cli, manager, and UI modules that don't need the engine's execution logic. Separating them makes engine.py focused on the loop (~390 lines of execution code) and lets type-only consumers avoid importing the engine's heavy deps. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 408c7fc commit 45e8273

File tree

7 files changed

+129
-112
lines changed

7 files changed

+129
-112
lines changed

src/ralphify/_run_types.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
"""Data types for run configuration and state.
2+
3+
These are the core types shared across the engine, CLI, manager, and UI
4+
modules. They are intentionally separate from ``engine.py`` so modules
5+
that only need the types don't pull in the engine's execution logic.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import threading
11+
from dataclasses import dataclass, field
12+
from enum import Enum
13+
from pathlib import Path
14+
15+
16+
class RunStatus(Enum):
17+
"""Lifecycle status of a run."""
18+
19+
PENDING = "pending"
20+
RUNNING = "running"
21+
PAUSED = "paused"
22+
STOPPING = "stopping"
23+
STOPPED = "stopped"
24+
COMPLETED = "completed"
25+
FAILED = "failed"
26+
27+
28+
@dataclass
29+
class RunConfig:
30+
"""All settings for a single run. Mutable — fields can change mid-run."""
31+
32+
command: str
33+
args: list[str]
34+
prompt_file: str
35+
prompt_text: str | None = None
36+
prompt_name: str | None = None
37+
max_iterations: int | None = None
38+
delay: float = 0
39+
timeout: float | None = None
40+
stop_on_error: bool = False
41+
log_dir: str | None = None
42+
project_root: Path = field(default_factory=lambda: Path("."))
43+
44+
45+
@dataclass
46+
class RunState:
47+
"""Observable state for a run.
48+
49+
Control methods (:meth:`request_stop`, :meth:`request_pause`,
50+
:meth:`request_resume`) use :class:`threading.Event` so the run loop
51+
can react at iteration boundaries without busy-waiting.
52+
53+
**Threading model**: counters (``iteration``, ``completed``, etc.) are
54+
written only by the engine thread and read by API threads. Under
55+
CPython's GIL this is safe — readers may see a briefly stale value,
56+
which is acceptable for dashboard display.
57+
"""
58+
59+
run_id: str
60+
status: RunStatus = RunStatus.PENDING
61+
iteration: int = 0
62+
completed: int = 0
63+
failed: int = 0
64+
timed_out: int = 0
65+
66+
_stop_requested: bool = False
67+
_pause_event: threading.Event = field(default_factory=threading.Event)
68+
_reload_requested: bool = False
69+
70+
def __post_init__(self) -> None:
71+
# Start un-paused
72+
self._pause_event.set()
73+
74+
def request_stop(self) -> None:
75+
"""Signal the loop to stop after the current iteration."""
76+
self._stop_requested = True
77+
# Unpause so the loop can exit
78+
self._pause_event.set()
79+
80+
def request_pause(self) -> None:
81+
"""Pause the loop between iterations until resumed."""
82+
self.status = RunStatus.PAUSED
83+
self._pause_event.clear()
84+
85+
def request_resume(self) -> None:
86+
"""Resume a paused loop."""
87+
self.status = RunStatus.RUNNING
88+
self._pause_event.set()
89+
90+
def request_reload(self) -> None:
91+
"""Request re-discovery of primitives before the next iteration."""
92+
self._reload_requested = True
93+
94+
@property
95+
def stop_requested(self) -> bool:
96+
"""Whether a stop has been requested."""
97+
return self._stop_requested
98+
99+
@property
100+
def paused(self) -> bool:
101+
"""Whether the run is currently paused."""
102+
return not self._pause_event.is_set()
103+
104+
def wait_for_unpause(self, timeout: float | None = None) -> bool:
105+
"""Block until unpaused or timeout. Returns True if unpaused."""
106+
return self._pause_event.wait(timeout=timeout)
107+
108+
def consume_reload_request(self) -> bool:
109+
"""If a reload was requested, clear the flag and return True."""
110+
if self._reload_requested:
111+
self._reload_requested = False
112+
return True
113+
return False

src/ralphify/cli.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@
1919
from ralphify._frontmatter import CHECK_MARKER, CONTEXT_MARKER, INSTRUCTION_MARKER, PROMPT_MARKER
2020
from ralphify.checks import discover_checks
2121
from ralphify.contexts import discover_contexts
22-
from ralphify.engine import RunConfig, RunState, run_loop
22+
from ralphify._run_types import RunConfig, RunState
23+
from ralphify.engine import run_loop
2324
from ralphify.instructions import discover_instructions
2425
from ralphify.prompts import discover_prompts, is_prompt_name, resolve_prompt_name
2526
from ralphify.detector import detect_project

src/ralphify/engine.py

Lines changed: 8 additions & 107 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
"""Extracted run loop with structured event emission.
1+
"""Run loop with structured event emission.
22
3-
The core ``run_loop`` function is the autonomous agent loop previously
4-
inlined in ``cli.py:run()``. It accepts a ``RunConfig``, ``RunState``,
5-
and ``EventEmitter``, making it reusable from both CLI and UI contexts.
3+
The core ``run_loop`` function is the autonomous agent loop. It accepts
4+
a ``RunConfig``, ``RunState``, and ``EventEmitter``, making it reusable
5+
from both CLI and UI contexts.
6+
7+
Run data types (``RunStatus``, ``RunConfig``, ``RunState``) live in
8+
``_run_types.py`` so modules that only need types don't import the engine.
69
"""
710

811
from __future__ import annotations
@@ -11,14 +14,12 @@
1114
import sys
1215
import time
1316
import traceback
14-
import threading
15-
from dataclasses import dataclass, field
1617
from datetime import datetime
17-
from enum import Enum
1818
from pathlib import Path
1919
from typing import Any, NamedTuple
2020
from ralphify._events import Event, EventEmitter, EventType, NullEmitter
2121
from ralphify._output import collect_output, format_duration
22+
from ralphify._run_types import RunConfig, RunState, RunStatus
2223
from ralphify.checks import (
2324
Check,
2425
discover_checks,
@@ -36,106 +37,6 @@
3637
from ralphify.instructions import Instruction, discover_instructions, resolve_instructions
3738

3839

39-
class RunStatus(Enum):
40-
"""Lifecycle status of a run."""
41-
42-
PENDING = "pending"
43-
RUNNING = "running"
44-
PAUSED = "paused"
45-
STOPPING = "stopping"
46-
STOPPED = "stopped"
47-
COMPLETED = "completed"
48-
FAILED = "failed"
49-
50-
51-
@dataclass
52-
class RunConfig:
53-
"""All settings for a single run. Mutable — fields can change mid-run."""
54-
55-
command: str
56-
args: list[str]
57-
prompt_file: str
58-
prompt_text: str | None = None
59-
prompt_name: str | None = None
60-
max_iterations: int | None = None
61-
delay: float = 0
62-
timeout: float | None = None
63-
stop_on_error: bool = False
64-
log_dir: str | None = None
65-
project_root: Path = field(default_factory=lambda: Path("."))
66-
67-
68-
@dataclass
69-
class RunState:
70-
"""Observable state for a run.
71-
72-
Control methods (:meth:`request_stop`, :meth:`request_pause`,
73-
:meth:`request_resume`) use :class:`threading.Event` so the run loop
74-
can react at iteration boundaries without busy-waiting.
75-
76-
**Threading model**: counters (``iteration``, ``completed``, etc.) are
77-
written only by the engine thread and read by API threads. Under
78-
CPython's GIL this is safe — readers may see a briefly stale value,
79-
which is acceptable for dashboard display.
80-
"""
81-
82-
run_id: str
83-
status: RunStatus = RunStatus.PENDING
84-
iteration: int = 0
85-
completed: int = 0
86-
failed: int = 0
87-
timed_out: int = 0
88-
89-
_stop_requested: bool = False
90-
_pause_event: threading.Event = field(default_factory=threading.Event)
91-
_reload_requested: bool = False
92-
93-
def __post_init__(self) -> None:
94-
# Start un-paused
95-
self._pause_event.set()
96-
97-
def request_stop(self) -> None:
98-
"""Signal the loop to stop after the current iteration."""
99-
self._stop_requested = True
100-
# Unpause so the loop can exit
101-
self._pause_event.set()
102-
103-
def request_pause(self) -> None:
104-
"""Pause the loop between iterations until resumed."""
105-
self.status = RunStatus.PAUSED
106-
self._pause_event.clear()
107-
108-
def request_resume(self) -> None:
109-
"""Resume a paused loop."""
110-
self.status = RunStatus.RUNNING
111-
self._pause_event.set()
112-
113-
def request_reload(self) -> None:
114-
"""Request re-discovery of primitives before the next iteration."""
115-
self._reload_requested = True
116-
117-
@property
118-
def stop_requested(self) -> bool:
119-
"""Whether a stop has been requested."""
120-
return self._stop_requested
121-
122-
@property
123-
def paused(self) -> bool:
124-
"""Whether the run is currently paused."""
125-
return not self._pause_event.is_set()
126-
127-
def wait_for_unpause(self, timeout: float | None = None) -> bool:
128-
"""Block until unpaused or timeout. Returns True if unpaused."""
129-
return self._pause_event.wait(timeout=timeout)
130-
131-
def consume_reload_request(self) -> bool:
132-
"""If a reload was requested, clear the flag and return True."""
133-
if self._reload_requested:
134-
self._reload_requested = False
135-
return True
136-
return False
137-
138-
13940
def _write_log(
14041
log_path_dir: Path,
14142
iteration: int,

src/ralphify/manager.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
from dataclasses import dataclass, field
1111

1212
from ralphify._events import EventEmitter, FanoutEmitter, QueueEmitter
13-
from ralphify.engine import RunConfig, RunState, run_loop
13+
from ralphify._run_types import RunConfig, RunState
14+
from ralphify.engine import run_loop
1415

1516

1617
@dataclass

src/ralphify/ui/api/runs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from fastapi import APIRouter, Depends, HTTPException, Request
88

99
from ralphify._frontmatter import PROMPT_MARKER
10-
from ralphify.engine import RunConfig
10+
from ralphify._run_types import RunConfig
1111
from ralphify.manager import ManagedRun, RunManager
1212
from ralphify.prompts import resolve_prompt_name
1313
from ralphify.ui.models import RunCreate, RunResponse, RunSettingsUpdate

tests/test_engine.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
from unittest.mock import patch
88

99
from ralphify._events import EventType, NullEmitter, QueueEmitter
10-
from ralphify.engine import RunConfig, RunState, RunStatus, run_loop
10+
from ralphify._run_types import RunConfig, RunState, RunStatus
11+
from ralphify.engine import run_loop
1112

1213
_MOCK_SUBPROCESS = "ralphify.engine.subprocess.run"
1314

tests/test_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from unittest.mock import patch
88

99
from ralphify._events import Event, EventType, FanoutEmitter, QueueEmitter
10-
from ralphify.engine import RunConfig, RunStatus
10+
from ralphify._run_types import RunConfig, RunStatus
1111
from ralphify.manager import ManagedRun, RunManager
1212

1313
_MOCK_SUBPROCESS = "ralphify.engine.subprocess.run"

0 commit comments

Comments
 (0)