codex-autorunner 0.1.1__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 (226) hide show
  1. codex_autorunner/__main__.py +4 -0
  2. codex_autorunner/agents/__init__.py +20 -0
  3. codex_autorunner/agents/base.py +2 -2
  4. codex_autorunner/agents/codex/harness.py +1 -1
  5. codex_autorunner/agents/opencode/__init__.py +4 -0
  6. codex_autorunner/agents/opencode/agent_config.py +104 -0
  7. codex_autorunner/agents/opencode/client.py +305 -28
  8. codex_autorunner/agents/opencode/harness.py +71 -20
  9. codex_autorunner/agents/opencode/logging.py +225 -0
  10. codex_autorunner/agents/opencode/run_prompt.py +261 -0
  11. codex_autorunner/agents/opencode/runtime.py +1202 -132
  12. codex_autorunner/agents/opencode/supervisor.py +194 -68
  13. codex_autorunner/agents/registry.py +258 -0
  14. codex_autorunner/agents/types.py +2 -2
  15. codex_autorunner/api.py +25 -0
  16. codex_autorunner/bootstrap.py +19 -40
  17. codex_autorunner/cli.py +234 -151
  18. codex_autorunner/core/about_car.py +44 -32
  19. codex_autorunner/core/adapter_utils.py +21 -0
  20. codex_autorunner/core/app_server_events.py +15 -6
  21. codex_autorunner/core/app_server_logging.py +55 -15
  22. codex_autorunner/core/app_server_prompts.py +28 -259
  23. codex_autorunner/core/app_server_threads.py +15 -26
  24. codex_autorunner/core/circuit_breaker.py +183 -0
  25. codex_autorunner/core/codex_runner.py +6 -0
  26. codex_autorunner/core/config.py +555 -133
  27. codex_autorunner/core/docs.py +54 -9
  28. codex_autorunner/core/drafts.py +82 -0
  29. codex_autorunner/core/engine.py +828 -274
  30. codex_autorunner/core/exceptions.py +60 -0
  31. codex_autorunner/core/flows/__init__.py +25 -0
  32. codex_autorunner/core/flows/controller.py +178 -0
  33. codex_autorunner/core/flows/definition.py +82 -0
  34. codex_autorunner/core/flows/models.py +75 -0
  35. codex_autorunner/core/flows/runtime.py +351 -0
  36. codex_autorunner/core/flows/store.py +485 -0
  37. codex_autorunner/core/flows/transition.py +133 -0
  38. codex_autorunner/core/flows/worker_process.py +242 -0
  39. codex_autorunner/core/hub.py +21 -13
  40. codex_autorunner/core/locks.py +118 -1
  41. codex_autorunner/core/logging_utils.py +9 -6
  42. codex_autorunner/core/path_utils.py +123 -0
  43. codex_autorunner/core/prompt.py +15 -7
  44. codex_autorunner/core/redaction.py +29 -0
  45. codex_autorunner/core/retry.py +61 -0
  46. codex_autorunner/core/review.py +888 -0
  47. codex_autorunner/core/review_context.py +161 -0
  48. codex_autorunner/core/run_index.py +223 -0
  49. codex_autorunner/core/runner_controller.py +44 -1
  50. codex_autorunner/core/runner_process.py +30 -1
  51. codex_autorunner/core/sqlite_utils.py +32 -0
  52. codex_autorunner/core/state.py +273 -44
  53. codex_autorunner/core/static_assets.py +55 -0
  54. codex_autorunner/core/supervisor_utils.py +67 -0
  55. codex_autorunner/core/text_delta_coalescer.py +43 -0
  56. codex_autorunner/core/update.py +20 -11
  57. codex_autorunner/core/update_runner.py +2 -0
  58. codex_autorunner/core/usage.py +107 -75
  59. codex_autorunner/core/utils.py +167 -3
  60. codex_autorunner/discovery.py +3 -3
  61. codex_autorunner/flows/ticket_flow/__init__.py +3 -0
  62. codex_autorunner/flows/ticket_flow/definition.py +91 -0
  63. codex_autorunner/integrations/agents/__init__.py +27 -0
  64. codex_autorunner/integrations/agents/agent_backend.py +142 -0
  65. codex_autorunner/integrations/agents/codex_backend.py +307 -0
  66. codex_autorunner/integrations/agents/opencode_backend.py +325 -0
  67. codex_autorunner/integrations/agents/run_event.py +71 -0
  68. codex_autorunner/integrations/app_server/client.py +708 -153
  69. codex_autorunner/integrations/app_server/supervisor.py +59 -33
  70. codex_autorunner/integrations/telegram/adapter.py +474 -185
  71. codex_autorunner/integrations/telegram/api_schemas.py +120 -0
  72. codex_autorunner/integrations/telegram/config.py +239 -1
  73. codex_autorunner/integrations/telegram/constants.py +19 -1
  74. codex_autorunner/integrations/telegram/dispatch.py +44 -8
  75. codex_autorunner/integrations/telegram/doctor.py +47 -0
  76. codex_autorunner/integrations/telegram/handlers/approvals.py +12 -10
  77. codex_autorunner/integrations/telegram/handlers/callbacks.py +15 -1
  78. codex_autorunner/integrations/telegram/handlers/commands/__init__.py +29 -0
  79. codex_autorunner/integrations/telegram/handlers/commands/approvals.py +173 -0
  80. codex_autorunner/integrations/telegram/handlers/commands/execution.py +2595 -0
  81. codex_autorunner/integrations/telegram/handlers/commands/files.py +1408 -0
  82. codex_autorunner/integrations/telegram/handlers/commands/flows.py +227 -0
  83. codex_autorunner/integrations/telegram/handlers/commands/formatting.py +81 -0
  84. codex_autorunner/integrations/telegram/handlers/commands/github.py +1688 -0
  85. codex_autorunner/integrations/telegram/handlers/commands/shared.py +190 -0
  86. codex_autorunner/integrations/telegram/handlers/commands/voice.py +112 -0
  87. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +2043 -0
  88. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +954 -5689
  89. codex_autorunner/integrations/telegram/handlers/{commands.py → commands_spec.py} +11 -4
  90. codex_autorunner/integrations/telegram/handlers/messages.py +374 -49
  91. codex_autorunner/integrations/telegram/handlers/questions.py +389 -0
  92. codex_autorunner/integrations/telegram/handlers/selections.py +6 -4
  93. codex_autorunner/integrations/telegram/handlers/utils.py +171 -0
  94. codex_autorunner/integrations/telegram/helpers.py +90 -18
  95. codex_autorunner/integrations/telegram/notifications.py +126 -35
  96. codex_autorunner/integrations/telegram/outbox.py +214 -43
  97. codex_autorunner/integrations/telegram/progress_stream.py +42 -19
  98. codex_autorunner/integrations/telegram/runtime.py +24 -13
  99. codex_autorunner/integrations/telegram/service.py +500 -129
  100. codex_autorunner/integrations/telegram/state.py +1278 -330
  101. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +322 -0
  102. codex_autorunner/integrations/telegram/transport.py +37 -4
  103. codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
  104. codex_autorunner/integrations/telegram/types.py +22 -2
  105. codex_autorunner/integrations/telegram/voice.py +14 -15
  106. codex_autorunner/manifest.py +2 -0
  107. codex_autorunner/plugin_api.py +22 -0
  108. codex_autorunner/routes/__init__.py +25 -14
  109. codex_autorunner/routes/agents.py +18 -78
  110. codex_autorunner/routes/analytics.py +239 -0
  111. codex_autorunner/routes/base.py +142 -113
  112. codex_autorunner/routes/file_chat.py +836 -0
  113. codex_autorunner/routes/flows.py +980 -0
  114. codex_autorunner/routes/messages.py +459 -0
  115. codex_autorunner/routes/repos.py +17 -0
  116. codex_autorunner/routes/review.py +148 -0
  117. codex_autorunner/routes/sessions.py +16 -8
  118. codex_autorunner/routes/settings.py +22 -0
  119. codex_autorunner/routes/shared.py +33 -3
  120. codex_autorunner/routes/system.py +22 -1
  121. codex_autorunner/routes/usage.py +87 -0
  122. codex_autorunner/routes/voice.py +5 -13
  123. codex_autorunner/routes/workspace.py +271 -0
  124. codex_autorunner/server.py +2 -1
  125. codex_autorunner/static/agentControls.js +9 -1
  126. codex_autorunner/static/agentEvents.js +248 -0
  127. codex_autorunner/static/app.js +27 -22
  128. codex_autorunner/static/autoRefresh.js +29 -1
  129. codex_autorunner/static/bootstrap.js +1 -0
  130. codex_autorunner/static/bus.js +1 -0
  131. codex_autorunner/static/cache.js +1 -0
  132. codex_autorunner/static/constants.js +20 -4
  133. codex_autorunner/static/dashboard.js +162 -150
  134. codex_autorunner/static/diffRenderer.js +37 -0
  135. codex_autorunner/static/docChatCore.js +324 -0
  136. codex_autorunner/static/docChatStorage.js +65 -0
  137. codex_autorunner/static/docChatVoice.js +65 -0
  138. codex_autorunner/static/docEditor.js +133 -0
  139. codex_autorunner/static/env.js +1 -0
  140. codex_autorunner/static/eventSummarizer.js +166 -0
  141. codex_autorunner/static/fileChat.js +182 -0
  142. codex_autorunner/static/health.js +155 -0
  143. codex_autorunner/static/hub.js +67 -126
  144. codex_autorunner/static/index.html +788 -807
  145. codex_autorunner/static/liveUpdates.js +59 -0
  146. codex_autorunner/static/loader.js +1 -0
  147. codex_autorunner/static/messages.js +470 -0
  148. codex_autorunner/static/mobileCompact.js +2 -1
  149. codex_autorunner/static/settings.js +24 -205
  150. codex_autorunner/static/styles.css +7577 -3758
  151. codex_autorunner/static/tabs.js +28 -5
  152. codex_autorunner/static/terminal.js +14 -0
  153. codex_autorunner/static/terminalManager.js +53 -59
  154. codex_autorunner/static/ticketChatActions.js +333 -0
  155. codex_autorunner/static/ticketChatEvents.js +16 -0
  156. codex_autorunner/static/ticketChatStorage.js +16 -0
  157. codex_autorunner/static/ticketChatStream.js +264 -0
  158. codex_autorunner/static/ticketEditor.js +750 -0
  159. codex_autorunner/static/ticketVoice.js +9 -0
  160. codex_autorunner/static/tickets.js +1315 -0
  161. codex_autorunner/static/utils.js +32 -3
  162. codex_autorunner/static/voice.js +21 -7
  163. codex_autorunner/static/workspace.js +672 -0
  164. codex_autorunner/static/workspaceApi.js +53 -0
  165. codex_autorunner/static/workspaceFileBrowser.js +504 -0
  166. codex_autorunner/tickets/__init__.py +20 -0
  167. codex_autorunner/tickets/agent_pool.py +377 -0
  168. codex_autorunner/tickets/files.py +85 -0
  169. codex_autorunner/tickets/frontmatter.py +55 -0
  170. codex_autorunner/tickets/lint.py +102 -0
  171. codex_autorunner/tickets/models.py +95 -0
  172. codex_autorunner/tickets/outbox.py +232 -0
  173. codex_autorunner/tickets/replies.py +179 -0
  174. codex_autorunner/tickets/runner.py +823 -0
  175. codex_autorunner/tickets/spec_ingest.py +77 -0
  176. codex_autorunner/voice/capture.py +7 -7
  177. codex_autorunner/voice/service.py +51 -9
  178. codex_autorunner/web/app.py +419 -199
  179. codex_autorunner/web/hub_jobs.py +13 -2
  180. codex_autorunner/web/middleware.py +47 -13
  181. codex_autorunner/web/pty_session.py +26 -13
  182. codex_autorunner/web/schemas.py +114 -109
  183. codex_autorunner/web/static_assets.py +55 -42
  184. codex_autorunner/web/static_refresh.py +86 -0
  185. codex_autorunner/workspace/__init__.py +40 -0
  186. codex_autorunner/workspace/paths.py +319 -0
  187. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/METADATA +20 -21
  188. codex_autorunner-1.0.0.dist-info/RECORD +251 -0
  189. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/WHEEL +1 -1
  190. codex_autorunner/core/doc_chat.py +0 -1415
  191. codex_autorunner/core/snapshot.py +0 -580
  192. codex_autorunner/integrations/github/chatops.py +0 -268
  193. codex_autorunner/integrations/github/pr_flow.py +0 -1314
  194. codex_autorunner/routes/docs.py +0 -381
  195. codex_autorunner/routes/github.py +0 -327
  196. codex_autorunner/routes/runs.py +0 -118
  197. codex_autorunner/spec_ingest.py +0 -788
  198. codex_autorunner/static/docChatActions.js +0 -279
  199. codex_autorunner/static/docChatEvents.js +0 -300
  200. codex_autorunner/static/docChatRender.js +0 -205
  201. codex_autorunner/static/docChatStream.js +0 -361
  202. codex_autorunner/static/docs.js +0 -20
  203. codex_autorunner/static/docsClipboard.js +0 -69
  204. codex_autorunner/static/docsCrud.js +0 -257
  205. codex_autorunner/static/docsDocUpdates.js +0 -62
  206. codex_autorunner/static/docsDrafts.js +0 -16
  207. codex_autorunner/static/docsElements.js +0 -69
  208. codex_autorunner/static/docsInit.js +0 -274
  209. codex_autorunner/static/docsParse.js +0 -160
  210. codex_autorunner/static/docsSnapshot.js +0 -87
  211. codex_autorunner/static/docsSpecIngest.js +0 -263
  212. codex_autorunner/static/docsState.js +0 -127
  213. codex_autorunner/static/docsThreadRegistry.js +0 -44
  214. codex_autorunner/static/docsUi.js +0 -153
  215. codex_autorunner/static/docsVoice.js +0 -56
  216. codex_autorunner/static/github.js +0 -442
  217. codex_autorunner/static/logs.js +0 -640
  218. codex_autorunner/static/runs.js +0 -409
  219. codex_autorunner/static/snapshot.js +0 -124
  220. codex_autorunner/static/state.js +0 -86
  221. codex_autorunner/static/todoPreview.js +0 -27
  222. codex_autorunner/workspace.py +0 -16
  223. codex_autorunner-0.1.1.dist-info/RECORD +0 -191
  224. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/entry_points.txt +0 -0
  225. {codex_autorunner-0.1.1.dist-info → codex_autorunner-1.0.0.dist-info}/licenses/LICENSE +0 -0
  226. {codex_autorunner-0.1.1.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
+ )