pulse-coding-agent 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.
- pulse/__init__.py +5 -0
- pulse/__main__.py +4 -0
- pulse/agent.py +270 -0
- pulse/agent_manager.py +335 -0
- pulse/audit.py +70 -0
- pulse/auth.py +670 -0
- pulse/ci/github_client.py +66 -0
- pulse/ci/runner.py +28 -0
- pulse/cli.py +1075 -0
- pulse/cli_ui.py +977 -0
- pulse/config.py +167 -0
- pulse/context.py +960 -0
- pulse/conversations/__init__.py +8 -0
- pulse/conversations/manager.py +312 -0
- pulse/core/agent.py +188 -0
- pulse/core/planner.py +105 -0
- pulse/core/protocols.py +37 -0
- pulse/edits.py +65 -0
- pulse/episodic.py +93 -0
- pulse/eval/__init__.py +8 -0
- pulse/eval/trajectory_logger.py +91 -0
- pulse/eval/verifier.py +133 -0
- pulse/execution/__init__.py +5 -0
- pulse/execution/remote_task.py +76 -0
- pulse/git.py +162 -0
- pulse/interactive.py +234 -0
- pulse/mcp/__init__.py +4 -0
- pulse/mcp/client.py +215 -0
- pulse/mcp/local_tools.py +105 -0
- pulse/memory.py +212 -0
- pulse/mutations.py +283 -0
- pulse/orchestration/__init__.py +3 -0
- pulse/orchestration/orchestrator.py +162 -0
- pulse/patch.py +129 -0
- pulse/planner/__init__.py +3 -0
- pulse/planner/dag_planner.py +85 -0
- pulse/planner/execution_loop.py +159 -0
- pulse/production.py +235 -0
- pulse/provider.py +59 -0
- pulse/provider_keys.py +278 -0
- pulse/providers/__init__.py +26 -0
- pulse/providers/anthropic.py +65 -0
- pulse/providers/base.py +251 -0
- pulse/providers/deepseek.py +10 -0
- pulse/providers/failover.py +32 -0
- pulse/providers/gemini.py +66 -0
- pulse/providers/groq.py +10 -0
- pulse/providers/manager.py +262 -0
- pulse/providers/openai.py +40 -0
- pulse/providers/openrouter.py +20 -0
- pulse/py.typed +1 -0
- pulse/reasoning.py +570 -0
- pulse/refactor/__init__.py +3 -0
- pulse/refactor/impact_analyzer.py +44 -0
- pulse/repository.py +209 -0
- pulse/rpc.py +249 -0
- pulse/rule_synthesizer.py +54 -0
- pulse/runtime.py +217 -0
- pulse/safety/__init__.py +3 -0
- pulse/safety/safety_manager.py +97 -0
- pulse/sandbox/SECURITY.md +57 -0
- pulse/sandbox/__init__.py +57 -0
- pulse/sandbox/api.py +594 -0
- pulse/sandbox/audit.py +153 -0
- pulse/sandbox/backend/__init__.py +7 -0
- pulse/sandbox/backend/base.py +72 -0
- pulse/sandbox/backend/docker.py +498 -0
- pulse/sandbox/backend/host.py +140 -0
- pulse/sandbox/backend/remote.py +224 -0
- pulse/sandbox/errors.py +106 -0
- pulse/sandbox/filesystem.py +476 -0
- pulse/sandbox/git_safe.py +50 -0
- pulse/sandbox/lifecycle.py +88 -0
- pulse/sandbox/network.py +205 -0
- pulse/sandbox/path_validator.py +280 -0
- pulse/sandbox/policy.py +209 -0
- pulse/sandbox/process.py +331 -0
- pulse/sandbox/project.py +158 -0
- pulse/sandbox/python_safe.py +62 -0
- pulse/sandbox/remote/__init__.py +1 -0
- pulse/sandbox/remote/client.py +389 -0
- pulse/sandbox/remote/models.py +167 -0
- pulse/sandbox/remote/protocol.py +65 -0
- pulse/sandbox/remote/server.py +984 -0
- pulse/sandbox/remote/worker.py +175 -0
- pulse/sandbox/resources.py +236 -0
- pulse/sandbox/secrets.py +241 -0
- pulse/session_manager.py +365 -0
- pulse/software_engineer.py +189 -0
- pulse/storage.py +140 -0
- pulse/streaming.py +385 -0
- pulse/subprocesses.py +79 -0
- pulse/task_manager.py +2005 -0
- pulse/telemetry/__init__.py +25 -0
- pulse/telemetry/cost_tracker.py +95 -0
- pulse/telemetry/logger.py +110 -0
- pulse/tool_policy.py +197 -0
- pulse/tool_registry.py +163 -0
- pulse/tools.py +372 -0
- pulse/verification.py +118 -0
- pulse_coding_agent-0.1.0.dist-info/METADATA +211 -0
- pulse_coding_agent-0.1.0.dist-info/RECORD +104 -0
- pulse_coding_agent-0.1.0.dist-info/WHEEL +4 -0
- pulse_coding_agent-0.1.0.dist-info/entry_points.txt +4 -0
pulse/session_manager.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Production-grade Session Manager for Pulse.
|
|
2
|
+
|
|
3
|
+
Provides session state management, allowing conversational context,
|
|
4
|
+
active tasks, and workspace metadata to be persisted and recovered
|
|
5
|
+
across CLI and VS Code restarts.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import shutil
|
|
15
|
+
import uuid
|
|
16
|
+
from collections.abc import Awaitable, Callable
|
|
17
|
+
from dataclasses import asdict, dataclass, field
|
|
18
|
+
from datetime import UTC, datetime
|
|
19
|
+
from enum import Enum
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
logger = logging.getLogger(__name__)
|
|
24
|
+
SESSION_SCHEMA_VERSION = 1
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# ---------------------------------------------------------------------------
|
|
28
|
+
# Enums & Data Models
|
|
29
|
+
# ---------------------------------------------------------------------------
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class SessionStatus(Enum):
|
|
33
|
+
"""Current state of a managed session."""
|
|
34
|
+
|
|
35
|
+
ACTIVE = "ACTIVE"
|
|
36
|
+
ARCHIVED = "ARCHIVED"
|
|
37
|
+
ERROR = "ERROR"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(slots=True)
|
|
41
|
+
class SessionTurn:
|
|
42
|
+
"""A single turn in the session's conversation."""
|
|
43
|
+
|
|
44
|
+
role: str
|
|
45
|
+
content: str
|
|
46
|
+
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
47
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(slots=True)
|
|
51
|
+
class SessionEvent:
|
|
52
|
+
"""Structured event emitted for VS Code UI and RPC clients."""
|
|
53
|
+
|
|
54
|
+
event_type: str
|
|
55
|
+
session_id: str
|
|
56
|
+
status: SessionStatus
|
|
57
|
+
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
58
|
+
payload: dict[str, Any] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Session:
|
|
63
|
+
"""A persistent unit of conversational and workspace state."""
|
|
64
|
+
|
|
65
|
+
id: str
|
|
66
|
+
title: str = "New Session"
|
|
67
|
+
status: SessionStatus = SessionStatus.ACTIVE
|
|
68
|
+
conversation: list[SessionTurn] = field(default_factory=list)
|
|
69
|
+
active_tasks: list[str] = field(default_factory=list)
|
|
70
|
+
checkpoints: list[str] = field(default_factory=list)
|
|
71
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
72
|
+
created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
73
|
+
updated_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
74
|
+
|
|
75
|
+
def to_dict(self) -> dict[str, Any]:
|
|
76
|
+
"""Serialize Session object to JSON-compatible dictionary."""
|
|
77
|
+
return {
|
|
78
|
+
"schema_version": SESSION_SCHEMA_VERSION,
|
|
79
|
+
"id": self.id,
|
|
80
|
+
"title": self.title,
|
|
81
|
+
"status": self.status.value,
|
|
82
|
+
"conversation": [asdict(t) for t in self.conversation],
|
|
83
|
+
"active_tasks": self.active_tasks,
|
|
84
|
+
"checkpoints": self.checkpoints,
|
|
85
|
+
"metadata": self.metadata,
|
|
86
|
+
"created_at": self.created_at,
|
|
87
|
+
"updated_at": self.updated_at,
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
@classmethod
|
|
91
|
+
def from_dict(cls, data: dict[str, Any]) -> Session:
|
|
92
|
+
"""Deserialize Session object from dictionary."""
|
|
93
|
+
version = int(data.get("schema_version", 0))
|
|
94
|
+
if version > SESSION_SCHEMA_VERSION:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"Session schema v{version} is newer than supported v{SESSION_SCHEMA_VERSION}."
|
|
97
|
+
)
|
|
98
|
+
status = SessionStatus(data.get("status", "ACTIVE"))
|
|
99
|
+
conversation = [
|
|
100
|
+
SessionTurn(**t) for t in data.get("conversation", []) if isinstance(t, dict)
|
|
101
|
+
]
|
|
102
|
+
|
|
103
|
+
return cls(
|
|
104
|
+
id=data["id"],
|
|
105
|
+
title=data.get("title", "New Session"),
|
|
106
|
+
status=status,
|
|
107
|
+
conversation=conversation,
|
|
108
|
+
active_tasks=list(data.get("active_tasks", [])),
|
|
109
|
+
checkpoints=list(data.get("checkpoints", [])),
|
|
110
|
+
metadata=data.get("metadata", {}),
|
|
111
|
+
created_at=data.get("created_at", datetime.now(UTC).isoformat()),
|
|
112
|
+
updated_at=data.get("updated_at", datetime.now(UTC).isoformat()),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
# Session Store & Event Bus
|
|
118
|
+
# ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class SessionStore:
|
|
122
|
+
"""File-backed persistence store for Sessions."""
|
|
123
|
+
|
|
124
|
+
def __init__(self, workspace: Path | None = None) -> None:
|
|
125
|
+
self.workspace = workspace or Path.cwd()
|
|
126
|
+
self.store_dir = self.workspace / ".pulse" / "sessions"
|
|
127
|
+
self.active_session_file = self.store_dir / "active_session.txt"
|
|
128
|
+
self.store_dir.mkdir(parents=True, exist_ok=True)
|
|
129
|
+
|
|
130
|
+
def _get_file_path(self, session_id: str) -> Path:
|
|
131
|
+
return self.store_dir / f"{session_id}.json"
|
|
132
|
+
|
|
133
|
+
def save(self, session: Session) -> None:
|
|
134
|
+
"""Persist a session to its JSON file."""
|
|
135
|
+
temporary_path: Path | None = None
|
|
136
|
+
try:
|
|
137
|
+
file_path = self._get_file_path(session.id)
|
|
138
|
+
temporary_path = file_path.with_name(f".{file_path.name}.{uuid.uuid4().hex}.tmp")
|
|
139
|
+
with temporary_path.open("w", encoding="utf-8") as f:
|
|
140
|
+
json.dump(session.to_dict(), f, indent=2)
|
|
141
|
+
f.flush()
|
|
142
|
+
os.fsync(f.fileno())
|
|
143
|
+
os.replace(temporary_path, file_path)
|
|
144
|
+
except OSError as err:
|
|
145
|
+
logger.error(f"Failed to persist session {session.id}: {err}")
|
|
146
|
+
finally:
|
|
147
|
+
if temporary_path:
|
|
148
|
+
temporary_path.unlink(missing_ok=True)
|
|
149
|
+
|
|
150
|
+
def load(self, session_id: str) -> Session | None:
|
|
151
|
+
"""Load a session from its JSON file."""
|
|
152
|
+
file_path = self._get_file_path(session_id)
|
|
153
|
+
if not file_path.exists():
|
|
154
|
+
return None
|
|
155
|
+
try:
|
|
156
|
+
with file_path.open("r", encoding="utf-8") as f:
|
|
157
|
+
data = json.load(f)
|
|
158
|
+
version = int(data.get("schema_version", 0))
|
|
159
|
+
session = Session.from_dict(data)
|
|
160
|
+
if version < SESSION_SCHEMA_VERSION:
|
|
161
|
+
backup = file_path.with_suffix(f".json.schema-v{version}.bak")
|
|
162
|
+
if not backup.exists():
|
|
163
|
+
shutil.copy2(file_path, backup)
|
|
164
|
+
self.save(session)
|
|
165
|
+
return session
|
|
166
|
+
except (OSError, ValueError, json.JSONDecodeError) as err:
|
|
167
|
+
logger.error(f"Failed to load session {session_id}: {err}")
|
|
168
|
+
return None
|
|
169
|
+
|
|
170
|
+
def list_all(self) -> list[Session]:
|
|
171
|
+
"""List all available sessions."""
|
|
172
|
+
sessions = []
|
|
173
|
+
for file_path in self.store_dir.glob("*.json"):
|
|
174
|
+
session = self.load(file_path.stem)
|
|
175
|
+
if session:
|
|
176
|
+
sessions.append(session)
|
|
177
|
+
return sorted(sessions, key=lambda s: s.updated_at, reverse=True)
|
|
178
|
+
|
|
179
|
+
def set_active_session_id(self, session_id: str) -> None:
|
|
180
|
+
"""Mark a session as the currently active one."""
|
|
181
|
+
try:
|
|
182
|
+
self.active_session_file.write_text(session_id, encoding="utf-8")
|
|
183
|
+
except OSError as err:
|
|
184
|
+
logger.error(f"Failed to write active session: {err}")
|
|
185
|
+
|
|
186
|
+
def get_active_session_id(self) -> str | None:
|
|
187
|
+
"""Read the currently active session ID."""
|
|
188
|
+
if not self.active_session_file.exists():
|
|
189
|
+
return None
|
|
190
|
+
try:
|
|
191
|
+
return self.active_session_file.read_text(encoding="utf-8").strip()
|
|
192
|
+
except OSError as err:
|
|
193
|
+
logger.error(f"Failed to read active session: {err}")
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
SessionEventListener = Callable[[SessionEvent], Awaitable[None] | None]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class SessionEventBus:
|
|
201
|
+
"""Async event bus emitting structured session events."""
|
|
202
|
+
|
|
203
|
+
def __init__(self) -> None:
|
|
204
|
+
self._listeners: list[SessionEventListener] = []
|
|
205
|
+
|
|
206
|
+
def subscribe(self, listener: SessionEventListener) -> None:
|
|
207
|
+
if listener not in self._listeners:
|
|
208
|
+
self._listeners.append(listener)
|
|
209
|
+
|
|
210
|
+
def unsubscribe(self, listener: SessionEventListener) -> None:
|
|
211
|
+
if listener in self._listeners:
|
|
212
|
+
self._listeners.remove(listener)
|
|
213
|
+
|
|
214
|
+
async def emit(self, event: SessionEvent) -> None:
|
|
215
|
+
for listener in list(self._listeners):
|
|
216
|
+
try:
|
|
217
|
+
res = listener(event)
|
|
218
|
+
if asyncio.iscoroutine(res) or hasattr(res, "__await__"):
|
|
219
|
+
await res
|
|
220
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
221
|
+
except Exception as err: # noqa: BLE001
|
|
222
|
+
# Intentionally broad to isolate event listener failures from crashing the session manager.
|
|
223
|
+
logger.warning(f"Error in session event listener: {err}")
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# Session Manager
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class SessionManager:
|
|
232
|
+
"""Principal Session Manager for Pulse.
|
|
233
|
+
|
|
234
|
+
Handles session creation, resuming, archiving, event emissions,
|
|
235
|
+
and coordinates state with the TaskManager.
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
def __init__(
|
|
239
|
+
self,
|
|
240
|
+
workspace: Path | None = None,
|
|
241
|
+
task_manager: Any | None = None,
|
|
242
|
+
telemetry: Any | None = None,
|
|
243
|
+
) -> None:
|
|
244
|
+
self.workspace = workspace or Path.cwd()
|
|
245
|
+
self.store = SessionStore(self.workspace)
|
|
246
|
+
self.event_bus = SessionEventBus()
|
|
247
|
+
self.task_manager = task_manager
|
|
248
|
+
self.telemetry = telemetry
|
|
249
|
+
self._active_session: Session | None = None
|
|
250
|
+
|
|
251
|
+
@property
|
|
252
|
+
def active_session(self) -> Session | None:
|
|
253
|
+
"""Get the currently loaded active session."""
|
|
254
|
+
return self._active_session
|
|
255
|
+
|
|
256
|
+
def _mark_updated(self, session: Session) -> None:
|
|
257
|
+
session.updated_at = datetime.now(UTC).isoformat()
|
|
258
|
+
self.store.save(session)
|
|
259
|
+
|
|
260
|
+
async def create_session(self, title: str | None = None, make_active: bool = True) -> Session:
|
|
261
|
+
"""Create and optionally activate a new session."""
|
|
262
|
+
session_id = str(uuid.uuid4())
|
|
263
|
+
session = Session(
|
|
264
|
+
id=session_id,
|
|
265
|
+
title=title or "New Session",
|
|
266
|
+
status=SessionStatus.ACTIVE
|
|
267
|
+
)
|
|
268
|
+
self.store.save(session)
|
|
269
|
+
|
|
270
|
+
if make_active:
|
|
271
|
+
await self._set_active_session(session)
|
|
272
|
+
|
|
273
|
+
if self.telemetry and hasattr(self.telemetry, "log_event"):
|
|
274
|
+
self.telemetry.log_event("session_created", session_id=session_id)
|
|
275
|
+
|
|
276
|
+
await self.event_bus.emit(
|
|
277
|
+
SessionEvent("session_created", session_id, session.status, payload={"title": session.title})
|
|
278
|
+
)
|
|
279
|
+
return session
|
|
280
|
+
|
|
281
|
+
async def load_session(self, session_id: str) -> Session:
|
|
282
|
+
"""Load an existing session from store."""
|
|
283
|
+
session = self.store.load(session_id)
|
|
284
|
+
if not session:
|
|
285
|
+
raise ValueError(f"Session {session_id} not found.")
|
|
286
|
+
return session
|
|
287
|
+
|
|
288
|
+
async def resume_session(self, session_id: str) -> Session:
|
|
289
|
+
"""Load a session and mark it as active."""
|
|
290
|
+
session = await self.load_session(session_id)
|
|
291
|
+
if session.status == SessionStatus.ARCHIVED:
|
|
292
|
+
session.status = SessionStatus.ACTIVE
|
|
293
|
+
self._mark_updated(session)
|
|
294
|
+
|
|
295
|
+
await self._set_active_session(session)
|
|
296
|
+
|
|
297
|
+
if self.telemetry and hasattr(self.telemetry, "log_event"):
|
|
298
|
+
self.telemetry.log_event("session_resumed", session_id=session_id)
|
|
299
|
+
|
|
300
|
+
await self.event_bus.emit(
|
|
301
|
+
SessionEvent("session_resumed", session_id, session.status, payload={"title": session.title})
|
|
302
|
+
)
|
|
303
|
+
return session
|
|
304
|
+
|
|
305
|
+
async def archive_session(self, session_id: str) -> Session:
|
|
306
|
+
"""Archive a session."""
|
|
307
|
+
session = await self.load_session(session_id)
|
|
308
|
+
session.status = SessionStatus.ARCHIVED
|
|
309
|
+
self._mark_updated(session)
|
|
310
|
+
|
|
311
|
+
if self._active_session and self._active_session.id == session_id:
|
|
312
|
+
self._active_session = None
|
|
313
|
+
try:
|
|
314
|
+
self.store.active_session_file.unlink(missing_ok=True)
|
|
315
|
+
except OSError:
|
|
316
|
+
pass
|
|
317
|
+
|
|
318
|
+
if self.telemetry and hasattr(self.telemetry, "log_event"):
|
|
319
|
+
self.telemetry.log_event("session_archived", session_id=session_id)
|
|
320
|
+
|
|
321
|
+
await self.event_bus.emit(
|
|
322
|
+
SessionEvent("session_archived", session_id, session.status)
|
|
323
|
+
)
|
|
324
|
+
return session
|
|
325
|
+
|
|
326
|
+
async def get_or_create_active_session(self) -> Session:
|
|
327
|
+
"""Retrieve the last active session, or create a new one if none exists."""
|
|
328
|
+
if self._active_session:
|
|
329
|
+
return self._active_session
|
|
330
|
+
|
|
331
|
+
active_id = self.store.get_active_session_id()
|
|
332
|
+
if active_id:
|
|
333
|
+
try:
|
|
334
|
+
session = await self.load_session(active_id)
|
|
335
|
+
if session.status == SessionStatus.ACTIVE:
|
|
336
|
+
self._active_session = session
|
|
337
|
+
return session
|
|
338
|
+
except ValueError:
|
|
339
|
+
pass # Session file might have been deleted
|
|
340
|
+
|
|
341
|
+
# Fallback to creating a new one
|
|
342
|
+
return await self.create_session(make_active=True)
|
|
343
|
+
|
|
344
|
+
async def add_conversation_turn(self, role: str, content: str, session_id: str | None = None, metadata: dict[str, Any] | None = None) -> None:
|
|
345
|
+
"""Append a conversational turn to a session."""
|
|
346
|
+
if session_id:
|
|
347
|
+
session = await self.load_session(session_id)
|
|
348
|
+
else:
|
|
349
|
+
session = await self.get_or_create_active_session()
|
|
350
|
+
|
|
351
|
+
turn = SessionTurn(role=role, content=content, metadata=metadata or {})
|
|
352
|
+
session.conversation.append(turn)
|
|
353
|
+
self._mark_updated(session)
|
|
354
|
+
|
|
355
|
+
# If this is the active session, update in-memory reference
|
|
356
|
+
if self._active_session and self._active_session.id == session.id:
|
|
357
|
+
self._active_session = session
|
|
358
|
+
|
|
359
|
+
await self.event_bus.emit(
|
|
360
|
+
SessionEvent("session_turn_added", session.id, session.status, payload={"role": role, "length": len(content)})
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
async def _set_active_session(self, session: Session) -> None:
|
|
364
|
+
self._active_session = session
|
|
365
|
+
self.store.set_active_session_id(session.id)
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
from collections.abc import AsyncGenerator
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from enum import Enum
|
|
6
|
+
|
|
7
|
+
from pulse.context import ContextManager
|
|
8
|
+
from pulse.core.planner import PlanningRequest, RequestPlanner
|
|
9
|
+
from pulse.memory import LongTermMemory
|
|
10
|
+
from pulse.reasoning import IntentCategory, ReasoningEngine
|
|
11
|
+
from pulse.repository import RepositoryIndex
|
|
12
|
+
from pulse.session_manager import SessionManager
|
|
13
|
+
from pulse.streaming import StreamEvent, StreamingExecutionEngine
|
|
14
|
+
from pulse.task_manager import TaskManager
|
|
15
|
+
from pulse.tool_registry import ToolRegistry
|
|
16
|
+
from pulse.verification import VerificationEngine
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class EngineerStatus(Enum):
|
|
22
|
+
IDLE = "IDLE"
|
|
23
|
+
PLANNING = "PLANNING"
|
|
24
|
+
EXECUTING = "EXECUTING"
|
|
25
|
+
VERIFYING = "VERIFYING"
|
|
26
|
+
BLOCKED = "BLOCKED"
|
|
27
|
+
COMPLETED = "COMPLETED"
|
|
28
|
+
CANCELLED = "CANCELLED"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass
|
|
32
|
+
class EngineerEvent:
|
|
33
|
+
event_type: str # feature_started, planning_complete, task_started, task_completed, task_failed, feature_blocked, feature_completed
|
|
34
|
+
message: str
|
|
35
|
+
metadata: dict[str, str | int | float | bool | None] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class EngineerResult:
|
|
40
|
+
status: EngineerStatus
|
|
41
|
+
tasks_created: int
|
|
42
|
+
tasks_completed: int
|
|
43
|
+
summary: str
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class AutonomousSoftwareEngineer:
|
|
47
|
+
"""Top-level autonomous orchestration engine.
|
|
48
|
+
|
|
49
|
+
Coordinates the Planner, TaskManager, StreamingExecutionEngine, and VerificationEngine
|
|
50
|
+
to autonomously satisfy complex feature requests.
|
|
51
|
+
"""
|
|
52
|
+
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
reasoning_engine: ReasoningEngine,
|
|
56
|
+
planner: RequestPlanner,
|
|
57
|
+
task_manager: TaskManager,
|
|
58
|
+
session_manager: SessionManager,
|
|
59
|
+
streaming_engine: StreamingExecutionEngine,
|
|
60
|
+
verification_engine: VerificationEngine,
|
|
61
|
+
context_manager: ContextManager,
|
|
62
|
+
memory: LongTermMemory,
|
|
63
|
+
repository: RepositoryIndex,
|
|
64
|
+
tool_registry: ToolRegistry,
|
|
65
|
+
) -> None:
|
|
66
|
+
self.reasoning_engine = reasoning_engine
|
|
67
|
+
self.planner = planner
|
|
68
|
+
self.task_manager = task_manager
|
|
69
|
+
self.session_manager = session_manager
|
|
70
|
+
self.streaming_engine = streaming_engine
|
|
71
|
+
self.verification_engine = verification_engine
|
|
72
|
+
self.context_manager = context_manager
|
|
73
|
+
self.memory = memory
|
|
74
|
+
self.repository = repository
|
|
75
|
+
self.tool_registry = tool_registry
|
|
76
|
+
|
|
77
|
+
async def execute_feature(
|
|
78
|
+
self,
|
|
79
|
+
request: str,
|
|
80
|
+
session_id: str | None = None,
|
|
81
|
+
cancellation_token: asyncio.Event | None = None,
|
|
82
|
+
) -> AsyncGenerator[EngineerEvent | StreamEvent, None]:
|
|
83
|
+
"""Executes a high-level feature request autonomously."""
|
|
84
|
+
session = None
|
|
85
|
+
if session_id:
|
|
86
|
+
try:
|
|
87
|
+
session = await self.session_manager.load_session(session_id)
|
|
88
|
+
await self.session_manager.add_conversation_turn("user", request, session_id=session.id)
|
|
89
|
+
except ValueError:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
if not session:
|
|
93
|
+
session = await self.session_manager.get_or_create_active_session()
|
|
94
|
+
await self.session_manager.add_conversation_turn("user", request, session_id=session.id)
|
|
95
|
+
|
|
96
|
+
yield EngineerEvent("feature_started", f"Starting feature: {request[:50]}...", {"session_id": session.id})
|
|
97
|
+
|
|
98
|
+
# 1. Reasoning
|
|
99
|
+
intent_res = await self.reasoning_engine.analyze_intent(request)
|
|
100
|
+
if intent_res.category == IntentCategory.DIRECT_ANSWER:
|
|
101
|
+
# Short-circuit for direct answers
|
|
102
|
+
yield EngineerEvent("feature_completed", "Direct answer provided without planning.")
|
|
103
|
+
return
|
|
104
|
+
|
|
105
|
+
# 2. Planning
|
|
106
|
+
yield EngineerEvent("planning_started", "Decomposing feature into subtasks...")
|
|
107
|
+
plan = await self.planner.plan(PlanningRequest(message=request))
|
|
108
|
+
yield EngineerEvent("planning_complete", f"Generated {len(plan.steps)} subtasks.")
|
|
109
|
+
|
|
110
|
+
# 3. Create Tasks
|
|
111
|
+
created_tasks = []
|
|
112
|
+
for step in plan.steps:
|
|
113
|
+
task = await self.task_manager.create_task(
|
|
114
|
+
title=f"Step {step.id}",
|
|
115
|
+
goal=step.description
|
|
116
|
+
)
|
|
117
|
+
created_tasks.append(task)
|
|
118
|
+
|
|
119
|
+
# 4. Iterative Execution Loop
|
|
120
|
+
tasks_completed = 0
|
|
121
|
+
for task in created_tasks:
|
|
122
|
+
if cancellation_token and cancellation_token.is_set():
|
|
123
|
+
yield EngineerEvent("feature_cancelled", "Execution cancelled by user.")
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
yield EngineerEvent("task_started", f"Executing task: {task.title}", {"task_id": task.id})
|
|
127
|
+
|
|
128
|
+
# Build context for this step
|
|
129
|
+
# Execute via StreamingExecutionEngine
|
|
130
|
+
try:
|
|
131
|
+
async for stream_event in self.streaming_engine.execute_stream(task.goal, task_id=task.id):
|
|
132
|
+
if cancellation_token and cancellation_token.is_set():
|
|
133
|
+
await self.task_manager.cancel_task(task.id, reason="User cancelled")
|
|
134
|
+
yield EngineerEvent("feature_cancelled", "Execution cancelled by user.")
|
|
135
|
+
return
|
|
136
|
+
yield stream_event
|
|
137
|
+
|
|
138
|
+
await self.task_manager.complete_task(task.id)
|
|
139
|
+
tasks_completed += 1
|
|
140
|
+
yield EngineerEvent("task_completed", f"Completed task: {task.title}", {"task_id": task.id})
|
|
141
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
142
|
+
except Exception as e: # noqa: BLE001
|
|
143
|
+
logger.error(f"Task {task.id} failed: {e}")
|
|
144
|
+
await self.task_manager.fail_task(task.id, error=str(e))
|
|
145
|
+
yield EngineerEvent("task_failed", f"Task failed: {task.title} - {e}", {"task_id": task.id})
|
|
146
|
+
yield EngineerEvent("feature_blocked", "Feature blocked due to task failure.")
|
|
147
|
+
return
|
|
148
|
+
|
|
149
|
+
# 5. Global Verification
|
|
150
|
+
yield EngineerEvent("verifying", "Running global verification...")
|
|
151
|
+
try:
|
|
152
|
+
ver_res = await self.verification_engine.verify()
|
|
153
|
+
if not ver_res.success:
|
|
154
|
+
yield EngineerEvent("verification_failed", "Global verification failed.", {"details": ver_res.message})
|
|
155
|
+
# Intentionally broad to isolate execution boundaries and prevent crashes.
|
|
156
|
+
except Exception as e: # noqa: BLE001
|
|
157
|
+
logger.warning(f"Global verification error: {e}")
|
|
158
|
+
|
|
159
|
+
# 6. Completion
|
|
160
|
+
summary = f"Successfully completed {tasks_completed}/{len(created_tasks)} tasks."
|
|
161
|
+
await self.session_manager.add_conversation_turn("agent", summary, session_id=session.id)
|
|
162
|
+
|
|
163
|
+
yield EngineerEvent("feature_completed", summary, {"tasks_completed": tasks_completed})
|
|
164
|
+
|
|
165
|
+
async def resume_feature(self, session_id: str, cancellation_token: asyncio.Event | None = None) -> AsyncGenerator[EngineerEvent | StreamEvent, None]:
|
|
166
|
+
"""Resumes an interrupted or paused feature session."""
|
|
167
|
+
try:
|
|
168
|
+
session = await self.session_manager.resume_session(session_id)
|
|
169
|
+
except ValueError as e:
|
|
170
|
+
yield EngineerEvent("resume_failed", str(e))
|
|
171
|
+
return
|
|
172
|
+
|
|
173
|
+
yield EngineerEvent("feature_resumed", f"Resuming session: {session_id}", {"session_id": session.id})
|
|
174
|
+
|
|
175
|
+
# In a real implementation we would fetch pending tasks and execute them.
|
|
176
|
+
yield EngineerEvent("feature_completed", "Resumed and completed.")
|
|
177
|
+
|
|
178
|
+
async def cancel_feature(self, session_id: str) -> None:
|
|
179
|
+
"""Cancels an ongoing feature execution."""
|
|
180
|
+
# Typically we'd find active tasks for this session and cancel them.
|
|
181
|
+
try:
|
|
182
|
+
session = await self.session_manager.load_session(session_id)
|
|
183
|
+
for task_id in session.active_tasks:
|
|
184
|
+
try:
|
|
185
|
+
await self.task_manager.cancel_task(task_id, reason="Feature cancelled")
|
|
186
|
+
except ValueError:
|
|
187
|
+
pass
|
|
188
|
+
except ValueError:
|
|
189
|
+
pass
|
pulse/storage.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Transactional SQLite schema migration, backup, and restore primitives."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
import tempfile
|
|
9
|
+
from collections.abc import Callable
|
|
10
|
+
from contextlib import closing
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
Migration = Callable[[sqlite3.Connection, int], None]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def schema_version(database_path: Path) -> int:
|
|
18
|
+
if not database_path.exists():
|
|
19
|
+
return 0
|
|
20
|
+
with closing(sqlite3.connect(database_path)) as connection:
|
|
21
|
+
return int(connection.execute("PRAGMA user_version").fetchone()[0])
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def backup_database(source: Path, destination: Path) -> Path:
|
|
25
|
+
source = source.resolve()
|
|
26
|
+
destination = destination.resolve()
|
|
27
|
+
if not source.is_file():
|
|
28
|
+
raise FileNotFoundError(source)
|
|
29
|
+
if source == destination:
|
|
30
|
+
raise ValueError("Backup destination must differ from the source database.")
|
|
31
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
with (
|
|
33
|
+
closing(sqlite3.connect(source)) as source_connection,
|
|
34
|
+
closing(sqlite3.connect(destination)) as backup_connection,
|
|
35
|
+
):
|
|
36
|
+
source_connection.backup(backup_connection)
|
|
37
|
+
result = backup_connection.execute("PRAGMA integrity_check").fetchone()
|
|
38
|
+
if not result or result[0] != "ok":
|
|
39
|
+
raise sqlite3.DatabaseError("Backup integrity check failed.")
|
|
40
|
+
return destination
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def restore_database(backup: Path, destination: Path) -> Path:
|
|
44
|
+
"""Restore a verified backup atomically; the destination must not be in use."""
|
|
45
|
+
backup = backup.resolve()
|
|
46
|
+
destination = destination.resolve()
|
|
47
|
+
if not backup.is_file():
|
|
48
|
+
raise FileNotFoundError(backup)
|
|
49
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
handle, temporary_name = tempfile.mkstemp(
|
|
51
|
+
prefix=f".{destination.name}.restore-", suffix=".sqlite3", dir=destination.parent
|
|
52
|
+
)
|
|
53
|
+
os.close(handle)
|
|
54
|
+
temporary_path = Path(temporary_name)
|
|
55
|
+
try:
|
|
56
|
+
backup_database(backup, temporary_path)
|
|
57
|
+
for suffix in ("-wal", "-shm"):
|
|
58
|
+
Path(f"{destination}{suffix}").unlink(missing_ok=True)
|
|
59
|
+
os.replace(temporary_path, destination)
|
|
60
|
+
finally:
|
|
61
|
+
temporary_path.unlink(missing_ok=True)
|
|
62
|
+
return destination
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def migrate_database(
|
|
66
|
+
database_path: Path,
|
|
67
|
+
target_version: int,
|
|
68
|
+
migration: Migration,
|
|
69
|
+
*,
|
|
70
|
+
timeout: float = 10.0,
|
|
71
|
+
) -> Path | None:
|
|
72
|
+
"""Migrate one database transactionally and back up any existing schema."""
|
|
73
|
+
if target_version < 1:
|
|
74
|
+
raise ValueError("Target schema version must be positive.")
|
|
75
|
+
database_path = database_path.resolve()
|
|
76
|
+
database_path.parent.mkdir(parents=True, exist_ok=True)
|
|
77
|
+
backup_path: Path | None = None
|
|
78
|
+
with closing(
|
|
79
|
+
sqlite3.connect(database_path, timeout=timeout, isolation_level=None)
|
|
80
|
+
) as connection:
|
|
81
|
+
connection.execute("PRAGMA journal_mode=WAL")
|
|
82
|
+
current = int(connection.execute("PRAGMA user_version").fetchone()[0])
|
|
83
|
+
if current > target_version:
|
|
84
|
+
raise RuntimeError(
|
|
85
|
+
f"Database schema v{current} is newer than supported v{target_version}."
|
|
86
|
+
)
|
|
87
|
+
if current == target_version:
|
|
88
|
+
return None
|
|
89
|
+
has_user_tables = connection.execute(
|
|
90
|
+
"SELECT 1 FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' LIMIT 1"
|
|
91
|
+
).fetchone()
|
|
92
|
+
if has_user_tables:
|
|
93
|
+
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S%fZ")
|
|
94
|
+
backup_path = (
|
|
95
|
+
database_path.parent
|
|
96
|
+
/ "backups"
|
|
97
|
+
/ f"{database_path.stem}.schema-v{current}-to-v{target_version}.{stamp}.sqlite3"
|
|
98
|
+
)
|
|
99
|
+
backup_database(database_path, backup_path)
|
|
100
|
+
try:
|
|
101
|
+
connection.execute("BEGIN IMMEDIATE")
|
|
102
|
+
migration(connection, current)
|
|
103
|
+
connection.execute(f"PRAGMA user_version = {target_version}")
|
|
104
|
+
connection.execute("COMMIT")
|
|
105
|
+
except BaseException:
|
|
106
|
+
connection.execute("ROLLBACK")
|
|
107
|
+
raise
|
|
108
|
+
return backup_path
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def main() -> int:
|
|
112
|
+
parser = argparse.ArgumentParser(description="Inspect, back up, or restore Pulse SQLite state.")
|
|
113
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
114
|
+
version_parser = commands.add_parser("version")
|
|
115
|
+
version_parser.add_argument("database", type=Path)
|
|
116
|
+
backup_parser = commands.add_parser("backup")
|
|
117
|
+
backup_parser.add_argument("source", type=Path)
|
|
118
|
+
backup_parser.add_argument("destination", type=Path)
|
|
119
|
+
restore_parser = commands.add_parser("restore")
|
|
120
|
+
restore_parser.add_argument("backup", type=Path)
|
|
121
|
+
restore_parser.add_argument("destination", type=Path)
|
|
122
|
+
restore_parser.add_argument("--confirm-stopped", action="store_true")
|
|
123
|
+
args = parser.parse_args()
|
|
124
|
+
try:
|
|
125
|
+
if args.command == "version":
|
|
126
|
+
print(schema_version(args.database))
|
|
127
|
+
elif args.command == "backup":
|
|
128
|
+
print(backup_database(args.source, args.destination))
|
|
129
|
+
else:
|
|
130
|
+
if not args.confirm_stopped:
|
|
131
|
+
parser.error("restore requires --confirm-stopped")
|
|
132
|
+
print(restore_database(args.backup, args.destination))
|
|
133
|
+
except (OSError, RuntimeError, sqlite3.DatabaseError, ValueError) as exc:
|
|
134
|
+
print(f"Storage operation failed: {exc}")
|
|
135
|
+
return 2
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
if __name__ == "__main__":
|
|
140
|
+
raise SystemExit(main())
|