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.
Files changed (82) hide show
  1. axion/__init__.py +3 -0
  2. axion/api/__init__.py +0 -0
  3. axion/api/anthropic.py +460 -0
  4. axion/api/client.py +259 -0
  5. axion/api/error.py +161 -0
  6. axion/api/ollama.py +597 -0
  7. axion/api/openai_compat.py +805 -0
  8. axion/api/openai_responses.py +627 -0
  9. axion/api/prompt_cache.py +31 -0
  10. axion/api/sse.py +98 -0
  11. axion/api/types.py +451 -0
  12. axion/cli/__init__.py +0 -0
  13. axion/cli/init_cmd.py +50 -0
  14. axion/cli/input.py +290 -0
  15. axion/cli/main.py +2953 -0
  16. axion/cli/render.py +489 -0
  17. axion/cli/tui.py +766 -0
  18. axion/commands/__init__.py +0 -0
  19. axion/commands/handlers/__init__.py +0 -0
  20. axion/commands/handlers/agents.py +51 -0
  21. axion/commands/handlers/builtin_commands.py +367 -0
  22. axion/commands/handlers/mcp.py +59 -0
  23. axion/commands/handlers/models.py +75 -0
  24. axion/commands/handlers/plugins.py +55 -0
  25. axion/commands/handlers/skills.py +61 -0
  26. axion/commands/parsing.py +317 -0
  27. axion/commands/registry.py +166 -0
  28. axion/compat_harness/__init__.py +0 -0
  29. axion/compat_harness/extractor.py +145 -0
  30. axion/plugins/__init__.py +0 -0
  31. axion/plugins/hooks.py +22 -0
  32. axion/plugins/manager.py +391 -0
  33. axion/plugins/manifest.py +270 -0
  34. axion/runtime/__init__.py +0 -0
  35. axion/runtime/bash.py +388 -0
  36. axion/runtime/bootstrap.py +39 -0
  37. axion/runtime/claude_subscription.py +300 -0
  38. axion/runtime/compact.py +233 -0
  39. axion/runtime/config.py +397 -0
  40. axion/runtime/conversation.py +1073 -0
  41. axion/runtime/file_ops.py +613 -0
  42. axion/runtime/git.py +213 -0
  43. axion/runtime/hooks.py +235 -0
  44. axion/runtime/image.py +212 -0
  45. axion/runtime/lanes.py +282 -0
  46. axion/runtime/lsp.py +425 -0
  47. axion/runtime/mcp/__init__.py +0 -0
  48. axion/runtime/mcp/client.py +76 -0
  49. axion/runtime/mcp/lifecycle.py +96 -0
  50. axion/runtime/mcp/stdio.py +318 -0
  51. axion/runtime/mcp/tool_bridge.py +79 -0
  52. axion/runtime/memory.py +196 -0
  53. axion/runtime/oauth.py +329 -0
  54. axion/runtime/openai_subscription.py +346 -0
  55. axion/runtime/permissions.py +247 -0
  56. axion/runtime/plan_mode.py +96 -0
  57. axion/runtime/policy_engine.py +259 -0
  58. axion/runtime/prompt.py +586 -0
  59. axion/runtime/recovery.py +261 -0
  60. axion/runtime/remote.py +28 -0
  61. axion/runtime/sandbox.py +68 -0
  62. axion/runtime/scheduler.py +231 -0
  63. axion/runtime/session.py +365 -0
  64. axion/runtime/sharing.py +159 -0
  65. axion/runtime/skills.py +124 -0
  66. axion/runtime/tasks.py +258 -0
  67. axion/runtime/usage.py +241 -0
  68. axion/runtime/workers.py +186 -0
  69. axion/telemetry/__init__.py +0 -0
  70. axion/telemetry/events.py +67 -0
  71. axion/telemetry/profile.py +49 -0
  72. axion/telemetry/sink.py +60 -0
  73. axion/telemetry/tracer.py +95 -0
  74. axion/tools/__init__.py +0 -0
  75. axion/tools/lane_completion.py +33 -0
  76. axion/tools/registry.py +853 -0
  77. axion/tools/tool_search.py +226 -0
  78. axion_code-1.0.0.dist-info/METADATA +709 -0
  79. axion_code-1.0.0.dist-info/RECORD +82 -0
  80. axion_code-1.0.0.dist-info/WHEEL +4 -0
  81. axion_code-1.0.0.dist-info/entry_points.txt +2 -0
  82. 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
@@ -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
@@ -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