codex-autorunner 0.1.2__py3-none-any.whl → 1.0.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.
Files changed (189) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/opencode/client.py +68 -35
  3. codex_autorunner/agents/opencode/logging.py +21 -5
  4. codex_autorunner/agents/opencode/run_prompt.py +1 -0
  5. codex_autorunner/agents/opencode/runtime.py +118 -30
  6. codex_autorunner/agents/opencode/supervisor.py +36 -48
  7. codex_autorunner/agents/registry.py +136 -8
  8. codex_autorunner/api.py +25 -0
  9. codex_autorunner/bootstrap.py +16 -35
  10. codex_autorunner/cli.py +157 -139
  11. codex_autorunner/core/about_car.py +44 -32
  12. codex_autorunner/core/adapter_utils.py +21 -0
  13. codex_autorunner/core/app_server_logging.py +7 -3
  14. codex_autorunner/core/app_server_prompts.py +27 -260
  15. codex_autorunner/core/app_server_threads.py +15 -26
  16. codex_autorunner/core/codex_runner.py +6 -0
  17. codex_autorunner/core/config.py +390 -100
  18. codex_autorunner/core/docs.py +10 -2
  19. codex_autorunner/core/drafts.py +82 -0
  20. codex_autorunner/core/engine.py +278 -262
  21. codex_autorunner/core/flows/__init__.py +25 -0
  22. codex_autorunner/core/flows/controller.py +178 -0
  23. codex_autorunner/core/flows/definition.py +82 -0
  24. codex_autorunner/core/flows/models.py +75 -0
  25. codex_autorunner/core/flows/runtime.py +351 -0
  26. codex_autorunner/core/flows/store.py +485 -0
  27. codex_autorunner/core/flows/transition.py +133 -0
  28. codex_autorunner/core/flows/worker_process.py +242 -0
  29. codex_autorunner/core/hub.py +15 -9
  30. codex_autorunner/core/locks.py +4 -0
  31. codex_autorunner/core/prompt.py +15 -7
  32. codex_autorunner/core/redaction.py +29 -0
  33. codex_autorunner/core/review_context.py +5 -8
  34. codex_autorunner/core/run_index.py +6 -0
  35. codex_autorunner/core/runner_process.py +5 -2
  36. codex_autorunner/core/state.py +0 -88
  37. codex_autorunner/core/static_assets.py +55 -0
  38. codex_autorunner/core/supervisor_utils.py +67 -0
  39. codex_autorunner/core/update.py +20 -11
  40. codex_autorunner/core/update_runner.py +2 -0
  41. codex_autorunner/core/utils.py +29 -2
  42. codex_autorunner/discovery.py +2 -4
  43. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  44. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  45. codex_autorunner/integrations/agents/__init__.py +27 -0
  46. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  47. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  48. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  49. codex_autorunner/integrations/agents/run_event.py +71 -0
  50. codex_autorunner/integrations/app_server/client.py +576 -92
  51. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  52. codex_autorunner/integrations/telegram/adapter.py +141 -167
  53. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  54. codex_autorunner/integrations/telegram/config.py +175 -0
  55. codex_autorunner/integrations/telegram/constants.py +16 -1
  56. codex_autorunner/integrations/telegram/dispatch.py +17 -0
  57. codex_autorunner/integrations/telegram/doctor.py +47 -0
  58. codex_autorunner/integrations/telegram/handlers/callbacks.py +0 -4
  59. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
  60. codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
  61. codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
  62. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  63. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
  64. codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
  65. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
  66. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +133 -475
  67. codex_autorunner/integrations/telegram/handlers/commands_spec.py +11 -4
  68. codex_autorunner/integrations/telegram/handlers/messages.py +120 -9
  69. codex_autorunner/integrations/telegram/helpers.py +88 -16
  70. codex_autorunner/integrations/telegram/outbox.py +208 -37
  71. codex_autorunner/integrations/telegram/progress_stream.py +3 -10
  72. codex_autorunner/integrations/telegram/service.py +214 -40
  73. codex_autorunner/integrations/telegram/state.py +100 -2
  74. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  75. codex_autorunner/integrations/telegram/transport.py +36 -3
  76. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  77. codex_autorunner/manifest.py +2 -0
  78. codex_autorunner/plugin_api.py +22 -0
  79. codex_autorunner/routes/__init__.py +23 -14
  80. codex_autorunner/routes/analytics.py +239 -0
  81. codex_autorunner/routes/base.py +81 -109
  82. codex_autorunner/routes/file_chat.py +836 -0
  83. codex_autorunner/routes/flows.py +980 -0
  84. codex_autorunner/routes/messages.py +459 -0
  85. codex_autorunner/routes/system.py +6 -1
  86. codex_autorunner/routes/usage.py +87 -0
  87. codex_autorunner/routes/workspace.py +271 -0
  88. codex_autorunner/server.py +2 -1
  89. codex_autorunner/static/agentControls.js +1 -0
  90. codex_autorunner/static/agentEvents.js +248 -0
  91. codex_autorunner/static/app.js +25 -22
  92. codex_autorunner/static/autoRefresh.js +29 -1
  93. codex_autorunner/static/bootstrap.js +1 -0
  94. codex_autorunner/static/bus.js +1 -0
  95. codex_autorunner/static/cache.js +1 -0
  96. codex_autorunner/static/constants.js +20 -4
  97. codex_autorunner/static/dashboard.js +162 -196
  98. codex_autorunner/static/diffRenderer.js +37 -0
  99. codex_autorunner/static/docChatCore.js +324 -0
  100. codex_autorunner/static/docChatStorage.js +65 -0
  101. codex_autorunner/static/docChatVoice.js +65 -0
  102. codex_autorunner/static/docEditor.js +133 -0
  103. codex_autorunner/static/env.js +1 -0
  104. codex_autorunner/static/eventSummarizer.js +166 -0
  105. codex_autorunner/static/fileChat.js +182 -0
  106. codex_autorunner/static/health.js +155 -0
  107. codex_autorunner/static/hub.js +41 -118
  108. codex_autorunner/static/index.html +787 -858
  109. codex_autorunner/static/liveUpdates.js +1 -0
  110. codex_autorunner/static/loader.js +1 -0
  111. codex_autorunner/static/messages.js +470 -0
  112. codex_autorunner/static/mobileCompact.js +2 -1
  113. codex_autorunner/static/settings.js +24 -211
  114. codex_autorunner/static/styles.css +7567 -3865
  115. codex_autorunner/static/tabs.js +28 -5
  116. codex_autorunner/static/terminal.js +14 -0
  117. codex_autorunner/static/terminalManager.js +34 -59
  118. codex_autorunner/static/ticketChatActions.js +333 -0
  119. codex_autorunner/static/ticketChatEvents.js +16 -0
  120. codex_autorunner/static/ticketChatStorage.js +16 -0
  121. codex_autorunner/static/ticketChatStream.js +264 -0
  122. codex_autorunner/static/ticketEditor.js +750 -0
  123. codex_autorunner/static/ticketVoice.js +9 -0
  124. codex_autorunner/static/tickets.js +1315 -0
  125. codex_autorunner/static/utils.js +32 -3
  126. codex_autorunner/static/voice.js +1 -0
  127. codex_autorunner/static/workspace.js +672 -0
  128. codex_autorunner/static/workspaceApi.js +53 -0
  129. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  130. codex_autorunner/tickets/__init__.py +20 -0
  131. codex_autorunner/tickets/agent_pool.py +377 -0
  132. codex_autorunner/tickets/files.py +85 -0
  133. codex_autorunner/tickets/frontmatter.py +55 -0
  134. codex_autorunner/tickets/lint.py +102 -0
  135. codex_autorunner/tickets/models.py +95 -0
  136. codex_autorunner/tickets/outbox.py +232 -0
  137. codex_autorunner/tickets/replies.py +179 -0
  138. codex_autorunner/tickets/runner.py +823 -0
  139. codex_autorunner/tickets/spec_ingest.py +77 -0
  140. codex_autorunner/web/app.py +269 -91
  141. codex_autorunner/web/middleware.py +3 -4
  142. codex_autorunner/web/schemas.py +89 -109
  143. codex_autorunner/web/static_assets.py +1 -44
  144. codex_autorunner/workspace/__init__.py +40 -0
  145. codex_autorunner/workspace/paths.py +319 -0
  146. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +18 -21
  147. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  148. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  149. codex_autorunner/agents/execution/policy.py +0 -292
  150. codex_autorunner/agents/factory.py +0 -52
  151. codex_autorunner/agents/orchestrator.py +0 -358
  152. codex_autorunner/core/doc_chat.py +0 -1446
  153. codex_autorunner/core/snapshot.py +0 -580
  154. codex_autorunner/integrations/github/chatops.py +0 -268
  155. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  156. codex_autorunner/routes/docs.py +0 -381
  157. codex_autorunner/routes/github.py +0 -327
  158. codex_autorunner/routes/runs.py +0 -250
  159. codex_autorunner/spec_ingest.py +0 -812
  160. codex_autorunner/static/docChatActions.js +0 -287
  161. codex_autorunner/static/docChatEvents.js +0 -300
  162. codex_autorunner/static/docChatRender.js +0 -205
  163. codex_autorunner/static/docChatStream.js +0 -361
  164. codex_autorunner/static/docs.js +0 -20
  165. codex_autorunner/static/docsClipboard.js +0 -69
  166. codex_autorunner/static/docsCrud.js +0 -257
  167. codex_autorunner/static/docsDocUpdates.js +0 -62
  168. codex_autorunner/static/docsDrafts.js +0 -16
  169. codex_autorunner/static/docsElements.js +0 -69
  170. codex_autorunner/static/docsInit.js +0 -285
  171. codex_autorunner/static/docsParse.js +0 -160
  172. codex_autorunner/static/docsSnapshot.js +0 -87
  173. codex_autorunner/static/docsSpecIngest.js +0 -263
  174. codex_autorunner/static/docsState.js +0 -127
  175. codex_autorunner/static/docsThreadRegistry.js +0 -44
  176. codex_autorunner/static/docsUi.js +0 -153
  177. codex_autorunner/static/docsVoice.js +0 -56
  178. codex_autorunner/static/github.js +0 -504
  179. codex_autorunner/static/logs.js +0 -678
  180. codex_autorunner/static/review.js +0 -157
  181. codex_autorunner/static/runs.js +0 -418
  182. codex_autorunner/static/snapshot.js +0 -124
  183. codex_autorunner/static/state.js +0 -94
  184. codex_autorunner/static/todoPreview.js +0 -27
  185. codex_autorunner/workspace.py +0 -16
  186. codex_autorunner-0.1.2.dist-info/RECORD +0 -222
  187. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  188. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  189. {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.0.0.dist-info}/top_level.txt +0 -0
@@ -1,812 +0,0 @@
1
- import asyncio
2
- import contextlib
3
- import difflib
4
- import logging
5
- import re
6
- import threading
7
- from contextlib import asynccontextmanager, contextmanager
8
- from dataclasses import dataclass, field
9
- from pathlib import Path
10
- from typing import Any, Dict, MutableMapping, Optional
11
-
12
- import httpx
13
-
14
- from .agents.opencode.runtime import (
15
- PERMISSION_ALLOW,
16
- OpenCodeTurnOutput,
17
- build_turn_id,
18
- collect_opencode_output,
19
- extract_session_id,
20
- opencode_missing_env,
21
- parse_message_response,
22
- split_model_id,
23
- )
24
- from .agents.opencode.supervisor import OpenCodeSupervisor
25
- from .core.app_server_events import AppServerEventBuffer
26
- from .core.app_server_prompts import (
27
- build_spec_ingest_prompt as build_app_server_spec_ingest_prompt,
28
- )
29
- from .core.app_server_threads import (
30
- AppServerThreadRegistry,
31
- default_app_server_threads_path,
32
- )
33
- from .core.docs import validate_todo_markdown
34
- from .core.engine import Engine
35
- from .core.locks import FileLock, FileLockBusy, FileLockError
36
- from .core.patch_utils import (
37
- PatchError,
38
- apply_patch_file,
39
- ensure_patch_targets_allowed,
40
- normalize_patch_text,
41
- preview_patch,
42
- )
43
- from .core.utils import atomic_write
44
- from .integrations.app_server.client import CodexAppServerError
45
- from .integrations.app_server.supervisor import WorkspaceAppServerSupervisor
46
-
47
- logger = logging.getLogger("codex_autorunner.spec_ingest")
48
-
49
- SPEC_INGEST_TIMEOUT_SECONDS = 240
50
- SPEC_INGEST_INTERRUPT_GRACE_SECONDS = 10
51
- SPEC_INGEST_PATCH_NAME = "spec-ingest.patch"
52
-
53
-
54
- class SpecIngestError(Exception):
55
- """Raised when ingesting a SPEC fails."""
56
-
57
-
58
- @dataclass
59
- class ActiveSpecIngestTurn:
60
- thread_id: str
61
- turn_id: str
62
- client: Any
63
- interrupted: bool = False
64
- interrupt_sent: bool = False
65
- interrupt_event: asyncio.Event = field(default_factory=asyncio.Event)
66
-
67
-
68
- def ensure_can_overwrite(engine: Engine, force: bool) -> None:
69
- if force:
70
- return
71
- for key in ("todo", "progress", "opinions"):
72
- existing = engine.docs.read_doc(key).strip()
73
- if existing:
74
- raise SpecIngestError(
75
- "TODO/PROGRESS/OPINIONS already contain content; rerun with --force to overwrite"
76
- )
77
-
78
-
79
- def clear_work_docs(engine: Engine) -> Dict[str, str]:
80
- defaults = {
81
- "todo": "# TODO\n\n",
82
- "progress": "# Progress\n\n",
83
- "opinions": "# Opinions\n\n",
84
- }
85
- for key, content in defaults.items():
86
- atomic_write(engine.config.doc_path(key), content)
87
- # Read back to reflect actual on-disk content.
88
- return {k: engine.docs.read_doc(k) for k in defaults.keys()}
89
-
90
-
91
- class SpecIngestService:
92
- def __init__(
93
- self,
94
- engine: Engine,
95
- *,
96
- app_server_supervisor: Optional[WorkspaceAppServerSupervisor] = None,
97
- app_server_threads: Optional[AppServerThreadRegistry] = None,
98
- app_server_events: Optional[AppServerEventBuffer] = None,
99
- opencode_supervisor: Optional[OpenCodeSupervisor] = None,
100
- env: Optional[MutableMapping[str, str]] = None,
101
- ) -> None:
102
- self.engine = engine
103
- self._env = env
104
- self._app_server_supervisor = app_server_supervisor
105
- self._app_server_threads = app_server_threads or AppServerThreadRegistry(
106
- default_app_server_threads_path(self.engine.repo_root)
107
- )
108
- self._app_server_events = app_server_events
109
- self._opencode_supervisor = opencode_supervisor
110
- self.patch_path = (
111
- self.engine.repo_root / ".codex-autorunner" / SPEC_INGEST_PATCH_NAME
112
- )
113
- self.last_agent_message: Optional[str] = None
114
- self._lock: Optional[asyncio.Lock] = None
115
- self._lock_path = (
116
- self.engine.repo_root / ".codex-autorunner" / "locks" / "spec_ingest.lock"
117
- )
118
- self._thread_lock = threading.Lock()
119
- self._active_turn: Optional[ActiveSpecIngestTurn] = None
120
- self._active_turn_lock = threading.Lock()
121
- self._pending_interrupt = False
122
-
123
- def _ensure_lock(self) -> asyncio.Lock:
124
- if self._lock is None:
125
- try:
126
- self._lock = asyncio.Lock()
127
- except RuntimeError:
128
- asyncio.set_event_loop(asyncio.new_event_loop())
129
- self._lock = asyncio.Lock()
130
- return self._lock
131
-
132
- def _ingest_busy(self) -> bool:
133
- lock = self._ensure_lock()
134
- if lock.locked():
135
- return True
136
- file_lock = FileLock(self._lock_path)
137
- try:
138
- file_lock.acquire(blocking=False)
139
- except FileLockBusy:
140
- return True
141
- except FileLockError:
142
- return True
143
- finally:
144
- file_lock.release()
145
- return False
146
-
147
- @asynccontextmanager
148
- async def ingest_lock(self):
149
- if not self._thread_lock.acquire(blocking=False):
150
- raise SpecIngestError("Spec ingest is already running")
151
- lock = self._ensure_lock()
152
- if lock.locked():
153
- self._thread_lock.release()
154
- raise SpecIngestError("Spec ingest is already running")
155
- await lock.acquire()
156
- file_lock = FileLock(self._lock_path)
157
- try:
158
- try:
159
- file_lock.acquire(blocking=False)
160
- except FileLockBusy as exc:
161
- raise SpecIngestError("Spec ingest is already running") from exc
162
- except FileLockError as exc:
163
- raise SpecIngestError(str(exc)) from exc
164
- yield
165
- finally:
166
- file_lock.release()
167
- lock.release()
168
- self._thread_lock.release()
169
- with self._active_turn_lock:
170
- self._pending_interrupt = False
171
-
172
- @contextmanager
173
- def _patch_lock(self):
174
- if not self._thread_lock.acquire(blocking=False):
175
- raise SpecIngestError("Spec ingest is already running")
176
- lock = self._ensure_lock()
177
- if lock.locked():
178
- self._thread_lock.release()
179
- raise SpecIngestError("Spec ingest is already running")
180
- file_lock = FileLock(self._lock_path)
181
- try:
182
- file_lock.acquire(blocking=False)
183
- except FileLockBusy as exc:
184
- self._thread_lock.release()
185
- raise SpecIngestError("Spec ingest is already running") from exc
186
- except FileLockError as exc:
187
- self._thread_lock.release()
188
- raise SpecIngestError(str(exc)) from exc
189
- try:
190
- yield
191
- finally:
192
- file_lock.release()
193
- self._thread_lock.release()
194
-
195
- def _ensure_app_server(self) -> WorkspaceAppServerSupervisor:
196
- if self._app_server_supervisor is None:
197
- raise SpecIngestError("App-server backend is not configured")
198
- return self._app_server_supervisor
199
-
200
- def _ensure_opencode(self) -> OpenCodeSupervisor:
201
- if self._opencode_supervisor is None:
202
- raise SpecIngestError("OpenCode backend is not configured")
203
- return self._opencode_supervisor
204
-
205
- def _get_active_turn(self) -> Optional[ActiveSpecIngestTurn]:
206
- with self._active_turn_lock:
207
- return self._active_turn
208
-
209
- def _clear_active_turn(self, turn_id: str) -> None:
210
- with self._active_turn_lock:
211
- if self._active_turn and self._active_turn.turn_id == turn_id:
212
- self._active_turn = None
213
-
214
- def _register_active_turn(
215
- self, client: Any, turn_id: str, thread_id: str
216
- ) -> ActiveSpecIngestTurn:
217
- interrupt_event = asyncio.Event()
218
- active = ActiveSpecIngestTurn(
219
- thread_id=thread_id,
220
- turn_id=turn_id,
221
- client=client,
222
- interrupted=False,
223
- interrupt_sent=False,
224
- interrupt_event=interrupt_event,
225
- )
226
- with self._active_turn_lock:
227
- self._active_turn = active
228
- if self._pending_interrupt:
229
- self._pending_interrupt = False
230
- active.interrupted = True
231
- interrupt_event.set()
232
- return active
233
-
234
- async def _interrupt_turn(self, active: ActiveSpecIngestTurn) -> None:
235
- if active.interrupt_sent:
236
- return
237
- active.interrupt_sent = True
238
- try:
239
- if not hasattr(active.client, "turn_interrupt"):
240
- return
241
- await asyncio.wait_for(
242
- active.client.turn_interrupt(
243
- active.turn_id, thread_id=active.thread_id
244
- ),
245
- timeout=SPEC_INGEST_INTERRUPT_GRACE_SECONDS,
246
- )
247
- except asyncio.TimeoutError:
248
- pass
249
- except CodexAppServerError:
250
- pass
251
-
252
- async def _abort_opencode(
253
- self, active: ActiveSpecIngestTurn, thread_id: str
254
- ) -> None:
255
- if active.interrupt_sent:
256
- return
257
- active.interrupt_sent = True
258
- try:
259
- if not hasattr(active.client, "abort"):
260
- return
261
- await asyncio.wait_for(
262
- active.client.abort(thread_id),
263
- timeout=SPEC_INGEST_INTERRUPT_GRACE_SECONDS,
264
- )
265
- except asyncio.TimeoutError:
266
- pass
267
- except (OSError, RuntimeError, httpx.HTTPError) as exc:
268
- logger.debug("Failed to abort spec ingest turn: %s", exc)
269
-
270
- async def interrupt(self) -> Dict[str, str]:
271
- active = self._get_active_turn()
272
- if active is None:
273
- pending = self._ingest_busy()
274
- with self._active_turn_lock:
275
- self._pending_interrupt = pending
276
- return self._assemble_response(
277
- {},
278
- status="interrupted",
279
- agent_message="Spec ingest interrupted",
280
- )
281
- active.interrupted = True
282
- active.interrupt_event.set()
283
- await self._interrupt_turn(active)
284
- return self._assemble_response(
285
- {},
286
- status="interrupted",
287
- agent_message="Spec ingest interrupted",
288
- )
289
-
290
- def _allowed_targets(self) -> Dict[str, str]:
291
- config = self.engine.config
292
- rel = {}
293
- for key in ("todo", "progress", "opinions"):
294
- rel[key] = str(config.doc_path(key).relative_to(self.engine.repo_root))
295
- return rel
296
-
297
- def _spec_path(self, spec_path: Optional[Path]) -> Path:
298
- target = spec_path or self.engine.config.doc_path("spec")
299
- if not target.exists():
300
- raise SpecIngestError(f"SPEC not found at {target}")
301
- text = target.read_text(encoding="utf-8")
302
- if not text.strip():
303
- raise SpecIngestError(f"SPEC at {target} is empty")
304
- return target
305
-
306
- def _assemble_response(
307
- self,
308
- docs: Dict[str, str],
309
- *,
310
- patch: Optional[str] = None,
311
- agent_message: Optional[str] = None,
312
- status: str = "ok",
313
- ) -> Dict[str, str]:
314
- return {
315
- "status": status,
316
- "todo": docs.get("todo", self.engine.docs.read_doc("todo")),
317
- "progress": docs.get("progress", self.engine.docs.read_doc("progress")),
318
- "opinions": docs.get("opinions", self.engine.docs.read_doc("opinions")),
319
- "spec": self.engine.docs.read_doc("spec"),
320
- "summary": self.engine.docs.read_doc("summary"),
321
- "patch": patch or "",
322
- "agent_message": agent_message or "",
323
- }
324
-
325
- def pending_patch(self) -> Optional[Dict[str, str]]:
326
- with self._patch_lock():
327
- if not self.patch_path.exists():
328
- return None
329
- patch_text_raw = self.patch_path.read_text(encoding="utf-8")
330
- targets = self._allowed_targets()
331
- try:
332
- patch_text, raw_targets = normalize_patch_text(patch_text_raw)
333
- ensure_patch_targets_allowed(raw_targets, targets.values())
334
- preview = preview_patch(self.engine.repo_root, patch_text, raw_targets)
335
- except PatchError:
336
- return None
337
- docs = {
338
- key: preview.get(path, self.engine.docs.read_doc(key))
339
- for key, path in targets.items()
340
- }
341
- return self._assemble_response(
342
- docs, patch=patch_text, agent_message=self.last_agent_message
343
- )
344
-
345
- def apply_patch(self) -> Dict[str, str]:
346
- with self._patch_lock():
347
- if not self.patch_path.exists():
348
- raise SpecIngestError("No pending spec ingest patch")
349
- patch_text_raw = self.patch_path.read_text(encoding="utf-8")
350
- targets = self._allowed_targets()
351
- try:
352
- patch_text, raw_targets = normalize_patch_text(patch_text_raw)
353
- ensure_patch_targets_allowed(raw_targets, targets.values())
354
- self.patch_path.write_text(patch_text, encoding="utf-8")
355
- apply_patch_file(self.engine.repo_root, self.patch_path, raw_targets)
356
- except PatchError as exc:
357
- raise SpecIngestError(str(exc)) from exc
358
- self.patch_path.unlink(missing_ok=True)
359
- return self._assemble_response(
360
- {
361
- key: self.engine.docs.read_doc(key)
362
- for key in ("todo", "progress", "opinions")
363
- }
364
- )
365
-
366
- def discard_patch(self) -> Dict[str, str]:
367
- with self._patch_lock():
368
- if self.patch_path.exists():
369
- self.patch_path.unlink(missing_ok=True)
370
- return self._assemble_response(
371
- {
372
- key: self.engine.docs.read_doc(key)
373
- for key in ("todo", "progress", "opinions")
374
- }
375
- )
376
-
377
- def _build_patch(self, rel_path: str, before: str, after: str) -> str:
378
- diff = difflib.unified_diff(
379
- before.splitlines(),
380
- after.splitlines(),
381
- fromfile=f"a/{rel_path}",
382
- tofile=f"b/{rel_path}",
383
- lineterm="",
384
- )
385
- return "\n".join(diff)
386
-
387
- def _restore_docs(self, backups: Dict[str, str]) -> None:
388
- config = self.engine.config
389
- for key, content in backups.items():
390
- path = config.doc_path(key)
391
- try:
392
- current = path.read_text(encoding="utf-8")
393
- except OSError:
394
- current = ""
395
- if current != content:
396
- atomic_write(path, content)
397
-
398
- async def _execute_app_server(
399
- self,
400
- *,
401
- force: bool,
402
- spec_path: Optional[Path],
403
- message: Optional[str],
404
- model: Optional[str] = None,
405
- reasoning: Optional[str] = None,
406
- ) -> Dict[str, str]:
407
- if not force:
408
- ensure_can_overwrite(self.engine, force=False)
409
- spec_target = self._spec_path(spec_path)
410
- prompt = build_app_server_spec_ingest_prompt(
411
- self.engine.config,
412
- message=message or "Ingest SPEC into TODO/PROGRESS/OPINIONS.",
413
- spec_path=spec_target,
414
- )
415
-
416
- # Backup docs
417
- backups = {}
418
- for key in ("todo", "progress", "opinions"):
419
- backups[key] = self.engine.docs.read_doc(key)
420
-
421
- supervisor = self._ensure_app_server()
422
- client = await supervisor.get_client(self.engine.repo_root)
423
- key = "spec_ingest"
424
- thread_id = self._app_server_threads.get_thread_id(key)
425
- if thread_id:
426
- try:
427
- result = await client.thread_resume(thread_id)
428
- resumed = result.get("id")
429
- if isinstance(resumed, str) and resumed:
430
- thread_id = resumed
431
- self._app_server_threads.set_thread_id(key, thread_id)
432
- except CodexAppServerError:
433
- self._app_server_threads.reset_thread(key)
434
- thread_id = None
435
- if not thread_id:
436
- thread = await client.thread_start(str(self.engine.repo_root))
437
- thread_id = thread.get("id")
438
- if not isinstance(thread_id, str) or not thread_id:
439
- raise SpecIngestError("App-server did not return a thread id")
440
- self._app_server_threads.set_thread_id(key, thread_id)
441
-
442
- turn_kwargs: dict[str, Any] = {}
443
- if model:
444
- turn_kwargs["model"] = model
445
- if reasoning:
446
- turn_kwargs["effort"] = reasoning
447
- handle = await client.turn_start(
448
- thread_id,
449
- prompt,
450
- approval_policy="never",
451
- sandbox_policy="dangerFullAccess", # Allowed for doc edits per user request
452
- **turn_kwargs,
453
- )
454
- active = self._register_active_turn(client, handle.turn_id, handle.thread_id)
455
- if self._app_server_events is not None:
456
- try:
457
- await self._app_server_events.register_turn(
458
- handle.thread_id, handle.turn_id
459
- )
460
- except (KeyError, TypeError, RuntimeError) as exc:
461
- logger.debug("Failed to register turn: %s", exc)
462
-
463
- turn_task = asyncio.create_task(handle.wait(timeout=None))
464
- timeout_task = asyncio.create_task(asyncio.sleep(SPEC_INGEST_TIMEOUT_SECONDS))
465
- interrupt_task = asyncio.create_task(active.interrupt_event.wait())
466
-
467
- try:
468
- tasks = {turn_task, timeout_task, interrupt_task}
469
- done, _pending = await asyncio.wait(
470
- tasks, return_when=asyncio.FIRST_COMPLETED
471
- )
472
- if timeout_task in done:
473
- turn_task.add_done_callback(lambda task: task.exception())
474
- raise SpecIngestError("Spec ingest agent timed out")
475
- if interrupt_task in done:
476
- active.interrupted = True
477
- await self._interrupt_turn(active)
478
- done, _pending = await asyncio.wait(
479
- {turn_task}, timeout=SPEC_INGEST_INTERRUPT_GRACE_SECONDS
480
- )
481
- if not done:
482
- turn_task.add_done_callback(lambda task: task.exception())
483
- return self._assemble_response(
484
- {},
485
- status="interrupted",
486
- agent_message="Spec ingest interrupted",
487
- )
488
- result = await turn_task
489
- finally:
490
- self._clear_active_turn(handle.turn_id)
491
- timeout_task.cancel()
492
- with contextlib.suppress(asyncio.CancelledError):
493
- await timeout_task
494
- interrupt_task.cancel()
495
- with contextlib.suppress(asyncio.CancelledError):
496
- await interrupt_task
497
-
498
- if active.interrupted:
499
- # Restore docs if interrupted
500
- self._restore_docs(backups)
501
- return self._assemble_response(
502
- {},
503
- status="interrupted",
504
- agent_message="Spec ingest interrupted",
505
- )
506
-
507
- if result.errors:
508
- # Restore docs on error
509
- self._restore_docs(backups)
510
- raise SpecIngestError(result.errors[-1])
511
-
512
- output = "\n".join(result.agent_messages).strip()
513
- agent_message = SpecIngestPatchParser.parse_agent_message(output)
514
-
515
- # Compute patch from file changes
516
- patches = []
517
- docs_preview = {}
518
- targets = self._allowed_targets()
519
-
520
- for key in ("todo", "progress", "opinions"):
521
- path = self.engine.config.doc_path(key)
522
- try:
523
- after = path.read_text(encoding="utf-8")
524
- except OSError:
525
- after = ""
526
- before = backups.get(key, "")
527
- docs_preview[key] = after
528
-
529
- if after == before:
530
- continue
531
-
532
- rel_path = targets[key]
533
- patch = self._build_patch(rel_path, before, after)
534
- if patch.strip():
535
- patches.append(patch)
536
-
537
- todo_errors = validate_todo_markdown(docs_preview.get("todo", ""))
538
- if todo_errors:
539
- # Restore docs before failing.
540
- self._restore_docs(backups)
541
- raise SpecIngestError("Invalid TODO format: " + "; ".join(todo_errors))
542
-
543
- # Always restore docs to state before ingest (user must apply patch)
544
- self._restore_docs(backups)
545
-
546
- patch_text = "\n".join(patches)
547
- if not patch_text.strip():
548
- raise SpecIngestError(
549
- "App-server did not make any changes to TODO/PROGRESS/OPINIONS"
550
- )
551
-
552
- self.patch_path.write_text(patch_text, encoding="utf-8")
553
- self.last_agent_message = agent_message
554
-
555
- return self._assemble_response(
556
- docs_preview, patch=patch_text, agent_message=agent_message
557
- )
558
-
559
- async def _execute_opencode(
560
- self,
561
- *,
562
- force: bool,
563
- spec_path: Optional[Path],
564
- message: Optional[str],
565
- model: Optional[str],
566
- reasoning: Optional[str],
567
- ) -> Dict[str, str]:
568
- if not force:
569
- ensure_can_overwrite(self.engine, force=False)
570
- spec_target = self._spec_path(spec_path)
571
- prompt = build_app_server_spec_ingest_prompt(
572
- self.engine.config,
573
- message=message or "Ingest SPEC into TODO/PROGRESS/OPINIONS.",
574
- spec_path=spec_target,
575
- )
576
- backups = {
577
- key: self.engine.docs.read_doc(key)
578
- for key in ("todo", "progress", "opinions")
579
- }
580
- supervisor = self._ensure_opencode()
581
- client = await supervisor.get_client(self.engine.repo_root)
582
- key = "spec_ingest.opencode"
583
- thread_id = self._app_server_threads.get_thread_id(key)
584
- if thread_id:
585
- try:
586
- await client.get_session(thread_id)
587
- except (OSError, KeyError, ValueError, httpx.HTTPError) as exc:
588
- logger.debug("OpenCode session not found, resetting thread: %s", exc)
589
- self._app_server_threads.reset_thread(key)
590
- thread_id = None
591
- if not thread_id:
592
- session = await client.create_session(directory=str(self.engine.repo_root))
593
- thread_id = extract_session_id(session, allow_fallback_id=True)
594
- if not isinstance(thread_id, str) or not thread_id:
595
- raise SpecIngestError("OpenCode did not return a session id")
596
- self._app_server_threads.set_thread_id(key, thread_id)
597
-
598
- model_payload = split_model_id(model)
599
- missing_env = await opencode_missing_env(
600
- client, str(self.engine.repo_root), model_payload, env=self._env
601
- )
602
- if missing_env:
603
- provider_id = model_payload.get("providerID") if model_payload else None
604
- missing_label = ", ".join(missing_env)
605
- raise SpecIngestError(
606
- "OpenCode provider "
607
- f"{provider_id or 'selected'} requires env vars: {missing_label}"
608
- )
609
- opencode_turn_started = False
610
- await supervisor.mark_turn_started(self.engine.repo_root)
611
- opencode_turn_started = True
612
- turn_id = build_turn_id(thread_id)
613
- active = self._register_active_turn(client, turn_id, thread_id)
614
- permission_policy = PERMISSION_ALLOW
615
- ready_event = asyncio.Event()
616
- output_task = asyncio.create_task(
617
- collect_opencode_output(
618
- client,
619
- session_id=thread_id,
620
- workspace_path=str(self.engine.repo_root),
621
- permission_policy=permission_policy,
622
- question_policy="auto_first_option",
623
- should_stop=active.interrupt_event.is_set,
624
- ready_event=ready_event,
625
- )
626
- )
627
- with contextlib.suppress(asyncio.TimeoutError):
628
- await asyncio.wait_for(ready_event.wait(), timeout=2.0)
629
- prompt_task = asyncio.create_task(
630
- client.prompt_async(
631
- thread_id,
632
- message=prompt,
633
- model=model_payload,
634
- variant=reasoning,
635
- )
636
- )
637
- timeout_task = asyncio.create_task(asyncio.sleep(SPEC_INGEST_TIMEOUT_SECONDS))
638
- interrupt_task = asyncio.create_task(active.interrupt_event.wait())
639
- try:
640
- prompt_response = None
641
- try:
642
- prompt_response = await prompt_task
643
- except Exception as exc:
644
- active.interrupt_event.set()
645
- output_task.cancel()
646
- with contextlib.suppress(asyncio.CancelledError):
647
- await output_task
648
- raise SpecIngestError(f"OpenCode prompt failed: {exc}") from exc
649
- tasks = {output_task, timeout_task, interrupt_task}
650
- done, _pending = await asyncio.wait(
651
- tasks, return_when=asyncio.FIRST_COMPLETED
652
- )
653
- if timeout_task in done:
654
- output_task.add_done_callback(lambda task: task.exception())
655
- raise SpecIngestError("Spec ingest agent timed out")
656
- if interrupt_task in done:
657
- active.interrupted = True
658
- await self._abort_opencode(active, thread_id)
659
- done, _pending = await asyncio.wait(
660
- {output_task}, timeout=SPEC_INGEST_INTERRUPT_GRACE_SECONDS
661
- )
662
- if not done:
663
- output_task.add_done_callback(lambda task: task.exception())
664
- output_result = await output_task
665
- if output_result.text or output_result.error:
666
- pass
667
- elif prompt_response is not None:
668
- fallback = parse_message_response(prompt_response)
669
- if fallback.text:
670
- output_result = OpenCodeTurnOutput(
671
- text=fallback.text, error=fallback.error
672
- )
673
- finally:
674
- self._clear_active_turn(turn_id)
675
- timeout_task.cancel()
676
- with contextlib.suppress(asyncio.CancelledError):
677
- await timeout_task
678
- interrupt_task.cancel()
679
- with contextlib.suppress(asyncio.CancelledError):
680
- await interrupt_task
681
- if opencode_turn_started:
682
- await supervisor.mark_turn_finished(self.engine.repo_root)
683
-
684
- if active.interrupted:
685
- self._restore_docs(backups)
686
- return self._assemble_response(
687
- {},
688
- status="interrupted",
689
- agent_message="Spec ingest interrupted",
690
- )
691
-
692
- if output_result.error:
693
- raise SpecIngestError(output_result.error)
694
- agent_message = SpecIngestPatchParser.parse_agent_message(output_result.text)
695
- patches = []
696
- docs_preview = {}
697
- targets = self._allowed_targets()
698
-
699
- for key in ("todo", "progress", "opinions"):
700
- path = self.engine.config.doc_path(key)
701
- try:
702
- after = path.read_text(encoding="utf-8")
703
- except OSError:
704
- after = ""
705
- before = backups.get(key, "")
706
- docs_preview[key] = after
707
-
708
- if after == before:
709
- continue
710
-
711
- rel_path = targets[key]
712
- patch = self._build_patch(rel_path, before, after)
713
- if patch.strip():
714
- patches.append(patch)
715
-
716
- todo_errors = validate_todo_markdown(docs_preview.get("todo", ""))
717
- if todo_errors:
718
- self._restore_docs(backups)
719
- raise SpecIngestError("Invalid TODO format: " + "; ".join(todo_errors))
720
-
721
- self._restore_docs(backups)
722
-
723
- patch_text = "\n".join(patches)
724
- if not patch_text.strip():
725
- raise SpecIngestError(
726
- "OpenCode did not make any changes to TODO/PROGRESS/OPINIONS"
727
- )
728
-
729
- self.patch_path.write_text(patch_text, encoding="utf-8")
730
- self.last_agent_message = agent_message
731
-
732
- return self._assemble_response(
733
- docs_preview, patch=patch_text, agent_message=agent_message
734
- )
735
-
736
- async def execute(
737
- self,
738
- *,
739
- force: bool,
740
- spec_path: Optional[Path] = None,
741
- message: Optional[str] = None,
742
- agent: Optional[str] = None,
743
- model: Optional[str] = None,
744
- reasoning: Optional[str] = None,
745
- ) -> Dict[str, str]:
746
- async with self.ingest_lock():
747
- if (agent or "").strip().lower() == "opencode":
748
- return await self._execute_opencode(
749
- force=force,
750
- spec_path=spec_path,
751
- message=message,
752
- model=model,
753
- reasoning=reasoning,
754
- )
755
- return await self._execute_app_server(
756
- force=force,
757
- spec_path=spec_path,
758
- message=message,
759
- model=model,
760
- reasoning=reasoning,
761
- )
762
-
763
-
764
- class SpecIngestPatchParser:
765
- @staticmethod
766
- def parse_agent_message(text: str) -> str:
767
- clean = (text or "").strip()
768
- if not clean:
769
- return "Updated docs via spec ingest."
770
- for line in clean.splitlines():
771
- if line.lower().startswith("agent:"):
772
- return line[len("agent:") :].strip() or "Updated docs via spec ingest."
773
- return clean.splitlines()[0].strip()
774
-
775
- @staticmethod
776
- def strip_code_fences(text: str) -> str:
777
- # Kept for backward compatibility if needed, but likely unused in new flow
778
- lines = text.strip().splitlines()
779
- if (
780
- len(lines) >= 2
781
- and lines[0].startswith("```")
782
- and lines[-1].startswith("```")
783
- ):
784
- return "\n".join(lines[1:-1]).strip()
785
- return text.strip()
786
-
787
- @classmethod
788
- def split_patch(cls, output: str) -> tuple[str, str]:
789
- # Kept for backward compatibility if needed, but likely unused in new flow
790
- if not output:
791
- return "", ""
792
- match = re.search(
793
- r"<PATCH>(.*?)</PATCH>", output, flags=re.IGNORECASE | re.DOTALL
794
- )
795
- if match:
796
- patch_text = cls.strip_code_fences(match.group(1))
797
- before = output[: match.start()].strip()
798
- after = output[match.end() :].strip()
799
- message_text = "\n".join(part for part in [before, after] if part)
800
- return message_text, patch_text
801
- lines = output.splitlines()
802
- start_idx = None
803
- for idx, line in enumerate(lines):
804
- if line.startswith("--- ") or line.startswith("*** Begin Patch"):
805
- start_idx = idx
806
- break
807
- if start_idx is None:
808
- return output.strip(), ""
809
- message_text = "\n".join(lines[:start_idx]).strip()
810
- patch_text = "\n".join(lines[start_idx:]).strip()
811
- patch_text = cls.strip_code_fences(patch_text)
812
- return message_text, patch_text