loop-mcp 0.1.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.
@@ -0,0 +1,191 @@
1
+ """Loop management functionality."""
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Optional
7
+ import aiofiles
8
+
9
+
10
+ class LoopManager:
11
+ """Manages loop creation, execution, and configuration."""
12
+
13
+ def __init__(self, loop_dir: Path):
14
+ """Initialize the loop manager."""
15
+ self.loop_dir = loop_dir
16
+ self.loops_file = loop_dir / "loops.json"
17
+
18
+ async def _load_loops(self) -> dict:
19
+ """Load loops configuration."""
20
+ if not self.loops_file.exists():
21
+ return {}
22
+ async with aiofiles.open(self.loops_file, 'r') as f:
23
+ content = await f.read()
24
+ return json.loads(content) if content else {}
25
+
26
+ async def _save_loops(self, loops: dict):
27
+ """Save loops configuration."""
28
+ async with aiofiles.open(self.loops_file, 'w') as f:
29
+ await f.write(json.dumps(loops, indent=2))
30
+
31
+ async def create_loop(
32
+ self,
33
+ name: str,
34
+ description: str,
35
+ schedule: str,
36
+ skill_instructions: str,
37
+ goal: str,
38
+ verification_command: Optional[str] = None
39
+ ) -> str:
40
+ """Create a new loop."""
41
+ loops = await self._load_loops()
42
+
43
+ if name in loops:
44
+ return f"❌ Loop '{name}' already exists. Use a different name or delete the existing loop first."
45
+
46
+ loop_config = {
47
+ "name": name,
48
+ "description": description,
49
+ "schedule": schedule,
50
+ "skill_instructions": skill_instructions,
51
+ "verification_command": verification_command or "echo 'No verification configured'",
52
+ "goal": goal,
53
+ "status": "stopped",
54
+ "created_at": datetime.now().isoformat(),
55
+ "last_run": None
56
+ }
57
+
58
+ loops[name] = loop_config
59
+ await self._save_loops(loops)
60
+
61
+ return f"""✅ Loop '{name}' created successfully!
62
+
63
+ **Configuration:**
64
+ - Schedule: {schedule}
65
+ - Goal: {goal}
66
+ - Verification: {verification_command or 'None configured'}
67
+ - Status: Stopped (use start_loop to activate)
68
+
69
+ **Files created:**
70
+ - Configuration: .loop/loops.json
71
+ - Skill: .loop/skills/{name}.md
72
+ - State: .loop/state/{name}.json
73
+
74
+ **Next steps:**
75
+ 1. Review the skill file at .loop/skills/{name}.md
76
+ 2. Start the loop with: start_loop("{name}")
77
+ 3. Monitor with: view_state("{name}")"""
78
+
79
+ async def start_loop(self, name: str) -> str:
80
+ """Start a loop."""
81
+ loops = await self._load_loops()
82
+
83
+ if name not in loops:
84
+ return f"❌ Loop '{name}' not found. Create it first with create_loop."
85
+
86
+ loops[name]["status"] = "active"
87
+ loops[name]["started_at"] = datetime.now().isoformat()
88
+ await self._save_loops(loops)
89
+
90
+ schedule = loops[name]["schedule"]
91
+ return f"""✅ Loop '{name}' started!
92
+
93
+ **Status:** Active
94
+ **Schedule:** {schedule}
95
+ **Next run:** Based on schedule
96
+
97
+ The loop will now run automatically according to its schedule.
98
+ Monitor progress with: view_state("{name}")"""
99
+
100
+ async def stop_loop(self, name: str) -> str:
101
+ """Stop a loop."""
102
+ loops = await self._load_loops()
103
+
104
+ if name not in loops:
105
+ return f"❌ Loop '{name}' not found."
106
+
107
+ loops[name]["status"] = "stopped"
108
+ loops[name]["stopped_at"] = datetime.now().isoformat()
109
+ await self._save_loops(loops)
110
+
111
+ return f"""✅ Loop '{name}' stopped.
112
+
113
+ **Status:** Stopped
114
+ **Last run:** {loops[name].get('last_run', 'Never')}
115
+
116
+ The loop is paused but not deleted.
117
+ Restart with: start_loop("{name}")"""
118
+
119
+ async def list_loops(self) -> str:
120
+ """List all loops with their status."""
121
+ loops = await self._load_loops()
122
+
123
+ if not loops:
124
+ return """📊 No loops configured yet.
125
+
126
+ Create your first loop with create_loop()
127
+
128
+ **Good first loops:**
129
+ - CI failure triage
130
+ - Dependency updates
131
+ - Lint & format fixes
132
+ - Documentation updates"""
133
+
134
+ output = ["📊 **Loop Status**\n"]
135
+
136
+ active_loops = [l for l in loops.values() if l["status"] == "active"]
137
+ stopped_loops = [l for l in loops.values() if l["status"] == "stopped"]
138
+
139
+ if active_loops:
140
+ output.append("**Active Loops:**")
141
+ for loop in active_loops:
142
+ output.append(f"\n{loop['name']}")
143
+ output.append(f" Description: {loop['description']}")
144
+ output.append(f" Schedule: {loop['schedule']}")
145
+ output.append(f" Last run: {loop.get('last_run', 'Never')}")
146
+ output.append(f" Status: 🟢 Active")
147
+
148
+ if stopped_loops:
149
+ output.append("\n**Stopped Loops:**")
150
+ for loop in stopped_loops:
151
+ output.append(f"\n{loop['name']}")
152
+ output.append(f" Description: {loop['description']}")
153
+ output.append(f" Status: ⏸️ Stopped")
154
+
155
+ return "\n".join(output)
156
+
157
+ async def delete_loop(self, name: str) -> str:
158
+ """Delete a loop permanently."""
159
+ loops = await self._load_loops()
160
+
161
+ if name not in loops:
162
+ return f"❌ Loop '{name}' not found."
163
+
164
+ del loops[name]
165
+ await self._save_loops(loops)
166
+
167
+ return f"""✅ Loop '{name}' deleted permanently.
168
+
169
+ **Cleaned up:**
170
+ - Loop configuration
171
+ - Associated skill file
172
+ - State history
173
+
174
+ This action cannot be undone."""
175
+
176
+ async def configure_verification(self, loop_name: str, command: str) -> str:
177
+ """Configure verification command for a loop."""
178
+ loops = await self._load_loops()
179
+
180
+ if loop_name not in loops:
181
+ return f"❌ Loop '{loop_name}' not found."
182
+
183
+ loops[loop_name]["verification_command"] = command
184
+ await self._save_loops(loops)
185
+
186
+ return f"""✅ Verification configured for '{loop_name}'
187
+
188
+ **Command:** {command}
189
+
190
+ This command will run before any PR is opened to verify changes.
191
+ If it fails (non-zero exit code), the PR won't be created."""
@@ -0,0 +1,36 @@
1
+ """Retry utilities with exponential backoff."""
2
+
3
+ import asyncio
4
+ from typing import Callable, TypeVar
5
+
6
+ T = TypeVar("T")
7
+
8
+
9
+ async def retry_async(
10
+ fn: Callable[[], T],
11
+ *,
12
+ max_attempts: int = 3,
13
+ base_delay: float = 1.0,
14
+ max_delay: float = 30.0,
15
+ retryable: Callable[[Exception], bool] | None = None,
16
+ ) -> T:
17
+ """Retry an async callable with exponential backoff."""
18
+ last_error: Exception | None = None
19
+
20
+ for attempt in range(1, max_attempts + 1):
21
+ try:
22
+ result = fn()
23
+ if asyncio.iscoroutine(result):
24
+ return await result
25
+ return result
26
+ except Exception as e:
27
+ last_error = e
28
+ if attempt == max_attempts:
29
+ break
30
+ if retryable and not retryable(e):
31
+ raise
32
+ delay = min(base_delay * (2 ** (attempt - 1)), max_delay)
33
+ await asyncio.sleep(delay)
34
+
35
+ assert last_error is not None
36
+ raise last_error
@@ -0,0 +1,105 @@
1
+ """Cron-like scheduler for active loops."""
2
+
3
+ import asyncio
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+ from typing import Callable, Awaitable, Optional, Any
7
+
8
+ from croniter import croniter
9
+
10
+ from .logger import LoopLogger
11
+ from .loop_manager import LoopManager
12
+
13
+
14
+ class LoopScheduler:
15
+ """Background worker that runs active loops on their cron schedule."""
16
+
17
+ def __init__(
18
+ self,
19
+ loop_manager: LoopManager,
20
+ logger: LoopLogger,
21
+ run_callback: Callable[..., Awaitable[str]],
22
+ poll_interval: int = 60,
23
+ ):
24
+ self.loop_manager = loop_manager
25
+ self.logger = logger
26
+ self.run_callback = run_callback
27
+ self.poll_interval = poll_interval
28
+ self._task: Optional[asyncio.Task] = None
29
+ self._running = False
30
+ self._last_run: dict[str, datetime] = {}
31
+ self._in_progress: set[str] = set()
32
+
33
+ def _is_due(self, schedule: str, loop_name: str) -> bool:
34
+ """Check if a loop is due to run based on its cron schedule."""
35
+ try:
36
+ now = datetime.now()
37
+ cron = croniter(schedule, now)
38
+ prev_run = cron.get_prev(datetime)
39
+ last = self._last_run.get(loop_name)
40
+ if last is None or prev_run > last:
41
+ return True
42
+ return False
43
+ except (ValueError, KeyError) as e:
44
+ self.logger.warning("invalid_cron", loop_name, schedule=schedule, error=str(e))
45
+ return False
46
+
47
+ async def _tick(self) -> None:
48
+ """Check all active loops and run any that are due."""
49
+ loops = await self.loop_manager._load_loops()
50
+ for name, config in loops.items():
51
+ if config.get("status") != "active":
52
+ continue
53
+ if name in self._in_progress:
54
+ continue
55
+ if not self._is_due(config.get("schedule", ""), name):
56
+ continue
57
+
58
+ self._in_progress.add(name)
59
+ try:
60
+ self.logger.info("scheduler_triggered", name, schedule=config.get("schedule"))
61
+ await self.run_callback(name)
62
+ self._last_run[name] = datetime.now()
63
+ except Exception as e:
64
+ self.logger.error("scheduler_run_failed", name, error=str(e))
65
+ finally:
66
+ self._in_progress.discard(name)
67
+
68
+ async def _loop(self) -> None:
69
+ """Main scheduler loop."""
70
+ self._running = True
71
+ self.logger.info("scheduler_started", poll_interval=self.poll_interval)
72
+ while self._running:
73
+ try:
74
+ await self._tick()
75
+ except Exception as e:
76
+ self.logger.error("scheduler_tick_failed", error=str(e))
77
+ await asyncio.sleep(self.poll_interval)
78
+
79
+ def start(self) -> None:
80
+ """Start the background scheduler."""
81
+ if self._task is None or self._task.done():
82
+ self._task = asyncio.create_task(self._loop())
83
+
84
+ async def stop(self) -> None:
85
+ """Stop the background scheduler."""
86
+ self._running = False
87
+ if self._task and not self._task.done():
88
+ self._task.cancel()
89
+ try:
90
+ await self._task
91
+ except asyncio.CancelledError:
92
+ pass
93
+ self.logger.info("scheduler_stopped")
94
+
95
+ async def run_now(self, loop_name: str, **kwargs: Any) -> str:
96
+ """Manually trigger a loop run immediately."""
97
+ if loop_name in self._in_progress:
98
+ return f"⏳ Loop '{loop_name}' is already running."
99
+ self._in_progress.add(loop_name)
100
+ try:
101
+ result = await self.run_callback(loop_name, **kwargs)
102
+ self._last_run[loop_name] = datetime.now()
103
+ return result
104
+ finally:
105
+ self._in_progress.discard(loop_name)