@hunter-harness/workflow-harness 0.2.26 → 0.2.28
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/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/general/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/general/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +8 -4
- package/harness/bundles/general/claude-code/harness-sync/reference.md +28 -0
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +2147 -189
- package/harness/bundles/general/claude-code/scripts/harness_change.py +8 -3
- package/harness/bundles/general/claude-code/scripts/harness_events.py +57 -9
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +530 -8
- package/harness/bundles/general/claude-code/scripts/harness_integration.py +263 -6
- package/harness/bundles/general/claude-code/scripts/harness_phase.py +758 -1
- package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/general/claude-code/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/general/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +8 -4
- package/harness/bundles/general/codebuddy/harness-sync/reference.md +28 -0
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +2147 -189
- package/harness/bundles/general/codebuddy/scripts/harness_change.py +8 -3
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +57 -9
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +530 -8
- package/harness/bundles/general/codebuddy/scripts/harness_integration.py +263 -6
- package/harness/bundles/general/codebuddy/scripts/harness_phase.py +758 -1
- package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/general/codebuddy/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/general/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +8 -4
- package/harness/bundles/general/codex/harness-sync/reference.md +28 -0
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/scripts/harness_archive.py +2147 -189
- package/harness/bundles/general/codex/scripts/harness_change.py +8 -3
- package/harness/bundles/general/codex/scripts/harness_events.py +57 -9
- package/harness/bundles/general/codex/scripts/harness_gate.py +530 -8
- package/harness/bundles/general/codex/scripts/harness_integration.py +263 -6
- package/harness/bundles/general/codex/scripts/harness_phase.py +758 -1
- package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/general/codex/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/codex/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/general/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +8 -4
- package/harness/bundles/general/cursor/harness-sync/reference.md +28 -0
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/scripts/harness_archive.py +2147 -189
- package/harness/bundles/general/cursor/scripts/harness_change.py +8 -3
- package/harness/bundles/general/cursor/scripts/harness_events.py +57 -9
- package/harness/bundles/general/cursor/scripts/harness_gate.py +530 -8
- package/harness/bundles/general/cursor/scripts/harness_integration.py +263 -6
- package/harness/bundles/general/cursor/scripts/harness_phase.py +758 -1
- package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/general/cursor/scripts/harness_retry.py +175 -0
- package/harness/bundles/general/cursor/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/java/claude-code/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/java/claude-code/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +8 -4
- package/harness/bundles/java/claude-code/harness-sync/reference.md +28 -0
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +2147 -189
- package/harness/bundles/java/claude-code/scripts/harness_change.py +8 -3
- package/harness/bundles/java/claude-code/scripts/harness_events.py +57 -9
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +530 -8
- package/harness/bundles/java/claude-code/scripts/harness_integration.py +263 -6
- package/harness/bundles/java/claude-code/scripts/harness_phase.py +758 -1
- package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/java/claude-code/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/java/codebuddy/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +8 -4
- package/harness/bundles/java/codebuddy/harness-sync/reference.md +28 -0
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +2147 -189
- package/harness/bundles/java/codebuddy/scripts/harness_change.py +8 -3
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +57 -9
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +530 -8
- package/harness/bundles/java/codebuddy/scripts/harness_integration.py +263 -6
- package/harness/bundles/java/codebuddy/scripts/harness_phase.py +758 -1
- package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/java/codebuddy/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/java/codex/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +8 -4
- package/harness/bundles/java/codex/harness-sync/reference.md +28 -0
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/scripts/harness_archive.py +2147 -189
- package/harness/bundles/java/codex/scripts/harness_change.py +8 -3
- package/harness/bundles/java/codex/scripts/harness_events.py +57 -9
- package/harness/bundles/java/codex/scripts/harness_gate.py +530 -8
- package/harness/bundles/java/codex/scripts/harness_integration.py +263 -6
- package/harness/bundles/java/codex/scripts/harness_phase.py +758 -1
- package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/java/codex/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/codex/scripts/harness_test_guard.py +702 -22
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +25 -6
- package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +44 -0
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +493 -74
- package/harness/bundles/java/cursor/harness-knowledge-query/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-package/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-plan/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-review/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-run/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +8 -4
- package/harness/bundles/java/cursor/harness-sync/reference.md +28 -0
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/scripts/harness_archive.py +2147 -189
- package/harness/bundles/java/cursor/scripts/harness_change.py +8 -3
- package/harness/bundles/java/cursor/scripts/harness_events.py +57 -9
- package/harness/bundles/java/cursor/scripts/harness_gate.py +530 -8
- package/harness/bundles/java/cursor/scripts/harness_integration.py +263 -6
- package/harness/bundles/java/cursor/scripts/harness_phase.py +758 -1
- package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +74 -0
- package/harness/bundles/java/cursor/scripts/harness_retry.py +175 -0
- package/harness/bundles/java/cursor/scripts/harness_test_guard.py +702 -22
- package/harness/manifests/general/claude-code.json +28 -24
- package/harness/manifests/general/codebuddy.json +28 -24
- package/harness/manifests/general/codex.json +28 -24
- package/harness/manifests/general/cursor.json +28 -24
- package/harness/manifests/java/claude-code.json +30 -26
- package/harness/manifests/java/codebuddy.json +30 -26
- package/harness/manifests/java/codex.json +30 -26
- package/harness/manifests/java/cursor.json +30 -26
- package/hunter-workflow-family.json +2 -2
- package/package.json +1 -1
|
@@ -160,6 +160,118 @@ def _git_is_ancestor(cwd: Path, ancestor: str, descendant: str) -> bool:
|
|
|
160
160
|
return proc.returncode == 0
|
|
161
161
|
|
|
162
162
|
|
|
163
|
+
def evaluate_projection_gate(
|
|
164
|
+
project: Path,
|
|
165
|
+
phase: str,
|
|
166
|
+
) -> dict[str, Any]:
|
|
167
|
+
"""Validate three-stage projection identity and apply phase fallback policy."""
|
|
168
|
+
project = project.resolve()
|
|
169
|
+
path = project / ".harness" / "config" / "projection-receipt.json"
|
|
170
|
+
release_phase = phase in {"submit", "archive", "release", "deploy"}
|
|
171
|
+
if not path.is_file():
|
|
172
|
+
return {
|
|
173
|
+
"ok": True,
|
|
174
|
+
"code": "PROJECTION_RECEIPT_NOT_CONFIGURED",
|
|
175
|
+
"status": "legacy",
|
|
176
|
+
"mode": "legacy",
|
|
177
|
+
"receiptPath": str(path),
|
|
178
|
+
"releasePhase": release_phase,
|
|
179
|
+
}
|
|
180
|
+
try:
|
|
181
|
+
receipt = _read_json(path)
|
|
182
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
183
|
+
receipt = {}
|
|
184
|
+
parse_error = str(exc)
|
|
185
|
+
else:
|
|
186
|
+
parse_error = None
|
|
187
|
+
source = receipt.get("source")
|
|
188
|
+
transform = receipt.get("transform")
|
|
189
|
+
target = receipt.get("target")
|
|
190
|
+
source = source if isinstance(source, dict) else {}
|
|
191
|
+
transform = transform if isinstance(transform, dict) else {}
|
|
192
|
+
target = target if isinstance(target, dict) else {}
|
|
193
|
+
digest_pattern = re.compile(r"sha256:[0-9a-fA-F]{64}")
|
|
194
|
+
issues = []
|
|
195
|
+
if receipt.get("schemaVersion") != 1:
|
|
196
|
+
issues.append("schemaVersion")
|
|
197
|
+
for label, value in (
|
|
198
|
+
("source.hash", source.get("hash")),
|
|
199
|
+
("transform.hash", transform.get("hash")),
|
|
200
|
+
("target.hash", target.get("hash")),
|
|
201
|
+
):
|
|
202
|
+
if not isinstance(value, str) or not digest_pattern.fullmatch(value):
|
|
203
|
+
issues.append(label)
|
|
204
|
+
if not str(transform.get("version") or "").strip():
|
|
205
|
+
issues.append("transform.version")
|
|
206
|
+
if not isinstance(receipt.get("projectOwnedExclusions"), list):
|
|
207
|
+
issues.append("projectOwnedExclusions")
|
|
208
|
+
if not str(receipt.get("generatedAt") or "").strip():
|
|
209
|
+
issues.append("generatedAt")
|
|
210
|
+
status = str(receipt.get("status") or "").strip().lower()
|
|
211
|
+
if status not in {"verified", "degraded"}:
|
|
212
|
+
issues.append("status")
|
|
213
|
+
status = "degraded"
|
|
214
|
+
if parse_error:
|
|
215
|
+
issues.append("parse")
|
|
216
|
+
receipt_hash = "sha256:" + hashlib.sha256(
|
|
217
|
+
json.dumps(
|
|
218
|
+
receipt,
|
|
219
|
+
ensure_ascii=False,
|
|
220
|
+
sort_keys=True,
|
|
221
|
+
separators=(",", ":"),
|
|
222
|
+
).encode("utf-8")
|
|
223
|
+
).hexdigest()
|
|
224
|
+
degraded = status == "degraded" or bool(issues)
|
|
225
|
+
if degraded and release_phase:
|
|
226
|
+
return {
|
|
227
|
+
"ok": False,
|
|
228
|
+
"code": "PROJECTION_DEGRADED_BLOCKED",
|
|
229
|
+
"message": (
|
|
230
|
+
"adapter/config projection is degraded; refresh the projection "
|
|
231
|
+
"receipt before submit/archive/release"
|
|
232
|
+
),
|
|
233
|
+
"status": "degraded",
|
|
234
|
+
"mode": "blocked",
|
|
235
|
+
"issues": issues,
|
|
236
|
+
"receipt": receipt,
|
|
237
|
+
"receiptHash": receipt_hash,
|
|
238
|
+
"receiptPath": str(path),
|
|
239
|
+
"releasePhase": True,
|
|
240
|
+
"remediation": (
|
|
241
|
+
"regenerate .harness/config/projection-receipt.json from the "
|
|
242
|
+
"project truth source"
|
|
243
|
+
),
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
"ok": True,
|
|
247
|
+
"code": (
|
|
248
|
+
"PROJECTION_VERIFIED"
|
|
249
|
+
if not degraded
|
|
250
|
+
else "PROJECTION_DEGRADED_FALLBACK"
|
|
251
|
+
),
|
|
252
|
+
"message": (
|
|
253
|
+
"projection receipt verified"
|
|
254
|
+
if not degraded
|
|
255
|
+
else "degraded projection allowed only in explicit fallback mode"
|
|
256
|
+
),
|
|
257
|
+
"status": "verified" if not degraded else "degraded",
|
|
258
|
+
"mode": "normal" if not degraded else "fallback",
|
|
259
|
+
"issues": issues,
|
|
260
|
+
"receipt": receipt,
|
|
261
|
+
"receiptHash": receipt_hash,
|
|
262
|
+
"receiptPath": str(path),
|
|
263
|
+
"releasePhase": release_phase,
|
|
264
|
+
"remediation": (
|
|
265
|
+
None
|
|
266
|
+
if not degraded
|
|
267
|
+
else (
|
|
268
|
+
"regenerate .harness/config/projection-receipt.json from the "
|
|
269
|
+
"project truth source before any release phase"
|
|
270
|
+
)
|
|
271
|
+
),
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
|
|
163
275
|
def resolve_execution_root(main_project: Path, raw: str | None) -> Path:
|
|
164
276
|
candidate = Path(raw).expanduser().resolve() if raw else main_project.resolve()
|
|
165
277
|
if not candidate.is_dir():
|
|
@@ -173,6 +285,46 @@ def resolve_execution_root(main_project: Path, raw: str | None) -> Path:
|
|
|
173
285
|
return root
|
|
174
286
|
|
|
175
287
|
|
|
288
|
+
def resolve_begin_execution_hint(
|
|
289
|
+
main_project: Path,
|
|
290
|
+
*,
|
|
291
|
+
requested: str | None,
|
|
292
|
+
capsule: dict[str, Any] | None,
|
|
293
|
+
cwd: Path | None = None,
|
|
294
|
+
) -> dict[str, str]:
|
|
295
|
+
"""Choose an execution root explicitly, from the capsule, or from Git cwd."""
|
|
296
|
+
if requested:
|
|
297
|
+
return {
|
|
298
|
+
"executionRoot": str(Path(requested).expanduser().resolve()),
|
|
299
|
+
"source": "explicit",
|
|
300
|
+
}
|
|
301
|
+
if isinstance(capsule, dict) and str(capsule.get("executionRoot") or "").strip():
|
|
302
|
+
return {
|
|
303
|
+
"executionRoot": str(capsule["executionRoot"]),
|
|
304
|
+
"source": "phase-capsule",
|
|
305
|
+
}
|
|
306
|
+
current = (cwd or Path.cwd()).resolve()
|
|
307
|
+
try:
|
|
308
|
+
top = _git_text(current, "rev-parse", "--show-toplevel")
|
|
309
|
+
detected = Path(top).resolve() if top else None
|
|
310
|
+
if (
|
|
311
|
+
detected is not None
|
|
312
|
+
and detected.is_dir()
|
|
313
|
+
and hp.repository_identity(detected)
|
|
314
|
+
== hp.repository_identity(main_project)
|
|
315
|
+
):
|
|
316
|
+
return {
|
|
317
|
+
"executionRoot": str(detected),
|
|
318
|
+
"source": "cwd-git-root",
|
|
319
|
+
}
|
|
320
|
+
except (OSError, RuntimeError, ValueError):
|
|
321
|
+
pass
|
|
322
|
+
return {
|
|
323
|
+
"executionRoot": str(main_project.resolve()),
|
|
324
|
+
"source": "main-project-default",
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
|
|
176
328
|
def _phase_capsule_path(change_dir: Path, phase: str, run_id: str) -> Path:
|
|
177
329
|
key = hashlib.sha256(f"{phase}\0{run_id}".encode("utf-8")).hexdigest()[:20]
|
|
178
330
|
return (
|
|
@@ -206,6 +358,37 @@ def write_phase_capsule(
|
|
|
206
358
|
return path
|
|
207
359
|
|
|
208
360
|
|
|
361
|
+
def persist_close_failure(
|
|
362
|
+
change_dir: Path,
|
|
363
|
+
phase: str,
|
|
364
|
+
run_id: str,
|
|
365
|
+
capsule: dict[str, Any] | None,
|
|
366
|
+
*,
|
|
367
|
+
status: str,
|
|
368
|
+
error: dict[str, Any],
|
|
369
|
+
) -> None:
|
|
370
|
+
"""Journal a retryable close failure before returning to the caller."""
|
|
371
|
+
if capsule is None:
|
|
372
|
+
return
|
|
373
|
+
transaction = capsule.get("closeTransaction")
|
|
374
|
+
transaction = dict(transaction) if isinstance(transaction, dict) else {}
|
|
375
|
+
transaction.update(
|
|
376
|
+
{
|
|
377
|
+
"status": status,
|
|
378
|
+
"retryable": True,
|
|
379
|
+
"guardClosed": bool(transaction.get("guardClosed")),
|
|
380
|
+
"phaseEndRecorded": bool(transaction.get("phaseEndRecorded")),
|
|
381
|
+
"leaseReleased": False,
|
|
382
|
+
"lastError": error,
|
|
383
|
+
"updatedAt": he.now_iso(),
|
|
384
|
+
}
|
|
385
|
+
)
|
|
386
|
+
capsule["closeTransaction"] = transaction
|
|
387
|
+
capsule.pop("closeStatus", None)
|
|
388
|
+
capsule.pop("closedAt", None)
|
|
389
|
+
write_phase_capsule(change_dir, phase, run_id, capsule)
|
|
390
|
+
|
|
391
|
+
|
|
209
392
|
def validate_phase_capsule(
|
|
210
393
|
capsule: dict[str, Any],
|
|
211
394
|
*,
|
|
@@ -770,6 +953,145 @@ def _ordered_unique(items: list[str]) -> list[str]:
|
|
|
770
953
|
return list(dict.fromkeys(items))
|
|
771
954
|
|
|
772
955
|
|
|
956
|
+
FINAL_SEQUENCE_CANONICAL_ORDER = (
|
|
957
|
+
"complete-review",
|
|
958
|
+
"code-freeze",
|
|
959
|
+
"unit-test-full",
|
|
960
|
+
"delta-review",
|
|
961
|
+
"submit-reuse",
|
|
962
|
+
"remote-candidate-ci",
|
|
963
|
+
"archive",
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _sequence_slug(value: str) -> str:
|
|
968
|
+
expanded = re.sub(r"(?<=[a-z0-9])(?=[A-Z])", "-", value.strip())
|
|
969
|
+
return re.sub(r"[^a-z0-9]+", "-", expanded.lower()).strip("-")
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def compile_final_sequence_dag(
|
|
973
|
+
base_dag: dict[str, Any],
|
|
974
|
+
sequence: list[Any],
|
|
975
|
+
) -> dict[str, Any]:
|
|
976
|
+
"""Compile a declared release sequence into an executable, versioned DAG."""
|
|
977
|
+
raw_nodes = base_dag.get("nodes")
|
|
978
|
+
raw_edges = base_dag.get("edges")
|
|
979
|
+
if not isinstance(raw_nodes, list) or not isinstance(raw_edges, list):
|
|
980
|
+
raise ValueError("base requiredGateDag must contain nodes and edges arrays")
|
|
981
|
+
if not sequence:
|
|
982
|
+
raise ValueError("finalSequence must not be empty")
|
|
983
|
+
|
|
984
|
+
entries: list[dict[str, Any]] = []
|
|
985
|
+
seen: set[str] = set()
|
|
986
|
+
for index, raw in enumerate(sequence):
|
|
987
|
+
if isinstance(raw, str):
|
|
988
|
+
name = raw
|
|
989
|
+
metadata: dict[str, Any] = {}
|
|
990
|
+
elif isinstance(raw, dict):
|
|
991
|
+
name = str(raw.get("id") or raw.get("name") or raw.get("step") or "")
|
|
992
|
+
metadata = {
|
|
993
|
+
key: value
|
|
994
|
+
for key, value in raw.items()
|
|
995
|
+
if key not in {"id", "name", "step", "dependsOn", "kind"}
|
|
996
|
+
}
|
|
997
|
+
else:
|
|
998
|
+
raise ValueError(f"finalSequence[{index}] must be a string or object")
|
|
999
|
+
slug = _sequence_slug(name)
|
|
1000
|
+
if not slug:
|
|
1001
|
+
raise ValueError(f"finalSequence[{index}] requires a non-empty name")
|
|
1002
|
+
if slug in seen:
|
|
1003
|
+
raise ValueError(f"duplicate finalSequence step: {slug}")
|
|
1004
|
+
seen.add(slug)
|
|
1005
|
+
entries.append({"slug": slug, "metadata": metadata})
|
|
1006
|
+
|
|
1007
|
+
positions = {entry["slug"]: index for index, entry in enumerate(entries)}
|
|
1008
|
+
present_order = [
|
|
1009
|
+
name for name in FINAL_SEQUENCE_CANONICAL_ORDER if name in positions
|
|
1010
|
+
]
|
|
1011
|
+
for left, right in zip(present_order, present_order[1:]):
|
|
1012
|
+
if positions[left] > positions[right]:
|
|
1013
|
+
raise ValueError(
|
|
1014
|
+
f"finalSequence must place {left} before {right}; "
|
|
1015
|
+
"code-freeze must precede unit-test-full"
|
|
1016
|
+
)
|
|
1017
|
+
if "archive" in positions and positions["archive"] != len(entries) - 1:
|
|
1018
|
+
raise ValueError("finalSequence archive step must be last")
|
|
1019
|
+
|
|
1020
|
+
nodes = [dict(node) for node in raw_nodes if isinstance(node, dict)]
|
|
1021
|
+
edges = [dict(edge) for edge in raw_edges if isinstance(edge, dict)]
|
|
1022
|
+
base_ids = {
|
|
1023
|
+
str(node.get("id") or "")
|
|
1024
|
+
for node in nodes
|
|
1025
|
+
if str(node.get("id") or "")
|
|
1026
|
+
}
|
|
1027
|
+
depended_on = {
|
|
1028
|
+
str(dependency)
|
|
1029
|
+
for node in nodes
|
|
1030
|
+
for dependency in (node.get("dependsOn") or [])
|
|
1031
|
+
if isinstance(dependency, str)
|
|
1032
|
+
}
|
|
1033
|
+
base_terminals = sorted(base_ids - depended_on)
|
|
1034
|
+
|
|
1035
|
+
sequence_ids: list[str] = []
|
|
1036
|
+
predecessor_ids = base_terminals
|
|
1037
|
+
freeze_id = "sequence:code-freeze"
|
|
1038
|
+
for index, entry in enumerate(entries):
|
|
1039
|
+
node_id = f"sequence:{entry['slug']}"
|
|
1040
|
+
sequence_ids.append(node_id)
|
|
1041
|
+
node = {
|
|
1042
|
+
"id": node_id,
|
|
1043
|
+
"kind": "sequence",
|
|
1044
|
+
"phase": "archive",
|
|
1045
|
+
"sequenceIndex": index,
|
|
1046
|
+
"dependsOn": list(predecessor_ids),
|
|
1047
|
+
"subjectBound": True,
|
|
1048
|
+
"invalidatedByProductChange": (
|
|
1049
|
+
freeze_id in sequence_ids or node_id == freeze_id
|
|
1050
|
+
),
|
|
1051
|
+
**entry["metadata"],
|
|
1052
|
+
}
|
|
1053
|
+
nodes.append(node)
|
|
1054
|
+
edges.extend(
|
|
1055
|
+
{"from": dependency, "to": node_id}
|
|
1056
|
+
for dependency in predecessor_ids
|
|
1057
|
+
)
|
|
1058
|
+
predecessor_ids = [node_id]
|
|
1059
|
+
|
|
1060
|
+
return {
|
|
1061
|
+
"schemaVersion": 2,
|
|
1062
|
+
"nodes": nodes,
|
|
1063
|
+
"edges": edges,
|
|
1064
|
+
"finalSequence": {
|
|
1065
|
+
"schemaVersion": 1,
|
|
1066
|
+
"nodeIds": sequence_ids,
|
|
1067
|
+
"freezeNodeId": (
|
|
1068
|
+
freeze_id if freeze_id in sequence_ids else None
|
|
1069
|
+
),
|
|
1070
|
+
"terminalNodeId": sequence_ids[-1],
|
|
1071
|
+
},
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _load_configured_final_sequence(
|
|
1076
|
+
project: Path,
|
|
1077
|
+
change_dir: Path,
|
|
1078
|
+
) -> list[Any] | None:
|
|
1079
|
+
candidates = (
|
|
1080
|
+
change_dir / "meta" / "final-sequence.json",
|
|
1081
|
+
project / ".harness" / "config" / "final-sequence.json",
|
|
1082
|
+
)
|
|
1083
|
+
for path in candidates:
|
|
1084
|
+
if not path.is_file():
|
|
1085
|
+
continue
|
|
1086
|
+
raw = _read_json(path)
|
|
1087
|
+
if isinstance(raw, list):
|
|
1088
|
+
return raw
|
|
1089
|
+
if isinstance(raw, dict) and isinstance(raw.get("finalSequence"), list):
|
|
1090
|
+
return raw["finalSequence"]
|
|
1091
|
+
raise ValueError(f"final sequence configuration is invalid: {path}")
|
|
1092
|
+
return None
|
|
1093
|
+
|
|
1094
|
+
|
|
773
1095
|
def _apply_required_gate_contract(
|
|
774
1096
|
payload: dict[str, Any],
|
|
775
1097
|
workflow: dict[str, Any],
|
|
@@ -949,7 +1271,15 @@ def classify_risk(
|
|
|
949
1271
|
}
|
|
950
1272
|
if stage == "post-run":
|
|
951
1273
|
_write_json(change_dir / "meta" / "risk-classification.json", payload)
|
|
952
|
-
|
|
1274
|
+
result = _apply_required_gate_contract(payload, workflow, capabilities)
|
|
1275
|
+
configured_sequence = _load_configured_final_sequence(main_project, change_dir)
|
|
1276
|
+
if configured_sequence is not None:
|
|
1277
|
+
result["requiredGateDag"] = compile_final_sequence_dag(
|
|
1278
|
+
result["requiredGateDag"],
|
|
1279
|
+
configured_sequence,
|
|
1280
|
+
)
|
|
1281
|
+
result["finalSequence"] = result["requiredGateDag"]["finalSequence"]
|
|
1282
|
+
return result
|
|
953
1283
|
|
|
954
1284
|
|
|
955
1285
|
def _stage_decisions_for_tier(
|
|
@@ -993,14 +1323,35 @@ def apply_tier_override(
|
|
|
993
1323
|
payload["conditionalStages"] = list(tier_policy["conditionalStages"])
|
|
994
1324
|
payload["stageDecisions"] = _stage_decisions_for_tier(workflow, tier, signals)
|
|
995
1325
|
payload["tierOverride"] = {"tier": tier, "by": override_by, "at": now}
|
|
996
|
-
|
|
1326
|
+
result = _apply_required_gate_contract(
|
|
997
1327
|
payload, workflow, list(payload.get("capabilities") or [])
|
|
998
1328
|
)
|
|
1329
|
+
final_sequence = payload.get("finalSequence")
|
|
1330
|
+
node_ids = (
|
|
1331
|
+
final_sequence.get("nodeIds")
|
|
1332
|
+
if isinstance(final_sequence, dict)
|
|
1333
|
+
else None
|
|
1334
|
+
)
|
|
1335
|
+
if isinstance(node_ids, list) and node_ids:
|
|
1336
|
+
sequence = [
|
|
1337
|
+
str(node_id).removeprefix("sequence:")
|
|
1338
|
+
for node_id in node_ids
|
|
1339
|
+
if str(node_id).startswith("sequence:")
|
|
1340
|
+
]
|
|
1341
|
+
if sequence:
|
|
1342
|
+
result["requiredGateDag"] = compile_final_sequence_dag(
|
|
1343
|
+
result["requiredGateDag"],
|
|
1344
|
+
sequence,
|
|
1345
|
+
)
|
|
1346
|
+
result["finalSequence"] = result["requiredGateDag"][
|
|
1347
|
+
"finalSequence"
|
|
1348
|
+
]
|
|
1349
|
+
return result
|
|
999
1350
|
|
|
1000
1351
|
|
|
1001
1352
|
def gate_policy_document(payload: dict[str, Any]) -> dict[str, Any]:
|
|
1002
1353
|
"""Cross-change contract: meta/gate-policy.json (schemaVersion=1)."""
|
|
1003
|
-
|
|
1354
|
+
document = {
|
|
1004
1355
|
"schemaVersion": 1,
|
|
1005
1356
|
"tier": payload["tier"],
|
|
1006
1357
|
"source": payload["source"],
|
|
@@ -1016,6 +1367,12 @@ def gate_policy_document(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
1016
1367
|
"classifiedAt": payload.get("classifiedAt") or hc.now_iso(),
|
|
1017
1368
|
"tierOverride": payload.get("tierOverride"),
|
|
1018
1369
|
}
|
|
1370
|
+
if isinstance(payload.get("finalSequence"), dict):
|
|
1371
|
+
document["finalSequence"] = dict(payload["finalSequence"])
|
|
1372
|
+
for optional in ("candidateVerification", "releasePolicy"):
|
|
1373
|
+
if isinstance(payload.get(optional), dict):
|
|
1374
|
+
document[optional] = dict(payload[optional])
|
|
1375
|
+
return document
|
|
1019
1376
|
|
|
1020
1377
|
|
|
1021
1378
|
def classify_defaults(
|
|
@@ -1140,6 +1497,7 @@ def append_phase_event(
|
|
|
1140
1497
|
executor_tool: str | None = None,
|
|
1141
1498
|
executor_agent: str | None = None,
|
|
1142
1499
|
executor_model: str | None = None,
|
|
1500
|
+
code: str | None = None,
|
|
1143
1501
|
) -> dict[str, Any]:
|
|
1144
1502
|
events_file = he.events_path(change_dir)
|
|
1145
1503
|
existing = he.load_events(events_file)
|
|
@@ -1154,7 +1512,7 @@ def append_phase_event(
|
|
|
1154
1512
|
name=None,
|
|
1155
1513
|
path=None,
|
|
1156
1514
|
kind=None,
|
|
1157
|
-
code=
|
|
1515
|
+
code=code,
|
|
1158
1516
|
severity=None,
|
|
1159
1517
|
message=None,
|
|
1160
1518
|
decision=None,
|
|
@@ -1192,6 +1550,34 @@ def append_phase_event(
|
|
|
1192
1550
|
}
|
|
1193
1551
|
|
|
1194
1552
|
|
|
1553
|
+
def record_blocked_attempt(
|
|
1554
|
+
change_dir: Path,
|
|
1555
|
+
*,
|
|
1556
|
+
phase: str,
|
|
1557
|
+
code: str,
|
|
1558
|
+
message: str,
|
|
1559
|
+
run_id: str | None = None,
|
|
1560
|
+
) -> dict[str, Any]:
|
|
1561
|
+
"""Persist a rejected begin as a complete, zero-work attempt."""
|
|
1562
|
+
blocked_run_id = run_id or "blocked-" + uuid.uuid4().hex[:12]
|
|
1563
|
+
append_phase_event(
|
|
1564
|
+
change_dir,
|
|
1565
|
+
phase=phase,
|
|
1566
|
+
type_="phase.start",
|
|
1567
|
+
note=f"begin rejected: {code}",
|
|
1568
|
+
run_id=blocked_run_id,
|
|
1569
|
+
)
|
|
1570
|
+
return append_phase_event(
|
|
1571
|
+
change_dir,
|
|
1572
|
+
phase=phase,
|
|
1573
|
+
type_="phase.end",
|
|
1574
|
+
status="BLOCKED",
|
|
1575
|
+
note=message,
|
|
1576
|
+
run_id=blocked_run_id,
|
|
1577
|
+
code=code,
|
|
1578
|
+
)
|
|
1579
|
+
|
|
1580
|
+
|
|
1195
1581
|
def _phase_event_exists(change_dir: Path, phase: str, type_: str, run_id: str) -> bool:
|
|
1196
1582
|
return any(
|
|
1197
1583
|
event.get("phase") == phase
|
|
@@ -1279,6 +1665,15 @@ def cmd_begin(args: argparse.Namespace) -> int:
|
|
|
1279
1665
|
change_dir = Path(resolved["changeDir"])
|
|
1280
1666
|
concurrency_block = hc.check_concurrency_block(project, resolved["changeId"])
|
|
1281
1667
|
if concurrency_block is not None:
|
|
1668
|
+
record_blocked_attempt(
|
|
1669
|
+
change_dir,
|
|
1670
|
+
phase=args.phase,
|
|
1671
|
+
code=str(concurrency_block.get("code", "CONCURRENCY_BLOCKED")),
|
|
1672
|
+
message=str(
|
|
1673
|
+
concurrency_block.get("message", "concurrency mode blocked begin")
|
|
1674
|
+
),
|
|
1675
|
+
run_id=args.run_id or os.environ.get("HUNTER_HARNESS_RUN_ID"),
|
|
1676
|
+
)
|
|
1282
1677
|
return emit_error(
|
|
1283
1678
|
str(concurrency_block.get("code", "CONCURRENCY_BLOCKED")),
|
|
1284
1679
|
str(concurrency_block.get("message", "concurrency mode blocked begin")),
|
|
@@ -1314,6 +1709,18 @@ def cmd_begin(args: argparse.Namespace) -> int:
|
|
|
1314
1709
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
1315
1710
|
return emit_error("BUNDLE_IDENTITY_INVALID", str(exc), as_json=as_json)
|
|
1316
1711
|
executor_tool = executor_tool or str(identity.get("adapter") or "") or None
|
|
1712
|
+
projection = evaluate_projection_gate(project, args.phase)
|
|
1713
|
+
if not projection.get("ok"):
|
|
1714
|
+
return emit_error(
|
|
1715
|
+
str(projection.get("code") or "PROJECTION_BLOCKED"),
|
|
1716
|
+
str(projection.get("message") or "projection gate blocked phase"),
|
|
1717
|
+
as_json=as_json,
|
|
1718
|
+
extra={
|
|
1719
|
+
key: value
|
|
1720
|
+
for key, value in projection.items()
|
|
1721
|
+
if key not in {"ok", "code", "message"}
|
|
1722
|
+
},
|
|
1723
|
+
)
|
|
1317
1724
|
explicit_run_id = args.run_id or os.environ.get("HUNTER_HARNESS_RUN_ID")
|
|
1318
1725
|
current_lease = hc.inspect_lease(project, resolved["changeId"])
|
|
1319
1726
|
run_id = explicit_run_id or (
|
|
@@ -1325,11 +1732,16 @@ def cmd_begin(args: argparse.Namespace) -> int:
|
|
|
1325
1732
|
capsule = load_phase_capsule(change_dir, args.phase, run_id)
|
|
1326
1733
|
except ValueError as exc:
|
|
1327
1734
|
return emit_error("PHASE_CAPSULE_INVALID", str(exc), as_json=as_json)
|
|
1328
|
-
execution_hint =
|
|
1329
|
-
|
|
1330
|
-
|
|
1735
|
+
execution_hint = resolve_begin_execution_hint(
|
|
1736
|
+
project,
|
|
1737
|
+
requested=args.project,
|
|
1738
|
+
capsule=capsule,
|
|
1739
|
+
)
|
|
1331
1740
|
try:
|
|
1332
|
-
execution_root = resolve_execution_root(
|
|
1741
|
+
execution_root = resolve_execution_root(
|
|
1742
|
+
project,
|
|
1743
|
+
execution_hint["executionRoot"],
|
|
1744
|
+
)
|
|
1333
1745
|
except ValueError as exc:
|
|
1334
1746
|
return emit_error("EXECUTION_ROOT_INVALID", str(exc), as_json=as_json)
|
|
1335
1747
|
if capsule is not None:
|
|
@@ -1398,6 +1810,7 @@ def cmd_begin(args: argparse.Namespace) -> int:
|
|
|
1398
1810
|
"baseCommit": base_commit or current_head,
|
|
1399
1811
|
"currentHead": current_head,
|
|
1400
1812
|
"createdAt": he.now_iso(),
|
|
1813
|
+
"projectionReceipt": projection,
|
|
1401
1814
|
}
|
|
1402
1815
|
write_phase_capsule(change_dir, args.phase, run_id, capsule)
|
|
1403
1816
|
event_result = {"ok": True, "skipped": True, "reason": "already-recorded"} \
|
|
@@ -1434,6 +1847,8 @@ def cmd_begin(args: argparse.Namespace) -> int:
|
|
|
1434
1847
|
"event": event_result,
|
|
1435
1848
|
"testGuard": guard_result,
|
|
1436
1849
|
"policySchemaVersion": policy.get("schemaVersion"),
|
|
1850
|
+
"projection": projection,
|
|
1851
|
+
"executionRootSource": execution_hint["source"],
|
|
1437
1852
|
}
|
|
1438
1853
|
emit(payload, as_json=as_json)
|
|
1439
1854
|
return 0
|
|
@@ -1491,6 +1906,18 @@ def cmd_close(args: argparse.Namespace) -> int:
|
|
|
1491
1906
|
if args.project:
|
|
1492
1907
|
requested_root = resolve_execution_root(project, args.project)
|
|
1493
1908
|
if requested_root != execution_root:
|
|
1909
|
+
persist_close_failure(
|
|
1910
|
+
change_dir,
|
|
1911
|
+
args.phase,
|
|
1912
|
+
run_id,
|
|
1913
|
+
capsule,
|
|
1914
|
+
status="ROOT_VALIDATION_FAILED",
|
|
1915
|
+
error={
|
|
1916
|
+
"code": "EXECUTION_ROOT_MISMATCH",
|
|
1917
|
+
"storedExecutionRoot": str(execution_root),
|
|
1918
|
+
"requestedExecutionRoot": str(requested_root),
|
|
1919
|
+
},
|
|
1920
|
+
)
|
|
1494
1921
|
return emit_error(
|
|
1495
1922
|
"EXECUTION_ROOT_MISMATCH",
|
|
1496
1923
|
"close must use the execution root captured at begin",
|
|
@@ -1498,9 +1925,24 @@ def cmd_close(args: argparse.Namespace) -> int:
|
|
|
1498
1925
|
extra={
|
|
1499
1926
|
"storedExecutionRoot": str(execution_root),
|
|
1500
1927
|
"requestedExecutionRoot": str(requested_root),
|
|
1928
|
+
"recoveryAction": (
|
|
1929
|
+
"retry close from the stored execution root or "
|
|
1930
|
+
"pass --project with storedExecutionRoot"
|
|
1931
|
+
),
|
|
1501
1932
|
},
|
|
1502
1933
|
)
|
|
1503
1934
|
except ValueError as exc:
|
|
1935
|
+
persist_close_failure(
|
|
1936
|
+
change_dir,
|
|
1937
|
+
args.phase,
|
|
1938
|
+
run_id,
|
|
1939
|
+
capsule,
|
|
1940
|
+
status="ROOT_VALIDATION_FAILED",
|
|
1941
|
+
error={
|
|
1942
|
+
"code": "EXECUTION_ROOT_INVALID",
|
|
1943
|
+
"message": str(exc),
|
|
1944
|
+
},
|
|
1945
|
+
)
|
|
1504
1946
|
return emit_error("EXECUTION_ROOT_INVALID", str(exc), as_json=as_json)
|
|
1505
1947
|
else:
|
|
1506
1948
|
try:
|
|
@@ -1520,13 +1962,85 @@ def cmd_close(args: argparse.Namespace) -> int:
|
|
|
1520
1962
|
allow_head_advance=args.phase in {"run", "submit", "merge"},
|
|
1521
1963
|
)
|
|
1522
1964
|
except ValueError as exc:
|
|
1965
|
+
persist_close_failure(
|
|
1966
|
+
change_dir,
|
|
1967
|
+
args.phase,
|
|
1968
|
+
run_id,
|
|
1969
|
+
capsule,
|
|
1970
|
+
status="CAPSULE_VALIDATION_FAILED",
|
|
1971
|
+
error={
|
|
1972
|
+
"code": "PHASE_CAPSULE_MISMATCH",
|
|
1973
|
+
"message": str(exc),
|
|
1974
|
+
},
|
|
1975
|
+
)
|
|
1523
1976
|
return emit_error("PHASE_CAPSULE_MISMATCH", str(exc), as_json=as_json)
|
|
1524
1977
|
|
|
1978
|
+
projection = evaluate_projection_gate(project, args.phase)
|
|
1979
|
+
if not projection.get("ok"):
|
|
1980
|
+
persist_close_failure(
|
|
1981
|
+
change_dir,
|
|
1982
|
+
args.phase,
|
|
1983
|
+
run_id,
|
|
1984
|
+
capsule,
|
|
1985
|
+
status="PROJECTION_VALIDATION_FAILED",
|
|
1986
|
+
error=projection,
|
|
1987
|
+
)
|
|
1988
|
+
return emit_error(
|
|
1989
|
+
str(projection.get("code") or "PROJECTION_BLOCKED"),
|
|
1990
|
+
str(projection.get("message") or "projection gate blocked close"),
|
|
1991
|
+
as_json=as_json,
|
|
1992
|
+
extra={
|
|
1993
|
+
key: value
|
|
1994
|
+
for key, value in projection.items()
|
|
1995
|
+
if key not in {"ok", "code", "message"}
|
|
1996
|
+
},
|
|
1997
|
+
)
|
|
1998
|
+
captured_projection = (
|
|
1999
|
+
capsule.get("projectionReceipt")
|
|
2000
|
+
if isinstance(capsule, dict)
|
|
2001
|
+
else None
|
|
2002
|
+
)
|
|
2003
|
+
if (
|
|
2004
|
+
isinstance(captured_projection, dict)
|
|
2005
|
+
and captured_projection.get("receiptHash")
|
|
2006
|
+
and captured_projection.get("receiptHash")
|
|
2007
|
+
!= projection.get("receiptHash")
|
|
2008
|
+
and args.phase in {"submit", "archive", "release", "deploy"}
|
|
2009
|
+
):
|
|
2010
|
+
mismatch = {
|
|
2011
|
+
"code": "PROJECTION_CHANGED_DURING_PHASE",
|
|
2012
|
+
"message": "projection receipt changed after phase begin",
|
|
2013
|
+
"capturedReceiptHash": captured_projection.get("receiptHash"),
|
|
2014
|
+
"currentReceiptHash": projection.get("receiptHash"),
|
|
2015
|
+
}
|
|
2016
|
+
persist_close_failure(
|
|
2017
|
+
change_dir,
|
|
2018
|
+
args.phase,
|
|
2019
|
+
run_id,
|
|
2020
|
+
capsule,
|
|
2021
|
+
status="PROJECTION_VALIDATION_FAILED",
|
|
2022
|
+
error=mismatch,
|
|
2023
|
+
)
|
|
2024
|
+
return emit_error(
|
|
2025
|
+
mismatch["code"],
|
|
2026
|
+
mismatch["message"],
|
|
2027
|
+
as_json=as_json,
|
|
2028
|
+
extra=mismatch,
|
|
2029
|
+
)
|
|
2030
|
+
|
|
1525
2031
|
ledger_result = validate_ledger_for_phase_close(
|
|
1526
2032
|
change_dir, args.phase, policy, execution_root=execution_root,
|
|
1527
2033
|
phase_status=args.status,
|
|
1528
2034
|
)
|
|
1529
2035
|
if not ledger_result.get("ok") and args.phase in {"run", "test", "package"}:
|
|
2036
|
+
persist_close_failure(
|
|
2037
|
+
change_dir,
|
|
2038
|
+
args.phase,
|
|
2039
|
+
run_id,
|
|
2040
|
+
capsule,
|
|
2041
|
+
status="LEDGER_VALIDATION_FAILED",
|
|
2042
|
+
error=ledger_result,
|
|
2043
|
+
)
|
|
1530
2044
|
return emit_error(
|
|
1531
2045
|
str(ledger_result.get("code", "LEDGER_INVALID")),
|
|
1532
2046
|
str(ledger_result.get("message", "ledger validation failed")),
|
|
@@ -1538,6 +2052,14 @@ def cmd_close(args: argparse.Namespace) -> int:
|
|
|
1538
2052
|
if args.phase in {"run", "test"}:
|
|
1539
2053
|
coverage = _validate_scenario_coverage(change_dir)
|
|
1540
2054
|
if not coverage.get("ok"):
|
|
2055
|
+
persist_close_failure(
|
|
2056
|
+
change_dir,
|
|
2057
|
+
args.phase,
|
|
2058
|
+
run_id,
|
|
2059
|
+
capsule,
|
|
2060
|
+
status="SCENARIO_VALIDATION_FAILED",
|
|
2061
|
+
error=coverage,
|
|
2062
|
+
)
|
|
1541
2063
|
return emit_error(
|
|
1542
2064
|
str(coverage.get("code", "SCENARIO_COVERAGE_FAILED")),
|
|
1543
2065
|
str(coverage.get("message", "scenario coverage validation failed")),
|