codex-autorunner 1.2.1__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.
- codex_autorunner/bootstrap.py +26 -5
- codex_autorunner/core/config.py +176 -59
- codex_autorunner/core/filesystem.py +24 -0
- codex_autorunner/core/flows/controller.py +50 -12
- codex_autorunner/core/flows/runtime.py +8 -3
- codex_autorunner/core/hub.py +293 -16
- codex_autorunner/core/lifecycle_events.py +44 -5
- codex_autorunner/core/pma_delivery.py +81 -0
- codex_autorunner/core/pma_dispatches.py +224 -0
- codex_autorunner/core/pma_lane_worker.py +122 -0
- codex_autorunner/core/pma_queue.py +167 -18
- codex_autorunner/core/pma_reactive.py +91 -0
- codex_autorunner/core/pma_safety.py +58 -0
- codex_autorunner/core/pma_sink.py +104 -0
- codex_autorunner/core/pma_transcripts.py +183 -0
- codex_autorunner/core/safe_paths.py +117 -0
- codex_autorunner/housekeeping.py +77 -23
- codex_autorunner/integrations/agents/codex_backend.py +18 -12
- codex_autorunner/integrations/agents/wiring.py +2 -0
- codex_autorunner/integrations/app_server/client.py +31 -0
- codex_autorunner/integrations/app_server/supervisor.py +3 -0
- codex_autorunner/integrations/telegram/constants.py +1 -1
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +16 -15
- codex_autorunner/integrations/telegram/handlers/commands/files.py +5 -8
- codex_autorunner/integrations/telegram/handlers/commands/github.py +10 -6
- codex_autorunner/integrations/telegram/handlers/commands/shared.py +9 -8
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +85 -2
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +29 -8
- codex_autorunner/integrations/telegram/helpers.py +30 -2
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +54 -3
- codex_autorunner/static/docChatCore.js +2 -0
- codex_autorunner/static/hub.js +59 -0
- codex_autorunner/static/index.html +70 -54
- codex_autorunner/static/notificationBell.js +173 -0
- codex_autorunner/static/notifications.js +154 -36
- codex_autorunner/static/pma.js +96 -35
- codex_autorunner/static/styles.css +415 -4
- codex_autorunner/static/utils.js +5 -1
- codex_autorunner/surfaces/cli/cli.py +206 -129
- codex_autorunner/surfaces/cli/template_repos.py +157 -0
- codex_autorunner/surfaces/web/app.py +193 -5
- codex_autorunner/surfaces/web/routes/file_chat.py +109 -61
- codex_autorunner/surfaces/web/routes/flows.py +125 -67
- codex_autorunner/surfaces/web/routes/pma.py +638 -57
- codex_autorunner/tickets/agent_pool.py +6 -1
- codex_autorunner/tickets/outbox.py +27 -14
- codex_autorunner/tickets/replies.py +4 -10
- codex_autorunner/tickets/runner.py +1 -0
- codex_autorunner/workspace/paths.py +8 -3
- {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/METADATA +1 -1
- {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/RECORD +55 -45
- {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.2.1.dist-info → codex_autorunner-1.3.0.dist-info}/top_level.txt +0 -0
codex_autorunner/bootstrap.py
CHANGED
|
@@ -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
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
codex_autorunner/core/config.py
CHANGED
|
@@ -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
|
|
@@ -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,
|
|
@@ -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
|
-
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2658
|
-
|
|
2659
|
-
housekeeping_cfg
|
|
2660
|
-
)
|
|
2661
|
-
|
|
2662
|
-
|
|
2663
|
-
|
|
2664
|
-
|
|
2665
|
-
|
|
2666
|
-
|
|
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
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
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
|
-
|
|
2717
|
-
|
|
2718
|
-
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
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
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
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
|
-
|
|
2747
|
-
|
|
2748
|
-
|
|
2749
|
-
|
|
2750
|
-
|
|
2751
|
-
|
|
2752
|
-
|
|
2753
|
-
|
|
2754
|
-
|
|
2755
|
-
|
|
2756
|
-
|
|
2757
|
-
|
|
2758
|
-
|
|
2759
|
-
|
|
2760
|
-
|
|
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:
|
|
@@ -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[
|
|
50
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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,
|
|
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
|
|