codex-autorunner 0.1.2__py3-none-any.whl → 1.1.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/__init__.py +12 -1
- codex_autorunner/__main__.py +4 -0
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/client.py +68 -35
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/logging.py +21 -5
- codex_autorunner/agents/opencode/run_prompt.py +1 -0
- codex_autorunner/agents/opencode/runtime.py +176 -47
- codex_autorunner/agents/opencode/supervisor.py +36 -48
- codex_autorunner/agents/registry.py +155 -8
- codex_autorunner/api.py +25 -0
- codex_autorunner/bootstrap.py +22 -37
- codex_autorunner/cli.py +5 -1156
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +4 -0
- codex_autorunner/core/about_car.py +49 -32
- codex_autorunner/core/adapter_utils.py +21 -0
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_logging.py +7 -3
- codex_autorunner/core/app_server_prompts.py +27 -260
- codex_autorunner/core/app_server_threads.py +26 -28
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +12 -2
- codex_autorunner/core/config.py +587 -103
- codex_autorunner/core/docs.py +10 -2
- codex_autorunner/core/drafts.py +136 -0
- codex_autorunner/core/engine.py +1531 -866
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/flows/__init__.py +25 -0
- codex_autorunner/core/flows/controller.py +202 -0
- codex_autorunner/core/flows/definition.py +82 -0
- codex_autorunner/core/flows/models.py +88 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +131 -0
- codex_autorunner/core/flows/runtime.py +382 -0
- codex_autorunner/core/flows/store.py +568 -0
- codex_autorunner/core/flows/transition.py +138 -0
- codex_autorunner/core/flows/ux_helpers.py +257 -0
- codex_autorunner/core/flows/worker_process.py +242 -0
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +136 -16
- codex_autorunner/core/locks.py +4 -0
- codex_autorunner/core/notifications.py +14 -2
- codex_autorunner/core/ports/__init__.py +28 -0
- codex_autorunner/core/ports/agent_backend.py +150 -0
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/core/ports/run_event.py +91 -0
- codex_autorunner/core/prompt.py +15 -7
- codex_autorunner/core/redaction.py +29 -0
- codex_autorunner/core/review_context.py +5 -8
- codex_autorunner/core/run_index.py +6 -0
- codex_autorunner/core/runner_process.py +5 -2
- codex_autorunner/core/state.py +0 -88
- codex_autorunner/core/state_roots.py +57 -0
- codex_autorunner/core/supervisor_protocol.py +15 -0
- codex_autorunner/core/supervisor_utils.py +67 -0
- codex_autorunner/core/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +201 -0
- codex_autorunner/core/ticket_manager_cli.py +432 -0
- codex_autorunner/core/update.py +24 -16
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/update_runner.py +2 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +120 -11
- codex_autorunner/discovery.py +2 -4
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +15 -10
- codex_autorunner/flows/ticket_flow/__init__.py +3 -0
- codex_autorunner/flows/ticket_flow/definition.py +98 -0
- codex_autorunner/integrations/agents/__init__.py +17 -0
- codex_autorunner/integrations/agents/backend_orchestrator.py +284 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +448 -0
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +598 -0
- codex_autorunner/integrations/agents/runner.py +91 -0
- codex_autorunner/integrations/agents/wiring.py +271 -0
- codex_autorunner/integrations/app_server/client.py +583 -152
- codex_autorunner/integrations/app_server/env.py +2 -107
- codex_autorunner/{core/app_server_events.py → integrations/app_server/event_buffer.py} +15 -8
- codex_autorunner/integrations/app_server/supervisor.py +59 -33
- codex_autorunner/integrations/telegram/adapter.py +204 -165
- codex_autorunner/integrations/telegram/api_schemas.py +120 -0
- codex_autorunner/integrations/telegram/config.py +221 -0
- codex_autorunner/integrations/telegram/constants.py +17 -2
- codex_autorunner/integrations/telegram/dispatch.py +17 -0
- codex_autorunner/integrations/telegram/doctor.py +47 -0
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -4
- codex_autorunner/integrations/telegram/handlers/commands/__init__.py +2 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +53 -57
- codex_autorunner/integrations/telegram/handlers/commands/files.py +2 -6
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +1364 -0
- codex_autorunner/integrations/telegram/handlers/commands/formatting.py +1 -1
- codex_autorunner/integrations/telegram/handlers/commands/github.py +41 -582
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +8 -8
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +137 -478
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +17 -4
- codex_autorunner/integrations/telegram/handlers/messages.py +121 -9
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +111 -16
- codex_autorunner/integrations/telegram/outbox.py +208 -37
- codex_autorunner/integrations/telegram/progress_stream.py +3 -10
- codex_autorunner/integrations/telegram/service.py +221 -42
- codex_autorunner/integrations/telegram/state.py +100 -2
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +611 -0
- codex_autorunner/integrations/telegram/transport.py +39 -4
- codex_autorunner/integrations/telegram/trigger_mode.py +53 -0
- codex_autorunner/manifest.py +2 -0
- codex_autorunner/plugin_api.py +22 -0
- codex_autorunner/routes/__init__.py +37 -67
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +3 -0
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -624
- codex_autorunner/routes/file_chat.py +7 -0
- codex_autorunner/routes/flows.py +7 -0
- codex_autorunner/routes/messages.py +7 -0
- codex_autorunner/routes/repos.py +2 -196
- codex_autorunner/routes/review.py +2 -147
- codex_autorunner/routes/sessions.py +2 -175
- codex_autorunner/routes/settings.py +2 -168
- codex_autorunner/routes/shared.py +2 -275
- codex_autorunner/routes/system.py +4 -188
- codex_autorunner/routes/usage.py +3 -0
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +3 -0
- codex_autorunner/server.py +3 -2
- codex_autorunner/static/agentControls.js +41 -11
- codex_autorunner/static/agentEvents.js +248 -0
- codex_autorunner/static/app.js +35 -24
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +36 -8
- codex_autorunner/static/bootstrap.js +1 -0
- codex_autorunner/static/bus.js +1 -0
- codex_autorunner/static/cache.js +1 -0
- codex_autorunner/static/constants.js +20 -4
- codex_autorunner/static/dashboard.js +344 -325
- codex_autorunner/static/diffRenderer.js +37 -0
- codex_autorunner/static/docChatCore.js +324 -0
- codex_autorunner/static/docChatStorage.js +65 -0
- codex_autorunner/static/docChatVoice.js +65 -0
- codex_autorunner/static/docEditor.js +133 -0
- codex_autorunner/static/env.js +1 -0
- codex_autorunner/static/eventSummarizer.js +166 -0
- codex_autorunner/static/fileChat.js +182 -0
- codex_autorunner/static/health.js +155 -0
- codex_autorunner/static/hub.js +126 -185
- codex_autorunner/static/index.html +839 -863
- codex_autorunner/static/liveUpdates.js +1 -0
- codex_autorunner/static/loader.js +1 -0
- codex_autorunner/static/messages.js +873 -0
- codex_autorunner/static/mobileCompact.js +2 -1
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +149 -217
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/styles.css +8850 -3876
- codex_autorunner/static/tabs.js +175 -11
- codex_autorunner/static/terminal.js +32 -0
- codex_autorunner/static/terminalManager.js +34 -59
- codex_autorunner/static/ticketChatActions.js +333 -0
- codex_autorunner/static/ticketChatEvents.js +16 -0
- codex_autorunner/static/ticketChatStorage.js +16 -0
- codex_autorunner/static/ticketChatStream.js +264 -0
- codex_autorunner/static/ticketEditor.js +844 -0
- codex_autorunner/static/ticketVoice.js +9 -0
- codex_autorunner/static/tickets.js +1988 -0
- codex_autorunner/static/utils.js +43 -3
- codex_autorunner/static/voice.js +1 -0
- codex_autorunner/static/workspace.js +765 -0
- codex_autorunner/static/workspaceApi.js +53 -0
- codex_autorunner/static/workspaceFileBrowser.js +504 -0
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +1224 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2019 -0
- codex_autorunner/surfaces/web/hub_jobs.py +192 -0
- codex_autorunner/surfaces/web/middleware.py +587 -0
- codex_autorunner/surfaces/web/pty_session.py +370 -0
- codex_autorunner/surfaces/web/review.py +6 -0
- codex_autorunner/surfaces/web/routes/__init__.py +78 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +277 -0
- codex_autorunner/surfaces/web/routes/app_server.py +132 -0
- codex_autorunner/surfaces/web/routes/archive.py +357 -0
- codex_autorunner/surfaces/web/routes/base.py +615 -0
- codex_autorunner/surfaces/web/routes/file_chat.py +836 -0
- codex_autorunner/surfaces/web/routes/flows.py +1164 -0
- codex_autorunner/surfaces/web/routes/messages.py +459 -0
- codex_autorunner/surfaces/web/routes/repos.py +197 -0
- codex_autorunner/surfaces/web/routes/review.py +148 -0
- codex_autorunner/surfaces/web/routes/sessions.py +176 -0
- codex_autorunner/surfaces/web/routes/settings.py +169 -0
- codex_autorunner/surfaces/web/routes/shared.py +280 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -0
- codex_autorunner/surfaces/web/routes/usage.py +89 -0
- codex_autorunner/surfaces/web/routes/voice.py +120 -0
- codex_autorunner/surfaces/web/routes/workspace.py +271 -0
- codex_autorunner/surfaces/web/runner_manager.py +25 -0
- codex_autorunner/surfaces/web/schemas.py +417 -0
- codex_autorunner/surfaces/web/static_assets.py +490 -0
- codex_autorunner/surfaces/web/static_refresh.py +86 -0
- codex_autorunner/surfaces/web/terminal_sessions.py +78 -0
- codex_autorunner/tickets/__init__.py +27 -0
- codex_autorunner/tickets/agent_pool.py +399 -0
- codex_autorunner/tickets/files.py +89 -0
- codex_autorunner/tickets/frontmatter.py +55 -0
- codex_autorunner/tickets/lint.py +102 -0
- codex_autorunner/tickets/models.py +97 -0
- codex_autorunner/tickets/outbox.py +244 -0
- codex_autorunner/tickets/replies.py +179 -0
- codex_autorunner/tickets/runner.py +881 -0
- codex_autorunner/tickets/spec_ingest.py +77 -0
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1771
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -587
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -396
- codex_autorunner/web/static_assets.py +4 -484
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/__init__.py +40 -0
- codex_autorunner/workspace/paths.py +335 -0
- codex_autorunner-1.1.0.dist-info/METADATA +154 -0
- codex_autorunner-1.1.0.dist-info/RECORD +308 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/WHEEL +1 -1
- codex_autorunner/agents/execution/policy.py +0 -292
- codex_autorunner/agents/factory.py +0 -52
- codex_autorunner/agents/orchestrator.py +0 -358
- codex_autorunner/core/doc_chat.py +0 -1446
- codex_autorunner/core/snapshot.py +0 -580
- codex_autorunner/integrations/github/chatops.py +0 -268
- codex_autorunner/integrations/github/pr_flow.py +0 -1314
- codex_autorunner/routes/docs.py +0 -381
- codex_autorunner/routes/github.py +0 -327
- codex_autorunner/routes/runs.py +0 -250
- codex_autorunner/spec_ingest.py +0 -812
- codex_autorunner/static/docChatActions.js +0 -287
- codex_autorunner/static/docChatEvents.js +0 -300
- codex_autorunner/static/docChatRender.js +0 -205
- codex_autorunner/static/docChatStream.js +0 -361
- codex_autorunner/static/docs.js +0 -20
- codex_autorunner/static/docsClipboard.js +0 -69
- codex_autorunner/static/docsCrud.js +0 -257
- codex_autorunner/static/docsDocUpdates.js +0 -62
- codex_autorunner/static/docsDrafts.js +0 -16
- codex_autorunner/static/docsElements.js +0 -69
- codex_autorunner/static/docsInit.js +0 -285
- codex_autorunner/static/docsParse.js +0 -160
- codex_autorunner/static/docsSnapshot.js +0 -87
- codex_autorunner/static/docsSpecIngest.js +0 -263
- codex_autorunner/static/docsState.js +0 -127
- codex_autorunner/static/docsThreadRegistry.js +0 -44
- codex_autorunner/static/docsUi.js +0 -153
- codex_autorunner/static/docsVoice.js +0 -56
- codex_autorunner/static/github.js +0 -504
- codex_autorunner/static/logs.js +0 -678
- codex_autorunner/static/review.js +0 -157
- codex_autorunner/static/runs.js +0 -418
- codex_autorunner/static/snapshot.js +0 -124
- codex_autorunner/static/state.js +0 -94
- codex_autorunner/static/todoPreview.js +0 -27
- codex_autorunner/workspace.py +0 -16
- codex_autorunner-0.1.2.dist-info/METADATA +0 -249
- codex_autorunner-0.1.2.dist-info/RECORD +0 -222
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-0.1.2.dist-info → codex_autorunner-1.1.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,1224 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import ipaddress
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import NoReturn, Optional
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
import typer
|
|
14
|
+
import uvicorn
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from ...agents.registry import validate_agent_id
|
|
18
|
+
from ...bootstrap import seed_hub_files, seed_repo_files
|
|
19
|
+
from ...core.config import (
|
|
20
|
+
CONFIG_FILENAME,
|
|
21
|
+
ConfigError,
|
|
22
|
+
HubConfig,
|
|
23
|
+
RepoConfig,
|
|
24
|
+
_normalize_base_path,
|
|
25
|
+
collect_env_overrides,
|
|
26
|
+
derive_repo_config,
|
|
27
|
+
find_nearest_hub_config_path,
|
|
28
|
+
load_hub_config,
|
|
29
|
+
load_repo_config,
|
|
30
|
+
)
|
|
31
|
+
from ...core.engine import DoctorReport, Engine, LockError, clear_stale_lock, doctor
|
|
32
|
+
from ...core.git_utils import GitError, run_git
|
|
33
|
+
from ...core.hub import HubSupervisor
|
|
34
|
+
from ...core.logging_utils import log_event, setup_rotating_logger
|
|
35
|
+
from ...core.optional_dependencies import require_optional_dependencies
|
|
36
|
+
from ...core.state import RunnerState, load_state, now_iso, save_state, state_lock
|
|
37
|
+
from ...core.usage import (
|
|
38
|
+
UsageError,
|
|
39
|
+
default_codex_home,
|
|
40
|
+
parse_iso_datetime,
|
|
41
|
+
summarize_hub_usage,
|
|
42
|
+
summarize_repo_usage,
|
|
43
|
+
)
|
|
44
|
+
from ...core.utils import RepoNotFoundError, default_editor, find_repo_root
|
|
45
|
+
from ...integrations.agents.wiring import (
|
|
46
|
+
build_agent_backend_factory,
|
|
47
|
+
build_app_server_supervisor_factory,
|
|
48
|
+
)
|
|
49
|
+
from ...integrations.telegram.adapter import TelegramAPIError, TelegramBotClient
|
|
50
|
+
from ...integrations.telegram.doctor import telegram_doctor_checks
|
|
51
|
+
from ...integrations.telegram.service import (
|
|
52
|
+
TelegramBotConfig,
|
|
53
|
+
TelegramBotConfigError,
|
|
54
|
+
TelegramBotLockError,
|
|
55
|
+
TelegramBotService,
|
|
56
|
+
)
|
|
57
|
+
from ...integrations.telegram.state import TelegramStateStore
|
|
58
|
+
from ...manifest import load_manifest
|
|
59
|
+
from ...voice import VoiceConfig
|
|
60
|
+
from ..web.app import create_hub_app
|
|
61
|
+
|
|
62
|
+
logger = logging.getLogger("codex_autorunner.cli")
|
|
63
|
+
|
|
64
|
+
app = typer.Typer(add_completion=False)
|
|
65
|
+
hub_app = typer.Typer(add_completion=False)
|
|
66
|
+
telegram_app = typer.Typer(add_completion=False)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main() -> None:
|
|
70
|
+
"""Entrypoint for CLI execution."""
|
|
71
|
+
app()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _raise_exit(message: str, *, cause: Optional[BaseException] = None) -> NoReturn:
|
|
75
|
+
typer.echo(message, err=True)
|
|
76
|
+
if cause is not None:
|
|
77
|
+
raise typer.Exit(code=1) from cause
|
|
78
|
+
raise typer.Exit(code=1)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _require_repo_config(repo: Optional[Path], hub: Optional[Path]) -> Engine:
|
|
82
|
+
try:
|
|
83
|
+
repo_root = find_repo_root(repo or Path.cwd())
|
|
84
|
+
except RepoNotFoundError as exc:
|
|
85
|
+
_raise_exit("No .git directory found for repo commands.", cause=exc)
|
|
86
|
+
try:
|
|
87
|
+
config = load_repo_config(repo_root, hub_path=hub)
|
|
88
|
+
return Engine(
|
|
89
|
+
repo_root,
|
|
90
|
+
config=config,
|
|
91
|
+
hub_path=hub,
|
|
92
|
+
backend_factory=build_agent_backend_factory(repo_root, config),
|
|
93
|
+
app_server_supervisor_factory=build_app_server_supervisor_factory(config),
|
|
94
|
+
agent_id_validator=validate_agent_id,
|
|
95
|
+
)
|
|
96
|
+
except ConfigError as exc:
|
|
97
|
+
_raise_exit(str(exc), cause=exc)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _require_hub_config(path: Optional[Path]) -> HubConfig:
|
|
101
|
+
try:
|
|
102
|
+
return load_hub_config(path or Path.cwd())
|
|
103
|
+
except ConfigError as exc:
|
|
104
|
+
_raise_exit(str(exc), cause=exc)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _build_server_url(config, path: str) -> str:
|
|
108
|
+
base_path = config.server_base_path or ""
|
|
109
|
+
if base_path.endswith("/") and path.startswith("/"):
|
|
110
|
+
base_path = base_path[:-1]
|
|
111
|
+
return f"http://{config.server_host}:{config.server_port}{base_path}{path}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _resolve_hub_config_path_for_cli(
|
|
115
|
+
repo_root: Path, hub: Optional[Path]
|
|
116
|
+
) -> Optional[Path]:
|
|
117
|
+
if hub:
|
|
118
|
+
candidate = hub
|
|
119
|
+
if candidate.is_dir():
|
|
120
|
+
candidate = candidate / CONFIG_FILENAME
|
|
121
|
+
return candidate if candidate.exists() else None
|
|
122
|
+
return find_nearest_hub_config_path(repo_root)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _resolve_repo_api_path(repo_root: Path, hub: Optional[Path], path: str) -> str:
|
|
126
|
+
if not path.startswith("/"):
|
|
127
|
+
path = f"/{path}"
|
|
128
|
+
hub_config_path = _resolve_hub_config_path_for_cli(repo_root, hub)
|
|
129
|
+
if hub_config_path is None:
|
|
130
|
+
return path
|
|
131
|
+
hub_root = hub_config_path.parent.parent.resolve()
|
|
132
|
+
manifest_rel: Optional[str] = None
|
|
133
|
+
try:
|
|
134
|
+
raw = yaml.safe_load(hub_config_path.read_text(encoding="utf-8")) or {}
|
|
135
|
+
if isinstance(raw, dict):
|
|
136
|
+
hub_cfg = raw.get("hub")
|
|
137
|
+
if isinstance(hub_cfg, dict):
|
|
138
|
+
manifest_value = hub_cfg.get("manifest")
|
|
139
|
+
if isinstance(manifest_value, str) and manifest_value.strip():
|
|
140
|
+
manifest_rel = manifest_value.strip()
|
|
141
|
+
except (OSError, yaml.YAMLError, KeyError, ValueError) as exc:
|
|
142
|
+
logger.debug("Failed to read hub config for manifest: %s", exc)
|
|
143
|
+
manifest_rel = None
|
|
144
|
+
manifest_path = hub_root / (manifest_rel or ".codex-autorunner/manifest.yml")
|
|
145
|
+
if not manifest_path.exists():
|
|
146
|
+
return path
|
|
147
|
+
try:
|
|
148
|
+
manifest = load_manifest(manifest_path, hub_root)
|
|
149
|
+
except (OSError, ValueError, KeyError) as exc:
|
|
150
|
+
logger.debug("Failed to load manifest: %s", exc)
|
|
151
|
+
return path
|
|
152
|
+
repo_root = repo_root.resolve()
|
|
153
|
+
for entry in manifest.repos:
|
|
154
|
+
candidate = (hub_root / entry.path).resolve()
|
|
155
|
+
if candidate == repo_root:
|
|
156
|
+
return f"/repos/{entry.id}{path}"
|
|
157
|
+
return path
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _resolve_auth_token(env_name: str) -> Optional[str]:
|
|
161
|
+
if not env_name:
|
|
162
|
+
return None
|
|
163
|
+
value = os.environ.get(env_name)
|
|
164
|
+
if value is None:
|
|
165
|
+
return None
|
|
166
|
+
value = value.strip()
|
|
167
|
+
return value or None
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _require_auth_token(env_name: Optional[str]) -> Optional[str]:
|
|
171
|
+
if not env_name:
|
|
172
|
+
return None
|
|
173
|
+
token = _resolve_auth_token(env_name)
|
|
174
|
+
if not token:
|
|
175
|
+
_raise_exit(
|
|
176
|
+
f"server.auth_token_env is set to {env_name}, but the environment variable is missing."
|
|
177
|
+
)
|
|
178
|
+
return token
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _is_loopback_host(host: str) -> bool:
|
|
182
|
+
if host == "localhost":
|
|
183
|
+
return True
|
|
184
|
+
try:
|
|
185
|
+
return ipaddress.ip_address(host).is_loopback
|
|
186
|
+
except ValueError:
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _enforce_bind_auth(host: str, token_env: str) -> None:
|
|
191
|
+
if _is_loopback_host(host):
|
|
192
|
+
return
|
|
193
|
+
if _resolve_auth_token(token_env):
|
|
194
|
+
return
|
|
195
|
+
_raise_exit(
|
|
196
|
+
"Refusing to bind to a non-loopback host without server.auth_token_env set."
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _request_json(
|
|
201
|
+
method: str,
|
|
202
|
+
url: str,
|
|
203
|
+
payload: Optional[dict] = None,
|
|
204
|
+
token_env: Optional[str] = None,
|
|
205
|
+
) -> dict:
|
|
206
|
+
headers = None
|
|
207
|
+
if token_env:
|
|
208
|
+
token = _require_auth_token(token_env)
|
|
209
|
+
headers = {"Authorization": f"Bearer {token}"}
|
|
210
|
+
response = httpx.request(method, url, json=payload, timeout=2.0, headers=headers)
|
|
211
|
+
response.raise_for_status()
|
|
212
|
+
data = response.json()
|
|
213
|
+
return data if isinstance(data, dict) else {}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _require_optional_feature(
|
|
217
|
+
*, feature: str, deps: list[tuple[str, str]], extra: Optional[str] = None
|
|
218
|
+
) -> None:
|
|
219
|
+
try:
|
|
220
|
+
require_optional_dependencies(feature=feature, deps=deps, extra=extra)
|
|
221
|
+
except ConfigError as exc:
|
|
222
|
+
_raise_exit(str(exc), cause=exc)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
app.add_typer(hub_app, name="hub")
|
|
226
|
+
app.add_typer(telegram_app, name="telegram")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _has_nested_git(path: Path) -> bool:
|
|
230
|
+
try:
|
|
231
|
+
for child in path.iterdir():
|
|
232
|
+
if not child.is_dir() or child.is_symlink():
|
|
233
|
+
continue
|
|
234
|
+
if (child / ".git").exists():
|
|
235
|
+
return True
|
|
236
|
+
if _has_nested_git(child):
|
|
237
|
+
return True
|
|
238
|
+
except OSError:
|
|
239
|
+
return False
|
|
240
|
+
return False
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
@app.command()
|
|
244
|
+
def init(
|
|
245
|
+
path: Optional[Path] = typer.Argument(None, help="Repo path; defaults to CWD"),
|
|
246
|
+
force: bool = typer.Option(False, "--force", help="Overwrite existing files"),
|
|
247
|
+
git_init: bool = typer.Option(False, "--git-init", help="Run git init if missing"),
|
|
248
|
+
mode: str = typer.Option(
|
|
249
|
+
"auto",
|
|
250
|
+
"--mode",
|
|
251
|
+
help="Initialization mode: repo, hub, or auto (default)",
|
|
252
|
+
),
|
|
253
|
+
):
|
|
254
|
+
"""Initialize a repo for Codex autorunner."""
|
|
255
|
+
start_path = (path or Path.cwd()).resolve()
|
|
256
|
+
mode = (mode or "auto").lower()
|
|
257
|
+
if mode not in ("auto", "repo", "hub"):
|
|
258
|
+
_raise_exit("Invalid mode; expected repo, hub, or auto")
|
|
259
|
+
|
|
260
|
+
git_required = True
|
|
261
|
+
target_root: Optional[Path] = None
|
|
262
|
+
selected_mode = mode
|
|
263
|
+
|
|
264
|
+
# First try to treat this as a repo init if requested or auto-detected via .git.
|
|
265
|
+
if mode in ("auto", "repo"):
|
|
266
|
+
try:
|
|
267
|
+
target_root = find_repo_root(start_path)
|
|
268
|
+
selected_mode = "repo"
|
|
269
|
+
except RepoNotFoundError:
|
|
270
|
+
target_root = None
|
|
271
|
+
|
|
272
|
+
# If no git root was found, decide between hub or repo-with-git-init.
|
|
273
|
+
if target_root is None:
|
|
274
|
+
target_root = start_path
|
|
275
|
+
if mode in ("hub",) or (mode == "auto" and _has_nested_git(target_root)):
|
|
276
|
+
selected_mode = "hub"
|
|
277
|
+
git_required = False
|
|
278
|
+
elif git_init:
|
|
279
|
+
selected_mode = "repo"
|
|
280
|
+
try:
|
|
281
|
+
proc = run_git(["init"], target_root, check=False)
|
|
282
|
+
except GitError as exc:
|
|
283
|
+
_raise_exit(f"git init failed: {exc}")
|
|
284
|
+
if proc.returncode != 0:
|
|
285
|
+
detail = (
|
|
286
|
+
proc.stderr or proc.stdout or ""
|
|
287
|
+
).strip() or f"exit {proc.returncode}"
|
|
288
|
+
_raise_exit(f"git init failed: {detail}")
|
|
289
|
+
else:
|
|
290
|
+
_raise_exit("No .git directory found; rerun with --git-init to create one")
|
|
291
|
+
|
|
292
|
+
ca_dir = target_root / ".codex-autorunner"
|
|
293
|
+
ca_dir.mkdir(parents=True, exist_ok=True)
|
|
294
|
+
|
|
295
|
+
hub_config_path = find_nearest_hub_config_path(target_root)
|
|
296
|
+
try:
|
|
297
|
+
if selected_mode == "hub":
|
|
298
|
+
seed_hub_files(target_root, force=force)
|
|
299
|
+
typer.echo(f"Initialized hub at {ca_dir}")
|
|
300
|
+
else:
|
|
301
|
+
seed_repo_files(target_root, force=force, git_required=git_required)
|
|
302
|
+
typer.echo(f"Initialized repo at {ca_dir}")
|
|
303
|
+
if hub_config_path is None:
|
|
304
|
+
seed_hub_files(target_root, force=force)
|
|
305
|
+
typer.echo(f"Initialized hub at {ca_dir}")
|
|
306
|
+
except ConfigError as exc:
|
|
307
|
+
_raise_exit(str(exc), cause=exc)
|
|
308
|
+
typer.echo("Init complete")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
@app.command()
|
|
312
|
+
def status(
|
|
313
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
314
|
+
hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
|
|
315
|
+
output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
|
|
316
|
+
):
|
|
317
|
+
"""Show autorunner status."""
|
|
318
|
+
engine = _require_repo_config(repo, hub)
|
|
319
|
+
state = load_state(engine.state_path)
|
|
320
|
+
outstanding, _ = engine.docs.todos()
|
|
321
|
+
repo_key = str(engine.repo_root)
|
|
322
|
+
session_id = state.repo_to_session.get(repo_key) or state.repo_to_session.get(
|
|
323
|
+
f"{repo_key}:codex"
|
|
324
|
+
)
|
|
325
|
+
opencode_session_id = state.repo_to_session.get(f"{repo_key}:opencode")
|
|
326
|
+
session_record = state.sessions.get(session_id) if session_id else None
|
|
327
|
+
opencode_record = (
|
|
328
|
+
state.sessions.get(opencode_session_id) if opencode_session_id else None
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
if output_json:
|
|
332
|
+
hub_config_path = _resolve_hub_config_path_for_cli(engine.repo_root, hub)
|
|
333
|
+
payload = {
|
|
334
|
+
"repo": str(engine.repo_root),
|
|
335
|
+
"hub": (
|
|
336
|
+
str(hub_config_path.parent.parent.resolve())
|
|
337
|
+
if hub_config_path
|
|
338
|
+
else None
|
|
339
|
+
),
|
|
340
|
+
"status": state.status,
|
|
341
|
+
"last_run_id": state.last_run_id,
|
|
342
|
+
"last_exit_code": state.last_exit_code,
|
|
343
|
+
"last_run_started_at": state.last_run_started_at,
|
|
344
|
+
"last_run_finished_at": state.last_run_finished_at,
|
|
345
|
+
"runner_pid": state.runner_pid,
|
|
346
|
+
"session_id": session_id,
|
|
347
|
+
"session_record": (
|
|
348
|
+
{
|
|
349
|
+
"repo_path": session_record.repo_path,
|
|
350
|
+
"created_at": session_record.created_at,
|
|
351
|
+
"last_seen_at": session_record.last_seen_at,
|
|
352
|
+
"status": session_record.status,
|
|
353
|
+
"agent": session_record.agent,
|
|
354
|
+
}
|
|
355
|
+
if session_record
|
|
356
|
+
else None
|
|
357
|
+
),
|
|
358
|
+
"opencode_session_id": opencode_session_id,
|
|
359
|
+
"opencode_record": (
|
|
360
|
+
{
|
|
361
|
+
"repo_path": opencode_record.repo_path,
|
|
362
|
+
"created_at": opencode_record.created_at,
|
|
363
|
+
"last_seen_at": opencode_record.last_seen_at,
|
|
364
|
+
"status": opencode_record.status,
|
|
365
|
+
"agent": opencode_record.agent,
|
|
366
|
+
}
|
|
367
|
+
if opencode_record
|
|
368
|
+
else None
|
|
369
|
+
),
|
|
370
|
+
"outstanding_todos": len(outstanding),
|
|
371
|
+
}
|
|
372
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
typer.echo(f"Repo: {engine.repo_root}")
|
|
376
|
+
typer.echo(f"Status: {state.status}")
|
|
377
|
+
typer.echo(f"Last run id: {state.last_run_id}")
|
|
378
|
+
typer.echo(f"Last exit code: {state.last_exit_code}")
|
|
379
|
+
typer.echo(f"Last start: {state.last_run_started_at}")
|
|
380
|
+
typer.echo(f"Last finish: {state.last_run_finished_at}")
|
|
381
|
+
typer.echo(f"Runner pid: {state.runner_pid}")
|
|
382
|
+
if not session_id and not opencode_session_id:
|
|
383
|
+
typer.echo("Terminal session: none")
|
|
384
|
+
if session_id:
|
|
385
|
+
detail = ""
|
|
386
|
+
if session_record:
|
|
387
|
+
detail = f" (status={session_record.status}, last_seen={session_record.last_seen_at})"
|
|
388
|
+
typer.echo(f"Terminal session (codex): {session_id}{detail}")
|
|
389
|
+
if opencode_session_id and opencode_session_id != session_id:
|
|
390
|
+
detail = ""
|
|
391
|
+
if opencode_record:
|
|
392
|
+
detail = f" (status={opencode_record.status}, last_seen={opencode_record.last_seen_at})"
|
|
393
|
+
typer.echo(f"Terminal session (opencode): {opencode_session_id}{detail}")
|
|
394
|
+
typer.echo(f"Outstanding TODO items: {len(outstanding)}")
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
@app.command()
|
|
398
|
+
def sessions(
|
|
399
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
400
|
+
hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
|
|
401
|
+
output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
|
|
402
|
+
):
|
|
403
|
+
"""List active terminal sessions."""
|
|
404
|
+
engine = _require_repo_config(repo, hub)
|
|
405
|
+
config = engine.config
|
|
406
|
+
path = _resolve_repo_api_path(engine.repo_root, hub, "/api/sessions")
|
|
407
|
+
url = _build_server_url(config, path)
|
|
408
|
+
auth_token = _resolve_auth_token(config.server_auth_token_env)
|
|
409
|
+
if auth_token:
|
|
410
|
+
url = f"{url}?include_abs_paths=1"
|
|
411
|
+
payload = None
|
|
412
|
+
source = "server"
|
|
413
|
+
try:
|
|
414
|
+
payload = _request_json("GET", url, token_env=config.server_auth_token_env)
|
|
415
|
+
except (
|
|
416
|
+
httpx.HTTPError,
|
|
417
|
+
httpx.ConnectError,
|
|
418
|
+
httpx.TimeoutException,
|
|
419
|
+
OSError,
|
|
420
|
+
) as exc:
|
|
421
|
+
logger.debug(
|
|
422
|
+
"Failed to fetch sessions from server, falling back to state: %s", exc
|
|
423
|
+
)
|
|
424
|
+
state = load_state(engine.state_path)
|
|
425
|
+
payload = {
|
|
426
|
+
"sessions": [
|
|
427
|
+
{
|
|
428
|
+
"session_id": session_id,
|
|
429
|
+
"repo_path": record.repo_path,
|
|
430
|
+
"created_at": record.created_at,
|
|
431
|
+
"last_seen_at": record.last_seen_at,
|
|
432
|
+
"status": record.status,
|
|
433
|
+
"alive": None,
|
|
434
|
+
}
|
|
435
|
+
for session_id, record in state.sessions.items()
|
|
436
|
+
],
|
|
437
|
+
"repo_to_session": dict(state.repo_to_session),
|
|
438
|
+
}
|
|
439
|
+
source = "state"
|
|
440
|
+
|
|
441
|
+
if output_json:
|
|
442
|
+
if source != "server":
|
|
443
|
+
payload["source"] = source
|
|
444
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
445
|
+
return
|
|
446
|
+
|
|
447
|
+
sessions_payload = payload.get("sessions", []) if isinstance(payload, dict) else []
|
|
448
|
+
typer.echo(f"Sessions ({source}): {len(sessions_payload)}")
|
|
449
|
+
for entry in sessions_payload:
|
|
450
|
+
if not isinstance(entry, dict):
|
|
451
|
+
continue
|
|
452
|
+
session_id = entry.get("session_id") or "unknown"
|
|
453
|
+
repo_path = entry.get("abs_repo_path") or entry.get("repo_path") or "unknown"
|
|
454
|
+
status = entry.get("status") or "unknown"
|
|
455
|
+
last_seen = entry.get("last_seen_at") or "unknown"
|
|
456
|
+
alive = entry.get("alive")
|
|
457
|
+
alive_text = "unknown" if alive is None else str(bool(alive))
|
|
458
|
+
typer.echo(
|
|
459
|
+
f"- {session_id}: repo={repo_path} status={status} last_seen={last_seen} alive={alive_text}"
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
@app.command("stop-session")
|
|
464
|
+
def stop_session(
|
|
465
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
466
|
+
hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
|
|
467
|
+
session_id: Optional[str] = typer.Option(
|
|
468
|
+
None, "--session", help="Session id to stop"
|
|
469
|
+
),
|
|
470
|
+
):
|
|
471
|
+
"""Stop a terminal session by id or repo path."""
|
|
472
|
+
engine = _require_repo_config(repo, hub)
|
|
473
|
+
config = engine.config
|
|
474
|
+
payload: dict[str, str] = {}
|
|
475
|
+
if session_id:
|
|
476
|
+
payload["session_id"] = session_id
|
|
477
|
+
else:
|
|
478
|
+
payload["repo_path"] = str(engine.repo_root)
|
|
479
|
+
|
|
480
|
+
path = _resolve_repo_api_path(engine.repo_root, hub, "/api/sessions/stop")
|
|
481
|
+
url = _build_server_url(config, path)
|
|
482
|
+
try:
|
|
483
|
+
response = _request_json(
|
|
484
|
+
"POST", url, payload, token_env=config.server_auth_token_env
|
|
485
|
+
)
|
|
486
|
+
stopped_id = response.get("session_id", payload.get("session_id", ""))
|
|
487
|
+
typer.echo(f"Stopped session {stopped_id}")
|
|
488
|
+
return
|
|
489
|
+
except (
|
|
490
|
+
httpx.HTTPError,
|
|
491
|
+
httpx.ConnectError,
|
|
492
|
+
httpx.TimeoutException,
|
|
493
|
+
OSError,
|
|
494
|
+
) as exc:
|
|
495
|
+
logger.debug(
|
|
496
|
+
"Failed to stop session via server, falling back to state: %s", exc
|
|
497
|
+
)
|
|
498
|
+
|
|
499
|
+
with state_lock(engine.state_path):
|
|
500
|
+
state = load_state(engine.state_path)
|
|
501
|
+
target_id = payload.get("session_id")
|
|
502
|
+
if not target_id:
|
|
503
|
+
repo_lookup = payload.get("repo_path")
|
|
504
|
+
if repo_lookup:
|
|
505
|
+
target_id = (
|
|
506
|
+
state.repo_to_session.get(repo_lookup)
|
|
507
|
+
or state.repo_to_session.get(f"{repo_lookup}:codex")
|
|
508
|
+
or state.repo_to_session.get(f"{repo_lookup}:opencode")
|
|
509
|
+
)
|
|
510
|
+
if not target_id:
|
|
511
|
+
_raise_exit("Session not found (server unavailable)")
|
|
512
|
+
state.sessions.pop(target_id, None)
|
|
513
|
+
state.repo_to_session = {
|
|
514
|
+
repo_key: sid
|
|
515
|
+
for repo_key, sid in state.repo_to_session.items()
|
|
516
|
+
if sid != target_id
|
|
517
|
+
}
|
|
518
|
+
save_state(engine.state_path, state)
|
|
519
|
+
typer.echo(f"Stopped session {target_id} (state only)")
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
@app.command()
|
|
523
|
+
def usage(
|
|
524
|
+
repo: Optional[Path] = typer.Option(
|
|
525
|
+
None, "--repo", help="Repo or hub path; defaults to CWD"
|
|
526
|
+
),
|
|
527
|
+
hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
|
|
528
|
+
codex_home: Optional[Path] = typer.Option(
|
|
529
|
+
None, "--codex-home", help="Override CODEX_HOME (defaults to env or ~/.codex)"
|
|
530
|
+
),
|
|
531
|
+
since: Optional[str] = typer.Option(
|
|
532
|
+
None,
|
|
533
|
+
"--since",
|
|
534
|
+
help="ISO timestamp filter, e.g. 2025-12-01 or 2025-12-01T12:00Z",
|
|
535
|
+
),
|
|
536
|
+
until: Optional[str] = typer.Option(
|
|
537
|
+
None, "--until", help="Upper bound ISO timestamp filter"
|
|
538
|
+
),
|
|
539
|
+
output_json: bool = typer.Option(False, "--json", help="Emit JSON output"),
|
|
540
|
+
):
|
|
541
|
+
"""Show Codex/OpenCode token usage for a repo or hub by reading local session logs."""
|
|
542
|
+
try:
|
|
543
|
+
since_dt = parse_iso_datetime(since)
|
|
544
|
+
until_dt = parse_iso_datetime(until)
|
|
545
|
+
except UsageError as exc:
|
|
546
|
+
_raise_exit(str(exc), cause=exc)
|
|
547
|
+
|
|
548
|
+
codex_root = (codex_home or default_codex_home()).expanduser()
|
|
549
|
+
|
|
550
|
+
repo_root: Optional[Path] = None
|
|
551
|
+
try:
|
|
552
|
+
repo_root = find_repo_root(repo or Path.cwd())
|
|
553
|
+
except RepoNotFoundError:
|
|
554
|
+
repo_root = None
|
|
555
|
+
|
|
556
|
+
if repo_root and (repo_root / ".codex-autorunner" / "state.sqlite3").exists():
|
|
557
|
+
engine = _require_repo_config(repo, hub)
|
|
558
|
+
else:
|
|
559
|
+
try:
|
|
560
|
+
config = load_hub_config(hub or repo or Path.cwd())
|
|
561
|
+
except ConfigError as exc:
|
|
562
|
+
_raise_exit(str(exc), cause=exc)
|
|
563
|
+
manifest = load_manifest(config.manifest_path, config.root)
|
|
564
|
+
repo_map = [(entry.id, (config.root / entry.path)) for entry in manifest.repos]
|
|
565
|
+
per_repo, unmatched = summarize_hub_usage(
|
|
566
|
+
repo_map,
|
|
567
|
+
codex_root,
|
|
568
|
+
since=since_dt,
|
|
569
|
+
until=until_dt,
|
|
570
|
+
)
|
|
571
|
+
if output_json:
|
|
572
|
+
payload = {
|
|
573
|
+
"mode": "hub",
|
|
574
|
+
"hub_root": str(config.root),
|
|
575
|
+
"codex_home": str(codex_root),
|
|
576
|
+
"since": since,
|
|
577
|
+
"until": until,
|
|
578
|
+
"repos": {
|
|
579
|
+
repo_id: summary.to_dict() for repo_id, summary in per_repo.items()
|
|
580
|
+
},
|
|
581
|
+
"unmatched": unmatched.to_dict(),
|
|
582
|
+
}
|
|
583
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
584
|
+
return
|
|
585
|
+
|
|
586
|
+
typer.echo(f"Hub: {config.root}")
|
|
587
|
+
typer.echo(f"CODEX_HOME: {codex_root}")
|
|
588
|
+
typer.echo(f"Repos: {len(per_repo)}")
|
|
589
|
+
for repo_id, summary in per_repo.items():
|
|
590
|
+
typer.echo(
|
|
591
|
+
f"- {repo_id}: total={summary.totals.total_tokens} "
|
|
592
|
+
f"(input={summary.totals.input_tokens}, cached={summary.totals.cached_input_tokens}, "
|
|
593
|
+
f"output={summary.totals.output_tokens}, reasoning={summary.totals.reasoning_output_tokens}) "
|
|
594
|
+
f"events={summary.events}"
|
|
595
|
+
)
|
|
596
|
+
if unmatched.events or unmatched.totals.total_tokens:
|
|
597
|
+
typer.echo(
|
|
598
|
+
f"- unmatched: total={unmatched.totals.total_tokens} events={unmatched.events}"
|
|
599
|
+
)
|
|
600
|
+
return
|
|
601
|
+
|
|
602
|
+
summary = summarize_repo_usage(
|
|
603
|
+
engine.repo_root,
|
|
604
|
+
codex_root,
|
|
605
|
+
since=since_dt,
|
|
606
|
+
until=until_dt,
|
|
607
|
+
)
|
|
608
|
+
|
|
609
|
+
if output_json:
|
|
610
|
+
payload = {
|
|
611
|
+
"mode": "repo",
|
|
612
|
+
"repo": str(engine.repo_root),
|
|
613
|
+
"codex_home": str(codex_root),
|
|
614
|
+
"since": since,
|
|
615
|
+
"until": until,
|
|
616
|
+
"usage": summary.to_dict(),
|
|
617
|
+
}
|
|
618
|
+
typer.echo(json.dumps(payload, indent=2))
|
|
619
|
+
return
|
|
620
|
+
|
|
621
|
+
typer.echo(f"Repo: {engine.repo_root}")
|
|
622
|
+
typer.echo(f"CODEX_HOME: {codex_root}")
|
|
623
|
+
typer.echo(
|
|
624
|
+
f"Totals: total={summary.totals.total_tokens} "
|
|
625
|
+
f"(input={summary.totals.input_tokens}, cached={summary.totals.cached_input_tokens}, "
|
|
626
|
+
f"output={summary.totals.output_tokens}, reasoning={summary.totals.reasoning_output_tokens})"
|
|
627
|
+
)
|
|
628
|
+
typer.echo(f"Events counted: {summary.events}")
|
|
629
|
+
if summary.latest_rate_limits:
|
|
630
|
+
primary = summary.latest_rate_limits.get("primary", {}) or {}
|
|
631
|
+
secondary = summary.latest_rate_limits.get("secondary", {}) or {}
|
|
632
|
+
typer.echo(
|
|
633
|
+
f"Latest rate limits: primary_used={primary.get('used_percent')}%/{primary.get('window_minutes')}m, "
|
|
634
|
+
f"secondary_used={secondary.get('used_percent')}%/{secondary.get('window_minutes')}m"
|
|
635
|
+
)
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
@app.command()
|
|
639
|
+
def run(
|
|
640
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
641
|
+
hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
|
|
642
|
+
force: bool = typer.Option(False, "--force", help="Ignore existing lock"),
|
|
643
|
+
):
|
|
644
|
+
"""Run the autorunner loop."""
|
|
645
|
+
engine: Optional[Engine] = None
|
|
646
|
+
try:
|
|
647
|
+
engine = _require_repo_config(repo, hub)
|
|
648
|
+
engine.clear_stop_request()
|
|
649
|
+
engine.acquire_lock(force=force)
|
|
650
|
+
engine.run_loop()
|
|
651
|
+
except (ConfigError, LockError) as exc:
|
|
652
|
+
_raise_exit(str(exc), cause=exc)
|
|
653
|
+
finally:
|
|
654
|
+
if engine:
|
|
655
|
+
try:
|
|
656
|
+
engine.release_lock()
|
|
657
|
+
except OSError as exc:
|
|
658
|
+
logger.debug("Failed to release lock in run command: %s", exc)
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
@app.command()
|
|
662
|
+
def once(
|
|
663
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
664
|
+
hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
|
|
665
|
+
force: bool = typer.Option(False, "--force", help="Ignore existing lock"),
|
|
666
|
+
):
|
|
667
|
+
"""Execute a single Codex run."""
|
|
668
|
+
engine: Optional[Engine] = None
|
|
669
|
+
try:
|
|
670
|
+
engine = _require_repo_config(repo, hub)
|
|
671
|
+
engine.clear_stop_request()
|
|
672
|
+
engine.acquire_lock(force=force)
|
|
673
|
+
engine.run_once()
|
|
674
|
+
except (ConfigError, LockError) as exc:
|
|
675
|
+
_raise_exit(str(exc), cause=exc)
|
|
676
|
+
finally:
|
|
677
|
+
if engine:
|
|
678
|
+
try:
|
|
679
|
+
engine.release_lock()
|
|
680
|
+
except OSError as exc:
|
|
681
|
+
logger.debug("Failed to release lock in once command: %s", exc)
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
@app.command()
|
|
685
|
+
def kill(
|
|
686
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
687
|
+
hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
|
|
688
|
+
):
|
|
689
|
+
"""Force-kill a running autorunner and clear stale lock/state."""
|
|
690
|
+
engine = _require_repo_config(repo, hub)
|
|
691
|
+
pid = engine.kill_running_process()
|
|
692
|
+
with state_lock(engine.state_path):
|
|
693
|
+
state = load_state(engine.state_path)
|
|
694
|
+
new_state = RunnerState(
|
|
695
|
+
last_run_id=state.last_run_id,
|
|
696
|
+
status="error",
|
|
697
|
+
last_exit_code=137,
|
|
698
|
+
last_run_started_at=state.last_run_started_at,
|
|
699
|
+
last_run_finished_at=now_iso(),
|
|
700
|
+
autorunner_agent_override=state.autorunner_agent_override,
|
|
701
|
+
autorunner_model_override=state.autorunner_model_override,
|
|
702
|
+
autorunner_effort_override=state.autorunner_effort_override,
|
|
703
|
+
autorunner_approval_policy=state.autorunner_approval_policy,
|
|
704
|
+
autorunner_sandbox_mode=state.autorunner_sandbox_mode,
|
|
705
|
+
autorunner_workspace_write_network=state.autorunner_workspace_write_network,
|
|
706
|
+
runner_pid=None,
|
|
707
|
+
sessions=state.sessions,
|
|
708
|
+
repo_to_session=state.repo_to_session,
|
|
709
|
+
)
|
|
710
|
+
save_state(engine.state_path, new_state)
|
|
711
|
+
clear_stale_lock(engine.lock_path)
|
|
712
|
+
if pid:
|
|
713
|
+
typer.echo(f"Sent SIGTERM to pid {pid}")
|
|
714
|
+
else:
|
|
715
|
+
typer.echo("No active autorunner process found; cleared stale lock if any.")
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
@app.command()
|
|
719
|
+
def resume(
|
|
720
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
721
|
+
hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
|
|
722
|
+
once: bool = typer.Option(False, "--once", help="Resume with a single run"),
|
|
723
|
+
force: bool = typer.Option(False, "--force", help="Override active lock"),
|
|
724
|
+
):
|
|
725
|
+
"""Resume a stopped/errored autorunner, clearing stale locks if needed."""
|
|
726
|
+
engine: Optional[Engine] = None
|
|
727
|
+
try:
|
|
728
|
+
engine = _require_repo_config(repo, hub)
|
|
729
|
+
engine.clear_stop_request()
|
|
730
|
+
clear_stale_lock(engine.lock_path)
|
|
731
|
+
engine.acquire_lock(force=force)
|
|
732
|
+
engine.run_loop(stop_after_runs=1 if once else None)
|
|
733
|
+
except (ConfigError, LockError) as exc:
|
|
734
|
+
_raise_exit(str(exc), cause=exc)
|
|
735
|
+
finally:
|
|
736
|
+
if engine:
|
|
737
|
+
try:
|
|
738
|
+
engine.release_lock()
|
|
739
|
+
except OSError as exc:
|
|
740
|
+
logger.debug("Failed to release lock in resume command: %s", exc)
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
@app.command()
|
|
744
|
+
def log(
|
|
745
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
746
|
+
hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
|
|
747
|
+
run_id: Optional[int] = typer.Option(None, "--run", help="Show a specific run"),
|
|
748
|
+
tail: Optional[int] = typer.Option(None, "--tail", help="Tail last N lines"),
|
|
749
|
+
):
|
|
750
|
+
"""Show autorunner log output."""
|
|
751
|
+
engine = _require_repo_config(repo, hub)
|
|
752
|
+
if not engine.log_path.exists():
|
|
753
|
+
_raise_exit("Log file not found; run init")
|
|
754
|
+
|
|
755
|
+
if run_id is not None:
|
|
756
|
+
block = engine.read_run_block(run_id)
|
|
757
|
+
if not block:
|
|
758
|
+
_raise_exit("run not found")
|
|
759
|
+
typer.echo(block)
|
|
760
|
+
return
|
|
761
|
+
|
|
762
|
+
if tail is not None:
|
|
763
|
+
typer.echo(engine.tail_log(tail))
|
|
764
|
+
else:
|
|
765
|
+
state = load_state(engine.state_path)
|
|
766
|
+
last_id = state.last_run_id
|
|
767
|
+
if last_id is None:
|
|
768
|
+
typer.echo("No runs recorded yet")
|
|
769
|
+
return
|
|
770
|
+
block = engine.read_run_block(last_id)
|
|
771
|
+
if not block:
|
|
772
|
+
typer.echo("No run block found (log may have rotated)")
|
|
773
|
+
return
|
|
774
|
+
typer.echo(block)
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
@app.command()
|
|
778
|
+
def edit(
|
|
779
|
+
target: str = typer.Argument(..., help="active_context|decisions|spec"),
|
|
780
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
781
|
+
hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
|
|
782
|
+
):
|
|
783
|
+
"""Open one of the docs in $EDITOR."""
|
|
784
|
+
engine = _require_repo_config(repo, hub)
|
|
785
|
+
config = engine.config
|
|
786
|
+
key = target.lower()
|
|
787
|
+
if key not in ("active_context", "decisions", "spec"):
|
|
788
|
+
_raise_exit("Invalid target; choose active_context, decisions, or spec")
|
|
789
|
+
path = config.doc_path(key)
|
|
790
|
+
ui_cfg = config.raw.get("ui") if isinstance(config.raw, dict) else {}
|
|
791
|
+
ui_cfg = ui_cfg if isinstance(ui_cfg, dict) else {}
|
|
792
|
+
config_editor = ui_cfg.get("editor") if isinstance(ui_cfg, dict) else None
|
|
793
|
+
if not isinstance(config_editor, str) or not config_editor.strip():
|
|
794
|
+
config_editor = "vi"
|
|
795
|
+
editor = (
|
|
796
|
+
os.environ.get("VISUAL")
|
|
797
|
+
or os.environ.get("EDITOR")
|
|
798
|
+
or default_editor(fallback=config_editor)
|
|
799
|
+
)
|
|
800
|
+
editor_parts = shlex.split(editor)
|
|
801
|
+
if not editor_parts:
|
|
802
|
+
editor_parts = [editor]
|
|
803
|
+
typer.echo(f"Opening {path} with {' '.join(editor_parts)}")
|
|
804
|
+
subprocess.run([*editor_parts, str(path)])
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
@app.command("doctor")
|
|
808
|
+
def doctor_cmd(
|
|
809
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo or hub path"),
|
|
810
|
+
json_output: bool = typer.Option(False, "--json", help="Output JSON for scripting"),
|
|
811
|
+
):
|
|
812
|
+
"""Validate repo or hub setup."""
|
|
813
|
+
try:
|
|
814
|
+
start_path = repo or Path.cwd()
|
|
815
|
+
report = doctor(start_path)
|
|
816
|
+
|
|
817
|
+
hub_config = load_hub_config(start_path)
|
|
818
|
+
repo_config: Optional[RepoConfig] = None
|
|
819
|
+
try:
|
|
820
|
+
repo_root = find_repo_root(start_path)
|
|
821
|
+
repo_config = derive_repo_config(hub_config, repo_root)
|
|
822
|
+
except RepoNotFoundError:
|
|
823
|
+
repo_config = None
|
|
824
|
+
|
|
825
|
+
telegram_checks = telegram_doctor_checks(repo_config or hub_config)
|
|
826
|
+
report = DoctorReport(checks=report.checks + telegram_checks)
|
|
827
|
+
except ConfigError as exc:
|
|
828
|
+
_raise_exit(str(exc), cause=exc)
|
|
829
|
+
if json_output:
|
|
830
|
+
typer.echo(json.dumps(report.to_dict(), indent=2))
|
|
831
|
+
if report.has_errors():
|
|
832
|
+
raise typer.Exit(code=1)
|
|
833
|
+
return
|
|
834
|
+
for check in report.checks:
|
|
835
|
+
line = f"- {check.status.upper()}: {check.message}"
|
|
836
|
+
if check.fix:
|
|
837
|
+
line = f"{line} Fix: {check.fix}"
|
|
838
|
+
typer.echo(line)
|
|
839
|
+
if report.has_errors():
|
|
840
|
+
_raise_exit("Doctor check failed")
|
|
841
|
+
typer.echo("Doctor check passed")
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
@app.command()
|
|
845
|
+
def serve(
|
|
846
|
+
path: Optional[Path] = typer.Option(None, "--path", "--hub", help="Hub root path"),
|
|
847
|
+
host: Optional[str] = typer.Option(None, "--host", help="Host to bind"),
|
|
848
|
+
port: Optional[int] = typer.Option(None, "--port", help="Port to bind"),
|
|
849
|
+
base_path: Optional[str] = typer.Option(
|
|
850
|
+
None, "--base-path", help="Base path for the server"
|
|
851
|
+
),
|
|
852
|
+
):
|
|
853
|
+
"""Start the hub web server and UI API."""
|
|
854
|
+
try:
|
|
855
|
+
config = load_hub_config(path or Path.cwd())
|
|
856
|
+
except ConfigError as exc:
|
|
857
|
+
_raise_exit(str(exc), cause=exc)
|
|
858
|
+
bind_host = host or config.server_host
|
|
859
|
+
bind_port = port or config.server_port
|
|
860
|
+
normalized_base = (
|
|
861
|
+
_normalize_base_path(base_path)
|
|
862
|
+
if base_path is not None
|
|
863
|
+
else config.server_base_path
|
|
864
|
+
)
|
|
865
|
+
_enforce_bind_auth(bind_host, config.server_auth_token_env)
|
|
866
|
+
typer.echo(f"Serving hub on http://{bind_host}:{bind_port}{normalized_base or ''}")
|
|
867
|
+
uvicorn.run(
|
|
868
|
+
create_hub_app(config.root, base_path=normalized_base),
|
|
869
|
+
host=bind_host,
|
|
870
|
+
port=bind_port,
|
|
871
|
+
root_path="",
|
|
872
|
+
access_log=config.server_access_log,
|
|
873
|
+
)
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
@hub_app.command("create")
|
|
877
|
+
def hub_create(
|
|
878
|
+
repo_id: str = typer.Argument(..., help="Repo id to create and initialize"),
|
|
879
|
+
repo_path: Optional[Path] = typer.Option(
|
|
880
|
+
None,
|
|
881
|
+
"--repo-path",
|
|
882
|
+
help="Custom repo path relative to hub repos_root",
|
|
883
|
+
),
|
|
884
|
+
path: Optional[Path] = typer.Option(None, "--path", help="Hub root path"),
|
|
885
|
+
force: bool = typer.Option(False, "--force", help="Allow existing directory"),
|
|
886
|
+
git_init: bool = typer.Option(
|
|
887
|
+
True, "--git-init/--no-git-init", help="Run git init in the new repo"
|
|
888
|
+
),
|
|
889
|
+
):
|
|
890
|
+
"""Create a new git repo under the hub and initialize codex-autorunner files."""
|
|
891
|
+
config = _require_hub_config(path)
|
|
892
|
+
supervisor = HubSupervisor(
|
|
893
|
+
config,
|
|
894
|
+
backend_factory_builder=build_agent_backend_factory,
|
|
895
|
+
app_server_supervisor_factory_builder=build_app_server_supervisor_factory,
|
|
896
|
+
agent_id_validator=validate_agent_id,
|
|
897
|
+
)
|
|
898
|
+
try:
|
|
899
|
+
snapshot = supervisor.create_repo(
|
|
900
|
+
repo_id, repo_path, git_init=git_init, force=force
|
|
901
|
+
)
|
|
902
|
+
except Exception as exc:
|
|
903
|
+
_raise_exit(str(exc), cause=exc)
|
|
904
|
+
typer.echo(f"Created repo {snapshot.id} at {snapshot.path}")
|
|
905
|
+
|
|
906
|
+
|
|
907
|
+
@hub_app.command("clone")
|
|
908
|
+
def hub_clone(
|
|
909
|
+
git_url: str = typer.Option(
|
|
910
|
+
..., "--git-url", help="Git URL or local path to clone"
|
|
911
|
+
),
|
|
912
|
+
repo_id: Optional[str] = typer.Option(
|
|
913
|
+
None, "--id", help="Repo id to register (defaults from git URL)"
|
|
914
|
+
),
|
|
915
|
+
repo_path: Optional[Path] = typer.Option(
|
|
916
|
+
None,
|
|
917
|
+
"--repo-path",
|
|
918
|
+
help="Custom repo path relative to hub repos_root",
|
|
919
|
+
),
|
|
920
|
+
path: Optional[Path] = typer.Option(None, "--path", help="Hub root path"),
|
|
921
|
+
force: bool = typer.Option(False, "--force", help="Allow existing directory"),
|
|
922
|
+
):
|
|
923
|
+
"""Clone a git repo under the hub and initialize codex-autorunner files."""
|
|
924
|
+
config = _require_hub_config(path)
|
|
925
|
+
supervisor = HubSupervisor(
|
|
926
|
+
config,
|
|
927
|
+
backend_factory_builder=build_agent_backend_factory,
|
|
928
|
+
app_server_supervisor_factory_builder=build_app_server_supervisor_factory,
|
|
929
|
+
agent_id_validator=validate_agent_id,
|
|
930
|
+
)
|
|
931
|
+
try:
|
|
932
|
+
snapshot = supervisor.clone_repo(
|
|
933
|
+
git_url=git_url, repo_id=repo_id, repo_path=repo_path, force=force
|
|
934
|
+
)
|
|
935
|
+
except Exception as exc:
|
|
936
|
+
_raise_exit(str(exc), cause=exc)
|
|
937
|
+
typer.echo(
|
|
938
|
+
f"Cloned repo {snapshot.id} at {snapshot.path} (status={snapshot.status.value})"
|
|
939
|
+
)
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
@hub_app.command("serve")
|
|
943
|
+
def hub_serve(
|
|
944
|
+
path: Optional[Path] = typer.Option(None, "--path", help="Hub root path"),
|
|
945
|
+
host: Optional[str] = typer.Option(None, "--host", help="Host to bind"),
|
|
946
|
+
port: Optional[int] = typer.Option(None, "--port", help="Port to bind"),
|
|
947
|
+
base_path: Optional[str] = typer.Option(
|
|
948
|
+
None, "--base-path", help="Base path for the server"
|
|
949
|
+
),
|
|
950
|
+
):
|
|
951
|
+
"""Start the hub supervisor server."""
|
|
952
|
+
config = _require_hub_config(path)
|
|
953
|
+
normalized_base = (
|
|
954
|
+
_normalize_base_path(base_path)
|
|
955
|
+
if base_path is not None
|
|
956
|
+
else config.server_base_path
|
|
957
|
+
)
|
|
958
|
+
bind_host = host or config.server_host
|
|
959
|
+
bind_port = port or config.server_port
|
|
960
|
+
_enforce_bind_auth(bind_host, config.server_auth_token_env)
|
|
961
|
+
typer.echo(f"Serving hub on http://{bind_host}:{bind_port}{normalized_base or ''}")
|
|
962
|
+
uvicorn.run(
|
|
963
|
+
create_hub_app(config.root, base_path=normalized_base),
|
|
964
|
+
host=bind_host,
|
|
965
|
+
port=bind_port,
|
|
966
|
+
root_path="",
|
|
967
|
+
access_log=config.server_access_log,
|
|
968
|
+
)
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
@hub_app.command("scan")
|
|
972
|
+
def hub_scan(path: Optional[Path] = typer.Option(None, "--path", help="Hub root path")):
|
|
973
|
+
"""Trigger discovery/init and print repo statuses."""
|
|
974
|
+
config = _require_hub_config(path)
|
|
975
|
+
supervisor = HubSupervisor(
|
|
976
|
+
config,
|
|
977
|
+
backend_factory_builder=build_agent_backend_factory,
|
|
978
|
+
app_server_supervisor_factory_builder=build_app_server_supervisor_factory,
|
|
979
|
+
agent_id_validator=validate_agent_id,
|
|
980
|
+
)
|
|
981
|
+
snapshots = supervisor.scan()
|
|
982
|
+
typer.echo(f"Scanned hub at {config.root} (repos_root={config.repos_root})")
|
|
983
|
+
for snap in snapshots:
|
|
984
|
+
typer.echo(
|
|
985
|
+
f"- {snap.id}: {snap.status.value}, initialized={snap.initialized}, exists={snap.exists_on_disk}"
|
|
986
|
+
)
|
|
987
|
+
|
|
988
|
+
|
|
989
|
+
@telegram_app.command("start")
|
|
990
|
+
def telegram_start(
|
|
991
|
+
path: Optional[Path] = typer.Option(None, "--path", help="Repo or hub root path"),
|
|
992
|
+
):
|
|
993
|
+
"""Start the Telegram bot (polling)."""
|
|
994
|
+
_require_optional_feature(
|
|
995
|
+
feature="telegram",
|
|
996
|
+
deps=[("httpx", "httpx")],
|
|
997
|
+
extra="telegram",
|
|
998
|
+
)
|
|
999
|
+
try:
|
|
1000
|
+
config = load_hub_config(path or Path.cwd())
|
|
1001
|
+
except ConfigError as exc:
|
|
1002
|
+
_raise_exit(str(exc), cause=exc)
|
|
1003
|
+
telegram_cfg = TelegramBotConfig.from_raw(
|
|
1004
|
+
config.raw.get("telegram_bot") if isinstance(config.raw, dict) else None,
|
|
1005
|
+
root=config.root,
|
|
1006
|
+
agent_binaries=getattr(config, "agents", None)
|
|
1007
|
+
and {name: agent.binary for name, agent in config.agents.items()},
|
|
1008
|
+
)
|
|
1009
|
+
if not telegram_cfg.enabled:
|
|
1010
|
+
_raise_exit("telegram_bot is disabled; set telegram_bot.enabled: true")
|
|
1011
|
+
try:
|
|
1012
|
+
telegram_cfg.validate()
|
|
1013
|
+
except TelegramBotConfigError as exc:
|
|
1014
|
+
_raise_exit(str(exc), cause=exc)
|
|
1015
|
+
logger = setup_rotating_logger("codex-autorunner-telegram", config.log)
|
|
1016
|
+
env_overrides = collect_env_overrides(env=os.environ, include_telegram=True)
|
|
1017
|
+
if env_overrides:
|
|
1018
|
+
logger.info("Environment overrides active: %s", ", ".join(env_overrides))
|
|
1019
|
+
log_event(
|
|
1020
|
+
logger,
|
|
1021
|
+
logging.INFO,
|
|
1022
|
+
"telegram.bot.starting",
|
|
1023
|
+
root=str(config.root),
|
|
1024
|
+
mode="hub",
|
|
1025
|
+
)
|
|
1026
|
+
voice_raw = config.repo_defaults.get("voice") if config.repo_defaults else None
|
|
1027
|
+
voice_config = VoiceConfig.from_raw(voice_raw, env=os.environ)
|
|
1028
|
+
update_repo_url = config.update_repo_url
|
|
1029
|
+
update_repo_ref = config.update_repo_ref
|
|
1030
|
+
|
|
1031
|
+
async def _run() -> None:
|
|
1032
|
+
service = TelegramBotService(
|
|
1033
|
+
telegram_cfg,
|
|
1034
|
+
logger=logger,
|
|
1035
|
+
hub_root=config.root,
|
|
1036
|
+
manifest_path=config.manifest_path,
|
|
1037
|
+
voice_config=voice_config,
|
|
1038
|
+
housekeeping_config=config.housekeeping,
|
|
1039
|
+
update_repo_url=update_repo_url,
|
|
1040
|
+
update_repo_ref=update_repo_ref,
|
|
1041
|
+
update_skip_checks=config.update_skip_checks,
|
|
1042
|
+
app_server_auto_restart=config.app_server.auto_restart,
|
|
1043
|
+
)
|
|
1044
|
+
await service.run_polling()
|
|
1045
|
+
|
|
1046
|
+
try:
|
|
1047
|
+
asyncio.run(_run())
|
|
1048
|
+
except TelegramBotLockError as exc:
|
|
1049
|
+
_raise_exit(str(exc), cause=exc)
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
@telegram_app.command("health")
|
|
1053
|
+
def telegram_health(
|
|
1054
|
+
path: Optional[Path] = typer.Option(None, "--path", help="Repo or hub root path"),
|
|
1055
|
+
timeout: float = typer.Option(5.0, "--timeout", help="Timeout (seconds)"),
|
|
1056
|
+
):
|
|
1057
|
+
"""Check Telegram API connectivity for the configured bot."""
|
|
1058
|
+
_require_optional_feature(
|
|
1059
|
+
feature="telegram",
|
|
1060
|
+
deps=[("httpx", "httpx")],
|
|
1061
|
+
extra="telegram",
|
|
1062
|
+
)
|
|
1063
|
+
try:
|
|
1064
|
+
config = load_hub_config(path or Path.cwd())
|
|
1065
|
+
except ConfigError as exc:
|
|
1066
|
+
_raise_exit(str(exc), cause=exc)
|
|
1067
|
+
telegram_cfg = TelegramBotConfig.from_raw(
|
|
1068
|
+
config.raw.get("telegram_bot") if isinstance(config.raw, dict) else None,
|
|
1069
|
+
root=config.root,
|
|
1070
|
+
agent_binaries=getattr(config, "agents", None)
|
|
1071
|
+
and {name: agent.binary for name, agent in config.agents.items()},
|
|
1072
|
+
)
|
|
1073
|
+
if not telegram_cfg.enabled:
|
|
1074
|
+
_raise_exit("telegram_bot is disabled; set telegram_bot.enabled: true")
|
|
1075
|
+
bot_token = telegram_cfg.bot_token
|
|
1076
|
+
if not bot_token:
|
|
1077
|
+
_raise_exit(f"missing bot token env '{telegram_cfg.bot_token_env}'")
|
|
1078
|
+
timeout_seconds = max(float(timeout), 0.1)
|
|
1079
|
+
|
|
1080
|
+
async def _run() -> None:
|
|
1081
|
+
async with TelegramBotClient(bot_token) as client:
|
|
1082
|
+
await asyncio.wait_for(client.get_me(), timeout=timeout_seconds)
|
|
1083
|
+
|
|
1084
|
+
try:
|
|
1085
|
+
asyncio.run(_run())
|
|
1086
|
+
except TelegramAPIError as exc:
|
|
1087
|
+
_raise_exit(f"Telegram health check failed: {exc}", cause=exc)
|
|
1088
|
+
|
|
1089
|
+
|
|
1090
|
+
@telegram_app.command("state-check")
|
|
1091
|
+
def telegram_state_check(
|
|
1092
|
+
path: Optional[Path] = typer.Option(None, "--path", help="Repo or hub root path"),
|
|
1093
|
+
):
|
|
1094
|
+
"""Open the Telegram state DB and ensure schema migrations apply."""
|
|
1095
|
+
try:
|
|
1096
|
+
config = load_hub_config(path or Path.cwd())
|
|
1097
|
+
except ConfigError as exc:
|
|
1098
|
+
_raise_exit(str(exc), cause=exc)
|
|
1099
|
+
telegram_cfg = TelegramBotConfig.from_raw(
|
|
1100
|
+
config.raw.get("telegram_bot") if isinstance(config.raw, dict) else None,
|
|
1101
|
+
root=config.root,
|
|
1102
|
+
agent_binaries=getattr(config, "agents", None)
|
|
1103
|
+
and {name: agent.binary for name, agent in config.agents.items()},
|
|
1104
|
+
)
|
|
1105
|
+
if not telegram_cfg.enabled:
|
|
1106
|
+
_raise_exit("telegram_bot is disabled; set telegram_bot.enabled: true")
|
|
1107
|
+
|
|
1108
|
+
try:
|
|
1109
|
+
store = TelegramStateStore(
|
|
1110
|
+
telegram_cfg.state_file,
|
|
1111
|
+
default_approval_mode=telegram_cfg.defaults.approval_mode,
|
|
1112
|
+
)
|
|
1113
|
+
# This will open the DB and apply schema/migrations.
|
|
1114
|
+
store._connection_sync() # type: ignore[attr-defined]
|
|
1115
|
+
except Exception as exc: # pragma: no cover - defensive runtime check
|
|
1116
|
+
_raise_exit(f"Telegram state check failed: {exc}", cause=exc)
|
|
1117
|
+
|
|
1118
|
+
|
|
1119
|
+
@app.command()
|
|
1120
|
+
def flow(
|
|
1121
|
+
action: str = typer.Argument(..., help="worker"),
|
|
1122
|
+
repo: Optional[Path] = typer.Option(None, "--repo", help="Repo path"),
|
|
1123
|
+
hub: Optional[Path] = typer.Option(None, "--hub", help="Hub root path"),
|
|
1124
|
+
run_id: Optional[str] = typer.Option(
|
|
1125
|
+
None, "--run-id", help="Flow run ID (for worker)"
|
|
1126
|
+
),
|
|
1127
|
+
):
|
|
1128
|
+
"""Flow runtime commands."""
|
|
1129
|
+
engine = _require_repo_config(repo, hub)
|
|
1130
|
+
|
|
1131
|
+
if action == "worker":
|
|
1132
|
+
if not run_id:
|
|
1133
|
+
_raise_exit("--run-id is required for worker command")
|
|
1134
|
+
try:
|
|
1135
|
+
run_id = str(uuid.UUID(str(run_id)))
|
|
1136
|
+
except ValueError:
|
|
1137
|
+
_raise_exit("Invalid run_id format; must be a UUID")
|
|
1138
|
+
|
|
1139
|
+
from ...core.flows import FlowController, FlowStore
|
|
1140
|
+
from ...core.flows.models import FlowRunStatus
|
|
1141
|
+
from ...flows.ticket_flow.definition import build_ticket_flow_definition
|
|
1142
|
+
from ...tickets import AgentPool
|
|
1143
|
+
|
|
1144
|
+
db_path = engine.repo_root / ".codex-autorunner" / "flows.db"
|
|
1145
|
+
artifacts_root = engine.repo_root / ".codex-autorunner" / "flows"
|
|
1146
|
+
|
|
1147
|
+
typer.echo(f"Starting flow worker for run {run_id}")
|
|
1148
|
+
|
|
1149
|
+
async def _run_worker():
|
|
1150
|
+
typer.echo(f"Flow worker started for {run_id}")
|
|
1151
|
+
typer.echo(f"DB path: {db_path}")
|
|
1152
|
+
typer.echo(f"Artifacts root: {artifacts_root}")
|
|
1153
|
+
|
|
1154
|
+
store = FlowStore(db_path)
|
|
1155
|
+
store.initialize()
|
|
1156
|
+
|
|
1157
|
+
record = store.get_flow_run(run_id)
|
|
1158
|
+
if not record:
|
|
1159
|
+
typer.echo(f"Flow run {run_id} not found", err=True)
|
|
1160
|
+
store.close()
|
|
1161
|
+
raise typer.Exit(code=1)
|
|
1162
|
+
store.close()
|
|
1163
|
+
|
|
1164
|
+
agent_pool: AgentPool | None = None
|
|
1165
|
+
|
|
1166
|
+
def _build_definition(flow_type: str):
|
|
1167
|
+
nonlocal agent_pool
|
|
1168
|
+
if flow_type == "pr_flow":
|
|
1169
|
+
_raise_exit(
|
|
1170
|
+
"PR flow is no longer supported. Use ticket_flow instead."
|
|
1171
|
+
)
|
|
1172
|
+
if flow_type == "ticket_flow":
|
|
1173
|
+
agent_pool = AgentPool(engine.config)
|
|
1174
|
+
return build_ticket_flow_definition(agent_pool=agent_pool)
|
|
1175
|
+
_raise_exit(f"Unknown flow type for run {run_id}: {flow_type}")
|
|
1176
|
+
return None
|
|
1177
|
+
|
|
1178
|
+
definition = _build_definition(record.flow_type)
|
|
1179
|
+
definition.validate()
|
|
1180
|
+
|
|
1181
|
+
controller = FlowController(
|
|
1182
|
+
definition=definition,
|
|
1183
|
+
db_path=db_path,
|
|
1184
|
+
artifacts_root=artifacts_root,
|
|
1185
|
+
)
|
|
1186
|
+
controller.initialize()
|
|
1187
|
+
|
|
1188
|
+
record = controller.get_status(run_id)
|
|
1189
|
+
if not record:
|
|
1190
|
+
typer.echo(f"Flow run {run_id} not found", err=True)
|
|
1191
|
+
raise typer.Exit(code=1)
|
|
1192
|
+
|
|
1193
|
+
if record.status.is_terminal() and record.status not in {
|
|
1194
|
+
FlowRunStatus.STOPPED,
|
|
1195
|
+
FlowRunStatus.FAILED,
|
|
1196
|
+
}:
|
|
1197
|
+
typer.echo(
|
|
1198
|
+
f"Flow run {run_id} already completed (status={record.status})"
|
|
1199
|
+
)
|
|
1200
|
+
return
|
|
1201
|
+
|
|
1202
|
+
action = (
|
|
1203
|
+
"Resuming" if record.status != FlowRunStatus.PENDING else "Starting"
|
|
1204
|
+
)
|
|
1205
|
+
typer.echo(f"{action} flow run {run_id} from step: {record.current_step}")
|
|
1206
|
+
try:
|
|
1207
|
+
final_record = await controller.run_flow(run_id)
|
|
1208
|
+
typer.echo(
|
|
1209
|
+
f"Flow run {run_id} finished with status {final_record.status}"
|
|
1210
|
+
)
|
|
1211
|
+
finally:
|
|
1212
|
+
if agent_pool is not None:
|
|
1213
|
+
try:
|
|
1214
|
+
await agent_pool.close()
|
|
1215
|
+
except Exception:
|
|
1216
|
+
typer.echo("Failed to close agent pool cleanly", err=True)
|
|
1217
|
+
|
|
1218
|
+
asyncio.run(_run_worker())
|
|
1219
|
+
else:
|
|
1220
|
+
_raise_exit(f"Unknown action: {action}")
|
|
1221
|
+
|
|
1222
|
+
|
|
1223
|
+
if __name__ == "__main__":
|
|
1224
|
+
app()
|