@structupath/pi-steel 0.2.2 → 0.2.3
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.
- package/DATA_PROVENANCE.json +23 -0
- package/DATA_PROVENANCE.md +35 -0
- package/PUBLIC_DATA_POLICY.md +52 -0
- package/README.md +114 -33
- package/docs/assets/pi-steel-demo.gif +0 -0
- package/docs/assets/pi-steel-gallery.webp +0 -0
- package/package.json +35 -4
- package/pyproject.toml +28 -0
- package/requirements-dev.txt +5 -0
- package/requirements-render.txt +1 -0
- package/requirements-tested.txt +9 -0
- package/requirements.txt +7 -0
- package/scripts/check-data-provenance.py +140 -0
- package/scripts/check-public-data.py +401 -0
- package/scripts/doctor.py +127 -0
- package/skills/_shared/bootstrap.py +32 -0
- package/skills/_shared/pi_steel/__init__.py +47 -0
- package/skills/_shared/pi_steel/cli.py +167 -0
- package/skills/_shared/pi_steel/contracts.py +87 -0
- package/skills/_shared/pi_steel/geometry_verify.py +243 -0
- package/skills/_shared/pi_steel/parsing.py +205 -0
- package/skills/_shared/pi_steel/run_manifest.py +317 -0
- package/skills/_shared/pi_steel/validation.py +597 -0
- package/skills/_shared/schemas/estimate-package.schema.json +424 -0
- package/skills/_shared/schemas/nest-result.schema.json +443 -0
- package/skills/_shared/schemas/run-manifest.schema.json +145 -0
- package/skills/steel-estimate/SKILL.md +119 -0
- package/skills/steel-estimate/references/estimate-package-example.json +91 -0
- package/skills/steel-estimate/references/output-contract.md +47 -0
- package/skills/steel-estimate/scripts/acknowledge-finding.py +193 -0
- package/skills/steel-estimate/scripts/build-estimate-package.py +836 -0
- package/skills/steel-nest/SKILL.md +50 -23
- package/skills/steel-nest/references/FIXTURE_PROVENANCE.md +12 -0
- package/skills/steel-nest/references/example_job.json +21 -25
- package/skills/steel-nest/references/job_template.json +11 -9
- package/skills/steel-nest/scripts/nest.py +1276 -250
- package/skills/steel-rfq/SKILL.md +93 -175
- package/skills/steel-rfq/assets/company-profile.example.json +11 -5
- package/skills/steel-rfq/references/rfq-input.md +58 -0
- package/skills/steel-rfq/scripts/generate-rfq.py +1221 -0
- package/skills/steel-rfq/scripts/recalc.py +33 -10
- package/skills/steel-takeoff/SKILL.md +12 -8
- package/skills/steel-takeoff/assets/bom-template.csv +1 -1
- package/skills/steel-takeoff/references/takeoff-procedures.md +3 -1
- package/skills/steel-takeoff/scripts/calculate-weight.sh +5 -10
- package/skills/steel-takeoff/scripts/lookup-member.sh +2 -2
- package/skills/steel-takeoff/scripts/validate-bom.py +151 -196
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Adapters from the supported legacy BOM and nesting inputs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .contracts import (
|
|
11
|
+
ESTIMATE_PACKAGE_VERSION,
|
|
12
|
+
content_hash,
|
|
13
|
+
fallback_source_id,
|
|
14
|
+
item_id_for,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def parse_length_ft(raw: str) -> float:
|
|
19
|
+
value = raw.strip()
|
|
20
|
+
if "'" not in value:
|
|
21
|
+
return float(value)
|
|
22
|
+
feet, _, inches = value.replace('"', "").partition("'")
|
|
23
|
+
return float(feet.replace("-", "").strip()) + (
|
|
24
|
+
float(inches.replace("-", "").strip()) / 12 if inches.strip() else 0
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _package(
|
|
29
|
+
*,
|
|
30
|
+
project_id: str,
|
|
31
|
+
revision_id: str,
|
|
32
|
+
source_type: str,
|
|
33
|
+
source_hash: str,
|
|
34
|
+
items: list[dict[str, Any]],
|
|
35
|
+
stock: list[dict[str, Any]] | None = None,
|
|
36
|
+
unit_system: str = "imperial",
|
|
37
|
+
) -> dict[str, Any]:
|
|
38
|
+
return {
|
|
39
|
+
"schema_version": ESTIMATE_PACKAGE_VERSION,
|
|
40
|
+
"project": {
|
|
41
|
+
"project_id": project_id,
|
|
42
|
+
"revision": {"revision_id": revision_id},
|
|
43
|
+
},
|
|
44
|
+
"unit_system": unit_system,
|
|
45
|
+
"items": items,
|
|
46
|
+
"stock": stock or [],
|
|
47
|
+
"commercial_basis": {"currency": "USD", "costs": []},
|
|
48
|
+
"review": {"status": "draft", "findings": [], "acknowledgements": []},
|
|
49
|
+
"lineage": {
|
|
50
|
+
"source_type": source_type,
|
|
51
|
+
"source_hash": source_hash,
|
|
52
|
+
"configuration_hash": content_hash({"adapter": source_type, "version": 1}),
|
|
53
|
+
},
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def adapt_legacy_bom_csv(
|
|
58
|
+
path: str | Path, *, project_id: str, revision_id: str
|
|
59
|
+
) -> dict[str, Any]:
|
|
60
|
+
csv_path = Path(path)
|
|
61
|
+
raw_bytes = csv_path.read_bytes()
|
|
62
|
+
items: list[dict[str, Any]] = []
|
|
63
|
+
with csv_path.open(newline="", encoding="utf-8-sig") as handle:
|
|
64
|
+
for row in csv.DictReader(handle):
|
|
65
|
+
mark = row.get("Mark", "").strip()
|
|
66
|
+
if mark.startswith("#") or not any((value or "").strip() for value in row.values()):
|
|
67
|
+
continue
|
|
68
|
+
explicit_source = row.get("Source_ID", "").strip()
|
|
69
|
+
stable_identity = {
|
|
70
|
+
key: (value or "").strip()
|
|
71
|
+
for key, value in row.items()
|
|
72
|
+
if key not in {"Total_Wt_lbs"}
|
|
73
|
+
}
|
|
74
|
+
source_id = explicit_source or (
|
|
75
|
+
f"legacy:{revision_id}:mark:{mark}"
|
|
76
|
+
if mark
|
|
77
|
+
else fallback_source_id(revision_id, stable_identity)
|
|
78
|
+
)
|
|
79
|
+
item: dict[str, Any] = {
|
|
80
|
+
"intent": row.get("Intent", "").strip() or "fabricated_part",
|
|
81
|
+
"source_id": source_id,
|
|
82
|
+
"item_id": item_id_for(project_id, revision_id, source_id),
|
|
83
|
+
"quantity": int(row.get("Qty", "0").strip()),
|
|
84
|
+
"mark": mark,
|
|
85
|
+
"designation": row.get("Size", "").strip(),
|
|
86
|
+
"grade": row.get("Grade", "").strip(),
|
|
87
|
+
"length_ft": parse_length_ft(row.get("Length_ft", "0")),
|
|
88
|
+
"unit_weight_plf": float(row.get("Unit_Wt_plf", "0").strip()),
|
|
89
|
+
"connections": row.get("Connections", "").strip(),
|
|
90
|
+
"notes": row.get("Notes", "").strip(),
|
|
91
|
+
}
|
|
92
|
+
total = row.get("Total_Wt_lbs", "").strip()
|
|
93
|
+
if total:
|
|
94
|
+
item["total_weight_lbs"] = float(total)
|
|
95
|
+
sheet = row.get("Source_Sheet", "").strip()
|
|
96
|
+
detail = row.get("Source_Detail", "").strip()
|
|
97
|
+
if sheet or detail:
|
|
98
|
+
item["source_evidence"] = [
|
|
99
|
+
{
|
|
100
|
+
"source": sheet or "legacy_bom",
|
|
101
|
+
"locator": detail or mark or source_id,
|
|
102
|
+
}
|
|
103
|
+
]
|
|
104
|
+
if not explicit_source and not mark:
|
|
105
|
+
item["identity_warning"] = True
|
|
106
|
+
items.append(item)
|
|
107
|
+
return _package(
|
|
108
|
+
project_id=project_id,
|
|
109
|
+
revision_id=revision_id,
|
|
110
|
+
source_type="legacy_bom_csv",
|
|
111
|
+
source_hash=content_hash({"bytes_hex": raw_bytes.hex()}),
|
|
112
|
+
items=items,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def adapt_legacy_nest(
|
|
117
|
+
data: dict[str, Any] | str | Path, *, project_id: str, revision_id: str
|
|
118
|
+
) -> dict[str, Any]:
|
|
119
|
+
if isinstance(data, (str, Path)):
|
|
120
|
+
value = json.loads(Path(data).read_text(encoding="utf-8"))
|
|
121
|
+
else:
|
|
122
|
+
value = json.loads(json.dumps(data))
|
|
123
|
+
material = value.get("material")
|
|
124
|
+
grade = value.get("grade")
|
|
125
|
+
unit_system = value.get("unit_system")
|
|
126
|
+
thickness = value.get("thickness_in", value.get("settings", {}).get("thickness_in"))
|
|
127
|
+
items = []
|
|
128
|
+
for part in value.get("parts", []):
|
|
129
|
+
explicit_source = part.get("source_id")
|
|
130
|
+
identity = {
|
|
131
|
+
key: part.get(key)
|
|
132
|
+
for key in ("name", "width", "height", "shape", "area")
|
|
133
|
+
}
|
|
134
|
+
source_id = explicit_source or fallback_source_id(revision_id, identity)
|
|
135
|
+
geometry = {
|
|
136
|
+
"shape": part.get("shape", "rect"),
|
|
137
|
+
"width": part.get("width"),
|
|
138
|
+
"height": part.get("height"),
|
|
139
|
+
"thickness": thickness,
|
|
140
|
+
"holes": [
|
|
141
|
+
(
|
|
142
|
+
{
|
|
143
|
+
"kind": "round",
|
|
144
|
+
"diameter": hole.get("dia"),
|
|
145
|
+
"x": hole.get("x"),
|
|
146
|
+
"y": hole.get("y"),
|
|
147
|
+
}
|
|
148
|
+
if "dia" in hole
|
|
149
|
+
else {
|
|
150
|
+
"kind": "rect",
|
|
151
|
+
"width": hole.get("w"),
|
|
152
|
+
"height": hole.get("h"),
|
|
153
|
+
"x": hole.get("x"),
|
|
154
|
+
"y": hole.get("y"),
|
|
155
|
+
}
|
|
156
|
+
)
|
|
157
|
+
for hole in part.get("holes", [])
|
|
158
|
+
],
|
|
159
|
+
"rotatable": part.get("rotatable", True),
|
|
160
|
+
}
|
|
161
|
+
if "area" in part:
|
|
162
|
+
geometry["area"] = part["area"]
|
|
163
|
+
item = {
|
|
164
|
+
"intent": "fabricated_part",
|
|
165
|
+
"source_id": source_id,
|
|
166
|
+
"item_id": item_id_for(project_id, revision_id, source_id),
|
|
167
|
+
"quantity": part.get("qty", 0),
|
|
168
|
+
"mark": part.get("name", ""),
|
|
169
|
+
"geometry": geometry,
|
|
170
|
+
}
|
|
171
|
+
if material is not None:
|
|
172
|
+
item["material"] = material
|
|
173
|
+
if grade is not None:
|
|
174
|
+
item["grade"] = grade
|
|
175
|
+
if not explicit_source:
|
|
176
|
+
item["identity_warning"] = True
|
|
177
|
+
items.append(item)
|
|
178
|
+
|
|
179
|
+
stock = []
|
|
180
|
+
if material and grade and thickness is not None:
|
|
181
|
+
for index, legacy_stock in enumerate(value.get("stock", []), start=1):
|
|
182
|
+
stock.append(
|
|
183
|
+
{
|
|
184
|
+
"stock_kind": "purchasable",
|
|
185
|
+
"inventory_id": f"legacy-stock:{revision_id}:{index:04d}",
|
|
186
|
+
"material": material,
|
|
187
|
+
"grade": grade,
|
|
188
|
+
"width": legacy_stock.get("width"),
|
|
189
|
+
"height": legacy_stock.get("height"),
|
|
190
|
+
"thickness": legacy_stock.get("thickness", thickness),
|
|
191
|
+
"quantity": legacy_stock.get(
|
|
192
|
+
"qty", 1 if legacy_stock.get("unlimited") else 0
|
|
193
|
+
),
|
|
194
|
+
"status": "available",
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
return _package(
|
|
198
|
+
project_id=project_id,
|
|
199
|
+
revision_id=revision_id,
|
|
200
|
+
source_type="legacy_nest_json",
|
|
201
|
+
source_hash=content_hash(value),
|
|
202
|
+
items=items,
|
|
203
|
+
stock=stock,
|
|
204
|
+
unit_system=unit_system or "imperial",
|
|
205
|
+
)
|
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
"""Run manifests and isolated atomic artifact publication."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import tempfile
|
|
12
|
+
import uuid
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from pathlib import Path, PurePosixPath
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
MANIFEST_SCHEMA_VERSION = "1.0.0"
|
|
19
|
+
RUN_OUTCOMES = frozenset(
|
|
20
|
+
{
|
|
21
|
+
"ready",
|
|
22
|
+
"review_required",
|
|
23
|
+
"blocked",
|
|
24
|
+
"dependency_missing",
|
|
25
|
+
"usage_or_internal_error",
|
|
26
|
+
}
|
|
27
|
+
)
|
|
28
|
+
OUTCOME_EXIT_CODES = {
|
|
29
|
+
"ready": 0,
|
|
30
|
+
"review_required": 2,
|
|
31
|
+
"blocked": 3,
|
|
32
|
+
"dependency_missing": 4,
|
|
33
|
+
"usage_or_internal_error": 1,
|
|
34
|
+
}
|
|
35
|
+
PACKAGE_STATUSES = frozenset(
|
|
36
|
+
{
|
|
37
|
+
"draft",
|
|
38
|
+
"review_required",
|
|
39
|
+
"validated",
|
|
40
|
+
"nested_partial",
|
|
41
|
+
"nest_verified",
|
|
42
|
+
"rfq_draft_review_required",
|
|
43
|
+
"rfq_ready_for_review",
|
|
44
|
+
}
|
|
45
|
+
)
|
|
46
|
+
ARTIFACT_READINESS = frozenset(
|
|
47
|
+
{"geometry_verified", "reference_only", "draft", "diagnostic"}
|
|
48
|
+
)
|
|
49
|
+
_RUN_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
50
|
+
_HASH = re.compile(r"^[0-9a-f]{64}$")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ManifestError(RuntimeError):
|
|
54
|
+
"""Raised when a run cannot be published safely."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def canonical_json_bytes(value: Any) -> bytes:
|
|
58
|
+
"""Return a stable UTF-8 JSON representation suitable for hashing."""
|
|
59
|
+
return (
|
|
60
|
+
json.dumps(
|
|
61
|
+
value,
|
|
62
|
+
ensure_ascii=False,
|
|
63
|
+
allow_nan=False,
|
|
64
|
+
separators=(",", ":"),
|
|
65
|
+
sort_keys=True,
|
|
66
|
+
)
|
|
67
|
+
+ "\n"
|
|
68
|
+
).encode("utf-8")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def sha256_bytes(value: bytes) -> str:
|
|
72
|
+
return hashlib.sha256(value).hexdigest()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def sha256_file(path: str | Path) -> str:
|
|
76
|
+
digest = hashlib.sha256()
|
|
77
|
+
with Path(path).open("rb") as stream:
|
|
78
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
79
|
+
digest.update(chunk)
|
|
80
|
+
return digest.hexdigest()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def outcome_exit_code(outcome: str) -> int:
|
|
84
|
+
try:
|
|
85
|
+
return OUTCOME_EXIT_CODES[outcome]
|
|
86
|
+
except KeyError as exc:
|
|
87
|
+
raise ManifestError(f"unknown run outcome: {outcome}") from exc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def semantic_manifest_projection(manifest: dict[str, Any]) -> dict[str, Any]:
|
|
91
|
+
"""Remove volatile fields and the self-referential hash before semantic hashing."""
|
|
92
|
+
projection = copy.deepcopy(manifest)
|
|
93
|
+
for field in ("semantic_hash", "run_id", "created_at"):
|
|
94
|
+
projection.pop(field, None)
|
|
95
|
+
return projection
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _safe_relative_path(value: str | Path) -> PurePosixPath:
|
|
99
|
+
text = Path(value).as_posix()
|
|
100
|
+
path = PurePosixPath(text)
|
|
101
|
+
if (
|
|
102
|
+
text in {"", "."}
|
|
103
|
+
or path.is_absolute()
|
|
104
|
+
or ".." in path.parts
|
|
105
|
+
or path.name in {"run-manifest.json", "latest-run.json"}
|
|
106
|
+
):
|
|
107
|
+
raise ManifestError(f"unsafe or reserved artifact path: {text}")
|
|
108
|
+
return path
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _utc_now() -> str:
|
|
112
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
class RunPublisher:
|
|
116
|
+
"""Stage one isolated run and publish it with an integrity manifest."""
|
|
117
|
+
|
|
118
|
+
def __init__(
|
|
119
|
+
self,
|
|
120
|
+
destination: str | Path,
|
|
121
|
+
*,
|
|
122
|
+
stage: str,
|
|
123
|
+
run_outcome: str,
|
|
124
|
+
package_status: str,
|
|
125
|
+
input_hash: str,
|
|
126
|
+
configuration_hash: str,
|
|
127
|
+
schema_versions: dict[str, str],
|
|
128
|
+
tool_versions: dict[str, str],
|
|
129
|
+
explicit_dates: dict[str, str],
|
|
130
|
+
warnings: list[Any] | None = None,
|
|
131
|
+
approximations: list[Any] | None = None,
|
|
132
|
+
run_id: str | None = None,
|
|
133
|
+
created_at: str | None = None,
|
|
134
|
+
) -> None:
|
|
135
|
+
if run_outcome not in RUN_OUTCOMES:
|
|
136
|
+
raise ManifestError(f"unknown run outcome: {run_outcome}")
|
|
137
|
+
if package_status not in PACKAGE_STATUSES:
|
|
138
|
+
raise ManifestError(f"unknown package status: {package_status}")
|
|
139
|
+
if not _HASH.fullmatch(input_hash) or not _HASH.fullmatch(configuration_hash):
|
|
140
|
+
raise ManifestError("input and configuration hashes must be SHA-256 hex")
|
|
141
|
+
|
|
142
|
+
self.destination = Path(destination).resolve()
|
|
143
|
+
self.run_id = run_id or (
|
|
144
|
+
datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ")
|
|
145
|
+
+ "-"
|
|
146
|
+
+ uuid.uuid4().hex[:12]
|
|
147
|
+
)
|
|
148
|
+
if not _RUN_ID.fullmatch(self.run_id):
|
|
149
|
+
raise ManifestError(f"invalid run id: {self.run_id}")
|
|
150
|
+
|
|
151
|
+
self._metadata = {
|
|
152
|
+
"schema_version": MANIFEST_SCHEMA_VERSION,
|
|
153
|
+
"run_id": self.run_id,
|
|
154
|
+
"stage": stage,
|
|
155
|
+
"run_outcome": run_outcome,
|
|
156
|
+
"package_status": package_status,
|
|
157
|
+
"created_at": created_at or _utc_now(),
|
|
158
|
+
"schema_versions": dict(schema_versions),
|
|
159
|
+
"tool_versions": dict(tool_versions),
|
|
160
|
+
"input_hash": input_hash,
|
|
161
|
+
"configuration_hash": configuration_hash,
|
|
162
|
+
"explicit_dates": dict(explicit_dates),
|
|
163
|
+
"warnings": list(warnings or []),
|
|
164
|
+
"approximations": list(approximations or []),
|
|
165
|
+
}
|
|
166
|
+
self._artifacts: dict[str, dict[str, Any]] = {}
|
|
167
|
+
self._published = False
|
|
168
|
+
|
|
169
|
+
staging_parent = self.destination / ".staging"
|
|
170
|
+
staging_parent.mkdir(parents=True, exist_ok=True)
|
|
171
|
+
self.staging_path = Path(
|
|
172
|
+
tempfile.mkdtemp(prefix=f"{self.run_id}-", dir=staging_parent)
|
|
173
|
+
)
|
|
174
|
+
self.final_path = self.destination / "runs" / self.run_id
|
|
175
|
+
|
|
176
|
+
def __enter__(self) -> "RunPublisher":
|
|
177
|
+
return self
|
|
178
|
+
|
|
179
|
+
def __exit__(self, exc_type, exc, traceback) -> None:
|
|
180
|
+
if not self._published and self.staging_path.exists():
|
|
181
|
+
shutil.rmtree(self.staging_path)
|
|
182
|
+
|
|
183
|
+
def path_for(self, relative_path: str | Path) -> Path:
|
|
184
|
+
relative = _safe_relative_path(relative_path)
|
|
185
|
+
target = self.staging_path.joinpath(*relative.parts)
|
|
186
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
return target
|
|
188
|
+
|
|
189
|
+
def register_artifact(
|
|
190
|
+
self,
|
|
191
|
+
relative_path: str | Path,
|
|
192
|
+
*,
|
|
193
|
+
readiness: str,
|
|
194
|
+
media_type: str | None = None,
|
|
195
|
+
) -> Path:
|
|
196
|
+
if readiness not in ARTIFACT_READINESS:
|
|
197
|
+
raise ManifestError(f"unknown artifact readiness: {readiness}")
|
|
198
|
+
relative = _safe_relative_path(relative_path)
|
|
199
|
+
text = relative.as_posix()
|
|
200
|
+
if text in self._artifacts:
|
|
201
|
+
raise ManifestError(f"artifact already registered: {text}")
|
|
202
|
+
record: dict[str, Any] = {"readiness": readiness}
|
|
203
|
+
if media_type:
|
|
204
|
+
record["media_type"] = media_type
|
|
205
|
+
self._artifacts[text] = record
|
|
206
|
+
return self.path_for(relative)
|
|
207
|
+
|
|
208
|
+
def write_bytes(
|
|
209
|
+
self,
|
|
210
|
+
relative_path: str | Path,
|
|
211
|
+
value: bytes,
|
|
212
|
+
*,
|
|
213
|
+
readiness: str,
|
|
214
|
+
media_type: str | None = None,
|
|
215
|
+
) -> Path:
|
|
216
|
+
target = self.register_artifact(
|
|
217
|
+
relative_path, readiness=readiness, media_type=media_type
|
|
218
|
+
)
|
|
219
|
+
target.write_bytes(value)
|
|
220
|
+
return target
|
|
221
|
+
|
|
222
|
+
def write_json(
|
|
223
|
+
self,
|
|
224
|
+
relative_path: str | Path,
|
|
225
|
+
value: Any,
|
|
226
|
+
*,
|
|
227
|
+
readiness: str,
|
|
228
|
+
) -> Path:
|
|
229
|
+
return self.write_bytes(
|
|
230
|
+
relative_path,
|
|
231
|
+
canonical_json_bytes(value),
|
|
232
|
+
readiness=readiness,
|
|
233
|
+
media_type="application/json",
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
def write_qa_report(self, value: Any) -> Path:
|
|
237
|
+
"""Register the standard machine-readable QA artifact for a stage run."""
|
|
238
|
+
return self.write_json("qa-report.json", value, readiness="diagnostic")
|
|
239
|
+
|
|
240
|
+
def publish(self) -> Path:
|
|
241
|
+
if self._published:
|
|
242
|
+
raise ManifestError("run already published")
|
|
243
|
+
if self.final_path.exists():
|
|
244
|
+
raise ManifestError(f"run already exists: {self.run_id}")
|
|
245
|
+
if (
|
|
246
|
+
self._metadata["run_outcome"] in {"ready", "review_required", "blocked"}
|
|
247
|
+
and "qa-report.json" not in self._artifacts
|
|
248
|
+
):
|
|
249
|
+
raise ManifestError(
|
|
250
|
+
f"{self._metadata['run_outcome']} runs require qa-report.json"
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
actual_files = set()
|
|
254
|
+
for path in self.staging_path.rglob("*"):
|
|
255
|
+
if path.is_symlink():
|
|
256
|
+
raise ManifestError("artifact symlinks are not allowed")
|
|
257
|
+
if path.is_file():
|
|
258
|
+
actual_files.add(path.relative_to(self.staging_path).as_posix())
|
|
259
|
+
registered_files = set(self._artifacts)
|
|
260
|
+
if actual_files != registered_files:
|
|
261
|
+
missing = sorted(registered_files - actual_files)
|
|
262
|
+
extra = sorted(actual_files - registered_files)
|
|
263
|
+
raise ManifestError(
|
|
264
|
+
f"artifact allow-list mismatch; missing={missing}, unregistered={extra}"
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
artifact_records = []
|
|
268
|
+
for relative in sorted(self._artifacts):
|
|
269
|
+
path = self.staging_path / relative
|
|
270
|
+
record = {
|
|
271
|
+
"path": relative,
|
|
272
|
+
"sha256": sha256_file(path),
|
|
273
|
+
"size_bytes": path.stat().st_size,
|
|
274
|
+
**self._artifacts[relative],
|
|
275
|
+
}
|
|
276
|
+
artifact_records.append(record)
|
|
277
|
+
|
|
278
|
+
manifest = {**self._metadata, "artifacts": artifact_records}
|
|
279
|
+
manifest["semantic_hash"] = sha256_bytes(
|
|
280
|
+
canonical_json_bytes(semantic_manifest_projection(manifest))
|
|
281
|
+
)
|
|
282
|
+
manifest_path = self.staging_path / "run-manifest.json"
|
|
283
|
+
manifest_path.write_bytes(canonical_json_bytes(manifest))
|
|
284
|
+
|
|
285
|
+
self.final_path.parent.mkdir(parents=True, exist_ok=True)
|
|
286
|
+
os.replace(self.staging_path, self.final_path)
|
|
287
|
+
manifest_hash = sha256_file(self.final_path / "run-manifest.json")
|
|
288
|
+
pointer = {
|
|
289
|
+
"schema_version": MANIFEST_SCHEMA_VERSION,
|
|
290
|
+
"run_id": self.run_id,
|
|
291
|
+
"run_directory": f"runs/{self.run_id}",
|
|
292
|
+
"manifest_sha256": manifest_hash,
|
|
293
|
+
}
|
|
294
|
+
pointer_fd, pointer_name = tempfile.mkstemp(
|
|
295
|
+
prefix=".latest-run-", dir=self.destination
|
|
296
|
+
)
|
|
297
|
+
try:
|
|
298
|
+
with os.fdopen(pointer_fd, "wb") as stream:
|
|
299
|
+
stream.write(canonical_json_bytes(pointer))
|
|
300
|
+
os.replace(pointer_name, self.destination / "latest-run.json")
|
|
301
|
+
except Exception as pointer_error:
|
|
302
|
+
try:
|
|
303
|
+
shutil.rmtree(self.final_path)
|
|
304
|
+
except OSError as rollback_error:
|
|
305
|
+
raise ManifestError(
|
|
306
|
+
"latest-run pointer update failed and the unpublished run "
|
|
307
|
+
f"could not be rolled back: {self.final_path}"
|
|
308
|
+
) from rollback_error
|
|
309
|
+
raise ManifestError(
|
|
310
|
+
"latest-run pointer update failed; unpublished run was rolled back"
|
|
311
|
+
) from pointer_error
|
|
312
|
+
finally:
|
|
313
|
+
if os.path.exists(pointer_name):
|
|
314
|
+
os.unlink(pointer_name)
|
|
315
|
+
|
|
316
|
+
self._published = True
|
|
317
|
+
return self.final_path
|