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.
Files changed (178) hide show
  1. monkeybot/__init__.py +3 -0
  2. monkeybot/cli/__init__.py +3 -0
  3. monkeybot/cli/__main__.py +8 -0
  4. monkeybot/cli/audio_io.py +8 -0
  5. monkeybot/cli/gateway_manager.py +17 -0
  6. monkeybot/cli/main.py +22 -0
  7. monkeybot/cli/push_to_talk.py +12 -0
  8. monkeybot/cli/realtime_client.py +13 -0
  9. monkeybot/core/__init__.py +19 -0
  10. monkeybot/core/attachments/__init__.py +22 -0
  11. monkeybot/core/attachments/catalog.py +62 -0
  12. monkeybot/core/attachments/config.py +52 -0
  13. monkeybot/core/attachments/freeze.py +158 -0
  14. monkeybot/core/attachments/resolve.py +70 -0
  15. monkeybot/core/attachments/store.py +180 -0
  16. monkeybot/core/attachments/text.py +72 -0
  17. monkeybot/core/attachments/tools.py +54 -0
  18. monkeybot/core/bootstrap.py +242 -0
  19. monkeybot/core/config/__init__.py +71 -0
  20. monkeybot/core/config/realtime_config.py +150 -0
  21. monkeybot/core/config/runtime_env.py +262 -0
  22. monkeybot/core/config/settings.py +341 -0
  23. monkeybot/core/config/validation.py +249 -0
  24. monkeybot/core/config/yaml_loader.py +45 -0
  25. monkeybot/core/context/__init__.py +781 -0
  26. monkeybot/core/context/campaign_context.py +8 -0
  27. monkeybot/core/context/common.py +14 -0
  28. monkeybot/core/context/curator.py +255 -0
  29. monkeybot/core/context/epoch.py +226 -0
  30. monkeybot/core/context/memory_prompt.py +222 -0
  31. monkeybot/core/context/tool_output_policy.py +270 -0
  32. monkeybot/core/context/tool_result_ingress.py +290 -0
  33. monkeybot/core/context/tool_shapers.py +361 -0
  34. monkeybot/core/hooks/__init__.py +261 -0
  35. monkeybot/core/llm/__init__.py +4 -0
  36. monkeybot/core/llm/provider.py +296 -0
  37. monkeybot/core/llm/realtime_provider.py +203 -0
  38. monkeybot/core/llm/usage.py +57 -0
  39. monkeybot/core/logging_utils.py +24 -0
  40. monkeybot/core/mcp/__init__.py +1 -0
  41. monkeybot/core/mcp/mcp_client.py +1215 -0
  42. monkeybot/core/mcp/ports_mcp.py +109 -0
  43. monkeybot/core/memory/__init__.py +24 -0
  44. monkeybot/core/memory/hook.py +413 -0
  45. monkeybot/core/memory/index_format.py +104 -0
  46. monkeybot/core/memory/integrity.py +180 -0
  47. monkeybot/core/memory/organizer.py +270 -0
  48. monkeybot/core/memory/storage_ops.py +139 -0
  49. monkeybot/core/memory/subsystem.py +91 -0
  50. monkeybot/core/messages/__init__.py +16 -0
  51. monkeybot/core/messages/convert_provider.py +41 -0
  52. monkeybot/core/messages/tool_integrity.py +262 -0
  53. monkeybot/core/messages/transform_context.py +84 -0
  54. monkeybot/core/path_safety.py +11 -0
  55. monkeybot/core/persistence/__init__.py +17 -0
  56. monkeybot/core/persistence/backends.py +236 -0
  57. monkeybot/core/persistence/db.py +28 -0
  58. monkeybot/core/persistence/durable_runs.py +286 -0
  59. monkeybot/core/persistence/firestore.py +658 -0
  60. monkeybot/core/persistence/firestore_scheduled_loops.py +336 -0
  61. monkeybot/core/persistence/history.py +156 -0
  62. monkeybot/core/persistence/postgres.py +895 -0
  63. monkeybot/core/persistence/runs.py +76 -0
  64. monkeybot/core/persistence/scheduled_loops.py +435 -0
  65. monkeybot/core/persistence/session_turn_locks.py +94 -0
  66. monkeybot/core/persistence/sqlite.py +218 -0
  67. monkeybot/core/persistence/sqlite_backend.py +74 -0
  68. monkeybot/core/persistence/thread_summary.py +61 -0
  69. monkeybot/core/persistence/transcript.py +194 -0
  70. monkeybot/core/persistence/usage.py +149 -0
  71. monkeybot/core/prompts/__init__.py +1 -0
  72. monkeybot/core/prompts/harness_prompt.py +197 -0
  73. monkeybot/core/prompts/prompt.py +215 -0
  74. monkeybot/core/runtime/__init__.py +1 -0
  75. monkeybot/core/runtime/context_budget.py +267 -0
  76. monkeybot/core/runtime/events.py +819 -0
  77. monkeybot/core/runtime/input_admission.py +154 -0
  78. monkeybot/core/runtime/loop.py +2374 -0
  79. monkeybot/core/runtime/provider_stream_mapper.py +159 -0
  80. monkeybot/core/runtime/realtime_loop.py +654 -0
  81. monkeybot/core/runtime/utterance_buffer.py +179 -0
  82. monkeybot/core/subagents/__init__.py +1 -0
  83. monkeybot/core/subagents/subagent_proto.py +331 -0
  84. monkeybot/core/subagents/subagent_worker.py +441 -0
  85. monkeybot/core/subagents/worker_pool.py +403 -0
  86. monkeybot/core/testing/__init__.py +1 -0
  87. monkeybot/core/testing/mocks_provider.py +86 -0
  88. monkeybot/core/testing/mocks_realtime_provider.py +137 -0
  89. monkeybot/core/tools/__init__.py +1 -0
  90. monkeybot/core/tools/core_tool_executor.py +1548 -0
  91. monkeybot/core/tools/inspector.py +226 -0
  92. monkeybot/core/tools/loop_inspector.py +45 -0
  93. monkeybot/core/tools/patch.py +480 -0
  94. monkeybot/core/tools/permission.py +284 -0
  95. monkeybot/core/tools/sandbox_executor.py +255 -0
  96. monkeybot/core/tools/spill_inventory.py +35 -0
  97. monkeybot/core/tools/terminal.py +381 -0
  98. monkeybot/core/tools/text_normalize.py +25 -0
  99. monkeybot/core/tools/types.py +33 -0
  100. monkeybot/core/tools/workspace_service.py +710 -0
  101. monkeybot/core/tools/workspace_tools.py +116 -0
  102. monkeybot/core/types/__init__.py +1 -0
  103. monkeybot/core/types/content_blocks.py +644 -0
  104. monkeybot/core/types/interfaces.py +156 -0
  105. monkeybot/core/types/types_tools.py +29 -0
  106. monkeybot/core/workspace/__init__.py +8 -0
  107. monkeybot/core/workspace/factory.py +45 -0
  108. monkeybot/core/workspace/gcs.py +130 -0
  109. monkeybot/core/workspace/local.py +162 -0
  110. monkeybot/core/workspace/protocol.py +45 -0
  111. monkeybot/core/workspace/s3.py +151 -0
  112. monkeybot/core/workspace_layout.py +27 -0
  113. monkeybot/gateway/__init__.py +1 -0
  114. monkeybot/gateway/bootstrap.py +18 -0
  115. monkeybot/gateway/main.py +47 -0
  116. monkeybot/gateway/realtime/__init__.py +31 -0
  117. monkeybot/gateway/realtime/app.py +321 -0
  118. monkeybot/gateway/realtime/deps.py +52 -0
  119. monkeybot/gateway/realtime/errors.py +81 -0
  120. monkeybot/gateway/realtime/guardrails.py +88 -0
  121. monkeybot/gateway/realtime/manager.py +77 -0
  122. monkeybot/gateway/realtime/metrics.py +144 -0
  123. monkeybot/gateway/realtime/routes.py +864 -0
  124. monkeybot/gateway/realtime/session.py +232 -0
  125. monkeybot/gateway/realtime/wire.py +412 -0
  126. monkeybot/gateway/realtime_main.py +49 -0
  127. monkeybot/gateway/sse/__init__.py +1 -0
  128. monkeybot/gateway/sse/app.py +733 -0
  129. monkeybot/gateway/sse/loop_port.py +31 -0
  130. monkeybot/gateway/sse/models.py +177 -0
  131. monkeybot/gateway/sse/reply_body.py +91 -0
  132. monkeybot/gateway/sse/routes.py +1101 -0
  133. monkeybot/gateway/sse/scheduler_routes.py +200 -0
  134. monkeybot/gateway/sse/scheduler_wiring.py +96 -0
  135. monkeybot/gateway/sse/session_bus.py +226 -0
  136. monkeybot/gateway/sse/sse.py +46 -0
  137. monkeybot/gateway/sse/workspace_layout.py +7 -0
  138. monkeybot/observability/__init__.py +220 -0
  139. monkeybot/observability/_state.py +10 -0
  140. monkeybot/observability/instrumentation.py +153 -0
  141. monkeybot/observability/propagation.py +65 -0
  142. monkeybot/observability/spans.py +455 -0
  143. monkeybot/providers/__init__.py +19 -0
  144. monkeybot/providers/_openai_compat.py +450 -0
  145. monkeybot/providers/_utils.py +473 -0
  146. monkeybot/providers/bedrock.py +145 -0
  147. monkeybot/providers/claude.py +125 -0
  148. monkeybot/providers/gemini.py +677 -0
  149. monkeybot/providers/gemini_live.py +398 -0
  150. monkeybot/providers/huggingface.py +129 -0
  151. monkeybot/providers/nvidia.py +104 -0
  152. monkeybot/providers/ollama.py +152 -0
  153. monkeybot/providers/openai.py +127 -0
  154. monkeybot/providers/pricing.py +60 -0
  155. monkeybot/providers/sampling.py +44 -0
  156. monkeybot/providers/vertex_claude.py +148 -0
  157. monkeybot/scaffold/__init__.py +33 -0
  158. monkeybot/scheduler/__init__.py +13 -0
  159. monkeybot/scheduler/__main__.py +4 -0
  160. monkeybot/scheduler/engine.py +333 -0
  161. monkeybot/scheduler/http_invoker.py +61 -0
  162. monkeybot/scheduler/interval.py +77 -0
  163. monkeybot/scheduler/tick_result.py +34 -0
  164. monkeybot/scheduler/worker.py +87 -0
  165. monkeybot/subagents/__init__.py +1 -0
  166. monkeybot/subagents/worker/__init__.py +1 -0
  167. monkeybot/subagents/worker/__main__.py +22 -0
  168. monkeybot/web_search/__init__.py +82 -0
  169. monkeybot/web_search/backends/__init__.py +5 -0
  170. monkeybot/web_search/backends/duckduckgo.py +32 -0
  171. monkeybot/web_search/backends/firecrawl.py +43 -0
  172. monkeybot/web_search/backends/tavily.py +45 -0
  173. monkeybot/web_search/protocol.py +25 -0
  174. monkeybot/web_search/tool.py +56 -0
  175. monkeybot-2.1.1.dist-info/METADATA +318 -0
  176. monkeybot-2.1.1.dist-info/RECORD +178 -0
  177. monkeybot-2.1.1.dist-info/WHEEL +4 -0
  178. 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
+ }