feat: cortex init + schedule — self-managed jobs via systemd/launchd
All checks were successful
Tests / test (push) Successful in 2s
All checks were successful
Tests / test (push) Successful in 2s
- cortex init: creates workspace, smoke tests, optional job setup - cortex schedule list|status|enable|disable|logs - Linux: systemd user timers - macOS: launchd plists - Jobs: feedback (6h), hygiene (daily), health (30min) - --interval flag for custom intervals - Replaces external OpenClaw cron jobs - 169/169 tests green
This commit is contained in:
parent
734f96cfcf
commit
cbd3556a09
3 changed files with 588 additions and 1 deletions
|
|
@ -2,6 +2,8 @@
|
||||||
"""Cortex CLI — unified entry point for all intelligence modules.
|
"""Cortex CLI — unified entry point for all intelligence modules.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
|
cortex init [--enable-all] [--non-interactive]
|
||||||
|
cortex schedule list|status|enable|disable|logs <job>
|
||||||
cortex triage score "task description"
|
cortex triage score "task description"
|
||||||
cortex health [--json]
|
cortex health [--json]
|
||||||
cortex feedback --since 6h [--dry-run]
|
cortex feedback --since 6h [--dry-run]
|
||||||
|
|
@ -25,7 +27,15 @@ def main():
|
||||||
# Strip the command from argv so sub-modules see clean args
|
# Strip the command from argv so sub-modules see clean args
|
||||||
sys.argv = [f"cortex {cmd}"] + sys.argv[2:]
|
sys.argv = [f"cortex {cmd}"] + sys.argv[2:]
|
||||||
|
|
||||||
if cmd == "version":
|
if cmd == "init":
|
||||||
|
from cortex.init import main as init_main
|
||||||
|
init_main()
|
||||||
|
|
||||||
|
elif cmd == "schedule":
|
||||||
|
from cortex.scheduler import main as schedule_main
|
||||||
|
schedule_main()
|
||||||
|
|
||||||
|
elif cmd == "version":
|
||||||
from cortex import __version__
|
from cortex import __version__
|
||||||
print(f"cortex {__version__}")
|
print(f"cortex {__version__}")
|
||||||
|
|
||||||
|
|
|
||||||
129
cortex/init.py
Normal file
129
cortex/init.py
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Cortex Init — first-time setup.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cortex init [--home ~/.cortex] [--enable-all] [--non-interactive]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from cortex.config import cortex_home, memory_dir, config_path, logs_dir
|
||||||
|
from cortex.scheduler import JOBS, enable_job
|
||||||
|
|
||||||
|
|
||||||
|
def _prompt(msg: str, default: str = "y") -> bool:
|
||||||
|
"""Simple y/n prompt."""
|
||||||
|
suffix = " [Y/n] " if default == "y" else " [y/N] "
|
||||||
|
try:
|
||||||
|
answer = input(msg + suffix).strip().lower()
|
||||||
|
except (EOFError, KeyboardInterrupt):
|
||||||
|
print()
|
||||||
|
return default == "y"
|
||||||
|
if not answer:
|
||||||
|
return default == "y"
|
||||||
|
return answer in ("y", "yes", "ja", "j")
|
||||||
|
|
||||||
|
|
||||||
|
def init(home: Path | None = None, enable_all: bool = False,
|
||||||
|
non_interactive: bool = False) -> bool:
|
||||||
|
"""Initialize cortex workspace."""
|
||||||
|
base = home or cortex_home()
|
||||||
|
mem = memory_dir()
|
||||||
|
logs = logs_dir()
|
||||||
|
cfg = config_path()
|
||||||
|
|
||||||
|
print(f"🧠 Cortex Init")
|
||||||
|
print(f" Home: {base}")
|
||||||
|
print(f" Memory: {mem}")
|
||||||
|
print(f" Config: {cfg}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Create directories
|
||||||
|
for d in [base, mem, mem / "archive", logs]:
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
print(f" 📁 {d}")
|
||||||
|
|
||||||
|
# Create default config if not exists
|
||||||
|
if not cfg.exists():
|
||||||
|
default_config = {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"permanent_files": ["README.md"],
|
||||||
|
"sessions_dir": "~/.openclaw/agents/main/sessions",
|
||||||
|
}
|
||||||
|
cfg.write_text(json.dumps(default_config, indent=2) + "\n")
|
||||||
|
print(f" 📄 {cfg} (created)")
|
||||||
|
else:
|
||||||
|
print(f" 📄 {cfg} (exists)")
|
||||||
|
|
||||||
|
# Create default memory README
|
||||||
|
readme = mem / "README.md"
|
||||||
|
if not readme.exists():
|
||||||
|
readme.write_text("# Memory\n\nCortex memory directory. Files here are managed by `cortex hygiene`.\n")
|
||||||
|
print(f" 📄 {readme} (created)")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Smoke test
|
||||||
|
print(" 🔍 Smoke test...")
|
||||||
|
try:
|
||||||
|
from cortex.triage import score_task
|
||||||
|
r = score_task("test task")
|
||||||
|
print(f" ✅ Triage: priority={r.priority:.2f}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Triage failed: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from cortex.memory_hygiene import gather_stats
|
||||||
|
s = gather_stats()
|
||||||
|
print(f" ✅ Hygiene: {s['total_files']} files, {s['total_size_human']}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" ❌ Hygiene failed: {e}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Schedule jobs
|
||||||
|
if enable_all:
|
||||||
|
jobs_to_enable = list(JOBS.keys())
|
||||||
|
elif non_interactive:
|
||||||
|
jobs_to_enable = []
|
||||||
|
else:
|
||||||
|
print(" 📅 Schedule periodic jobs?")
|
||||||
|
jobs_to_enable = []
|
||||||
|
for name, jdef in JOBS.items():
|
||||||
|
interval = jdef["default_interval_min"]
|
||||||
|
if _prompt(f" Enable {name} (every {interval}min)?"):
|
||||||
|
jobs_to_enable.append(name)
|
||||||
|
|
||||||
|
for job in jobs_to_enable:
|
||||||
|
ok = enable_job(job)
|
||||||
|
if ok:
|
||||||
|
print(f" ✅ {job} scheduled")
|
||||||
|
else:
|
||||||
|
print(f" ❌ {job} failed to schedule")
|
||||||
|
|
||||||
|
print()
|
||||||
|
print(" 🧠 Cortex ready!")
|
||||||
|
if jobs_to_enable:
|
||||||
|
print(f" Run 'cortex schedule status' to verify jobs.")
|
||||||
|
print(f" Run 'cortex --help' for all commands.")
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Initialize Cortex")
|
||||||
|
parser.add_argument("--home", type=Path, help="Cortex home directory")
|
||||||
|
parser.add_argument("--enable-all", action="store_true",
|
||||||
|
help="Enable all scheduled jobs")
|
||||||
|
parser.add_argument("--non-interactive", action="store_true",
|
||||||
|
help="Skip interactive prompts")
|
||||||
|
args = parser.parse_args()
|
||||||
|
init(home=args.home, enable_all=args.enable_all,
|
||||||
|
non_interactive=args.non_interactive)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
448
cortex/scheduler.py
Normal file
448
cortex/scheduler.py
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Cortex Scheduler — manage periodic jobs via systemd (Linux) or launchd (macOS).
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cortex schedule list
|
||||||
|
cortex schedule enable <job> [--interval <minutes>]
|
||||||
|
cortex schedule disable <job>
|
||||||
|
cortex schedule status
|
||||||
|
cortex schedule logs <job> [--lines 50]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import platform
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from cortex.config import cortex_home
|
||||||
|
|
||||||
|
|
||||||
|
# --- Job definitions ---
|
||||||
|
|
||||||
|
JOBS = {
|
||||||
|
"feedback": {
|
||||||
|
"description": "Extract lessons from session transcripts",
|
||||||
|
"command": "cortex feedback --since {interval}",
|
||||||
|
"default_interval_min": 360, # 6 hours
|
||||||
|
"calendar": "*-*-* 0/6:00:00", # systemd OnCalendar
|
||||||
|
},
|
||||||
|
"hygiene": {
|
||||||
|
"description": "Clean stale/duplicate/orphan memory files",
|
||||||
|
"command": "cortex hygiene duplicates && cortex hygiene stale && cortex hygiene archive",
|
||||||
|
"default_interval_min": 1440, # daily
|
||||||
|
"calendar": "*-*-* 04:00:00",
|
||||||
|
},
|
||||||
|
"health": {
|
||||||
|
"description": "Proactive system health scan",
|
||||||
|
"command": "cortex health --json",
|
||||||
|
"default_interval_min": 30,
|
||||||
|
"calendar": "*-*-* *:0/30:00",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_linux() -> bool:
|
||||||
|
return platform.system() == "Linux"
|
||||||
|
|
||||||
|
|
||||||
|
def _is_macos() -> bool:
|
||||||
|
return platform.system() == "Darwin"
|
||||||
|
|
||||||
|
|
||||||
|
def _cortex_bin() -> str:
|
||||||
|
"""Find the cortex binary path."""
|
||||||
|
which = shutil.which("cortex")
|
||||||
|
if which:
|
||||||
|
return which
|
||||||
|
# Fallback: python -m cortex.cli
|
||||||
|
return f"{sys.executable} -m cortex.cli"
|
||||||
|
|
||||||
|
|
||||||
|
def _env_vars() -> dict[str, str]:
|
||||||
|
"""Collect CORTEX_* env vars to pass to scheduled jobs."""
|
||||||
|
env = {}
|
||||||
|
for key in ("CORTEX_HOME", "CORTEX_MEMORY_DIR", "CORTEX_CONFIG",
|
||||||
|
"CORTEX_GROWTH_LOG", "CORTEX_ROADMAP"):
|
||||||
|
val = os.environ.get(key)
|
||||||
|
if val:
|
||||||
|
env[key] = val
|
||||||
|
return env
|
||||||
|
|
||||||
|
|
||||||
|
# --- systemd (Linux) ---
|
||||||
|
|
||||||
|
def _systemd_dir() -> Path:
|
||||||
|
return Path.home() / ".config" / "systemd" / "user"
|
||||||
|
|
||||||
|
|
||||||
|
def _systemd_unit_name(job: str) -> str:
|
||||||
|
return f"cortex-{job}"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_systemd_units(job: str, interval_min: int) -> tuple[Path, Path]:
|
||||||
|
"""Write .service and .timer files for a job."""
|
||||||
|
job_def = JOBS[job]
|
||||||
|
unit = _systemd_unit_name(job)
|
||||||
|
sdir = _systemd_dir()
|
||||||
|
sdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
cortex_bin = _cortex_bin()
|
||||||
|
env_vars = _env_vars()
|
||||||
|
env_lines = "\n".join(f"Environment={k}={v}" for k, v in env_vars.items())
|
||||||
|
|
||||||
|
# For feedback, convert interval to --since format
|
||||||
|
command = job_def["command"]
|
||||||
|
if "{interval}" in command:
|
||||||
|
hours = max(1, interval_min // 60)
|
||||||
|
command = command.replace("{interval}", f"{hours}h")
|
||||||
|
|
||||||
|
service_path = sdir / f"{unit}.service"
|
||||||
|
service_path.write_text(textwrap.dedent(f"""\
|
||||||
|
[Unit]
|
||||||
|
Description=Cortex {job.title()} — {job_def['description']}
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=oneshot
|
||||||
|
ExecStart=/bin/bash -c '{command}'
|
||||||
|
{env_lines}
|
||||||
|
Environment=PATH={os.environ.get('PATH', '/usr/local/bin:/usr/bin:/bin')}
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
|
"""))
|
||||||
|
|
||||||
|
timer_path = sdir / f"{unit}.timer"
|
||||||
|
timer_path.write_text(textwrap.dedent(f"""\
|
||||||
|
[Unit]
|
||||||
|
Description=Cortex {job.title()} Timer — every {interval_min}min
|
||||||
|
|
||||||
|
[Timer]
|
||||||
|
OnBootSec=5min
|
||||||
|
OnUnitActiveSec={interval_min}min
|
||||||
|
Persistent=true
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=timers.target
|
||||||
|
"""))
|
||||||
|
|
||||||
|
return service_path, timer_path
|
||||||
|
|
||||||
|
|
||||||
|
def _enable_systemd(job: str, interval_min: int) -> bool:
|
||||||
|
service_path, timer_path = _write_systemd_units(job, interval_min)
|
||||||
|
unit = _systemd_unit_name(job)
|
||||||
|
try:
|
||||||
|
subprocess.run(["systemctl", "--user", "daemon-reload"], check=True, capture_output=True)
|
||||||
|
subprocess.run(["systemctl", "--user", "enable", "--now", f"{unit}.timer"],
|
||||||
|
check=True, capture_output=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error enabling systemd timer: {e.stderr.decode()}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _disable_systemd(job: str) -> bool:
|
||||||
|
unit = _systemd_unit_name(job)
|
||||||
|
try:
|
||||||
|
subprocess.run(["systemctl", "--user", "disable", "--now", f"{unit}.timer"],
|
||||||
|
check=True, capture_output=True)
|
||||||
|
# Clean up unit files
|
||||||
|
sdir = _systemd_dir()
|
||||||
|
for ext in (".service", ".timer"):
|
||||||
|
f = sdir / f"{unit}{ext}"
|
||||||
|
if f.exists():
|
||||||
|
f.unlink()
|
||||||
|
subprocess.run(["systemctl", "--user", "daemon-reload"], capture_output=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error disabling systemd timer: {e.stderr.decode()}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _status_systemd() -> list[dict]:
|
||||||
|
results = []
|
||||||
|
for job in JOBS:
|
||||||
|
unit = _systemd_unit_name(job)
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["systemctl", "--user", "is-active", f"{unit}.timer"],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
active = r.stdout.strip() == "active"
|
||||||
|
except FileNotFoundError:
|
||||||
|
active = False
|
||||||
|
|
||||||
|
# Get next trigger time
|
||||||
|
next_run = ""
|
||||||
|
if active:
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["systemctl", "--user", "show", f"{unit}.timer",
|
||||||
|
"--property=NextElapseUSecRealtime"],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
next_run = r.stdout.strip().split("=", 1)[-1] if "=" in r.stdout else ""
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"job": job,
|
||||||
|
"description": JOBS[job]["description"],
|
||||||
|
"active": active,
|
||||||
|
"next_run": next_run,
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _logs_systemd(job: str, lines: int = 50) -> str:
|
||||||
|
unit = _systemd_unit_name(job)
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["journalctl", "--user", "-u", f"{unit}.service",
|
||||||
|
f"--lines={lines}", "--no-pager"],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
return r.stdout
|
||||||
|
except FileNotFoundError:
|
||||||
|
return "journalctl not available"
|
||||||
|
|
||||||
|
|
||||||
|
# --- launchd (macOS) ---
|
||||||
|
|
||||||
|
def _launchd_dir() -> Path:
|
||||||
|
return Path.home() / "Library" / "LaunchAgents"
|
||||||
|
|
||||||
|
|
||||||
|
def _launchd_label(job: str) -> str:
|
||||||
|
return f"dev.cortex.{job}"
|
||||||
|
|
||||||
|
|
||||||
|
def _write_launchd_plist(job: str, interval_min: int) -> Path:
|
||||||
|
"""Write a launchd plist for a job."""
|
||||||
|
job_def = JOBS[job]
|
||||||
|
label = _launchd_label(job)
|
||||||
|
pdir = _launchd_dir()
|
||||||
|
pdir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
cortex_bin = _cortex_bin()
|
||||||
|
env_vars = _env_vars()
|
||||||
|
|
||||||
|
command = job_def["command"]
|
||||||
|
if "{interval}" in command:
|
||||||
|
hours = max(1, interval_min // 60)
|
||||||
|
command = command.replace("{interval}", f"{hours}h")
|
||||||
|
|
||||||
|
log_dir = cortex_home() / "logs"
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
env_xml = ""
|
||||||
|
all_env = {**env_vars, "PATH": os.environ.get("PATH", "/usr/local/bin:/usr/bin:/bin")}
|
||||||
|
if all_env:
|
||||||
|
env_xml = " <key>EnvironmentVariables</key>\n <dict>\n"
|
||||||
|
for k, v in all_env.items():
|
||||||
|
env_xml += f" <key>{k}</key>\n <string>{v}</string>\n"
|
||||||
|
env_xml += " </dict>"
|
||||||
|
|
||||||
|
plist_path = pdir / f"{label}.plist"
|
||||||
|
plist_path.write_text(textwrap.dedent(f"""\
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>{label}</string>
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/bin/bash</string>
|
||||||
|
<string>-c</string>
|
||||||
|
<string>{command}</string>
|
||||||
|
</array>
|
||||||
|
<key>StartInterval</key>
|
||||||
|
<integer>{interval_min * 60}</integer>
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>{log_dir / f'{job}.log'}</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>{log_dir / f'{job}.err'}</string>
|
||||||
|
{env_xml}
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
"""))
|
||||||
|
return plist_path
|
||||||
|
|
||||||
|
|
||||||
|
def _enable_launchd(job: str, interval_min: int) -> bool:
|
||||||
|
plist_path = _write_launchd_plist(job, interval_min)
|
||||||
|
label = _launchd_label(job)
|
||||||
|
try:
|
||||||
|
# Unload first if already loaded
|
||||||
|
subprocess.run(["launchctl", "unload", str(plist_path)],
|
||||||
|
capture_output=True)
|
||||||
|
subprocess.run(["launchctl", "load", str(plist_path)],
|
||||||
|
check=True, capture_output=True)
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error loading launchd job: {e.stderr.decode()}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _disable_launchd(job: str) -> bool:
|
||||||
|
label = _launchd_label(job)
|
||||||
|
plist_path = _launchd_dir() / f"{label}.plist"
|
||||||
|
try:
|
||||||
|
subprocess.run(["launchctl", "unload", str(plist_path)],
|
||||||
|
check=True, capture_output=True)
|
||||||
|
if plist_path.exists():
|
||||||
|
plist_path.unlink()
|
||||||
|
return True
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"Error unloading launchd job: {e.stderr.decode()}", file=sys.stderr)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _status_launchd() -> list[dict]:
|
||||||
|
results = []
|
||||||
|
for job in JOBS:
|
||||||
|
label = _launchd_label(job)
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["launchctl", "list", label],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
active = r.returncode == 0
|
||||||
|
except FileNotFoundError:
|
||||||
|
active = False
|
||||||
|
|
||||||
|
results.append({
|
||||||
|
"job": job,
|
||||||
|
"description": JOBS[job]["description"],
|
||||||
|
"active": active,
|
||||||
|
"next_run": "",
|
||||||
|
})
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def _logs_launchd(job: str, lines: int = 50) -> str:
|
||||||
|
log_path = cortex_home() / "logs" / f"{job}.log"
|
||||||
|
if not log_path.exists():
|
||||||
|
return f"No log file at {log_path}"
|
||||||
|
all_lines = log_path.read_text().splitlines()
|
||||||
|
return "\n".join(all_lines[-lines:])
|
||||||
|
|
||||||
|
|
||||||
|
# --- Unified interface ---
|
||||||
|
|
||||||
|
def enable_job(job: str, interval_min: Optional[int] = None) -> bool:
|
||||||
|
if job not in JOBS:
|
||||||
|
print(f"Unknown job: {job}. Available: {', '.join(JOBS.keys())}")
|
||||||
|
return False
|
||||||
|
interval = interval_min or JOBS[job]["default_interval_min"]
|
||||||
|
if _is_linux():
|
||||||
|
return _enable_systemd(job, interval)
|
||||||
|
elif _is_macos():
|
||||||
|
return _enable_launchd(job, interval)
|
||||||
|
else:
|
||||||
|
print(f"Unsupported platform: {platform.system()}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def disable_job(job: str) -> bool:
|
||||||
|
if job not in JOBS:
|
||||||
|
print(f"Unknown job: {job}. Available: {', '.join(JOBS.keys())}")
|
||||||
|
return False
|
||||||
|
if _is_linux():
|
||||||
|
return _disable_systemd(job)
|
||||||
|
elif _is_macos():
|
||||||
|
return _disable_launchd(job)
|
||||||
|
else:
|
||||||
|
print(f"Unsupported platform: {platform.system()}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def status() -> list[dict]:
|
||||||
|
if _is_linux():
|
||||||
|
return _status_systemd()
|
||||||
|
elif _is_macos():
|
||||||
|
return _status_launchd()
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def logs(job: str, lines: int = 50) -> str:
|
||||||
|
if _is_linux():
|
||||||
|
return _logs_systemd(job, lines)
|
||||||
|
elif _is_macos():
|
||||||
|
return _logs_launchd(job, lines)
|
||||||
|
return "Unsupported platform"
|
||||||
|
|
||||||
|
|
||||||
|
# --- CLI ---
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Cortex Scheduler")
|
||||||
|
sub = parser.add_subparsers(dest="action")
|
||||||
|
|
||||||
|
sub.add_parser("list", help="List available jobs")
|
||||||
|
sub.add_parser("status", help="Show active job status")
|
||||||
|
|
||||||
|
enable_p = sub.add_parser("enable", help="Enable a scheduled job")
|
||||||
|
enable_p.add_argument("job", choices=JOBS.keys())
|
||||||
|
enable_p.add_argument("--interval", type=int, help="Interval in minutes")
|
||||||
|
|
||||||
|
disable_p = sub.add_parser("disable", help="Disable a scheduled job")
|
||||||
|
disable_p.add_argument("job", choices=JOBS.keys())
|
||||||
|
|
||||||
|
logs_p = sub.add_parser("logs", help="Show job logs")
|
||||||
|
logs_p.add_argument("job", choices=JOBS.keys())
|
||||||
|
logs_p.add_argument("--lines", type=int, default=50)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.action == "list":
|
||||||
|
print(f"{'Job':<12} {'Interval':<12} {'Description'}")
|
||||||
|
print("-" * 60)
|
||||||
|
for name, jdef in JOBS.items():
|
||||||
|
interval = f"{jdef['default_interval_min']}min"
|
||||||
|
print(f"{name:<12} {interval:<12} {jdef['description']}")
|
||||||
|
|
||||||
|
elif args.action == "status":
|
||||||
|
results = status()
|
||||||
|
if not results:
|
||||||
|
print(f"No scheduler support on {platform.system()}")
|
||||||
|
return
|
||||||
|
print(f"{'Job':<12} {'Status':<10} {'Next Run'}")
|
||||||
|
print("-" * 60)
|
||||||
|
for r in results:
|
||||||
|
st = "✅ active" if r["active"] else "⬚ inactive"
|
||||||
|
print(f"{r['job']:<12} {st:<10} {r['next_run']}")
|
||||||
|
|
||||||
|
elif args.action == "enable":
|
||||||
|
ok = enable_job(args.job, args.interval)
|
||||||
|
if ok:
|
||||||
|
interval = args.interval or JOBS[args.job]["default_interval_min"]
|
||||||
|
print(f"✅ {args.job} enabled (every {interval}min)")
|
||||||
|
else:
|
||||||
|
print(f"❌ Failed to enable {args.job}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.action == "disable":
|
||||||
|
ok = disable_job(args.job)
|
||||||
|
if ok:
|
||||||
|
print(f"⬚ {args.job} disabled")
|
||||||
|
else:
|
||||||
|
print(f"❌ Failed to disable {args.job}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
elif args.action == "logs":
|
||||||
|
print(logs(args.job, args.lines))
|
||||||
|
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Loading…
Reference in a new issue