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,490 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ import shutil
7
+ import time
8
+ from contextlib import ExitStack
9
+ from importlib import resources
10
+ from pathlib import Path
11
+ from typing import Iterable, Optional
12
+ from uuid import uuid4
13
+
14
+ from ...core.logging_utils import safe_log
15
+
16
+ _ASSET_VERSION_TOKEN = "__CAR_ASSET_VERSION__"
17
+ _REQUIRED_STATIC_ASSETS = (
18
+ "index.html",
19
+ "styles.css",
20
+ "bootstrap.js",
21
+ "loader.js",
22
+ "app.js",
23
+ "vendor/xterm.js",
24
+ "vendor/xterm-addon-fit.js",
25
+ "vendor/xterm.css",
26
+ )
27
+
28
+
29
+ def missing_static_assets(static_dir: Path) -> list[str]:
30
+ missing: list[str] = []
31
+ for rel_path in _REQUIRED_STATIC_ASSETS:
32
+ try:
33
+ if not (static_dir / rel_path).exists():
34
+ missing.append(rel_path)
35
+ except OSError:
36
+ missing.append(rel_path)
37
+ return missing
38
+
39
+
40
+ def resolve_static_dir() -> tuple[Path, Optional[ExitStack]]:
41
+ """Locate packaged static assets."""
42
+
43
+ static_root = resources.files("codex_autorunner").joinpath("static")
44
+ if isinstance(static_root, Path):
45
+ if static_root.exists():
46
+ return static_root, None
47
+ fallback = Path(__file__).resolve().parent.parent / "static"
48
+ return fallback, None
49
+
50
+ stack = ExitStack()
51
+ try:
52
+ static_path = stack.enter_context(resources.as_file(static_root))
53
+ except Exception:
54
+ stack.close()
55
+ fallback = Path(__file__).resolve().parent.parent / "static"
56
+ return fallback, None
57
+ if static_path.exists():
58
+ return static_path, stack
59
+
60
+ stack.close()
61
+ fallback = Path(__file__).resolve().parent.parent / "static"
62
+ return fallback, None
63
+
64
+
65
+ def _iter_static_source_files(source_dir: Path) -> Iterable[Path]:
66
+ try:
67
+ for path in source_dir.rglob("*.ts"):
68
+ try:
69
+ if path.name.endswith(".d.ts"):
70
+ continue
71
+ if path.is_dir():
72
+ continue
73
+ yield path
74
+ except OSError:
75
+ continue
76
+ except Exception:
77
+ return
78
+
79
+
80
+ def _stale_static_sources(source_dir: Path, static_dir: Path) -> list[str]:
81
+ stale: list[str] = []
82
+ for source in _iter_static_source_files(source_dir):
83
+ try:
84
+ rel_path = source.relative_to(source_dir)
85
+ except ValueError:
86
+ rel_path = Path(source.name)
87
+ target = (static_dir / rel_path).with_suffix(".js")
88
+ try:
89
+ source_mtime = source.stat().st_mtime
90
+ except OSError:
91
+ continue
92
+ try:
93
+ target_mtime = target.stat().st_mtime
94
+ except OSError:
95
+ stale.append(rel_path.as_posix())
96
+ continue
97
+ if source_mtime > target_mtime:
98
+ stale.append(rel_path.as_posix())
99
+ return stale
100
+
101
+
102
+ def warn_on_stale_static_assets(static_dir: Path, logger: logging.Logger) -> None:
103
+ source_dir = Path(__file__).resolve().parent.parent / "static_src"
104
+ if not source_dir.exists():
105
+ return
106
+ stale = _stale_static_sources(source_dir, static_dir)
107
+ if not stale:
108
+ return
109
+ preview = ", ".join(stale[:5])
110
+ suffix = f" (+{len(stale) - 5} more)" if len(stale) > 5 else ""
111
+ safe_log(
112
+ logger,
113
+ logging.WARNING,
114
+ "Static assets appear stale; run `pnpm run build`. Newer sources: %s%s",
115
+ preview,
116
+ suffix,
117
+ )
118
+
119
+
120
+ def _iter_asset_files(static_dir: Path) -> Iterable[Path]:
121
+ try:
122
+ for path in static_dir.rglob("*"):
123
+ try:
124
+ if path.is_dir():
125
+ continue
126
+ yield path
127
+ except OSError:
128
+ continue
129
+ except Exception:
130
+ return
131
+
132
+
133
+ def _hash_file(path: Path, digest: "hashlib._Hash") -> None:
134
+ try:
135
+ if path.is_symlink():
136
+ digest.update(b"SYMLINK:")
137
+ try:
138
+ target = path.readlink()
139
+ except OSError:
140
+ target = Path("dangling")
141
+ digest.update(str(target).encode("utf-8", errors="replace"))
142
+ return
143
+ with path.open("rb") as handle:
144
+ while True:
145
+ chunk = handle.read(1024 * 1024)
146
+ if not chunk:
147
+ break
148
+ digest.update(chunk)
149
+ except OSError:
150
+ digest.update(b"UNREADABLE")
151
+
152
+
153
+ def asset_version(static_dir: Path) -> str:
154
+ digest = hashlib.sha256()
155
+ files = sorted(_iter_asset_files(static_dir), key=lambda p: p.as_posix())
156
+ if not files:
157
+ return "0"
158
+ for path in files:
159
+ try:
160
+ rel_path = path.relative_to(static_dir)
161
+ except ValueError:
162
+ rel_path = path
163
+ digest.update(rel_path.as_posix().encode("utf-8", errors="replace"))
164
+ _hash_file(path, digest)
165
+ return digest.hexdigest()
166
+
167
+
168
+ def render_index_html(static_dir: Path, version: Optional[str]) -> str:
169
+ index_path = static_dir / "index.html"
170
+ text = index_path.read_text(encoding="utf-8")
171
+ if version:
172
+ text = text.replace(_ASSET_VERSION_TOKEN, version)
173
+ return text
174
+
175
+
176
+ def security_headers() -> dict[str, str]:
177
+ # CSP: scripts are all local with no inline JS; runtime UI uses inline styles.
178
+ return {
179
+ "Content-Security-Policy": (
180
+ "default-src 'self'; "
181
+ "script-src 'self'; "
182
+ "style-src 'self' 'unsafe-inline'; "
183
+ "img-src 'self' data:; "
184
+ "font-src 'self' data:; "
185
+ "connect-src 'self' ws: wss:; "
186
+ "frame-ancestors 'none'"
187
+ ),
188
+ "Referrer-Policy": "same-origin",
189
+ "X-Content-Type-Options": "nosniff",
190
+ "X-Frame-Options": "DENY",
191
+ }
192
+
193
+
194
+ def index_response_headers() -> dict[str, str]:
195
+ headers = {
196
+ "Cache-Control": "no-store, no-cache, must-revalidate, max-age=0",
197
+ "Pragma": "no-cache",
198
+ "Expires": "0",
199
+ }
200
+ headers.update(security_headers())
201
+ return headers
202
+
203
+
204
+ def _cleanup_temp_dir(path: Path, logger: logging.Logger) -> None:
205
+ try:
206
+ shutil.rmtree(path)
207
+ except FileNotFoundError:
208
+ return
209
+ except Exception as exc:
210
+ safe_log(
211
+ logger,
212
+ logging.WARNING,
213
+ "Failed to remove temporary static cache dir %s",
214
+ path,
215
+ exc=exc,
216
+ )
217
+
218
+
219
+ def _acquire_cache_lock(lock_path: Path, logger: logging.Logger) -> bool:
220
+ try:
221
+ fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
222
+ except FileExistsError:
223
+ return False
224
+ except Exception as exc:
225
+ safe_log(
226
+ logger,
227
+ logging.WARNING,
228
+ "Failed to create static cache lock %s",
229
+ lock_path,
230
+ exc=exc,
231
+ )
232
+ return False
233
+ try:
234
+ os.write(fd, str(os.getpid()).encode("utf-8"))
235
+ except Exception:
236
+ pass
237
+ finally:
238
+ try:
239
+ os.close(fd)
240
+ except Exception:
241
+ pass
242
+ return True
243
+
244
+
245
+ def _release_cache_lock(lock_path: Path, logger: logging.Logger) -> None:
246
+ try:
247
+ lock_path.unlink()
248
+ except FileNotFoundError:
249
+ return
250
+ except Exception as exc:
251
+ safe_log(
252
+ logger,
253
+ logging.WARNING,
254
+ "Failed to remove static cache lock %s",
255
+ lock_path,
256
+ exc=exc,
257
+ )
258
+
259
+
260
+ def _cache_dir_mtime(path: Path) -> float:
261
+ index_path = path / "index.html"
262
+ try:
263
+ return index_path.stat().st_mtime
264
+ except OSError:
265
+ try:
266
+ return path.stat().st_mtime
267
+ except OSError:
268
+ return 0.0
269
+
270
+
271
+ def _list_cache_entries(cache_root: Path) -> list[Path]:
272
+ if not cache_root.exists():
273
+ return []
274
+ entries: list[Path] = []
275
+ try:
276
+ for entry in cache_root.iterdir():
277
+ if entry.name.startswith("."):
278
+ continue
279
+ try:
280
+ if entry.is_dir():
281
+ entries.append(entry)
282
+ except OSError:
283
+ continue
284
+ except OSError:
285
+ return []
286
+ return entries
287
+
288
+
289
+ def _select_latest_valid_cache(cache_root: Path) -> Optional[Path]:
290
+ candidates = []
291
+ for entry in _list_cache_entries(cache_root):
292
+ if not missing_static_assets(entry):
293
+ candidates.append(entry)
294
+ if not candidates:
295
+ return None
296
+ candidates.sort(key=_cache_dir_mtime, reverse=True)
297
+ return candidates[0]
298
+
299
+
300
+ def _prune_cache_entries(
301
+ cache_root: Path,
302
+ *,
303
+ keep: set[Path],
304
+ max_cache_entries: int,
305
+ max_cache_age_days: Optional[int],
306
+ logger: logging.Logger,
307
+ ) -> None:
308
+ if max_cache_entries <= 0 and max_cache_age_days is None:
309
+ return
310
+ entries = _list_cache_entries(cache_root)
311
+ if not entries:
312
+ return
313
+ now = time.time()
314
+ if max_cache_age_days is not None:
315
+ cutoff = now - (max_cache_age_days * 86400)
316
+ for entry in list(entries):
317
+ if entry in keep:
318
+ continue
319
+ if _cache_dir_mtime(entry) < cutoff:
320
+ try:
321
+ shutil.rmtree(entry)
322
+ entries.remove(entry)
323
+ except Exception as exc:
324
+ safe_log(
325
+ logger,
326
+ logging.WARNING,
327
+ "Failed to remove stale static cache dir %s",
328
+ entry,
329
+ exc=exc,
330
+ )
331
+ if max_cache_entries > 0 and len(entries) > max_cache_entries:
332
+ removable = [entry for entry in entries if entry not in keep]
333
+ removable.sort(key=_cache_dir_mtime)
334
+ for entry in removable[: len(entries) - max_cache_entries]:
335
+ try:
336
+ shutil.rmtree(entry)
337
+ except Exception as exc:
338
+ safe_log(
339
+ logger,
340
+ logging.WARNING,
341
+ "Failed to remove old static cache dir %s",
342
+ entry,
343
+ exc=exc,
344
+ )
345
+
346
+
347
+ def materialize_static_assets(
348
+ cache_root: Path,
349
+ *,
350
+ max_cache_entries: int,
351
+ max_cache_age_days: Optional[int],
352
+ logger: logging.Logger,
353
+ ) -> tuple[Path, Optional[ExitStack]]:
354
+ static_dir, static_context = resolve_static_dir()
355
+ existing_cache = _select_latest_valid_cache(cache_root)
356
+ missing_source = missing_static_assets(static_dir)
357
+ if missing_source:
358
+ if static_context is not None:
359
+ static_context.close()
360
+ if existing_cache is not None:
361
+ _prune_cache_entries(
362
+ cache_root,
363
+ keep={existing_cache},
364
+ max_cache_entries=max_cache_entries,
365
+ max_cache_age_days=max_cache_age_days,
366
+ logger=logger,
367
+ )
368
+ return existing_cache, None
369
+ raise RuntimeError("Static UI assets missing; reinstall package")
370
+ fingerprint = asset_version(static_dir)
371
+ target_dir = cache_root / fingerprint
372
+ if target_dir.exists() and not missing_static_assets(target_dir):
373
+ if static_context is not None:
374
+ static_context.close()
375
+ _prune_cache_entries(
376
+ cache_root,
377
+ keep={target_dir},
378
+ max_cache_entries=max_cache_entries,
379
+ max_cache_age_days=max_cache_age_days,
380
+ logger=logger,
381
+ )
382
+ return target_dir, None
383
+ try:
384
+ cache_root.mkdir(parents=True, exist_ok=True)
385
+ except Exception as exc:
386
+ safe_log(
387
+ logger,
388
+ logging.WARNING,
389
+ "Failed to create static cache root %s",
390
+ cache_root,
391
+ exc=exc,
392
+ )
393
+ if static_context is not None:
394
+ static_context.close()
395
+ if existing_cache is not None:
396
+ return existing_cache, None
397
+ raise RuntimeError("Static UI assets missing; reinstall package") from exc
398
+ lock_path = cache_root / f".lock-{fingerprint}"
399
+ lock_acquired = _acquire_cache_lock(lock_path, logger)
400
+ if not lock_acquired:
401
+ deadline = time.monotonic() + 5.0
402
+ while time.monotonic() < deadline:
403
+ if target_dir.exists() and not missing_static_assets(target_dir):
404
+ if static_context is not None:
405
+ static_context.close()
406
+ _prune_cache_entries(
407
+ cache_root,
408
+ keep={target_dir},
409
+ max_cache_entries=max_cache_entries,
410
+ max_cache_age_days=max_cache_age_days,
411
+ logger=logger,
412
+ )
413
+ return target_dir, None
414
+ time.sleep(0.2)
415
+ temp_dir = cache_root / f".tmp-{fingerprint}-{uuid4().hex}"
416
+ try:
417
+ shutil.copytree(static_dir, temp_dir, symlinks=True)
418
+ missing = missing_static_assets(temp_dir)
419
+ if missing:
420
+ safe_log(
421
+ logger,
422
+ logging.WARNING,
423
+ "Static UI assets missing in cache copy %s: %s",
424
+ temp_dir,
425
+ ", ".join(missing),
426
+ )
427
+ raise RuntimeError("Static UI assets missing; reinstall package")
428
+ if target_dir.exists():
429
+ existing_missing = missing_static_assets(target_dir)
430
+ if not existing_missing:
431
+ _cleanup_temp_dir(temp_dir, logger)
432
+ if static_context is not None:
433
+ static_context.close()
434
+ _prune_cache_entries(
435
+ cache_root,
436
+ keep={target_dir},
437
+ max_cache_entries=max_cache_entries,
438
+ max_cache_age_days=max_cache_age_days,
439
+ logger=logger,
440
+ )
441
+ return target_dir, None
442
+ try:
443
+ shutil.rmtree(target_dir)
444
+ except Exception as exc:
445
+ safe_log(
446
+ logger,
447
+ logging.WARNING,
448
+ "Failed to replace stale static cache dir %s",
449
+ target_dir,
450
+ exc=exc,
451
+ )
452
+ raise RuntimeError(
453
+ "Static UI assets missing; reinstall package"
454
+ ) from exc
455
+ temp_dir.replace(target_dir)
456
+ except Exception as exc:
457
+ _cleanup_temp_dir(temp_dir, logger)
458
+ if static_context is not None:
459
+ static_context.close()
460
+ if existing_cache is not None:
461
+ return existing_cache, None
462
+ raise RuntimeError("Static UI assets missing; reinstall package") from exc
463
+ finally:
464
+ if lock_acquired:
465
+ _release_cache_lock(lock_path, logger)
466
+ if static_context is not None:
467
+ static_context.close()
468
+ _prune_cache_entries(
469
+ cache_root,
470
+ keep={target_dir},
471
+ max_cache_entries=max_cache_entries,
472
+ max_cache_age_days=max_cache_age_days,
473
+ logger=logger,
474
+ )
475
+ return target_dir, None
476
+
477
+
478
+ def require_static_assets(static_dir: Path, logger: logging.Logger) -> None:
479
+ missing = missing_static_assets(static_dir)
480
+ if not missing:
481
+ warn_on_stale_static_assets(static_dir, logger)
482
+ return
483
+ safe_log(
484
+ logger,
485
+ logging.ERROR,
486
+ "Static UI assets missing in %s: %s",
487
+ static_dir,
488
+ ", ".join(missing),
489
+ )
490
+ raise RuntimeError("Static UI assets missing; reinstall package")
@@ -0,0 +1,86 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Optional
6
+
7
+ from ...core.logging_utils import safe_log
8
+ from .static_assets import (
9
+ asset_version,
10
+ materialize_static_assets,
11
+ missing_static_assets,
12
+ require_static_assets,
13
+ )
14
+
15
+
16
+ def _update_static_files(static_files: object, static_dir: Path) -> None:
17
+ try:
18
+ static_files.directory = static_dir
19
+ static_files.all_directories = static_files.get_directories( # type: ignore[attr-defined]
20
+ static_dir,
21
+ static_files.packages, # type: ignore[attr-defined]
22
+ )
23
+ static_files.config_checked = False
24
+ except Exception:
25
+ return
26
+
27
+
28
+ def refresh_static_assets(app: object) -> bool:
29
+ lock = getattr(getattr(app, "state", None), "static_assets_lock", None)
30
+ if lock is None or not lock.acquire(blocking=False):
31
+ return False
32
+ try:
33
+ state = getattr(app, "state", None)
34
+ if state is None:
35
+ return False
36
+ current_dir = getattr(state, "static_dir", None)
37
+ if isinstance(current_dir, Path) and not missing_static_assets(current_dir):
38
+ return True
39
+ config = getattr(state, "config", None)
40
+ logger = getattr(state, "logger", None)
41
+ static_candidates = []
42
+ if config is not None:
43
+ static_candidates.append(config.static_assets)
44
+ hub_static = getattr(state, "hub_static_assets", None)
45
+ if hub_static is not None and (
46
+ not static_candidates
47
+ or hub_static.cache_root != static_candidates[0].cache_root
48
+ ):
49
+ static_candidates.append(hub_static)
50
+ for static_cfg in static_candidates:
51
+ try:
52
+ static_dir, static_context = materialize_static_assets(
53
+ static_cfg.cache_root,
54
+ max_cache_entries=static_cfg.max_cache_entries,
55
+ max_cache_age_days=static_cfg.max_cache_age_days,
56
+ logger=logger,
57
+ )
58
+ require_static_assets(static_dir, logger)
59
+ except Exception as exc:
60
+ if logger is not None:
61
+ safe_log(
62
+ logger,
63
+ logging.WARNING,
64
+ "Static assets refresh failed for cache root %s",
65
+ static_cfg.cache_root,
66
+ exc=exc,
67
+ )
68
+ continue
69
+ old_context: Optional[object] = getattr(
70
+ state, "static_assets_context", None
71
+ )
72
+ if old_context is not None:
73
+ try:
74
+ old_context.close()
75
+ except Exception:
76
+ pass
77
+ state.static_dir = static_dir
78
+ state.static_assets_context = static_context
79
+ state.asset_version = asset_version(static_dir)
80
+ static_files = getattr(state, "static_files", None)
81
+ if static_files is not None:
82
+ _update_static_files(static_files, static_dir)
83
+ return True
84
+ return False
85
+ finally:
86
+ lock.release()
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import time
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+ from typing import Optional
7
+
8
+ from ...core.state import persist_session_registry
9
+ from .pty_session import ActiveSession
10
+
11
+
12
+ def parse_last_seen_at(value: Optional[str]) -> Optional[float]:
13
+ if not value:
14
+ return None
15
+ try:
16
+ parsed = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
17
+ except ValueError:
18
+ return None
19
+ return parsed.replace(tzinfo=timezone.utc).timestamp()
20
+
21
+
22
+ def session_last_touch(session: ActiveSession, record) -> float:
23
+ last_seen = parse_last_seen_at(getattr(record, "last_seen_at", None))
24
+ if last_seen is None:
25
+ return session.pty.last_active
26
+ return max(last_seen, session.pty.last_active)
27
+
28
+
29
+ def parse_tui_idle_seconds(config) -> Optional[float]:
30
+ notifications_cfg = (
31
+ config.notifications if isinstance(config.notifications, dict) else {}
32
+ )
33
+ idle_seconds = notifications_cfg.get("tui_idle_seconds")
34
+ if idle_seconds is None:
35
+ return None
36
+ try:
37
+ idle_seconds = float(idle_seconds)
38
+ except (TypeError, ValueError):
39
+ return None
40
+ if idle_seconds <= 0:
41
+ return None
42
+ return idle_seconds
43
+
44
+
45
+ def prune_terminal_registry(
46
+ state_path: Path,
47
+ terminal_sessions: dict[str, ActiveSession],
48
+ session_registry: dict,
49
+ repo_to_session: dict[str, str],
50
+ max_idle_seconds: Optional[int],
51
+ ) -> bool:
52
+ now = time.time()
53
+ removed_any = False
54
+ for session_id, session in list(terminal_sessions.items()):
55
+ if not session.pty.isalive():
56
+ session.close()
57
+ terminal_sessions.pop(session_id, None)
58
+ session_registry.pop(session_id, None)
59
+ removed_any = True
60
+ continue
61
+ if max_idle_seconds is not None and max_idle_seconds > 0:
62
+ last_touch = session_last_touch(session, session_registry.get(session_id))
63
+ if now - last_touch > max_idle_seconds:
64
+ session.close()
65
+ terminal_sessions.pop(session_id, None)
66
+ session_registry.pop(session_id, None)
67
+ removed_any = True
68
+ for session_id in list(session_registry.keys()):
69
+ if session_id not in terminal_sessions:
70
+ session_registry.pop(session_id, None)
71
+ removed_any = True
72
+ for repo_path, session_id in list(repo_to_session.items()):
73
+ if session_id not in session_registry:
74
+ repo_to_session.pop(repo_path, None)
75
+ removed_any = True
76
+ if removed_any:
77
+ persist_session_registry(state_path, session_registry, repo_to_session)
78
+ return removed_any
@@ -5,10 +5,17 @@ markdown tickets with YAML frontmatter.
5
5
  """
6
6
 
7
7
  from .agent_pool import AgentPool, AgentTurnRequest, AgentTurnResult
8
- from .models import TicketDoc, TicketFrontmatter, TicketResult, TicketRunConfig
8
+ from .models import (
9
+ DEFAULT_MAX_TOTAL_TURNS,
10
+ TicketDoc,
11
+ TicketFrontmatter,
12
+ TicketResult,
13
+ TicketRunConfig,
14
+ )
9
15
  from .runner import TicketRunner
10
16
 
11
17
  __all__ = [
18
+ "DEFAULT_MAX_TOTAL_TURNS",
12
19
  "AgentPool",
13
20
  "AgentTurnRequest",
14
21
  "AgentTurnResult",