simrun 0.1.0__py3-none-any.whl

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.
simrun/service/logs.py ADDED
@@ -0,0 +1,91 @@
1
+ """The logs of the pods of a task, kept in the run store.
2
+
3
+ The logs of a pod are gone with the pod: Kueue deletes a preempted pod, Argo deletes
4
+ the pods of a workflow with it after its time to live, and stopping a workflow ends
5
+ its pods. So the controller copies the logs of the main containers of the pods of a
6
+ task into `tasks/<task>/logs/` once the task is final and its pods terminated, the
7
+ ones which ended before it stops or cancels a workflow (the last `KEPT_TAIL_LINES`
8
+ lines, at most what the backend reads of a log): a file `<pod>.log` per pod (every
9
+ attempt the backend can still read) and `index.json`, the pods in their order,
10
+ written last, which marks the logs as kept. An ended run is followed until its logs
11
+ are kept or its workflow is gone (`RunRecord.pending_logs`); a run which never had a
12
+ workflow keeps none. The API serves the kept logs first and the logs of the live pods
13
+ otherwise.
14
+ """
15
+
16
+ import json
17
+ import os
18
+ import uuid
19
+ from collections import deque
20
+ from pathlib import Path
21
+
22
+ from simrun.service.backend import PodLog
23
+
24
+ LOGS_DIR = "logs"
25
+ INDEX_FILE = "index.json"
26
+ # the lines of every pod an agent or a person gets by default
27
+ DEFAULT_TAIL_LINES = 300
28
+ # the last lines of every pod kept in the store
29
+ KEPT_TAIL_LINES = 100_000
30
+
31
+
32
+ def _write(path: Path, text: str) -> None:
33
+ """Write a file atomically (a temporary file in its directory, renamed)."""
34
+ tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp")
35
+ tmp.write_text(text)
36
+ os.replace(tmp, path)
37
+
38
+
39
+ def kept(task_dir: Path) -> bool:
40
+ """Whether the logs of a task are kept in its directory."""
41
+ return (task_dir / LOGS_DIR / INDEX_FILE).is_file()
42
+
43
+
44
+ def keep(task_dir: Path, logs: list[PodLog]) -> None:
45
+ """Keep the logs of the pods of a task in `task_dir/logs`.
46
+
47
+ Args:
48
+ task_dir: The directory of the task.
49
+ logs: The logs of its pods, oldest first.
50
+ """
51
+ directory = task_dir / LOGS_DIR
52
+ directory.mkdir(parents=True, exist_ok=True)
53
+ index = []
54
+ for log in logs:
55
+ name = f"{log.pod}.log"
56
+ _write(directory / name, log.text)
57
+ index.append({"node": log.node, "pod": log.pod, "file": name})
58
+ _write(directory / INDEX_FILE, json.dumps(index, indent=2))
59
+
60
+
61
+ def _tail(path: Path, lines: int | None) -> str:
62
+ with path.open(encoding="utf-8", errors="replace") as handle:
63
+ if lines is None:
64
+ return handle.read()
65
+ return "".join(deque(handle, maxlen=lines))
66
+
67
+
68
+ def read_kept(task_dir: Path, tail_lines: int | None = None) -> list[PodLog] | None:
69
+ """The kept logs of a task, the last `tail_lines` lines of each.
70
+
71
+ Returns:
72
+ The logs, None when they are not kept (yet).
73
+ """
74
+ directory = task_dir / LOGS_DIR
75
+ try:
76
+ index = json.loads((directory / INDEX_FILE).read_text())
77
+ except FileNotFoundError:
78
+ return None
79
+ logs = []
80
+ for entry in index:
81
+ try:
82
+ text = _tail(directory / entry["file"], tail_lines)
83
+ except FileNotFoundError:
84
+ text = "(the kept log is missing)\n"
85
+ logs.append(PodLog(node=entry["node"], pod=entry["pod"], text=text))
86
+ return logs
87
+
88
+
89
+ def render(logs: list[PodLog]) -> str:
90
+ """The logs of the pods of a task as one text, each pod under a header."""
91
+ return "\n".join(f"==> {log.node} ({log.pod}) <==\n{log.text}" for log in logs)
@@ -0,0 +1,349 @@
1
+ """The submission of a run in the background: its environment, image and workflow.
2
+
3
+ `POST /runs` validates and plans a run, writes its record `submitting` (with the
4
+ deterministic name of its workflow and the settings of its tasks) and returns its id
5
+ at once. The controller then runs `Submitter.run` in its pool of submissions
6
+ (`Controller.start_submission`): it resolves the environment of the run, builds its
7
+ image when the registry does not have it (once per image: a second submission of the
8
+ same new environment waits for the first build and finds the image), records the
9
+ image and the time it was ready (`built`, from which the grace of a submission
10
+ counts), reserves the node of the run and submits its workflow; the run is queued
11
+ then.
12
+
13
+ A failure lands in the record: the run fails with a message, its node is released and
14
+ a workflow a failed submission may have created is cancelled. A run cancelled while it
15
+ is resolved or built is not submitted; one cancelled while its workflow is submitted
16
+ has the workflow cancelled; the workflow of one cancelled and deleted meanwhile is
17
+ cancelled as an orphan.
18
+ """
19
+
20
+ import dataclasses
21
+ import logging
22
+ import shutil
23
+ import tempfile
24
+ import threading
25
+ from collections.abc import Generator
26
+ from contextlib import contextmanager, suppress
27
+ from datetime import UTC, datetime
28
+ from pathlib import Path
29
+
30
+ import simrun
31
+ from simrun.argo import to_argo
32
+ from simrun.config import ClusterConfig
33
+ from simrun.environment import (
34
+ BUILD_CONSTRAINTS,
35
+ Environment,
36
+ dockerfile,
37
+ image_ref,
38
+ resolve_environment,
39
+ )
40
+ from simrun.planner import PodShape, TaskPlan
41
+ from simrun.plugins import TaskType
42
+ from simrun.service.backend import Backend
43
+ from simrun.service.controller import TERMINAL_RUN_STATES, Controller, closed
44
+ from simrun.snapshot import SourceProvenance
45
+ from simrun.store import RunRecord, RunState, RunStore
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+ ENVIRONMENT_LOCK = "environment.lock"
50
+
51
+
52
+ class SubmissionError(Exception):
53
+ """A submission which cannot go on; the message is the one of the record."""
54
+
55
+
56
+ def has_model_project(store: Path, sources: list[SourceProvenance]) -> bool:
57
+ """Whether the model of a run (its first source) has a `pyproject.toml`."""
58
+ return bool(sources) and (store / sources[0].snapshot / "pyproject.toml").is_file()
59
+
60
+
61
+ def task_plans(record: RunRecord) -> dict[str, TaskPlan]:
62
+ """The plans of the tasks of a run from its record."""
63
+ return {
64
+ task_id: dataclasses.replace(TaskPlan(**plan), shape=PodShape(**plan["shape"]))
65
+ for task_id, plan in record.plans.items()
66
+ }
67
+
68
+
69
+ def _undo(current: RunRecord, record: RunRecord) -> dict[str, object]:
70
+ """What a run which ended during its submission still has to undo.
71
+
72
+ The workflow is cancelled, and the node released: the submission may have
73
+ reserved it after the end of the run released it (a release removes only the
74
+ reservation of its run, so it may be repeated).
75
+ """
76
+ return {
77
+ "pending_cancel": True,
78
+ "pending_release": current.pending_release or record.reserve,
79
+ "pending_logs": True,
80
+ }
81
+
82
+
83
+ class Submitter:
84
+ """Resolves, builds and submits the runs written `submitting`."""
85
+
86
+ def __init__(
87
+ self,
88
+ store: RunStore,
89
+ backend: Backend,
90
+ types: dict[str, TaskType],
91
+ controller: Controller,
92
+ cluster: ClusterConfig,
93
+ python: str,
94
+ simrun_wheel: Path | None,
95
+ build_constraints: Path | None = None,
96
+ ) -> None:
97
+ """Create the submitter.
98
+
99
+ Args:
100
+ store: The run store.
101
+ backend: The backend the workflows run on.
102
+ types: The task types by name.
103
+ controller: The controller, whose lock guards the records.
104
+ cluster: The cluster.
105
+ python: Python version of the images.
106
+ simrun_wheel: The simrun wheel the images install.
107
+ build_constraints: The pinned versions of the build backends, the ones
108
+ of simrun by default.
109
+ """
110
+ self.store = store
111
+ self.backend = backend
112
+ self.types = types
113
+ self.controller = controller
114
+ self.cluster = cluster
115
+ self.python = python
116
+ self.simrun_wheel = simrun_wheel
117
+ self.build_constraints = build_constraints or BUILD_CONSTRAINTS
118
+ self._reserve_lock = threading.Lock()
119
+ self._builds_lock = threading.Lock()
120
+ self._builds: dict[str, threading.Lock] = {}
121
+
122
+ @contextmanager
123
+ def _building(self, ref: str) -> Generator[None]:
124
+ """Hold the lock of an image, so it is built once at a time."""
125
+ with self._builds_lock:
126
+ lock = self._builds.setdefault(ref, threading.Lock())
127
+ with lock:
128
+ yield
129
+
130
+ def environment(self, record: RunRecord) -> tuple[str | None, str | None]:
131
+ """The image of a run and the lock of its environment, built when missing.
132
+
133
+ A run whose model has no `pyproject.toml` keeps the image it was written
134
+ with (the default image of the service).
135
+
136
+ Raises:
137
+ SubmissionError: The environment cannot be resolved, the service has no
138
+ simrun wheel to build with, or the build fails.
139
+ """
140
+ if not has_model_project(self.store.root, record.sources):
141
+ return record.image, None
142
+ model, *libraries = record.sources
143
+ try:
144
+ env = resolve_environment(
145
+ self.store.root,
146
+ model,
147
+ libraries,
148
+ python=self.python,
149
+ simrun_version=simrun.__version__,
150
+ build_constraints=self.build_constraints,
151
+ )
152
+ except (ValueError, RuntimeError) as error:
153
+ raise SubmissionError(
154
+ f"the environment cannot be resolved: {error}"
155
+ ) from error
156
+ ref = image_ref(env, self.cluster.registry)
157
+ with self._building(ref):
158
+ try:
159
+ missing = not self.backend.has_image(ref)
160
+ except Exception as error:
161
+ raise SubmissionError(
162
+ f"the registry cannot tell whether it has {ref}: {error}"
163
+ ) from error
164
+ if missing:
165
+ self._build(env, ref)
166
+ return ref, env.lock
167
+
168
+ def _build(self, env: Environment, ref: str) -> None:
169
+ wheel = self.simrun_wheel
170
+ if wheel is None:
171
+ raise SubmissionError("the service has no simrun_wheel to build with")
172
+ with tempfile.TemporaryDirectory(prefix="simrun-build-") as tmp:
173
+ context = Path(tmp)
174
+ (context / "Dockerfile").write_text(
175
+ dockerfile(env, self.cluster.base_image_ref(), wheel.name)
176
+ )
177
+ shutil.copy2(wheel, context / wheel.name)
178
+ logger.info("building the image %s", ref)
179
+ try:
180
+ self.backend.build_image(context, ref)
181
+ except Exception as error:
182
+ raise SubmissionError(
183
+ f"building the image {ref} failed: {error}"
184
+ ) from error
185
+
186
+ def run(self, run_id: str) -> None:
187
+ """Resolve, build and submit a run written `submitting`.
188
+
189
+ Args:
190
+ run_id: The run.
191
+ """
192
+ try:
193
+ record = self.store.read(run_id)
194
+ except KeyError:
195
+ return
196
+ if record.state != RunState.SUBMITTING:
197
+ return
198
+ try:
199
+ image, lock = self.environment(record)
200
+ built = record.model_copy(
201
+ update={"image": image, "built": datetime.now(UTC)}
202
+ )
203
+ if not self.controller.write_if_unchanged(record, built):
204
+ logger.info("run %s changed while it was built: not submitted", run_id)
205
+ return
206
+ record = built
207
+ if lock is not None:
208
+ (self.store.run_dir(run_id) / ENVIRONMENT_LOCK).write_text(lock)
209
+ try:
210
+ manifest = to_argo(record, task_plans(record), self.types, self.cluster)
211
+ except ValueError as error:
212
+ raise SubmissionError(str(error)) from error
213
+ if record.reserve is not None:
214
+ self._reserve(record.reserve, run_id)
215
+ except SubmissionError as error:
216
+ self._fail(record, str(error))
217
+ return
218
+ try:
219
+ self.backend.submit(manifest)
220
+ except Exception as error:
221
+ self._abort(record, error)
222
+ return
223
+ self._submitted(record)
224
+
225
+ def _reserve(self, node: str, run_id: str) -> None:
226
+ """Reserve the node of a run, unless another run took it meanwhile.
227
+
228
+ Raises:
229
+ SubmissionError: The node is unknown, reserved by another run, or its
230
+ reservation failed.
231
+ """
232
+ with self._reserve_lock:
233
+ try:
234
+ found = next((n for n in self.backend.nodes() if n.name == node), None)
235
+ if found is None:
236
+ raise SubmissionError(f"unknown node {node!r} to reserve")
237
+ if found.reserved_by not in (None, run_id):
238
+ raise SubmissionError(
239
+ f"node {node!r} is reserved by run {found.reserved_by}"
240
+ )
241
+ self.backend.reserve(node, run_id)
242
+ except SubmissionError:
243
+ raise
244
+ except Exception as error:
245
+ raise SubmissionError(
246
+ f"reserving node {node!r} failed: {error}"
247
+ ) from error
248
+
249
+ def _fail(self, record: RunRecord, message: str) -> None:
250
+ """Fail a run whose submission cannot go on; nothing was submitted."""
251
+ logger.error("the submission of run %s failed: %s", record.id, message)
252
+ failed = closed(
253
+ record, RunState.FAILED, record.tasks, message, pending_logs=False
254
+ )
255
+ if self.controller.write_if_unchanged(record, failed):
256
+ self.controller.settle(record.id)
257
+
258
+ def _abort(self, record: RunRecord, error: Exception) -> None:
259
+ """Fail a run whose submission of the workflow failed, then settle it.
260
+
261
+ The workflow may have been created although the submission raised, so it is
262
+ cancelled like the node is released, retried by the controller on failure.
263
+ """
264
+ logger.error("submitting run %s failed: %s", record.id, error)
265
+ failed = closed(
266
+ record,
267
+ RunState.FAILED,
268
+ record.tasks,
269
+ f"submitting failed: {error}",
270
+ pending_cancel=True,
271
+ )
272
+ try:
273
+ if not self.controller.write_if_unchanged(record, failed):
274
+ # cancelled meanwhile: the cancellation is retried by the controller
275
+ self._cancel_if_pending(record)
276
+ return
277
+ except OSError:
278
+ logger.exception("writing the failed record of run %s failed", record.id)
279
+ if record.reserve is not None:
280
+ with suppress(Exception):
281
+ self.backend.unreserve(record.reserve, record.id)
282
+ return
283
+ self.controller.settle(record.id)
284
+
285
+ def _cancel_if_pending(self, record: RunRecord) -> None:
286
+ """Cancel the workflow of a run which ended or was deleted meanwhile."""
287
+ with self.controller.lock:
288
+ try:
289
+ current: RunRecord | None = self.store.read(record.id)
290
+ except KeyError:
291
+ current = None
292
+ if current is not None and current.state in TERMINAL_RUN_STATES:
293
+ self.store.write(current.model_copy(update=_undo(current, record)))
294
+ if current is None:
295
+ self._cancel_orphan(record)
296
+ else:
297
+ self.controller.settle(record.id)
298
+
299
+ def _submitted(self, record: RunRecord) -> None:
300
+ """Queue a submitted run, or cancel its workflow when it ended meanwhile."""
301
+ ended = False
302
+ with self.controller.lock:
303
+ try:
304
+ current: RunRecord | None = self.store.read(record.id)
305
+ except KeyError:
306
+ current = None
307
+ if current is not None:
308
+ ended = current.state in TERMINAL_RUN_STATES
309
+ if ended: # cancelled while it was submitted: cancel the workflow
310
+ current = current.model_copy(update=_undo(current, record))
311
+ else:
312
+ current = current.model_copy(update={"state": RunState.QUEUED})
313
+ self.store.write(current)
314
+ if current is None:
315
+ self._cancel_orphan(record)
316
+ return
317
+ if ended:
318
+ self.controller.settle(record.id)
319
+ logger.info("run %s submitted by %s", record.id, record.submitted_by)
320
+
321
+ def _cancel_orphan(self, record: RunRecord) -> None:
322
+ """Undo the submission of a run cancelled and deleted while it was submitted.
323
+
324
+ Its workflow is cancelled and its node released (the job may have reserved
325
+ it after the run was released and deleted); no record is left to retry
326
+ either, so both are best effort and logged.
327
+ """
328
+ name = record.backend_name
329
+ logger.warning(
330
+ "run %s was cancelled and deleted while it was submitted", record.id
331
+ )
332
+ if name is not None:
333
+ try:
334
+ self.backend.cancel(name)
335
+ except KeyError:
336
+ pass
337
+ except Exception:
338
+ logger.exception("cancelling the orphan workflow %s failed", name)
339
+ if record.reserve is not None:
340
+ try:
341
+ self.backend.unreserve(record.reserve, record.id)
342
+ except KeyError:
343
+ pass
344
+ except Exception:
345
+ logger.exception(
346
+ "releasing node %s of the orphan run %s failed",
347
+ record.reserve,
348
+ record.id,
349
+ )
simrun/snapshot.py ADDED
@@ -0,0 +1,196 @@
1
+ """Snapshots of working trees with provenance.
2
+
3
+ A snapshot is a copy of the files git knows of a working tree (tracked and
4
+ untracked, not ignored, uncommitted changes included) in the run store. Files
5
+ unchanged since the latest snapshot of the same name are hard links to it. The files
6
+ of a snapshot are read-only (`a-w`, part of the hash of its content): a snapshot is
7
+ named by its content and never changes, and a build backend which writes through a
8
+ link of a shadow tree into one fails loudly instead.
9
+ Submodules are out of scope: their content is not copied.
10
+ """
11
+
12
+ import hashlib
13
+ import logging
14
+ import os
15
+ import secrets
16
+ import shutil
17
+ import stat
18
+ import subprocess
19
+ import tempfile
20
+ from pathlib import Path
21
+
22
+ from pydantic import BaseModel
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class SourceProvenance(BaseModel):
28
+ """Where the files of a snapshot come from.
29
+
30
+ Attributes:
31
+ name: Name of the source.
32
+ branch: Branch of the working tree, `None` for a detached HEAD.
33
+ commit: Commit of HEAD.
34
+ dirty: Whether the working tree differs from HEAD.
35
+ changed_files: Porcelain status lines, e.g. `M src/a.py`.
36
+ diff_sha256: Hash of the snapshot tree (paths, types, modes, contents);
37
+ the patch stays as provenance.
38
+ snapshot: Directory of the snapshot, relative to the store root.
39
+ patch: Stored `git diff HEAD --binary`, relative to the store root.
40
+ """
41
+
42
+ name: str
43
+ branch: str | None
44
+ commit: str
45
+ dirty: bool
46
+ changed_files: list[str]
47
+ diff_sha256: str
48
+ snapshot: str
49
+ patch: str | None
50
+
51
+
52
+ def _run(cmd: list[str], input: bytes | None = None, ok: tuple[int, ...] = ()) -> bytes:
53
+ result = subprocess.run(cmd, input=input, capture_output=True)
54
+ if result.returncode == 23 and _only_missing_files(result.stderr):
55
+ return result.stdout # a file vanished between the listing and the copy
56
+ if result.returncode != 0 and result.returncode not in ok:
57
+ raise RuntimeError(
58
+ f"{cmd[0]} failed with exit code {result.returncode}: "
59
+ f"{result.stderr.decode(errors='replace').strip()}"
60
+ )
61
+ return result.stdout
62
+
63
+
64
+ def _only_missing_files(stderr: bytes) -> bool:
65
+ """Whether every error of rsync (exit 23) is a source file which is gone."""
66
+ errors = [
67
+ line
68
+ for line in stderr.decode(errors="replace").splitlines()
69
+ if line.startswith("rsync:")
70
+ ]
71
+ return bool(errors) and all(
72
+ "link_stat" in line and "No such file" in line for line in errors
73
+ )
74
+
75
+
76
+ def _git(repo: Path, *args: str) -> bytes:
77
+ return _run(["git", "-C", str(repo), *args])
78
+
79
+
80
+ def _tree_hash(root: Path) -> str:
81
+ """Hash the entries of a tree: path, type, mode bits, content or target."""
82
+ digest = hashlib.sha256()
83
+ for dirpath, dirnames, filenames in os.walk(root):
84
+ dirnames.sort()
85
+ # a symlink to a directory is listed in dirnames and not descended into
86
+ links = [d for d in dirnames if (Path(dirpath) / d).is_symlink()]
87
+ for fname in sorted(filenames + links):
88
+ path = Path(dirpath) / fname
89
+ st = path.lstat()
90
+ kind = b"l" if stat.S_ISLNK(st.st_mode) else b"f"
91
+ content = (
92
+ os.fsencode(os.readlink(path)) if kind == b"l" else path.read_bytes()
93
+ )
94
+ digest.update(os.fsencode(str(path.relative_to(root))) + b"\0")
95
+ digest.update(kind + b"%o" % stat.S_IMODE(st.st_mode) + b"\0")
96
+ digest.update(b"%d\0" % len(content) + content)
97
+ return digest.hexdigest()
98
+
99
+
100
+ def _split(data: bytes) -> list[str]:
101
+ return [os.fsdecode(p) for p in data.split(b"\0") if p]
102
+
103
+
104
+ def _write_atomic(path: Path, data: bytes, mode: int | None = None) -> None:
105
+ tmp = path.with_name(f".tmp-{secrets.token_hex(6)}-{path.name}")
106
+ tmp.write_bytes(data)
107
+ if mode is not None:
108
+ tmp.chmod(mode)
109
+ os.replace(tmp, path)
110
+
111
+
112
+ def snapshot(repo: Path, store: Path, name: str) -> SourceProvenance:
113
+ """Copy a working tree into `store/snapshots/<name>`.
114
+
115
+ Args:
116
+ repo: A git working tree (a worktree with a `.git` file works too).
117
+ store: Root of the run store.
118
+ name: Name of the source.
119
+
120
+ Returns:
121
+ The provenance of the snapshot.
122
+
123
+ Raises:
124
+ RuntimeError: When rsync is not installed.
125
+ """
126
+ if shutil.which("rsync") is None:
127
+ raise RuntimeError("rsync is required to snapshot a working tree")
128
+ commit = _git(repo, "rev-parse", "HEAD").decode().strip()
129
+ branch = _git(repo, "branch", "--show-current").decode().strip() or None
130
+ status = _git(repo, "status", "--porcelain", "--untracked-files=all")
131
+ changed = [line for line in status.decode().splitlines() if line]
132
+ patch = _git(repo, "diff", "HEAD", "--binary")
133
+ listed = _split(
134
+ _git(repo, "ls-files", "-z", "--cached", "--others", "--exclude-standard")
135
+ )
136
+ dirty = bool(changed)
137
+
138
+ # skip deleted files and the directories of submodules
139
+ files = sorted(
140
+ {
141
+ f
142
+ for f in listed
143
+ if os.path.lexists(repo / f)
144
+ and (os.path.islink(repo / f) or not os.path.isdir(repo / f))
145
+ }
146
+ )
147
+
148
+ parent = store / "snapshots" / name
149
+ parent.mkdir(parents=True, exist_ok=True)
150
+ latest = parent / "latest"
151
+ tmp = Path(tempfile.mkdtemp(prefix=".tmp-", dir=parent))
152
+ try:
153
+ # --checksum: link only files of identical content; exit 24 (a file
154
+ # vanished before the copy) leaves it out of the snapshot and its hash
155
+ # --chmod: the files read-only, also the ones linked to the latest snapshot,
156
+ # whose modes then match
157
+ cmd = ["rsync", "-a", "--checksum", "--chmod=F-w", "--files-from=-"]
158
+ cmd.append("--from0")
159
+ if latest.is_dir():
160
+ cmd.append(f"--link-dest={latest.resolve()}")
161
+ cmd += [f"{repo}/", f"{tmp}/"]
162
+ _run(cmd, input=b"".join(os.fsencode(f) + b"\0" for f in files), ok=(24,))
163
+ diff_sha256 = _tree_hash(tmp)
164
+ dirname = f"{commit[:12]}-{diff_sha256[:12]}"
165
+ final = parent / dirname
166
+ tmp.chmod(0o755)
167
+ if not final.is_dir():
168
+ try:
169
+ os.rename(tmp, final)
170
+ except OSError:
171
+ if not final.is_dir():
172
+ raise
173
+ logger.info("snapshot %s was created concurrently", final)
174
+ finally:
175
+ shutil.rmtree(tmp, ignore_errors=True)
176
+
177
+ patch_rel: str | None = None
178
+ if dirty:
179
+ patch_path = parent / f"{dirname}.patch"
180
+ if not patch_path.exists():
181
+ _write_atomic(patch_path, patch, mode=0o444)
182
+ patch_rel = str(patch_path.relative_to(store))
183
+
184
+ link = parent / f".tmp-link-{secrets.token_hex(6)}"
185
+ link.symlink_to(dirname)
186
+ os.replace(link, latest)
187
+ return SourceProvenance(
188
+ name=name,
189
+ branch=branch,
190
+ commit=commit,
191
+ dirty=dirty,
192
+ changed_files=changed,
193
+ diff_sha256=diff_sha256,
194
+ snapshot=str(final.relative_to(store)),
195
+ patch=patch_rel,
196
+ )