jobstate 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.
Files changed (35) hide show
  1. jobstate-0.1.0/.gitignore +12 -0
  2. jobstate-0.1.0/LICENSE +21 -0
  3. jobstate-0.1.0/PKG-INFO +59 -0
  4. jobstate-0.1.0/docs/pypi.md +38 -0
  5. jobstate-0.1.0/pyproject.toml +45 -0
  6. jobstate-0.1.0/src/jobstate/__init__.py +1 -0
  7. jobstate-0.1.0/src/jobstate/api.py +1 -0
  8. jobstate-0.1.0/src/jobstate/cli.py +158 -0
  9. jobstate-0.1.0/src/jobstate/core/__init__.py +1 -0
  10. jobstate-0.1.0/src/jobstate/core/capabilities.py +86 -0
  11. jobstate-0.1.0/src/jobstate/core/config.py +142 -0
  12. jobstate-0.1.0/src/jobstate/core/delivery.py +86 -0
  13. jobstate-0.1.0/src/jobstate/core/events.py +157 -0
  14. jobstate-0.1.0/src/jobstate/core/failover.py +94 -0
  15. jobstate-0.1.0/src/jobstate/core/llview.py +229 -0
  16. jobstate-0.1.0/src/jobstate/core/outcome.py +76 -0
  17. jobstate-0.1.0/src/jobstate/core/policy.py +327 -0
  18. jobstate-0.1.0/src/jobstate/core/profile.py +319 -0
  19. jobstate-0.1.0/src/jobstate/core/runner.py +208 -0
  20. jobstate-0.1.0/src/jobstate/core/schema.py +122 -0
  21. jobstate-0.1.0/src/jobstate/core/service.py +200 -0
  22. jobstate-0.1.0/src/jobstate/core/slurm.py +366 -0
  23. jobstate-0.1.0/src/jobstate/core/state.py +237 -0
  24. jobstate-0.1.0/src/jobstate/core/submit.py +383 -0
  25. jobstate-0.1.0/src/jobstate/core/watcher.py +192 -0
  26. jobstate-0.1.0/src/jobstate/mcp/__init__.py +1 -0
  27. jobstate-0.1.0/src/jobstate/mcp/notify.py +102 -0
  28. jobstate-0.1.0/src/jobstate/mcp/resources.py +59 -0
  29. jobstate-0.1.0/src/jobstate/mcp/server.py +164 -0
  30. jobstate-0.1.0/src/jobstate/mcp/tools.py +574 -0
  31. jobstate-0.1.0/src/jobstate/profiles/jupiter.yaml +145 -0
  32. jobstate-0.1.0/src/jobstate/profiles/jureca.yaml +188 -0
  33. jobstate-0.1.0/src/jobstate/profiles/jusuf.yaml +157 -0
  34. jobstate-0.1.0/src/jobstate/profiles/juwels.yaml +179 -0
  35. jobstate-0.1.0/src/jobstate/profiles/juwelsbooster.yaml +139 -0
@@ -0,0 +1,12 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ build/
5
+ dist/
6
+ .pytest_cache/
7
+ .ruff_cache/
8
+ .venv/
9
+
10
+ # Local planning notes
11
+ docs/design.md
12
+ tasks/PLAN.md
jobstate-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Forschungszentrum Juelich GmbH, Juelich Supercomputing Centre
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,59 @@
1
+ Metadata-Version: 2.5
2
+ Name: jobstate
3
+ Version: 0.1.0
4
+ Summary: Submit, watch and report on Slurm jobs from AI agents: an MCP server and CLI for JSC's JUPITER, JURECA, JUWELS and JUSUF
5
+ Project-URL: Repository, https://gitlab.jsc.fz-juelich.de/sdlaml/agents-on-hpc/jobstate-mcp
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: ai-agents,hpc,jupiter,jureca,jusuf,juwels,mcp,slurm
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: Operating System :: POSIX
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Topic :: System :: Distributed Computing
17
+ Requires-Python: >=3.12
18
+ Requires-Dist: mcp<3,>=2.3
19
+ Requires-Dist: pyyaml>=6
20
+ Description-Content-Type: text/markdown
21
+
22
+ # jobstate
23
+
24
+ Tools for AI agents to submit, watch and report on Slurm jobs on the JSC supercomputers JUPITER, JURECA, JUWELS and JUSUF: an
25
+ [MCP](https://modelcontextprotocol.io) server (`jobstate-mcp`) and a command-line tool (`jobstate`).
26
+
27
+ Instead of polling `squeue` turn after turn, an agent waits for its jobs with one tool call, or is notified when
28
+ they end; a long job costs one model turn at the end. Submissions go through a dry run and a separate confirmation,
29
+ under limits and budget guards the server enforces itself.
30
+
31
+ ## Install
32
+
33
+ ```sh
34
+ uv tool install jobstate
35
+ ```
36
+
37
+ On a JSC login node, check the setup and add the server to Claude Code:
38
+
39
+ ```sh
40
+ jobstate check
41
+ claude mcp add -s user jobstate -- jobstate-mcp
42
+ ```
43
+
44
+ From your own machine the server reaches the cluster over ssh:
45
+
46
+ ```sh
47
+ jobstate check --cluster jupiter --host <your ssh config name>
48
+ claude mcp add -s user jobstate-jupiter -- jobstate-mcp --cluster jupiter --host <your ssh config name>
49
+ ```
50
+
51
+ ## Tools
52
+
53
+ `job_status`, `list_jobs`, `job_summary`, `tail_log`, `job_report` (GPU efficiency from LLview), `watch_job`,
54
+ `wait_for_jobs`, `cluster_info`, and, unless configured read-only, `submit_job` (a dry run), `confirm_submit` and
55
+ `cancel_job`. End-of-job events carry exit code, run time against the limit, busy cores, peak memory, I/O and
56
+ node-hours from Slurm accounting.
57
+
58
+ Configuration (allowed accounts, tighter limits, read-only mode, ntfy or webhook notifications) goes into
59
+ `~/.config/jobstate-mcp/config.yaml`. Python 3.12 or later.
@@ -0,0 +1,38 @@
1
+ # jobstate
2
+
3
+ Tools for AI agents to submit, watch and report on Slurm jobs on the JSC supercomputers JUPITER, JURECA, JUWELS and JUSUF: an
4
+ [MCP](https://modelcontextprotocol.io) server (`jobstate-mcp`) and a command-line tool (`jobstate`).
5
+
6
+ Instead of polling `squeue` turn after turn, an agent waits for its jobs with one tool call, or is notified when
7
+ they end; a long job costs one model turn at the end. Submissions go through a dry run and a separate confirmation,
8
+ under limits and budget guards the server enforces itself.
9
+
10
+ ## Install
11
+
12
+ ```sh
13
+ uv tool install jobstate
14
+ ```
15
+
16
+ On a JSC login node, check the setup and add the server to Claude Code:
17
+
18
+ ```sh
19
+ jobstate check
20
+ claude mcp add -s user jobstate -- jobstate-mcp
21
+ ```
22
+
23
+ From your own machine the server reaches the cluster over ssh:
24
+
25
+ ```sh
26
+ jobstate check --cluster jupiter --host <your ssh config name>
27
+ claude mcp add -s user jobstate-jupiter -- jobstate-mcp --cluster jupiter --host <your ssh config name>
28
+ ```
29
+
30
+ ## Tools
31
+
32
+ `job_status`, `list_jobs`, `job_summary`, `tail_log`, `job_report` (GPU efficiency from LLview), `watch_job`,
33
+ `wait_for_jobs`, `cluster_info`, and, unless configured read-only, `submit_job` (a dry run), `confirm_submit` and
34
+ `cancel_job`. End-of-job events carry exit code, run time against the limit, busy cores, peak memory, I/O and
35
+ node-hours from Slurm accounting.
36
+
37
+ Configuration (allowed accounts, tighter limits, read-only mode, ntfy or webhook notifications) goes into
38
+ `~/.config/jobstate-mcp/config.yaml`. Python 3.12 or later.
@@ -0,0 +1,45 @@
1
+ [project]
2
+ name = "jobstate"
3
+ version = "0.1.0"
4
+ description = "Submit, watch and report on Slurm jobs from AI agents: an MCP server and CLI for JSC's JUPITER, JURECA, JUWELS and JUSUF"
5
+ readme = "docs/pypi.md" # the PyPI page; README.md is the repository's proposal and guide
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.12"
9
+ dependencies = ["mcp>=2.3,<3", "pyyaml>=6"]
10
+ keywords = ["mcp", "slurm", "hpc", "ai-agents", "jupiter", "jureca", "juwels", "jusuf"]
11
+ classifiers = [
12
+ "Development Status :: 3 - Alpha",
13
+ "Environment :: Console",
14
+ "Intended Audience :: Science/Research",
15
+ "Operating System :: POSIX",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.12",
18
+ "Programming Language :: Python :: 3.13",
19
+ "Topic :: System :: Distributed Computing",
20
+ ]
21
+
22
+ [project.urls]
23
+ Repository = "https://gitlab.jsc.fz-juelich.de/sdlaml/agents-on-hpc/jobstate-mcp"
24
+
25
+ [project.scripts]
26
+ jobstate = "jobstate.cli:main"
27
+ jobstate-mcp = "jobstate.mcp.server:main"
28
+
29
+ [build-system]
30
+ requires = ["hatchling"]
31
+ build-backend = "hatchling.build"
32
+
33
+ [tool.hatch.build.targets.wheel]
34
+ packages = ["src/jobstate"]
35
+
36
+ [tool.hatch.build.targets.sdist]
37
+ # Public archive: code, licence and the PyPI description only. tests/fixtures hold recorded cluster output (user
38
+ # and project names, paths), README.md, tasks/ and docs/ internal notes; none of it belongs on PyPI.
39
+ include = ["src/jobstate", "docs/pypi.md", "LICENSE", "pyproject.toml"]
40
+ exclude = [".zenv"] # the local environment; its packages' LICENSE files would match license-files
41
+
42
+ [dependency-groups]
43
+ dev = [
44
+ "pytest>=9",
45
+ ]
@@ -0,0 +1 @@
1
+ """jobstate: submit, watch and report on HPC jobs. Core library shared by the CLI, MCP server and Python API."""
@@ -0,0 +1 @@
1
+ """Python API front end. Calls core functions only; never another front end."""
@@ -0,0 +1,158 @@
1
+ """Command-line front end (`jobstate`). Calls core functions only; never another front end.
2
+
3
+ jobstate status 123456 123457_2 one line per job
4
+ jobstate list [--state RUNNING] your queued and running jobs
5
+ jobstate tail 123456 [--stderr] the end of a job's log
6
+ jobstate report 123456 efficiency from LLview, with the data's age
7
+ jobstate info partitions, limits, filesystems and rules of the cluster
8
+ jobstate guards [--reset SCRIPT] what agents may submit, budget guard use; allow a blocked script again
9
+ jobstate check can jobstate work here: ssh, Slurm, sacct, LLview, config, state
10
+
11
+ `--cluster` defaults to $SYSTEMNAME on JSC machines. `--exec auto` runs Slurm locally where `squeue` exists and
12
+ over ssh otherwise; `--host` is the ssh destination (a ~/.ssh/config name works), default the login alias. Over
13
+ ssh, commands go to the cluster's login nodes in turn, skipping unreachable ones (`--no-failover`: only --host).
14
+ Exit status: 0 done, 1 the cluster or a job could not answer, 2 bad arguments.
15
+ """
16
+
17
+ import argparse
18
+ import asyncio
19
+ import json
20
+ import sys
21
+ from collections.abc import Sequence
22
+
23
+ from .core import capabilities
24
+ from .core import config as configs
25
+ from .core import profile as profiles
26
+ from .core import slurm
27
+ from .core.llview import Report
28
+ from .core.policy import Ledger, Policy
29
+ from .core.service import EXEC_MODES, JobNotFound, Service, Tail, default_cluster, explain, plain
30
+
31
+
32
+ class _Usage(Exception):
33
+ """Bad arguments found after parsing (exit status 2)."""
34
+
35
+
36
+ def _parser() -> argparse.ArgumentParser:
37
+ common = argparse.ArgumentParser(add_help=False)
38
+ common.add_argument("--cluster", choices=profiles.available(), default=default_cluster(),
39
+ help="cluster profile (default: $SYSTEMNAME)")
40
+ common.add_argument("--exec", dest="exec_mode", choices=EXEC_MODES, default="auto",
41
+ help="run Slurm commands here or over ssh (default: auto)")
42
+ common.add_argument("--host", help="ssh destination, e.g. a ~/.ssh/config name (default: the login alias)")
43
+ common.add_argument("--no-failover", dest="failover", action="store_false",
44
+ help="use only --host, not the cluster's other login nodes")
45
+ common.add_argument("--json", action="store_true", help="print JSON instead of text")
46
+
47
+ parser = argparse.ArgumentParser(prog="jobstate", description="Status, queue and logs of Slurm jobs.")
48
+ sub = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
49
+ p = sub.add_parser("status", parents=[common], help="state of jobs, one line each")
50
+ p.add_argument("ids", nargs="+", metavar="JOBID")
51
+ p = sub.add_parser("list", parents=[common], help="your queued and running jobs")
52
+ p.add_argument("--state", action="append", type=str.upper, choices=sorted(slurm.STATES), metavar="STATE",
53
+ help="only jobs in this state or with this flag (repeatable)")
54
+ p = sub.add_parser("tail", parents=[common], help="the end of a job's log")
55
+ p.add_argument("id", metavar="JOBID")
56
+ p.add_argument("--stderr", action="store_const", const="stderr", default="stdout", dest="stream",
57
+ help="the stderr log (the stdout log if the job writes both into one)")
58
+ p.add_argument("-n", "--lines", type=int, default=50, help=f"lines, at most {slurm.MAX_LOG_LINES} (default 50)")
59
+ p.add_argument("--bytes", type=int, default=16384, dest="max_bytes",
60
+ help=f"read at most this much, up to {slurm.MAX_LOG_BYTES} (default 16384)")
61
+ p.add_argument("--offset", type=int, help="continue from this byte offset (printed by an earlier call)")
62
+ p = sub.add_parser("report", parents=[common], help="a job's efficiency from LLview")
63
+ p.add_argument("id", metavar="JOBID")
64
+ sub.add_parser("info", parents=[common], help="partitions, limits, filesystems and rules of the cluster")
65
+ sub.add_parser("check", parents=[common], help="check ssh, Slurm, sacct, LLview, config and state, with fixes")
66
+ p = sub.add_parser("guards", parents=[common], help="what agents may submit, and the budget guards' use")
67
+ p.add_argument("--reset", metavar="SCRIPT", help="allow a script blocked after failing too often (the script's "
68
+ "path on the cluster, as shown)")
69
+ return parser
70
+
71
+
72
+ def _tail_note(t: Tail) -> str:
73
+ c = t.chunk
74
+ notes = [f"{t.job.id} {t.stream} {c.path}", f"bytes {c.start}-{c.next_offset} of {c.size}"]
75
+ if t.merged:
76
+ notes.append("stderr goes to this log")
77
+ if c.reset:
78
+ notes.append("the log was shorter than the offset; read from its end")
79
+ if c.truncated:
80
+ notes.append("more after this" if c.next_offset < c.size else "earlier output left out")
81
+ return f"== {'; '.join(notes)}; next: --offset {c.next_offset}"
82
+
83
+
84
+ async def _run(args, service: Service) -> int:
85
+ out = sys.stdout
86
+ if args.command == "check":
87
+ checks = await capabilities.check(service)
88
+ out.write(f"{service.profile.name}\n" + "".join(f" {c.line()}\n" for c in checks))
89
+ return 0 if all(c.ok is not False for c in checks) else 1
90
+ if args.command == "guards":
91
+ policy = Policy(service.profile, configs.load(service.profile), Ledger(service.profile.cluster))
92
+ if args.reset:
93
+ policy.ledger.reset(args.reset)
94
+ out.write(f"{args.reset}: failures before now no longer count\n")
95
+ return 0
96
+ out.write(f"{service.profile.name} ({configs.default_path()}"
97
+ f"{'' if configs.default_path().exists() else ', not found: defaults'})\n")
98
+ out.writelines(f" {line}\n" for line in policy.describe())
99
+ return 0
100
+ if args.command == "info":
101
+ out.write(json.dumps(plain(service.profile), indent=2) + "\n" if args.json
102
+ else profiles.context(service.profile))
103
+ return 0
104
+
105
+ unknown: list[str] = []
106
+ if args.command == "status":
107
+ result = await service.status(args.ids)
108
+ unknown = slurm.missing(args.ids, result.value or [])
109
+ elif args.command == "list":
110
+ result = await service.jobs(args.state)
111
+ elif args.command == "report":
112
+ result = await service.report(args.id)
113
+ else:
114
+ result = await service.tail(args.id, stream=args.stream, lines=args.lines, max_bytes=args.max_bytes,
115
+ offset=args.offset)
116
+ if result.value is None:
117
+ print(f"jobstate: {explain(result.outcome)}", file=sys.stderr)
118
+ return 1
119
+
120
+ value = result.value
121
+ if args.json:
122
+ out.write(json.dumps(plain(value), indent=2) + "\n")
123
+ elif isinstance(value, Report):
124
+ out.write(value.line() + "\n")
125
+ elif isinstance(value, Tail):
126
+ print(_tail_note(value), file=sys.stderr) # the log itself is job output: keep it apart from our notes
127
+ out.write(value.chunk.text)
128
+ else:
129
+ out.writelines(f"{job.line()}\n" for job in value)
130
+ if not value and args.command == "list":
131
+ print("no jobs in the queue", file=sys.stderr)
132
+
133
+ out.flush() # our messages on stderr come after the lines they follow
134
+ for job_id in unknown:
135
+ print(f"jobstate: no job {job_id}", file=sys.stderr)
136
+ return 1 if unknown else 0
137
+
138
+
139
+ def main(argv: Sequence[str] | None = None, *, service: Service | None = None) -> int:
140
+ """Entry point. Tests pass a `service` built on a fake runner instead of a real cluster connection."""
141
+ args = _parser().parse_args(argv)
142
+ try:
143
+ if service is None:
144
+ if args.cluster is None:
145
+ raise _Usage(f"--cluster is required here (one of: {', '.join(profiles.available())})")
146
+ service = Service.create(args.cluster, exec_mode=args.exec_mode, host=args.host,
147
+ failover=args.failover)
148
+ return asyncio.run(_run(args, service))
149
+ except (_Usage, ValueError) as e:
150
+ print(f"jobstate: {e}", file=sys.stderr)
151
+ return 2
152
+ except JobNotFound as e:
153
+ print(f"jobstate: {e}", file=sys.stderr)
154
+ return 1
155
+
156
+
157
+ if __name__ == "__main__":
158
+ sys.exit(main())
@@ -0,0 +1 @@
1
+ """Core library: everything the front ends share. No MCP, CLI or I/O-format concerns here."""
@@ -0,0 +1,86 @@
1
+ """Capability check per cluster: ssh, Slurm --json, sacct, the LLview CLI, the user config and the state directory.
2
+
3
+ `jobstate check` runs it and explains each problem; `jobstate-mcp` runs it in the background at startup and shows the
4
+ result in `cluster_info`. A failed check never stops the server (chosen 2026-10-17): a cluster in maintenance or a
5
+ broken LLview must not take the tools away; the tools then return their own clear errors.
6
+ """
7
+
8
+ from dataclasses import dataclass
9
+
10
+ from . import config as configs
11
+ from .outcome import Outcome, Status
12
+ from .service import Service, ssh_hint
13
+ from .slurm import ParseError, parse_sacct, parse_squeue, sacct_recent, squeue_mine
14
+ from .state import Store
15
+
16
+ TIMEOUT = 30.0
17
+
18
+ @dataclass(frozen=True)
19
+ class Check:
20
+ name: str
21
+ ok: bool | None # None: could not be checked (e.g. the cluster was unreachable)
22
+ detail: str
23
+ fix: str | None = None
24
+
25
+ def line(self) -> str:
26
+ mark = {True: "ok ", False: "FAIL", None: "? "}[self.ok]
27
+ return f"{mark} {self.name}: {self.detail}" + (f"\n fix: {self.fix}" if self.fix else "")
28
+
29
+
30
+ async def check(service: Service, *, store: Store | None = None) -> list[Check]:
31
+ """Every check for `service`'s cluster, in order; later cluster checks are skipped if ssh fails."""
32
+ p, results = service.profile, []
33
+
34
+ try:
35
+ read_only = configs.load(p).read_only
36
+ results.append(Check("config", True, f"{configs.default_path()} " + (
37
+ "loaded" if configs.default_path().exists() else "not found: defaults (writes within site limits)")
38
+ + (" (read-only)" if read_only else "")))
39
+ except configs.ConfigError as e:
40
+ results.append(Check("config", False, str(e), "fix the key named above; `jobstate guards` shows the result"))
41
+
42
+ try:
43
+ state = store or Store(p.cluster)
44
+ state.heartbeat()
45
+ state.end()
46
+ results.append(Check("state", True, f"{state.dir} is writable"))
47
+ except OSError as e:
48
+ results.append(Check("state", False, str(e), "jobstate keeps its state under $XDG_STATE_HOME or "
49
+ "~/.local/state; make it writable"))
50
+
51
+ first = await service.runner.run(["true"], timeout=TIMEOUT)
52
+ where = service.runner.host or "this machine"
53
+ if not first.ok:
54
+ hint = ssh_hint(first)
55
+ results.append(Check("connection", False, f"{where}: {first.message or first.stderr.strip()[:200]}",
56
+ hint or "run `ssh <host> true` in a terminal to see what ssh needs"))
57
+ return results + [Check(name, None, "skipped: no connection") for name in ("slurm", "sacct", "llview")]
58
+ results.append(Check("connection", True, f"commands run on {where} ({first.elapsed:.1f} s)"))
59
+
60
+ squeue = await service.runner.run(squeue_mine(), timeout=TIMEOUT)
61
+ results.append(_parsed("slurm", squeue, parse_squeue, "squeue --me --json",
62
+ "Slurm 21.08 or later is needed for --json; is this a Slurm login node?"))
63
+ sacct = await service.runner.run(sacct_recent(), timeout=TIMEOUT)
64
+ results.append(_parsed("sacct", sacct, parse_sacct, "sacct --json",
65
+ "job status after a job leaves the queue needs Slurm accounting (slurmdbd)"))
66
+
67
+ if p.llview is None:
68
+ results.append(Check("llview", None, "no LLview CLI in the profile: job_report unavailable"))
69
+ else:
70
+ cli = await service.runner.run(["test", "-x", p.llview.cli], timeout=TIMEOUT)
71
+ if cli.ok:
72
+ results.append(Check("llview", True, f"{p.llview.cli} found (job_report; data 10-35 min behind)"))
73
+ else:
74
+ results.append(Check("llview", False, f"{p.llview.cli} not found", "job_report will not work; the other "
75
+ "tools do not need LLview"))
76
+ return results
77
+
78
+
79
+ def _parsed(name: str, out: Outcome, parse, command: str, fix: str) -> Check:
80
+ if not out.ok:
81
+ return Check(name, False, f"{command}: {out.message or (out.stderr.strip().splitlines() or ['?'])[-1]}", fix)
82
+ try:
83
+ jobs = parse(out.stdout)
84
+ except ParseError as e:
85
+ return Check(name, False, f"{command}: {e}", fix)
86
+ return Check(name, True, f"{command} works ({len(jobs)} job{'s' if len(jobs) != 1 else ''}, {out.elapsed:.1f} s)")
@@ -0,0 +1,142 @@
1
+ """The user's config (~/.config/jobstate-mcp/config.yaml): per cluster, what agents may do within the site limits.
2
+
3
+ jupiter:
4
+ read_only: false
5
+ accounts: [solai] # budgets agents may use; missing: any account Slurm accepts
6
+ default_account: solai # used when a request names none
7
+ partitions: [booster] # missing: every partition in the profile
8
+ max_nodes: 4 # per job; missing: the partition's limit
9
+ max_time: "02:00:00" # per job; missing: the partition's limit
10
+ paths: # where scripts and working directories may be; missing: the account's
11
+ - $SCRATCH_solai # project and scratch directories from the cluster profile
12
+ - /e/scratch/solai/me # ($PROJECT_<account>, ...; variables are expanded on the cluster)
13
+ guards:
14
+ submissions_per_hour: 60
15
+ concurrent_jobs: 50
16
+ node_hours_per_day: 500
17
+ failures_in_a_row: 5
18
+ notify: # copies of every end-of-job event, beside the agent's own delivery
19
+ ntfy: https://ntfy.sh/<a secret topic> # phone notification with the event's text
20
+ webhook: https://example.org/hook # the event as JSON
21
+ command: [notify-send, jobstate] # the event as JSON on stdin
22
+
23
+ The config can only tighten the cluster profile: a limit above the site's is an error, not ignored. Without a
24
+ config, agents may submit within the site limits with any account Slurm accepts, under the default guards.
25
+ """
26
+
27
+ import os
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+
31
+ import yaml
32
+
33
+ from .delivery import NotifyConfig
34
+ from .profile import Profile
35
+ from .schema import SchemaError, Section, format_time
36
+
37
+ # Default guards (chosen 2026-10-17): loose, for supervised sweeps; the config can tighten them.
38
+ SUBMISSIONS_PER_HOUR = 60
39
+ CONCURRENT_JOBS = 50
40
+ NODE_HOURS_PER_DAY = 500
41
+ FAILURES_IN_A_ROW = 5
42
+
43
+
44
+ class ConfigError(SchemaError):
45
+ """The config file is malformed, breaks the schema or loosens a site limit."""
46
+
47
+
48
+ class _Section(Section):
49
+ error = ConfigError
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class Guards:
54
+ submissions_per_hour: int = SUBMISSIONS_PER_HOUR
55
+ concurrent_jobs: int = CONCURRENT_JOBS # jobs this server submitted, queued or running
56
+ node_hours_per_day: int = NODE_HOURS_PER_DAY # requested nodes x walltime, last 24 h
57
+ failures_in_a_row: int = FAILURES_IN_A_ROW # of one script; then refused until a person resets it
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ClusterConfig:
62
+ read_only: bool = False
63
+ accounts: tuple[str, ...] = () # empty: any account Slurm accepts
64
+ default_account: str | None = None
65
+ partitions: tuple[str, ...] = () # empty: every partition in the profile
66
+ max_nodes: int | None = None
67
+ max_time: int | None = None # minutes
68
+ paths: tuple[str, ...] = () # directories, may hold $VARs (expanded on the cluster); empty: from the profile
69
+ guards: Guards = field(default_factory=Guards)
70
+ notify: NotifyConfig = field(default_factory=NotifyConfig)
71
+
72
+
73
+ def default_path() -> Path:
74
+ config_home = os.environ.get("XDG_CONFIG_HOME")
75
+ return (Path(config_home) if config_home else Path.home() / ".config") / "jobstate-mcp" / "config.yaml"
76
+
77
+
78
+ def _cluster(s: _Section, profile: Profile) -> ClusterConfig:
79
+ accounts = tuple(s.strings("accounts")) if "accounts" in s.data else ()
80
+ partitions = tuple(s.strings("partitions")) if "partitions" in s.data else ()
81
+ paths = tuple(s.strings("paths")) if "paths" in s.data else ()
82
+ g = s.opt_section("guards")
83
+ guards = Guards()
84
+ if g is not None:
85
+ guards = Guards(*(g.opt_count(name, default=getattr(guards, name), minimum=1)
86
+ for name in ("submissions_per_hour", "concurrent_jobs", "node_hours_per_day",
87
+ "failures_in_a_row")))
88
+ g.done()
89
+ n = s.opt_section("notify")
90
+ notify = NotifyConfig()
91
+ if n is not None:
92
+ notify = NotifyConfig(webhook=n.opt_text("webhook"), ntfy=n.opt_text("ntfy"),
93
+ command=tuple(n.strings("command")) if "command" in n.data else ())
94
+ n.done()
95
+ for key, url in (("webhook", notify.webhook), ("ntfy", notify.ntfy)):
96
+ if url and not url.startswith(("https://", "http://localhost", "http://127.0.0.1")):
97
+ raise ConfigError(f"{n.path}.{key}: {url!r} must be https:// (or http:// on localhost)")
98
+ config = ClusterConfig(read_only=s.flag("read_only") if "read_only" in s.data else False, accounts=accounts,
99
+ default_account=s.opt_text("default_account"), partitions=partitions,
100
+ max_nodes=s.opt_count("max_nodes", default=None, minimum=1),
101
+ max_time=s.time("max_time") if "max_time" in s.data else None, paths=paths, guards=guards,
102
+ notify=notify)
103
+ s.done()
104
+
105
+ for name in partitions:
106
+ if name not in profile.partitions:
107
+ raise ConfigError(f"{s.path}.partitions: {name!r} is not a partition of {profile.name} "
108
+ f"({', '.join(profile.partitions)})")
109
+ if config.default_account and accounts and config.default_account not in accounts:
110
+ raise ConfigError(f"{s.path}.default_account: {config.default_account!r} is not in `accounts`")
111
+ usable = [profile.partitions[p] for p in partitions or profile.partitions]
112
+ if config.max_nodes is not None and config.max_nodes > max(p.max_nodes for p in usable):
113
+ raise ConfigError(f"{s.path}.max_nodes: {config.max_nodes} is above the site limit "
114
+ f"({max(p.max_nodes for p in usable)}); the config can only tighten limits")
115
+ if config.max_time is not None and config.max_time > max(p.max_time for p in usable):
116
+ raise ConfigError(f"{s.path}.max_time: {format_time(config.max_time)} is above the site limit "
117
+ f"({format_time(max(p.max_time for p in usable))}); the config can only tighten limits")
118
+ for path in paths:
119
+ if not (path.startswith("/") or path.startswith("$")):
120
+ raise ConfigError(f"{s.path}.paths: {path!r} must be absolute or start with a $VARIABLE")
121
+ return config
122
+
123
+
124
+ def load(profile: Profile, path: Path | None = None) -> ClusterConfig:
125
+ """The config for `profile`'s cluster; the defaults when the file or the cluster's entry is missing."""
126
+ path = path or default_path()
127
+ try:
128
+ data = yaml.safe_load(path.read_text())
129
+ except FileNotFoundError:
130
+ return ClusterConfig()
131
+ except yaml.YAMLError as e:
132
+ raise ConfigError(f"{path}: not valid YAML: {e}") from None
133
+ if data is None:
134
+ return ClusterConfig()
135
+ if not isinstance(data, dict):
136
+ raise ConfigError(f"{path}: expected a mapping of cluster names")
137
+ if profile.cluster not in data:
138
+ return ClusterConfig()
139
+ try:
140
+ return _cluster(_Section(data[profile.cluster], profile.cluster), profile)
141
+ except ConfigError as e:
142
+ raise ConfigError(f"{path}: {e}") from None
@@ -0,0 +1,86 @@
1
+ """Event delivery to observers beside the agent: a webhook (JSON POST), ntfy (a phone notification) or a command.
2
+
3
+ Configured per cluster in the user's jobstate config (`notify:`); nothing is sent anywhere otherwise. Observers get
4
+ a copy: they never mark an event delivered, so the agent still receives it (wait_for_jobs, channel events). They
5
+ carry the event's structured fields and text only, never job names or log text. A failed delivery is logged and
6
+ dropped, never retried in a loop and never allowed to stop the watcher.
7
+ """
8
+
9
+ import asyncio
10
+ import json
11
+ import logging
12
+ import urllib.request
13
+ from collections.abc import Sequence
14
+ from dataclasses import dataclass
15
+
16
+ from .events import Event, event_dict
17
+ from .profile import Profile
18
+
19
+ log = logging.getLogger(__name__)
20
+
21
+ TIMEOUT = 10.0 # seconds per delivery
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class NotifyConfig:
26
+ webhook: str | None = None # URL; receives the event as JSON
27
+ ntfy: str | None = None # ntfy topic URL, e.g. https://ntfy.sh/<secret topic>; receives the text
28
+ command: tuple[str, ...] = () # argv; receives the event as JSON on stdin
29
+
30
+
31
+ def _post(url: str, body: bytes, headers: dict[str, str]) -> int:
32
+ request = urllib.request.Request(url, data=body, headers=headers, method="POST")
33
+ with urllib.request.urlopen(request, timeout=TIMEOUT) as response: # noqa: S310 (URL from the user's config)
34
+ return response.status
35
+
36
+
37
+ class Webhook:
38
+ def __init__(self, url: str, profile: Profile):
39
+ self.url, self.profile = url, profile
40
+
41
+ async def __call__(self, event: Event) -> None:
42
+ body = json.dumps(event_dict(event, self.profile)).encode()
43
+ await asyncio.to_thread(_post, self.url, body, {"Content-Type": "application/json"})
44
+
45
+
46
+ class Ntfy:
47
+ def __init__(self, url: str, profile: Profile):
48
+ self.url, self.profile = url, profile
49
+
50
+ async def __call__(self, event: Event) -> None:
51
+ failed = sum(1 for j in event.jobs if not j.ok)
52
+ title = f"jobstate {event.cluster}: {len(event.jobs)} job{'s' if len(event.jobs) > 1 else ''} done" + (
53
+ f", {failed} not COMPLETED" if failed else "")
54
+ headers = {"Title": title, "Tags": "warning" if failed else "white_check_mark"}
55
+ await asyncio.to_thread(_post, self.url, event.text(self.profile).encode(), headers)
56
+
57
+
58
+ class Command:
59
+ def __init__(self, argv: Sequence[str], profile: Profile):
60
+ self.argv, self.profile = tuple(argv), profile
61
+
62
+ async def __call__(self, event: Event) -> None:
63
+ proc = await asyncio.create_subprocess_exec(*self.argv, stdin=asyncio.subprocess.PIPE,
64
+ stdout=asyncio.subprocess.DEVNULL,
65
+ stderr=asyncio.subprocess.DEVNULL)
66
+ try:
67
+ async with asyncio.timeout(TIMEOUT):
68
+ await proc.communicate(json.dumps(event_dict(event, self.profile)).encode())
69
+ except TimeoutError:
70
+ proc.kill()
71
+ await proc.wait()
72
+ raise
73
+ if proc.returncode:
74
+ raise RuntimeError(f"{self.argv[0]} exited with {proc.returncode}")
75
+
76
+
77
+ def sinks(config: NotifyConfig, profile: Profile) -> list:
78
+ """The observers configured for a cluster, each an async callable taking an Event."""
79
+ found: list = []
80
+ if config.webhook:
81
+ found.append(Webhook(config.webhook, profile))
82
+ if config.ntfy:
83
+ found.append(Ntfy(config.ntfy, profile))
84
+ if config.command:
85
+ found.append(Command(config.command, profile))
86
+ return found