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
@@ -0,0 +1,95 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any, Optional
6
+
7
+
8
+ @dataclass(frozen=True)
9
+ class TicketFrontmatter:
10
+ """Parsed, validated ticket frontmatter.
11
+
12
+ Only a minimal set of keys are required for orchestration. Additional
13
+ keys are preserved in `extra` for forward compatibility.
14
+ """
15
+
16
+ agent: str
17
+ done: bool
18
+ title: Optional[str] = None
19
+ goal: Optional[str] = None
20
+ # Optional model/reasoning overrides for this ticket.
21
+ model: Optional[str] = None
22
+ reasoning: Optional[str] = None
23
+ extra: dict[str, Any] = field(default_factory=dict)
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class TicketDoc:
28
+ path: Path
29
+ index: int
30
+ frontmatter: TicketFrontmatter
31
+ body: str
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Dispatch:
36
+ """Agent-to-human communication dispatched via the outbox.
37
+
38
+ A Dispatch is the canonical unit of agent→human communication. The mode
39
+ determines whether it's informational or requires human action:
40
+ - "notify": FYI, agent continues working
41
+ - "pause": Handoff, agent yields and awaits human reply
42
+ """
43
+
44
+ mode: str # "notify" | "pause"
45
+ body: str
46
+ title: Optional[str] = None
47
+ extra: dict[str, Any] = field(default_factory=dict)
48
+
49
+ @property
50
+ def is_handoff(self) -> bool:
51
+ """True if this dispatch requires human action (mode='pause')."""
52
+ return self.mode == "pause"
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class DispatchRecord:
57
+ """Archived dispatch with sequence number and file references.
58
+
59
+ This is the envelope/record created when a Dispatch is archived to the
60
+ dispatch history directory.
61
+ """
62
+
63
+ seq: int
64
+ dispatch: Dispatch
65
+ archived_dir: Path
66
+ archived_files: tuple[Path, ...]
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class TicketRunConfig:
71
+ ticket_dir: Path
72
+ runs_dir: Path
73
+ max_total_turns: int = 25
74
+ max_lint_retries: int = 3
75
+ max_commit_retries: int = 2
76
+ auto_commit: bool = True
77
+ checkpoint_message_template: str = (
78
+ "CAR checkpoint: run={run_id} turn={turn} agent={agent}"
79
+ )
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class TicketResult:
84
+ """Return value of a single TicketRunner.step() call."""
85
+
86
+ status: str # "continue" | "paused" | "completed" | "failed"
87
+ state: dict[str, Any]
88
+ reason: Optional[str] = None
89
+ reason_details: Optional[str] = None # Technical details (git status, etc.)
90
+ dispatch: Optional[DispatchRecord] = None
91
+ current_ticket: Optional[str] = None
92
+ agent_output: Optional[str] = None
93
+ agent_id: Optional[str] = None
94
+ agent_conversation_id: Optional[str] = None
95
+ agent_turn_id: Optional[str] = None
@@ -0,0 +1,232 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from .frontmatter import parse_markdown_frontmatter
9
+ from .lint import lint_dispatch_frontmatter
10
+ from .models import Dispatch, DispatchRecord
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class OutboxPaths:
15
+ """Filesystem paths for the dispatch outbox."""
16
+
17
+ run_dir: Path
18
+ dispatch_dir: Path
19
+ dispatch_history_dir: Path
20
+ dispatch_path: Path
21
+
22
+
23
+ def resolve_outbox_paths(
24
+ *, workspace_root: Path, runs_dir: Path, run_id: str
25
+ ) -> OutboxPaths:
26
+ run_dir = workspace_root / runs_dir / run_id
27
+ dispatch_dir = run_dir / "dispatch"
28
+ dispatch_history_dir = run_dir / "dispatch_history"
29
+ dispatch_path = run_dir / "DISPATCH.md"
30
+ return OutboxPaths(
31
+ run_dir=run_dir,
32
+ dispatch_dir=dispatch_dir,
33
+ dispatch_history_dir=dispatch_history_dir,
34
+ dispatch_path=dispatch_path,
35
+ )
36
+
37
+
38
+ def ensure_outbox_dirs(paths: OutboxPaths) -> None:
39
+ paths.dispatch_dir.mkdir(parents=True, exist_ok=True)
40
+ paths.dispatch_history_dir.mkdir(parents=True, exist_ok=True)
41
+
42
+
43
+ def _copy_item(src: Path, dst: Path) -> None:
44
+ if src.is_dir():
45
+ shutil.copytree(src, dst)
46
+ else:
47
+ dst.parent.mkdir(parents=True, exist_ok=True)
48
+ shutil.copy2(src, dst)
49
+
50
+
51
+ def _list_dispatch_items(dispatch_dir: Path) -> list[Path]:
52
+ if not dispatch_dir.exists() or not dispatch_dir.is_dir():
53
+ return []
54
+ items: list[Path] = []
55
+ for child in sorted(dispatch_dir.iterdir(), key=lambda p: p.name):
56
+ if child.name.startswith("."):
57
+ continue
58
+ items.append(child)
59
+ return items
60
+
61
+
62
+ def _delete_dispatch_items(items: list[Path]) -> None:
63
+ for item in items:
64
+ try:
65
+ if item.is_dir():
66
+ shutil.rmtree(item)
67
+ else:
68
+ item.unlink()
69
+ except OSError:
70
+ # Best-effort cleanup.
71
+ continue
72
+
73
+
74
+ def parse_dispatch(path: Path) -> tuple[Optional[Dispatch], list[str]]:
75
+ """Parse a dispatch file (DISPATCH.md) into a Dispatch object."""
76
+ try:
77
+ raw = path.read_text(encoding="utf-8")
78
+ except OSError as exc:
79
+ return None, [f"Failed to read dispatch file: {exc}"]
80
+
81
+ data, body = parse_markdown_frontmatter(raw)
82
+ normalized, errors = lint_dispatch_frontmatter(data)
83
+ if errors:
84
+ return None, errors
85
+
86
+ mode = normalized.get("mode", "notify")
87
+ title = normalized.get("title")
88
+ title_str = title.strip() if isinstance(title, str) and title.strip() else None
89
+ extra = dict(normalized)
90
+ extra.pop("mode", None)
91
+ extra.pop("title", None)
92
+ return (
93
+ Dispatch(mode=mode, body=body.lstrip("\n"), title=title_str, extra=extra),
94
+ [],
95
+ )
96
+
97
+
98
+ def create_turn_summary(
99
+ paths: OutboxPaths,
100
+ *,
101
+ next_seq: int,
102
+ agent_output: str,
103
+ ticket_id: Optional[str] = None,
104
+ agent_id: Optional[str] = None,
105
+ turn_number: Optional[int] = None,
106
+ ) -> tuple[Optional[DispatchRecord], list[str]]:
107
+ """Create a turn summary dispatch record for the agent's final output.
108
+
109
+ This creates a synthetic dispatch with mode="turn_summary" to show
110
+ the agent's final turn output in the dispatch history panel.
111
+
112
+ Returns (DispatchRecord, []) on success.
113
+ Returns (None, errors) on failure.
114
+ """
115
+
116
+ if not agent_output or not agent_output.strip():
117
+ return None, []
118
+
119
+ extra: dict = {}
120
+ if ticket_id:
121
+ extra["ticket_id"] = ticket_id
122
+ if agent_id:
123
+ extra["agent_id"] = agent_id
124
+ if turn_number is not None:
125
+ extra["turn_number"] = turn_number
126
+ extra["is_turn_summary"] = True
127
+
128
+ dispatch = Dispatch(
129
+ mode="turn_summary",
130
+ body=agent_output.strip(),
131
+ title=None,
132
+ extra=extra,
133
+ )
134
+
135
+ dest = paths.dispatch_history_dir / f"{next_seq:04d}"
136
+ try:
137
+ dest.mkdir(parents=True, exist_ok=False)
138
+ except OSError as exc:
139
+ return None, [f"Failed to create turn summary dir: {exc}"]
140
+
141
+ # Write a synthetic DISPATCH.md for consistency
142
+ msg_dest = dest / "DISPATCH.md"
143
+ try:
144
+ # Write minimal frontmatter + body
145
+ content = f"---\nmode: turn_summary\n---\n\n{agent_output.strip()}\n"
146
+ msg_dest.write_text(content, encoding="utf-8")
147
+ except OSError as exc:
148
+ return None, [f"Failed to write turn summary: {exc}"]
149
+
150
+ return (
151
+ DispatchRecord(
152
+ seq=next_seq,
153
+ dispatch=dispatch,
154
+ archived_dir=dest,
155
+ archived_files=(msg_dest,),
156
+ ),
157
+ [],
158
+ )
159
+
160
+
161
+ def archive_dispatch(
162
+ paths: OutboxPaths,
163
+ *,
164
+ next_seq: int,
165
+ ticket_id: Optional[str] = None,
166
+ ) -> tuple[Optional[DispatchRecord], list[str]]:
167
+ """Archive the current dispatch and attachments to the dispatch history.
168
+
169
+ Moves DISPATCH.md + attachments into dispatch_history/<seq>/.
170
+
171
+ Returns (DispatchRecord, []) on success.
172
+ Returns (None, []) when no dispatch file exists.
173
+ Returns (None, errors) on failure.
174
+ """
175
+
176
+ if not paths.dispatch_path.exists():
177
+ return None, []
178
+
179
+ dispatch, errors = parse_dispatch(paths.dispatch_path)
180
+ if errors or dispatch is None:
181
+ return None, errors
182
+
183
+ # Add ticket_id to extra if provided
184
+ if ticket_id and dispatch is not None:
185
+ extra = dict(dispatch.extra)
186
+ extra["ticket_id"] = ticket_id
187
+ dispatch = Dispatch(
188
+ mode=dispatch.mode,
189
+ body=dispatch.body,
190
+ title=dispatch.title,
191
+ extra=extra,
192
+ )
193
+
194
+ items = _list_dispatch_items(paths.dispatch_dir)
195
+ dest = paths.dispatch_history_dir / f"{next_seq:04d}"
196
+ try:
197
+ dest.mkdir(parents=True, exist_ok=False)
198
+ except OSError as exc:
199
+ return None, [f"Failed to create dispatch history dir: {exc}"]
200
+
201
+ archived: list[Path] = []
202
+ try:
203
+ # Archive the dispatch file.
204
+ msg_dest = dest / "DISPATCH.md"
205
+ _copy_item(paths.dispatch_path, msg_dest)
206
+ archived.append(msg_dest)
207
+
208
+ # Archive all attachments.
209
+ for item in items:
210
+ item_dest = dest / item.name
211
+ _copy_item(item, item_dest)
212
+ archived.append(item_dest)
213
+
214
+ except OSError as exc:
215
+ return None, [f"Failed to archive dispatch: {exc}"]
216
+
217
+ # Cleanup (best-effort).
218
+ try:
219
+ paths.dispatch_path.unlink()
220
+ except OSError:
221
+ pass
222
+ _delete_dispatch_items(items)
223
+
224
+ return (
225
+ DispatchRecord(
226
+ seq=next_seq,
227
+ dispatch=dispatch,
228
+ archived_dir=dest,
229
+ archived_files=tuple(archived),
230
+ ),
231
+ [],
232
+ )
@@ -0,0 +1,179 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import shutil
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ from .frontmatter import parse_markdown_frontmatter
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ReplyPaths:
14
+ run_dir: Path
15
+ reply_dir: Path
16
+ reply_history_dir: Path
17
+ user_reply_path: Path
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class UserReply:
22
+ body: str
23
+ title: Optional[str] = None
24
+ extra: dict = field(default_factory=dict)
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ReplyDispatch:
29
+ seq: int
30
+ reply: UserReply
31
+ archived_dir: Path
32
+ archived_files: tuple[Path, ...]
33
+
34
+
35
+ def resolve_reply_paths(
36
+ *, workspace_root: Path, runs_dir: Path, run_id: str
37
+ ) -> ReplyPaths:
38
+ run_dir = workspace_root / runs_dir / run_id
39
+ reply_dir = run_dir / "reply"
40
+ reply_history_dir = run_dir / "reply_history"
41
+ user_reply_path = run_dir / "USER_REPLY.md"
42
+ return ReplyPaths(
43
+ run_dir=run_dir,
44
+ reply_dir=reply_dir,
45
+ reply_history_dir=reply_history_dir,
46
+ user_reply_path=user_reply_path,
47
+ )
48
+
49
+
50
+ def ensure_reply_dirs(paths: ReplyPaths) -> None:
51
+ paths.reply_dir.mkdir(parents=True, exist_ok=True)
52
+ paths.reply_history_dir.mkdir(parents=True, exist_ok=True)
53
+
54
+
55
+ def parse_user_reply(path: Path) -> tuple[Optional[UserReply], list[str]]:
56
+ """Parse a USER_REPLY.md file.
57
+
58
+ USER_REPLY.md is intentionally permissive:
59
+ - frontmatter is optional
60
+ - we accept any YAML keys (stored in `extra`)
61
+ """
62
+
63
+ try:
64
+ raw = path.read_text(encoding="utf-8")
65
+ except OSError as exc:
66
+ return None, [f"Failed to read USER_REPLY.md: {exc}"]
67
+
68
+ data, body = parse_markdown_frontmatter(raw)
69
+ title = data.get("title")
70
+ title_str = title.strip() if isinstance(title, str) and title.strip() else None
71
+ extra = dict(data)
72
+ extra.pop("title", None)
73
+
74
+ # Keep the body as-is, but normalize leading whitespace so it mirrors DISPATCH.md.
75
+ return UserReply(body=body.lstrip("\n"), title=title_str, extra=extra), []
76
+
77
+
78
+ def _copy_item(src: Path, dst: Path) -> None:
79
+ if src.is_dir():
80
+ shutil.copytree(src, dst)
81
+ else:
82
+ dst.parent.mkdir(parents=True, exist_ok=True)
83
+ shutil.copy2(src, dst)
84
+
85
+
86
+ def _list_reply_items(reply_dir: Path) -> list[Path]:
87
+ if not reply_dir.exists() or not reply_dir.is_dir():
88
+ return []
89
+ items: list[Path] = []
90
+ for child in sorted(reply_dir.iterdir(), key=lambda p: p.name):
91
+ if child.name.startswith("."):
92
+ continue
93
+ items.append(child)
94
+ return items
95
+
96
+
97
+ def _delete_items(items: list[Path]) -> None:
98
+ for item in items:
99
+ try:
100
+ if item.is_dir():
101
+ shutil.rmtree(item)
102
+ else:
103
+ item.unlink()
104
+ except OSError:
105
+ continue
106
+
107
+
108
+ _SEQ_RE = re.compile(r"^[0-9]{4}$")
109
+
110
+
111
+ def next_reply_seq(reply_history_dir: Path) -> int:
112
+ """Return the next sequence number for reply_history."""
113
+
114
+ if not reply_history_dir.exists() or not reply_history_dir.is_dir():
115
+ return 1
116
+ existing: list[int] = []
117
+ for child in reply_history_dir.iterdir():
118
+ try:
119
+ if not child.is_dir():
120
+ continue
121
+ if not _SEQ_RE.fullmatch(child.name):
122
+ continue
123
+ existing.append(int(child.name))
124
+ except OSError:
125
+ continue
126
+ return (max(existing) + 1) if existing else 1
127
+
128
+
129
+ def dispatch_reply(
130
+ paths: ReplyPaths, *, next_seq: int
131
+ ) -> tuple[Optional[ReplyDispatch], list[str]]:
132
+ """Archive USER_REPLY.md + reply/* into reply_history/<seq>/.
133
+
134
+ Returns (dispatch, errors). When USER_REPLY.md does not exist, returns (None, []).
135
+ """
136
+
137
+ if not paths.user_reply_path.exists():
138
+ return None, []
139
+
140
+ reply, errors = parse_user_reply(paths.user_reply_path)
141
+ if errors or reply is None:
142
+ return None, errors
143
+
144
+ items = _list_reply_items(paths.reply_dir)
145
+ dest = paths.reply_history_dir / f"{next_seq:04d}"
146
+ try:
147
+ dest.mkdir(parents=True, exist_ok=False)
148
+ except OSError as exc:
149
+ return None, [f"Failed to create reply history dir: {exc}"]
150
+
151
+ archived: list[Path] = []
152
+ try:
153
+ msg_dest = dest / "USER_REPLY.md"
154
+ _copy_item(paths.user_reply_path, msg_dest)
155
+ archived.append(msg_dest)
156
+
157
+ for item in items:
158
+ item_dest = dest / item.name
159
+ _copy_item(item, item_dest)
160
+ archived.append(item_dest)
161
+ except OSError as exc:
162
+ return None, [f"Failed to archive reply: {exc}"]
163
+
164
+ # Cleanup (best-effort).
165
+ try:
166
+ paths.user_reply_path.unlink()
167
+ except OSError:
168
+ pass
169
+ _delete_items(items)
170
+
171
+ return (
172
+ ReplyDispatch(
173
+ seq=next_seq,
174
+ reply=reply,
175
+ archived_dir=dest,
176
+ archived_files=tuple(archived),
177
+ ),
178
+ [],
179
+ )