axion-code 1.0.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.
- axion/__init__.py +3 -0
- axion/api/__init__.py +0 -0
- axion/api/anthropic.py +460 -0
- axion/api/client.py +259 -0
- axion/api/error.py +161 -0
- axion/api/ollama.py +597 -0
- axion/api/openai_compat.py +805 -0
- axion/api/openai_responses.py +627 -0
- axion/api/prompt_cache.py +31 -0
- axion/api/sse.py +98 -0
- axion/api/types.py +451 -0
- axion/cli/__init__.py +0 -0
- axion/cli/init_cmd.py +50 -0
- axion/cli/input.py +290 -0
- axion/cli/main.py +2953 -0
- axion/cli/render.py +489 -0
- axion/cli/tui.py +766 -0
- axion/commands/__init__.py +0 -0
- axion/commands/handlers/__init__.py +0 -0
- axion/commands/handlers/agents.py +51 -0
- axion/commands/handlers/builtin_commands.py +367 -0
- axion/commands/handlers/mcp.py +59 -0
- axion/commands/handlers/models.py +75 -0
- axion/commands/handlers/plugins.py +55 -0
- axion/commands/handlers/skills.py +61 -0
- axion/commands/parsing.py +317 -0
- axion/commands/registry.py +166 -0
- axion/compat_harness/__init__.py +0 -0
- axion/compat_harness/extractor.py +145 -0
- axion/plugins/__init__.py +0 -0
- axion/plugins/hooks.py +22 -0
- axion/plugins/manager.py +391 -0
- axion/plugins/manifest.py +270 -0
- axion/runtime/__init__.py +0 -0
- axion/runtime/bash.py +388 -0
- axion/runtime/bootstrap.py +39 -0
- axion/runtime/claude_subscription.py +300 -0
- axion/runtime/compact.py +233 -0
- axion/runtime/config.py +397 -0
- axion/runtime/conversation.py +1073 -0
- axion/runtime/file_ops.py +613 -0
- axion/runtime/git.py +213 -0
- axion/runtime/hooks.py +235 -0
- axion/runtime/image.py +212 -0
- axion/runtime/lanes.py +282 -0
- axion/runtime/lsp.py +425 -0
- axion/runtime/mcp/__init__.py +0 -0
- axion/runtime/mcp/client.py +76 -0
- axion/runtime/mcp/lifecycle.py +96 -0
- axion/runtime/mcp/stdio.py +318 -0
- axion/runtime/mcp/tool_bridge.py +79 -0
- axion/runtime/memory.py +196 -0
- axion/runtime/oauth.py +329 -0
- axion/runtime/openai_subscription.py +346 -0
- axion/runtime/permissions.py +247 -0
- axion/runtime/plan_mode.py +96 -0
- axion/runtime/policy_engine.py +259 -0
- axion/runtime/prompt.py +586 -0
- axion/runtime/recovery.py +261 -0
- axion/runtime/remote.py +28 -0
- axion/runtime/sandbox.py +68 -0
- axion/runtime/scheduler.py +231 -0
- axion/runtime/session.py +365 -0
- axion/runtime/sharing.py +159 -0
- axion/runtime/skills.py +124 -0
- axion/runtime/tasks.py +258 -0
- axion/runtime/usage.py +241 -0
- axion/runtime/workers.py +186 -0
- axion/telemetry/__init__.py +0 -0
- axion/telemetry/events.py +67 -0
- axion/telemetry/profile.py +49 -0
- axion/telemetry/sink.py +60 -0
- axion/telemetry/tracer.py +95 -0
- axion/tools/__init__.py +0 -0
- axion/tools/lane_completion.py +33 -0
- axion/tools/registry.py +853 -0
- axion/tools/tool_search.py +226 -0
- axion_code-1.0.0.dist-info/METADATA +709 -0
- axion_code-1.0.0.dist-info/RECORD +82 -0
- axion_code-1.0.0.dist-info/WHEEL +4 -0
- axion_code-1.0.0.dist-info/entry_points.txt +2 -0
- axion_code-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
"""Failure recovery recipes with actual retry execution.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/runtime/src/recovery_recipes.rs
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import enum
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
from typing import Any, Awaitable, Callable
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FailureScenario(enum.Enum):
|
|
19
|
+
TRUST_PROMPT_UNRESOLVED = "trust_prompt_unresolved"
|
|
20
|
+
PROMPT_MISDELIVERY = "prompt_misdelivery"
|
|
21
|
+
STALE_BRANCH = "stale_branch"
|
|
22
|
+
COMPILE_RED_CROSS_CRATE = "compile_red_cross_crate"
|
|
23
|
+
MCP_HANDSHAKE_FAILURE = "mcp_handshake_failure"
|
|
24
|
+
PARTIAL_PLUGIN_STARTUP = "partial_plugin_startup"
|
|
25
|
+
PROVIDER_FAILURE = "provider_failure"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class RecoveryStep(enum.Enum):
|
|
29
|
+
ACCEPT_TRUST_PROMPT = "accept_trust_prompt"
|
|
30
|
+
REDIRECT_PROMPT_TO_AGENT = "redirect_prompt_to_agent"
|
|
31
|
+
REBASE_BRANCH = "rebase_branch"
|
|
32
|
+
CLEAN_BUILD = "clean_build"
|
|
33
|
+
RETRY_MCP_HANDSHAKE = "retry_mcp_handshake"
|
|
34
|
+
RESTART_PLUGIN = "restart_plugin"
|
|
35
|
+
RESTART_WORKER = "restart_worker"
|
|
36
|
+
ESCALATE_TO_HUMAN = "escalate_to_human"
|
|
37
|
+
WAIT_AND_RETRY = "wait_and_retry"
|
|
38
|
+
LOG_AND_CONTINUE = "log_and_continue"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class EscalationPolicy(enum.Enum):
|
|
42
|
+
ALERT_HUMAN = "alert_human"
|
|
43
|
+
LOG_AND_CONTINUE = "log_and_continue"
|
|
44
|
+
ABORT = "abort"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class RecoveryRecipe:
|
|
49
|
+
"""A recovery recipe for a specific failure scenario."""
|
|
50
|
+
|
|
51
|
+
scenario: FailureScenario
|
|
52
|
+
steps: list[RecoveryStep]
|
|
53
|
+
max_attempts: int = 3
|
|
54
|
+
escalation: EscalationPolicy = EscalationPolicy.ALERT_HUMAN
|
|
55
|
+
backoff_ms: int = 1000
|
|
56
|
+
description: str = ""
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class RecoveryContext:
|
|
61
|
+
"""Context available to recovery execution."""
|
|
62
|
+
|
|
63
|
+
scenario: FailureScenario
|
|
64
|
+
worker_id: str | None = None
|
|
65
|
+
plugin_name: str | None = None
|
|
66
|
+
mcp_server: str | None = None
|
|
67
|
+
branch: str | None = None
|
|
68
|
+
error_message: str = ""
|
|
69
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class RecoveryEvent:
|
|
74
|
+
"""Event recorded during recovery execution."""
|
|
75
|
+
|
|
76
|
+
step: RecoveryStep
|
|
77
|
+
attempt: int
|
|
78
|
+
success: bool
|
|
79
|
+
message: str = ""
|
|
80
|
+
timestamp_ms: int = field(default_factory=lambda: int(time.time() * 1000))
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class RecoveryResult:
|
|
85
|
+
"""Result of a recovery attempt."""
|
|
86
|
+
|
|
87
|
+
success: bool
|
|
88
|
+
attempts: int = 0
|
|
89
|
+
message: str = ""
|
|
90
|
+
events: list[RecoveryEvent] = field(default_factory=list)
|
|
91
|
+
escalated: bool = False
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Step executors
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
# Registry of step execution functions
|
|
99
|
+
StepExecutor = Callable[[RecoveryContext], Awaitable[bool]]
|
|
100
|
+
_step_executors: dict[RecoveryStep, StepExecutor] = {}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def register_step_executor(step: RecoveryStep, executor: StepExecutor) -> None:
|
|
104
|
+
"""Register an executor for a recovery step."""
|
|
105
|
+
_step_executors[step] = executor
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
async def _default_step_executor(step: RecoveryStep, context: RecoveryContext) -> bool:
|
|
109
|
+
"""Default executor that logs and returns success for simple steps."""
|
|
110
|
+
match step:
|
|
111
|
+
case RecoveryStep.LOG_AND_CONTINUE:
|
|
112
|
+
logger.info("Recovery: logging and continuing for %s", context.scenario.value)
|
|
113
|
+
return True
|
|
114
|
+
case RecoveryStep.WAIT_AND_RETRY:
|
|
115
|
+
logger.info("Recovery: waiting before retry for %s", context.scenario.value)
|
|
116
|
+
await asyncio.sleep(1.0)
|
|
117
|
+
return True
|
|
118
|
+
case RecoveryStep.ESCALATE_TO_HUMAN:
|
|
119
|
+
logger.warning(
|
|
120
|
+
"Recovery: escalating to human for %s: %s",
|
|
121
|
+
context.scenario.value, context.error_message,
|
|
122
|
+
)
|
|
123
|
+
return True # Escalation "succeeds" — it's been handed off
|
|
124
|
+
case _:
|
|
125
|
+
# Check registered executors
|
|
126
|
+
executor = _step_executors.get(step)
|
|
127
|
+
if executor:
|
|
128
|
+
return await executor(context)
|
|
129
|
+
logger.warning("No executor for recovery step %s, skipping", step.value)
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
# Recipe registry
|
|
135
|
+
# ---------------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def recipe_for(scenario: FailureScenario) -> RecoveryRecipe:
|
|
138
|
+
"""Get the recovery recipe for a given failure scenario."""
|
|
139
|
+
recipes: dict[FailureScenario, RecoveryRecipe] = {
|
|
140
|
+
FailureScenario.TRUST_PROMPT_UNRESOLVED: RecoveryRecipe(
|
|
141
|
+
scenario=scenario,
|
|
142
|
+
steps=[RecoveryStep.ACCEPT_TRUST_PROMPT],
|
|
143
|
+
max_attempts=1,
|
|
144
|
+
description="Auto-resolve trust prompt if allowlisted",
|
|
145
|
+
),
|
|
146
|
+
FailureScenario.PROMPT_MISDELIVERY: RecoveryRecipe(
|
|
147
|
+
scenario=scenario,
|
|
148
|
+
steps=[RecoveryStep.REDIRECT_PROMPT_TO_AGENT, RecoveryStep.WAIT_AND_RETRY],
|
|
149
|
+
max_attempts=3,
|
|
150
|
+
backoff_ms=500,
|
|
151
|
+
description="Redirect prompt to correct target with retry",
|
|
152
|
+
),
|
|
153
|
+
FailureScenario.STALE_BRANCH: RecoveryRecipe(
|
|
154
|
+
scenario=scenario,
|
|
155
|
+
steps=[RecoveryStep.REBASE_BRANCH],
|
|
156
|
+
max_attempts=2,
|
|
157
|
+
description="Rebase stale branch from upstream",
|
|
158
|
+
),
|
|
159
|
+
FailureScenario.COMPILE_RED_CROSS_CRATE: RecoveryRecipe(
|
|
160
|
+
scenario=scenario,
|
|
161
|
+
steps=[RecoveryStep.CLEAN_BUILD],
|
|
162
|
+
max_attempts=2,
|
|
163
|
+
description="Clean build artifacts and rebuild",
|
|
164
|
+
),
|
|
165
|
+
FailureScenario.MCP_HANDSHAKE_FAILURE: RecoveryRecipe(
|
|
166
|
+
scenario=scenario,
|
|
167
|
+
steps=[RecoveryStep.RETRY_MCP_HANDSHAKE, RecoveryStep.WAIT_AND_RETRY],
|
|
168
|
+
max_attempts=3,
|
|
169
|
+
backoff_ms=2000,
|
|
170
|
+
description="Retry MCP handshake with backoff",
|
|
171
|
+
),
|
|
172
|
+
FailureScenario.PARTIAL_PLUGIN_STARTUP: RecoveryRecipe(
|
|
173
|
+
scenario=scenario,
|
|
174
|
+
steps=[RecoveryStep.RESTART_PLUGIN],
|
|
175
|
+
max_attempts=2,
|
|
176
|
+
description="Restart failed plugin",
|
|
177
|
+
),
|
|
178
|
+
FailureScenario.PROVIDER_FAILURE: RecoveryRecipe(
|
|
179
|
+
scenario=scenario,
|
|
180
|
+
steps=[RecoveryStep.RESTART_WORKER, RecoveryStep.WAIT_AND_RETRY],
|
|
181
|
+
max_attempts=3,
|
|
182
|
+
backoff_ms=5000,
|
|
183
|
+
escalation=EscalationPolicy.LOG_AND_CONTINUE,
|
|
184
|
+
description="Restart worker with backoff, log if persistent",
|
|
185
|
+
),
|
|
186
|
+
}
|
|
187
|
+
return recipes.get(scenario, RecoveryRecipe(
|
|
188
|
+
scenario=scenario,
|
|
189
|
+
steps=[RecoveryStep.ESCALATE_TO_HUMAN],
|
|
190
|
+
max_attempts=1,
|
|
191
|
+
escalation=EscalationPolicy.ABORT,
|
|
192
|
+
description="Unknown scenario — escalate immediately",
|
|
193
|
+
))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ---------------------------------------------------------------------------
|
|
197
|
+
# Recovery executor
|
|
198
|
+
# ---------------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
async def attempt_recovery(
|
|
201
|
+
scenario: FailureScenario,
|
|
202
|
+
context: RecoveryContext | None = None,
|
|
203
|
+
) -> RecoveryResult:
|
|
204
|
+
"""Execute the recovery recipe for a scenario.
|
|
205
|
+
|
|
206
|
+
Runs steps in order, retrying up to max_attempts with backoff.
|
|
207
|
+
Falls back to escalation policy if all attempts fail.
|
|
208
|
+
"""
|
|
209
|
+
recipe = recipe_for(scenario)
|
|
210
|
+
ctx = context or RecoveryContext(scenario=scenario)
|
|
211
|
+
result = RecoveryResult()
|
|
212
|
+
|
|
213
|
+
for attempt in range(1, recipe.max_attempts + 1):
|
|
214
|
+
result.attempts = attempt
|
|
215
|
+
all_steps_ok = True
|
|
216
|
+
|
|
217
|
+
for step in recipe.steps:
|
|
218
|
+
try:
|
|
219
|
+
success = await _default_step_executor(step, ctx)
|
|
220
|
+
except Exception as exc:
|
|
221
|
+
logger.error("Recovery step %s failed: %s", step.value, exc)
|
|
222
|
+
success = False
|
|
223
|
+
|
|
224
|
+
event = RecoveryEvent(
|
|
225
|
+
step=step, attempt=attempt, success=success,
|
|
226
|
+
message=f"Step {step.value}: {'OK' if success else 'FAILED'}",
|
|
227
|
+
)
|
|
228
|
+
result.events.append(event)
|
|
229
|
+
|
|
230
|
+
if not success:
|
|
231
|
+
all_steps_ok = False
|
|
232
|
+
break
|
|
233
|
+
|
|
234
|
+
if all_steps_ok:
|
|
235
|
+
result.success = True
|
|
236
|
+
result.message = f"Recovered after {attempt} attempt(s)"
|
|
237
|
+
return result
|
|
238
|
+
|
|
239
|
+
# Backoff before retry
|
|
240
|
+
if attempt < recipe.max_attempts:
|
|
241
|
+
delay_ms = recipe.backoff_ms * (2 ** (attempt - 1))
|
|
242
|
+
logger.info("Recovery attempt %d failed, retrying in %dms", attempt, delay_ms)
|
|
243
|
+
await asyncio.sleep(delay_ms / 1000.0)
|
|
244
|
+
|
|
245
|
+
# All attempts exhausted — escalate
|
|
246
|
+
result.message = f"Recovery failed after {recipe.max_attempts} attempt(s)"
|
|
247
|
+
|
|
248
|
+
match recipe.escalation:
|
|
249
|
+
case EscalationPolicy.ALERT_HUMAN:
|
|
250
|
+
result.escalated = True
|
|
251
|
+
result.message += " — escalated to human"
|
|
252
|
+
logger.warning("Recovery escalated: %s", result.message)
|
|
253
|
+
case EscalationPolicy.LOG_AND_CONTINUE:
|
|
254
|
+
result.message += " — logged and continuing"
|
|
255
|
+
logger.warning("Recovery failed but continuing: %s", result.message)
|
|
256
|
+
case EscalationPolicy.ABORT:
|
|
257
|
+
result.escalated = True
|
|
258
|
+
result.message += " — aborting"
|
|
259
|
+
logger.error("Recovery failed, aborting: %s", result.message)
|
|
260
|
+
|
|
261
|
+
return result
|
axion/runtime/remote.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Remote session support.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/runtime/src/remote.rs
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class RemoteSessionContext:
|
|
13
|
+
"""Context for a remote session connection."""
|
|
14
|
+
|
|
15
|
+
session_id: str
|
|
16
|
+
host: str
|
|
17
|
+
port: int = 0
|
|
18
|
+
protocol: str = "https"
|
|
19
|
+
auth_token: str | None = None
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class UpstreamProxy:
|
|
24
|
+
"""Proxy configuration for upstream connections."""
|
|
25
|
+
|
|
26
|
+
url: str
|
|
27
|
+
headers: dict[str, str] = field(default_factory=dict)
|
|
28
|
+
timeout_ms: int = 30_000
|
axion/runtime/sandbox.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Sandbox detection and configuration.
|
|
2
|
+
|
|
3
|
+
Maps to: rust/crates/runtime/src/sandbox.rs
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import platform
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class SandboxStatus:
|
|
15
|
+
"""Status of the execution sandbox."""
|
|
16
|
+
|
|
17
|
+
available: bool
|
|
18
|
+
enabled: bool
|
|
19
|
+
platform: str
|
|
20
|
+
details: str = ""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def detect_sandbox() -> SandboxStatus:
|
|
24
|
+
"""Detect sandbox capabilities on the current platform."""
|
|
25
|
+
plat = platform.system().lower()
|
|
26
|
+
|
|
27
|
+
if plat == "linux":
|
|
28
|
+
# Check for common sandboxing tools
|
|
29
|
+
has_firejail = os.path.exists("/usr/bin/firejail")
|
|
30
|
+
has_bubblewrap = os.path.exists("/usr/bin/bwrap")
|
|
31
|
+
has_docker = os.path.exists("/usr/bin/docker")
|
|
32
|
+
|
|
33
|
+
if has_firejail:
|
|
34
|
+
return SandboxStatus(
|
|
35
|
+
available=True, enabled=False, platform="linux",
|
|
36
|
+
details="firejail available",
|
|
37
|
+
)
|
|
38
|
+
if has_bubblewrap:
|
|
39
|
+
return SandboxStatus(
|
|
40
|
+
available=True, enabled=False, platform="linux",
|
|
41
|
+
details="bubblewrap available",
|
|
42
|
+
)
|
|
43
|
+
if has_docker:
|
|
44
|
+
return SandboxStatus(
|
|
45
|
+
available=True, enabled=False, platform="linux",
|
|
46
|
+
details="docker available",
|
|
47
|
+
)
|
|
48
|
+
return SandboxStatus(
|
|
49
|
+
available=False, enabled=False, platform="linux",
|
|
50
|
+
details="No sandbox tools found (install firejail or bubblewrap)",
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if plat == "darwin":
|
|
54
|
+
return SandboxStatus(
|
|
55
|
+
available=True, enabled=False, platform="macos",
|
|
56
|
+
details="macOS sandbox-exec available",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
if plat == "windows":
|
|
60
|
+
return SandboxStatus(
|
|
61
|
+
available=False, enabled=False, platform="windows",
|
|
62
|
+
details="Sandboxing not supported on Windows",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
return SandboxStatus(
|
|
66
|
+
available=False, enabled=False, platform=plat,
|
|
67
|
+
details=f"Unknown platform: {plat}",
|
|
68
|
+
)
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"""Cron scheduler loop — runs scheduled tasks on a timer.
|
|
2
|
+
|
|
3
|
+
Checks the CronRegistry every minute and triggers due tasks by
|
|
4
|
+
creating entries in the TaskRegistry and optionally spawning workers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import re
|
|
12
|
+
import time
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from typing import Any, Awaitable, Callable
|
|
16
|
+
|
|
17
|
+
from axion.runtime.tasks import CronRegistry, TaskPacket, TaskRegistry
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Cron expression parser (basic: minute hour day month weekday)
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
def cron_matches_now(expression: str, now: datetime | None = None) -> bool:
|
|
27
|
+
"""Check if a cron expression matches the current (or given) time.
|
|
28
|
+
|
|
29
|
+
Supports: * (any), specific values, comma-separated lists, and */N (step).
|
|
30
|
+
Format: minute hour day_of_month month day_of_week
|
|
31
|
+
"""
|
|
32
|
+
now = now or datetime.now()
|
|
33
|
+
parts = expression.strip().split()
|
|
34
|
+
if len(parts) != 5:
|
|
35
|
+
logger.warning("Invalid cron expression (need 5 fields): %s", expression)
|
|
36
|
+
return False
|
|
37
|
+
|
|
38
|
+
fields = [
|
|
39
|
+
(parts[0], now.minute, 0, 59),
|
|
40
|
+
(parts[1], now.hour, 0, 23),
|
|
41
|
+
(parts[2], now.day, 1, 31),
|
|
42
|
+
(parts[3], now.month, 1, 12),
|
|
43
|
+
(parts[4], now.weekday(), 0, 6), # 0=Monday in Python
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
for pattern, current, low, high in fields:
|
|
47
|
+
if not _field_matches(pattern, current, low, high):
|
|
48
|
+
return False
|
|
49
|
+
return True
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _field_matches(pattern: str, value: int, low: int, high: int) -> bool:
|
|
53
|
+
"""Check if a single cron field matches the current value."""
|
|
54
|
+
if pattern == "*":
|
|
55
|
+
return True
|
|
56
|
+
|
|
57
|
+
# Step: */N
|
|
58
|
+
step_match = re.match(r"^\*/(\d+)$", pattern)
|
|
59
|
+
if step_match:
|
|
60
|
+
step = int(step_match.group(1))
|
|
61
|
+
return value % step == 0
|
|
62
|
+
|
|
63
|
+
# Comma-separated list: 1,5,10
|
|
64
|
+
for part in pattern.split(","):
|
|
65
|
+
part = part.strip()
|
|
66
|
+
# Range: 1-5
|
|
67
|
+
range_match = re.match(r"^(\d+)-(\d+)$", part)
|
|
68
|
+
if range_match:
|
|
69
|
+
lo, hi = int(range_match.group(1)), int(range_match.group(2))
|
|
70
|
+
if lo <= value <= hi:
|
|
71
|
+
return True
|
|
72
|
+
continue
|
|
73
|
+
# Exact value
|
|
74
|
+
try:
|
|
75
|
+
if int(part) == value:
|
|
76
|
+
return True
|
|
77
|
+
except ValueError:
|
|
78
|
+
pass
|
|
79
|
+
|
|
80
|
+
return False
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------------------------------------------------------------------------
|
|
84
|
+
# Scheduler
|
|
85
|
+
# ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class SchedulerConfig:
|
|
89
|
+
"""Configuration for the cron scheduler."""
|
|
90
|
+
|
|
91
|
+
check_interval_seconds: float = 60.0 # Check every minute
|
|
92
|
+
max_concurrent_tasks: int = 5
|
|
93
|
+
enabled: bool = True
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class CronScheduler:
|
|
97
|
+
"""Background scheduler that triggers cron-scheduled tasks.
|
|
98
|
+
|
|
99
|
+
Usage:
|
|
100
|
+
scheduler = CronScheduler(cron_registry, task_registry)
|
|
101
|
+
await scheduler.start()
|
|
102
|
+
# ... later ...
|
|
103
|
+
await scheduler.stop()
|
|
104
|
+
"""
|
|
105
|
+
|
|
106
|
+
def __init__(
|
|
107
|
+
self,
|
|
108
|
+
cron_registry: CronRegistry,
|
|
109
|
+
task_registry: TaskRegistry,
|
|
110
|
+
config: SchedulerConfig | None = None,
|
|
111
|
+
on_task_triggered: Callable[[str, TaskPacket], Awaitable[None]] | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
self.cron_registry = cron_registry
|
|
114
|
+
self.task_registry = task_registry
|
|
115
|
+
self.config = config or SchedulerConfig()
|
|
116
|
+
self.on_task_triggered = on_task_triggered
|
|
117
|
+
self._task: asyncio.Task[None] | None = None
|
|
118
|
+
self._running = False
|
|
119
|
+
|
|
120
|
+
async def start(self) -> None:
|
|
121
|
+
"""Start the scheduler loop in the background."""
|
|
122
|
+
if self._running:
|
|
123
|
+
return
|
|
124
|
+
self._running = True
|
|
125
|
+
self._task = asyncio.create_task(self._loop())
|
|
126
|
+
logger.info("Cron scheduler started (interval: %.0fs)", self.config.check_interval_seconds)
|
|
127
|
+
|
|
128
|
+
async def stop(self) -> None:
|
|
129
|
+
"""Stop the scheduler loop."""
|
|
130
|
+
self._running = False
|
|
131
|
+
if self._task:
|
|
132
|
+
self._task.cancel()
|
|
133
|
+
try:
|
|
134
|
+
await self._task
|
|
135
|
+
except asyncio.CancelledError:
|
|
136
|
+
pass
|
|
137
|
+
self._task = None
|
|
138
|
+
logger.info("Cron scheduler stopped")
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def is_running(self) -> bool:
|
|
142
|
+
return self._running
|
|
143
|
+
|
|
144
|
+
async def _loop(self) -> None:
|
|
145
|
+
"""Main scheduler loop — checks cron entries every interval."""
|
|
146
|
+
while self._running:
|
|
147
|
+
try:
|
|
148
|
+
await self._check_and_trigger()
|
|
149
|
+
except Exception:
|
|
150
|
+
logger.exception("Scheduler loop error")
|
|
151
|
+
|
|
152
|
+
await asyncio.sleep(self.config.check_interval_seconds)
|
|
153
|
+
|
|
154
|
+
async def _check_and_trigger(self) -> None:
|
|
155
|
+
"""Check all enabled cron entries and trigger any that are due."""
|
|
156
|
+
now = datetime.now()
|
|
157
|
+
now_ms = int(time.time() * 1000)
|
|
158
|
+
|
|
159
|
+
for entry in self.cron_registry.enabled_entries():
|
|
160
|
+
if not cron_matches_now(entry.schedule, now):
|
|
161
|
+
continue
|
|
162
|
+
|
|
163
|
+
# Don't re-trigger within the same minute
|
|
164
|
+
if entry.last_run_ms > 0:
|
|
165
|
+
elapsed_ms = now_ms - entry.last_run_ms
|
|
166
|
+
if elapsed_ms < 55_000: # Less than 55 seconds since last run
|
|
167
|
+
continue
|
|
168
|
+
|
|
169
|
+
# Trigger the task
|
|
170
|
+
logger.info("Cron trigger: %s (schedule: %s)", entry.cron_id, entry.schedule)
|
|
171
|
+
task_id = self.task_registry.create(entry.packet)
|
|
172
|
+
self.cron_registry.record_run(entry.cron_id)
|
|
173
|
+
|
|
174
|
+
# Notify callback if set
|
|
175
|
+
if self.on_task_triggered:
|
|
176
|
+
try:
|
|
177
|
+
await self.on_task_triggered(task_id, entry.packet)
|
|
178
|
+
except Exception:
|
|
179
|
+
logger.exception("on_task_triggered callback failed for %s", task_id)
|
|
180
|
+
|
|
181
|
+
def status(self) -> dict[str, Any]:
|
|
182
|
+
"""Return scheduler status summary."""
|
|
183
|
+
entries = self.cron_registry.all_entries()
|
|
184
|
+
return {
|
|
185
|
+
"running": self._running,
|
|
186
|
+
"interval_seconds": self.config.check_interval_seconds,
|
|
187
|
+
"total_entries": len(entries),
|
|
188
|
+
"enabled_entries": len([e for e in entries if e.enabled]),
|
|
189
|
+
"entries": [
|
|
190
|
+
{
|
|
191
|
+
"cron_id": e.cron_id,
|
|
192
|
+
"schedule": e.schedule,
|
|
193
|
+
"enabled": e.enabled,
|
|
194
|
+
"run_count": e.run_count,
|
|
195
|
+
"last_run_ms": e.last_run_ms,
|
|
196
|
+
"objective": e.packet.objective[:50],
|
|
197
|
+
}
|
|
198
|
+
for e in entries
|
|
199
|
+
],
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ---------------------------------------------------------------------------
|
|
204
|
+
# Convenience: human-readable schedule descriptions
|
|
205
|
+
# ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def describe_schedule(expression: str) -> str:
|
|
208
|
+
"""Convert a cron expression to a human-readable description."""
|
|
209
|
+
parts = expression.strip().split()
|
|
210
|
+
if len(parts) != 5:
|
|
211
|
+
return expression
|
|
212
|
+
|
|
213
|
+
minute, hour, dom, month, dow = parts
|
|
214
|
+
|
|
215
|
+
if expression == "* * * * *":
|
|
216
|
+
return "Every minute"
|
|
217
|
+
if minute.startswith("*/"):
|
|
218
|
+
n = minute[2:]
|
|
219
|
+
return f"Every {n} minutes"
|
|
220
|
+
if hour == "*" and minute != "*":
|
|
221
|
+
return f"Every hour at minute {minute}"
|
|
222
|
+
if dom == "*" and month == "*" and dow == "*":
|
|
223
|
+
if hour != "*" and minute != "*":
|
|
224
|
+
return f"Daily at {hour}:{minute.zfill(2)}"
|
|
225
|
+
if dow != "*" and dom == "*":
|
|
226
|
+
days = {"0": "Mon", "1": "Tue", "2": "Wed", "3": "Thu", "4": "Fri", "5": "Sat", "6": "Sun"}
|
|
227
|
+
day_name = days.get(dow, dow)
|
|
228
|
+
if hour != "*" and minute != "*":
|
|
229
|
+
return f"Every {day_name} at {hour}:{minute.zfill(2)}"
|
|
230
|
+
|
|
231
|
+
return expression
|