@hunter-harness/workflow-harness 0.2.13 → 0.2.15
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-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-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-run/reference.md +3 -1
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +2 -2
- package/harness/bundles/general/claude-code/harness-submit/checklist.md +1 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +204 -0
- package/harness/bundles/general/claude-code/scripts/harness_change.py +310 -16
- package/harness/bundles/general/claude-code/scripts/harness_deploy.py +177 -0
- package/harness/bundles/general/claude-code/scripts/harness_events.py +113 -1
- package/harness/bundles/general/claude-code/scripts/harness_gate.py +57 -4
- package/harness/bundles/general/claude-code/scripts/harness_integration.py +67 -7
- package/harness/bundles/general/claude-code/scripts/harness_ledger.py +15 -3
- package/harness/bundles/general/claude-code/scripts/harness_plan_finalize.py +85 -0
- package/harness/bundles/general/claude-code/scripts/harness_preflight.py +193 -77
- package/harness/bundles/general/claude-code/scripts/harness_state.py +12 -4
- package/harness/bundles/general/claude-code/scripts/harness_test_guard.py +86 -13
- 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-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-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-run/reference.md +3 -1
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +2 -2
- package/harness/bundles/general/codebuddy/harness-submit/checklist.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +204 -0
- package/harness/bundles/general/codebuddy/scripts/harness_change.py +310 -16
- package/harness/bundles/general/codebuddy/scripts/harness_deploy.py +177 -0
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +113 -1
- package/harness/bundles/general/codebuddy/scripts/harness_gate.py +57 -4
- package/harness/bundles/general/codebuddy/scripts/harness_integration.py +67 -7
- package/harness/bundles/general/codebuddy/scripts/harness_ledger.py +15 -3
- package/harness/bundles/general/codebuddy/scripts/harness_plan_finalize.py +85 -0
- package/harness/bundles/general/codebuddy/scripts/harness_preflight.py +193 -77
- package/harness/bundles/general/codebuddy/scripts/harness_state.py +12 -4
- package/harness/bundles/general/codebuddy/scripts/harness_test_guard.py +86 -13
- 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-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-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-run/reference.md +3 -1
- package/harness/bundles/general/codex/harness-submit/SKILL.md +2 -2
- package/harness/bundles/general/codex/harness-submit/checklist.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/codex/scripts/harness_archive.py +204 -0
- package/harness/bundles/general/codex/scripts/harness_change.py +310 -16
- package/harness/bundles/general/codex/scripts/harness_deploy.py +177 -0
- package/harness/bundles/general/codex/scripts/harness_events.py +113 -1
- package/harness/bundles/general/codex/scripts/harness_gate.py +57 -4
- package/harness/bundles/general/codex/scripts/harness_integration.py +67 -7
- package/harness/bundles/general/codex/scripts/harness_ledger.py +15 -3
- package/harness/bundles/general/codex/scripts/harness_plan_finalize.py +85 -0
- package/harness/bundles/general/codex/scripts/harness_preflight.py +193 -77
- package/harness/bundles/general/codex/scripts/harness_state.py +12 -4
- package/harness/bundles/general/codex/scripts/harness_test_guard.py +86 -13
- 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-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-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-run/reference.md +3 -1
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +2 -2
- package/harness/bundles/general/cursor/harness-submit/checklist.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/general/cursor/scripts/harness_archive.py +204 -0
- package/harness/bundles/general/cursor/scripts/harness_change.py +310 -16
- package/harness/bundles/general/cursor/scripts/harness_deploy.py +177 -0
- package/harness/bundles/general/cursor/scripts/harness_events.py +113 -1
- package/harness/bundles/general/cursor/scripts/harness_gate.py +57 -4
- package/harness/bundles/general/cursor/scripts/harness_integration.py +67 -7
- package/harness/bundles/general/cursor/scripts/harness_ledger.py +15 -3
- package/harness/bundles/general/cursor/scripts/harness_plan_finalize.py +85 -0
- package/harness/bundles/general/cursor/scripts/harness_preflight.py +193 -77
- package/harness/bundles/general/cursor/scripts/harness_state.py +12 -4
- package/harness/bundles/general/cursor/scripts/harness_test_guard.py +86 -13
- 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-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-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 +2 -2
- package/harness/bundles/java/claude-code/harness-submit/checklist.md +1 -1
- package/harness/bundles/java/claude-code/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +204 -0
- package/harness/bundles/java/claude-code/scripts/harness_change.py +310 -16
- package/harness/bundles/java/claude-code/scripts/harness_deploy.py +177 -0
- package/harness/bundles/java/claude-code/scripts/harness_events.py +113 -1
- package/harness/bundles/java/claude-code/scripts/harness_gate.py +57 -4
- package/harness/bundles/java/claude-code/scripts/harness_integration.py +67 -7
- package/harness/bundles/java/claude-code/scripts/harness_ledger.py +15 -3
- package/harness/bundles/java/claude-code/scripts/harness_plan_finalize.py +85 -0
- package/harness/bundles/java/claude-code/scripts/harness_preflight.py +193 -77
- package/harness/bundles/java/claude-code/scripts/harness_state.py +12 -4
- package/harness/bundles/java/claude-code/scripts/harness_test_guard.py +86 -13
- 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-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-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 +2 -2
- package/harness/bundles/java/codebuddy/harness-submit/checklist.md +1 -1
- package/harness/bundles/java/codebuddy/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +204 -0
- package/harness/bundles/java/codebuddy/scripts/harness_change.py +310 -16
- package/harness/bundles/java/codebuddy/scripts/harness_deploy.py +177 -0
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +113 -1
- package/harness/bundles/java/codebuddy/scripts/harness_gate.py +57 -4
- package/harness/bundles/java/codebuddy/scripts/harness_integration.py +67 -7
- package/harness/bundles/java/codebuddy/scripts/harness_ledger.py +15 -3
- package/harness/bundles/java/codebuddy/scripts/harness_plan_finalize.py +85 -0
- package/harness/bundles/java/codebuddy/scripts/harness_preflight.py +193 -77
- package/harness/bundles/java/codebuddy/scripts/harness_state.py +12 -4
- package/harness/bundles/java/codebuddy/scripts/harness_test_guard.py +86 -13
- 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-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-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 +2 -2
- package/harness/bundles/java/codex/harness-submit/checklist.md +1 -1
- package/harness/bundles/java/codex/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/codex/scripts/harness_archive.py +204 -0
- package/harness/bundles/java/codex/scripts/harness_change.py +310 -16
- package/harness/bundles/java/codex/scripts/harness_deploy.py +177 -0
- package/harness/bundles/java/codex/scripts/harness_events.py +113 -1
- package/harness/bundles/java/codex/scripts/harness_gate.py +57 -4
- package/harness/bundles/java/codex/scripts/harness_integration.py +67 -7
- package/harness/bundles/java/codex/scripts/harness_ledger.py +15 -3
- package/harness/bundles/java/codex/scripts/harness_plan_finalize.py +85 -0
- package/harness/bundles/java/codex/scripts/harness_preflight.py +193 -77
- package/harness/bundles/java/codex/scripts/harness_state.py +12 -4
- package/harness/bundles/java/codex/scripts/harness_test_guard.py +86 -13
- 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-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-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 +2 -2
- package/harness/bundles/java/cursor/harness-submit/checklist.md +1 -1
- package/harness/bundles/java/cursor/harness-sync/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-test/SKILL.md +1 -1
- package/harness/bundles/java/cursor/scripts/harness_archive.py +204 -0
- package/harness/bundles/java/cursor/scripts/harness_change.py +310 -16
- package/harness/bundles/java/cursor/scripts/harness_deploy.py +177 -0
- package/harness/bundles/java/cursor/scripts/harness_events.py +113 -1
- package/harness/bundles/java/cursor/scripts/harness_gate.py +57 -4
- package/harness/bundles/java/cursor/scripts/harness_integration.py +67 -7
- package/harness/bundles/java/cursor/scripts/harness_ledger.py +15 -3
- package/harness/bundles/java/cursor/scripts/harness_plan_finalize.py +85 -0
- package/harness/bundles/java/cursor/scripts/harness_preflight.py +193 -77
- package/harness/bundles/java/cursor/scripts/harness_state.py +12 -4
- package/harness/bundles/java/cursor/scripts/harness_test_guard.py +86 -13
- package/harness/manifests/general/claude-code.json +25 -25
- package/harness/manifests/general/codebuddy.json +25 -25
- package/harness/manifests/general/codex.json +25 -25
- package/harness/manifests/general/cursor.json +25 -25
- package/harness/manifests/java/claude-code.json +26 -26
- package/harness/manifests/java/codebuddy.json +26 -26
- package/harness/manifests/java/codex.json +26 -26
- package/harness/manifests/java/cursor.json +26 -26
- package/hunter-workflow-family.json +2 -2
- package/package.json +15 -15
|
@@ -224,6 +224,60 @@ def _layout_fields(project_root: Path, change_id: str) -> dict[str, Any]:
|
|
|
224
224
|
}
|
|
225
225
|
|
|
226
226
|
|
|
227
|
+
def read_concurrency_mode(project_root: Path) -> str:
|
|
228
|
+
"""Return the configured concurrency mode (retro §5.2).
|
|
229
|
+
|
|
230
|
+
Defaults to ``single-active`` when no config declares a mode. Supported
|
|
231
|
+
values: ``single-active`` (default, blocks a second active Change),
|
|
232
|
+
``isolated-multi-active`` (allows multiple active Changes but all
|
|
233
|
+
Change-scoped commands require ``--change``).
|
|
234
|
+
"""
|
|
235
|
+
cfg = project_root / ".harness" / "config.json"
|
|
236
|
+
if cfg.is_file():
|
|
237
|
+
try:
|
|
238
|
+
data = json.loads(cfg.read_text(encoding="utf-8"))
|
|
239
|
+
if isinstance(data, dict):
|
|
240
|
+
mode = data.get("concurrencyMode")
|
|
241
|
+
if isinstance(mode, str) and mode in {
|
|
242
|
+
"single-active",
|
|
243
|
+
"isolated-multi-active",
|
|
244
|
+
}:
|
|
245
|
+
return mode
|
|
246
|
+
except (OSError, json.JSONDecodeError):
|
|
247
|
+
pass
|
|
248
|
+
return "single-active"
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def check_concurrency_block(
|
|
252
|
+
project_root: Path, change_id: str
|
|
253
|
+
) -> dict[str, Any] | None:
|
|
254
|
+
"""Return a blocking payload when a second active Change is forbidden.
|
|
255
|
+
|
|
256
|
+
Returns ``None`` when the begin may proceed. In ``single-active`` mode a
|
|
257
|
+
second active Change (any other than ``change_id``) blocks begin; in
|
|
258
|
+
``isolated-multi-active`` mode multiple active Changes are allowed.
|
|
259
|
+
"""
|
|
260
|
+
mode = read_concurrency_mode(project_root)
|
|
261
|
+
if mode == "isolated-multi-active":
|
|
262
|
+
return None
|
|
263
|
+
active = list_active_changes(project_root)
|
|
264
|
+
others = [entry for entry in active if entry.get("changeId") != change_id]
|
|
265
|
+
if not others:
|
|
266
|
+
return None
|
|
267
|
+
return {
|
|
268
|
+
"ok": False,
|
|
269
|
+
"code": "SINGLE_ACTIVE_BLOCKED",
|
|
270
|
+
"message": (
|
|
271
|
+
"single-active concurrency mode: another active Change exists; "
|
|
272
|
+
"use portfolio/decompose to sequence multiple Changes, or switch "
|
|
273
|
+
"to isolated-multi-active after ensuring full Change-scoped isolation"
|
|
274
|
+
),
|
|
275
|
+
"concurrencyMode": mode,
|
|
276
|
+
"activeChanges": active,
|
|
277
|
+
"blockingChanges": others,
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
|
|
227
281
|
def resolve_change(
|
|
228
282
|
project_root: Path,
|
|
229
283
|
change_id: str | None,
|
|
@@ -536,7 +590,9 @@ def lease_port(
|
|
|
536
590
|
}
|
|
537
591
|
for port in range(start, end + 1):
|
|
538
592
|
if port not in used:
|
|
593
|
+
lease_id = str(uuid.uuid4())
|
|
539
594
|
entry = {
|
|
595
|
+
"leaseId": lease_id,
|
|
540
596
|
"changeId": change_id,
|
|
541
597
|
"runId": run_id,
|
|
542
598
|
"pid": os.getpid(),
|
|
@@ -549,7 +605,7 @@ def lease_port(
|
|
|
549
605
|
leases.append(entry)
|
|
550
606
|
registry["leases"] = leases
|
|
551
607
|
_write_json(registry_path, registry)
|
|
552
|
-
return {"ok": True, "code": "PORT_LEASED", "port": port, "lease": entry}
|
|
608
|
+
return {"ok": True, "code": "PORT_LEASED", "port": port, "leaseId": lease_id, "lease": entry}
|
|
553
609
|
return {
|
|
554
610
|
"ok": False,
|
|
555
611
|
"code": "PORT_RANGE_EXHAUSTED",
|
|
@@ -562,7 +618,16 @@ def release_port(
|
|
|
562
618
|
*,
|
|
563
619
|
change_id: str,
|
|
564
620
|
run_id: str,
|
|
621
|
+
port: int | None = None,
|
|
622
|
+
lease_id: str | None = None,
|
|
565
623
|
) -> dict[str, Any]:
|
|
624
|
+
"""Release port leases by subset (retro §5.16).
|
|
625
|
+
|
|
626
|
+
- `--lease-id`: release only the matching leaseId.
|
|
627
|
+
- `--port`: release only the matching port (must match changeId+runId).
|
|
628
|
+
- Neither: release all leases matching (changeId, runId) — does NOT require
|
|
629
|
+
all leases under changeId to have the same runId.
|
|
630
|
+
"""
|
|
566
631
|
registry_path = project_root / PORTS_REL / "registry.json"
|
|
567
632
|
if not registry_path.is_file():
|
|
568
633
|
return {"ok": True, "code": "PORT_LEASE_ABSENT", "changeId": change_id}
|
|
@@ -574,27 +639,84 @@ def release_port(
|
|
|
574
639
|
leases = registry.get("leases") if isinstance(registry, dict) else None
|
|
575
640
|
if not isinstance(leases, list):
|
|
576
641
|
return {"ok": False, "code": "PORT_REGISTRY_INVALID", "message": "leases is not a list"}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
642
|
+
|
|
643
|
+
# Select the subset to release based on lease_id, port, or (changeId, runId)
|
|
644
|
+
if lease_id is not None:
|
|
645
|
+
to_release = [
|
|
646
|
+
item for item in leases
|
|
647
|
+
if isinstance(item, dict) and str(item.get("leaseId")) == lease_id
|
|
648
|
+
]
|
|
649
|
+
# Validate that the lease belongs to this change
|
|
650
|
+
for item in to_release:
|
|
651
|
+
if str(item.get("changeId")) != change_id:
|
|
652
|
+
return {
|
|
653
|
+
"ok": False,
|
|
654
|
+
"code": "PORT_LEASE_OWNER_MISMATCH",
|
|
655
|
+
"message": "leaseId does not belong to this change",
|
|
656
|
+
"holder": item,
|
|
657
|
+
}
|
|
658
|
+
elif port is not None:
|
|
659
|
+
to_release = [
|
|
660
|
+
item for item in leases
|
|
661
|
+
if isinstance(item, dict)
|
|
662
|
+
and item.get("port") == port
|
|
663
|
+
and str(item.get("changeId")) == change_id
|
|
664
|
+
]
|
|
665
|
+
# Validate runId matches
|
|
666
|
+
for item in to_release:
|
|
667
|
+
if str(item.get("runId")) != run_id:
|
|
668
|
+
return {
|
|
669
|
+
"ok": False,
|
|
670
|
+
"code": "PORT_LEASE_OWNER_MISMATCH",
|
|
671
|
+
"message": f"port {port} owned by different runId",
|
|
672
|
+
"holder": item,
|
|
673
|
+
"conflictingOwners": [
|
|
674
|
+
{"runId": str(i.get("runId")), "port": i.get("port")}
|
|
675
|
+
for i in leases
|
|
676
|
+
if isinstance(i, dict)
|
|
677
|
+
and i.get("port") == port
|
|
678
|
+
and str(i.get("changeId")) == change_id
|
|
679
|
+
],
|
|
680
|
+
}
|
|
681
|
+
else:
|
|
682
|
+
# Release all matching (changeId, runId) — subset release
|
|
683
|
+
to_release = [
|
|
684
|
+
item for item in leases
|
|
685
|
+
if isinstance(item, dict)
|
|
686
|
+
and str(item.get("changeId")) == change_id
|
|
687
|
+
and str(item.get("runId")) == run_id
|
|
688
|
+
]
|
|
689
|
+
|
|
690
|
+
if not to_release:
|
|
691
|
+
# Check if there are other leases under this changeId (different runId)
|
|
692
|
+
other_owned = [
|
|
693
|
+
item for item in leases
|
|
694
|
+
if isinstance(item, dict)
|
|
695
|
+
and str(item.get("changeId")) == change_id
|
|
696
|
+
and str(item.get("runId")) != run_id
|
|
697
|
+
]
|
|
698
|
+
if other_owned:
|
|
699
|
+
return {
|
|
700
|
+
"ok": False,
|
|
701
|
+
"code": "PORT_LEASE_OWNER_MISMATCH",
|
|
702
|
+
"message": "no matching leases for this runId; other runIds exist under this changeId",
|
|
703
|
+
"conflictingOwners": [
|
|
704
|
+
{"runId": str(i.get("runId")), "port": i.get("port"), "leaseId": i.get("leaseId")}
|
|
705
|
+
for i in other_owned
|
|
706
|
+
],
|
|
707
|
+
}
|
|
582
708
|
return {"ok": True, "code": "PORT_LEASE_ABSENT", "changeId": change_id}
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
"message": "run id does not match port lease owner",
|
|
588
|
-
"holder": owned[0],
|
|
589
|
-
}
|
|
590
|
-
released_ports = [item.get("port") for item in owned]
|
|
591
|
-
registry["leases"] = [item for item in leases if item not in owned]
|
|
709
|
+
|
|
710
|
+
released_ports = [item.get("port") for item in to_release]
|
|
711
|
+
released_ids = [item.get("leaseId") for item in to_release]
|
|
712
|
+
registry["leases"] = [item for item in leases if item not in to_release]
|
|
592
713
|
_write_json(registry_path, registry)
|
|
593
714
|
return {
|
|
594
715
|
"ok": True,
|
|
595
716
|
"code": "PORT_LEASE_RELEASED",
|
|
596
717
|
"changeId": change_id,
|
|
597
718
|
"ports": released_ports,
|
|
719
|
+
"leaseIds": released_ids,
|
|
598
720
|
}
|
|
599
721
|
|
|
600
722
|
|
|
@@ -794,7 +916,13 @@ def cmd_lease_port(args: argparse.Namespace) -> int:
|
|
|
794
916
|
|
|
795
917
|
def cmd_release_port(args: argparse.Namespace) -> int:
|
|
796
918
|
project = resolve_main_project_root()
|
|
797
|
-
payload = release_port(
|
|
919
|
+
payload = release_port(
|
|
920
|
+
project,
|
|
921
|
+
change_id=args.change,
|
|
922
|
+
run_id=args.run_id,
|
|
923
|
+
port=getattr(args, "port", None),
|
|
924
|
+
lease_id=getattr(args, "lease_id", None),
|
|
925
|
+
)
|
|
798
926
|
if payload.get("ok"):
|
|
799
927
|
emit(payload, as_json=bool(args.json))
|
|
800
928
|
return 0
|
|
@@ -827,6 +955,161 @@ def cmd_integration_lock(args: argparse.Namespace) -> int:
|
|
|
827
955
|
)
|
|
828
956
|
|
|
829
957
|
|
|
958
|
+
def _ensure_change_identity(change_dir: Path) -> dict[str, str]:
|
|
959
|
+
"""Ensure meta/change-identity.json exists with a stable UUID (retro §5.5).
|
|
960
|
+
|
|
961
|
+
Returns the identity dict with changeUuid and changeName.
|
|
962
|
+
"""
|
|
963
|
+
identity_path = change_dir / "meta" / "change-identity.json"
|
|
964
|
+
if identity_path.is_file():
|
|
965
|
+
try:
|
|
966
|
+
data = json.loads(identity_path.read_text(encoding="utf-8-sig"))
|
|
967
|
+
if isinstance(data, dict) and data.get("changeUuid"):
|
|
968
|
+
return data
|
|
969
|
+
except (OSError, json.JSONDecodeError):
|
|
970
|
+
pass
|
|
971
|
+
# Generate new identity
|
|
972
|
+
identity = {
|
|
973
|
+
"schemaVersion": 1,
|
|
974
|
+
"changeUuid": str(uuid.uuid4()),
|
|
975
|
+
"changeName": change_dir.name,
|
|
976
|
+
"createdAt": now_iso(),
|
|
977
|
+
}
|
|
978
|
+
identity_path.parent.mkdir(parents=True, exist_ok=True)
|
|
979
|
+
identity_path.write_text(
|
|
980
|
+
json.dumps(identity, ensure_ascii=False, indent=2) + "\n",
|
|
981
|
+
encoding="utf-8",
|
|
982
|
+
newline="\n",
|
|
983
|
+
)
|
|
984
|
+
return identity
|
|
985
|
+
|
|
986
|
+
|
|
987
|
+
def cmd_rename(args: argparse.Namespace) -> int:
|
|
988
|
+
"""Rename a change atomically: directory, pointers, worktree, identity (retro §5.5).
|
|
989
|
+
|
|
990
|
+
Appends a change.rename event; does not rewrite history.
|
|
991
|
+
"""
|
|
992
|
+
project = resolve_main_project_root()
|
|
993
|
+
changes_root = project / ".harness" / "changes"
|
|
994
|
+
old_dir = changes_root / args.change
|
|
995
|
+
new_dir = changes_root / args.to
|
|
996
|
+
|
|
997
|
+
if not old_dir.is_dir():
|
|
998
|
+
return emit_error(
|
|
999
|
+
"CHANGE_NOT_FOUND",
|
|
1000
|
+
f"change directory not found: {old_dir}",
|
|
1001
|
+
as_json=bool(args.json),
|
|
1002
|
+
)
|
|
1003
|
+
if new_dir.exists():
|
|
1004
|
+
return emit_error(
|
|
1005
|
+
"CHANGE_ALREADY_EXISTS",
|
|
1006
|
+
f"target change directory already exists: {new_dir}",
|
|
1007
|
+
as_json=bool(args.json),
|
|
1008
|
+
)
|
|
1009
|
+
|
|
1010
|
+
# Ensure identity exists before rename
|
|
1011
|
+
identity = _ensure_change_identity(old_dir)
|
|
1012
|
+
old_uuid = identity.get("changeUuid", "")
|
|
1013
|
+
|
|
1014
|
+
# Rename directory
|
|
1015
|
+
import shutil
|
|
1016
|
+
shutil.move(str(old_dir), str(new_dir))
|
|
1017
|
+
|
|
1018
|
+
# Update knowledge-context.json.changeId if present
|
|
1019
|
+
kc_path = new_dir / "meta" / "knowledge-context.json"
|
|
1020
|
+
if kc_path.is_file():
|
|
1021
|
+
try:
|
|
1022
|
+
kc = json.loads(kc_path.read_text(encoding="utf-8-sig"))
|
|
1023
|
+
if isinstance(kc, dict):
|
|
1024
|
+
kc["changeId"] = args.to
|
|
1025
|
+
kc["changeUuid"] = old_uuid
|
|
1026
|
+
kc_path.write_text(
|
|
1027
|
+
json.dumps(kc, ensure_ascii=False, indent=2) + "\n",
|
|
1028
|
+
encoding="utf-8",
|
|
1029
|
+
newline="\n",
|
|
1030
|
+
)
|
|
1031
|
+
except (OSError, json.JSONDecodeError):
|
|
1032
|
+
pass
|
|
1033
|
+
|
|
1034
|
+
# Update worktree.json path/branch if present
|
|
1035
|
+
wt_path = new_dir / "meta" / "worktree.json"
|
|
1036
|
+
if wt_path.is_file():
|
|
1037
|
+
try:
|
|
1038
|
+
wt = json.loads(wt_path.read_text(encoding="utf-8-sig"))
|
|
1039
|
+
if isinstance(wt, dict):
|
|
1040
|
+
old_name = args.change
|
|
1041
|
+
new_name = args.to
|
|
1042
|
+
# Update path and branch if they contain old name
|
|
1043
|
+
if isinstance(wt.get("path"), str) and old_name in wt["path"]:
|
|
1044
|
+
wt["path"] = wt["path"].replace(old_name, new_name)
|
|
1045
|
+
if isinstance(wt.get("branch"), str) and old_name in wt["branch"]:
|
|
1046
|
+
wt["branch"] = wt["branch"].replace(old_name, new_name)
|
|
1047
|
+
wt_path.write_text(
|
|
1048
|
+
json.dumps(wt, ensure_ascii=False, indent=2) + "\n",
|
|
1049
|
+
encoding="utf-8",
|
|
1050
|
+
newline="\n",
|
|
1051
|
+
)
|
|
1052
|
+
except (OSError, json.JSONDecodeError):
|
|
1053
|
+
pass
|
|
1054
|
+
|
|
1055
|
+
# Update change-identity.json.changeName
|
|
1056
|
+
identity_path = new_dir / "meta" / "change-identity.json"
|
|
1057
|
+
if identity_path.is_file():
|
|
1058
|
+
try:
|
|
1059
|
+
ident = json.loads(identity_path.read_text(encoding="utf-8-sig"))
|
|
1060
|
+
if isinstance(ident, dict):
|
|
1061
|
+
ident["changeName"] = args.to
|
|
1062
|
+
ident["renamedFrom"] = args.change
|
|
1063
|
+
identity_path.write_text(
|
|
1064
|
+
json.dumps(ident, ensure_ascii=False, indent=2) + "\n",
|
|
1065
|
+
encoding="utf-8",
|
|
1066
|
+
newline="\n",
|
|
1067
|
+
)
|
|
1068
|
+
except (OSError, json.JSONDecodeError):
|
|
1069
|
+
pass
|
|
1070
|
+
|
|
1071
|
+
# Append change.rename event (does not rewrite history)
|
|
1072
|
+
try:
|
|
1073
|
+
import harness_events
|
|
1074
|
+
harness_events.append_event(
|
|
1075
|
+
new_dir,
|
|
1076
|
+
phase="plan",
|
|
1077
|
+
type_="change.rename",
|
|
1078
|
+
renamed_from=args.change,
|
|
1079
|
+
renamed_to=args.to,
|
|
1080
|
+
change_uuid=old_uuid,
|
|
1081
|
+
note=f"change renamed from {args.change} to {args.to}",
|
|
1082
|
+
)
|
|
1083
|
+
except Exception:
|
|
1084
|
+
pass # event append failure should not block rename
|
|
1085
|
+
|
|
1086
|
+
payload = {
|
|
1087
|
+
"ok": True,
|
|
1088
|
+
"code": "RENAMED",
|
|
1089
|
+
"changeUuid": old_uuid,
|
|
1090
|
+
"renamedFrom": args.change,
|
|
1091
|
+
"renamedTo": args.to,
|
|
1092
|
+
"changeDir": str(new_dir),
|
|
1093
|
+
}
|
|
1094
|
+
emit(payload, as_json=bool(args.json))
|
|
1095
|
+
return 0
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
def cmd_ensure_identity(args: argparse.Namespace) -> int:
|
|
1099
|
+
"""Ensure meta/change-identity.json exists with a stable UUID (retro §5.5)."""
|
|
1100
|
+
project = resolve_main_project_root()
|
|
1101
|
+
change_dir = project / ".harness" / "changes" / args.change
|
|
1102
|
+
if not change_dir.is_dir():
|
|
1103
|
+
return emit_error(
|
|
1104
|
+
"CHANGE_NOT_FOUND",
|
|
1105
|
+
f"change directory not found: {change_dir}",
|
|
1106
|
+
as_json=bool(args.json),
|
|
1107
|
+
)
|
|
1108
|
+
identity = _ensure_change_identity(change_dir)
|
|
1109
|
+
emit(identity, as_json=bool(args.json))
|
|
1110
|
+
return 0
|
|
1111
|
+
|
|
1112
|
+
|
|
830
1113
|
def build_parser() -> argparse.ArgumentParser:
|
|
831
1114
|
parser = argparse.ArgumentParser(prog="harness_change.py")
|
|
832
1115
|
parser.add_argument("--json", action="store_true")
|
|
@@ -869,6 +1152,8 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
869
1152
|
p_port_release = sub.add_parser("release-port", parents=[shared])
|
|
870
1153
|
p_port_release.add_argument("--change", required=True)
|
|
871
1154
|
p_port_release.add_argument("--run-id", required=True)
|
|
1155
|
+
p_port_release.add_argument("--port", type=int, default=None)
|
|
1156
|
+
p_port_release.add_argument("--lease-id", default=None)
|
|
872
1157
|
p_port_release.set_defaults(func=cmd_release_port)
|
|
873
1158
|
|
|
874
1159
|
p_lock = sub.add_parser("integration-lock", parents=[shared])
|
|
@@ -877,6 +1162,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
877
1162
|
p_lock.add_argument("--ttl-seconds", type=int, default=3600)
|
|
878
1163
|
p_lock.set_defaults(func=cmd_integration_lock)
|
|
879
1164
|
|
|
1165
|
+
p_rename = sub.add_parser("rename", parents=[shared])
|
|
1166
|
+
p_rename.add_argument("--change", required=True)
|
|
1167
|
+
p_rename.add_argument("--to", required=True)
|
|
1168
|
+
p_rename.set_defaults(func=cmd_rename)
|
|
1169
|
+
|
|
1170
|
+
p_identity = sub.add_parser("ensure-identity", parents=[shared])
|
|
1171
|
+
p_identity.add_argument("--change", required=True)
|
|
1172
|
+
p_identity.set_defaults(func=cmd_ensure_identity)
|
|
1173
|
+
|
|
880
1174
|
return parser
|
|
881
1175
|
|
|
882
1176
|
|
|
@@ -623,6 +623,107 @@ def collect_files(root: Path) -> dict[str, str]:
|
|
|
623
623
|
return files
|
|
624
624
|
|
|
625
625
|
|
|
626
|
+
# --- Per-file bundle manifest (retro 2026-07-20 §5.1/5.25) -----------------
|
|
627
|
+
#
|
|
628
|
+
# registry_version + bundle_hash alone cannot prove every installed file
|
|
629
|
+
# belongs to the declared bundle. The bundle therefore ships a per-file
|
|
630
|
+
# manifest (relpath/sha256/size/mode/adapterTransformationId); install
|
|
631
|
+
# verifies staging content against it BEFORE the atomic switch and never
|
|
632
|
+
# updates metadata on partial failure.
|
|
633
|
+
|
|
634
|
+
BUNDLE_MANIFEST_NAME = "bundle-manifest.json"
|
|
635
|
+
_MANIFEST_EXCLUDED = frozenset(
|
|
636
|
+
{BUILD_MARKER, MANIFEST_NAME, AGENTS_MANAGED_NAME, BUNDLE_MANIFEST_NAME}
|
|
637
|
+
)
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def build_manifest(root: Path, transformation_id: str = "raw") -> list[dict[str, Any]]:
|
|
641
|
+
"""Return per-file manifest entries for every bundle file under ``root``.
|
|
642
|
+
|
|
643
|
+
Install metadata (build marker, managed manifests, the manifest itself)
|
|
644
|
+
is excluded. Entries are sorted by relpath for deterministic output.
|
|
645
|
+
"""
|
|
646
|
+
root = root.resolve()
|
|
647
|
+
entries: list[dict[str, Any]] = []
|
|
648
|
+
for path in sorted(root.rglob("*")):
|
|
649
|
+
if not path.is_file() or "__pycache__" in path.parts:
|
|
650
|
+
continue
|
|
651
|
+
rel = path.relative_to(root).as_posix()
|
|
652
|
+
if rel in _MANIFEST_EXCLUDED:
|
|
653
|
+
continue
|
|
654
|
+
stat = path.stat()
|
|
655
|
+
entries.append(
|
|
656
|
+
{
|
|
657
|
+
"relpath": rel,
|
|
658
|
+
"sha256": sha256_file(path),
|
|
659
|
+
"size": stat.st_size,
|
|
660
|
+
"mode": stat.st_mode,
|
|
661
|
+
"adapterTransformationId": transformation_id,
|
|
662
|
+
}
|
|
663
|
+
)
|
|
664
|
+
return entries
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def compute_bundle_manifest_hash(entries: list[dict[str, Any]]) -> str:
|
|
668
|
+
"""Deterministic aggregate hash over manifest entries (order-independent).
|
|
669
|
+
|
|
670
|
+
Must match packages/contracts/src/bundle-manifest.ts computeBundleManifestHash.
|
|
671
|
+
"""
|
|
672
|
+
ordered = sorted(entries, key=lambda entry: entry["relpath"])
|
|
673
|
+
lines = [
|
|
674
|
+
f"{entry['relpath']}:{entry['sha256']}:{entry['size']}:{entry['mode']}:{entry['adapterTransformationId']}"
|
|
675
|
+
for entry in ordered
|
|
676
|
+
]
|
|
677
|
+
return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def aggregate_installed_content_hash(files: dict[str, str]) -> str:
|
|
681
|
+
"""Deterministic aggregate hash over actually-installed relpath->sha256."""
|
|
682
|
+
lines = [f"{rel}:{files[rel]}" for rel in sorted(files)]
|
|
683
|
+
return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _verify_tree_against_manifest(
|
|
687
|
+
root: Path, entries: list[dict[str, Any]]
|
|
688
|
+
) -> list[dict[str, str]]:
|
|
689
|
+
"""Return mismatch details for files under ``root`` vs manifest entries."""
|
|
690
|
+
mismatches: list[dict[str, str]] = []
|
|
691
|
+
actual = collect_files(root)
|
|
692
|
+
declared = {entry["relpath"]: entry["sha256"] for entry in entries}
|
|
693
|
+
for rel, expected in sorted(declared.items()):
|
|
694
|
+
if rel not in actual:
|
|
695
|
+
mismatches.append({"relpath": rel, "expected": expected, "actual": "<missing>"})
|
|
696
|
+
elif actual[rel] != expected:
|
|
697
|
+
mismatches.append(
|
|
698
|
+
{"relpath": rel, "expected": expected, "actual": actual[rel]}
|
|
699
|
+
)
|
|
700
|
+
return mismatches
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def cmd_verify_installed(
|
|
704
|
+
installed_dir: Path, entries: list[dict[str, Any]], bundle_version: str
|
|
705
|
+
) -> dict[str, Any]:
|
|
706
|
+
"""Verify installed content against the bundle manifest, per file."""
|
|
707
|
+
installed_dir = installed_dir.resolve()
|
|
708
|
+
mismatches = _verify_tree_against_manifest(installed_dir, entries)
|
|
709
|
+
actual = collect_files(installed_dir)
|
|
710
|
+
declared = {entry["relpath"] for entry in entries}
|
|
711
|
+
content = {
|
|
712
|
+
rel: digest
|
|
713
|
+
for rel, digest in actual.items()
|
|
714
|
+
if rel not in _MANIFEST_EXCLUDED and rel in declared
|
|
715
|
+
}
|
|
716
|
+
return {
|
|
717
|
+
"ok": not mismatches,
|
|
718
|
+
"action": "verify-installed",
|
|
719
|
+
"bundleVersion": bundle_version,
|
|
720
|
+
"verificationStatus": "verified" if not mismatches else "degraded",
|
|
721
|
+
"installedContentHash": aggregate_installed_content_hash(content),
|
|
722
|
+
"verifiedAt": now_iso(),
|
|
723
|
+
"mismatchDetails": mismatches,
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
|
|
626
727
|
def cmd_install(build_out: Path, project: Path, target: Path | None) -> dict[str, Any]:
|
|
627
728
|
build_out = build_out.resolve()
|
|
628
729
|
project = project.resolve()
|
|
@@ -644,6 +745,23 @@ def cmd_install(build_out: Path, project: Path, target: Path | None) -> dict[str
|
|
|
644
745
|
backup: str | None = None
|
|
645
746
|
staging = dest.parent / f".{dest.name}.staging-{uuid.uuid4().hex[:8]}"
|
|
646
747
|
shutil.copytree(build_out, staging)
|
|
748
|
+
# Per-file content proof (retro §5.1): if the build carries a
|
|
749
|
+
# bundle-manifest.json, staging must match it exactly before anything is
|
|
750
|
+
# switched. A mismatch aborts the install without touching dest or any
|
|
751
|
+
# metadata — never a partial install under a fresh version label.
|
|
752
|
+
manifest_path = staging / BUNDLE_MANIFEST_NAME
|
|
753
|
+
if manifest_path.is_file():
|
|
754
|
+
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
755
|
+
manifest_entries = (
|
|
756
|
+
manifest_data.get("files", []) if isinstance(manifest_data, dict) else []
|
|
757
|
+
)
|
|
758
|
+
mismatches = _verify_tree_against_manifest(staging, manifest_entries)
|
|
759
|
+
if mismatches:
|
|
760
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
761
|
+
raise ValueError(
|
|
762
|
+
"BUNDLE_CONTENT_MISMATCH: staging diverges from bundle-manifest.json: "
|
|
763
|
+
+ ", ".join(m["relpath"] for m in mismatches)
|
|
764
|
+
)
|
|
647
765
|
if dest.exists() and any(dest.iterdir()):
|
|
648
766
|
managed_old = _read_managed_set(dest / MANIFEST_NAME)
|
|
649
767
|
# Preserve user-owned files: anything in dest that was NOT managed by a
|
|
@@ -771,6 +889,28 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
771
889
|
v.add_argument("--bundle", type=Path, required=True)
|
|
772
890
|
v.add_argument("--manifest", type=Path, required=True)
|
|
773
891
|
v.add_argument("--json", action="store_true")
|
|
892
|
+
|
|
893
|
+
g = sub.add_parser(
|
|
894
|
+
"generate-manifest",
|
|
895
|
+
help="Generate per-file bundle-manifest.json for a bundle dir",
|
|
896
|
+
)
|
|
897
|
+
g.add_argument("--bundle", type=Path, required=True)
|
|
898
|
+
g.add_argument("--bundle-version", required=True)
|
|
899
|
+
g.add_argument(
|
|
900
|
+
"--transformation",
|
|
901
|
+
choices=["raw", "adapted", "workflow-packaged"],
|
|
902
|
+
default="raw",
|
|
903
|
+
)
|
|
904
|
+
g.add_argument("--out", type=Path)
|
|
905
|
+
g.add_argument("--json", action="store_true")
|
|
906
|
+
|
|
907
|
+
vi = sub.add_parser(
|
|
908
|
+
"verify-installed",
|
|
909
|
+
help="Verify installed tree content against bundle-manifest.json",
|
|
910
|
+
)
|
|
911
|
+
vi.add_argument("--installed", type=Path, required=True)
|
|
912
|
+
vi.add_argument("--manifest", type=Path, required=True)
|
|
913
|
+
vi.add_argument("--json", action="store_true")
|
|
774
914
|
return p
|
|
775
915
|
|
|
776
916
|
|
|
@@ -794,6 +934,43 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
794
934
|
if args.json:
|
|
795
935
|
return emit_json(result)
|
|
796
936
|
return 0 if result["ok"] else 1
|
|
937
|
+
if args.command == "generate-manifest":
|
|
938
|
+
entries = build_manifest(args.bundle, transformation_id=args.transformation)
|
|
939
|
+
manifest = {
|
|
940
|
+
"schemaVersion": 1,
|
|
941
|
+
"bundleVersion": args.bundle_version,
|
|
942
|
+
"bundleManifestHash": compute_bundle_manifest_hash(entries),
|
|
943
|
+
"files": entries,
|
|
944
|
+
}
|
|
945
|
+
text = json.dumps(manifest, ensure_ascii=False, indent=2) + "\n"
|
|
946
|
+
if args.out is not None:
|
|
947
|
+
args.out.parent.mkdir(parents=True, exist_ok=True)
|
|
948
|
+
args.out.write_text(text, encoding="utf-8", newline="\n")
|
|
949
|
+
result = {
|
|
950
|
+
"ok": True,
|
|
951
|
+
"action": "generate-manifest",
|
|
952
|
+
"bundleVersion": args.bundle_version,
|
|
953
|
+
"bundleManifestHash": manifest["bundleManifestHash"],
|
|
954
|
+
"fileCount": len(entries),
|
|
955
|
+
"out": str(args.out) if args.out is not None else None,
|
|
956
|
+
}
|
|
957
|
+
if args.json:
|
|
958
|
+
return emit_json(result)
|
|
959
|
+
if args.out is None:
|
|
960
|
+
print(text, end="")
|
|
961
|
+
return 0
|
|
962
|
+
if args.command == "verify-installed":
|
|
963
|
+
manifest_data = json.loads(args.manifest.read_text(encoding="utf-8"))
|
|
964
|
+
if not isinstance(manifest_data, dict):
|
|
965
|
+
raise ValueError("manifest must be a JSON object")
|
|
966
|
+
result = cmd_verify_installed(
|
|
967
|
+
args.installed,
|
|
968
|
+
manifest_data.get("files", []),
|
|
969
|
+
str(manifest_data.get("bundleVersion", "")),
|
|
970
|
+
)
|
|
971
|
+
if args.json:
|
|
972
|
+
return emit_json(result)
|
|
973
|
+
return 0 if result["ok"] else 1
|
|
797
974
|
except (FileNotFoundError, KeyError, RuntimeError, ValueError) as exc:
|
|
798
975
|
if getattr(args, "json", False):
|
|
799
976
|
return emit_json({"ok": False, "error": str(exc)}, ok=False)
|