PICurv 0.1.0
A Parallel Particle-In-Cell Solver for Curvilinear LES
 
Loading...
Searching...
No Matches
inventory.py
Go to the documentation of this file.
1"""!
2@file inventory.py
3@brief Discovering what an artifact contains and which target was selected.
4"""
5
6import argparse
7import base64
8import concurrent.futures
9import contextlib
10import datetime
11import errno
12import hashlib
13import json
14import os
15import re
16import shutil
17import socket
18import subprocess
19import sys
20import tarfile
21import tempfile
22import uuid
23from pathlib import Path
24import yaml
25from .models import (
26 CHECKPOINT_DIRECTORY_PATTERN,
27 INCOMPLETE_CHECKPOINT_PATTERN,
28 STORAGE_LOCK_FILENAME,
29 STORAGE_STATE_FILENAME,
30 StorageError,
31 UNCLASSIFIED_COMPONENT,
32 WORKSPACE_CONFIG_FILENAME,
33 read_artifact_identity,
34 RUN_MANIFEST_FILENAME,
35 STUDY_MANIFEST_FILENAME,
36 WORKSPACE_EXCLUDED_ROOTS,
37 storage_state_summary,
38)
39from .safety import (
40 _assert_archive_safe,
41 _lock_owner_active,
42 _slurm_activity,
43)
44
45
46def _completed_study_members(study_root: str) -> list:
47 """!
48 @brief Select the study members that are finished and idle.
49
50 @details "Finished" is deliberately conservative: a member qualifies only when it
51 holds at least one committed checkpoint, is writing none, and has no
52 active lock or scheduler job. Anything ambiguous is skipped rather than
53 archived mid-flight.
54 @param[in] study_root Study directory to scan.
55 @return Sorted member ids.
56 """
57 cases_dir = os.path.join(study_root, "cases")
58 study_identity = read_artifact_identity(study_root)
59 selected = []
60 for name in sorted(os.listdir(cases_dir)):
61 member = os.path.join(cases_dir, name)
62 if not os.path.isdir(member):
63 continue
64 # A member is a member because it carries a run manifest, not because its
65 # directory is named `case_NNNN`. The name pattern is the fallback for a
66 # member staged before manifests recorded study membership.
67 member_identity = read_artifact_identity(member)
68 if member_identity["identity_source"] != "manifest" and not re.fullmatch(r"case_\d+", name):
69 continue
70 target = {
71 "artifact_type": "study-case", "root_path": member, "original_path": member,
72 "run_id": member_identity["run_id"] or name,
73 "study_id": study_identity["study_id"],
74 "case_id": member_identity["case_id"] or name,
75 "study_path": study_root,
76 "identity_source": member_identity["identity_source"],
77 }
78 try:
79 inventory = inspect_artifact(target)
80 _assert_archive_safe(inventory)
81 except StorageError:
82 continue
83 if inventory["checkpoint_steps"]:
84 selected.append(name)
85 return selected
86
87
88def _select_completed_case_ids(study_root: str) -> list:
89 """!
90 @brief Resolve `--completed` into member ids, raising if none qualify.
91 @param[in] study_root Study directory to scan.
92 @return Sorted member ids; never empty.
93 """
94 case_ids = _completed_study_members(study_root)
95 if not case_ids:
96 raise StorageError(
97 f"No completed member found in {study_root}. A member is complete when it "
98 "holds a committed checkpoint and no run is active on it."
99 )
100 print("[INFO] Selected completed member(s): " + ", ".join(case_ids))
101 return case_ids
102
103
104def _validate_case_id(case_id: str) -> str:
105 """!
106 @brief Validate a canonical numbered study-member identifier.
107
108 @details This checks a value the user typed before it becomes a path segment, so it
109 stays a pattern match on purpose: it is what stops `--case-id ../..` from
110 resolving outside the study. What a member *is* is still decided by its
111 manifest, not by this.
112 @param[in] case_id Value supplied through the `case_id` argument.
113 @return Result produced by this operation.
114 """
115 if not re.fullmatch(r"case_\d+", str(case_id or "")):
116 raise StorageError(f"Invalid study case ID {case_id!r}; expected a value such as case_0003.")
117 return str(case_id)
118
119
120def resolve_local_storage_targets(run_dir: str = None, study_dir: str = None, case_ids=None,
121 workspace: str = None, include_inputs: bool = False,
122 completed: bool = False) -> list:
123 """!
124 @brief Resolve explicit run/study/workspace selectors into artifact descriptions.
125 @param[in] run_dir Value supplied through the `run_dir` argument.
126 @param[in] study_dir Value supplied through the `study_dir` argument.
127 @param[in] case_ids Value supplied through the `case_ids` argument.
128 @param[in] workspace Workspace root to protect as its own artifact.
129 @param[in] include_inputs Whether workspace protection covers user-supplied inputs.
130 @param[in] completed Select every finished study member instead of naming them.
131 @return Result produced by this operation.
132 """
133 selectors = [bool(run_dir), bool(study_dir), bool(workspace)]
134 if sum(selectors) != 1:
135 raise StorageError("Select exactly one of --run-dir, --study-dir, or --workspace.")
136 if include_inputs and not workspace:
137 raise StorageError("--include-inputs is valid only with --workspace.")
138 if workspace:
139 if case_ids:
140 raise StorageError("--case-id is valid only with --study-dir.")
141 root = os.path.abspath(workspace)
142 if not os.path.isfile(os.path.join(root, WORKSPACE_CONFIG_FILENAME)):
143 raise StorageError(
144 f"Directory is not an initialized PICurv workspace (missing "
145 f"{WORKSPACE_CONFIG_FILENAME}): {root}"
146 )
147 identity = {}
148 try:
149 with open(os.path.join(root, WORKSPACE_CONFIG_FILENAME), "r", encoding="utf-8") as stream:
150 identity = (yaml.safe_load(stream) or {}).get("workspace") or {}
151 except (OSError, ValueError):
152 identity = {}
153 return [{
154 "artifact_type": "workspace",
155 "root_path": root,
156 "original_path": root,
157 "run_id": None,
158 "study_id": None,
159 "case_id": None,
160 "workspace_id": identity.get("id") or os.path.basename(root),
161 "include_inputs": bool(include_inputs),
162 }]
163 if run_dir:
164 if case_ids:
165 raise StorageError("--case-id is valid only with --study-dir.")
166 root = os.path.abspath(run_dir)
167 if not os.path.isdir(root):
168 raise StorageError(f"Run directory not found: {root}")
169 # The run's own manifest says what it is and what it is called. A directory
170 # with no manifest is still accepted - runs staged by an older release have
171 # none - but it must at least carry the config snapshot every run writes, and
172 # the fallback identity is recorded rather than passed off as the real one.
173 identity = read_artifact_identity(root)
174 if identity["identity_source"] != "manifest" and not os.path.isdir(
175 os.path.join(root, "config")):
176 raise StorageError(
177 f"Directory does not look like a PICurv run (no {RUN_MANIFEST_FILENAME} "
178 f"and no config/): {root}"
179 )
180 if identity["artifact_type"] == "study":
181 raise StorageError(f"That is a study, not a run; use --study-dir: {root}")
182 return [{
183 "artifact_type": "run",
184 "root_path": root,
185 "original_path": root,
186 "run_id": identity["run_id"],
187 "study_id": identity["study_id"] if identity["case_id"] else None,
188 "case_id": identity["case_id"],
189 "identity_source": identity["identity_source"],
190 }]
191
192 study_root = os.path.abspath(study_dir)
193 if not os.path.isdir(study_root):
194 raise StorageError(f"Study directory not found: {study_root}")
195 study_identity = read_artifact_identity(study_root)
196 if study_identity["identity_source"] != "manifest" and not os.path.isdir(
197 os.path.join(study_root, "cases")):
198 raise StorageError(
199 f"Directory does not look like a PICurv study (no {STUDY_MANIFEST_FILENAME} "
200 f"and no cases/): {study_root}"
201 )
202 if not os.path.isdir(os.path.join(study_root, "cases")):
203 raise StorageError(f"Study has no cases/ directory: {study_root}")
204 if completed:
205 if case_ids:
206 raise StorageError("--completed selects members itself; do not also pass --case-id.")
207 case_ids = _select_completed_case_ids(study_root)
208 if case_ids:
209 targets = []
210 for raw_case_id in case_ids:
211 case_id = _validate_case_id(raw_case_id)
212 case_root = os.path.join(study_root, "cases", case_id)
213 if not os.path.isdir(case_root):
214 raise StorageError(f"Study member not found: {case_root}")
215 member_identity = read_artifact_identity(case_root)
216 targets.append({
217 "artifact_type": "study-case",
218 "root_path": case_root,
219 "original_path": case_root,
220 "run_id": member_identity["run_id"] or case_id,
221 "study_id": study_identity["study_id"],
222 "case_id": member_identity["case_id"] or case_id,
223 "study_path": study_root,
224 "identity_source": member_identity["identity_source"],
225 })
226 return targets
227 return [{
228 "artifact_type": "study",
229 "root_path": study_root,
230 "original_path": study_root,
231 "run_id": None,
232 "study_id": study_identity["study_id"],
233 "case_id": None,
234 "identity_source": study_identity["identity_source"],
235 }]
236
237
238def _path_is_within(root: str, candidate: str) -> bool:
239 """!
240 @brief Return whether an absolute candidate remains within a root directory.
241 @param[in] root Value supplied through the `root` argument.
242 @param[in] candidate Value supplied through the `candidate` argument.
243 @return Result produced by this operation.
244 """
245 try:
246 return os.path.commonpath([os.path.abspath(root), os.path.abspath(candidate)]) == os.path.abspath(root)
247 except ValueError:
248 return False
249
250
251def _resolve_configured_path(root: str, value: str) -> str:
252 """!
253 @brief Resolve runtime-directory syntax using the run directory as working directory.
254 @param[in] root Value supplied through the `root` argument.
255 @param[in] value Value supplied through the `value` argument.
256 @return Result produced by this operation.
257 """
258 return os.path.abspath(value if os.path.isabs(value) else os.path.join(root, value))
259
260
261def _artifact_runtime_roots(root: str) -> list:
262 """!
263 @brief Return run-like roots contained by a standalone run or whole study.
264 @param[in] root Value supplied through the `root` argument.
265 @return Result produced by this operation.
266 """
267 root_path = Path(os.path.abspath(root))
268 case_root = root_path / "cases"
269 if case_root.is_dir():
270 return [
271 str(path) for path in sorted(case_root.glob("case_*"))
272 if path.is_dir()
273 ]
274 return [str(root_path)]
275
276
277def _discover_external_paths(root: str) -> list:
278 """!
279 @brief Report explicit external-reference descriptors inside an artifact.
280 @param[in] root Value supplied through the `root` argument.
281 @return Result produced by this operation.
282 """
283 external = []
284 archive_root = os.path.abspath(root)
285 for descriptor in Path(archive_root).glob("**/*.reference.yml"):
286 try:
287 with descriptor.open("r", encoding="utf-8") as stream:
288 payload = yaml.safe_load(stream) or {}
289 target = payload.get("picurv_external_reference")
290 if isinstance(target, str) and os.path.isabs(target):
291 external.append({
292 "source": os.path.relpath(descriptor, archive_root).replace(os.sep, "/"),
293 "path": os.path.abspath(target),
294 })
295 except (OSError, ValueError, TypeError):
296 pass
297 return sorted(external, key=lambda item: (item["source"], item["path"]))
298
299
300def _discover_dependencies(root: str) -> list:
301 """!
302 @brief Discover absolute restart/source paths embedded in generated controls.
303 @param[in] root Value supplied through the `root` argument.
304 @return Result produced by this operation.
305 """
306 dependencies = []
307 archive_root = os.path.abspath(root)
308 controls = []
309 for runtime_root in _artifact_runtime_roots(root):
310 controls.extend(sorted(Path(runtime_root).glob("config/*.control")))
311 for control in controls:
312 try:
313 lines = control.read_text(encoding="utf-8", errors="replace").splitlines()
314 except OSError:
315 continue
316 for line in lines:
317 stripped = line.strip()
318 if not stripped.startswith("-restart_dir "):
319 continue
320 try:
321 tokens = __import__("shlex").split(stripped)
322 except ValueError:
323 continue
324 if len(tokens) >= 2 and os.path.isabs(tokens[1]) and not _path_is_within(archive_root, tokens[1]):
325 dependencies.append({"kind": "restart", "path": os.path.abspath(tokens[1])})
326 return dependencies
327
328
329def _walk_archive_entries(root: str, excluded_roots=()) -> list:
330 """!
331 @brief Enumerate archive entries without following symlinks.
332 @param[in] root Value supplied through the `root` argument.
333 @param[in] excluded_roots Top-level directory names to skip entirely.
334 @return Result produced by this operation.
335 """
336 root_abs = os.path.abspath(root)
337 excluded = set(excluded_roots or ())
338 entries = []
339
340 def visit(directory: str):
341 """!
342 @brief Recursively inventory directory entries without following symlinks.
343 @param[in] directory Value supplied through the `directory` argument.
344 """
345 try:
346 children = sorted(os.scandir(directory), key=lambda item: item.name)
347 except OSError as exc:
348 raise StorageError(f"Unable to inventory {directory}: {exc}") from exc
349 for child in children:
350 if child.name in {STORAGE_STATE_FILENAME, STORAGE_LOCK_FILENAME}:
351 continue
352 rel = os.path.relpath(child.path, root_abs).replace(os.sep, "/")
353 if rel in excluded:
354 continue
355 try:
356 stat_result = child.stat(follow_symlinks=False)
357 except OSError as exc:
358 raise StorageError(f"Unable to stat {child.path}: {exc}") from exc
359 if child.is_symlink():
360 entry_type = "symlink"
361 size = 0
362 elif child.is_dir(follow_symlinks=False):
363 entry_type = "directory"
364 size = 0
365 elif child.is_file(follow_symlinks=False):
366 entry_type = "file"
367 size = int(stat_result.st_size)
368 else:
369 raise StorageError(f"Unsupported filesystem entry in artifact: {child.path}")
370 entries.append({
371 "path": rel,
372 "type": entry_type,
373 "size": size,
374 "mode": int(stat_result.st_mode & 0o7777),
375 "mtime_ns": int(stat_result.st_mtime_ns),
376 })
377 if entry_type == "directory":
378 visit(child.path)
379
380 visit(root_abs)
381 return entries
382
383
384#: Component paths assumed when an artifact carries no manifest of its own: a run
385#: staged by an older release, or a directory restored without its identity. These are
386#: the fixed workspace topology the conductor writes, kept here so a manifest-less
387#: artifact still classifies rather than becoming wholly "unclassified" and unprunable.
388FALLBACK_COMPONENT_PATHS = {
389 "config": "config",
390 "scheduler": "scheduler",
391 "inputs": "inputs",
392 "output": "output",
393 "checkpoints": "output/checkpoints",
394 "analysis": "output/analysis",
395 "visualization": "output/visualization",
396 "logs": "logs",
397}
398
399
400def _artifact_component_layout(root: str, artifact_type: str) -> dict:
401 """!
402 @brief Read from the artifact's own manifests where each component lives.
403
404 @details Storage classifies what it packages by asking the run what its directories
405 are, not by assuming the topology from path prefixes. A run records its
406 component map in `manifest.json` under `paths`, and a study's members each
407 record their own, so a renamed, restored, or re-rooted artifact classifies
408 the same way it did where it was written.
409
410 Members are found by the manifest each one carries, not by matching a
411 `case_NNNN` name. When a manifest is missing the fixed workspace topology
412 is used and reported through `identity_source`, so a caller can tell an
413 answer from a fallback.
414 @param[in] root Absolute artifact root.
415 @param[in] artifact_type "run", "study", "study-case", or "workspace".
416 @return Mapping with `members` (member-prefix to component-path map) and
417 `identity_source`.
418 """
419 root_abs = os.path.abspath(root)
420
421 def component_paths(member_root: str) -> tuple:
422 """!
423 @brief Return one artifact's declared component map and where it came from.
424 @param[in] member_root Absolute run or study-member root.
425 @return Tuple of (component path mapping, identity source).
426 """
427 identity = read_artifact_identity(member_root)
428 declared = identity.get("paths") or {}
429 # A study manifest's `paths` names its scripts and tables, not the run-relative
430 # component tree, so it is not a component map and must not be read as one.
431 usable = {
432 key: str(value).strip("/")
433 for key, value in declared.items()
434 if isinstance(value, str) and value and not os.path.isabs(value)
435 }
436 if identity["identity_source"] == "manifest" and usable:
437 return usable, "manifest"
438 return dict(FALLBACK_COMPONENT_PATHS), "fixed-topology"
439
440 members = {}
441 sources = set()
442 if artifact_type == "study":
443 cases_root = os.path.join(root_abs, "cases")
444 if os.path.isdir(cases_root):
445 for name in sorted(os.listdir(cases_root)):
446 member_root = os.path.join(cases_root, name)
447 if not os.path.isdir(member_root):
448 continue
449 paths, source = component_paths(member_root)
450 members[f"cases/{name}"] = paths
451 sources.add(source)
452 paths, source = component_paths(root_abs)
453 members[""] = paths
454 sources.add(source)
455 return {
456 "root": root_abs,
457 "members": members,
458 "identity_source": "manifest" if sources == {"manifest"} else "mixed"
459 if "manifest" in sources else "fixed-topology",
460 }
461
462
463def _checkpoint_step_from_bundle(bundle_root: str):
464 """!
465 @brief Read a committed checkpoint's own recorded step number.
466
467 @details The bundle writes `-checkpoint_step` into `checkpoint.meta`, which is what
468 the step *is*; the directory name is a rendering of it. Reading the record
469 keeps the classification on the same footing as the rest of this module,
470 and stays correct for a bundle restored under a different name.
471 @param[in] bundle_root Absolute path to one checkpoint bundle directory.
472 @return The recorded step as an int, or None when it cannot be read.
473 """
474 try:
475 with open(os.path.join(bundle_root, "checkpoint.meta"), "r", encoding="utf-8") as stream:
476 for line in stream:
477 tokens = line.split("#", 1)[0].split()
478 if len(tokens) == 2 and tokens[0] == "-checkpoint_step":
479 return int(tokens[1])
480 except (OSError, ValueError):
481 return None
482 return None
483
484
485def _classify_workspace_component(relative_path: str) -> str:
486 """!
487 @brief Classify one workspace-owned path for packaging and retention.
488
489 @details A workspace has no manifest of components the way a run does: its shape is
490 the fixed layout `picurv init` writes, and `.picurv-workspace.yml` records
491 identity rather than topology. So this stays a layout decision, and the
492 three named roots are the ones the workspace contract defines.
493 @param[in] relative_path Workspace-relative path.
494 @return Component name.
495 """
496 first = relative_path.split("/")[0]
497 if first == "config" or relative_path.startswith("."):
498 return "workspace-config"
499 if first == "inputs":
500 return "workspace-inputs"
501 if first == "assets":
502 return "assets"
503 return UNCLASSIFIED_COMPONENT
504
505
506def _classify_component(relative_path: str, layout: dict = None) -> str:
507 """!
508 @brief Classify one artifact path for packaging and local retention.
509 @param[in] relative_path Artifact-relative path of the entry.
510 @param[in] layout Component layout from `_artifact_component_layout()`. When absent
511 the fixed workspace topology is assumed.
512 @return Component name, or a `checkpoint:<step>` token.
513 """
514 layout = layout or {"root": "", "members": {"": dict(FALLBACK_COMPONENT_PATHS)}}
515 members = layout["members"]
516 # Longest member prefix wins, so a study member's own map beats the study's.
517 prefix = ""
518 for candidate in members:
519 if not candidate:
520 continue
521 if relative_path == candidate or relative_path.startswith(candidate + "/"):
522 if len(candidate) > len(prefix):
523 prefix = candidate
524 paths = members.get(prefix) or dict(FALLBACK_COMPONENT_PATHS)
525 local = relative_path[len(prefix):].lstrip("/") if prefix else relative_path
526 base = os.path.basename(relative_path)
527
528 def under(component_key: str) -> bool:
529 """!
530 @brief Whether the entry lies at or below one declared component path.
531 @param[in] component_key Component name in the artifact's own path map.
532 @return True when the entry belongs to that component.
533 """
534 declared = paths.get(component_key)
535 if not declared:
536 return False
537 return local == declared or local.startswith(declared + "/")
538
539 # The locks record which assets and which executables a run consumed. They are the
540 # run's provenance, not its payload: pruning them would leave a cold run unable to
541 # say what it was built from, and reference-aware asset pruning unable to see it.
542 if base in {
543 "manifest.json", "study_manifest.json", "study.yml", "cluster.yml",
544 "assets.lock.yml", "software.lock.json",
545 }:
546 return "metadata"
547 if under("config") or under("scheduler"):
548 return "metadata"
549 if under("checkpoints"):
550 declared = paths["checkpoints"]
551 remainder = local[len(declared):].lstrip("/")
552 bundle = remainder.split("/", 1)[0]
553 if bundle:
554 step = _checkpoint_step_from_bundle(
555 os.path.join(layout["root"], *(prefix.split("/") if prefix else []),
556 *declared.split("/"), bundle)
557 )
558 if step is None:
559 # The bundle's own record is the authority, but a checkpoint whose
560 # metadata cannot be read must not thereby stop being a checkpoint:
561 # that would move it into a component an ordinary policy prunes. Fall
562 # back to the name it was written under.
563 name_match = CHECKPOINT_DIRECTORY_PATTERN.fullmatch(bundle)
564 if name_match:
565 step = int(name_match.group(1))
566 if step is not None:
567 return f"checkpoint:{step}"
568 # Neither the record nor the name identifies a step. Leave it unclassified,
569 # which is archived and never pruned, rather than guessing.
570 return UNCLASSIFIED_COMPONENT
571 return "raw-output"
572 if under("analysis"):
573 return "analysis"
574 if under("visualization"):
575 return "visualization"
576 if under("output"):
577 return "raw-output"
578 if under("inputs"):
579 return "inputs"
580 if under("logs"):
581 return "logs"
582 if local.split("/")[0] == "assets":
583 return "assets"
584 if local.split("/")[0] == "results":
585 return "analysis"
586 # Nothing recognized this path. It is archived like everything else, but it is
587 # never pruned: storage must not delete a file whose purpose it cannot state.
588 return UNCLASSIFIED_COMPONENT
589
590
591def _checkpoint_steps(entries: list) -> list:
592 """!
593 @brief Return committed checkpoint steps represented in an inventory.
594 @param[in] entries Value supplied through the `entries` argument.
595 @return Result produced by this operation.
596 """
597 candidates = {}
598 for entry in entries:
599 component = entry.get("component") or ""
600 if component.startswith("checkpoint:"):
601 step = int(component.split(":", 1)[1])
602 candidates.setdefault(step, set()).add(os.path.basename(entry["path"]))
603 return sorted(step for step, names in candidates.items() if {"checkpoint.meta", "COMMITTED"} <= names)
604
605
606def _find_incomplete_checkpoints(entries: list) -> list:
607 """!
608 @brief Return incomplete checkpoint paths that make archival unsafe.
609 @param[in] entries Value supplied through the `entries` argument.
610 @return Result produced by this operation.
611 """
612 return [
613 entry["path"] for entry in entries
614 if any(INCOMPLETE_CHECKPOINT_PATTERN.match(part) for part in entry["path"].split("/"))
615 ]
616
617
618def inspect_artifact(target: dict, query_scheduler: bool = True) -> dict:
619 """!
620 @brief Build a read-only inventory and lifecycle assessment for one artifact.
621 @param[in] target Value supplied through the `target` argument.
622 @param[in] query_scheduler Value supplied through the `query_scheduler` argument.
623 @return Result produced by this operation.
624 """
625 root = target["root_path"]
626 workspace = target["artifact_type"] == "workspace"
627 entries = _walk_archive_entries(
628 root, WORKSPACE_EXCLUDED_ROOTS if workspace else ()
629 )
630 layout = None if workspace else _artifact_component_layout(root, target["artifact_type"])
631 for entry in entries:
632 entry["component"] = (
633 _classify_workspace_component(entry["path"]) if workspace
634 else _classify_component(entry["path"], layout)
635 )
636 if workspace and not target.get("include_inputs"):
637 # User-supplied data is theirs. It is archived only on request, because a
638 # workspace protect is about the configuration and catalog, not about taking
639 # custody of possibly enormous imported fields.
640 entries = [
641 entry for entry in entries
642 if entry["component"] != "workspace-inputs" or entry["type"] == "directory"
643 ]
644 lock_paths = []
645 for name in ("post.lock.json", "solver.lock.json"):
646 for candidate in Path(root).glob(f"**/scheduler/{name}"):
647 if _lock_owner_active(str(candidate)):
648 lock_paths.append(os.path.relpath(candidate, root))
649 slurm = _slurm_activity(root) if query_scheduler else {"job_ids": [], "active": [], "unknown": False}
650 state = storage_state_summary(root)
651 return {
652 "target": dict(target),
653 "component_layout_source": layout["identity_source"] if layout else "workspace-contract",
654 "entries": entries,
655 "file_count": sum(entry["type"] in {"file", "symlink"} for entry in entries),
656 "total_bytes": sum(entry["size"] for entry in entries),
657 "checkpoint_steps": _checkpoint_steps(entries),
658 "incomplete_checkpoints": _find_incomplete_checkpoints(entries),
659 "active_locks": sorted(lock_paths),
660 "slurm": slurm,
661 "external_paths": _discover_external_paths(root),
662 "dependencies": _discover_dependencies(root),
663 "storage": state,
664 }
665
666
667def _inventory_fingerprint(inventory: dict) -> str:
668 """!
669 @brief Stable digest of the file set an archive would package.
670
671 @details Built from each entry's run-relative path, size, and modification time -
672 the same signals ordinary change detection uses - so it can be computed
673 without reading file contents. Two archives of the same artifact agree on
674 it exactly when nothing has been written since, which is what makes a
675 re-upload detectably redundant.
676 @param[in] inventory Inventory returned by `inspect_artifact()`.
677 @return Hex digest over the normalized entry list.
678 """
679 digest = hashlib.sha256()
680 for entry in sorted(inventory["entries"], key=lambda item: item["path"]):
681 digest.update(
682 f"{entry['path']}\0{entry.get('kind')}\0{entry.get('bytes')}\0"
683 f"{entry.get('mtime_ns')}\n".encode("utf-8")
684 )
685 return digest.hexdigest()
User-facing storage workflow failure.
Definition models.py:139