monkeybot 2.1.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.
- monkeybot/__init__.py +3 -0
- monkeybot/cli/__init__.py +3 -0
- monkeybot/cli/__main__.py +8 -0
- monkeybot/cli/audio_io.py +8 -0
- monkeybot/cli/gateway_manager.py +17 -0
- monkeybot/cli/main.py +22 -0
- monkeybot/cli/push_to_talk.py +12 -0
- monkeybot/cli/realtime_client.py +13 -0
- monkeybot/core/__init__.py +19 -0
- monkeybot/core/attachments/__init__.py +22 -0
- monkeybot/core/attachments/catalog.py +62 -0
- monkeybot/core/attachments/config.py +52 -0
- monkeybot/core/attachments/freeze.py +158 -0
- monkeybot/core/attachments/resolve.py +70 -0
- monkeybot/core/attachments/store.py +180 -0
- monkeybot/core/attachments/text.py +72 -0
- monkeybot/core/attachments/tools.py +54 -0
- monkeybot/core/bootstrap.py +242 -0
- monkeybot/core/config/__init__.py +71 -0
- monkeybot/core/config/realtime_config.py +150 -0
- monkeybot/core/config/runtime_env.py +262 -0
- monkeybot/core/config/settings.py +341 -0
- monkeybot/core/config/validation.py +249 -0
- monkeybot/core/config/yaml_loader.py +45 -0
- monkeybot/core/context/__init__.py +781 -0
- monkeybot/core/context/campaign_context.py +8 -0
- monkeybot/core/context/common.py +14 -0
- monkeybot/core/context/curator.py +255 -0
- monkeybot/core/context/epoch.py +226 -0
- monkeybot/core/context/memory_prompt.py +222 -0
- monkeybot/core/context/tool_output_policy.py +270 -0
- monkeybot/core/context/tool_result_ingress.py +290 -0
- monkeybot/core/context/tool_shapers.py +361 -0
- monkeybot/core/hooks/__init__.py +261 -0
- monkeybot/core/llm/__init__.py +4 -0
- monkeybot/core/llm/provider.py +296 -0
- monkeybot/core/llm/realtime_provider.py +203 -0
- monkeybot/core/llm/usage.py +57 -0
- monkeybot/core/logging_utils.py +24 -0
- monkeybot/core/mcp/__init__.py +1 -0
- monkeybot/core/mcp/mcp_client.py +1215 -0
- monkeybot/core/mcp/ports_mcp.py +109 -0
- monkeybot/core/memory/__init__.py +24 -0
- monkeybot/core/memory/hook.py +413 -0
- monkeybot/core/memory/index_format.py +104 -0
- monkeybot/core/memory/integrity.py +180 -0
- monkeybot/core/memory/organizer.py +270 -0
- monkeybot/core/memory/storage_ops.py +139 -0
- monkeybot/core/memory/subsystem.py +91 -0
- monkeybot/core/messages/__init__.py +16 -0
- monkeybot/core/messages/convert_provider.py +41 -0
- monkeybot/core/messages/tool_integrity.py +262 -0
- monkeybot/core/messages/transform_context.py +84 -0
- monkeybot/core/path_safety.py +11 -0
- monkeybot/core/persistence/__init__.py +17 -0
- monkeybot/core/persistence/backends.py +236 -0
- monkeybot/core/persistence/db.py +28 -0
- monkeybot/core/persistence/durable_runs.py +286 -0
- monkeybot/core/persistence/firestore.py +658 -0
- monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
- monkeybot/core/persistence/history.py +156 -0
- monkeybot/core/persistence/postgres.py +895 -0
- monkeybot/core/persistence/runs.py +76 -0
- monkeybot/core/persistence/scheduled_loops.py +435 -0
- monkeybot/core/persistence/session_turn_locks.py +94 -0
- monkeybot/core/persistence/sqlite.py +218 -0
- monkeybot/core/persistence/sqlite_backend.py +74 -0
- monkeybot/core/persistence/thread_summary.py +61 -0
- monkeybot/core/persistence/transcript.py +194 -0
- monkeybot/core/persistence/usage.py +149 -0
- monkeybot/core/prompts/__init__.py +1 -0
- monkeybot/core/prompts/harness_prompt.py +197 -0
- monkeybot/core/prompts/prompt.py +215 -0
- monkeybot/core/runtime/__init__.py +1 -0
- monkeybot/core/runtime/context_budget.py +267 -0
- monkeybot/core/runtime/events.py +819 -0
- monkeybot/core/runtime/input_admission.py +154 -0
- monkeybot/core/runtime/loop.py +2374 -0
- monkeybot/core/runtime/provider_stream_mapper.py +159 -0
- monkeybot/core/runtime/realtime_loop.py +654 -0
- monkeybot/core/runtime/utterance_buffer.py +179 -0
- monkeybot/core/subagents/__init__.py +1 -0
- monkeybot/core/subagents/subagent_proto.py +331 -0
- monkeybot/core/subagents/subagent_worker.py +441 -0
- monkeybot/core/subagents/worker_pool.py +403 -0
- monkeybot/core/testing/__init__.py +1 -0
- monkeybot/core/testing/mocks_provider.py +86 -0
- monkeybot/core/testing/mocks_realtime_provider.py +137 -0
- monkeybot/core/tools/__init__.py +1 -0
- monkeybot/core/tools/core_tool_executor.py +1548 -0
- monkeybot/core/tools/inspector.py +226 -0
- monkeybot/core/tools/loop_inspector.py +45 -0
- monkeybot/core/tools/patch.py +480 -0
- monkeybot/core/tools/permission.py +284 -0
- monkeybot/core/tools/sandbox_executor.py +255 -0
- monkeybot/core/tools/spill_inventory.py +35 -0
- monkeybot/core/tools/terminal.py +381 -0
- monkeybot/core/tools/text_normalize.py +25 -0
- monkeybot/core/tools/types.py +33 -0
- monkeybot/core/tools/workspace_service.py +710 -0
- monkeybot/core/tools/workspace_tools.py +116 -0
- monkeybot/core/types/__init__.py +1 -0
- monkeybot/core/types/content_blocks.py +644 -0
- monkeybot/core/types/interfaces.py +156 -0
- monkeybot/core/types/types_tools.py +29 -0
- monkeybot/core/workspace/__init__.py +8 -0
- monkeybot/core/workspace/factory.py +45 -0
- monkeybot/core/workspace/gcs.py +130 -0
- monkeybot/core/workspace/local.py +162 -0
- monkeybot/core/workspace/protocol.py +45 -0
- monkeybot/core/workspace/s3.py +151 -0
- monkeybot/core/workspace_layout.py +27 -0
- monkeybot/gateway/__init__.py +1 -0
- monkeybot/gateway/bootstrap.py +18 -0
- monkeybot/gateway/main.py +47 -0
- monkeybot/gateway/realtime/__init__.py +31 -0
- monkeybot/gateway/realtime/app.py +321 -0
- monkeybot/gateway/realtime/deps.py +52 -0
- monkeybot/gateway/realtime/errors.py +81 -0
- monkeybot/gateway/realtime/guardrails.py +88 -0
- monkeybot/gateway/realtime/manager.py +77 -0
- monkeybot/gateway/realtime/metrics.py +144 -0
- monkeybot/gateway/realtime/routes.py +864 -0
- monkeybot/gateway/realtime/session.py +232 -0
- monkeybot/gateway/realtime/wire.py +412 -0
- monkeybot/gateway/realtime_main.py +49 -0
- monkeybot/gateway/sse/__init__.py +1 -0
- monkeybot/gateway/sse/app.py +733 -0
- monkeybot/gateway/sse/loop_port.py +31 -0
- monkeybot/gateway/sse/models.py +177 -0
- monkeybot/gateway/sse/reply_body.py +91 -0
- monkeybot/gateway/sse/routes.py +1101 -0
- monkeybot/gateway/sse/scheduler_routes.py +200 -0
- monkeybot/gateway/sse/scheduler_wiring.py +96 -0
- monkeybot/gateway/sse/session_bus.py +226 -0
- monkeybot/gateway/sse/sse.py +46 -0
- monkeybot/gateway/sse/workspace_layout.py +7 -0
- monkeybot/observability/__init__.py +220 -0
- monkeybot/observability/_state.py +10 -0
- monkeybot/observability/instrumentation.py +153 -0
- monkeybot/observability/propagation.py +65 -0
- monkeybot/observability/spans.py +455 -0
- monkeybot/providers/__init__.py +19 -0
- monkeybot/providers/_openai_compat.py +450 -0
- monkeybot/providers/_utils.py +473 -0
- monkeybot/providers/bedrock.py +145 -0
- monkeybot/providers/claude.py +125 -0
- monkeybot/providers/gemini.py +677 -0
- monkeybot/providers/gemini_live.py +398 -0
- monkeybot/providers/huggingface.py +129 -0
- monkeybot/providers/nvidia.py +104 -0
- monkeybot/providers/ollama.py +152 -0
- monkeybot/providers/openai.py +127 -0
- monkeybot/providers/pricing.py +60 -0
- monkeybot/providers/sampling.py +44 -0
- monkeybot/providers/vertex_claude.py +148 -0
- monkeybot/scaffold/__init__.py +33 -0
- monkeybot/scheduler/__init__.py +13 -0
- monkeybot/scheduler/__main__.py +4 -0
- monkeybot/scheduler/engine.py +333 -0
- monkeybot/scheduler/http_invoker.py +61 -0
- monkeybot/scheduler/interval.py +77 -0
- monkeybot/scheduler/tick_result.py +34 -0
- monkeybot/scheduler/worker.py +87 -0
- monkeybot/subagents/__init__.py +1 -0
- monkeybot/subagents/worker/__init__.py +1 -0
- monkeybot/subagents/worker/__main__.py +22 -0
- monkeybot/web_search/__init__.py +82 -0
- monkeybot/web_search/backends/__init__.py +5 -0
- monkeybot/web_search/backends/duckduckgo.py +32 -0
- monkeybot/web_search/backends/firecrawl.py +43 -0
- monkeybot/web_search/backends/tavily.py +45 -0
- monkeybot/web_search/protocol.py +25 -0
- monkeybot/web_search/tool.py +56 -0
- monkeybot-2.1.1.dist-info/METADATA +318 -0
- monkeybot-2.1.1.dist-info/RECORD +178 -0
- monkeybot-2.1.1.dist-info/WHEEL +4 -0
- monkeybot-2.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
"""Codex-style ``apply_patch`` parse + derive + fail-closed apply.
|
|
2
|
+
|
|
3
|
+
Format (OpenCode / Codex envelope)::
|
|
4
|
+
|
|
5
|
+
*** Begin Patch
|
|
6
|
+
*** Add File: path
|
|
7
|
+
+line
|
|
8
|
+
*** Update File: path
|
|
9
|
+
*** Move to: new_path # optional
|
|
10
|
+
@@ context
|
|
11
|
+
keep
|
|
12
|
+
-old
|
|
13
|
+
+new
|
|
14
|
+
*** Delete File: path
|
|
15
|
+
*** End Patch
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import logging
|
|
21
|
+
import re
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import TYPE_CHECKING, Literal
|
|
25
|
+
|
|
26
|
+
from monkeybot.core.logging_utils import kv
|
|
27
|
+
from monkeybot.core.tools.text_normalize import normalize_unicode_punctuation
|
|
28
|
+
from monkeybot.core.tools.workspace_service import WorkspaceError
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from monkeybot.core.tools.workspace_service import WorkspaceFileService
|
|
32
|
+
|
|
33
|
+
logger = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
BEGIN_MARKER = "*** Begin Patch"
|
|
36
|
+
END_MARKER = "*** End Patch"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class PatchError(Exception):
|
|
40
|
+
"""Patch parse or apply validation failure (fail-closed; no disk writes yet)."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message: str, code: str = "patch_error") -> None:
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.code = code
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass(frozen=True)
|
|
48
|
+
class UpdateChunk:
|
|
49
|
+
old_lines: tuple[str, ...]
|
|
50
|
+
new_lines: tuple[str, ...]
|
|
51
|
+
change_context: str | None = None
|
|
52
|
+
is_end_of_file: bool = False
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class AddHunk:
|
|
57
|
+
path: str
|
|
58
|
+
contents: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class DeleteHunk:
|
|
63
|
+
path: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class UpdateHunk:
|
|
68
|
+
path: str
|
|
69
|
+
chunks: tuple[UpdateChunk, ...]
|
|
70
|
+
move_path: str | None = None
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
Hunk = AddHunk | DeleteHunk | UpdateHunk
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _strip_heredoc(input_text: str) -> str:
|
|
77
|
+
match = re.match(
|
|
78
|
+
r"^(?:cat\s+)?<<['\"]?(\w+)['\"]?\s*\n([\s\S]*?)\n\1\s*$",
|
|
79
|
+
input_text,
|
|
80
|
+
)
|
|
81
|
+
if match:
|
|
82
|
+
return match.group(2)
|
|
83
|
+
return input_text
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse_header(
|
|
87
|
+
lines: list[str], start_idx: int
|
|
88
|
+
) -> tuple[Literal["add", "delete", "update"], str, str | None, int] | None:
|
|
89
|
+
"""Return (kind, file_path, move_path, next_idx) or None."""
|
|
90
|
+
line = lines[start_idx]
|
|
91
|
+
if line.startswith("*** Add File:"):
|
|
92
|
+
path = line[len("*** Add File:") :].strip()
|
|
93
|
+
return ("add", path, None, start_idx + 1) if path else None
|
|
94
|
+
if line.startswith("*** Delete File:"):
|
|
95
|
+
path = line[len("*** Delete File:") :].strip()
|
|
96
|
+
return ("delete", path, None, start_idx + 1) if path else None
|
|
97
|
+
if line.startswith("*** Update File:"):
|
|
98
|
+
path = line[len("*** Update File:") :].strip()
|
|
99
|
+
move_path: str | None = None
|
|
100
|
+
next_idx = start_idx + 1
|
|
101
|
+
if next_idx < len(lines) and lines[next_idx].startswith("*** Move to:"):
|
|
102
|
+
move_path = lines[next_idx][len("*** Move to:") :].strip() or None
|
|
103
|
+
next_idx += 1
|
|
104
|
+
return ("update", path, move_path, next_idx) if path else None
|
|
105
|
+
return None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _parse_add_content(lines: list[str], start_idx: int) -> tuple[str, int]:
|
|
109
|
+
parts: list[str] = []
|
|
110
|
+
i = start_idx
|
|
111
|
+
while i < len(lines) and not lines[i].startswith("***"):
|
|
112
|
+
if lines[i].startswith("+"):
|
|
113
|
+
parts.append(lines[i][1:])
|
|
114
|
+
i += 1
|
|
115
|
+
return "\n".join(parts), i
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _parse_update_chunks(lines: list[str], start_idx: int) -> tuple[list[UpdateChunk], int]:
|
|
119
|
+
chunks: list[UpdateChunk] = []
|
|
120
|
+
i = start_idx
|
|
121
|
+
while i < len(lines) and not lines[i].startswith("***"):
|
|
122
|
+
if not lines[i].startswith("@@"):
|
|
123
|
+
i += 1
|
|
124
|
+
continue
|
|
125
|
+
context_line = lines[i][2:].strip()
|
|
126
|
+
i += 1
|
|
127
|
+
old_lines: list[str] = []
|
|
128
|
+
new_lines: list[str] = []
|
|
129
|
+
is_eof = False
|
|
130
|
+
while i < len(lines) and not lines[i].startswith("@@") and not lines[i].startswith("***"):
|
|
131
|
+
change = lines[i]
|
|
132
|
+
if change == "*** End of File":
|
|
133
|
+
is_eof = True
|
|
134
|
+
i += 1
|
|
135
|
+
break
|
|
136
|
+
if change.startswith(" "):
|
|
137
|
+
content = change[1:]
|
|
138
|
+
old_lines.append(content)
|
|
139
|
+
new_lines.append(content)
|
|
140
|
+
elif change.startswith("-"):
|
|
141
|
+
old_lines.append(change[1:])
|
|
142
|
+
elif change.startswith("+"):
|
|
143
|
+
new_lines.append(change[1:])
|
|
144
|
+
i += 1
|
|
145
|
+
chunks.append(
|
|
146
|
+
UpdateChunk(
|
|
147
|
+
old_lines=tuple(old_lines),
|
|
148
|
+
new_lines=tuple(new_lines),
|
|
149
|
+
change_context=context_line or None,
|
|
150
|
+
is_end_of_file=is_eof,
|
|
151
|
+
)
|
|
152
|
+
)
|
|
153
|
+
return chunks, i
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def parse_patch(patch_text: str) -> list[Hunk]:
|
|
157
|
+
"""Parse a Codex-style patch into hunks. Raises :class:`PatchError` on invalid input."""
|
|
158
|
+
cleaned = _strip_heredoc(patch_text.strip())
|
|
159
|
+
lines = cleaned.split("\n")
|
|
160
|
+
try:
|
|
161
|
+
begin_idx = next(i for i, ln in enumerate(lines) if ln.strip() == BEGIN_MARKER)
|
|
162
|
+
end_idx = next(i for i, ln in enumerate(lines) if ln.strip() == END_MARKER)
|
|
163
|
+
except StopIteration as exc:
|
|
164
|
+
raise PatchError(
|
|
165
|
+
"Invalid patch format: missing Begin/End markers",
|
|
166
|
+
code="missing_markers",
|
|
167
|
+
) from exc
|
|
168
|
+
if begin_idx >= end_idx:
|
|
169
|
+
raise PatchError(
|
|
170
|
+
"Invalid patch format: Begin marker must precede End marker",
|
|
171
|
+
code="missing_markers",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
hunks: list[Hunk] = []
|
|
175
|
+
i = begin_idx + 1
|
|
176
|
+
while i < end_idx:
|
|
177
|
+
header = _parse_header(lines, i)
|
|
178
|
+
if header is None:
|
|
179
|
+
i += 1
|
|
180
|
+
continue
|
|
181
|
+
kind, path, move_path, next_idx = header
|
|
182
|
+
if kind == "add":
|
|
183
|
+
content, i = _parse_add_content(lines, next_idx)
|
|
184
|
+
hunks.append(AddHunk(path=path, contents=content))
|
|
185
|
+
elif kind == "delete":
|
|
186
|
+
hunks.append(DeleteHunk(path=path))
|
|
187
|
+
i = next_idx
|
|
188
|
+
else:
|
|
189
|
+
chunks, i = _parse_update_chunks(lines, next_idx)
|
|
190
|
+
hunks.append(UpdateHunk(path=path, move_path=move_path, chunks=tuple(chunks)))
|
|
191
|
+
|
|
192
|
+
if not hunks:
|
|
193
|
+
raise PatchError("patch rejected: empty patch (no hunks)", code="empty_patch")
|
|
194
|
+
return hunks
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _try_match(
|
|
198
|
+
lines: list[str],
|
|
199
|
+
pattern: list[str],
|
|
200
|
+
start_index: int,
|
|
201
|
+
compare: Callable[[str, str], bool],
|
|
202
|
+
eof: bool,
|
|
203
|
+
) -> int:
|
|
204
|
+
if eof:
|
|
205
|
+
from_end = len(lines) - len(pattern)
|
|
206
|
+
if from_end >= start_index and all(
|
|
207
|
+
compare(lines[from_end + j], pattern[j]) for j in range(len(pattern))
|
|
208
|
+
):
|
|
209
|
+
return from_end
|
|
210
|
+
for i in range(start_index, len(lines) - len(pattern) + 1):
|
|
211
|
+
if all(compare(lines[i + j], pattern[j]) for j in range(len(pattern))):
|
|
212
|
+
return i
|
|
213
|
+
return -1
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def seek_sequence(
|
|
217
|
+
lines: list[str],
|
|
218
|
+
pattern: list[str],
|
|
219
|
+
start_index: int = 0,
|
|
220
|
+
*,
|
|
221
|
+
eof: bool = False,
|
|
222
|
+
) -> int:
|
|
223
|
+
"""Find ``pattern`` in ``lines`` with exact → rstrip → trim → unicode-normalize passes."""
|
|
224
|
+
if not pattern:
|
|
225
|
+
return -1
|
|
226
|
+
for compare in (
|
|
227
|
+
(lambda a, b: a == b),
|
|
228
|
+
(lambda a, b: a.rstrip() == b.rstrip()),
|
|
229
|
+
(lambda a, b: a.strip() == b.strip()),
|
|
230
|
+
(
|
|
231
|
+
lambda a, b: normalize_unicode_punctuation(a.strip())
|
|
232
|
+
== normalize_unicode_punctuation(b.strip())
|
|
233
|
+
),
|
|
234
|
+
):
|
|
235
|
+
found = _try_match(lines, pattern, start_index, compare, eof)
|
|
236
|
+
if found != -1:
|
|
237
|
+
return found
|
|
238
|
+
return -1
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def derive_new_contents(
|
|
242
|
+
file_path: str,
|
|
243
|
+
chunks: tuple[UpdateChunk, ...] | list[UpdateChunk],
|
|
244
|
+
original_text: str,
|
|
245
|
+
) -> str:
|
|
246
|
+
"""Apply update chunks to ``original_text``; raise :class:`PatchError` if a chunk cannot match."""
|
|
247
|
+
original_lines = original_text.split("\n")
|
|
248
|
+
if original_lines and original_lines[-1] == "":
|
|
249
|
+
original_lines = original_lines[:-1]
|
|
250
|
+
|
|
251
|
+
replacements: list[tuple[int, int, list[str]]] = []
|
|
252
|
+
line_index = 0
|
|
253
|
+
for chunk in chunks:
|
|
254
|
+
context_matched = False
|
|
255
|
+
if chunk.change_context:
|
|
256
|
+
ctx_idx = seek_sequence(original_lines, [chunk.change_context], line_index)
|
|
257
|
+
if ctx_idx == -1:
|
|
258
|
+
raise PatchError(
|
|
259
|
+
f"Failed to find context '{chunk.change_context}' in {file_path}",
|
|
260
|
+
code="context_not_found",
|
|
261
|
+
)
|
|
262
|
+
line_index = ctx_idx + 1
|
|
263
|
+
context_matched = True
|
|
264
|
+
|
|
265
|
+
if not chunk.old_lines:
|
|
266
|
+
# Pure insertion: place after the @@ context anchor when present;
|
|
267
|
+
# otherwise append at EOF (Codex-style "insert at end").
|
|
268
|
+
if context_matched:
|
|
269
|
+
insertion_idx = line_index
|
|
270
|
+
else:
|
|
271
|
+
insertion_idx = (
|
|
272
|
+
len(original_lines) - 1
|
|
273
|
+
if original_lines and original_lines[-1] == ""
|
|
274
|
+
else len(original_lines)
|
|
275
|
+
)
|
|
276
|
+
replacements.append((insertion_idx, 0, list(chunk.new_lines)))
|
|
277
|
+
continue
|
|
278
|
+
|
|
279
|
+
pattern = list(chunk.old_lines)
|
|
280
|
+
new_slice = list(chunk.new_lines)
|
|
281
|
+
found = seek_sequence(
|
|
282
|
+
original_lines, pattern, line_index, eof=chunk.is_end_of_file
|
|
283
|
+
)
|
|
284
|
+
if found == -1 and pattern and pattern[-1] == "":
|
|
285
|
+
pattern = pattern[:-1]
|
|
286
|
+
if new_slice and new_slice[-1] == "":
|
|
287
|
+
new_slice = new_slice[:-1]
|
|
288
|
+
found = seek_sequence(
|
|
289
|
+
original_lines, pattern, line_index, eof=chunk.is_end_of_file
|
|
290
|
+
)
|
|
291
|
+
if found == -1:
|
|
292
|
+
raise PatchError(
|
|
293
|
+
f"Failed to find expected lines in {file_path}:\n"
|
|
294
|
+
+ "\n".join(chunk.old_lines),
|
|
295
|
+
code="hunk_not_found",
|
|
296
|
+
)
|
|
297
|
+
replacements.append((found, len(pattern), new_slice))
|
|
298
|
+
line_index = found + len(pattern)
|
|
299
|
+
|
|
300
|
+
replacements.sort(key=lambda r: r[0])
|
|
301
|
+
result = list(original_lines)
|
|
302
|
+
for start_idx, old_len, new_segment in reversed(replacements):
|
|
303
|
+
del result[start_idx : start_idx + old_len]
|
|
304
|
+
for j, line in enumerate(new_segment):
|
|
305
|
+
result.insert(start_idx + j, line)
|
|
306
|
+
|
|
307
|
+
if not result or result[-1] != "":
|
|
308
|
+
result.append("")
|
|
309
|
+
return "\n".join(result)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@dataclass(frozen=True)
|
|
313
|
+
class _PlannedOp:
|
|
314
|
+
action: Literal["add", "update", "move", "delete"]
|
|
315
|
+
path: str
|
|
316
|
+
content: str | None = None
|
|
317
|
+
move_path: str | None = None
|
|
318
|
+
# Prior file body for rollback after a mid-apply failure.
|
|
319
|
+
old_content: str | None = None
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _plan_ops(workspace: WorkspaceFileService, hunks: list[Hunk]) -> list[_PlannedOp]:
|
|
323
|
+
if not hunks:
|
|
324
|
+
raise PatchError("patch rejected: empty patch (no hunks)", code="empty_patch")
|
|
325
|
+
|
|
326
|
+
planned: list[_PlannedOp] = []
|
|
327
|
+
for hunk in hunks:
|
|
328
|
+
if isinstance(hunk, AddHunk):
|
|
329
|
+
workspace.resolve_workspace_path(hunk.path)
|
|
330
|
+
content = hunk.contents
|
|
331
|
+
if content and not content.endswith("\n"):
|
|
332
|
+
content = content + "\n"
|
|
333
|
+
planned.append(_PlannedOp(action="add", path=hunk.path, content=content))
|
|
334
|
+
elif isinstance(hunk, DeleteHunk):
|
|
335
|
+
fp = workspace.resolve_workspace_path(hunk.path)
|
|
336
|
+
if not fp.is_file():
|
|
337
|
+
raise PatchError(
|
|
338
|
+
f"apply_patch verification failed: Failed to read file to delete: {hunk.path}",
|
|
339
|
+
code="not_found",
|
|
340
|
+
)
|
|
341
|
+
old = fp.read_text(encoding="utf-8", errors="replace")
|
|
342
|
+
planned.append(
|
|
343
|
+
_PlannedOp(action="delete", path=hunk.path, old_content=old)
|
|
344
|
+
)
|
|
345
|
+
else:
|
|
346
|
+
fp = workspace.resolve_workspace_path(hunk.path)
|
|
347
|
+
if not fp.is_file():
|
|
348
|
+
raise PatchError(
|
|
349
|
+
f"apply_patch verification failed: Failed to read file to update: {hunk.path}",
|
|
350
|
+
code="not_found",
|
|
351
|
+
)
|
|
352
|
+
if hunk.move_path:
|
|
353
|
+
workspace.resolve_workspace_path(hunk.move_path)
|
|
354
|
+
old = fp.read_text(encoding="utf-8", errors="replace")
|
|
355
|
+
new_content = derive_new_contents(hunk.path, hunk.chunks, old)
|
|
356
|
+
if hunk.move_path:
|
|
357
|
+
planned.append(
|
|
358
|
+
_PlannedOp(
|
|
359
|
+
action="move",
|
|
360
|
+
path=hunk.path,
|
|
361
|
+
move_path=hunk.move_path,
|
|
362
|
+
content=new_content,
|
|
363
|
+
old_content=old,
|
|
364
|
+
)
|
|
365
|
+
)
|
|
366
|
+
else:
|
|
367
|
+
planned.append(
|
|
368
|
+
_PlannedOp(
|
|
369
|
+
action="update",
|
|
370
|
+
path=hunk.path,
|
|
371
|
+
content=new_content,
|
|
372
|
+
old_content=old,
|
|
373
|
+
)
|
|
374
|
+
)
|
|
375
|
+
return planned
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def _rollback_ops(workspace: WorkspaceFileService, done: list[_PlannedOp]) -> None:
|
|
379
|
+
"""Best-effort undo of successfully applied ops (reverse order)."""
|
|
380
|
+
for op in reversed(done):
|
|
381
|
+
try:
|
|
382
|
+
if op.action == "add":
|
|
383
|
+
workspace.delete_file(op.path)
|
|
384
|
+
elif op.action == "update":
|
|
385
|
+
if op.old_content is not None:
|
|
386
|
+
workspace.write_file(op.path, op.old_content)
|
|
387
|
+
elif op.action == "move":
|
|
388
|
+
if op.move_path is not None:
|
|
389
|
+
try:
|
|
390
|
+
workspace.delete_file(op.move_path)
|
|
391
|
+
except WorkspaceError as exc:
|
|
392
|
+
logger.warning(
|
|
393
|
+
"apply_patch rollback: could not remove move dest %s",
|
|
394
|
+
kv(path=op.move_path, error=str(exc)),
|
|
395
|
+
)
|
|
396
|
+
if op.old_content is not None:
|
|
397
|
+
workspace.write_file(op.path, op.old_content)
|
|
398
|
+
elif op.action == "delete":
|
|
399
|
+
if op.old_content is not None:
|
|
400
|
+
workspace.write_file(op.path, op.old_content)
|
|
401
|
+
except WorkspaceError:
|
|
402
|
+
logger.exception(
|
|
403
|
+
"apply_patch rollback failed %s",
|
|
404
|
+
kv(action=op.action, path=op.path, move_path=op.move_path),
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _apply_ops(
|
|
409
|
+
workspace: WorkspaceFileService, planned: list[_PlannedOp]
|
|
410
|
+
) -> list[dict[str, object]]:
|
|
411
|
+
done: list[_PlannedOp] = []
|
|
412
|
+
files: list[dict[str, object]] = []
|
|
413
|
+
try:
|
|
414
|
+
for op in planned:
|
|
415
|
+
if op.action == "add":
|
|
416
|
+
if op.content is None:
|
|
417
|
+
raise PatchError("add op missing content", code="internal")
|
|
418
|
+
workspace.write_file(op.path, op.content)
|
|
419
|
+
files.append({"path": op.path, "action": "add"})
|
|
420
|
+
elif op.action == "update":
|
|
421
|
+
if op.content is None:
|
|
422
|
+
raise PatchError("update op missing content", code="internal")
|
|
423
|
+
workspace.write_file(op.path, op.content)
|
|
424
|
+
files.append({"path": op.path, "action": "update"})
|
|
425
|
+
elif op.action == "move":
|
|
426
|
+
if op.content is None or op.move_path is None:
|
|
427
|
+
raise PatchError("move op missing content or move_path", code="internal")
|
|
428
|
+
# Record after the dest write so rollback can remove it if delete fails.
|
|
429
|
+
workspace.write_file(op.move_path, op.content)
|
|
430
|
+
done.append(op)
|
|
431
|
+
workspace.delete_file(op.path)
|
|
432
|
+
files.append(
|
|
433
|
+
{"path": op.move_path, "action": "move", "from": op.path}
|
|
434
|
+
)
|
|
435
|
+
continue
|
|
436
|
+
elif op.action == "delete":
|
|
437
|
+
workspace.delete_file(op.path)
|
|
438
|
+
files.append({"path": op.path, "action": "delete"})
|
|
439
|
+
done.append(op)
|
|
440
|
+
except (WorkspaceError, PatchError) as exc:
|
|
441
|
+
_rollback_ops(workspace, done)
|
|
442
|
+
if isinstance(exc, PatchError):
|
|
443
|
+
raise
|
|
444
|
+
raise PatchError(str(exc), code=getattr(exc, "code", "write_failed")) from exc
|
|
445
|
+
return files
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _file_summary_line(entry: dict[str, object]) -> str:
|
|
449
|
+
action = str(entry["action"])
|
|
450
|
+
path = str(entry["path"])
|
|
451
|
+
if action == "move":
|
|
452
|
+
return f"M {entry.get('from')} -> {path}"
|
|
453
|
+
return f"{action[0].upper()} {path}"
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def plan_and_apply_patch(
|
|
457
|
+
workspace: WorkspaceFileService,
|
|
458
|
+
hunks: list[Hunk],
|
|
459
|
+
) -> dict[str, object]:
|
|
460
|
+
"""Validate all hunks, then apply; roll back on mid-apply failure."""
|
|
461
|
+
planned = _plan_ops(workspace, hunks)
|
|
462
|
+
try:
|
|
463
|
+
files = _apply_ops(workspace, planned)
|
|
464
|
+
except PatchError as exc:
|
|
465
|
+
logger.warning(
|
|
466
|
+
"apply_patch failed %s",
|
|
467
|
+
kv(code=exc.code, hunks=len(hunks), error=str(exc)),
|
|
468
|
+
)
|
|
469
|
+
raise
|
|
470
|
+
|
|
471
|
+
summary = "\n".join(_file_summary_line(f) for f in files)
|
|
472
|
+
logger.info(
|
|
473
|
+
"apply_patch ok %s",
|
|
474
|
+
kv(files=len(files), actions=",".join(str(f["action"]) for f in files)),
|
|
475
|
+
)
|
|
476
|
+
return {
|
|
477
|
+
"ok": True,
|
|
478
|
+
"files": files,
|
|
479
|
+
"message": f"Success. Updated the following files:\n{summary}",
|
|
480
|
+
}
|