API Reference¶
Sync Module¶
srunx.sync.rsync ¶
Rsync-based file synchronization for remote SLURM servers.
RsyncResult
dataclass
¶
Result of an rsync operation.
RsyncClient ¶
RsyncClient(
hostname: str,
username: str,
port: int = 22,
key_filename: str | None = None,
proxy_jump: str | None = None,
ssh_config_path: str | None = None,
exclude_patterns: Sequence[str] | None = None,
)
Rsync wrapper for syncing files to/from remote SLURM servers.
Handles SSH connection options (port, key, ProxyJump, ssh_config) and builds rsync commands with sensible defaults for development workflow synchronization.
Source code in src/srunx/sync/rsync.py
push ¶
push(
local_path: str | Path,
remote_path: str | None = None,
*,
delete: bool = False,
dry_run: bool = False,
itemize: bool = False,
verbose: bool = False,
max_delete: int | None = None,
exclude_patterns: Sequence[str] | None = None,
) -> RsyncResult
Sync a local directory/file to the remote server.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
local_path
|
str | Path
|
Local file or directory to push. |
required |
remote_path
|
str | None
|
Destination path on the remote server.
If None, uses |
None
|
delete
|
bool
|
Remove remote files not present locally. Defaults to
False: a push that silently prunes remote-only files
(training checkpoints, run logs) is a data-loss footgun, and
every historical incident here came from a caller inheriting
a mirror-by-default. Mirror semantics are opt-in — callers
that genuinely want them pass |
False
|
max_delete
|
int | None
|
Blast-radius cap for a mirror — not an atomic
refusal. rsync deletes up to this many entries, skips the
remaining deletions, finishes transferring, and only then exits
25, so on a cap hit the destination has already changed
(verified against openrsync). A caller that needs "refuse
without touching anything" must count deletions in a separate
dry run and decide before calling; the MCP
:func: |
None
|
dry_run
|
bool
|
Perform a trial run that changes nothing on the remote —
no transfers, no deletions, and no directory creation. Note
that rsync without |
False
|
itemize
|
bool
|
Add |
False
|
verbose
|
bool
|
Stream rsync's per-file progress to stderr live
instead of capturing it silently. Adds
|
False
|
exclude_patterns
|
Sequence[str] | None
|
Additional exclude patterns for this call only. |
None
|
Returns:
| Type | Description |
|---|---|
RsyncResult
|
RsyncResult with returncode, stdout, and stderr. |
Raises:
| Type | Description |
|---|---|
ValueError
|
If |
Source code in src/srunx/sync/rsync.py
209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 | |
pull ¶
pull(
remote_path: str,
local_path: str | Path,
*,
delete: bool = False,
dry_run: bool = False,
itemize: bool = False,
exclude_patterns: Sequence[str] | None = None,
) -> RsyncResult
Sync a remote directory/file to the local machine.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
remote_path
|
str
|
Source path on the remote server. |
required |
local_path
|
str | Path
|
Local destination path. |
required |
delete
|
bool
|
Remove local files not present on the remote (default False). |
False
|
dry_run
|
bool
|
Perform a trial run with no changes made. |
False
|
itemize
|
bool
|
Add |
False
|
exclude_patterns
|
Sequence[str] | None
|
Additional exclude patterns for this call only. |
None
|
Returns:
| Type | Description |
|---|---|
RsyncResult
|
RsyncResult with returncode, stdout, and stderr. |
Source code in src/srunx/sync/rsync.py
read_remote_file ¶
Return the remote file's contents, or None if it doesn't exist.
require_owned=True additionally refuses a file belonging to another
account. Worth the extra check wherever the contents drive an action —
the upload manifest decides which paths a user is told are safe to
delete, so a file another account planted there must not be believed.
The ownership marker leaves it off: it is advisory, and refusing to read
a foreign one would turn a warning into a hard failure.
The check narrows that window rather than closing it: the ownership test and the read are two pathname lookups, so a peer able to write the directory could swap the file in between. Closing it needs the owner verified on the same descriptor that is read, which a shell cannot express — it would mean driving this over SFTP rather than ssh.
Used by the per-machine ownership marker (#137 part 4) to read
.srunx-owner.json before each sync. The check needs to
distinguish "file missing" (legitimate first sync, returns
None) from "ssh / network failed" (raise so the caller
knows the marker can't be trusted).
Implementation: ssh ... cat -- <path> with a per-file
existence test wrapped in a single shell command — keeps the
round-trip count to one per check.
Source code in src/srunx/sync/rsync.py
611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 | |
write_remote_file ¶
Write content to remote_path atomically (temp file + rename).
mode defaults to world-readable, which the ownership marker needs:
its whole job is telling another account that this mount is in use,
and a marker they cannot read is a marker that does not work. Anything
only its own writer reads should pass 600 — the upload manifest
does, since it enumerates a project's file names and the workstation
that pushed them, and a mount root is often traversable even when the
directories under it are not.
mv within one directory is a rename(2), so a concurrent reader
sees either the previous file or the complete new one. Writing with
tee straight at the target does not give that: tee
truncates first, so a reader in that window sees an empty file. This
function used to do exactly that while documenting the opposite.
The target is rejected when it is a symlink (tee / mv would act
on whatever it points at, letting a planted link redirect the write
outside the mount) or a directory (mv would move the new file
inside it and exit 0, reporting success while the control file stays
unreadable). A probe that cannot be completed is also an error, since
its silence proves nothing.
Those checks are made twice — once up front and once immediately before
the rename — and the result is verified afterwards. GNU coreutils can
refuse a directory destination outright (mv -T) and that path is
taken when available; elsewhere a swap in the instant before the rename
is detected rather than prevented, and never reported as success.
Known limit: a peer could point our temp directory's name at a different directory we own, which passes the ownership check. That costs nothing (they cannot reach inside a directory of ours) beyond the publish possibly crossing filesystems. Closing it would need fd-relative operations, which a shell cannot express — it would mean driving this over SFTP instead of ssh.
The parent directory is assumed to exist (for the owner-marker case the rsync that just ran guarantees it).
Raises:
| Type | Description |
|---|---|
ValueError
|
If remote_path is login-relative. The write anchors its working directory inside a private temp directory, which would change how such a path resolves. Also if mode is not three octal digits — it reaches a remote shell unquoted. |
RuntimeError
|
If the target is a symlink or a directory, if the target could not be probed, or if any write / publish step exits non-zero — so the caller surfaces the failure instead of silently leaving a stale file behind. |
Source code in src/srunx/sync/rsync.py
691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 822 823 824 825 826 827 828 829 830 831 832 833 834 835 836 837 838 839 840 841 842 843 844 845 846 847 848 849 850 851 852 853 854 855 856 857 858 859 860 861 862 863 864 865 866 867 868 869 870 871 872 873 874 875 876 877 878 879 880 881 882 883 884 885 886 887 888 889 890 891 892 893 894 895 896 897 898 899 900 901 902 903 904 905 906 907 908 909 910 911 912 913 914 915 916 917 918 919 920 921 922 923 924 925 926 927 928 929 930 931 932 933 934 935 936 937 938 939 940 941 942 943 944 | |
remote_sha256 ¶
Return the SHA-256 hex digest of remote_path, or None.
Used by :func:srunx.sync.hash_verify.verify_paths_match
(#137 part 5) to detect the silent-rsync-failure case where
rsync exits 0 but the specific file we're about to sbatch
never reached the cluster (excluded by a stray rule, lost to
a path-translation bug, …). A None return defers to the
caller, which decides whether "missing" or "no tool" should
block submission.
Returns:
| Type | Description |
|---|---|
str | None
|
The 64-char lowercase hex digest on success. |
str | None
|
|
str | None
|
|
str | None
|
is available on the remote PATH (logged at debug — the |
str | None
|
rsync that just succeeded is the user's main signal). |
Raises:
| Type | Description |
|---|---|
RuntimeError
|
For any other ssh / network failure (connection
refused, host key mismatch, host unreachable, …). Callers
that want "best effort" can catch and downgrade; the
marker-read code in :func: |
Source code in src/srunx/sync/rsync.py
977 978 979 980 981 982 983 984 985 986 987 988 989 990 991 992 993 994 995 996 997 998 999 1000 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 | |
list_local_files ¶
list_local_files(
local_path: str | Path,
exclude_patterns: Sequence[str] | None = None,
) -> list[str]
List the files a push of local_path would transfer, as relative paths.
Runs rsync with this client's binary and merged filter, so the answer
cannot disagree with what a real push would send. Re-implementing the
matching in Python would drift from rsync's actual semantics for
anchored patterns, directory rules and **.
Paths come back in rsync's escaped form, the same one a deletion
preview prints, and that is the point: the two are compared to each
other. :func:unescape_rsync_path converts back for display.
The inventory is an itemize run against a throwaway empty directory —
every file is "new" against it — rather than --list-only, whose
output cannot be parsed safely. That listing prints a newline inside a
filename literally, so one file becomes two lines; a name crafted to
look like a listing line then yields two paths that do not exist. A
directory named victim\n-rw-r--r-- 1 2026 was enough to record
both victim and a fabricated output.py — and had a job written
anything by either name, the comparison would have offered live job
output for deletion. Itemize escapes the newline (\#012) and the
backslash (\#134), so one line is always exactly one file.
Directories are excluded from the result — callers record files.
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If rsync exits non-zero, so a partial listing is never mistaken for a complete one. |
Source code in src/srunx/sync/rsync.py
1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 | |
effective_excludes ¶
Return the patterns a call passing exclude_patterns would filter on.
push / pull merge per-call patterns on top of the instance's for
that invocation only, without storing them, so
:attr:exclude_patterns alone under-reports what a given call actually
filtered on — it omits exactly the mount-level patterns a user
configured.
That matters wherever the filter is reported back to a user: an excluded path is invisible to an inspection and protected from a mirror's deletions, so a missing pattern makes the report read as "in sync" when it really means "never looked at".
Source code in src/srunx/sync/rsync.py
get_default_remote_path
staticmethod
¶
Derive a default remote workspace path from the git repo or cwd.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
local_path
|
str | Path | None
|
Optional local directory to derive the project name from. If None, uses the current working directory. |
None
|
Returns:
| Type | Description |
|---|---|
str
|
A path like |
Source code in src/srunx/sync/rsync.py
get_logger ¶
Get a logger instance for a module.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
Module name (usually name). |
required |
Returns:
| Type | Description |
|---|---|
Logger
|
Logger instance. |
_C_LOCALE_ENV ¶
Environment forcing rsync's output into one deterministic encoding.
Which bytes rsync escapes depends on what the locale calls printable, and
the two outputs this compares must agree. Verified on openrsync: under a
UTF-8 locale データ.csv comes back as a mix of raw and escaped bytes
(\ufffd\#203\#207\ufffd...) while LC_ALL=C gives the fully escaped
\#343\#203\#207.... Run the inventory one way and the deletion preview
the other, and a genuinely stale non-ASCII file matches nothing — it is
dropped from the report and the mount reads as clean.
Only output formatting is affected. rsync passes filenames through as bytes
unless --iconv is given, which srunx never does.
Source code in src/srunx/sync/rsync.py
unescape_rsync_path ¶
Turn rsync's escaped output back into the filename it stands for.
Paths are compared in their escaped form — that is what makes an inventory
entry and a deletion candidate the same string — but a user reading a
report should see データ.csv, not \#343\#203\#207....
Only printable characters are restored. rsync escapes control bytes
because they are not safe to print, and this output goes to a terminal:
a file named innocent\#033c.py would otherwise emit ESC-c and reset the
terminal, and \#012 would forge an extra line in a sync preview. Since
a cluster job can choose the names it writes, that is an injection an
attacker controls. Anything unprintable — control bytes, bidi overrides,
undecodable bytes left as surrogates — stays in the escaped form, which is
still perfectly readable and names the file unambiguously.
A run that decodes to a mix of printable and not is kept escaped whole. It is the conservative direction and such names do not occur by accident.
Source code in src/srunx/sync/rsync.py
_restore_run ¶
Sweep orchestration¶
srunx.runtime.sweep.expand ¶
Pure functions for matrix expansion, sweep-spec merging, and CLI flag parsing.
All validation routes through WorkflowValidationError so CLI, Web API, and
MCP paths surface a consistent error category.
expand_matrix ¶
Cross-product of matrix axes merged into base_args.
Axis iteration order follows insertion order of matrix. Matrix values
override any identically-keyed entry in base_args (matrix wins at the
args level; the deps.<parent>.<key> channel remains a separate space).
Raises:
| Type | Description |
|---|---|
WorkflowValidationError
|
empty matrix (R2.10), empty axis list (R2.4),
non-scalar axis value (R2.5), axis named |
merge_sweep_specs ¶
merge_sweep_specs(
yaml_sweep: SweepSpec | None,
cli_sweep_axes: dict[str, list[ScalarValue]],
cli_arg_overrides: dict[str, str],
cli_fail_fast: bool | None,
cli_max_parallel: int | None,
) -> SweepSpec | None
Merge YAML sweep: block with CLI flags at axis granularity.
- If neither YAML nor CLI provides any matrix axis, returns
None(caller runs the non-sweep path). - CLI axes replace same-named YAML axes; CLI-only axes are added.
--arg KEYcolliding with--sweep KEYis rejected (R3.6).- The final
max_parallelmust be set and >= 1 (R2.6).
Raises:
| Type | Description |
|---|---|
WorkflowValidationError
|
on |
parse_arg_flags ¶
Tokenize --arg KEY=VALUE occurrences.
Rules:
- Split on the FIRST = (later = characters stay in the value).
- Duplicate keys: last occurrence wins (R1.2).
- Missing = raises WorkflowValidationError (R3.8).
- Values are always strings; no int/float/bool auto-cast (R3.10).
parse_sweep_flags ¶
Tokenize --sweep KEY=v1,v2,v3 occurrences.
- Split axis at the first
=(axis names cannot contain=). - Values are split on
,with no escape handling (Phase 1, R3.5). - Empty elements (
a,,b) are preserved as empty strings (R3.9). - Missing
=raisesWorkflowValidationError(R3.8). - Duplicate axis: last occurrence wins (consistent with
parse_arg_flags).
srunx.runtime.sweep.orchestrator ¶
SweepOrchestrator: materialize matrix cells and drive them under a semaphore.
See .claude/specs/workflow-parameter-sweep/design.md § SweepOrchestrator
and tasks 17-20.
SweepOrchestrator ¶
SweepOrchestrator(
*,
workflow_yaml_path: Path | None,
workflow_data: dict[str, Any],
args_override: dict[str, Any] | None,
sweep_spec: SweepSpec,
submission_source: Literal["cli", "web", "mcp"],
callbacks: Sequence[Callback] | None = None,
endpoint_id: int | None = None,
preset: str = "terminal",
executor_factory: WorkflowJobExecutorFactory
| None = None,
submission_context: SubmissionRenderContext
| None = None,
)
Drive sweep execution: materialize cells, run them, aggregate status.
materialize ¶
Expand + materialize cells synchronously; return sweep_run_id.
Separated from :meth:arun so HTTP callers can materialize
inside the request (to obtain sweep_run_id) and then spawn
:meth:arun_from_materialized as a background task.
arun_from_materialized
async
¶
Run the execution loop for an already-materialized sweep.
Assumes :meth:materialize (or equivalent) populated
self._cells and self._sweep_run_id. Used by both
:meth:arun (materialize + run in the same call) and the Web
dispatcher (materialize synchronously, then spawn this as a
background task).
arun
async
¶
Execute the sweep with bounded concurrency.
Steps: expand → materialize → spawn N cells behind an
anyio.Semaphore(min(max_parallel, cell_count)) → return
the final SweepRun row.
resume_from_db
async
¶
Resume a sweep whose cells were already materialized.
Used by :class:srunx.runtime.sweep.reconciler.SweepReconciler to
spawn orchestrator tasks after a crash. Skips expand +
materialize; the caller provides the already-materialized
pending cells.
request_cancel ¶
Mark the sweep as cancel-requested and drain pending cells.
Idempotent: a second call is a no-op because _cancelled is
set and SweepRunRepository.request_cancel guards on
cancel_requested_at IS NULL.
get_active_orchestrator ¶
Return the live orchestrator for sweep_run_id or None.
drain_sweep_pending_cells ¶
Cancel every still-pending cell for sweep_run_id and sync counters.
Runs in a single IMMEDIATE TX on a fresh connection. After the drain
it triggers the aggregator so the sweep can transition to its final
status if every in-flight cell is already done. Returns the number
of cells moved from pending to cancelled (0 when nothing was
pending).
This is the out-of-process drain used by the cancel endpoint when
no in-process orchestrator is registered (crash-recovery path) and
by :class:SweepOrchestrator itself via :meth:SweepOrchestrator._drain.
Security helpers¶
srunx.runtime.security.python_args ¶
Reject python: prefix in user-supplied args / matrix values.
Runs on Web API submission (YAML + JSON) and MCP tool calls. The
python: prefix in args is a server-side evaluation escape hatch
reserved for CLI-local use; exposing it over transport boundaries is a
security concern (remote code execution via workflow mutation). See
:func:srunx.runtime.workflow.loader._has_python_prefix for the CLI-side parser that
actually evaluates these values — the check here mirrors its matching
rules (prefix match, leading-whitespace-tolerant, case-insensitive).
PythonPrefixViolation
dataclass
¶
Structured violation record. Caller converts to transport-specific error.
find_python_prefix ¶
Recursively scan a dict / list / scalar payload; return the first violation.
Traverses nested dict -> list -> str. Non-string scalars
(int / float / bool / None) are ignored. Returns None when no
violation is found.
srunx.runtime.security.mount_paths ¶
Mount-root guard for ShellJob script paths.
Both the Web API and the MCP tool surface the same attack shape: a
workflow YAML can declare template: shell with an arbitrary
script_path that render_shell_job_script then reads verbatim.
Without a guard, a caller could exfiltrate or inject arbitrary host
files via e.g. script_path: ../../../etc/passwd.
The helper here returns a structured ShellJobScriptViolation so
each transport caller can raise the right exception type (Web →
HTTPException(403), MCP → ValueError) while the actual
directory-check logic lives in one place.
ShellJobScriptViolation
dataclass
¶
First ShellJob whose script path escapes the allowed mount roots.
find_shell_script_violation ¶
find_shell_script_violation(
workflow: Workflow, mount_roots: Iterable[Path]
) -> ShellJobScriptViolation | None
Return the first ShellJob pointing outside every mount_roots entry.
mount_roots must already be resolved absolute paths (see
:meth:pathlib.Path.resolve). Non-ShellJob entries are ignored.
Returns None when every ShellJob's script_path is contained
in at least one root.
SLURM protocol constants¶
srunx.slurm.states ¶
SLURM protocol-level state constants.
Separate from :class:srunx.domain.JobStatus (the domain-level enum)
because SLURM's raw state vocabulary is wider than what srunx models:
SLURM emits NODE_FAIL / PREEMPTED / OUT_OF_MEMORY for
terminal failures that srunx currently collapses into FAILED at
the domain boundary. Keeping these strings in a single module lets
every caller that speaks SLURM-native states (notification preset
filter, active-watch poller, web SSH adapter) agree on the set
without risking drift when a new terminal state is added.