codex-autorunner 1.0.0__py3-none-any.whl → 1.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codex_autorunner/__init__.py +12 -1
- codex_autorunner/agents/codex/harness.py +1 -1
- codex_autorunner/agents/opencode/client.py +113 -4
- codex_autorunner/agents/opencode/constants.py +3 -0
- codex_autorunner/agents/opencode/harness.py +6 -1
- codex_autorunner/agents/opencode/runtime.py +59 -18
- codex_autorunner/agents/opencode/supervisor.py +4 -0
- codex_autorunner/agents/registry.py +36 -7
- codex_autorunner/bootstrap.py +226 -4
- codex_autorunner/cli.py +5 -1174
- codex_autorunner/codex_cli.py +20 -84
- codex_autorunner/core/__init__.py +20 -0
- codex_autorunner/core/about_car.py +119 -1
- codex_autorunner/core/app_server_ids.py +59 -0
- codex_autorunner/core/app_server_threads.py +17 -2
- codex_autorunner/core/app_server_utils.py +165 -0
- codex_autorunner/core/archive.py +349 -0
- codex_autorunner/core/codex_runner.py +6 -2
- codex_autorunner/core/config.py +433 -4
- codex_autorunner/core/context_awareness.py +38 -0
- codex_autorunner/core/docs.py +0 -122
- codex_autorunner/core/drafts.py +58 -4
- codex_autorunner/core/exceptions.py +4 -0
- codex_autorunner/core/filebox.py +265 -0
- codex_autorunner/core/flows/controller.py +96 -2
- codex_autorunner/core/flows/models.py +13 -0
- codex_autorunner/core/flows/reasons.py +52 -0
- codex_autorunner/core/flows/reconciler.py +134 -0
- codex_autorunner/core/flows/runtime.py +57 -4
- codex_autorunner/core/flows/store.py +142 -7
- codex_autorunner/core/flows/transition.py +27 -15
- codex_autorunner/core/flows/ux_helpers.py +272 -0
- codex_autorunner/core/flows/worker_process.py +32 -6
- codex_autorunner/core/git_utils.py +62 -0
- codex_autorunner/core/hub.py +291 -20
- codex_autorunner/core/lifecycle_events.py +253 -0
- codex_autorunner/core/notifications.py +14 -2
- codex_autorunner/core/path_utils.py +2 -1
- codex_autorunner/core/pma_audit.py +224 -0
- codex_autorunner/core/pma_context.py +496 -0
- codex_autorunner/core/pma_dispatch_interceptor.py +284 -0
- codex_autorunner/core/pma_lifecycle.py +527 -0
- codex_autorunner/core/pma_queue.py +367 -0
- codex_autorunner/core/pma_safety.py +221 -0
- codex_autorunner/core/pma_state.py +115 -0
- codex_autorunner/core/ports/__init__.py +28 -0
- codex_autorunner/{integrations/agents → core/ports}/agent_backend.py +13 -8
- codex_autorunner/core/ports/backend_orchestrator.py +41 -0
- codex_autorunner/{integrations/agents → core/ports}/run_event.py +23 -6
- codex_autorunner/core/prompt.py +0 -80
- codex_autorunner/core/prompts.py +56 -172
- codex_autorunner/core/redaction.py +0 -4
- codex_autorunner/core/review_context.py +11 -9
- codex_autorunner/core/runner_controller.py +35 -33
- codex_autorunner/core/runner_state.py +147 -0
- codex_autorunner/core/runtime.py +829 -0
- codex_autorunner/core/sqlite_utils.py +13 -4
- codex_autorunner/core/state.py +7 -10
- codex_autorunner/core/state_roots.py +62 -0
- codex_autorunner/core/supervisor_protocol.py +15 -0
- codex_autorunner/core/templates/__init__.py +39 -0
- codex_autorunner/core/templates/git_mirror.py +234 -0
- codex_autorunner/core/templates/provenance.py +56 -0
- codex_autorunner/core/templates/scan_cache.py +120 -0
- codex_autorunner/core/text_delta_coalescer.py +54 -0
- codex_autorunner/core/ticket_linter_cli.py +218 -0
- codex_autorunner/core/ticket_manager_cli.py +494 -0
- codex_autorunner/core/time_utils.py +11 -0
- codex_autorunner/core/types.py +18 -0
- codex_autorunner/core/update.py +4 -5
- codex_autorunner/core/update_paths.py +28 -0
- codex_autorunner/core/usage.py +164 -12
- codex_autorunner/core/utils.py +125 -15
- codex_autorunner/flows/review/__init__.py +17 -0
- codex_autorunner/{core/review.py → flows/review/service.py} +37 -34
- codex_autorunner/flows/ticket_flow/definition.py +52 -3
- codex_autorunner/integrations/agents/__init__.py +11 -19
- codex_autorunner/integrations/agents/backend_orchestrator.py +302 -0
- codex_autorunner/integrations/agents/codex_adapter.py +90 -0
- codex_autorunner/integrations/agents/codex_backend.py +177 -25
- codex_autorunner/integrations/agents/opencode_adapter.py +108 -0
- codex_autorunner/integrations/agents/opencode_backend.py +305 -32
- codex_autorunner/integrations/agents/runner.py +86 -0
- codex_autorunner/integrations/agents/wiring.py +279 -0
- codex_autorunner/integrations/app_server/client.py +7 -60
- 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/telegram/adapter.py +65 -0
- codex_autorunner/integrations/telegram/config.py +46 -0
- codex_autorunner/integrations/telegram/constants.py +1 -1
- codex_autorunner/integrations/telegram/doctor.py +228 -6
- codex_autorunner/integrations/telegram/handlers/callbacks.py +7 -0
- codex_autorunner/integrations/telegram/handlers/commands/execution.py +236 -74
- codex_autorunner/integrations/telegram/handlers/commands/files.py +314 -75
- codex_autorunner/integrations/telegram/handlers/commands/flows.py +1496 -71
- codex_autorunner/integrations/telegram/handlers/commands/workspace.py +498 -37
- codex_autorunner/integrations/telegram/handlers/commands_runtime.py +206 -48
- codex_autorunner/integrations/telegram/handlers/commands_spec.py +20 -3
- codex_autorunner/integrations/telegram/handlers/messages.py +27 -1
- codex_autorunner/integrations/telegram/handlers/selections.py +61 -1
- codex_autorunner/integrations/telegram/helpers.py +22 -1
- codex_autorunner/integrations/telegram/runtime.py +9 -4
- codex_autorunner/integrations/telegram/service.py +45 -10
- codex_autorunner/integrations/telegram/state.py +38 -0
- codex_autorunner/integrations/telegram/ticket_flow_bridge.py +338 -43
- codex_autorunner/integrations/telegram/transport.py +13 -4
- codex_autorunner/integrations/templates/__init__.py +27 -0
- codex_autorunner/integrations/templates/scan_agent.py +312 -0
- codex_autorunner/routes/__init__.py +37 -76
- codex_autorunner/routes/agents.py +2 -137
- codex_autorunner/routes/analytics.py +2 -238
- codex_autorunner/routes/app_server.py +2 -131
- codex_autorunner/routes/base.py +2 -596
- codex_autorunner/routes/file_chat.py +4 -833
- codex_autorunner/routes/flows.py +4 -977
- codex_autorunner/routes/messages.py +4 -456
- 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 -193
- codex_autorunner/routes/usage.py +2 -86
- codex_autorunner/routes/voice.py +2 -119
- codex_autorunner/routes/workspace.py +2 -270
- codex_autorunner/server.py +4 -4
- codex_autorunner/static/agentControls.js +61 -16
- codex_autorunner/static/app.js +126 -14
- codex_autorunner/static/archive.js +826 -0
- codex_autorunner/static/archiveApi.js +37 -0
- codex_autorunner/static/autoRefresh.js +7 -7
- codex_autorunner/static/chatUploads.js +137 -0
- codex_autorunner/static/dashboard.js +224 -171
- codex_autorunner/static/docChatCore.js +185 -13
- codex_autorunner/static/fileChat.js +68 -40
- codex_autorunner/static/fileboxUi.js +159 -0
- codex_autorunner/static/hub.js +114 -131
- codex_autorunner/static/index.html +375 -49
- codex_autorunner/static/messages.js +568 -87
- codex_autorunner/static/notifications.js +255 -0
- codex_autorunner/static/pma.js +1167 -0
- codex_autorunner/static/preserve.js +17 -0
- codex_autorunner/static/settings.js +128 -6
- codex_autorunner/static/smartRefresh.js +52 -0
- codex_autorunner/static/streamUtils.js +57 -0
- codex_autorunner/static/styles.css +9798 -6143
- codex_autorunner/static/tabs.js +152 -11
- codex_autorunner/static/templateReposSettings.js +225 -0
- codex_autorunner/static/terminal.js +18 -0
- codex_autorunner/static/ticketChatActions.js +165 -3
- codex_autorunner/static/ticketChatStream.js +17 -119
- codex_autorunner/static/ticketEditor.js +137 -15
- codex_autorunner/static/ticketTemplates.js +798 -0
- codex_autorunner/static/tickets.js +821 -98
- codex_autorunner/static/turnEvents.js +27 -0
- codex_autorunner/static/turnResume.js +33 -0
- codex_autorunner/static/utils.js +39 -0
- codex_autorunner/static/workspace.js +389 -82
- codex_autorunner/static/workspaceFileBrowser.js +15 -13
- codex_autorunner/surfaces/__init__.py +5 -0
- codex_autorunner/surfaces/cli/__init__.py +6 -0
- codex_autorunner/surfaces/cli/cli.py +2534 -0
- codex_autorunner/surfaces/cli/codex_cli.py +20 -0
- codex_autorunner/surfaces/cli/pma_cli.py +817 -0
- codex_autorunner/surfaces/telegram/__init__.py +3 -0
- codex_autorunner/surfaces/web/__init__.py +1 -0
- codex_autorunner/surfaces/web/app.py +2223 -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 +82 -0
- codex_autorunner/surfaces/web/routes/agents.py +138 -0
- codex_autorunner/surfaces/web/routes/analytics.py +284 -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 +1117 -0
- codex_autorunner/surfaces/web/routes/filebox.py +227 -0
- codex_autorunner/surfaces/web/routes/flows.py +1354 -0
- codex_autorunner/surfaces/web/routes/messages.py +490 -0
- codex_autorunner/surfaces/web/routes/pma.py +1652 -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 +277 -0
- codex_autorunner/surfaces/web/routes/system.py +196 -0
- codex_autorunner/surfaces/web/routes/templates.py +634 -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 +469 -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 +8 -1
- codex_autorunner/tickets/agent_pool.py +53 -4
- codex_autorunner/tickets/files.py +37 -16
- codex_autorunner/tickets/lint.py +50 -0
- codex_autorunner/tickets/models.py +6 -1
- codex_autorunner/tickets/outbox.py +50 -2
- codex_autorunner/tickets/runner.py +396 -57
- codex_autorunner/web/__init__.py +5 -1
- codex_autorunner/web/app.py +2 -1949
- codex_autorunner/web/hub_jobs.py +2 -191
- codex_autorunner/web/middleware.py +2 -586
- codex_autorunner/web/pty_session.py +2 -369
- codex_autorunner/web/runner_manager.py +2 -24
- codex_autorunner/web/schemas.py +2 -376
- codex_autorunner/web/static_assets.py +4 -441
- codex_autorunner/web/static_refresh.py +2 -85
- codex_autorunner/web/terminal_sessions.py +2 -77
- codex_autorunner/workspace/paths.py +49 -33
- codex_autorunner-1.2.0.dist-info/METADATA +150 -0
- codex_autorunner-1.2.0.dist-info/RECORD +339 -0
- codex_autorunner/core/adapter_utils.py +0 -21
- codex_autorunner/core/engine.py +0 -2653
- codex_autorunner/core/static_assets.py +0 -55
- codex_autorunner-1.0.0.dist-info/METADATA +0 -246
- codex_autorunner-1.0.0.dist-info/RECORD +0 -251
- /codex_autorunner/{routes → surfaces/web/routes}/terminal_images.py +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/WHEEL +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/entry_points.txt +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/licenses/LICENSE +0 -0
- {codex_autorunner-1.0.0.dist-info → codex_autorunner-1.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Any, Optional
|
|
4
|
+
|
|
5
|
+
from .models import FlowRunStatus
|
|
6
|
+
|
|
7
|
+
MAX_REASON_SUMMARY_LEN = 120
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _truncate(text: str, max_len: int = MAX_REASON_SUMMARY_LEN) -> str:
|
|
11
|
+
if len(text) <= max_len:
|
|
12
|
+
return text
|
|
13
|
+
return f"{text[:max_len].rstrip()}…"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def ensure_reason_summary(
|
|
17
|
+
state: Any,
|
|
18
|
+
*,
|
|
19
|
+
status: FlowRunStatus,
|
|
20
|
+
error_message: Optional[str] = None,
|
|
21
|
+
default: Optional[str] = None,
|
|
22
|
+
) -> dict[str, Any]:
|
|
23
|
+
"""Ensure state includes a short reason_summary when stopping/pausing/failing."""
|
|
24
|
+
normalized: dict[str, Any] = dict(state) if isinstance(state, dict) else {}
|
|
25
|
+
existing = normalized.get("reason_summary")
|
|
26
|
+
if isinstance(existing, str) and existing.strip():
|
|
27
|
+
return normalized
|
|
28
|
+
|
|
29
|
+
reason: Optional[str] = None
|
|
30
|
+
engine = normalized.get("ticket_engine")
|
|
31
|
+
if isinstance(engine, dict):
|
|
32
|
+
engine_reason = engine.get("reason")
|
|
33
|
+
if isinstance(engine_reason, str) and engine_reason.strip():
|
|
34
|
+
reason = engine_reason.strip()
|
|
35
|
+
|
|
36
|
+
if not reason and isinstance(error_message, str) and error_message.strip():
|
|
37
|
+
reason = error_message.strip()
|
|
38
|
+
|
|
39
|
+
if not reason:
|
|
40
|
+
if default:
|
|
41
|
+
reason = default
|
|
42
|
+
else:
|
|
43
|
+
fallback = {
|
|
44
|
+
FlowRunStatus.PAUSED: "Paused",
|
|
45
|
+
FlowRunStatus.FAILED: "Failed",
|
|
46
|
+
FlowRunStatus.STOPPED: "Stopped",
|
|
47
|
+
}
|
|
48
|
+
reason = fallback.get(status)
|
|
49
|
+
|
|
50
|
+
if reason:
|
|
51
|
+
normalized["reason_summary"] = _truncate(reason)
|
|
52
|
+
return normalized
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from dataclasses import dataclass
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
from ..locks import FileLockBusy, file_lock
|
|
9
|
+
from .models import FlowRunRecord, FlowRunStatus
|
|
10
|
+
from .store import UNSET, FlowStore
|
|
11
|
+
from .transition import resolve_flow_transition
|
|
12
|
+
from .worker_process import FlowWorkerHealth, check_worker_health, clear_worker_metadata
|
|
13
|
+
|
|
14
|
+
_logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
_ACTIVE_STATUSES = (
|
|
17
|
+
FlowRunStatus.RUNNING,
|
|
18
|
+
FlowRunStatus.STOPPING,
|
|
19
|
+
FlowRunStatus.PAUSED,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class FlowReconcileSummary:
|
|
25
|
+
checked: int = 0
|
|
26
|
+
active: int = 0
|
|
27
|
+
updated: int = 0
|
|
28
|
+
locked: int = 0
|
|
29
|
+
errors: int = 0
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class FlowReconcileResult:
|
|
34
|
+
records: list[FlowRunRecord]
|
|
35
|
+
summary: FlowReconcileSummary
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _reconcile_lock_path(repo_root: Path, run_id: str) -> Path:
|
|
39
|
+
return repo_root / ".codex-autorunner" / "flows" / run_id / "reconcile.lock"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ensure_worker_not_stale(health: FlowWorkerHealth) -> None:
|
|
43
|
+
if health.status in {"dead", "mismatch", "invalid"}:
|
|
44
|
+
try:
|
|
45
|
+
clear_worker_metadata(health.artifact_path.parent)
|
|
46
|
+
except Exception:
|
|
47
|
+
_logger.debug("Failed to clear worker metadata: %s", health.artifact_path)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def reconcile_flow_run(
|
|
51
|
+
repo_root: Path,
|
|
52
|
+
record: FlowRunRecord,
|
|
53
|
+
store: FlowStore,
|
|
54
|
+
*,
|
|
55
|
+
logger: Optional[logging.Logger] = None,
|
|
56
|
+
) -> tuple[FlowRunRecord, bool, bool]:
|
|
57
|
+
if record.status not in _ACTIVE_STATUSES:
|
|
58
|
+
return record, False, False
|
|
59
|
+
|
|
60
|
+
lock_path = _reconcile_lock_path(repo_root, record.id)
|
|
61
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
try:
|
|
63
|
+
with file_lock(lock_path, blocking=False):
|
|
64
|
+
health = check_worker_health(repo_root, record.id)
|
|
65
|
+
decision = resolve_flow_transition(record, health)
|
|
66
|
+
|
|
67
|
+
if (
|
|
68
|
+
decision.status == record.status
|
|
69
|
+
and decision.finished_at == record.finished_at
|
|
70
|
+
and decision.state == (record.state or {})
|
|
71
|
+
):
|
|
72
|
+
return record, False, False
|
|
73
|
+
|
|
74
|
+
(logger or _logger).info(
|
|
75
|
+
"Reconciling flow %s: %s -> %s (%s)",
|
|
76
|
+
record.id,
|
|
77
|
+
record.status.value,
|
|
78
|
+
decision.status.value,
|
|
79
|
+
decision.note or "reconcile",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
updated = store.update_flow_run_status(
|
|
83
|
+
run_id=record.id,
|
|
84
|
+
status=decision.status,
|
|
85
|
+
state=decision.state,
|
|
86
|
+
finished_at=decision.finished_at if decision.finished_at else UNSET,
|
|
87
|
+
)
|
|
88
|
+
_ensure_worker_not_stale(health)
|
|
89
|
+
return (updated or record), bool(updated), False
|
|
90
|
+
except FileLockBusy:
|
|
91
|
+
return record, False, True
|
|
92
|
+
except Exception as exc:
|
|
93
|
+
(logger or _logger).warning("Failed to reconcile flow %s: %s", record.id, exc)
|
|
94
|
+
return record, False, False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def reconcile_flow_runs(
|
|
98
|
+
repo_root: Path,
|
|
99
|
+
*,
|
|
100
|
+
flow_type: Optional[str] = None,
|
|
101
|
+
logger: Optional[logging.Logger] = None,
|
|
102
|
+
) -> FlowReconcileResult:
|
|
103
|
+
db_path = repo_root / ".codex-autorunner" / "flows.db"
|
|
104
|
+
if not db_path.exists():
|
|
105
|
+
return FlowReconcileResult(records=[], summary=FlowReconcileSummary())
|
|
106
|
+
from ..config import load_repo_config
|
|
107
|
+
|
|
108
|
+
config = load_repo_config(repo_root)
|
|
109
|
+
store = FlowStore(db_path, durable=config.durable_writes)
|
|
110
|
+
summary = FlowReconcileSummary()
|
|
111
|
+
records: list[FlowRunRecord] = []
|
|
112
|
+
try:
|
|
113
|
+
store.initialize()
|
|
114
|
+
for record in store.list_flow_runs(flow_type=flow_type):
|
|
115
|
+
if record.status in _ACTIVE_STATUSES:
|
|
116
|
+
summary.active += 1
|
|
117
|
+
summary.checked += 1
|
|
118
|
+
record, updated, locked = reconcile_flow_run(
|
|
119
|
+
repo_root, record, store, logger=logger
|
|
120
|
+
)
|
|
121
|
+
if updated:
|
|
122
|
+
summary.updated += 1
|
|
123
|
+
if locked:
|
|
124
|
+
summary.locked += 1
|
|
125
|
+
records.append(record)
|
|
126
|
+
except Exception as exc:
|
|
127
|
+
summary.errors += 1
|
|
128
|
+
(logger or _logger).warning("Flow reconcile run failed: %s", exc)
|
|
129
|
+
finally:
|
|
130
|
+
try:
|
|
131
|
+
store.close()
|
|
132
|
+
except Exception:
|
|
133
|
+
pass
|
|
134
|
+
return FlowReconcileResult(records=records, summary=summary)
|
|
@@ -5,23 +5,38 @@ from typing import Any, Callable, Dict, Optional, Set, cast
|
|
|
5
5
|
|
|
6
6
|
from .definition import FlowDefinition, StepFn, StepFn2, StepFn3
|
|
7
7
|
from .models import FlowEvent, FlowEventType, FlowRunRecord, FlowRunStatus
|
|
8
|
+
from .reasons import ensure_reason_summary
|
|
8
9
|
from .store import FlowStore, now_iso
|
|
9
10
|
|
|
10
11
|
_logger = logging.getLogger(__name__)
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
LifecycleEventCallback = Optional[Callable[[str, str, str, Dict[str, Any]], None]]
|
|
15
|
+
|
|
16
|
+
|
|
13
17
|
class FlowRuntime:
|
|
14
18
|
def __init__(
|
|
15
19
|
self,
|
|
16
20
|
definition: FlowDefinition,
|
|
17
21
|
store: FlowStore,
|
|
18
22
|
emit_event: Optional[Callable[[FlowEvent], None]] = None,
|
|
23
|
+
emit_lifecycle_event: LifecycleEventCallback = None,
|
|
19
24
|
):
|
|
20
25
|
self.definition = definition
|
|
21
26
|
self.store = store
|
|
22
27
|
self.emit_event = emit_event
|
|
28
|
+
self.emit_lifecycle_event = emit_lifecycle_event
|
|
23
29
|
self._stop_check_interval = 0.5
|
|
24
30
|
|
|
31
|
+
def _emit_lifecycle(
|
|
32
|
+
self, event_type: str, repo_id: str, run_id: str, data: Dict[str, Any]
|
|
33
|
+
) -> None:
|
|
34
|
+
if self.emit_lifecycle_event:
|
|
35
|
+
try:
|
|
36
|
+
self.emit_lifecycle_event(event_type, repo_id, run_id, data)
|
|
37
|
+
except Exception as exc:
|
|
38
|
+
_logger.exception("Error emitting lifecycle event: %s", exc)
|
|
39
|
+
|
|
25
40
|
def _emit(
|
|
26
41
|
self,
|
|
27
42
|
event_type: FlowEventType,
|
|
@@ -97,14 +112,21 @@ class FlowRuntime:
|
|
|
97
112
|
if record.stop_requested:
|
|
98
113
|
self._emit(FlowEventType.FLOW_STOPPED, run_id)
|
|
99
114
|
now = now_iso()
|
|
115
|
+
state = ensure_reason_summary(
|
|
116
|
+
dict(record.state or {}),
|
|
117
|
+
status=FlowRunStatus.STOPPED,
|
|
118
|
+
default="Stopped by user",
|
|
119
|
+
)
|
|
100
120
|
updated = self.store.update_flow_run_status(
|
|
101
121
|
run_id=run_id,
|
|
102
122
|
status=FlowRunStatus.STOPPED,
|
|
103
123
|
finished_at=now,
|
|
124
|
+
state=state,
|
|
104
125
|
)
|
|
105
126
|
if not updated:
|
|
106
127
|
raise RuntimeError(f"Failed to stop flow run {run_id}")
|
|
107
128
|
record = updated
|
|
129
|
+
self._emit_lifecycle("flow_stopped", "", run_id, {})
|
|
108
130
|
break
|
|
109
131
|
|
|
110
132
|
step_id = next_steps.pop()
|
|
@@ -128,17 +150,24 @@ class FlowRuntime:
|
|
|
128
150
|
data={"error": str(e)},
|
|
129
151
|
)
|
|
130
152
|
now = now_iso()
|
|
153
|
+
state = ensure_reason_summary(
|
|
154
|
+
dict(record.state or {}),
|
|
155
|
+
status=FlowRunStatus.FAILED,
|
|
156
|
+
error_message=str(e),
|
|
157
|
+
)
|
|
131
158
|
updated = self.store.update_flow_run_status(
|
|
132
159
|
run_id=run_id,
|
|
133
160
|
status=FlowRunStatus.FAILED,
|
|
134
161
|
finished_at=now,
|
|
135
162
|
error_message=str(e),
|
|
163
|
+
state=state,
|
|
136
164
|
)
|
|
137
165
|
if not updated:
|
|
138
166
|
raise RuntimeError(
|
|
139
167
|
f"Failed to update flow run {run_id} to failed state"
|
|
140
168
|
) from e
|
|
141
169
|
record = updated
|
|
170
|
+
self._emit_lifecycle("flow_failed", "", run_id, {"error": str(e)})
|
|
142
171
|
return record
|
|
143
172
|
|
|
144
173
|
async def _execute_step(
|
|
@@ -257,6 +286,7 @@ class FlowRuntime:
|
|
|
257
286
|
f"Failed to update flow run after step {step_id}"
|
|
258
287
|
)
|
|
259
288
|
record = updated
|
|
289
|
+
self._emit_lifecycle("flow_completed", "", record.id, {})
|
|
260
290
|
|
|
261
291
|
elif outcome.status == FlowRunStatus.FAILED:
|
|
262
292
|
self._emit(
|
|
@@ -267,12 +297,17 @@ class FlowRuntime:
|
|
|
267
297
|
)
|
|
268
298
|
|
|
269
299
|
now = now_iso()
|
|
300
|
+
state = ensure_reason_summary(
|
|
301
|
+
dict(record.state or {}),
|
|
302
|
+
status=FlowRunStatus.FAILED,
|
|
303
|
+
error_message=outcome.error,
|
|
304
|
+
)
|
|
270
305
|
updated = self.store.update_flow_run_status(
|
|
271
306
|
run_id=record.id,
|
|
272
307
|
status=FlowRunStatus.FAILED,
|
|
273
308
|
finished_at=now,
|
|
274
309
|
error_message=outcome.error,
|
|
275
|
-
state=
|
|
310
|
+
state=state,
|
|
276
311
|
current_step=None,
|
|
277
312
|
)
|
|
278
313
|
if not updated:
|
|
@@ -280,6 +315,9 @@ class FlowRuntime:
|
|
|
280
315
|
f"Failed to update flow run after step {step_id}"
|
|
281
316
|
)
|
|
282
317
|
record = updated
|
|
318
|
+
self._emit_lifecycle(
|
|
319
|
+
"flow_failed", "", record.id, {"error": outcome.error or ""}
|
|
320
|
+
)
|
|
283
321
|
|
|
284
322
|
elif outcome.status == FlowRunStatus.STOPPED:
|
|
285
323
|
self._emit(
|
|
@@ -290,11 +328,15 @@ class FlowRuntime:
|
|
|
290
328
|
)
|
|
291
329
|
|
|
292
330
|
now = now_iso()
|
|
331
|
+
state = ensure_reason_summary(
|
|
332
|
+
dict(record.state or {}),
|
|
333
|
+
status=FlowRunStatus.STOPPED,
|
|
334
|
+
)
|
|
293
335
|
updated = self.store.update_flow_run_status(
|
|
294
336
|
run_id=record.id,
|
|
295
337
|
status=FlowRunStatus.STOPPED,
|
|
296
338
|
finished_at=now,
|
|
297
|
-
state=
|
|
339
|
+
state=state,
|
|
298
340
|
current_step=None,
|
|
299
341
|
)
|
|
300
342
|
if not updated:
|
|
@@ -302,6 +344,7 @@ class FlowRuntime:
|
|
|
302
344
|
f"Failed to update flow run after step {step_id}"
|
|
303
345
|
)
|
|
304
346
|
record = updated
|
|
347
|
+
self._emit_lifecycle("flow_stopped", "", record.id, {})
|
|
305
348
|
|
|
306
349
|
elif outcome.status == FlowRunStatus.PAUSED:
|
|
307
350
|
self._emit(
|
|
@@ -311,10 +354,14 @@ class FlowRuntime:
|
|
|
311
354
|
step_id=step_id,
|
|
312
355
|
)
|
|
313
356
|
|
|
357
|
+
state = ensure_reason_summary(
|
|
358
|
+
dict(record.state or {}),
|
|
359
|
+
status=FlowRunStatus.PAUSED,
|
|
360
|
+
)
|
|
314
361
|
updated = self.store.update_flow_run_status(
|
|
315
362
|
run_id=record.id,
|
|
316
363
|
status=FlowRunStatus.PAUSED,
|
|
317
|
-
state=
|
|
364
|
+
state=state,
|
|
318
365
|
current_step=step_id,
|
|
319
366
|
)
|
|
320
367
|
if not updated:
|
|
@@ -322,6 +369,7 @@ class FlowRuntime:
|
|
|
322
369
|
f"Failed to update flow run after step {step_id}"
|
|
323
370
|
)
|
|
324
371
|
record = updated
|
|
372
|
+
self._emit_lifecycle("flow_paused", "", record.id, {})
|
|
325
373
|
|
|
326
374
|
return record
|
|
327
375
|
|
|
@@ -335,12 +383,17 @@ class FlowRuntime:
|
|
|
335
383
|
)
|
|
336
384
|
|
|
337
385
|
now = now_iso()
|
|
386
|
+
state = ensure_reason_summary(
|
|
387
|
+
dict(record.state or {}),
|
|
388
|
+
status=FlowRunStatus.FAILED,
|
|
389
|
+
error_message=str(e),
|
|
390
|
+
)
|
|
338
391
|
updated = self.store.update_flow_run_status(
|
|
339
392
|
run_id=record.id,
|
|
340
393
|
status=FlowRunStatus.FAILED,
|
|
341
394
|
finished_at=now,
|
|
342
395
|
error_message=str(e),
|
|
343
|
-
state=
|
|
396
|
+
state=state,
|
|
344
397
|
current_step=None,
|
|
345
398
|
)
|
|
346
399
|
if not updated:
|
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import json
|
|
2
4
|
import logging
|
|
3
5
|
import sqlite3
|
|
4
6
|
import threading
|
|
5
7
|
from contextlib import contextmanager
|
|
6
|
-
from datetime import datetime, timezone
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Any, Dict, Generator, List, Optional, cast
|
|
9
10
|
|
|
11
|
+
from ..sqlite_utils import SQLITE_PRAGMAS, SQLITE_PRAGMAS_DURABLE
|
|
12
|
+
from ..time_utils import now_iso
|
|
10
13
|
from .models import (
|
|
11
14
|
FlowArtifact,
|
|
12
15
|
FlowEvent,
|
|
@@ -21,18 +24,22 @@ SCHEMA_VERSION = 2
|
|
|
21
24
|
UNSET = object()
|
|
22
25
|
|
|
23
26
|
|
|
24
|
-
def now_iso() -> str:
|
|
25
|
-
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
26
|
-
|
|
27
|
-
|
|
28
27
|
class FlowStore:
|
|
29
|
-
def __init__(self, db_path: Path):
|
|
28
|
+
def __init__(self, db_path: Path, durable: bool = False):
|
|
30
29
|
self.db_path = db_path
|
|
30
|
+
self._durable = durable
|
|
31
31
|
self._local: threading.local = threading.local()
|
|
32
32
|
|
|
33
|
+
def __enter__(self) -> FlowStore:
|
|
34
|
+
self.initialize()
|
|
35
|
+
return self
|
|
36
|
+
|
|
37
|
+
def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
|
|
38
|
+
self.close()
|
|
39
|
+
|
|
33
40
|
def _get_conn(self) -> sqlite3.Connection:
|
|
34
41
|
if not hasattr(self._local, "conn"):
|
|
35
|
-
# Ensure parent directory exists so sqlite can create/open
|
|
42
|
+
# Ensure parent directory exists so sqlite can create/open file.
|
|
36
43
|
try:
|
|
37
44
|
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
|
38
45
|
except Exception:
|
|
@@ -42,6 +49,9 @@ class FlowStore:
|
|
|
42
49
|
self.db_path, check_same_thread=False, isolation_level=None
|
|
43
50
|
)
|
|
44
51
|
self._local.conn.row_factory = sqlite3.Row
|
|
52
|
+
pragmas = SQLITE_PRAGMAS_DURABLE if self._durable else SQLITE_PRAGMAS
|
|
53
|
+
for pragma in pragmas:
|
|
54
|
+
self._local.conn.execute(pragma)
|
|
45
55
|
return cast(sqlite3.Connection, self._local.conn)
|
|
46
56
|
|
|
47
57
|
@contextmanager
|
|
@@ -384,6 +394,131 @@ class FlowStore:
|
|
|
384
394
|
rows = conn.execute(query, params).fetchall()
|
|
385
395
|
return [self._row_to_flow_event(row) for row in rows]
|
|
386
396
|
|
|
397
|
+
def get_events_by_types(
|
|
398
|
+
self,
|
|
399
|
+
run_id: str,
|
|
400
|
+
event_types: list[FlowEventType],
|
|
401
|
+
*,
|
|
402
|
+
after_seq: Optional[int] = None,
|
|
403
|
+
limit: Optional[int] = None,
|
|
404
|
+
) -> List[FlowEvent]:
|
|
405
|
+
"""Return events for a run filtered to specific event types."""
|
|
406
|
+
if not event_types:
|
|
407
|
+
return []
|
|
408
|
+
conn = self._get_conn()
|
|
409
|
+
placeholders = ", ".join("?" for _ in event_types)
|
|
410
|
+
query = f"""
|
|
411
|
+
SELECT *
|
|
412
|
+
FROM flow_events
|
|
413
|
+
WHERE run_id = ? AND event_type IN ({placeholders})
|
|
414
|
+
"""
|
|
415
|
+
params: List[Any] = [run_id, *[t.value for t in event_types]]
|
|
416
|
+
|
|
417
|
+
if after_seq is not None:
|
|
418
|
+
query += " AND seq > ?"
|
|
419
|
+
params.append(after_seq)
|
|
420
|
+
|
|
421
|
+
query += " ORDER BY seq ASC"
|
|
422
|
+
|
|
423
|
+
if limit is not None:
|
|
424
|
+
query += " LIMIT ?"
|
|
425
|
+
params.append(limit)
|
|
426
|
+
|
|
427
|
+
rows = conn.execute(query, params).fetchall()
|
|
428
|
+
return [self._row_to_flow_event(row) for row in rows]
|
|
429
|
+
|
|
430
|
+
def get_events_by_type(
|
|
431
|
+
self,
|
|
432
|
+
run_id: str,
|
|
433
|
+
event_type: FlowEventType,
|
|
434
|
+
*,
|
|
435
|
+
after_seq: Optional[int] = None,
|
|
436
|
+
limit: Optional[int] = None,
|
|
437
|
+
) -> List[FlowEvent]:
|
|
438
|
+
return self.get_events_by_types(
|
|
439
|
+
run_id, [event_type], after_seq=after_seq, limit=limit
|
|
440
|
+
)
|
|
441
|
+
|
|
442
|
+
def get_last_event_meta(self, run_id: str) -> tuple[Optional[int], Optional[str]]:
|
|
443
|
+
conn = self._get_conn()
|
|
444
|
+
row = conn.execute(
|
|
445
|
+
"SELECT seq, timestamp FROM flow_events WHERE run_id = ? ORDER BY seq DESC LIMIT 1",
|
|
446
|
+
(run_id,),
|
|
447
|
+
).fetchone()
|
|
448
|
+
if row is None:
|
|
449
|
+
return None, None
|
|
450
|
+
return row["seq"], row["timestamp"]
|
|
451
|
+
|
|
452
|
+
def get_last_event_seq_by_types(
|
|
453
|
+
self, run_id: str, event_types: list[FlowEventType]
|
|
454
|
+
) -> Optional[int]:
|
|
455
|
+
if not event_types:
|
|
456
|
+
return None
|
|
457
|
+
conn = self._get_conn()
|
|
458
|
+
placeholders = ", ".join("?" for _ in event_types)
|
|
459
|
+
params = [run_id, *[t.value for t in event_types]]
|
|
460
|
+
row = conn.execute(
|
|
461
|
+
f"""
|
|
462
|
+
SELECT seq
|
|
463
|
+
FROM flow_events
|
|
464
|
+
WHERE run_id = ? AND event_type IN ({placeholders})
|
|
465
|
+
ORDER BY seq DESC
|
|
466
|
+
LIMIT 1
|
|
467
|
+
""",
|
|
468
|
+
params,
|
|
469
|
+
).fetchone()
|
|
470
|
+
if row is None:
|
|
471
|
+
return None
|
|
472
|
+
return cast(int, row["seq"])
|
|
473
|
+
|
|
474
|
+
def get_last_event_by_type(
|
|
475
|
+
self, run_id: str, event_type: FlowEventType
|
|
476
|
+
) -> Optional[FlowEvent]:
|
|
477
|
+
conn = self._get_conn()
|
|
478
|
+
row = conn.execute(
|
|
479
|
+
"""
|
|
480
|
+
SELECT *
|
|
481
|
+
FROM flow_events
|
|
482
|
+
WHERE run_id = ? AND event_type = ?
|
|
483
|
+
ORDER BY seq DESC
|
|
484
|
+
LIMIT 1
|
|
485
|
+
""",
|
|
486
|
+
(run_id, event_type.value),
|
|
487
|
+
).fetchone()
|
|
488
|
+
if row is None:
|
|
489
|
+
return None
|
|
490
|
+
return self._row_to_flow_event(row)
|
|
491
|
+
|
|
492
|
+
def get_latest_step_progress_current_ticket(
|
|
493
|
+
self, run_id: str, *, after_seq: Optional[int] = None, limit: int = 50
|
|
494
|
+
) -> Optional[str]:
|
|
495
|
+
"""Return the most recent step_progress.data.current_ticket for a run.
|
|
496
|
+
|
|
497
|
+
This is intentionally lightweight to support UI polling endpoints.
|
|
498
|
+
"""
|
|
499
|
+
conn = self._get_conn()
|
|
500
|
+
query = """
|
|
501
|
+
SELECT seq, data
|
|
502
|
+
FROM flow_events
|
|
503
|
+
WHERE run_id = ? AND event_type = ?
|
|
504
|
+
"""
|
|
505
|
+
params: List[Any] = [run_id, FlowEventType.STEP_PROGRESS.value]
|
|
506
|
+
if after_seq is not None:
|
|
507
|
+
query += " AND seq > ?"
|
|
508
|
+
params.append(after_seq)
|
|
509
|
+
query += " ORDER BY seq DESC LIMIT ?"
|
|
510
|
+
params.append(limit)
|
|
511
|
+
rows = conn.execute(query, params).fetchall()
|
|
512
|
+
for row in rows:
|
|
513
|
+
try:
|
|
514
|
+
data = json.loads(row["data"] or "{}")
|
|
515
|
+
except Exception:
|
|
516
|
+
data = {}
|
|
517
|
+
current_ticket = data.get("current_ticket")
|
|
518
|
+
if isinstance(current_ticket, str) and current_ticket.strip():
|
|
519
|
+
return current_ticket.strip()
|
|
520
|
+
return None
|
|
521
|
+
|
|
387
522
|
def create_artifact(
|
|
388
523
|
self,
|
|
389
524
|
artifact_id: str,
|
|
@@ -4,6 +4,7 @@ from dataclasses import dataclass
|
|
|
4
4
|
from typing import Any, Optional
|
|
5
5
|
|
|
6
6
|
from codex_autorunner.core.flows.models import FlowRunRecord, FlowRunStatus
|
|
7
|
+
from codex_autorunner.core.flows.reasons import ensure_reason_summary
|
|
7
8
|
from codex_autorunner.core.flows.store import now_iso
|
|
8
9
|
|
|
9
10
|
|
|
@@ -44,23 +45,10 @@ def resolve_flow_transition(
|
|
|
44
45
|
inner_status = engine.get("status")
|
|
45
46
|
reason_code = engine.get("reason_code")
|
|
46
47
|
|
|
47
|
-
# 1)
|
|
48
|
-
if (
|
|
49
|
-
record.status in (FlowRunStatus.RUNNING, FlowRunStatus.STOPPING)
|
|
50
|
-
and not health.is_alive
|
|
51
|
-
):
|
|
52
|
-
new_status = (
|
|
53
|
-
FlowRunStatus.STOPPED
|
|
54
|
-
if record.status == FlowRunStatus.STOPPING
|
|
55
|
-
else FlowRunStatus.FAILED
|
|
56
|
-
)
|
|
57
|
-
return TransitionDecision(
|
|
58
|
-
status=new_status, finished_at=now, state=state, note="worker-dead"
|
|
59
|
-
)
|
|
60
|
-
|
|
61
|
-
# 2) Inner engine reconciliation (worker is alive or not required).
|
|
48
|
+
# 1) Inner engine completion takes priority over worker liveness for active flows.
|
|
62
49
|
if record.status == FlowRunStatus.RUNNING:
|
|
63
50
|
if inner_status == "paused":
|
|
51
|
+
state = ensure_reason_summary(state, status=FlowRunStatus.PAUSED)
|
|
64
52
|
return TransitionDecision(
|
|
65
53
|
status=FlowRunStatus.PAUSED,
|
|
66
54
|
finished_at=None,
|
|
@@ -76,10 +64,32 @@ def resolve_flow_transition(
|
|
|
76
64
|
note="engine-completed",
|
|
77
65
|
)
|
|
78
66
|
|
|
67
|
+
# 2) Worker liveness overrides for active flows (only if engine not completed).
|
|
68
|
+
if not health.is_alive:
|
|
69
|
+
new_status = FlowRunStatus.FAILED
|
|
70
|
+
state = ensure_reason_summary(
|
|
71
|
+
state, status=new_status, default="Worker died"
|
|
72
|
+
)
|
|
73
|
+
return TransitionDecision(
|
|
74
|
+
status=new_status, finished_at=now, state=state, note="worker-dead"
|
|
75
|
+
)
|
|
76
|
+
|
|
79
77
|
return TransitionDecision(
|
|
80
78
|
status=FlowRunStatus.RUNNING, finished_at=None, state=state, note="running"
|
|
81
79
|
)
|
|
82
80
|
|
|
81
|
+
# Handle STOPPING case separately - worker liveness check still applies.
|
|
82
|
+
if record.status == FlowRunStatus.STOPPING and not health.is_alive:
|
|
83
|
+
state = ensure_reason_summary(
|
|
84
|
+
state, status=FlowRunStatus.STOPPED, default="Worker stopped"
|
|
85
|
+
)
|
|
86
|
+
return TransitionDecision(
|
|
87
|
+
status=FlowRunStatus.STOPPED,
|
|
88
|
+
finished_at=now,
|
|
89
|
+
state=state,
|
|
90
|
+
note="worker-dead",
|
|
91
|
+
)
|
|
92
|
+
|
|
83
93
|
if record.status == FlowRunStatus.PAUSED:
|
|
84
94
|
if inner_status == "completed":
|
|
85
95
|
return TransitionDecision(
|
|
@@ -98,6 +108,7 @@ def resolve_flow_transition(
|
|
|
98
108
|
engine.pop("reason", None)
|
|
99
109
|
engine.pop("reason_details", None)
|
|
100
110
|
engine.pop("reason_code", None)
|
|
111
|
+
state.pop("reason_summary", None)
|
|
101
112
|
engine["status"] = "running"
|
|
102
113
|
state["ticket_engine"] = engine
|
|
103
114
|
return TransitionDecision(
|
|
@@ -115,6 +126,7 @@ def resolve_flow_transition(
|
|
|
115
126
|
note="paused-worker-dead",
|
|
116
127
|
)
|
|
117
128
|
|
|
129
|
+
state = ensure_reason_summary(state, status=FlowRunStatus.PAUSED)
|
|
118
130
|
return TransitionDecision(
|
|
119
131
|
status=FlowRunStatus.PAUSED, finished_at=None, state=state, note="paused"
|
|
120
132
|
)
|