codex-autorunner 1.2.0__py3-none-any.whl → 1.3.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 (67) hide show
  1. codex_autorunner/bootstrap.py +26 -5
  2. codex_autorunner/core/about_car.py +12 -12
  3. codex_autorunner/core/config.py +178 -61
  4. codex_autorunner/core/context_awareness.py +1 -0
  5. codex_autorunner/core/filesystem.py +24 -0
  6. codex_autorunner/core/flows/controller.py +50 -12
  7. codex_autorunner/core/flows/runtime.py +8 -3
  8. codex_autorunner/core/hub.py +293 -16
  9. codex_autorunner/core/lifecycle_events.py +44 -5
  10. codex_autorunner/core/pma_context.py +188 -1
  11. codex_autorunner/core/pma_delivery.py +81 -0
  12. codex_autorunner/core/pma_dispatches.py +224 -0
  13. codex_autorunner/core/pma_lane_worker.py +122 -0
  14. codex_autorunner/core/pma_queue.py +167 -18
  15. codex_autorunner/core/pma_reactive.py +91 -0
  16. codex_autorunner/core/pma_safety.py +58 -0
  17. codex_autorunner/core/pma_sink.py +104 -0
  18. codex_autorunner/core/pma_transcripts.py +183 -0
  19. codex_autorunner/core/safe_paths.py +117 -0
  20. codex_autorunner/housekeeping.py +77 -23
  21. codex_autorunner/integrations/agents/codex_backend.py +18 -12
  22. codex_autorunner/integrations/agents/wiring.py +2 -0
  23. codex_autorunner/integrations/app_server/client.py +31 -0
  24. codex_autorunner/integrations/app_server/supervisor.py +3 -0
  25. codex_autorunner/integrations/telegram/adapter.py +1 -1
  26. codex_autorunner/integrations/telegram/config.py +1 -1
  27. codex_autorunner/integrations/telegram/constants.py +1 -1
  28. codex_autorunner/integrations/telegram/handlers/commands/execution.py +16 -15
  29. codex_autorunner/integrations/telegram/handlers/commands/files.py +5 -8
  30. codex_autorunner/integrations/telegram/handlers/commands/github.py +10 -6
  31. codex_autorunner/integrations/telegram/handlers/commands/shared.py +9 -8
  32. codex_autorunner/integrations/telegram/handlers/commands/workspace.py +85 -2
  33. codex_autorunner/integrations/telegram/handlers/commands_runtime.py +29 -8
  34. codex_autorunner/integrations/telegram/handlers/messages.py +8 -2
  35. codex_autorunner/integrations/telegram/helpers.py +30 -2
  36. codex_autorunner/integrations/telegram/ticket_flow_bridge.py +54 -3
  37. codex_autorunner/static/archive.js +274 -81
  38. codex_autorunner/static/archiveApi.js +21 -0
  39. codex_autorunner/static/constants.js +1 -1
  40. codex_autorunner/static/docChatCore.js +2 -0
  41. codex_autorunner/static/hub.js +59 -0
  42. codex_autorunner/static/index.html +70 -54
  43. codex_autorunner/static/notificationBell.js +173 -0
  44. codex_autorunner/static/notifications.js +187 -36
  45. codex_autorunner/static/pma.js +96 -35
  46. codex_autorunner/static/styles.css +431 -4
  47. codex_autorunner/static/terminalManager.js +22 -3
  48. codex_autorunner/static/utils.js +5 -1
  49. codex_autorunner/surfaces/cli/cli.py +206 -129
  50. codex_autorunner/surfaces/cli/template_repos.py +157 -0
  51. codex_autorunner/surfaces/web/app.py +193 -5
  52. codex_autorunner/surfaces/web/routes/archive.py +197 -0
  53. codex_autorunner/surfaces/web/routes/file_chat.py +115 -87
  54. codex_autorunner/surfaces/web/routes/flows.py +125 -67
  55. codex_autorunner/surfaces/web/routes/pma.py +638 -57
  56. codex_autorunner/surfaces/web/schemas.py +11 -0
  57. codex_autorunner/tickets/agent_pool.py +6 -1
  58. codex_autorunner/tickets/outbox.py +27 -14
  59. codex_autorunner/tickets/replies.py +4 -10
  60. codex_autorunner/tickets/runner.py +1 -0
  61. codex_autorunner/workspace/paths.py +8 -3
  62. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/METADATA +1 -1
  63. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/RECORD +67 -57
  64. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/WHEEL +0 -0
  65. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/entry_points.txt +0 -0
  66. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/licenses/LICENSE +0 -0
  67. {codex_autorunner-1.2.0.dist-info → codex_autorunner-1.3.0.dist-info}/top_level.txt +0 -0
@@ -188,11 +188,18 @@ def ensure_pma_docs(hub_root: Path, force: bool = False) -> None:
188
188
  ca_dir = hub_root / ".codex-autorunner"
189
189
  pma_dir = ca_dir / "pma"
190
190
  pma_dir.mkdir(parents=True, exist_ok=True)
191
- _seed_doc(pma_dir / "prompt.md", force, pma_prompt_content())
192
- _seed_doc(pma_dir / "ABOUT_CAR.md", force, pma_about_content())
193
- _seed_doc(pma_dir / "AGENTS.md", force, pma_agents_content())
194
- _seed_doc(pma_dir / "active_context.md", force, pma_active_context_content())
195
- _seed_doc(pma_dir / "context_log.md", force, pma_context_log_content())
191
+ docs_dir = pma_dir / "docs"
192
+ docs_dir.mkdir(parents=True, exist_ok=True)
193
+ seeds = (
194
+ ("prompt.md", pma_prompt_content),
195
+ ("ABOUT_CAR.md", pma_about_content),
196
+ ("AGENTS.md", pma_agents_content),
197
+ ("active_context.md", pma_active_context_content),
198
+ ("context_log.md", pma_context_log_content),
199
+ )
200
+ for filename, content_fn in seeds:
201
+ _seed_doc(pma_dir / filename, force, content_fn())
202
+ _seed_doc(docs_dir / filename, force, content_fn())
196
203
 
197
204
 
198
205
  def seed_hub_files(hub_root: Path, force: bool = False) -> None:
@@ -308,6 +315,20 @@ Do NOT copy `.codex-autorunner/` between worktrees:
308
315
 
309
316
  - User uploads arrive in `.codex-autorunner/pma/inbox/`.
310
317
  - Send user-facing files by writing to `.codex-autorunner/pma/outbox/`.
318
+
319
+ ## PMA dispatches (user attention)
320
+
321
+ - Create PMA dispatches by writing Markdown files to:
322
+ `.codex-autorunner/pma/dispatches/<timestamp>_<id>.md`
323
+ - File format: YAML frontmatter + markdown body.
324
+ - Required frontmatter fields:
325
+ - `title`: short summary
326
+ - `priority`: `info` | `warn` | `action`
327
+ - `created_at`: ISO 8601 timestamp
328
+ - `source_turn_id`: PMA turn id (for notifications)
329
+ - `links`: optional list of `{label, href}` objects
330
+ - `resolved_at`: leave empty/omit until resolved
331
+ - The web UI lists unresolved dispatches; resolve via UI or set `resolved_at`.
311
332
  """
312
333
 
313
334
 
@@ -7,7 +7,6 @@ from .config import (
7
7
  REPO_OVERRIDE_FILENAME,
8
8
  ROOT_CONFIG_FILENAME,
9
9
  ROOT_OVERRIDE_FILENAME,
10
- Config,
11
10
  find_nearest_hub_config_path,
12
11
  )
13
12
 
@@ -98,6 +97,18 @@ def build_about_car_markdown(
98
97
  f"`{decisions_disp}`\n"
99
98
  "- **Spec**: "
100
99
  f"`{spec_disp}`\n\n"
100
+ "## Web UI quick map (repo page)\n"
101
+ "- Repo view: `/repos/<repo_id>/`\n"
102
+ "- Tabs: **Tickets** = `.codex-autorunner/tickets/` queue.\n"
103
+ "- Tabs: **Inbox** = paused run dispatches/handoffs.\n"
104
+ "- Tabs: **Workspace** = edit `active_context.md`, `spec.md`, `decisions.md`.\n"
105
+ "- Tabs: **Terminal** = launches the configured `codex` binary in a PTY.\n"
106
+ "- Tabs: **Archive** = browse worktree snapshots.\n\n"
107
+ "## FileBox (attachments)\n"
108
+ "- Repo FileBox root: `.codex-autorunner/filebox/`.\n"
109
+ "- User uploads: `.codex-autorunner/filebox/inbox/`.\n"
110
+ "- Files to send back: `.codex-autorunner/filebox/outbox/`.\n"
111
+ "- Note: ticket_flow uses per-run dispatch directories; do not confuse dispatch with FileBox.\n\n"
101
112
  "## Critical rules\n"
102
113
  "- Do **not** create new copies of workspace docs elsewhere in the repo.\n"
103
114
  "- Treat `.codex-autorunner/` as intentional project structure even though it is hidden/gitignored.\n\n"
@@ -211,17 +222,6 @@ def build_tickets_agents_markdown(*, repo_root: Path) -> str:
211
222
  )
212
223
 
213
224
 
214
- def ensure_about_car_file(config: Config, *, force: bool = False) -> Path:
215
- """Config-aware wrapper that uses configured doc paths."""
216
- repo_root = config.root
217
- docs = {
218
- "active_context": config.doc_path("active_context"),
219
- "decisions": config.doc_path("decisions"),
220
- "spec": config.doc_path("spec"),
221
- }
222
- return ensure_about_car_file_for_repo(repo_root, doc_paths=docs, force=force)
223
-
224
-
225
225
  def ensure_ticket_flow_quickstart_file_for_repo(
226
226
  repo_root: Path, *, force: bool = False
227
227
  ) -> Path:
@@ -6,7 +6,7 @@ import os
6
6
  import shlex
7
7
  from os import PathLike
8
8
  from pathlib import Path
9
- from typing import IO, Any, Dict, List, Mapping, Optional, Union, cast
9
+ from typing import IO, Any, Dict, List, Mapping, Optional, Tuple, Type, Union, cast
10
10
 
11
11
  import yaml
12
12
 
@@ -174,6 +174,9 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
174
174
  "restart_backoff_max_seconds": 30.0,
175
175
  "restart_backoff_jitter_ratio": 0.1,
176
176
  },
177
+ "output": {
178
+ "policy": "final_only",
179
+ },
177
180
  "prompts": {
178
181
  # NOTE: These keys are legacy names kept for config compatibility.
179
182
  # The workspace cutover uses tickets + workspace docs + unified file chat; only
@@ -258,7 +261,7 @@ DEFAULT_REPO_CONFIG: Dict[str, Any] = {
258
261
  "files": True,
259
262
  "max_image_bytes": 10_000_000,
260
263
  "max_voice_bytes": 10_000_000,
261
- "max_file_bytes": 10_000_000,
264
+ "max_file_bytes": 100_000_000,
262
265
  "image_prompt": (
263
266
  "The user sent an image with no caption. Use it to continue the "
264
267
  "conversation; if no clear task, describe the image and ask what "
@@ -486,6 +489,15 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
486
489
  "docs_max_chars": 12_000,
487
490
  "active_context_max_lines": 200,
488
491
  "context_log_tail_lines": 120,
492
+ "reactive_enabled": True,
493
+ "reactive_event_types": [
494
+ "flow_paused",
495
+ "flow_failed",
496
+ "flow_completed",
497
+ "dispatch_created",
498
+ ],
499
+ "reactive_debounce_seconds": 300,
500
+ "reactive_origin_blocklist": ["pma"],
489
501
  },
490
502
  "templates": {
491
503
  "enabled": True,
@@ -542,7 +554,7 @@ DEFAULT_HUB_CONFIG: Dict[str, Any] = {
542
554
  "files": True,
543
555
  "max_image_bytes": 10_000_000,
544
556
  "max_voice_bytes": 10_000_000,
545
- "max_file_bytes": 10_000_000,
557
+ "max_file_bytes": 100_000_000,
546
558
  "image_prompt": (
547
559
  "The user sent an image with no caption. Use it to continue the "
548
560
  "conversation; if no clear task, describe the image and ask what "
@@ -827,6 +839,11 @@ class AppServerClientConfig:
827
839
  restart_backoff_jitter_ratio: float
828
840
 
829
841
 
842
+ @dataclasses.dataclass
843
+ class AppServerOutputConfig:
844
+ policy: str
845
+
846
+
830
847
  @dataclasses.dataclass
831
848
  class AppServerConfig:
832
849
  command: List[str]
@@ -840,6 +857,7 @@ class AppServerConfig:
840
857
  turn_stall_recovery_min_interval_seconds: Optional[float]
841
858
  request_timeout: Optional[float]
842
859
  client: AppServerClientConfig
860
+ output: AppServerOutputConfig
843
861
  prompts: AppServerPromptsConfig
844
862
 
845
863
 
@@ -864,6 +882,10 @@ class PmaConfig:
864
882
  active_context_max_lines: int = 200
865
883
  context_log_tail_lines: int = 120
866
884
  dispatch_interception_enabled: bool = False
885
+ reactive_enabled: bool = True
886
+ reactive_event_types: List[str] = dataclasses.field(default_factory=list)
887
+ reactive_debounce_seconds: int = 300
888
+ reactive_origin_blocklist: List[str] = dataclasses.field(default_factory=list)
867
889
 
868
890
 
869
891
  @dataclasses.dataclass
@@ -1218,6 +1240,24 @@ def _parse_app_server_prompts_config(
1218
1240
  )
1219
1241
 
1220
1242
 
1243
+ _APP_SERVER_OUTPUT_POLICIES = {"final_only", "all_agent_messages"}
1244
+
1245
+
1246
+ def _parse_app_server_output_config(
1247
+ cfg: Optional[Dict[str, Any]],
1248
+ defaults: Optional[Dict[str, Any]],
1249
+ ) -> AppServerOutputConfig:
1250
+ cfg = cfg if isinstance(cfg, dict) else {}
1251
+ defaults = defaults if isinstance(defaults, dict) else {}
1252
+ policy_raw = cfg.get("policy", defaults.get("policy"))
1253
+ policy = str(policy_raw).strip().lower() if policy_raw is not None else ""
1254
+ if policy not in _APP_SERVER_OUTPUT_POLICIES:
1255
+ policy = str(defaults.get("policy") or "final_only").strip().lower()
1256
+ if policy not in _APP_SERVER_OUTPUT_POLICIES:
1257
+ policy = "final_only"
1258
+ return AppServerOutputConfig(policy=policy)
1259
+
1260
+
1221
1261
  def _parse_app_server_config(
1222
1262
  cfg: Optional[Dict[str, Any]],
1223
1263
  root: Path,
@@ -1316,6 +1356,9 @@ def _parse_app_server_config(
1316
1356
  value = float(client_defaults.get(key) or 0.0)
1317
1357
  return value
1318
1358
 
1359
+ output_defaults = defaults.get("output")
1360
+ output_cfg_raw = cfg.get("output")
1361
+ output = _parse_app_server_output_config(output_cfg_raw, output_defaults)
1319
1362
  prompt_defaults = defaults.get("prompts")
1320
1363
  prompts = _parse_app_server_prompts_config(cfg.get("prompts"), prompt_defaults)
1321
1364
  return AppServerConfig(
@@ -1341,6 +1384,7 @@ def _parse_app_server_config(
1341
1384
  "restart_backoff_jitter_ratio", allow_zero=True
1342
1385
  ),
1343
1386
  ),
1387
+ output=output,
1344
1388
  prompts=prompts,
1345
1389
  )
1346
1390
 
@@ -1418,6 +1462,41 @@ def _parse_pma_config(
1418
1462
  defaults.get("dispatch_interception_enabled", False),
1419
1463
  )
1420
1464
  )
1465
+ reactive_enabled = bool(
1466
+ cfg.get("reactive_enabled", defaults.get("reactive_enabled", True))
1467
+ )
1468
+ reactive_event_types_raw = cfg.get(
1469
+ "reactive_event_types", defaults.get("reactive_event_types", [])
1470
+ )
1471
+ if isinstance(reactive_event_types_raw, list):
1472
+ reactive_event_types = [
1473
+ str(value).strip()
1474
+ for value in reactive_event_types_raw
1475
+ if str(value).strip()
1476
+ ]
1477
+ else:
1478
+ reactive_event_types = []
1479
+ reactive_debounce_seconds_raw = cfg.get(
1480
+ "reactive_debounce_seconds", defaults.get("reactive_debounce_seconds", 300)
1481
+ )
1482
+ try:
1483
+ reactive_debounce_seconds = int(reactive_debounce_seconds_raw)
1484
+ except (ValueError, TypeError):
1485
+ reactive_debounce_seconds = 300
1486
+ if reactive_debounce_seconds < 0:
1487
+ reactive_debounce_seconds = 0
1488
+ reactive_origin_blocklist_raw = cfg.get(
1489
+ "reactive_origin_blocklist",
1490
+ defaults.get("reactive_origin_blocklist", ["pma"]),
1491
+ )
1492
+ if isinstance(reactive_origin_blocklist_raw, list):
1493
+ reactive_origin_blocklist = [
1494
+ str(value).strip()
1495
+ for value in reactive_origin_blocklist_raw
1496
+ if str(value).strip()
1497
+ ]
1498
+ else:
1499
+ reactive_origin_blocklist = []
1421
1500
  return PmaConfig(
1422
1501
  enabled=enabled,
1423
1502
  default_agent=default_agent,
@@ -1431,6 +1510,10 @@ def _parse_pma_config(
1431
1510
  active_context_max_lines=active_context_max_lines,
1432
1511
  context_log_tail_lines=context_log_tail_lines,
1433
1512
  dispatch_interception_enabled=dispatch_interception_enabled,
1513
+ reactive_enabled=reactive_enabled,
1514
+ reactive_event_types=reactive_event_types,
1515
+ reactive_debounce_seconds=reactive_debounce_seconds,
1516
+ reactive_origin_blocklist=reactive_origin_blocklist,
1434
1517
  )
1435
1518
 
1436
1519
 
@@ -2645,34 +2728,62 @@ def _validate_hub_config(cfg: Dict[str, Any], *, root: Path) -> None:
2645
2728
  _validate_telegram_bot_config(cfg)
2646
2729
 
2647
2730
 
2731
+ def _validate_optional_type(
2732
+ mapping: Dict[str, Any],
2733
+ key: str,
2734
+ expected: Union[Type, Tuple[Type, ...]],
2735
+ *,
2736
+ path: str,
2737
+ allow_none: bool = False,
2738
+ ) -> None:
2739
+ if key in mapping:
2740
+ value = mapping.get(key)
2741
+ if value is None and allow_none:
2742
+ return
2743
+ if isinstance(value, expected):
2744
+ return
2745
+ type_name = (
2746
+ " or ".join(t.__name__ for t in expected)
2747
+ if isinstance(expected, tuple)
2748
+ else expected.__name__
2749
+ )
2750
+ raise ConfigError(f"{path}.{key} must be {type_name} if provided")
2751
+
2752
+
2753
+ def _validate_optional_int_ge(
2754
+ mapping: Dict[str, Any], key: str, min_value: int, *, path: str
2755
+ ) -> None:
2756
+ if key in mapping:
2757
+ value = mapping.get(key)
2758
+ if isinstance(value, int) and value < min_value:
2759
+ if min_value == 0:
2760
+ raise ConfigError(f"{path}.{key} must be >= 0")
2761
+ elif min_value == 1:
2762
+ raise ConfigError(f"{path}.{key} must be > 0")
2763
+ else:
2764
+ raise ConfigError(f"{path}.{key} must be >= {min_value}")
2765
+
2766
+
2648
2767
  def _validate_housekeeping_config(cfg: Dict[str, Any]) -> None:
2649
2768
  housekeeping_cfg = cfg.get("housekeeping")
2650
2769
  if housekeeping_cfg is None:
2651
2770
  return
2652
2771
  if not isinstance(housekeeping_cfg, dict):
2653
2772
  raise ConfigError("housekeeping section must be a mapping if provided")
2654
- if "enabled" in housekeeping_cfg and not isinstance(
2655
- housekeeping_cfg.get("enabled"), bool
2656
- ):
2657
- raise ConfigError("housekeeping.enabled must be boolean")
2658
- if "interval_seconds" in housekeeping_cfg and not isinstance(
2659
- housekeeping_cfg.get("interval_seconds"), int
2660
- ):
2661
- raise ConfigError("housekeeping.interval_seconds must be an integer")
2662
- interval_seconds = housekeeping_cfg.get("interval_seconds")
2663
- if isinstance(interval_seconds, int) and interval_seconds <= 0:
2664
- raise ConfigError("housekeeping.interval_seconds must be greater than 0")
2665
- if "min_file_age_seconds" in housekeeping_cfg and not isinstance(
2666
- housekeeping_cfg.get("min_file_age_seconds"), int
2667
- ):
2668
- raise ConfigError("housekeeping.min_file_age_seconds must be an integer")
2669
- min_file_age_seconds = housekeeping_cfg.get("min_file_age_seconds")
2670
- if isinstance(min_file_age_seconds, int) and min_file_age_seconds < 0:
2671
- raise ConfigError("housekeeping.min_file_age_seconds must be >= 0")
2672
- if "dry_run" in housekeeping_cfg and not isinstance(
2673
- housekeeping_cfg.get("dry_run"), bool
2674
- ):
2675
- raise ConfigError("housekeeping.dry_run must be boolean")
2773
+ _validate_optional_type(housekeeping_cfg, "enabled", bool, path="housekeeping")
2774
+ _validate_optional_type(
2775
+ housekeeping_cfg, "interval_seconds", int, path="housekeeping"
2776
+ )
2777
+ _validate_optional_int_ge(
2778
+ housekeeping_cfg, "interval_seconds", 1, path="housekeeping"
2779
+ )
2780
+ _validate_optional_type(
2781
+ housekeeping_cfg, "min_file_age_seconds", int, path="housekeeping"
2782
+ )
2783
+ _validate_optional_int_ge(
2784
+ housekeeping_cfg, "min_file_age_seconds", 0, path="housekeeping"
2785
+ )
2786
+ _validate_optional_type(housekeeping_cfg, "dry_run", bool, path="housekeeping")
2676
2787
  rules = housekeeping_cfg.get("rules")
2677
2788
  if rules is not None and not isinstance(rules, list):
2678
2789
  raise ConfigError("housekeeping.rules must be a list if provided")
@@ -2682,10 +2793,9 @@ def _validate_housekeeping_config(cfg: Dict[str, Any]) -> None:
2682
2793
  raise ConfigError(
2683
2794
  f"housekeeping.rules[{idx}] must be a mapping if provided"
2684
2795
  )
2685
- if "name" in rule and not isinstance(rule.get("name"), str):
2686
- raise ConfigError(
2687
- f"housekeeping.rules[{idx}].name must be a string if provided"
2688
- )
2796
+ _validate_optional_type(
2797
+ rule, "name", str, path=f"housekeeping.rules[{idx}]"
2798
+ )
2689
2799
  if "kind" in rule:
2690
2800
  kind = rule.get("kind")
2691
2801
  if not isinstance(kind, str):
@@ -2696,8 +2806,6 @@ def _validate_housekeeping_config(cfg: Dict[str, Any]) -> None:
2696
2806
  raise ConfigError(
2697
2807
  f"housekeeping.rules[{idx}].kind must be 'directory' or 'file'"
2698
2808
  )
2699
- if "path" in rule and not isinstance(rule.get("path"), str):
2700
- raise ConfigError(f"housekeeping.rules[{idx}].path must be a string")
2701
2809
  if "path" in rule:
2702
2810
  path_value = rule.get("path")
2703
2811
  if not isinstance(path_value, str) or not path_value:
@@ -2713,14 +2821,12 @@ def _validate_housekeeping_config(cfg: Dict[str, Any]) -> None:
2713
2821
  raise ConfigError(
2714
2822
  f"housekeeping.rules[{idx}].path must not contain '..' segments"
2715
2823
  )
2716
- if "glob" in rule and not isinstance(rule.get("glob"), str):
2717
- raise ConfigError(
2718
- f"housekeeping.rules[{idx}].glob must be a string if provided"
2719
- )
2720
- if "recursive" in rule and not isinstance(rule.get("recursive"), bool):
2721
- raise ConfigError(
2722
- f"housekeeping.rules[{idx}].recursive must be boolean if provided"
2723
- )
2824
+ _validate_optional_type(
2825
+ rule, "glob", str, path=f"housekeeping.rules[{idx}]"
2826
+ )
2827
+ _validate_optional_type(
2828
+ rule, "recursive", bool, path=f"housekeeping.rules[{idx}]"
2829
+ )
2724
2830
  for key in (
2725
2831
  "max_files",
2726
2832
  "max_total_bytes",
@@ -2728,13 +2834,12 @@ def _validate_housekeeping_config(cfg: Dict[str, Any]) -> None:
2728
2834
  "max_bytes",
2729
2835
  "max_lines",
2730
2836
  ):
2731
- if key in rule and not isinstance(rule.get(key), int):
2732
- raise ConfigError(
2733
- f"housekeeping.rules[{idx}].{key} must be an integer if provided"
2734
- )
2735
- value = rule.get(key)
2736
- if isinstance(value, int) and value < 0:
2737
- raise ConfigError(f"housekeeping.rules[{idx}].{key} must be >= 0")
2837
+ _validate_optional_type(
2838
+ rule, key, int, path=f"housekeeping.rules[{idx}]"
2839
+ )
2840
+ _validate_optional_int_ge(
2841
+ rule, key, 0, path=f"housekeeping.rules[{idx}]"
2842
+ )
2738
2843
 
2739
2844
 
2740
2845
  def _validate_static_assets_config(cfg: Dict[str, Any], scope: str) -> None:
@@ -2743,21 +2848,33 @@ def _validate_static_assets_config(cfg: Dict[str, Any], scope: str) -> None:
2743
2848
  return
2744
2849
  if not isinstance(static_cfg, dict):
2745
2850
  raise ConfigError(f"{scope}.static_assets must be a mapping if provided")
2746
- cache_root = static_cfg.get("cache_root")
2747
- if cache_root is not None and not isinstance(cache_root, str):
2748
- raise ConfigError(f"{scope}.static_assets.cache_root must be a string")
2749
- max_entries = static_cfg.get("max_cache_entries")
2750
- if max_entries is not None and not isinstance(max_entries, int):
2751
- raise ConfigError(f"{scope}.static_assets.max_cache_entries must be an integer")
2752
- if isinstance(max_entries, int) and max_entries < 0:
2753
- raise ConfigError(f"{scope}.static_assets.max_cache_entries must be >= 0")
2754
- max_age_days = static_cfg.get("max_cache_age_days")
2755
- if max_age_days is not None and not isinstance(max_age_days, int):
2756
- raise ConfigError(
2757
- f"{scope}.static_assets.max_cache_age_days must be an integer or null"
2758
- )
2759
- if isinstance(max_age_days, int) and max_age_days < 0:
2760
- raise ConfigError(f"{scope}.static_assets.max_cache_age_days must be >= 0")
2851
+ _validate_optional_type(
2852
+ static_cfg,
2853
+ "cache_root",
2854
+ str,
2855
+ path=f"{scope}.static_assets",
2856
+ allow_none=True,
2857
+ )
2858
+ _validate_optional_type(
2859
+ static_cfg,
2860
+ "max_cache_entries",
2861
+ int,
2862
+ path=f"{scope}.static_assets",
2863
+ allow_none=True,
2864
+ )
2865
+ _validate_optional_int_ge(
2866
+ static_cfg, "max_cache_entries", 0, path=f"{scope}.static_assets"
2867
+ )
2868
+ _validate_optional_type(
2869
+ static_cfg,
2870
+ "max_cache_age_days",
2871
+ int,
2872
+ path=f"{scope}.static_assets",
2873
+ allow_none=True,
2874
+ )
2875
+ _validate_optional_int_ge(
2876
+ static_cfg, "max_cache_age_days", 0, path=f"{scope}.static_assets"
2877
+ )
2761
2878
 
2762
2879
 
2763
2880
  def _validate_telegram_bot_config(cfg: Dict[str, Any]) -> None:
@@ -12,6 +12,7 @@ CAR’s durable control-plane lives under `.codex-autorunner/`:
12
12
  - `active_context.md` — current “north star” context; kept fresh for ongoing work.
13
13
  - `spec.md` — longer spec / acceptance criteria when needed.
14
14
  - `decisions.md` — prior decisions / tradeoffs when relevant.
15
+ - `.codex-autorunner/filebox/` — attachments inbox/outbox used by CAR surfaces (if present).
15
16
 
16
17
  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
 
@@ -0,0 +1,24 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+
7
+ def copy_path(src: Path, dst: Path) -> None:
8
+ """Copy a file or directory to a destination.
9
+
10
+ If src is a directory, copy it recursively using copytree.
11
+ If src is a file, copy it using copy2 after ensuring parent directory exists.
12
+
13
+ Args:
14
+ src: Source path to copy from
15
+ dst: Destination path to copy to
16
+
17
+ Raises:
18
+ OSError: If the copy operation fails
19
+ """
20
+ if src.is_dir():
21
+ shutil.copytree(src, dst)
22
+ else:
23
+ dst.parent.mkdir(parents=True, exist_ok=True)
24
+ shutil.copy2(src, dst)
@@ -4,6 +4,7 @@ import uuid
4
4
  from pathlib import Path
5
5
  from typing import Any, AsyncGenerator, Callable, Dict, Optional, Set
6
6
 
7
+ from ...manifest import ManifestError, load_manifest
7
8
  from ..lifecycle_events import LifecycleEventEmitter
8
9
  from ..utils import find_repo_root
9
10
  from .definition import FlowDefinition
@@ -46,16 +47,30 @@ class FlowController:
46
47
  self.artifacts_root = artifacts_root
47
48
  self.store = FlowStore(db_path, durable=durable)
48
49
  self._event_listeners: Set[Callable[[FlowEvent], None]] = set()
49
- self._lifecycle_event_listeners: Set[Callable[[str, str, str, dict], None]] = (
50
- set()
51
- )
50
+ self._lifecycle_event_listeners: Set[
51
+ Callable[[str, str, str, dict, str], None]
52
+ ] = set()
52
53
  self._lock = asyncio.Lock()
53
54
  self._lifecycle_emitter: Optional[LifecycleEventEmitter] = None
55
+ self._repo_id = ""
54
56
  if hub_root is None:
55
57
  hub_root = _find_hub_root(db_path.parent.parent if db_path else None)
56
58
  if hub_root is not None:
57
59
  self._lifecycle_emitter = LifecycleEventEmitter(hub_root)
58
60
  self.add_lifecycle_event_listener(self._emit_to_lifecycle_store)
61
+ self._repo_id = self._resolve_repo_id(hub_root)
62
+
63
+ def _resolve_repo_id(self, hub_root: Path) -> str:
64
+ repo_root = self.db_path.parent.parent if self.db_path else None
65
+ if repo_root is None:
66
+ return ""
67
+ manifest_path = hub_root / ".codex-autorunner" / "manifest.yml"
68
+ try:
69
+ manifest = load_manifest(manifest_path, hub_root)
70
+ except ManifestError:
71
+ return ""
72
+ entry = manifest.get_by_path(hub_root, repo_root)
73
+ return entry.id if entry else ""
59
74
 
60
75
  def initialize(self) -> None:
61
76
  self.artifacts_root.mkdir(parents=True, exist_ok=True)
@@ -209,38 +224,61 @@ class FlowController:
209
224
  self._event_listeners.discard(listener)
210
225
 
211
226
  def add_lifecycle_event_listener(
212
- self, listener: Callable[[str, str, str, dict], None]
227
+ self, listener: Callable[[str, str, str, dict, str], None]
213
228
  ) -> None:
214
229
  self._lifecycle_event_listeners.add(listener)
215
230
 
216
231
  def remove_lifecycle_event_listener(
217
- self, listener: Callable[[str, str, str, dict], None]
232
+ self, listener: Callable[[str, str, str, dict, str], None]
218
233
  ) -> None:
219
234
  self._lifecycle_event_listeners.discard(listener)
220
235
 
221
236
  def _emit_lifecycle(
222
- self, event_type: str, repo_id: str, run_id: str, data: Dict[str, Any]
237
+ self,
238
+ event_type: str,
239
+ repo_id: str,
240
+ run_id: str,
241
+ data: Dict[str, Any],
242
+ origin: str,
223
243
  ) -> None:
244
+ resolved_repo_id = self._repo_id or repo_id
245
+ payload = data
246
+ if resolved_repo_id and data.get("repo_id") != resolved_repo_id:
247
+ payload = dict(data)
248
+ payload["repo_id"] = resolved_repo_id
224
249
  for listener in self._lifecycle_event_listeners:
225
250
  try:
226
- listener(event_type, repo_id, run_id, data)
251
+ listener(event_type, resolved_repo_id, run_id, payload, origin)
227
252
  except Exception as e:
228
253
  _logger.exception("Error in lifecycle event listener: %s", e)
229
254
 
230
255
  def _emit_to_lifecycle_store(
231
- self, event_type: str, repo_id: str, run_id: str, data: Dict[str, Any]
256
+ self,
257
+ event_type: str,
258
+ repo_id: str,
259
+ run_id: str,
260
+ data: Dict[str, Any],
261
+ origin: str,
232
262
  ) -> None:
233
263
  if self._lifecycle_emitter is None:
234
264
  return
235
265
  try:
236
266
  if event_type == "flow_paused":
237
- self._lifecycle_emitter.emit_flow_paused(repo_id, run_id, data=data)
267
+ self._lifecycle_emitter.emit_flow_paused(
268
+ repo_id, run_id, data=data, origin=origin
269
+ )
238
270
  elif event_type == "flow_completed":
239
- self._lifecycle_emitter.emit_flow_completed(repo_id, run_id, data=data)
271
+ self._lifecycle_emitter.emit_flow_completed(
272
+ repo_id, run_id, data=data, origin=origin
273
+ )
240
274
  elif event_type == "flow_failed":
241
- self._lifecycle_emitter.emit_flow_failed(repo_id, run_id, data=data)
275
+ self._lifecycle_emitter.emit_flow_failed(
276
+ repo_id, run_id, data=data, origin=origin
277
+ )
242
278
  elif event_type == "flow_stopped":
243
- self._lifecycle_emitter.emit_flow_stopped(repo_id, run_id, data=data)
279
+ self._lifecycle_emitter.emit_flow_stopped(
280
+ repo_id, run_id, data=data, origin=origin
281
+ )
244
282
  except Exception as exc:
245
283
  _logger.exception("Error emitting to lifecycle store: %s", exc)
246
284
 
@@ -11,7 +11,7 @@ from .store import FlowStore, now_iso
11
11
  _logger = logging.getLogger(__name__)
12
12
 
13
13
 
14
- LifecycleEventCallback = Optional[Callable[[str, str, str, Dict[str, Any]], None]]
14
+ LifecycleEventCallback = Optional[Callable[[str, str, str, Dict[str, Any], str], None]]
15
15
 
16
16
 
17
17
  class FlowRuntime:
@@ -29,11 +29,16 @@ class FlowRuntime:
29
29
  self._stop_check_interval = 0.5
30
30
 
31
31
  def _emit_lifecycle(
32
- self, event_type: str, repo_id: str, run_id: str, data: Dict[str, Any]
32
+ self,
33
+ event_type: str,
34
+ repo_id: str,
35
+ run_id: str,
36
+ data: Dict[str, Any],
37
+ origin: str = "runner",
33
38
  ) -> None:
34
39
  if self.emit_lifecycle_event:
35
40
  try:
36
- self.emit_lifecycle_event(event_type, repo_id, run_id, data)
41
+ self.emit_lifecycle_event(event_type, repo_id, run_id, data, origin)
37
42
  except Exception as exc:
38
43
  _logger.exception("Error emitting lifecycle event: %s", exc)
39
44