codex-autorunner 1.0.0__py3-none-any.whl → 1.2.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 (227) hide show
  1. codex_autorunner/__init__.py +12 -1
  2. codex_autorunner/agents/codex/harness.py +1 -1
  3. codex_autorunner/agents/opencode/client.py +113 -4
  4. codex_autorunner/agents/opencode/constants.py +3 -0
  5. codex_autorunner/agents/opencode/harness.py +6 -1
  6. codex_autorunner/agents/opencode/runtime.py +59 -18
  7. codex_autorunner/agents/opencode/supervisor.py +4 -0
  8. codex_autorunner/agents/registry.py +36 -7
  9. codex_autorunner/bootstrap.py +226 -4
  10. codex_autorunner/cli.py +5 -1174
  11. codex_autorunner/codex_cli.py +20 -84
  12. codex_autorunner/core/__init__.py +20 -0
  13. codex_autorunner/core/about_car.py +119 -1
  14. codex_autorunner/core/app_server_ids.py +59 -0
  15. codex_autorunner/core/app_server_threads.py +17 -2
  16. codex_autorunner/core/app_server_utils.py +165 -0
  17. codex_autorunner/core/archive.py +349 -0
  18. codex_autorunner/core/codex_runner.py +6 -2
  19. codex_autorunner/core/config.py +433 -4
  20. codex_autorunner/core/context_awareness.py +38 -0
  21. codex_autorunner/core/docs.py +0 -122
  22. codex_autorunner/core/drafts.py +58 -4
  23. codex_autorunner/core/exceptions.py +4 -0
  24. codex_autorunner/core/filebox.py +265 -0
  25. codex_autorunner/core/flows/controller.py +96 -2
  26. codex_autorunner/core/flows/models.py +13 -0
  27. codex_autorunner/core/flows/reasons.py +52 -0
  28. codex_autorunner/core/flows/reconciler.py +134 -0
  29. codex_autorunner/core/flows/runtime.py +57 -4
  30. codex_autorunner/core/flows/store.py +142 -7
  31. codex_autorunner/core/flows/transition.py +27 -15
  32. codex_autorunner/core/flows/ux_helpers.py +272 -0
  33. codex_autorunner/core/flows/worker_process.py +32 -6
  34. codex_autorunner/core/git_utils.py +62 -0
  35. codex_autorunner/core/hub.py +291 -20
  36. codex_autorunner/core/lifecycle_events.py +253 -0
  37. codex_autorunner/core/notifications.py +14 -2
  38. codex_autorunner/core/path_utils.py +2 -1
  39. codex_autorunner/core/pma_audit.py +224 -0
  40. codex_autorunner/core/pma_context.py +496 -0
  41. codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
  42. codex_autorunner/core/pma_lifecycle.py +527 -0
  43. codex_autorunner/core/pma_queue.py +367 -0
  44. codex_autorunner/core/pma_safety.py +221 -0
  45. codex_autorunner/core/pma_state.py +115 -0
  46. codex_autorunner/core/ports/__init__.py +28 -0
  47. codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
  48. codex_autorunner/core/ports/backend_orchestrator.py +41 -0
  49. codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
  50. codex_autorunner/core/prompt.py +0 -80
  51. codex_autorunner/core/prompts.py +56 -172
  52. codex_autorunner/core/redaction.py +0 -4
  53. codex_autorunner/core/review_context.py +11 -9
  54. codex_autorunner/core/runner_controller.py +35 -33
  55. codex_autorunner/core/runner_state.py +147 -0
  56. codex_autorunner/core/runtime.py +829 -0
  57. codex_autorunner/core/sqlite_utils.py +13 -4
  58. codex_autorunner/core/state.py +7 -10
  59. codex_autorunner/core/state_roots.py +62 -0
  60. codex_autorunner/core/supervisor_protocol.py +15 -0
  61. codex_autorunner/core/templates/__init__.py +39 -0
  62. codex_autorunner/core/templates/git_mirror.py +234 -0
  63. codex_autorunner/core/templates/provenance.py +56 -0
  64. codex_autorunner/core/templates/scan_cache.py +120 -0
  65. codex_autorunner/core/text_delta_coalescer.py +54 -0
  66. codex_autorunner/core/ticket_linter_cli.py +218 -0
  67. codex_autorunner/core/ticket_manager_cli.py +494 -0
  68. codex_autorunner/core/time_utils.py +11 -0
  69. codex_autorunner/core/types.py +18 -0
  70. codex_autorunner/core/update.py +4 -5
  71. codex_autorunner/core/update_paths.py +28 -0
  72. codex_autorunner/core/usage.py +164 -12
  73. codex_autorunner/core/utils.py +125 -15
  74. codex_autorunner/flows/review/__init__.py +17 -0
  75. codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
  76. codex_autorunner/flows/ticket_flow/definition.py +52 -3
  77. codex_autorunner/integrations/agents/__init__.py +11 -19
  78. codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
  79. codex_autorunner/integrations/agents/codex_adapter.py +90 -0
  80. codex_autorunner/integrations/agents/codex_backend.py +177 -25
  81. codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
  82. codex_autorunner/integrations/agents/opencode_backend.py +305 -32
  83. codex_autorunner/integrations/agents/runner.py +86 -0
  84. codex_autorunner/integrations/agents/wiring.py +279 -0
  85. codex_autorunner/integrations/app_server/client.py +7 -60
  86. codex_autorunner/integrations/app_server/env.py +2 -107
  87. codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
  88. codex_autorunner/integrations/telegram/adapter.py +65 -0
  89. codex_autorunner/integrations/telegram/config.py +46 -0
  90. codex_autorunner/integrations/telegram/constants.py +1 -1
  91. codex_autorunner/integrations/telegram/doctor.py +228 -6
  92. codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
  93. codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
  94. codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
  95. codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
  96. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
  97. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
  98. codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
  99. codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
  100. codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
  101. codex_autorunner/integrations/telegram/helpers.py +22 -1
  102. codex_autorunner/integrations/telegram/runtime.py +9 -4
  103. codex_autorunner/integrations/telegram/service.py +45 -10
  104. codex_autorunner/integrations/telegram/state.py +38 -0
  105. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
  106. codex_autorunner/integrations/telegram/transport.py +13 -4
  107. codex_autorunner/integrations/templates/__init__.py +27 -0
  108. codex_autorunner/integrations/templates/scan_agent.py +312 -0
  109. codex_autorunner/routes/__init__.py +37 -76
  110. codex_autorunner/routes/agents.py +2 -137
  111. codex_autorunner/routes/analytics.py +2 -238
  112. codex_autorunner/routes/app_server.py +2 -131
  113. codex_autorunner/routes/base.py +2 -596
  114. codex_autorunner/routes/file_chat.py +4 -833
  115. codex_autorunner/routes/flows.py +4 -977
  116. codex_autorunner/routes/messages.py +4 -456
  117. codex_autorunner/routes/repos.py +2 -196
  118. codex_autorunner/routes/review.py +2 -147
  119. codex_autorunner/routes/sessions.py +2 -175
  120. codex_autorunner/routes/settings.py +2 -168
  121. codex_autorunner/routes/shared.py +2 -275
  122. codex_autorunner/routes/system.py +4 -193
  123. codex_autorunner/routes/usage.py +2 -86
  124. codex_autorunner/routes/voice.py +2 -119
  125. codex_autorunner/routes/workspace.py +2 -270
  126. codex_autorunner/server.py +4 -4
  127. codex_autorunner/static/agentControls.js +61 -16
  128. codex_autorunner/static/app.js +126 -14
  129. codex_autorunner/static/archive.js +826 -0
  130. codex_autorunner/static/archiveApi.js +37 -0
  131. codex_autorunner/static/autoRefresh.js +7 -7
  132. codex_autorunner/static/chatUploads.js +137 -0
  133. codex_autorunner/static/dashboard.js +224 -171
  134. codex_autorunner/static/docChatCore.js +185 -13
  135. codex_autorunner/static/fileChat.js +68 -40
  136. codex_autorunner/static/fileboxUi.js +159 -0
  137. codex_autorunner/static/hub.js +114 -131
  138. codex_autorunner/static/index.html +375 -49
  139. codex_autorunner/static/messages.js +568 -87
  140. codex_autorunner/static/notifications.js +255 -0
  141. codex_autorunner/static/pma.js +1167 -0
  142. codex_autorunner/static/preserve.js +17 -0
  143. codex_autorunner/static/settings.js +128 -6
  144. codex_autorunner/static/smartRefresh.js +52 -0
  145. codex_autorunner/static/streamUtils.js +57 -0
  146. codex_autorunner/static/styles.css +9798 -6143
  147. codex_autorunner/static/tabs.js +152 -11
  148. codex_autorunner/static/templateReposSettings.js +225 -0
  149. codex_autorunner/static/terminal.js +18 -0
  150. codex_autorunner/static/ticketChatActions.js +165 -3
  151. codex_autorunner/static/ticketChatStream.js +17 -119
  152. codex_autorunner/static/ticketEditor.js +137 -15
  153. codex_autorunner/static/ticketTemplates.js +798 -0
  154. codex_autorunner/static/tickets.js +821 -98
  155. codex_autorunner/static/turnEvents.js +27 -0
  156. codex_autorunner/static/turnResume.js +33 -0
  157. codex_autorunner/static/utils.js +39 -0
  158. codex_autorunner/static/workspace.js +389 -82
  159. codex_autorunner/static/workspaceFileBrowser.js +15 -13
  160. codex_autorunner/surfaces/__init__.py +5 -0
  161. codex_autorunner/surfaces/cli/__init__.py +6 -0
  162. codex_autorunner/surfaces/cli/cli.py +2534 -0
  163. codex_autorunner/surfaces/cli/codex_cli.py +20 -0
  164. codex_autorunner/surfaces/cli/pma_cli.py +817 -0
  165. codex_autorunner/surfaces/telegram/__init__.py +3 -0
  166. codex_autorunner/surfaces/web/__init__.py +1 -0
  167. codex_autorunner/surfaces/web/app.py +2223 -0
  168. codex_autorunner/surfaces/web/hub_jobs.py +192 -0
  169. codex_autorunner/surfaces/web/middleware.py +587 -0
  170. codex_autorunner/surfaces/web/pty_session.py +370 -0
  171. codex_autorunner/surfaces/web/review.py +6 -0
  172. codex_autorunner/surfaces/web/routes/__init__.py +82 -0
  173. codex_autorunner/surfaces/web/routes/agents.py +138 -0
  174. codex_autorunner/surfaces/web/routes/analytics.py +284 -0
  175. codex_autorunner/surfaces/web/routes/app_server.py +132 -0
  176. codex_autorunner/surfaces/web/routes/archive.py +357 -0
  177. codex_autorunner/surfaces/web/routes/base.py +615 -0
  178. codex_autorunner/surfaces/web/routes/file_chat.py +1117 -0
  179. codex_autorunner/surfaces/web/routes/filebox.py +227 -0
  180. codex_autorunner/surfaces/web/routes/flows.py +1354 -0
  181. codex_autorunner/surfaces/web/routes/messages.py +490 -0
  182. codex_autorunner/surfaces/web/routes/pma.py +1652 -0
  183. codex_autorunner/surfaces/web/routes/repos.py +197 -0
  184. codex_autorunner/surfaces/web/routes/review.py +148 -0
  185. codex_autorunner/surfaces/web/routes/sessions.py +176 -0
  186. codex_autorunner/surfaces/web/routes/settings.py +169 -0
  187. codex_autorunner/surfaces/web/routes/shared.py +277 -0
  188. codex_autorunner/surfaces/web/routes/system.py +196 -0
  189. codex_autorunner/surfaces/web/routes/templates.py +634 -0
  190. codex_autorunner/surfaces/web/routes/usage.py +89 -0
  191. codex_autorunner/surfaces/web/routes/voice.py +120 -0
  192. codex_autorunner/surfaces/web/routes/workspace.py +271 -0
  193. codex_autorunner/surfaces/web/runner_manager.py +25 -0
  194. codex_autorunner/surfaces/web/schemas.py +469 -0
  195. codex_autorunner/surfaces/web/static_assets.py +490 -0
  196. codex_autorunner/surfaces/web/static_refresh.py +86 -0
  197. codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
  198. codex_autorunner/tickets/__init__.py +8 -1
  199. codex_autorunner/tickets/agent_pool.py +53 -4
  200. codex_autorunner/tickets/files.py +37 -16
  201. codex_autorunner/tickets/lint.py +50 -0
  202. codex_autorunner/tickets/models.py +6 -1
  203. codex_autorunner/tickets/outbox.py +50 -2
  204. codex_autorunner/tickets/runner.py +396 -57
  205. codex_autorunner/web/__init__.py +5 -1
  206. codex_autorunner/web/app.py +2 -1949
  207. codex_autorunner/web/hub_jobs.py +2 -191
  208. codex_autorunner/web/middleware.py +2 -586
  209. codex_autorunner/web/pty_session.py +2 -369
  210. codex_autorunner/web/runner_manager.py +2 -24
  211. codex_autorunner/web/schemas.py +2 -376
  212. codex_autorunner/web/static_assets.py +4 -441
  213. codex_autorunner/web/static_refresh.py +2 -85
  214. codex_autorunner/web/terminal_sessions.py +2 -77
  215. codex_autorunner/workspace/paths.py +49 -33
  216. codex_autorunner-1.2.0.dist-info/METADATA +150 -0
  217. codex_autorunner-1.2.0.dist-info/RECORD +339 -0
  218. codex_autorunner/core/adapter_utils.py +0 -21
  219. codex_autorunner/core/engine.py +0 -2653
  220. codex_autorunner/core/static_assets.py +0 -55
  221. codex_autorunner-1.0.0.dist-info/METADATA +0 -246
  222. codex_autorunner-1.0.0.dist-info/RECORD +0 -251
  223. /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
  224. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
  225. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
  226. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
  227. {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,38 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Literal
4
+
5
+ CAR_AWARENESS_BLOCK = """<injected context>
6
+ You are operating inside a Codex Autorunner (CAR) managed repo.
7
+
8
+ CAR’s durable control-plane lives under `.codex-autorunner/`:
9
+ - `.codex-autorunner/ABOUT_CAR.md` — short repo-local briefing (ticket/workspace conventions + helper scripts).
10
+ - `.codex-autorunner/tickets/` — ordered ticket queue (`TICKET-###*.md`) used by the ticket flow runner.
11
+ - `.codex-autorunner/workspace/` — shared context docs:
12
+ - `active_context.md` — current “north star” context; kept fresh for ongoing work.
13
+ - `spec.md` — longer spec / acceptance criteria when needed.
14
+ - `decisions.md` — prior decisions / tradeoffs when relevant.
15
+
16
+ Intent signals: if the user mentions tickets, “dispatch”, “resume”, workspace docs, or `.codex-autorunner/`, they are likely referring to CAR artifacts/workflow rather than generic repo files.
17
+
18
+ Use the above as orientation. If you need the operational details (exact helper commands, what CAR auto-generates), read `.codex-autorunner/ABOUT_CAR.md`.
19
+ </injected context>"""
20
+
21
+ ROLE_ADDENDUM_START = "<role addendum>"
22
+ ROLE_ADDENDUM_END = "</role addendum>"
23
+
24
+
25
+ def format_file_role_addendum(
26
+ kind: Literal["ticket", "workspace", "other"],
27
+ rel_path: str,
28
+ ) -> str:
29
+ """Format a short role-specific addendum for prompts."""
30
+ if kind == "ticket":
31
+ text = f"This target is a CAR ticket at `{rel_path}`."
32
+ elif kind == "workspace":
33
+ text = f"This target is a CAR workspace doc at `{rel_path}`."
34
+ elif kind == "other":
35
+ text = f"This target file is `{rel_path}`."
36
+ else:
37
+ raise ValueError(f"Unsupported role addendum kind: {kind}")
38
+ return f"{ROLE_ADDENDUM_START}\n{text}\n{ROLE_ADDENDUM_END}"
@@ -1,122 +0,0 @@
1
- import re
2
- from pathlib import Path
3
- from typing import List, Tuple
4
-
5
- from .config import Config
6
-
7
- _TODO_LINE_RE = re.compile(r"^\s*[-*]\s*\[(?P<state>[ xX])\]\s*(?P<text>.*)$")
8
-
9
-
10
- def _iter_meaningful_lines(content: str):
11
- in_code_fence = False
12
- in_html_comment = False
13
- html_comment_pattern = re.compile(r"<!--.*?-->", re.DOTALL)
14
-
15
- for line in content.splitlines():
16
- stripped = line.strip()
17
-
18
- if stripped.startswith("```"):
19
- in_code_fence = not in_code_fence
20
- continue
21
-
22
- if in_code_fence:
23
- continue
24
-
25
- if line.lstrip().startswith("<!--"):
26
- if "-->" in line:
27
- if html_comment_pattern.search(line):
28
- continue
29
- else:
30
- in_html_comment = True
31
- continue
32
-
33
- if in_html_comment:
34
- if "-->" in line:
35
- in_html_comment = False
36
- continue
37
-
38
- yield line
39
-
40
-
41
- def parse_todos(content: str) -> Tuple[List[str], List[str]]:
42
- outstanding: List[str] = []
43
- done: List[str] = []
44
- if not content:
45
- return outstanding, done
46
-
47
- for line in _iter_meaningful_lines(content):
48
- match = _TODO_LINE_RE.match(line)
49
- if match:
50
- state = match.group("state")
51
- text = match.group("text").strip()
52
- if state in (" ",):
53
- outstanding.append(text)
54
- elif state in ("x", "X"):
55
- done.append(text)
56
- return outstanding, done
57
-
58
-
59
- _TODO_CHECKBOX_RE = re.compile(r"^\s*[-*]\s*\[(?P<state>[ xX])\]\s+\S")
60
- _TODO_BULLET_RE = re.compile(r"^\s*[-*]\s+")
61
-
62
-
63
- def validate_todo_markdown(content: str) -> List[str]:
64
- """
65
- Validate that TODO content contains tasks as markdown checkboxes.
66
-
67
- Rules:
68
- - If the file has any non-heading, non-empty content, it must include at least one checkbox line.
69
- - Any bullet line must be a checkbox bullet (no plain '-' bullets for tasks).
70
- """
71
- errors: List[str] = []
72
- if content is None:
73
- return ["TODO is missing"]
74
- lines = list(_iter_meaningful_lines(content))
75
- meaningful = [
76
- line for line in lines if line.strip() and not line.lstrip().startswith("#")
77
- ]
78
- if not meaningful:
79
- return []
80
- checkbox_lines = [line for line in meaningful if _TODO_CHECKBOX_RE.match(line)]
81
- if not checkbox_lines:
82
- errors.append(
83
- "TODO must contain at least one markdown checkbox task line like `- [ ] ...`."
84
- )
85
- bullet_lines = [line for line in meaningful if _TODO_BULLET_RE.match(line)]
86
- non_checkbox_bullets = [
87
- line for line in bullet_lines if not _TODO_CHECKBOX_RE.match(line)
88
- ]
89
- if non_checkbox_bullets:
90
- sample = non_checkbox_bullets[0].strip()
91
- errors.append(
92
- "TODO contains non-checkbox bullet(s); use `- [ ] ...` instead. "
93
- f"Example: `{sample}`"
94
- )
95
- return errors
96
-
97
-
98
- class DocsManager:
99
- def __init__(self, config: Config):
100
- self.config = config
101
-
102
- def read_doc(self, key: str) -> str:
103
- try:
104
- path = self.config.doc_path(key)
105
- except KeyError:
106
- return ""
107
- return path.read_text(encoding="utf-8") if path.exists() else ""
108
-
109
- def todos(self) -> Tuple[List[str], List[str]]:
110
- # Legacy helper retained for backward compatibility; newer configs may not
111
- # have a TODO doc at all.
112
- try:
113
- todo_path: Path = self.config.doc_path("todo")
114
- except KeyError:
115
- return [], []
116
- if not todo_path.exists():
117
- return [], []
118
- return parse_todos(todo_path.read_text(encoding="utf-8"))
119
-
120
- def todos_done(self) -> bool:
121
- outstanding, _ = self.todos()
122
- return len(outstanding) == 0
@@ -2,12 +2,18 @@ from __future__ import annotations
2
2
 
3
3
  import hashlib
4
4
  import json
5
+ import logging
6
+ from datetime import datetime, timezone
5
7
  from pathlib import Path
6
8
  from typing import Any, Dict, Optional
7
9
 
8
10
  from .utils import atomic_write
9
11
 
10
12
  FILE_CHAT_STATE_NAME = "file_chat_state.json"
13
+ FILE_CHAT_STATE_CORRUPT_SUFFIX = ".corrupt"
14
+ FILE_CHAT_STATE_NOTICE_SUFFIX = ".corrupt.json"
15
+
16
+ logger = logging.getLogger(__name__)
11
17
 
12
18
 
13
19
  def state_path(repo_root: Path) -> Path:
@@ -23,12 +29,19 @@ def load_state(repo_root: Path) -> Dict[str, Any]:
23
29
  if not path.exists():
24
30
  return {"drafts": {}}
25
31
  try:
26
- data = json.loads(path.read_text(encoding="utf-8"))
27
- if isinstance(data, dict):
28
- return data
32
+ raw = path.read_text(encoding="utf-8")
33
+ except OSError as exc:
34
+ logger.warning("Failed to read file chat state at %s: %s", path, exc)
29
35
  return {"drafts": {}}
30
- except Exception:
36
+ try:
37
+ data = json.loads(raw)
38
+ except json.JSONDecodeError as exc:
39
+ _handle_corrupt_state(path, str(exc))
40
+ return {"drafts": {}}
41
+ if not isinstance(data, dict):
42
+ _handle_corrupt_state(path, f"Expected JSON object, got {type(data).__name__}")
31
43
  return {"drafts": {}}
44
+ return data
32
45
 
33
46
 
34
47
  def save_state(repo_root: Path, state: Dict[str, Any]) -> None:
@@ -80,3 +93,44 @@ def invalidate_drafts_for_path(repo_root: Path, rel_path: str) -> list[str]:
80
93
  if removed_keys:
81
94
  save_drafts(repo_root, drafts)
82
95
  return removed_keys
96
+
97
+
98
+ def _stamp() -> str:
99
+ return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
100
+
101
+
102
+ def _notice_path(path: Path) -> Path:
103
+ return path.with_name(f"{path.name}{FILE_CHAT_STATE_NOTICE_SUFFIX}")
104
+
105
+
106
+ def _handle_corrupt_state(path: Path, detail: str) -> None:
107
+ stamp = _stamp()
108
+ backup_path = path.with_name(f"{path.name}{FILE_CHAT_STATE_CORRUPT_SUFFIX}.{stamp}")
109
+ path.parent.mkdir(parents=True, exist_ok=True)
110
+ try:
111
+ path.replace(backup_path)
112
+ backup_value = str(backup_path)
113
+ except OSError:
114
+ backup_value = ""
115
+ notice = {
116
+ "status": "corrupt",
117
+ "message": "Draft state reset due to corrupted file_chat_state.json.",
118
+ "detail": detail,
119
+ "detected_at": stamp,
120
+ "backup_path": backup_value,
121
+ }
122
+ notice_path = _notice_path(path)
123
+ try:
124
+ atomic_write(notice_path, json.dumps(notice, indent=2) + "\n")
125
+ except Exception:
126
+ logger.warning("Failed to write draft corruption notice at %s", notice_path)
127
+ try:
128
+ atomic_write(path, json.dumps({"drafts": {}}, indent=2) + "\n")
129
+ except Exception:
130
+ logger.warning("Failed to reset draft state at %s", path)
131
+ logger.warning(
132
+ "Corrupted file chat state detected; backup=%s notice=%s detail=%s",
133
+ backup_value or "unavailable",
134
+ notice_path,
135
+ detail,
136
+ )
@@ -58,3 +58,7 @@ class CircuitOpenError(CriticalError):
58
58
  msg,
59
59
  user_message=f"{service_name} is temporarily unavailable. Please try again later.",
60
60
  )
61
+
62
+
63
+ class AppServerError(CodexError):
64
+ pass
@@ -0,0 +1,265 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+ from typing import Dict, Iterable, List, Tuple
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class FileBoxEntry:
12
+ name: str
13
+ box: str # "inbox" | "outbox"
14
+ size: int | None
15
+ modified_at: str | None
16
+ source: str # "filebox", "pma", "telegram"
17
+ path: Path
18
+
19
+
20
+ BOXES = ("inbox", "outbox")
21
+
22
+
23
+ def filebox_root(repo_root: Path) -> Path:
24
+ return Path(repo_root) / ".codex-autorunner" / "filebox"
25
+
26
+
27
+ def inbox_dir(repo_root: Path) -> Path:
28
+ return filebox_root(repo_root) / "inbox"
29
+
30
+
31
+ def outbox_dir(repo_root: Path) -> Path:
32
+ return filebox_root(repo_root) / "outbox"
33
+
34
+
35
+ def outbox_pending_dir(repo_root: Path) -> Path:
36
+ # Preserves Telegram pending semantics while keeping everything under the shared FileBox.
37
+ return outbox_dir(repo_root) / "pending"
38
+
39
+
40
+ def outbox_sent_dir(repo_root: Path) -> Path:
41
+ return outbox_dir(repo_root) / "sent"
42
+
43
+
44
+ def ensure_structure(repo_root: Path) -> None:
45
+ for path in (
46
+ inbox_dir(repo_root),
47
+ outbox_dir(repo_root),
48
+ outbox_pending_dir(repo_root),
49
+ outbox_sent_dir(repo_root),
50
+ ):
51
+ path.mkdir(parents=True, exist_ok=True)
52
+
53
+
54
+ def sanitize_filename(name: str) -> str:
55
+ base = Path(name or "").name
56
+ if not base or base in {".", ".."}:
57
+ raise ValueError("Missing filename")
58
+ # Reject any path separators or traversal segments up-front.
59
+ if name != base or "/" in name or "\\" in name:
60
+ raise ValueError("Invalid filename")
61
+ parts = Path(base).parts
62
+ if any(part in {"", ".", ".."} for part in parts):
63
+ raise ValueError("Invalid filename")
64
+ return base
65
+
66
+
67
+ def _legacy_paths(repo_root: Path, box: str) -> List[Tuple[str, Path]]:
68
+ root = Path(repo_root)
69
+ paths: List[Tuple[str, Path]] = []
70
+ if box not in BOXES:
71
+ return paths
72
+
73
+ # PMA legacy paths
74
+ pma_dir = root / ".codex-autorunner" / "pma" / box
75
+ paths.append(("pma", pma_dir))
76
+
77
+ # Telegram legacy paths (topic-scoped). We merge inbox and outbox/pending|sent.
78
+ telegram_root = root / ".codex-autorunner" / "uploads" / "telegram-files"
79
+ if telegram_root.exists():
80
+ for topic in telegram_root.iterdir():
81
+ if not topic.is_dir():
82
+ continue
83
+ if box == "inbox":
84
+ paths.append(("telegram", topic / "inbox"))
85
+ elif box == "outbox":
86
+ paths.append(("telegram", topic / "outbox" / "pending"))
87
+ paths.append(("telegram", topic / "outbox" / "sent"))
88
+ return paths
89
+
90
+
91
+ def _gather_files(entries: Iterable[Tuple[str, Path]], box: str) -> List[FileBoxEntry]:
92
+ collected: List[FileBoxEntry] = []
93
+ for source, folder in entries:
94
+ if not folder.exists():
95
+ continue
96
+ try:
97
+ for path in folder.iterdir():
98
+ try:
99
+ if not path.is_file():
100
+ continue
101
+ stat = path.stat()
102
+ collected.append(
103
+ FileBoxEntry(
104
+ name=path.name,
105
+ box=box,
106
+ size=stat.st_size if stat else None,
107
+ modified_at=_format_mtime(stat.st_mtime) if stat else None,
108
+ source=source,
109
+ path=path,
110
+ )
111
+ )
112
+ except OSError:
113
+ continue
114
+ except OSError:
115
+ continue
116
+ return collected
117
+
118
+
119
+ def _dedupe(entries: List[FileBoxEntry]) -> List[FileBoxEntry]:
120
+ # Prefer primary filebox entries over legacy duplicates.
121
+ deduped: Dict[Tuple[str, str], FileBoxEntry] = {}
122
+ for entry in entries:
123
+ key = (entry.box, entry.name)
124
+ existing = deduped.get(key)
125
+ if existing is None:
126
+ deduped[key] = entry
127
+ continue
128
+ if existing.source != "filebox" and entry.source == "filebox":
129
+ deduped[key] = entry
130
+ return list(deduped.values())
131
+
132
+
133
+ def _format_mtime(ts: float | None) -> str | None:
134
+ if ts is None:
135
+ return None
136
+ try:
137
+ return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat()
138
+ except Exception:
139
+ return None
140
+
141
+
142
+ def list_filebox(
143
+ repo_root: Path, *, include_legacy: bool = True
144
+ ) -> Dict[str, List[FileBoxEntry]]:
145
+ ensure_structure(repo_root)
146
+ results: Dict[str, List[FileBoxEntry]] = {}
147
+ for box in BOXES:
148
+ primaries = _gather_files([("filebox", _box_dir(repo_root, box))], box)
149
+ legacy = (
150
+ _gather_files(_legacy_paths(repo_root, box), box) if include_legacy else []
151
+ )
152
+ results[box] = _dedupe(primaries + legacy)
153
+ return results
154
+
155
+
156
+ def _box_dir(repo_root: Path, box: str) -> Path:
157
+ if box == "inbox":
158
+ return inbox_dir(repo_root)
159
+ if box == "outbox":
160
+ return outbox_dir(repo_root)
161
+ raise ValueError("Invalid filebox")
162
+
163
+
164
+ def _target_path(repo_root: Path, box: str, filename: str) -> Path:
165
+ """Return a resolved path within the FileBox, rejecting traversal attempts."""
166
+
167
+ safe_name = sanitize_filename(filename)
168
+ target_dir = _box_dir(repo_root, box)
169
+ target_dir.mkdir(parents=True, exist_ok=True)
170
+
171
+ root = target_dir.resolve()
172
+ candidate = (root / safe_name).resolve()
173
+ try:
174
+ candidate.relative_to(root)
175
+ except ValueError as exc:
176
+ raise ValueError("Invalid filename") from exc
177
+ if candidate.parent != root:
178
+ # Disallow sneaky path tricks that resolve inside nested folders.
179
+ raise ValueError("Invalid filename")
180
+ return candidate
181
+
182
+
183
+ def save_file(repo_root: Path, box: str, filename: str, data: bytes) -> Path:
184
+ if box not in BOXES:
185
+ raise ValueError("Invalid box")
186
+ ensure_structure(repo_root)
187
+ path = _target_path(repo_root, box, filename)
188
+ path.write_bytes(data)
189
+ return path
190
+
191
+
192
+ def resolve_file(repo_root: Path, box: str, filename: str) -> FileBoxEntry | None:
193
+ if box not in BOXES:
194
+ return None
195
+ safe_name = sanitize_filename(filename)
196
+ paths: List[Tuple[str, Path]] = [("filebox", _box_dir(repo_root, box))]
197
+ paths.extend(_legacy_paths(repo_root, box))
198
+ candidates = _gather_files(paths, box)
199
+ for entry in candidates:
200
+ if entry.name == safe_name:
201
+ return entry
202
+ return None
203
+
204
+
205
+ def delete_file(repo_root: Path, box: str, filename: str) -> bool:
206
+ if box not in BOXES:
207
+ return False
208
+ safe_name = sanitize_filename(filename)
209
+ paths: List[Tuple[str, Path]] = [("filebox", _box_dir(repo_root, box))]
210
+ paths.extend(_legacy_paths(repo_root, box))
211
+ candidates = _gather_files(paths, box)
212
+ removed = False
213
+ for entry in candidates:
214
+ if entry.name != safe_name:
215
+ continue
216
+ try:
217
+ entry.path.unlink()
218
+ removed = True
219
+ except OSError:
220
+ continue
221
+ return removed
222
+
223
+
224
+ def migrate_legacy(repo_root: Path) -> int:
225
+ """
226
+ Opportunistically copy legacy PMA/Telegram files into the shared FileBox.
227
+ Returns the number of files copied.
228
+ """
229
+ copied = 0
230
+ ensure_structure(repo_root)
231
+ for box in BOXES:
232
+ target_dir = _box_dir(repo_root, box)
233
+ target_dir.mkdir(parents=True, exist_ok=True)
234
+ for _source, folder in _legacy_paths(repo_root, box):
235
+ if not folder.exists():
236
+ continue
237
+ for path in folder.iterdir():
238
+ try:
239
+ if not path.is_file():
240
+ continue
241
+ dest = target_dir / path.name
242
+ if dest.exists():
243
+ continue
244
+ shutil.copy2(path, dest)
245
+ copied += 1
246
+ except OSError:
247
+ continue
248
+ return copied
249
+
250
+
251
+ __all__ = [
252
+ "BOXES",
253
+ "FileBoxEntry",
254
+ "delete_file",
255
+ "filebox_root",
256
+ "inbox_dir",
257
+ "list_filebox",
258
+ "migrate_legacy",
259
+ "outbox_dir",
260
+ "outbox_pending_dir",
261
+ "outbox_sent_dir",
262
+ "resolve_file",
263
+ "sanitize_filename",
264
+ "save_file",
265
+ ]
@@ -4,11 +4,31 @@ import uuid
4
4
  from pathlib import Path
5
5
  from typing import Any, AsyncGenerator, Callable, Dict, Optional, Set
6
6
 
7
+ from ..lifecycle_events import LifecycleEventEmitter
8
+ from ..utils import find_repo_root
7
9
  from .definition import FlowDefinition
8
10
  from .models import FlowEvent, FlowRunRecord, FlowRunStatus
9
11
  from .runtime import FlowRuntime
10
12
  from .store import FlowStore
11
13
 
14
+
15
+ def _find_hub_root(repo_root: Optional[Path] = None) -> Optional[Path]:
16
+ if repo_root is None:
17
+ repo_root = find_repo_root()
18
+ if repo_root is None:
19
+ return None
20
+ current = repo_root
21
+ for _ in range(5):
22
+ manifest_path = current / ".codex-autorunner" / "manifest.yml"
23
+ if manifest_path.exists():
24
+ return current
25
+ parent = current.parent
26
+ if parent == current:
27
+ break
28
+ current = parent
29
+ return None
30
+
31
+
12
32
  _logger = logging.getLogger(__name__)
13
33
 
14
34
 
@@ -18,13 +38,24 @@ class FlowController:
18
38
  definition: FlowDefinition,
19
39
  db_path: Path,
20
40
  artifacts_root: Path,
41
+ durable: bool = False,
42
+ hub_root: Optional[Path] = None,
21
43
  ):
22
44
  self.definition = definition
23
45
  self.db_path = db_path
24
46
  self.artifacts_root = artifacts_root
25
- self.store = FlowStore(db_path)
47
+ self.store = FlowStore(db_path, durable=durable)
26
48
  self._event_listeners: Set[Callable[[FlowEvent], None]] = set()
49
+ self._lifecycle_event_listeners: Set[Callable[[str, str, str, dict], None]] = (
50
+ set()
51
+ )
27
52
  self._lock = asyncio.Lock()
53
+ self._lifecycle_emitter: Optional[LifecycleEventEmitter] = None
54
+ if hub_root is None:
55
+ hub_root = _find_hub_root(db_path.parent.parent if db_path else None)
56
+ if hub_root is not None:
57
+ self._lifecycle_emitter = LifecycleEventEmitter(hub_root)
58
+ self.add_lifecycle_event_listener(self._emit_to_lifecycle_store)
28
59
 
29
60
  def initialize(self) -> None:
30
61
  self.artifacts_root.mkdir(parents=True, exist_ok=True)
@@ -70,6 +101,7 @@ class FlowController:
70
101
  definition=self.definition,
71
102
  store=self.store,
72
103
  emit_event=self._emit_event,
104
+ emit_lifecycle_event=self._emit_lifecycle,
73
105
  )
74
106
  return await runtime.run_flow(run_id=run_id, initial_state=initial_state)
75
107
 
@@ -103,7 +135,33 @@ class FlowController:
103
135
  cleared = self.store.set_stop_requested(run_id, False)
104
136
  if not cleared:
105
137
  raise RuntimeError(f"Failed to clear stop flag for run {run_id}")
106
- return cleared
138
+ if record.status == FlowRunStatus.COMPLETED:
139
+ return cleared
140
+ state = dict(record.state or {})
141
+ engine = state.get("ticket_engine")
142
+ if isinstance(engine, dict):
143
+ engine = dict(engine)
144
+ if engine.get("reason_code") == "max_turns":
145
+ engine["total_turns"] = 0
146
+ engine["status"] = "running"
147
+ engine.pop("reason", None)
148
+ engine.pop("reason_details", None)
149
+ engine.pop("reason_code", None)
150
+ state["ticket_engine"] = engine
151
+ state.pop("reason_summary", None)
152
+
153
+ updated = self.store.update_flow_run_status(
154
+ run_id=run_id,
155
+ status=FlowRunStatus.RUNNING,
156
+ state=state,
157
+ )
158
+ if updated:
159
+ return updated
160
+
161
+ updated = self.store.get_flow_run(run_id)
162
+ if not updated:
163
+ raise RuntimeError(f"Failed to get record for run {run_id}")
164
+ return updated
107
165
 
108
166
  def get_status(self, run_id: str) -> Optional[FlowRunRecord]:
109
167
  return self.store.get_flow_run(run_id)
@@ -150,6 +208,42 @@ class FlowController:
150
208
  def remove_event_listener(self, listener: Callable[[FlowEvent], None]) -> None:
151
209
  self._event_listeners.discard(listener)
152
210
 
211
+ def add_lifecycle_event_listener(
212
+ self, listener: Callable[[str, str, str, dict], None]
213
+ ) -> None:
214
+ self._lifecycle_event_listeners.add(listener)
215
+
216
+ def remove_lifecycle_event_listener(
217
+ self, listener: Callable[[str, str, str, dict], None]
218
+ ) -> None:
219
+ self._lifecycle_event_listeners.discard(listener)
220
+
221
+ def _emit_lifecycle(
222
+ self, event_type: str, repo_id: str, run_id: str, data: Dict[str, Any]
223
+ ) -> None:
224
+ for listener in self._lifecycle_event_listeners:
225
+ try:
226
+ listener(event_type, repo_id, run_id, data)
227
+ except Exception as e:
228
+ _logger.exception("Error in lifecycle event listener: %s", e)
229
+
230
+ def _emit_to_lifecycle_store(
231
+ self, event_type: str, repo_id: str, run_id: str, data: Dict[str, Any]
232
+ ) -> None:
233
+ if self._lifecycle_emitter is None:
234
+ return
235
+ try:
236
+ if event_type == "flow_paused":
237
+ self._lifecycle_emitter.emit_flow_paused(repo_id, run_id, data=data)
238
+ elif event_type == "flow_completed":
239
+ self._lifecycle_emitter.emit_flow_completed(repo_id, run_id, data=data)
240
+ elif event_type == "flow_failed":
241
+ self._lifecycle_emitter.emit_flow_failed(repo_id, run_id, data=data)
242
+ elif event_type == "flow_stopped":
243
+ self._lifecycle_emitter.emit_flow_stopped(repo_id, run_id, data=data)
244
+ except Exception as exc:
245
+ _logger.exception("Error emitting to lifecycle store: %s", exc)
246
+
153
247
  def _emit_event(self, event: FlowEvent) -> None:
154
248
  for listener in self._event_listeners:
155
249
  try:
@@ -32,13 +32,26 @@ class FlowEventType(str, Enum):
32
32
  STEP_COMPLETED = "step_completed"
33
33
  STEP_FAILED = "step_failed"
34
34
  AGENT_STREAM_DELTA = "agent_stream_delta"
35
+ AGENT_MESSAGE_COMPLETE = "agent_message_complete"
36
+ AGENT_FAILED = "agent_failed"
35
37
  APP_SERVER_EVENT = "app_server_event"
38
+ TOOL_CALL = "tool_call"
39
+ TOOL_RESULT = "tool_result"
40
+ APPROVAL_REQUESTED = "approval_requested"
36
41
  TOKEN_USAGE = "token_usage"
37
42
  FLOW_STARTED = "flow_started"
38
43
  FLOW_STOPPED = "flow_stopped"
39
44
  FLOW_RESUMED = "flow_resumed"
40
45
  FLOW_COMPLETED = "flow_completed"
41
46
  FLOW_FAILED = "flow_failed"
47
+ RUN_STARTED = "run_started"
48
+ RUN_FINISHED = "run_finished"
49
+ RUN_STATE_CHANGED = "run_state_changed"
50
+ RUN_NO_PROGRESS = "run_no_progress"
51
+ PLAN_UPDATED = "plan_updated"
52
+ DIFF_UPDATED = "diff_updated"
53
+ RUN_TIMEOUT = "run_timeout"
54
+ RUN_CANCELLED = "run_cancelled"
42
55
 
43
56
 
44
57
  class FlowRunRecord(BaseModel):