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/edits.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Approval-gated file editing, independent from any user interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from difflib import unified_diff
|
|
8
|
+
|
|
9
|
+
from pulse.sandbox import ProjectSandbox
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class EditProposal:
|
|
14
|
+
file_path: str
|
|
15
|
+
before_content: str | None
|
|
16
|
+
after_content: str
|
|
17
|
+
reason: str
|
|
18
|
+
unified_diff: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class EditResult:
|
|
23
|
+
proposal: EditProposal
|
|
24
|
+
applied: bool
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
ApprovalHandler = Callable[[EditProposal], Awaitable[bool]]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class EditWorkflow:
|
|
31
|
+
"""Creates diffs first and mutates a project only after explicit approval."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, sandbox: ProjectSandbox) -> None:
|
|
34
|
+
self.sandbox = sandbox
|
|
35
|
+
|
|
36
|
+
async def propose(self, file_path: str, content: str, reason: str) -> EditProposal:
|
|
37
|
+
before = self.sandbox.read_file_for_edit(file_path)
|
|
38
|
+
return EditProposal(
|
|
39
|
+
file_path=file_path,
|
|
40
|
+
before_content=before,
|
|
41
|
+
after_content=content,
|
|
42
|
+
reason=reason,
|
|
43
|
+
unified_diff="".join(
|
|
44
|
+
unified_diff(
|
|
45
|
+
(before or "").splitlines(keepends=True),
|
|
46
|
+
content.splitlines(keepends=True),
|
|
47
|
+
fromfile=f"a/{file_path}",
|
|
48
|
+
tofile=f"b/{file_path}",
|
|
49
|
+
)
|
|
50
|
+
),
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
async def request_and_apply(
|
|
54
|
+
self, file_path: str, content: str, reason: str, approve: ApprovalHandler
|
|
55
|
+
) -> EditResult:
|
|
56
|
+
proposal = await self.propose(file_path, content, reason)
|
|
57
|
+
if not await approve(proposal):
|
|
58
|
+
self.sandbox.record_rejected_edit(proposal.file_path, proposal.reason)
|
|
59
|
+
return EditResult(proposal=proposal, applied=False)
|
|
60
|
+
|
|
61
|
+
self.sandbox.apply_approved_edit(proposal.file_path, proposal.after_content, proposal.reason)
|
|
62
|
+
return EditResult(proposal=proposal, applied=True)
|
|
63
|
+
|
|
64
|
+
async def rollback_last(self) -> bool:
|
|
65
|
+
return self.sandbox.rollback_last_approved_edit()
|
pulse/episodic.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import sqlite3
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from datetime import UTC, datetime
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from pulse.storage import migrate_database
|
|
9
|
+
|
|
10
|
+
EPISODIC_SCHEMA_VERSION = 1
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ExecutionTrace:
|
|
15
|
+
id: int
|
|
16
|
+
timestamp: str
|
|
17
|
+
prompt: str
|
|
18
|
+
error: str
|
|
19
|
+
resolution: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class EpisodicMemory:
|
|
23
|
+
"""Manages execution trace storage (Prompt, Error, Resolution) in workspace SQLite database."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, db_path: Path | None = None) -> None:
|
|
26
|
+
self.db_path = db_path or Path(".agent/episodic-memory.sqlite3")
|
|
27
|
+
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
self._init_db()
|
|
29
|
+
|
|
30
|
+
def _init_db(self) -> None:
|
|
31
|
+
def migration(conn: sqlite3.Connection, _current: int) -> None:
|
|
32
|
+
conn.execute(
|
|
33
|
+
"""
|
|
34
|
+
CREATE TABLE IF NOT EXISTS execution_traces (
|
|
35
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
36
|
+
timestamp TEXT NOT NULL,
|
|
37
|
+
prompt TEXT NOT NULL,
|
|
38
|
+
error TEXT NOT NULL,
|
|
39
|
+
resolution TEXT NOT NULL
|
|
40
|
+
)
|
|
41
|
+
"""
|
|
42
|
+
)
|
|
43
|
+
migrate_database(self.db_path, EPISODIC_SCHEMA_VERSION, migration)
|
|
44
|
+
|
|
45
|
+
def log_trace(self, prompt: str, error: str, resolution: str) -> ExecutionTrace:
|
|
46
|
+
timestamp = datetime.now(UTC).isoformat()
|
|
47
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
48
|
+
cursor = conn.cursor()
|
|
49
|
+
cursor.execute(
|
|
50
|
+
"INSERT INTO execution_traces (timestamp, prompt, error, resolution) VALUES (?, ?, ?, ?)",
|
|
51
|
+
(timestamp, prompt, error, resolution),
|
|
52
|
+
)
|
|
53
|
+
conn.commit()
|
|
54
|
+
trace_id = cursor.lastrowid or 0
|
|
55
|
+
|
|
56
|
+
return ExecutionTrace(
|
|
57
|
+
id=trace_id,
|
|
58
|
+
timestamp=timestamp,
|
|
59
|
+
prompt=prompt,
|
|
60
|
+
error=error,
|
|
61
|
+
resolution=resolution,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def search_similar_resolutions(self, query: str, limit: int = 5) -> list[ExecutionTrace]:
|
|
65
|
+
query_lower = f"%{query.lower()}%"
|
|
66
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
67
|
+
cursor = conn.cursor()
|
|
68
|
+
cursor.execute(
|
|
69
|
+
"""
|
|
70
|
+
SELECT id, timestamp, prompt, error, resolution
|
|
71
|
+
FROM execution_traces
|
|
72
|
+
WHERE LOWER(prompt) LIKE ? OR LOWER(error) LIKE ? OR LOWER(resolution) LIKE ?
|
|
73
|
+
ORDER BY id DESC
|
|
74
|
+
LIMIT ?
|
|
75
|
+
""",
|
|
76
|
+
(query_lower, query_lower, query_lower, limit),
|
|
77
|
+
)
|
|
78
|
+
rows = cursor.fetchall()
|
|
79
|
+
|
|
80
|
+
return [
|
|
81
|
+
ExecutionTrace(id=row[0], timestamp=row[1], prompt=row[2], error=row[3], resolution=row[4])
|
|
82
|
+
for row in rows
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
def get_all_traces(self) -> list[ExecutionTrace]:
|
|
86
|
+
with sqlite3.connect(self.db_path) as conn:
|
|
87
|
+
cursor = conn.cursor()
|
|
88
|
+
cursor.execute("SELECT id, timestamp, prompt, error, resolution FROM execution_traces ORDER BY id ASC")
|
|
89
|
+
rows = cursor.fetchall()
|
|
90
|
+
return [
|
|
91
|
+
ExecutionTrace(id=row[0], timestamp=row[1], prompt=row[2], error=row[3], resolution=row[4])
|
|
92
|
+
for row in rows
|
|
93
|
+
]
|
pulse/eval/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Evaluation utilities for Pulse.
|
|
2
|
+
Provides:
|
|
3
|
+
- PatchVerifier: Apply candidate diffs and evaluate test outcomes.
|
|
4
|
+
- TrajectoryLogger: Record agent action trajectories.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .trajectory_logger import TrajectoryLogger, TrajectoryStep # noqa: F401
|
|
8
|
+
from .verifier import PatchVerifier # noqa: F401
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import uuid
|
|
5
|
+
from collections.abc import Mapping, Sequence
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True)
|
|
13
|
+
class TrajectoryStep:
|
|
14
|
+
"""A single recorded step of an agent run."""
|
|
15
|
+
|
|
16
|
+
timestamp: str = field(default_factory=lambda: datetime.now(UTC).isoformat())
|
|
17
|
+
prompt: str | None = None
|
|
18
|
+
tool_name: str | None = None
|
|
19
|
+
tool_args: Mapping[str, Any] | None = None
|
|
20
|
+
reasoning: str | None = None
|
|
21
|
+
token_cost: int | None = None
|
|
22
|
+
test_log: str | None = None
|
|
23
|
+
result: str | None = None
|
|
24
|
+
|
|
25
|
+
class TrajectoryLogger:
|
|
26
|
+
"""Collects and persists a sequence of :class:`TrajectoryStep` objects.
|
|
27
|
+
Stored under ``.agent/trajectories/<task_id>.json``.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def __init__(self, workspace: Path, task_id: str | None = None) -> None:
|
|
31
|
+
self.workspace = workspace.resolve()
|
|
32
|
+
self.task_id = task_id or uuid.uuid4().hex
|
|
33
|
+
self._steps: list[TrajectoryStep] = []
|
|
34
|
+
self._base = self.workspace / ".agent" / "trajectories"
|
|
35
|
+
self._base.mkdir(parents=True, exist_ok=True)
|
|
36
|
+
|
|
37
|
+
# ------------------------------------------------------------------ API
|
|
38
|
+
def add_step(
|
|
39
|
+
self,
|
|
40
|
+
*,
|
|
41
|
+
prompt: str | None = None,
|
|
42
|
+
tool_name: str | None = None,
|
|
43
|
+
tool_args: Mapping[str, Any] | None = None,
|
|
44
|
+
reasoning: str | None = None,
|
|
45
|
+
token_cost: int | None = None,
|
|
46
|
+
test_log: str | None = None,
|
|
47
|
+
result: str | None = None,
|
|
48
|
+
) -> None:
|
|
49
|
+
"""Append a new step to the internal buffer."""
|
|
50
|
+
step = TrajectoryStep(
|
|
51
|
+
prompt=prompt,
|
|
52
|
+
tool_name=tool_name,
|
|
53
|
+
tool_args=tool_args,
|
|
54
|
+
reasoning=reasoning,
|
|
55
|
+
token_cost=token_cost,
|
|
56
|
+
test_log=test_log,
|
|
57
|
+
result=result,
|
|
58
|
+
)
|
|
59
|
+
self._steps.append(step)
|
|
60
|
+
|
|
61
|
+
def dump(self) -> Path:
|
|
62
|
+
"""Write the buffered steps to ``.agent/trajectories/<task_id>.json``.
|
|
63
|
+
Returns the path to the written file.
|
|
64
|
+
"""
|
|
65
|
+
out_path = self._base / f"{self.task_id}.json"
|
|
66
|
+
payload = {
|
|
67
|
+
"schema_version": 1,
|
|
68
|
+
"task_id": self.task_id,
|
|
69
|
+
"steps": [asdict(step) for step in self._steps],
|
|
70
|
+
}
|
|
71
|
+
out_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
|
72
|
+
return out_path
|
|
73
|
+
|
|
74
|
+
def load(self) -> Sequence[TrajectoryStep]:
|
|
75
|
+
"""Load a previously persisted trajectory (if it exists) and replace the buffer.
|
|
76
|
+
Returns the loaded list of steps.
|
|
77
|
+
"""
|
|
78
|
+
path = self._base / f"{self.task_id}.json"
|
|
79
|
+
if not path.is_file():
|
|
80
|
+
self._steps = []
|
|
81
|
+
return []
|
|
82
|
+
payload = json.loads(path.read_text(encoding="utf-8"))
|
|
83
|
+
raw: list[dict[str, Any]] = payload if isinstance(payload, list) else payload["steps"]
|
|
84
|
+
self._steps = [TrajectoryStep(**item) for item in raw]
|
|
85
|
+
return self._steps
|
|
86
|
+
|
|
87
|
+
# ------------------------------------------------------------------ Helpers
|
|
88
|
+
@property
|
|
89
|
+
def steps(self) -> Sequence[TrajectoryStep]:
|
|
90
|
+
"""Read‑only view of the current steps buffer."""
|
|
91
|
+
return tuple(self._steps)
|
pulse/eval/verifier.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Literal, TypedDict
|
|
11
|
+
|
|
12
|
+
from pulse.subprocesses import isolated_process_kwargs
|
|
13
|
+
|
|
14
|
+
TestResult = Literal["pass", "fail"]
|
|
15
|
+
|
|
16
|
+
class PatchVerificationMetrics(TypedDict):
|
|
17
|
+
"""Outcome metrics for a candidate fix."""
|
|
18
|
+
issue_description: str
|
|
19
|
+
patch: str
|
|
20
|
+
initial_test_result: TestResult
|
|
21
|
+
post_apply_test_result: TestResult
|
|
22
|
+
fail_to_pass: bool
|
|
23
|
+
pass_to_pass: bool
|
|
24
|
+
pytest_stdout: str
|
|
25
|
+
pytest_stderr: str
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True)
|
|
28
|
+
class PatchVerifier:
|
|
29
|
+
"""Applies a diff to a copy of the repository and evaluates test outcomes."""
|
|
30
|
+
|
|
31
|
+
workspace: Path
|
|
32
|
+
"""Root of the original Pulse project."""
|
|
33
|
+
|
|
34
|
+
def _run_pytest(self, directory: Path) -> tuple[int, str, str]:
|
|
35
|
+
"""Execute ``pytest`` in *directory* and return (returncode, stdout, stderr)."""
|
|
36
|
+
try:
|
|
37
|
+
proc = subprocess.run(
|
|
38
|
+
[sys.executable, "-m", "pytest", "-p", "no:cacheprovider"],
|
|
39
|
+
cwd=directory,
|
|
40
|
+
capture_output=True,
|
|
41
|
+
text=True,
|
|
42
|
+
timeout=120,
|
|
43
|
+
check=False,
|
|
44
|
+
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
|
|
45
|
+
**isolated_process_kwargs(),
|
|
46
|
+
)
|
|
47
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
48
|
+
except subprocess.TimeoutExpired as exc:
|
|
49
|
+
return -1, "", f"Timeout: {exc}"
|
|
50
|
+
|
|
51
|
+
def _apply_patch(self, target_dir: Path, patch_text: str) -> tuple[bool, str]:
|
|
52
|
+
"""Validate and apply a unified diff without allowing paths outside the copy."""
|
|
53
|
+
if not patch_text:
|
|
54
|
+
return False, "Patch is empty."
|
|
55
|
+
try:
|
|
56
|
+
normalized_patch = patch_text if patch_text.endswith("\n") else patch_text + "\n"
|
|
57
|
+
for mode in ("--check", "--apply"):
|
|
58
|
+
proc = subprocess.run(
|
|
59
|
+
["git", "apply", mode, "--whitespace=nowarn", "-"],
|
|
60
|
+
cwd=target_dir,
|
|
61
|
+
input=normalized_patch,
|
|
62
|
+
capture_output=True,
|
|
63
|
+
text=True,
|
|
64
|
+
timeout=30,
|
|
65
|
+
check=False,
|
|
66
|
+
**isolated_process_kwargs(),
|
|
67
|
+
)
|
|
68
|
+
if proc.returncode != 0:
|
|
69
|
+
detail = proc.stderr.strip() or proc.stdout.strip() or "git apply failed"
|
|
70
|
+
return False, detail
|
|
71
|
+
return True, ""
|
|
72
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
73
|
+
return False, f"Patch application failed: {exc}"
|
|
74
|
+
|
|
75
|
+
def verify(self, issue_description: str, patch: str) -> PatchVerificationMetrics:
|
|
76
|
+
"""Run the verification cycle and return detailed metrics.
|
|
77
|
+
Steps:
|
|
78
|
+
1. Run pytest on the original workspace (baseline).
|
|
79
|
+
2. Copy the workspace to a temporary isolated directory.
|
|
80
|
+
3. Apply the supplied *patch* in the temporary copy.
|
|
81
|
+
4. Re‑run pytest on the patched copy.
|
|
82
|
+
5. Compute pass/fail transitions and return the metrics dict.
|
|
83
|
+
"""
|
|
84
|
+
# Baseline run
|
|
85
|
+
init_rc, _init_out, _init_err = self._run_pytest(self.workspace)
|
|
86
|
+
initial_result: TestResult = "pass" if init_rc == 0 else "fail"
|
|
87
|
+
|
|
88
|
+
# Isolated copy and patch application
|
|
89
|
+
with tempfile.TemporaryDirectory() as tmp_dir:
|
|
90
|
+
tmp_path = Path(tmp_dir)
|
|
91
|
+
ignored = {
|
|
92
|
+
".git",
|
|
93
|
+
".mypy_cache",
|
|
94
|
+
".pytest_cache",
|
|
95
|
+
".ruff_cache",
|
|
96
|
+
".venv",
|
|
97
|
+
"__pycache__",
|
|
98
|
+
"build",
|
|
99
|
+
"dist",
|
|
100
|
+
"venv",
|
|
101
|
+
}
|
|
102
|
+
for item in self.workspace.iterdir():
|
|
103
|
+
if item.name in ignored:
|
|
104
|
+
continue
|
|
105
|
+
dest = tmp_path / item.name
|
|
106
|
+
if item.is_dir():
|
|
107
|
+
shutil.copytree(
|
|
108
|
+
item,
|
|
109
|
+
dest,
|
|
110
|
+
dirs_exist_ok=True,
|
|
111
|
+
ignore=shutil.ignore_patterns(*ignored),
|
|
112
|
+
)
|
|
113
|
+
else:
|
|
114
|
+
shutil.copy2(item, dest)
|
|
115
|
+
|
|
116
|
+
applied, apply_error = self._apply_patch(tmp_path, patch)
|
|
117
|
+
if applied:
|
|
118
|
+
post_rc, post_out, post_err = self._run_pytest(tmp_path)
|
|
119
|
+
else:
|
|
120
|
+
post_rc, post_out, post_err = -1, "", apply_error
|
|
121
|
+
|
|
122
|
+
post_result: TestResult = "pass" if post_rc == 0 else "fail"
|
|
123
|
+
|
|
124
|
+
return {
|
|
125
|
+
"issue_description": issue_description,
|
|
126
|
+
"patch": patch,
|
|
127
|
+
"initial_test_result": initial_result,
|
|
128
|
+
"post_apply_test_result": post_result,
|
|
129
|
+
"fail_to_pass": initial_result == "fail" and post_result == "pass",
|
|
130
|
+
"pass_to_pass": initial_result == "pass" and post_result == "pass",
|
|
131
|
+
"pytest_stdout": post_out,
|
|
132
|
+
"pytest_stderr": post_err,
|
|
133
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Fenced bridge between durable tasks and the remote sandbox."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from pulse.sandbox.api import Sandbox
|
|
11
|
+
from pulse.sandbox.process import ProcessResult
|
|
12
|
+
from pulse.task_manager import Task, TaskManager
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(slots=True)
|
|
16
|
+
class RemoteTaskExecutor:
|
|
17
|
+
"""Submit one task attempt to a remote sandbox using its durable ID.
|
|
18
|
+
|
|
19
|
+
This adapter is deliberately outside both ``TaskManager`` and the sandbox:
|
|
20
|
+
the task layer owns fencing and recovery while the sandbox owns execution
|
|
21
|
+
policy. It is the sole bridge responsible for carrying the fenced
|
|
22
|
+
``remote_execution_id`` across that boundary.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
task_manager: TaskManager
|
|
26
|
+
sandbox: Sandbox
|
|
27
|
+
|
|
28
|
+
async def execute(
|
|
29
|
+
self,
|
|
30
|
+
task_id: str,
|
|
31
|
+
command: str | list[str],
|
|
32
|
+
*,
|
|
33
|
+
cwd: Path | str | None = None,
|
|
34
|
+
env: dict[str, str] | None = None,
|
|
35
|
+
) -> Task:
|
|
36
|
+
await self.sandbox.initialize()
|
|
37
|
+
if getattr(self.sandbox.backend, "name", None) != "remote":
|
|
38
|
+
raise RuntimeError(
|
|
39
|
+
"RemoteTaskExecutor requires a configured remote sandbox backend."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
task = await self.task_manager.start_task(task_id)
|
|
43
|
+
execution_id = await self.task_manager.remote_execution_id_for_task(task.id)
|
|
44
|
+
try:
|
|
45
|
+
result = await self.sandbox.execute_command(
|
|
46
|
+
command,
|
|
47
|
+
cwd=cwd,
|
|
48
|
+
env=env,
|
|
49
|
+
execution_id=execution_id,
|
|
50
|
+
)
|
|
51
|
+
except Exception as err: # noqa: BLE001 - persists a recoverable task failure
|
|
52
|
+
return await self.task_manager.fail_task(task.id, str(err))
|
|
53
|
+
|
|
54
|
+
if self._succeeded(result):
|
|
55
|
+
return await self.task_manager.complete_task(
|
|
56
|
+
task.id, json.dumps(self._result_payload(result), sort_keys=True)
|
|
57
|
+
)
|
|
58
|
+
return await self.task_manager.fail_task(
|
|
59
|
+
task.id,
|
|
60
|
+
result.stderr or f"Remote command exited with code {result.exit_code}.",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
@staticmethod
|
|
64
|
+
def _succeeded(result: ProcessResult) -> bool:
|
|
65
|
+
return result.exit_code == 0 and not result.timed_out
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def _result_payload(result: ProcessResult) -> dict[str, Any]:
|
|
69
|
+
return {
|
|
70
|
+
"exit_code": result.exit_code,
|
|
71
|
+
"stdout": result.stdout,
|
|
72
|
+
"stderr": result.stderr,
|
|
73
|
+
"duration_ms": result.duration_ms,
|
|
74
|
+
"timed_out": result.timed_out,
|
|
75
|
+
"termination_reason": result.termination_reason,
|
|
76
|
+
}
|
pulse/git.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Async Git inspection and commit guidance, independent of Pulse interfaces."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Awaitable, Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from pulse.subprocesses import (
|
|
12
|
+
isolated_process_kwargs,
|
|
13
|
+
isolated_subprocess_environment,
|
|
14
|
+
terminate_process,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True, slots=True)
|
|
19
|
+
class GitChange:
|
|
20
|
+
path: str
|
|
21
|
+
index_status: str
|
|
22
|
+
worktree_status: str
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class GitStatus:
|
|
27
|
+
is_repository: bool
|
|
28
|
+
branch: str | None = None
|
|
29
|
+
head: str | None = None
|
|
30
|
+
changes: tuple[GitChange, ...] = ()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True, slots=True)
|
|
34
|
+
class DiffAnalysis:
|
|
35
|
+
files_changed: int
|
|
36
|
+
additions: int
|
|
37
|
+
deletions: int
|
|
38
|
+
files: tuple[str, ...] = ()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True, slots=True)
|
|
42
|
+
class GitInsight:
|
|
43
|
+
status: GitStatus
|
|
44
|
+
diff: DiffAnalysis
|
|
45
|
+
commit_suggestion: str | None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
GitRunner = Callable[[tuple[str, ...], Path], Awaitable[tuple[int, str, str]]]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class GitIntelligence:
|
|
52
|
+
"""Read-only Git state, diff analysis, and conventional commit suggestions."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, workspace: Path, *, runner: GitRunner | None = None) -> None:
|
|
55
|
+
self.workspace = workspace.resolve()
|
|
56
|
+
self._runner = runner or self._run_git
|
|
57
|
+
|
|
58
|
+
async def status(self) -> GitStatus:
|
|
59
|
+
code, output, _ = await self._runner(("rev-parse", "--is-inside-work-tree"), self.workspace)
|
|
60
|
+
if code != 0 or output.strip() != "true":
|
|
61
|
+
return GitStatus(False)
|
|
62
|
+
_, branch, _ = await self._runner(("branch", "--show-current"), self.workspace)
|
|
63
|
+
_, head, _ = await self._runner(("rev-parse", "--short", "HEAD"), self.workspace)
|
|
64
|
+
_, porcelain, _ = await self._runner(("status", "--porcelain=v1"), self.workspace)
|
|
65
|
+
return GitStatus(True, branch.strip() or None, head.strip() or None, self._parse_status(porcelain))
|
|
66
|
+
|
|
67
|
+
async def analyze_diff(self) -> DiffAnalysis:
|
|
68
|
+
status = await self.status()
|
|
69
|
+
if not status.is_repository:
|
|
70
|
+
return DiffAnalysis(0, 0, 0)
|
|
71
|
+
_, unstaged, _ = await self._runner(
|
|
72
|
+
("diff", "--no-ext-diff", "--no-textconv", "--numstat"), self.workspace
|
|
73
|
+
)
|
|
74
|
+
_, staged, _ = await self._runner(
|
|
75
|
+
("diff", "--cached", "--no-ext-diff", "--no-textconv", "--numstat"),
|
|
76
|
+
self.workspace,
|
|
77
|
+
)
|
|
78
|
+
additions = deletions = 0
|
|
79
|
+
files = {change.path for change in status.changes}
|
|
80
|
+
for line in f"{unstaged}\n{staged}".splitlines():
|
|
81
|
+
parts = line.split("\t", 2)
|
|
82
|
+
if len(parts) != 3:
|
|
83
|
+
continue
|
|
84
|
+
added, removed, path = parts
|
|
85
|
+
additions += int(added) if added.isdigit() else 0
|
|
86
|
+
deletions += int(removed) if removed.isdigit() else 0
|
|
87
|
+
files.add(path)
|
|
88
|
+
return DiffAnalysis(len(files), additions, deletions, tuple(sorted(files)))
|
|
89
|
+
|
|
90
|
+
async def inspect(self) -> GitInsight:
|
|
91
|
+
"""Return branch, working-tree changes, diff totals, and a commit idea."""
|
|
92
|
+
status = await self.status()
|
|
93
|
+
if not status.is_repository:
|
|
94
|
+
return GitInsight(status, DiffAnalysis(0, 0, 0), None)
|
|
95
|
+
# Reuse the status we already collected while keeping diff collection
|
|
96
|
+
# independent and safe for callers that only need one of the operations.
|
|
97
|
+
_, unstaged, _ = await self._runner(
|
|
98
|
+
("diff", "--no-ext-diff", "--no-textconv", "--numstat"), self.workspace
|
|
99
|
+
)
|
|
100
|
+
_, staged, _ = await self._runner(
|
|
101
|
+
("diff", "--cached", "--no-ext-diff", "--no-textconv", "--numstat"),
|
|
102
|
+
self.workspace,
|
|
103
|
+
)
|
|
104
|
+
additions = deletions = 0
|
|
105
|
+
files = {change.path for change in status.changes}
|
|
106
|
+
for line in f"{unstaged}\n{staged}".splitlines():
|
|
107
|
+
parts = line.split("\t", 2)
|
|
108
|
+
if len(parts) == 3:
|
|
109
|
+
additions += int(parts[0]) if parts[0].isdigit() else 0
|
|
110
|
+
deletions += int(parts[1]) if parts[1].isdigit() else 0
|
|
111
|
+
files.add(parts[2])
|
|
112
|
+
diff = DiffAnalysis(len(files), additions, deletions, tuple(sorted(files)))
|
|
113
|
+
return GitInsight(status, diff, self.suggest_commit(status, diff))
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def suggest_commit(status: GitStatus, diff: DiffAnalysis) -> str | None:
|
|
117
|
+
if not status.is_repository or not diff.files:
|
|
118
|
+
return None
|
|
119
|
+
paths = diff.files
|
|
120
|
+
if all(path.lower().endswith((".md", ".rst")) or "docs/" in path.lower() for path in paths):
|
|
121
|
+
kind = "docs"
|
|
122
|
+
elif all("test" in Path(path).name.lower() for path in paths):
|
|
123
|
+
kind = "test"
|
|
124
|
+
elif any(change.index_status == "A" or change.worktree_status == "?" for change in status.changes):
|
|
125
|
+
kind = "feat"
|
|
126
|
+
else:
|
|
127
|
+
kind = "chore"
|
|
128
|
+
subject = paths[0] if len(paths) == 1 else f"{len(paths)} files"
|
|
129
|
+
return f"{kind}: update {subject}"
|
|
130
|
+
|
|
131
|
+
@staticmethod
|
|
132
|
+
def _parse_status(output: str) -> tuple[GitChange, ...]:
|
|
133
|
+
changes: list[GitChange] = []
|
|
134
|
+
for line in output.splitlines():
|
|
135
|
+
if len(line) < 4:
|
|
136
|
+
continue
|
|
137
|
+
changes.append(GitChange(line[3:], line[0], line[1]))
|
|
138
|
+
return tuple(changes)
|
|
139
|
+
|
|
140
|
+
@staticmethod
|
|
141
|
+
async def _run_git(arguments: tuple[str, ...], workspace: Path) -> tuple[int, str, str]:
|
|
142
|
+
process: asyncio.subprocess.Process | None = None
|
|
143
|
+
try:
|
|
144
|
+
process = await asyncio.create_subprocess_exec(
|
|
145
|
+
"git", *arguments, cwd=workspace, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
|
|
146
|
+
env=isolated_subprocess_environment(
|
|
147
|
+
{
|
|
148
|
+
"GIT_CONFIG_NOSYSTEM": "1",
|
|
149
|
+
"GIT_CONFIG_GLOBAL": str(Path(os.devnull)),
|
|
150
|
+
"GIT_ATTR_NOSYSTEM": "1",
|
|
151
|
+
}
|
|
152
|
+
),
|
|
153
|
+
**isolated_process_kwargs(),
|
|
154
|
+
)
|
|
155
|
+
except OSError as error:
|
|
156
|
+
return 127, "", str(error)
|
|
157
|
+
try:
|
|
158
|
+
stdout, stderr = await process.communicate()
|
|
159
|
+
except asyncio.CancelledError:
|
|
160
|
+
await terminate_process(process)
|
|
161
|
+
raise
|
|
162
|
+
return process.returncode, stdout.decode(errors="replace"), stderr.decode(errors="replace")
|