Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
f39d888
Add versioned JobManager for reading/writing jobs across protocol ver…
koenvanderveen Jul 3, 2026
1066487
Nest ProtocolSchema inside PackageProtocolSchema and drop BaseVersion…
koenvanderveen Jul 6, 2026
7599a40
Replace PackageProtocolSchema with PackageInfo referenced by ReleaseA…
koenvanderveen Jul 6, 2026
abc9613
Add protocol_version to PackageInfo
koenvanderveen Jul 6, 2026
3963071
Rename registry history dicts to package_version_history and protocol…
koenvanderveen Jul 6, 2026
502d6f5
Key package_version_history by protocol version with PackageInfo valu…
koenvanderveen Jul 6, 2026
74bd7b1
Remove schema_for_package_version
koenvanderveen Jul 6, 2026
5b19f35
Simplify register_historic_protocol_schema to take only a ProtocolSchema
koenvanderveen Jul 6, 2026
0fe5512
Make historic schema validation opt-in via raise_for_unknown_objects
koenvanderveen Jul 6, 2026
16bde93
Split test_migration.py into focused test files
koenvanderveen Jul 6, 2026
08cb97e
Rename test_schemas.py to test_protocol_schemas.py
koenvanderveen Jul 6, 2026
8a75e4e
Regroup migration tests across files
koenvanderveen Jul 6, 2026
5050046
Move history tests into test_release_artifacts.py and drop test_proto…
koenvanderveen Jul 6, 2026
7fb2920
Rename history-stored test to old-protocols-stored
koenvanderveen Jul 6, 2026
26f7875
Make protocol_version_for_peer raise on unknown peers by default; ren…
koenvanderveen Jul 6, 2026
34f144d
Rename _write_downgraded to _write_in_target_version
koenvanderveen Jul 6, 2026
ad954d6
Add path docstrings to JobManager scanning helpers
koenvanderveen Jul 6, 2026
5408c93
Rename _resolve_ref to _find_jobref_from_name
koenvanderveen Jul 6, 2026
7870ec5
Remove _get_job_submitter; use the JobRef's ds_email directly
koenvanderveen Jul 6, 2026
8b65c73
Reorganize syft-job migration tests into unit/ and p2p/ folders
koenvanderveen Jul 6, 2026
93925b5
Add has_migration_path and test downgrade paths between all registere…
koenvanderveen Jul 7, 2026
0d62c6c
Use MyVersionedObject instead of Job in syft-migration test mocks
koenvanderveen Jul 7, 2026
6f0f177
Freeze released object schemas in release artifacts and detect drift
koenvanderveen Jul 7, 2026
7bfb744
Apply prettier formatting to protocol-0.json
koenvanderveen Jul 7, 2026
9e2b7f2
Split migrations history into package-artifacts and protocols folders
koenvanderveen Jul 7, 2026
8d6388e
Explain the fix steps when released object schemas drift
koenvanderveen Jul 7, 2026
995bf8f
Add generic loading test for all released protocol files
koenvanderveen Jul 7, 2026
d7dfb94
Move current-protocol roundtrip test into its own p2p test file
koenvanderveen Jul 8, 2026
2484538
Group protocol sanity-check tests into their own p2p file
koenvanderveen Jul 8, 2026
c503a7f
test: verify job read/write against all released syft-job fixtures
koenvanderveen Jul 8, 2026
f82861e
refactor: derive JobInfo identity from the path-derived JobRef
koenvanderveen Jul 8, 2026
ab9289e
test: clarify protocol-compat test names and peer-layout assertions
koenvanderveen Jul 8, 2026
46fea78
Merge branch 'dev' into koen/job-reader-writer
koenvanderveen Jul 8, 2026
84c40ca
test: fix JobInfo construction in datasets_jobs_repr after identity r…
koenvanderveen Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 34 additions & 62 deletions packages/syft-bg/src/syft_bg/notify/monitors/job.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from typing import Optional

from syft_job.config import SyftJobConfig
from syft_job.manager import JobManager, JobRef
from syft_job.models import JobState, JobStatus, JobSubmissionMetadata

from syft_bg.common.monitor import Monitor
Expand All @@ -29,28 +30,20 @@ def __init__(
self.job_config = SyftJobConfig.from_syftbox_folder(
str(self.syftbox_root), do_email
)
self.job_manager = JobManager(config=self.job_config)

def _check_all_entities(self):
self.process_local_status_changes()

def process_local_status_changes(self):
inbox_dir = self.job_config.get_all_submissions_dir(self.do_email)
if not inbox_dir.exists():
return

for ds_dir in inbox_dir.iterdir():
if not ds_dir.is_dir():
continue
for job_path in ds_dir.iterdir():
if not job_path.is_dir():
continue
try:
self._maybe_process_job(job_path)
except Exception as e:
print(f"[JobMonitor] Error checking job {job_path.name}: {e}")

def _maybe_process_job(self, job_path: Path):
metadata = self._load_job_metadata(job_path)
for ref in self.job_manager.iter_submission_refs(self.do_email):
try:
self._maybe_process_job(ref)
except Exception as e:
print(f"[JobMonitor] Error checking job {ref.job_name}: {e}")

def _maybe_process_job(self, ref: JobRef):
metadata = self._load_job_metadata(ref)
if not metadata:
return

Expand All @@ -62,7 +55,7 @@ def _maybe_process_job(self, job_path: Path):
if success:
print(f"[JobMonitor] Sent new job notification: {job_name}")

review_state = self._load_review_state(ds_email, job_name)
review_state = self._load_review_state(ref)

if review_state and review_state.status in (
JobStatus.APPROVED,
Expand All @@ -85,61 +78,40 @@ def _maybe_process_job(self, job_path: Path):

def seed_existing_jobs(self):
"""On fresh state, mark all existing jobs so we don't re-notify old jobs."""
inbox_dir = self.job_config.get_all_submissions_dir(self.do_email)
if not inbox_dir.exists():
return

count = 0
for ds_dir in inbox_dir.iterdir():
if not ds_dir.is_dir():
for ref in self.job_manager.iter_submission_refs(self.do_email):
metadata = self._load_job_metadata(ref)
if not metadata:
continue
for job_path in ds_dir.iterdir():
if not job_path.is_dir():
continue
metadata = self._load_job_metadata(job_path)
if not metadata:
continue
self.state.mark_notified(metadata.name, "new")
review_state = self._load_review_state(
metadata.submitted_by, metadata.name
)
if review_state:
if review_state.status in (
JobStatus.APPROVED,
JobStatus.RUNNING,
JobStatus.DONE,
JobStatus.FAILED,
):
self.state.mark_notified(metadata.name, "approved")
if review_state.status == JobStatus.DONE:
self.state.mark_notified(metadata.name, "executed")
if review_state.status == JobStatus.FAILED:
self.state.mark_notified(metadata.name, "failed")
count += 1
self.state.mark_notified(metadata.name, "new")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

metadata.name was replaced with ref.job_name in most places, but still remains in others like here.

review_state = self._load_review_state(ref)
if review_state:
if review_state.status in (
JobStatus.APPROVED,
JobStatus.RUNNING,
JobStatus.DONE,
JobStatus.FAILED,
):
self.state.mark_notified(metadata.name, "approved")
if review_state.status == JobStatus.DONE:
self.state.mark_notified(metadata.name, "executed")
if review_state.status == JobStatus.FAILED:
self.state.mark_notified(metadata.name, "failed")
count += 1

if count:
print(f"[JobMonitor] Seeded {count} existing jobs on fresh state")

def _load_review_state(self, ds_email: str, job_name: str) -> Optional[JobState]:
def _load_review_state(self, ref: JobRef) -> Optional[JobState]:
"""Load state.yaml from the job's review directory."""
review_dir = self.job_config.get_review_job_dir(
self.do_email, ds_email, job_name
)
state_file = review_dir / "state.yaml"
if not state_file.exists():
return None
try:
return JobState.load(state_file)
return self.job_manager.read_state(ref)
except Exception:
return None

def _load_job_metadata(self, job_path: Path) -> Optional[JobSubmissionMetadata]:
config_file = job_path / "config.yaml"
if not config_file.exists():
return None

def _load_job_metadata(self, ref: JobRef) -> Optional[JobSubmissionMetadata]:
try:
return JobSubmissionMetadata.load(config_file)
return self.job_manager.read_submission(ref)
except Exception as e:
print(f"[JobMonitor] Error reading job config {config_file}: {e}")
print(f"[JobMonitor] Error reading job config for {ref.job_name}: {e}")
return None
48 changes: 15 additions & 33 deletions packages/syft-enclave/src/syft_enclaves/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
from syft_client.sync.peers.peer_list import PeerList
from syft_datasets.dataset_manager import SyftDatasetManager
from syft_job.job import JobInfo, JobsList
from syft_job.models import JobState, JobStatus, JobSubmissionMetadata
from syft_job.manager import JobRef
from syft_job.models import JobState, JobStatus

from syft_enclaves.enclave_job_info import (
EnclaveJobInfo,
Expand Down Expand Up @@ -255,33 +256,19 @@ def receive_jobs(self):
4. Sets permissions and marks as distributed
"""
self._manager.job_client.scan_inbox()
inbox_dir = self._manager.job_client.config.get_all_submissions_dir(
self._manager.email
)
if not inbox_dir.exists():
return
job_manager = self._manager.job_client.manager
for ref in job_manager.iter_submission_refs(self._manager.email):
self._try_distribute_job(ref)

for ds_dir in inbox_dir.iterdir():
if not ds_dir.is_dir():
continue
for job_dir in ds_dir.iterdir():
if not job_dir.is_dir():
continue
self._try_distribute_job(ds_dir.name, job_dir)

def _try_distribute_job(self, ds_email: str, job_dir: Path):
def _try_distribute_job(self, ref: JobRef):
"""Distribute a single enclave job to relevant DOs if not yet distributed."""
config_path = job_dir / "config.yaml"
if not config_path.exists():
return

config = JobSubmissionMetadata.load(config_path)
job_manager = self._manager.job_client.manager
job_dir = job_manager.submission_dir(ref)
config = job_manager.read_submission(ref)
if config.job_type != "enclave" or not config.datasets:
return

review_dir = self._manager.job_client.config.get_review_job_dir(
self._manager.email, ds_email, job_dir.name
)
review_dir = job_manager.review_dir(ref)
distributed_marker = review_dir / "distributed"
if distributed_marker.exists():
return
Expand All @@ -296,7 +283,7 @@ def _try_distribute_job(self, ds_email: str, job_dir: Path):
recipients = list(dict.fromkeys([*submission_dos, *approval_dos]))
self._forward_job_to_dos(job_dir, recipients)
self._save_enclave_job_state(review_dir, approval_dos, config.datasets)
self._set_job_permissions(job_dir, recipients, approval_dos)
self._set_job_permissions(ref, recipients, approval_dos)
self._forward_approval_files_to_dos(review_dir, approval_dos)

distributed_marker.parent.mkdir(parents=True, exist_ok=True)
Expand Down Expand Up @@ -369,22 +356,17 @@ def _save_enclave_job_state(

def _set_job_permissions(
self,
job_dir: Path,
ref: JobRef,
read_dos: list[str],
approval_dos: list[str],
):
"""Grant inbox read to everyone who needs to see the job (referenced +
approving DOs), and approval-file write to the approving DOs."""
job_manager = self._manager.job_client.manager
datasite = self._manager.syftbox_folder / self._manager.email
ctx = SyftPermContext(datasite=datasite)
inbox_rel = job_dir.relative_to(datasite)

ds_email = job_dir.parent.name
job_name = job_dir.name
review_dir = self._manager.job_client.config.get_review_job_dir(
self._manager.email, ds_email, job_name
)
review_rel = review_dir.relative_to(datasite)
inbox_rel = job_manager.submission_dir(ref).relative_to(datasite)
review_rel = job_manager.review_dir(ref).relative_to(datasite)

for do_email in dict.fromkeys([*read_dos, *approval_dos]):
ctx.open(inbox_rel).grant_read_access(do_email)
Expand Down
4 changes: 4 additions & 0 deletions packages/syft-enclave/src/syft_enclaves/enclave_job_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ def __init__(self, job_client: JobClient):
def config(self):
return self._job_client.config

@property
def manager(self):
return self._job_client.manager

@property
def current_user_email(self):
return self._job_client.current_user_email
Expand Down
29 changes: 29 additions & 0 deletions packages/syft-job/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# syft-job

Job submission and execution for SyftBox: data scientists submit bash/Python jobs
into a data owner's inbox, the data owner reviews, approves, and runs them.

## Releasing

On **every** release (after bumping the version), run both release scripts:

```bash
uv run python scripts/export_release_artifact.py
uv run python scripts/generate_release_fixture.py
```

`export_release_artifact.py` always writes
`src/syft_job/migrations/history/package-artifacts/syft-job-<version>.json`
(the package's identity + the protocol it speaks), and additionally writes
`history/protocols/protocol-<n>.json` when the release ships a new protocol
version. It refuses to run if the job protocol changed without bumping
`JOB_PROTOCOL_VERSION` (`src/syft_job/migrations/registry.py`).

`generate_release_fixture.py` writes a full SyftBox tree —
`tests/migrations/p2p/fixtures/syft_job-<version>-protocol<p>_syftbox/` — exactly
as this release serializes jobs to disk. Commit it: future releases loop over
these fixtures (`test_older_protocol_compatibility.py`) to prove they can still
read and round-trip older on-disk data.

Tests check the code against these artifacts: released object versions are
frozen forever — changing one requires a new version plus migrations.
37 changes: 37 additions & 0 deletions packages/syft-job/scripts/export_release_artifact.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
"""Export the release artifacts for the current syft-job version.

Run on EVERY release (uv run python scripts/export_release_artifact.py):
always writes the package release info; additionally writes the protocol
artifact when this release introduces a new protocol version.
"""

import sys

from syft_job.migrations.history import PACKAGE_ARTIFACTS_DIR, PROTOCOLS_DIR
from syft_job.migrations.registry import JOB_PROTOCOL_VERSION, job_registry
from syft_job.version import __version__


def main() -> None:
# Import the models so every versioned object is registered.
import syft_job # noqa: F401

if job_registry.protocol_changed_without_bump():
sys.exit(
"The job protocol changed compared to the released "
f"protocol-{JOB_PROTOCOL_VERSION}.json; bump JOB_PROTOCOL_VERSION "
"in syft_job/migrations/registry.py before releasing."
)

info_path = PACKAGE_ARTIFACTS_DIR / f"syft-job-{__version__}.json"
job_registry.compute_released_package_protocol_info().save(info_path)
print(f"Wrote {info_path}")

protocol_path = PROTOCOLS_DIR / f"protocol-{JOB_PROTOCOL_VERSION}.json"
if not protocol_path.exists():
job_registry.compute_released_protocol().save(protocol_path)
print(f"Wrote {protocol_path} (new protocol version)")


if __name__ == "__main__":
main()
Loading
Loading