runspec-scheduler 0.1.0__tar.gz

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.
@@ -0,0 +1,61 @@
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *.pyo
5
+ *.pyd
6
+ .Python
7
+ *.egg
8
+ *.egg-info/
9
+ dist/
10
+ build/
11
+ .eggs/
12
+ .venv/
13
+ venv/
14
+ env/
15
+ .env
16
+ pip-wheel-metadata/
17
+ .pytest_cache/
18
+ .mypy_cache/
19
+ .ruff_cache/
20
+ htmlcov/
21
+ .coverage
22
+ coverage.xml
23
+ *.cover
24
+
25
+ # Node
26
+ node_modules/
27
+ dist/
28
+ *.js.map
29
+ .npm
30
+
31
+ # Go
32
+ *.exe
33
+ *.test
34
+ *.out
35
+ vendor/
36
+
37
+ # IDE
38
+ .idea/
39
+ .vscode/
40
+ *.iml
41
+ *.iws
42
+ *.ipr
43
+ .DS_Store
44
+ Thumbs.db
45
+
46
+ # Docs
47
+ site/
48
+
49
+ # Misc
50
+ *.log
51
+ *.tmp
52
+
53
+ # External reference repos (cloned locally, not committed)
54
+ chainlit-docs/
55
+ .chainlit/
56
+
57
+ # Claude Code local config (machine-specific)
58
+ .claude/launch.json
59
+
60
+ # Stray committed test venv (removed from tracking)
61
+ .venv-test/
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to `runspec-scheduler` are documented here.
4
+
5
+ ## [0.1.0]
6
+
7
+ Initial release.
8
+
9
+ - A long-running per-venv scheduler daemon (`systemctl --user`) that runs runnables
10
+ installed in its own venv on a cron/interval cadence — independent of any desktop
11
+ console.
12
+ - Itself a runspec runnable (`discoverable = false`) with verbs: `run` (daemon),
13
+ `add`, `remove`, `list`, `run-now`, `install-service`.
14
+ - Venv-scoped config at `{sys.prefix}/runspec_scheduler.toml` (override with
15
+ `--config` / `$RUNSPEC_SCHEDULER_CONFIG`); atomic writes; mtime hot-reload.
16
+ - Timing (`cron` / `every`) mirrors runspec-console's schedule semantics exactly,
17
+ pinned by a parity test.
18
+ - Rotating audit log at `{sys.prefix}/logs/scheduler.log`.
19
+ - Credential support for `run_as` runnables via `RUNSPEC_ENV_FILE`: a 600 env file
20
+ owned by the `run_as` account (written with the secret on stdin, never argv); the
21
+ daemon points `RUNSPEC_ENV_FILE` at it and escalates to `run_as` when firing.
@@ -0,0 +1,77 @@
1
+ Metadata-Version: 2.4
2
+ Name: runspec-scheduler
3
+ Version: 0.1.0
4
+ Summary: Cron-style scheduler service for runspec runnables installed in one venv
5
+ License-Expression: MIT
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: apscheduler<4,>=3.10
8
+ Requires-Dist: runspec>=0.48.0
9
+ Requires-Dist: tomli>=2.0; python_version < '3.11'
10
+ Provides-Extra: dev
11
+ Requires-Dist: mypy; extra == 'dev'
12
+ Requires-Dist: pytest>=8.0; extra == 'dev'
13
+ Requires-Dist: ruff==0.15.20; extra == 'dev'
14
+ Description-Content-Type: text/markdown
15
+
16
+ # runspec-scheduler
17
+
18
+ **Cron, but limited to runspec runnables installed in one venv.**
19
+
20
+ A long-running per-venv scheduler daemon (`systemctl --user`) that runs runnables
21
+ installed in *its own venv* on a cron/interval cadence — independent of any desktop
22
+ console. It is itself a runspec runnable, so `runspec-console` can detect it and
23
+ manage its schedules over SSH, but `discoverable = false` keeps it out of the Forms
24
+ tab / `runspec serve` MCP surface.
25
+
26
+ ## Why
27
+
28
+ A console-driven schedule only fires while the desktop console is open, and it's
29
+ per-operator. `runspec-scheduler` gives you **shared, always-on** schedules that live
30
+ on the host and run whether or not a console is connected.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ # Into the same venv whose runnables you want to schedule:
36
+ pip install runspec-scheduler
37
+
38
+ # Install + enable the systemd --user service for this venv:
39
+ runspec-scheduler install-service
40
+ loginctl enable-linger "$USER" # survive logout
41
+ ```
42
+
43
+ `install-service` writes `~/.config/systemd/user/runspec-scheduler-<venv>.service`
44
+ (one daemon per venv) and runs `systemctl --user enable --now`. To do it by hand, see
45
+ `deploy/runspec-scheduler.service`.
46
+
47
+ ## Use
48
+
49
+ ```bash
50
+ runspec-scheduler add --id nightly --runnable backup --cron '0 2 * * *'
51
+ runspec-scheduler add --id poll --runnable healthcheck --every 15m --args '{"level":"warn"}'
52
+ runspec-scheduler list
53
+ runspec-scheduler run-now --id poll
54
+ runspec-scheduler remove --id poll
55
+ ```
56
+
57
+ - Exactly one of `--cron` (5-field cron) or `--every` (`15m` / `1h` / `1d`).
58
+ - `--args` is a JSON object passed to the target runnable.
59
+ - Adding/removing a schedule hot-reloads the running daemon (mtime poll, ≤ 5s).
60
+
61
+ ## Config
62
+
63
+ Schedules live in `{venv}/runspec_scheduler.toml` (override with `--config` or
64
+ `$RUNSPEC_SCHEDULER_CONFIG`). The daemon keeps a rotating audit log at
65
+ `{venv}/logs/scheduler.log`; live logs via `journalctl --user -u runspec-scheduler-<venv> -f`.
66
+
67
+ ## Credentials (`run_as` runnables)
68
+
69
+ `runspec-console` can attach service-account credentials to a remote schedule. For a
70
+ runnable that declares `run_as`, the selected credentials are written to a **600 env
71
+ file owned by the `run_as` account** in that account's home; the daemon points
72
+ `RUNSPEC_ENV_FILE` at it and escalates to `run_as` when firing. Credentials are never
73
+ placed on a command line. This requires the daemon/SSH user to have passwordless
74
+ `sudo -u <run_as>` (the same precondition as running that `run_as` runnable at all).
75
+
76
+ Runnables **without** `run_as` run as the daemon's own user with the daemon's
77
+ environment (systemd `EnvironmentFile`) — no console credential injection.
@@ -0,0 +1,62 @@
1
+ # runspec-scheduler
2
+
3
+ **Cron, but limited to runspec runnables installed in one venv.**
4
+
5
+ A long-running per-venv scheduler daemon (`systemctl --user`) that runs runnables
6
+ installed in *its own venv* on a cron/interval cadence — independent of any desktop
7
+ console. It is itself a runspec runnable, so `runspec-console` can detect it and
8
+ manage its schedules over SSH, but `discoverable = false` keeps it out of the Forms
9
+ tab / `runspec serve` MCP surface.
10
+
11
+ ## Why
12
+
13
+ A console-driven schedule only fires while the desktop console is open, and it's
14
+ per-operator. `runspec-scheduler` gives you **shared, always-on** schedules that live
15
+ on the host and run whether or not a console is connected.
16
+
17
+ ## Install
18
+
19
+ ```bash
20
+ # Into the same venv whose runnables you want to schedule:
21
+ pip install runspec-scheduler
22
+
23
+ # Install + enable the systemd --user service for this venv:
24
+ runspec-scheduler install-service
25
+ loginctl enable-linger "$USER" # survive logout
26
+ ```
27
+
28
+ `install-service` writes `~/.config/systemd/user/runspec-scheduler-<venv>.service`
29
+ (one daemon per venv) and runs `systemctl --user enable --now`. To do it by hand, see
30
+ `deploy/runspec-scheduler.service`.
31
+
32
+ ## Use
33
+
34
+ ```bash
35
+ runspec-scheduler add --id nightly --runnable backup --cron '0 2 * * *'
36
+ runspec-scheduler add --id poll --runnable healthcheck --every 15m --args '{"level":"warn"}'
37
+ runspec-scheduler list
38
+ runspec-scheduler run-now --id poll
39
+ runspec-scheduler remove --id poll
40
+ ```
41
+
42
+ - Exactly one of `--cron` (5-field cron) or `--every` (`15m` / `1h` / `1d`).
43
+ - `--args` is a JSON object passed to the target runnable.
44
+ - Adding/removing a schedule hot-reloads the running daemon (mtime poll, ≤ 5s).
45
+
46
+ ## Config
47
+
48
+ Schedules live in `{venv}/runspec_scheduler.toml` (override with `--config` or
49
+ `$RUNSPEC_SCHEDULER_CONFIG`). The daemon keeps a rotating audit log at
50
+ `{venv}/logs/scheduler.log`; live logs via `journalctl --user -u runspec-scheduler-<venv> -f`.
51
+
52
+ ## Credentials (`run_as` runnables)
53
+
54
+ `runspec-console` can attach service-account credentials to a remote schedule. For a
55
+ runnable that declares `run_as`, the selected credentials are written to a **600 env
56
+ file owned by the `run_as` account** in that account's home; the daemon points
57
+ `RUNSPEC_ENV_FILE` at it and escalates to `run_as` when firing. Credentials are never
58
+ placed on a command line. This requires the daemon/SSH user to have passwordless
59
+ `sudo -u <run_as>` (the same precondition as running that `run_as` runnable at all).
60
+
61
+ Runnables **without** `run_as` run as the daemon's own user with the daemon's
62
+ environment (systemd `EnvironmentFile`) — no console credential injection.
@@ -0,0 +1,29 @@
1
+ # systemd --user unit for the runspec scheduler daemon.
2
+ #
3
+ # This is a template. `runspec-scheduler install-service` writes a per-venv copy
4
+ # named runspec-scheduler-<venv>.service with ExecStart pointing at this venv's
5
+ # binary — prefer that over copying this by hand. To install manually:
6
+ #
7
+ # cp deploy/runspec-scheduler.service \
8
+ # ~/.config/systemd/user/runspec-scheduler-<venv>.service
9
+ # # edit ExecStart to your venv's bin/runspec-scheduler
10
+ # systemctl --user daemon-reload
11
+ # systemctl --user enable --now runspec-scheduler-<venv>
12
+ # loginctl enable-linger "$USER" # keep it running after logout
13
+
14
+ [Unit]
15
+ Description=runspec scheduler
16
+ After=network-online.target
17
+ Wants=network-online.target
18
+
19
+ [Service]
20
+ Type=simple
21
+ WorkingDirectory=%h
22
+ # Point this at your venv's binary, e.g. %h/venvs/ops/bin/runspec-scheduler
23
+ ExecStart=%h/.local/bin/runspec-scheduler run
24
+ EnvironmentFile=-%h/.config/runspec-scheduler/runspec-scheduler.env
25
+ Restart=on-failure
26
+ RestartSec=5
27
+
28
+ [Install]
29
+ WantedBy=default.target
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "runspec-scheduler"
7
+ version = "0.1.0"
8
+ requires-python = ">=3.10"
9
+ description = "Cron-style scheduler service for runspec runnables installed in one venv"
10
+ readme = "README.md"
11
+ license = "MIT"
12
+ dependencies = [
13
+ # 0.48.0 for the `discoverable` boolean + serve leaf globals used by the console.
14
+ "runspec>=0.48.0",
15
+ # APScheduler drives the cron/interval timing. This package's whole reason to
16
+ # exist is scheduling, so it's a hard dependency (unlike runspec-console, which
17
+ # keeps it optional under the [schedule] extra).
18
+ "apscheduler>=3.10,<4",
19
+ "tomli>=2.0; python_version < '3.11'",
20
+ ]
21
+
22
+ [project.scripts]
23
+ # Entry point name MUST match the runnable name in runspec.toml.
24
+ runspec-scheduler = "runspec_scheduler.cli:main"
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "ruff==0.15.20",
29
+ "mypy",
30
+ "pytest>=8.0",
31
+ ]
32
+
33
+ [tool.pytest.ini_options]
34
+ testpaths = ["tests"]
35
+
36
+ [tool.mypy]
37
+ python_version = "3.10"
38
+
39
+ [[tool.mypy.overrides]]
40
+ # APScheduler ships no type stubs; tomli is the 3.10-only TOML reader.
41
+ module = ["apscheduler.*", "tomli"]
42
+ ignore_missing_imports = true
43
+
44
+ [tool.ruff]
45
+ line-length = 200
46
+ target-version = "py310"
47
+
48
+ [tool.ruff.lint]
49
+ select = ["E", "F", "I", "UP", "B", "SIM"]
@@ -0,0 +1,36 @@
1
+ """runspec-scheduler — a cron-style scheduler service for runspec runnables.
2
+
3
+ A long-running per-venv daemon (``systemctl --user``) that runs runnables
4
+ installed in *its own venv* on a cron/interval cadence, independent of any
5
+ desktop console. It is itself a runspec runnable with management verbs
6
+ (``run`` / ``add`` / ``remove`` / ``list`` / ``run-now`` / ``install-service``).
7
+
8
+ Public API (for corporate wrappers / tests): the pure config + timing helpers,
9
+ which have no runspec-console dependency.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from .config import (
15
+ ScheduleEntry,
16
+ config_path,
17
+ load_schedules,
18
+ read_config,
19
+ upsert_schedule,
20
+ write_schedules,
21
+ )
22
+ from .timing import When, next_fire_time, parse_when
23
+
24
+ __all__ = [
25
+ "ScheduleEntry",
26
+ "When",
27
+ "config_path",
28
+ "load_schedules",
29
+ "next_fire_time",
30
+ "parse_when",
31
+ "read_config",
32
+ "upsert_schedule",
33
+ "write_schedules",
34
+ ]
35
+
36
+ __version__ = "0.1.0"
@@ -0,0 +1,186 @@
1
+ """cli.py — the `runspec-scheduler` entry point.
2
+
3
+ One binary, several verbs. ``rs.parse("runspec-scheduler")`` resolves the
4
+ subcommand + args; we dispatch on ``spec.runspec_command``.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import runspec as rs
15
+
16
+ from .config import ScheduleEntry, config_path, load_schedules, remove_schedule, upsert_schedule
17
+ from .daemon import configure_logging, run_daemon
18
+ from .discovery import resolve_target
19
+ from .envfile import capture_env, remove_env_file, write_env_file
20
+ from .runner import run_runnable, venv_name
21
+ from .timing import next_fire_time, parse_when
22
+
23
+
24
+ def _opt(arg: Any) -> Any:
25
+ """Underlying value of a runspec ``Arg``; '' / None → None."""
26
+ value = getattr(arg, "value", arg)
27
+ if value is None:
28
+ return None
29
+ if isinstance(value, str) and not value.strip():
30
+ return None
31
+ return value
32
+
33
+
34
+ def _emit(payload: dict[str, Any]) -> None:
35
+ print(json.dumps(payload))
36
+
37
+
38
+ def _parse_json_arg(raw: Any, what: str) -> Any:
39
+ if raw is None:
40
+ return None
41
+ try:
42
+ return json.loads(str(raw))
43
+ except json.JSONDecodeError as exc:
44
+ _emit({"error": f"invalid JSON for {what}: {exc}"})
45
+ sys.exit(2)
46
+
47
+
48
+ def _cfg_path(spec: rs.RunSpec) -> Path:
49
+ return config_path(_opt(spec.config))
50
+
51
+
52
+ def _cmd_run(spec: rs.RunSpec) -> None:
53
+ configure_logging()
54
+ run_daemon(_cfg_path(spec), reload_interval=int(spec.reload_interval.value))
55
+
56
+
57
+ def _cmd_add(spec: rs.RunSpec) -> None:
58
+ path = _cfg_path(spec)
59
+ sid = str(spec.id.value)
60
+ runnable = str(spec.runnable.value)
61
+ when = parse_when(
62
+ {
63
+ "cron": _opt(spec.cron) or "",
64
+ "every": _opt(spec.every) or "",
65
+ "timezone": _opt(spec.timezone) or "",
66
+ }
67
+ )
68
+ args = _parse_json_arg(_opt(spec.args), "--args") or {}
69
+ if not isinstance(args, dict):
70
+ _emit({"error": "--args must be a JSON object"})
71
+ sys.exit(2)
72
+
73
+ target = resolve_target(runnable)
74
+ if target is None:
75
+ _emit({"error": f"runnable '{runnable}' is not installed in this venv ({venv_name()})"})
76
+ sys.exit(2)
77
+
78
+ cred_vars = _parse_json_arg(_opt(spec.cred_vars), "--cred-vars")
79
+ env_file = ""
80
+ if cred_vars:
81
+ if not isinstance(cred_vars, list):
82
+ _emit({"error": "--cred-vars must be a JSON array of variable names"})
83
+ sys.exit(2)
84
+ if not target.run_as:
85
+ _emit({"error": f"credentials require a run_as runnable, but '{runnable}' declares none"})
86
+ sys.exit(2)
87
+ env = capture_env([str(v) for v in cred_vars])
88
+ try:
89
+ env_file = write_env_file(sid, env, target.run_as, target.become_method, target.become_flags)
90
+ except (RuntimeError, ValueError) as exc:
91
+ _emit({"error": f"could not write credential env file: {exc}"})
92
+ sys.exit(1)
93
+
94
+ entry = ScheduleEntry(
95
+ id=sid,
96
+ when=when,
97
+ runnable=runnable,
98
+ args=args,
99
+ enabled=bool(spec.enabled.value),
100
+ run_as=target.run_as,
101
+ become_method=target.become_method,
102
+ env_file=env_file,
103
+ )
104
+ upsert_schedule(path, entry)
105
+ _emit({"ok": True, "id": sid, "runnable": runnable, "run_as": target.run_as, "env_file": bool(env_file)})
106
+
107
+
108
+ def _cmd_remove(spec: rs.RunSpec) -> None:
109
+ path = _cfg_path(spec)
110
+ sid = str(spec.id.value)
111
+ existing = next((e for e in load_schedules(path) if e.id == sid), None)
112
+ removed = remove_schedule(path, sid)
113
+ if existing and existing.env_file:
114
+ remove_env_file(existing.env_file, existing.run_as, existing.become_method)
115
+ _emit({"ok": removed, "id": sid})
116
+
117
+
118
+ def _cmd_list(spec: rs.RunSpec) -> None:
119
+ path = _cfg_path(spec)
120
+ out = []
121
+ for entry in load_schedules(path):
122
+ record = entry.to_dict()
123
+ nxt = next_fire_time(entry.when) if entry.enabled else None
124
+ record["next_run"] = nxt.isoformat() if nxt else None
125
+ out.append(record)
126
+ _emit({"schedules": out, "venv": venv_name(), "config": str(path)})
127
+
128
+
129
+ def _cmd_run_now(spec: rs.RunSpec) -> None:
130
+ path = _cfg_path(spec)
131
+ sid = str(spec.id.value)
132
+ entry = next((e for e in load_schedules(path) if e.id == sid), None)
133
+ if entry is None:
134
+ _emit({"error": f"no schedule with id '{sid}'"})
135
+ sys.exit(2)
136
+ target = resolve_target(entry.runnable)
137
+ if target is None:
138
+ _emit({"error": f"runnable '{entry.runnable}' not found in this venv"})
139
+ sys.exit(2)
140
+ result = run_runnable(
141
+ target.binary,
142
+ [],
143
+ dict(entry.args),
144
+ source_toml=target.source,
145
+ run_as=entry.run_as or target.run_as,
146
+ become_method=entry.become_method or target.become_method,
147
+ become_flags=target.become_flags,
148
+ env_file=entry.env_file,
149
+ )
150
+ _emit(
151
+ {
152
+ "id": sid,
153
+ "runnable": entry.runnable,
154
+ "exit_code": result.returncode,
155
+ "stdout": (result.stdout or "").strip(),
156
+ "stderr": (result.stderr or "").strip(),
157
+ }
158
+ )
159
+ sys.exit(0 if result.returncode == 0 else 1)
160
+
161
+
162
+ def _cmd_install_service(spec: rs.RunSpec) -> None:
163
+ from .service import install_service
164
+
165
+ result = install_service(enable=bool(spec.enable.value))
166
+ _emit(result)
167
+
168
+
169
+ def main() -> None:
170
+ spec = rs.parse("runspec-scheduler")
171
+ command = spec.runspec_command
172
+ if command == "run":
173
+ _cmd_run(spec)
174
+ elif command == "add":
175
+ _cmd_add(spec)
176
+ elif command == "remove":
177
+ _cmd_remove(spec)
178
+ elif command == "list":
179
+ _cmd_list(spec)
180
+ elif command == "run-now":
181
+ _cmd_run_now(spec)
182
+ elif command == "install-service":
183
+ _cmd_install_service(spec)
184
+ else: # pragma: no cover - require-command makes this unreachable
185
+ _emit({"error": "no command — try: run, add, remove, list, run-now, install-service"})
186
+ sys.exit(2)