@hunter-harness/workflow-harness 0.2.41 → 0.2.43
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/contracts/build-profile-v3.schema.json +56 -1
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-archive/reference.md +1 -0
- package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/general/claude-code/harness-archive/templates/summary-data-template.json +28 -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 +42 -16
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/general/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- 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 +6 -1
- package/harness/bundles/general/claude-code/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-sync/SKILL.md +16 -10
- package/harness/bundles/general/claude-code/harness-sync/reference.md +34 -14
- package/harness/bundles/general/claude-code/harness-test/SKILL.md +8 -2
- package/harness/bundles/general/claude-code/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/general/claude-code/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/general/claude-code/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +4 -0
- package/harness/bundles/general/claude-code/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/general/claude-code/scripts/harness_environment.py +719 -35
- package/harness/bundles/general/claude-code/scripts/harness_fixback.py +742 -0
- package/harness/bundles/general/claude-code/scripts/harness_profile.py +34 -0
- package/harness/bundles/general/claude-code/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/general/claude-code/scripts/harness_service.py +76 -0
- package/harness/bundles/general/claude-code/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/general/claude-code/scripts/harness_verification.py +408 -8
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/contracts/build-profile-v3.schema.json +56 -1
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/reference.md +1 -0
- package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/general/codebuddy/harness-archive/templates/summary-data-template.json +28 -0
- package/harness/bundles/general/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/SKILL.md +41 -15
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/general/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- 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 +6 -1
- package/harness/bundles/general/codebuddy/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-sync/SKILL.md +16 -10
- package/harness/bundles/general/codebuddy/harness-sync/reference.md +34 -14
- package/harness/bundles/general/codebuddy/harness-test/SKILL.md +8 -2
- package/harness/bundles/general/codebuddy/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/general/codebuddy/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/general/codebuddy/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +4 -0
- package/harness/bundles/general/codebuddy/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/general/codebuddy/scripts/harness_environment.py +719 -35
- package/harness/bundles/general/codebuddy/scripts/harness_fixback.py +742 -0
- package/harness/bundles/general/codebuddy/scripts/harness_profile.py +34 -0
- package/harness/bundles/general/codebuddy/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/general/codebuddy/scripts/harness_service.py +76 -0
- package/harness/bundles/general/codebuddy/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/general/codebuddy/scripts/harness_verification.py +408 -8
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/contracts/build-profile-v3.schema.json +56 -1
- package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-archive/reference.md +1 -0
- package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/general/codex/harness-archive/templates/summary-data-template.json +28 -0
- package/harness/bundles/general/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-knowledge-ingest/SKILL.md +41 -15
- package/harness/bundles/general/codex/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/general/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- 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 +6 -1
- package/harness/bundles/general/codex/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-sync/SKILL.md +16 -10
- package/harness/bundles/general/codex/harness-sync/reference.md +34 -14
- package/harness/bundles/general/codex/harness-test/SKILL.md +8 -2
- package/harness/bundles/general/codex/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/general/codex/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/general/codex/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/general/codex/scripts/harness_archive.py +4 -0
- package/harness/bundles/general/codex/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/general/codex/scripts/harness_environment.py +719 -35
- package/harness/bundles/general/codex/scripts/harness_fixback.py +742 -0
- package/harness/bundles/general/codex/scripts/harness_profile.py +34 -0
- package/harness/bundles/general/codex/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/general/codex/scripts/harness_service.py +76 -0
- package/harness/bundles/general/codex/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/general/codex/scripts/harness_verification.py +408 -8
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/contracts/build-profile-v3.schema.json +56 -1
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-archive/reference.md +1 -0
- package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/general/cursor/harness-archive/templates/summary-data-template.json +28 -0
- package/harness/bundles/general/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-knowledge-ingest/SKILL.md +41 -15
- package/harness/bundles/general/cursor/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/general/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- 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 +6 -1
- package/harness/bundles/general/cursor/harness-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-sync/SKILL.md +16 -10
- package/harness/bundles/general/cursor/harness-sync/reference.md +34 -14
- package/harness/bundles/general/cursor/harness-test/SKILL.md +8 -2
- package/harness/bundles/general/cursor/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/general/cursor/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/general/cursor/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/general/cursor/scripts/harness_archive.py +4 -0
- package/harness/bundles/general/cursor/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/general/cursor/scripts/harness_environment.py +719 -35
- package/harness/bundles/general/cursor/scripts/harness_fixback.py +742 -0
- package/harness/bundles/general/cursor/scripts/harness_profile.py +34 -0
- package/harness/bundles/general/cursor/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/general/cursor/scripts/harness_service.py +76 -0
- package/harness/bundles/general/cursor/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/general/cursor/scripts/harness_verification.py +408 -8
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/contracts/build-profile-v3.schema.json +56 -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/reference.md +1 -0
- package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/java/claude-code/harness-archive/templates/summary-data-template.json +28 -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 +42 -16
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/java/claude-code/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- 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 +16 -10
- package/harness/bundles/java/claude-code/harness-sync/reference.md +34 -14
- package/harness/bundles/java/claude-code/harness-test/SKILL.md +8 -2
- package/harness/bundles/java/claude-code/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/java/claude-code/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/java/claude-code/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/java/claude-code/scripts/harness_archive.py +4 -0
- package/harness/bundles/java/claude-code/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/java/claude-code/scripts/harness_environment.py +719 -35
- package/harness/bundles/java/claude-code/scripts/harness_fixback.py +742 -0
- package/harness/bundles/java/claude-code/scripts/harness_profile.py +34 -0
- package/harness/bundles/java/claude-code/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/java/claude-code/scripts/harness_service.py +76 -0
- package/harness/bundles/java/claude-code/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/java/claude-code/scripts/harness_verification.py +408 -8
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/contracts/build-profile-v3.schema.json +56 -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/reference.md +1 -0
- package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/java/codebuddy/harness-archive/templates/summary-data-template.json +28 -0
- package/harness/bundles/java/codebuddy/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/SKILL.md +41 -15
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/java/codebuddy/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- 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 +16 -10
- package/harness/bundles/java/codebuddy/harness-sync/reference.md +34 -14
- package/harness/bundles/java/codebuddy/harness-test/SKILL.md +8 -2
- package/harness/bundles/java/codebuddy/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/java/codebuddy/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/java/codebuddy/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/java/codebuddy/scripts/harness_archive.py +4 -0
- package/harness/bundles/java/codebuddy/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/java/codebuddy/scripts/harness_environment.py +719 -35
- package/harness/bundles/java/codebuddy/scripts/harness_fixback.py +742 -0
- package/harness/bundles/java/codebuddy/scripts/harness_profile.py +34 -0
- package/harness/bundles/java/codebuddy/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/java/codebuddy/scripts/harness_service.py +76 -0
- package/harness/bundles/java/codebuddy/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/java/codebuddy/scripts/harness_verification.py +408 -8
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/contracts/build-profile-v3.schema.json +56 -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/reference.md +1 -0
- package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/java/codex/harness-archive/templates/summary-data-template.json +28 -0
- package/harness/bundles/java/codex/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-knowledge-ingest/SKILL.md +41 -15
- package/harness/bundles/java/codex/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/java/codex/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- 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 +16 -10
- package/harness/bundles/java/codex/harness-sync/reference.md +34 -14
- package/harness/bundles/java/codex/harness-test/SKILL.md +8 -2
- package/harness/bundles/java/codex/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/java/codex/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/java/codex/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/java/codex/scripts/harness_archive.py +4 -0
- package/harness/bundles/java/codex/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/java/codex/scripts/harness_environment.py +719 -35
- package/harness/bundles/java/codex/scripts/harness_fixback.py +742 -0
- package/harness/bundles/java/codex/scripts/harness_profile.py +34 -0
- package/harness/bundles/java/codex/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/java/codex/scripts/harness_service.py +76 -0
- package/harness/bundles/java/codex/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/java/codex/scripts/harness_verification.py +408 -8
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/contracts/build-profile-v3.schema.json +56 -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/reference.md +1 -0
- package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +8 -0
- package/harness/bundles/java/cursor/harness-archive/templates/summary-data-template.json +28 -0
- package/harness/bundles/java/cursor/harness-codebase-map/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-knowledge-ingest/SKILL.md +41 -15
- package/harness/bundles/java/cursor/harness-knowledge-ingest/reference.md +57 -6
- package/harness/bundles/java/cursor/harness-knowledge-ingest/scripts/harness_knowledge.py +1097 -174
- 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 +16 -10
- package/harness/bundles/java/cursor/harness-sync/reference.md +34 -14
- package/harness/bundles/java/cursor/harness-test/SKILL.md +8 -2
- package/harness/bundles/java/cursor/protocols/execution-session-protocol.md +52 -0
- package/harness/bundles/java/cursor/protocols/state-layout-protocol.md +24 -1
- package/harness/bundles/java/cursor/protocols/verification-graph-protocol.md +6 -0
- package/harness/bundles/java/cursor/scripts/harness_archive.py +4 -0
- package/harness/bundles/java/cursor/scripts/harness_efficiency.py +290 -0
- package/harness/bundles/java/cursor/scripts/harness_environment.py +719 -35
- package/harness/bundles/java/cursor/scripts/harness_fixback.py +742 -0
- package/harness/bundles/java/cursor/scripts/harness_profile.py +34 -0
- package/harness/bundles/java/cursor/scripts/harness_runtime.py +1165 -3
- package/harness/bundles/java/cursor/scripts/harness_service.py +76 -0
- package/harness/bundles/java/cursor/scripts/harness_test_runner.py +127 -39
- package/harness/bundles/java/cursor/scripts/harness_verification.py +408 -8
- package/harness/manifests/general/claude-code.json +46 -33
- package/harness/manifests/general/codebuddy.json +46 -33
- package/harness/manifests/general/codex.json +46 -33
- package/harness/manifests/general/cursor.json +46 -33
- package/harness/manifests/java/claude-code.json +47 -34
- package/harness/manifests/java/codebuddy.json +47 -34
- package/harness/manifests/java/codex.json +47 -34
- package/harness/manifests/java/cursor.json +47 -34
- package/hunter-workflow-family.json +12 -10
- package/package.json +1 -1
|
@@ -13,11 +13,18 @@ import datetime as dt
|
|
|
13
13
|
import hashlib
|
|
14
14
|
import json
|
|
15
15
|
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import subprocess
|
|
16
18
|
import sys
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
from contextlib import contextmanager
|
|
17
23
|
from pathlib import Path
|
|
18
|
-
from typing import Any
|
|
24
|
+
from typing import Any, Iterator
|
|
19
25
|
|
|
20
26
|
import harness_service as hservice
|
|
27
|
+
from harness_paths import resolve_state_dir_for_contract
|
|
21
28
|
|
|
22
29
|
|
|
23
30
|
if hasattr(sys.stdout, "reconfigure"):
|
|
@@ -87,11 +94,20 @@ def _read_json(path: Path) -> Any:
|
|
|
87
94
|
|
|
88
95
|
def _write_json(path: Path, data: Any) -> None:
|
|
89
96
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
97
|
+
fd, raw_tmp = tempfile.mkstemp(
|
|
98
|
+
prefix=f".{path.name}.",
|
|
99
|
+
suffix=".tmp",
|
|
100
|
+
dir=str(path.parent),
|
|
94
101
|
)
|
|
102
|
+
tmp = Path(raw_tmp)
|
|
103
|
+
try:
|
|
104
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
105
|
+
handle.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
|
|
106
|
+
handle.flush()
|
|
107
|
+
os.fsync(handle.fileno())
|
|
108
|
+
os.replace(tmp, path)
|
|
109
|
+
finally:
|
|
110
|
+
tmp.unlink(missing_ok=True)
|
|
95
111
|
|
|
96
112
|
|
|
97
113
|
def default_lease_root(project: Path) -> Path:
|
|
@@ -231,6 +247,45 @@ def create_environment_receipt(
|
|
|
231
247
|
return receipt
|
|
232
248
|
|
|
233
249
|
|
|
250
|
+
def _validate_environment_receipt(
|
|
251
|
+
receipt: Any,
|
|
252
|
+
*,
|
|
253
|
+
stack_id: str,
|
|
254
|
+
environment_hash: str,
|
|
255
|
+
) -> bool:
|
|
256
|
+
if not isinstance(receipt, dict) or receipt.get("schemaVersion") != 1:
|
|
257
|
+
return False
|
|
258
|
+
if (
|
|
259
|
+
receipt.get("stackId") != stack_id
|
|
260
|
+
or receipt.get("environmentHash") != environment_hash
|
|
261
|
+
or not isinstance(receipt.get("content"), dict)
|
|
262
|
+
):
|
|
263
|
+
return False
|
|
264
|
+
try:
|
|
265
|
+
canonical_content = json.dumps(
|
|
266
|
+
_canonical_content_evidence(receipt["content"]),
|
|
267
|
+
ensure_ascii=False,
|
|
268
|
+
sort_keys=True,
|
|
269
|
+
separators=(",", ":"),
|
|
270
|
+
).encode("utf-8")
|
|
271
|
+
except ValueError:
|
|
272
|
+
return False
|
|
273
|
+
expected_fingerprint = "sha256:" + _sha256_bytes(canonical_content)
|
|
274
|
+
if receipt.get("contentFingerprint") != expected_fingerprint:
|
|
275
|
+
return False
|
|
276
|
+
identity_payload = json.dumps(
|
|
277
|
+
{
|
|
278
|
+
key: value
|
|
279
|
+
for key, value in receipt.items()
|
|
280
|
+
if key not in {"preparedAt", "receiptId"}
|
|
281
|
+
},
|
|
282
|
+
ensure_ascii=False,
|
|
283
|
+
sort_keys=True,
|
|
284
|
+
separators=(",", ":"),
|
|
285
|
+
).encode("utf-8")
|
|
286
|
+
return receipt.get("receiptId") == "sha256:" + _sha256_bytes(identity_payload)
|
|
287
|
+
|
|
288
|
+
|
|
234
289
|
def resolve_verification_environment(
|
|
235
290
|
receipt: dict[str, Any],
|
|
236
291
|
*,
|
|
@@ -279,9 +334,300 @@ def resolve_verification_environment(
|
|
|
279
334
|
|
|
280
335
|
def _lease_path(lease_root: Path, stack_id: str) -> Path:
|
|
281
336
|
safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in stack_id)
|
|
337
|
+
if safe != stack_id:
|
|
338
|
+
safe = f"{safe}-{hashlib.sha256(stack_id.encode('utf-8')).hexdigest()[:12]}"
|
|
282
339
|
return lease_root / f"{safe}.json"
|
|
283
340
|
|
|
284
341
|
|
|
342
|
+
@contextmanager
|
|
343
|
+
def _lease_registry_guard(lease_root: Path) -> Iterator[None]:
|
|
344
|
+
lease_root.mkdir(parents=True, exist_ok=True)
|
|
345
|
+
lock_path = lease_root / ".acquire.lock"
|
|
346
|
+
deadline = time.monotonic() + 5.0
|
|
347
|
+
token = uuid.uuid4().hex
|
|
348
|
+
while True:
|
|
349
|
+
try:
|
|
350
|
+
fd = os.open(
|
|
351
|
+
lock_path,
|
|
352
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
353
|
+
0o600,
|
|
354
|
+
)
|
|
355
|
+
except FileExistsError:
|
|
356
|
+
try:
|
|
357
|
+
holder = _read_json(lock_path)
|
|
358
|
+
except (OSError, json.JSONDecodeError):
|
|
359
|
+
holder = {}
|
|
360
|
+
pid = holder.get("pid") if isinstance(holder, dict) else None
|
|
361
|
+
if isinstance(pid, int) and pid > 0 and not hservice.is_pid_alive(pid):
|
|
362
|
+
try:
|
|
363
|
+
lock_path.unlink()
|
|
364
|
+
continue
|
|
365
|
+
except OSError:
|
|
366
|
+
pass
|
|
367
|
+
if time.monotonic() >= deadline:
|
|
368
|
+
raise RuntimeError("ENVIRONMENT_LEASE_BUSY")
|
|
369
|
+
time.sleep(0.02)
|
|
370
|
+
continue
|
|
371
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as handle:
|
|
372
|
+
json.dump(
|
|
373
|
+
{
|
|
374
|
+
"schemaVersion": 1,
|
|
375
|
+
"pid": os.getpid(),
|
|
376
|
+
"token": token,
|
|
377
|
+
"acquiredAt": now_iso(),
|
|
378
|
+
},
|
|
379
|
+
handle,
|
|
380
|
+
ensure_ascii=False,
|
|
381
|
+
)
|
|
382
|
+
handle.flush()
|
|
383
|
+
os.fsync(handle.fileno())
|
|
384
|
+
break
|
|
385
|
+
try:
|
|
386
|
+
yield
|
|
387
|
+
finally:
|
|
388
|
+
try:
|
|
389
|
+
holder = _read_json(lock_path)
|
|
390
|
+
except (OSError, json.JSONDecodeError):
|
|
391
|
+
holder = {}
|
|
392
|
+
if isinstance(holder, dict) and holder.get("token") == token:
|
|
393
|
+
lock_path.unlink(missing_ok=True)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _provider_operation_root(lease_root: Path) -> Path:
|
|
397
|
+
return lease_root / "provider-operations"
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _provider_receipt_digest(receipt: dict[str, Any]) -> str:
|
|
401
|
+
payload = {
|
|
402
|
+
key: value
|
|
403
|
+
for key, value in receipt.items()
|
|
404
|
+
if key != "receiptDigest"
|
|
405
|
+
}
|
|
406
|
+
return "sha256:" + _sha256_bytes(
|
|
407
|
+
json.dumps(
|
|
408
|
+
payload,
|
|
409
|
+
ensure_ascii=False,
|
|
410
|
+
sort_keys=True,
|
|
411
|
+
separators=(",", ":"),
|
|
412
|
+
).encode("utf-8")
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def execute_provider_operation(
|
|
417
|
+
project: Path,
|
|
418
|
+
*,
|
|
419
|
+
change_id: str,
|
|
420
|
+
stack_id: str,
|
|
421
|
+
operation: str,
|
|
422
|
+
argv: list[str],
|
|
423
|
+
lease_root: Path | None = None,
|
|
424
|
+
timeout_seconds: float = 900.0,
|
|
425
|
+
) -> dict[str, Any]:
|
|
426
|
+
"""Execute a reset/cleanup provider and emit a bound, durable receipt."""
|
|
427
|
+
if operation not in {"reset", "cleanup"}:
|
|
428
|
+
raise ValueError("ENVIRONMENT_PROVIDER_OPERATION_INVALID")
|
|
429
|
+
if (
|
|
430
|
+
not argv
|
|
431
|
+
or not all(isinstance(item, str) and item for item in argv)
|
|
432
|
+
or timeout_seconds <= 0
|
|
433
|
+
):
|
|
434
|
+
raise ValueError("ENVIRONMENT_PROVIDER_ARGV_INVALID")
|
|
435
|
+
root = lease_root or default_lease_root(project)
|
|
436
|
+
lease_path = _lease_path(root, stack_id)
|
|
437
|
+
lease = _read_json(lease_path)
|
|
438
|
+
if (
|
|
439
|
+
not isinstance(lease, dict)
|
|
440
|
+
or lease.get("changeId") != change_id
|
|
441
|
+
or lease.get("stackId") != stack_id
|
|
442
|
+
or lease.get("status") != "ACTIVE"
|
|
443
|
+
):
|
|
444
|
+
raise ValueError("ENVIRONMENT_PROVIDER_LEASE_INVALID")
|
|
445
|
+
executable = shutil.which(argv[0]) or str(Path(argv[0]).expanduser().resolve())
|
|
446
|
+
started_at = now_iso()
|
|
447
|
+
process = subprocess.Popen(
|
|
448
|
+
argv,
|
|
449
|
+
cwd=str(project.resolve()),
|
|
450
|
+
stdin=subprocess.DEVNULL,
|
|
451
|
+
stdout=subprocess.PIPE,
|
|
452
|
+
stderr=subprocess.PIPE,
|
|
453
|
+
shell=False,
|
|
454
|
+
)
|
|
455
|
+
created = hservice.get_process_create_time(process.pid)
|
|
456
|
+
try:
|
|
457
|
+
stdout, stderr = process.communicate(timeout=timeout_seconds)
|
|
458
|
+
timed_out = False
|
|
459
|
+
except subprocess.TimeoutExpired:
|
|
460
|
+
timed_out = True
|
|
461
|
+
hservice.terminate_process_tree(process.pid)
|
|
462
|
+
stdout, stderr = process.communicate()
|
|
463
|
+
operation_id = uuid.uuid4().hex
|
|
464
|
+
receipt_path = _provider_operation_root(root) / f"{operation}-{operation_id}.json"
|
|
465
|
+
receipt: dict[str, Any] = {
|
|
466
|
+
"schemaVersion": 2,
|
|
467
|
+
"operation": operation,
|
|
468
|
+
"status": "OK" if process.returncode == 0 and not timed_out else "FAIL",
|
|
469
|
+
"operationId": operation_id,
|
|
470
|
+
"changeId": change_id,
|
|
471
|
+
"stackId": stack_id,
|
|
472
|
+
"environmentHash": str(lease.get("environmentHash") or ""),
|
|
473
|
+
"environmentSessionId": str(lease.get("sessionId") or ""),
|
|
474
|
+
"contentFingerprint": str(
|
|
475
|
+
lease.get("contentFingerprint") or "sha256:none"
|
|
476
|
+
),
|
|
477
|
+
"performedAt": started_at,
|
|
478
|
+
"completedAt": now_iso(),
|
|
479
|
+
"provider": {
|
|
480
|
+
"pid": process.pid,
|
|
481
|
+
"executable": executable,
|
|
482
|
+
"startedAt": (
|
|
483
|
+
created.isoformat(timespec="seconds")
|
|
484
|
+
if created is not None
|
|
485
|
+
else started_at
|
|
486
|
+
),
|
|
487
|
+
"argvHash": "sha256:"
|
|
488
|
+
+ _sha256_bytes(
|
|
489
|
+
json.dumps(argv, ensure_ascii=False, separators=(",", ":")).encode(
|
|
490
|
+
"utf-8"
|
|
491
|
+
)
|
|
492
|
+
),
|
|
493
|
+
},
|
|
494
|
+
"exitCode": process.returncode,
|
|
495
|
+
"timedOut": timed_out,
|
|
496
|
+
"stdoutDigest": "sha256:" + _sha256_bytes(stdout),
|
|
497
|
+
"stderrDigest": "sha256:" + _sha256_bytes(stderr),
|
|
498
|
+
"receiptPath": str(receipt_path.resolve()),
|
|
499
|
+
}
|
|
500
|
+
receipt["receiptDigest"] = _provider_receipt_digest(receipt)
|
|
501
|
+
_write_json(receipt_path, receipt)
|
|
502
|
+
return receipt
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
def _operation_evidence(
|
|
506
|
+
evidence: dict[str, Any] | None,
|
|
507
|
+
*,
|
|
508
|
+
operation: str,
|
|
509
|
+
lease_root: Path,
|
|
510
|
+
lease: dict[str, Any],
|
|
511
|
+
) -> dict[str, Any] | None:
|
|
512
|
+
if not isinstance(evidence, dict):
|
|
513
|
+
return None
|
|
514
|
+
raw_path = str(evidence.get("receiptPath") or "").strip()
|
|
515
|
+
if not raw_path:
|
|
516
|
+
return None
|
|
517
|
+
receipt_path = Path(raw_path).expanduser().resolve()
|
|
518
|
+
provider_root = _provider_operation_root(lease_root).resolve()
|
|
519
|
+
try:
|
|
520
|
+
receipt_path.relative_to(provider_root)
|
|
521
|
+
except ValueError:
|
|
522
|
+
return None
|
|
523
|
+
try:
|
|
524
|
+
stored = _read_json(receipt_path)
|
|
525
|
+
except (OSError, json.JSONDecodeError):
|
|
526
|
+
return None
|
|
527
|
+
if stored != evidence or not isinstance(stored, dict):
|
|
528
|
+
return None
|
|
529
|
+
performed_at = _parse_expiry(stored.get("performedAt"))
|
|
530
|
+
age_seconds = (
|
|
531
|
+
(dt.datetime.now(dt.timezone.utc) - performed_at).total_seconds()
|
|
532
|
+
if performed_at is not None
|
|
533
|
+
else 1_000_000
|
|
534
|
+
)
|
|
535
|
+
if (
|
|
536
|
+
stored.get("schemaVersion") != 2
|
|
537
|
+
or stored.get("operation") != operation
|
|
538
|
+
or stored.get("status") != "OK"
|
|
539
|
+
or stored.get("changeId") != lease.get("changeId")
|
|
540
|
+
or stored.get("stackId") != lease.get("stackId")
|
|
541
|
+
or stored.get("environmentHash") != lease.get("environmentHash")
|
|
542
|
+
or stored.get("environmentSessionId") != lease.get("sessionId")
|
|
543
|
+
or stored.get("contentFingerprint")
|
|
544
|
+
!= str(lease.get("contentFingerprint") or "sha256:none")
|
|
545
|
+
or not str(stored.get("operationId") or "").strip()
|
|
546
|
+
or not isinstance(stored.get("provider"), dict)
|
|
547
|
+
or not str(stored["provider"].get("executable") or "").strip()
|
|
548
|
+
or not str(stored["provider"].get("argvHash") or "").startswith("sha256:")
|
|
549
|
+
or stored.get("exitCode") != 0
|
|
550
|
+
or stored.get("timedOut") is not False
|
|
551
|
+
or age_seconds < -5
|
|
552
|
+
or age_seconds > 900
|
|
553
|
+
or stored.get("receiptDigest") != _provider_receipt_digest(stored)
|
|
554
|
+
):
|
|
555
|
+
return None
|
|
556
|
+
consumed = lease_root / ".consumed-provider-operations" / (
|
|
557
|
+
hashlib.sha256(str(stored["operationId"]).encode("utf-8")).hexdigest()
|
|
558
|
+
+ ".json"
|
|
559
|
+
)
|
|
560
|
+
if consumed.exists():
|
|
561
|
+
return None
|
|
562
|
+
return stored
|
|
563
|
+
|
|
564
|
+
|
|
565
|
+
def _consume_operation_evidence(
|
|
566
|
+
lease_root: Path,
|
|
567
|
+
evidence: dict[str, Any],
|
|
568
|
+
) -> bool:
|
|
569
|
+
consumed = lease_root / ".consumed-provider-operations" / (
|
|
570
|
+
hashlib.sha256(str(evidence["operationId"]).encode("utf-8")).hexdigest()
|
|
571
|
+
+ ".json"
|
|
572
|
+
)
|
|
573
|
+
consumed.parent.mkdir(parents=True, exist_ok=True)
|
|
574
|
+
try:
|
|
575
|
+
descriptor = os.open(
|
|
576
|
+
consumed,
|
|
577
|
+
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
|
578
|
+
0o600,
|
|
579
|
+
)
|
|
580
|
+
except FileExistsError:
|
|
581
|
+
return False
|
|
582
|
+
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as stream:
|
|
583
|
+
json.dump(
|
|
584
|
+
{
|
|
585
|
+
"schemaVersion": 1,
|
|
586
|
+
"operationId": evidence["operationId"],
|
|
587
|
+
"receiptDigest": evidence["receiptDigest"],
|
|
588
|
+
"consumedAt": now_iso(),
|
|
589
|
+
},
|
|
590
|
+
stream,
|
|
591
|
+
ensure_ascii=False,
|
|
592
|
+
)
|
|
593
|
+
stream.flush()
|
|
594
|
+
os.fsync(stream.fileno())
|
|
595
|
+
return True
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _write_environment_action_receipt(
|
|
599
|
+
lease_root: Path,
|
|
600
|
+
*,
|
|
601
|
+
action: str,
|
|
602
|
+
lease: dict[str, Any],
|
|
603
|
+
previous_session_id: str | None = None,
|
|
604
|
+
receipt_dir: Path | None = None,
|
|
605
|
+
operation_evidence: dict[str, Any] | None = None,
|
|
606
|
+
) -> Path:
|
|
607
|
+
receipt = {
|
|
608
|
+
"schemaVersion": 1,
|
|
609
|
+
"action": action,
|
|
610
|
+
"recordedAt": now_iso(),
|
|
611
|
+
"changeId": lease.get("changeId"),
|
|
612
|
+
"stackId": lease.get("stackId"),
|
|
613
|
+
"sessionId": lease.get("sessionId"),
|
|
614
|
+
"mode": lease.get("mode"),
|
|
615
|
+
"environmentHash": lease.get("environmentHash"),
|
|
616
|
+
"contentFingerprint": lease.get("contentFingerprint"),
|
|
617
|
+
}
|
|
618
|
+
if previous_session_id:
|
|
619
|
+
receipt["previousSessionId"] = previous_session_id
|
|
620
|
+
if operation_evidence is not None:
|
|
621
|
+
receipt["operationEvidence"] = operation_evidence
|
|
622
|
+
receipt_dir = receipt_dir or lease_root.parent / "environment-receipts"
|
|
623
|
+
stack_digest = hashlib.sha256(
|
|
624
|
+
str(lease.get("stackId") or "").encode("utf-8")
|
|
625
|
+
).hexdigest()[:12]
|
|
626
|
+
target = receipt_dir / f"{action}-{stack_digest}-{uuid.uuid4().hex}.json"
|
|
627
|
+
_write_json(target, receipt)
|
|
628
|
+
return target
|
|
629
|
+
|
|
630
|
+
|
|
285
631
|
def list_leases(lease_root: Path) -> list[dict[str, Any]]:
|
|
286
632
|
if not lease_root.is_dir():
|
|
287
633
|
return []
|
|
@@ -340,24 +686,33 @@ def classify_leases(lease_root: Path) -> list[dict[str, Any]]:
|
|
|
340
686
|
item["classification"] = "LEASE_EXPIRY_INVALID"
|
|
341
687
|
results.append(item)
|
|
342
688
|
continue
|
|
343
|
-
|
|
344
|
-
item["classification"] = (
|
|
345
|
-
"STALE_CONTENT"
|
|
346
|
-
if lease.get("status") == "STALE_CONTENT"
|
|
347
|
-
else "ACTIVE"
|
|
348
|
-
)
|
|
349
|
-
results.append(item)
|
|
350
|
-
continue
|
|
689
|
+
unexpired = now < expires_at
|
|
351
690
|
owner = lease.get("owner")
|
|
352
691
|
if not isinstance(owner, dict) or any(
|
|
353
692
|
not owner.get(field) for field in ("pid", "executable", "startedAt")
|
|
354
693
|
):
|
|
694
|
+
if unexpired:
|
|
695
|
+
item["classification"] = (
|
|
696
|
+
"STALE_CONTENT"
|
|
697
|
+
if lease.get("status") == "STALE_CONTENT"
|
|
698
|
+
else "ACTIVE"
|
|
699
|
+
)
|
|
700
|
+
results.append(item)
|
|
701
|
+
continue
|
|
355
702
|
item["classification"] = "OWNER_IDENTITY_INCOMPLETE"
|
|
356
703
|
results.append(item)
|
|
357
704
|
continue
|
|
358
705
|
try:
|
|
359
706
|
owner_pid = int(owner["pid"])
|
|
360
707
|
except (TypeError, ValueError):
|
|
708
|
+
if unexpired:
|
|
709
|
+
item["classification"] = (
|
|
710
|
+
"STALE_CONTENT"
|
|
711
|
+
if lease.get("status") == "STALE_CONTENT"
|
|
712
|
+
else "ACTIVE"
|
|
713
|
+
)
|
|
714
|
+
results.append(item)
|
|
715
|
+
continue
|
|
361
716
|
item["classification"] = "OWNER_IDENTITY_INCOMPLETE"
|
|
362
717
|
results.append(item)
|
|
363
718
|
continue
|
|
@@ -365,6 +720,14 @@ def classify_leases(lease_root: Path) -> list[dict[str, Any]]:
|
|
|
365
720
|
item["classification"] = "RECLAIMABLE"
|
|
366
721
|
results.append(item)
|
|
367
722
|
continue
|
|
723
|
+
if unexpired:
|
|
724
|
+
item["classification"] = (
|
|
725
|
+
"STALE_CONTENT"
|
|
726
|
+
if lease.get("status") == "STALE_CONTENT"
|
|
727
|
+
else "ACTIVE"
|
|
728
|
+
)
|
|
729
|
+
results.append(item)
|
|
730
|
+
continue
|
|
368
731
|
identity = hservice.verify_process_identity(
|
|
369
732
|
{
|
|
370
733
|
"pid": owner_pid,
|
|
@@ -386,7 +749,7 @@ def classify_leases(lease_root: Path) -> list[dict[str, Any]]:
|
|
|
386
749
|
|
|
387
750
|
|
|
388
751
|
def reap_expired_leases(lease_root: Path) -> dict[str, Any]:
|
|
389
|
-
"""Remove only
|
|
752
|
+
"""Remove only leases whose recorded owner is definitely gone."""
|
|
390
753
|
leases = classify_leases(lease_root)
|
|
391
754
|
reaped: list[str] = []
|
|
392
755
|
for item in leases:
|
|
@@ -502,6 +865,16 @@ def require_writable_lease(
|
|
|
502
865
|
"changeId": change_id,
|
|
503
866
|
"stackId": stack_id,
|
|
504
867
|
}
|
|
868
|
+
if not _validate_environment_receipt(
|
|
869
|
+
environment_receipt,
|
|
870
|
+
stack_id=stack_id,
|
|
871
|
+
environment_hash=str(lease.get("environmentHash") or ""),
|
|
872
|
+
):
|
|
873
|
+
return {
|
|
874
|
+
"ok": False,
|
|
875
|
+
"code": "ENVIRONMENT_RECEIPT_INVALID",
|
|
876
|
+
"message": "environment receipt digest or identity is invalid",
|
|
877
|
+
}
|
|
505
878
|
observed_content = str(
|
|
506
879
|
environment_receipt.get("contentFingerprint") or ""
|
|
507
880
|
).strip()
|
|
@@ -532,7 +905,7 @@ def require_writable_lease(
|
|
|
532
905
|
return {"ok": True, "code": "LEASE_HELD", "lease": lease}
|
|
533
906
|
|
|
534
907
|
|
|
535
|
-
def
|
|
908
|
+
def _acquire_lease_locked(
|
|
536
909
|
project: Path,
|
|
537
910
|
*,
|
|
538
911
|
change_id: str,
|
|
@@ -542,8 +915,17 @@ def acquire_lease(
|
|
|
542
915
|
writable_volumes: list[str] | None = None,
|
|
543
916
|
ttl_seconds: int = 3600,
|
|
544
917
|
environment_receipt: dict[str, Any] | None = None,
|
|
918
|
+
mode: str = "change-session",
|
|
919
|
+
reset_evidence: dict[str, Any] | None = None,
|
|
920
|
+
receipt_dir: Path | None = None,
|
|
545
921
|
) -> dict[str, Any]:
|
|
546
|
-
"""Acquire
|
|
922
|
+
"""Acquire, reuse, or reset an isolated environment session."""
|
|
923
|
+
if mode not in {"change-session", "ephemeral"}:
|
|
924
|
+
return {
|
|
925
|
+
"ok": False,
|
|
926
|
+
"code": "ENVIRONMENT_SESSION_MODE_INVALID",
|
|
927
|
+
"message": f"unsupported environment session mode: {mode}",
|
|
928
|
+
}
|
|
547
929
|
root = lease_root or default_lease_root(project)
|
|
548
930
|
root.mkdir(parents=True, exist_ok=True)
|
|
549
931
|
volumes = [str(v).replace("\\", "/") for v in (writable_volumes or [])]
|
|
@@ -577,11 +959,16 @@ def acquire_lease(
|
|
|
577
959
|
}
|
|
578
960
|
|
|
579
961
|
path = _lease_path(root, stack_id)
|
|
962
|
+
current: dict[str, Any] = {}
|
|
580
963
|
if path.is_file():
|
|
581
964
|
try:
|
|
582
965
|
current = _read_json(path)
|
|
583
|
-
except (OSError, json.JSONDecodeError):
|
|
584
|
-
|
|
966
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
967
|
+
return {
|
|
968
|
+
"ok": False,
|
|
969
|
+
"code": "ENVIRONMENT_LEASE_UNREADABLE",
|
|
970
|
+
"message": str(exc),
|
|
971
|
+
}
|
|
585
972
|
if str(current.get("changeId") or "") not in {"", change_id}:
|
|
586
973
|
return {
|
|
587
974
|
"ok": False,
|
|
@@ -596,15 +983,15 @@ def acquire_lease(
|
|
|
596
983
|
started = dt.datetime.now().astimezone()
|
|
597
984
|
expires = started + dt.timedelta(seconds=max(60, int(ttl_seconds)))
|
|
598
985
|
if environment_receipt is not None:
|
|
599
|
-
if (
|
|
600
|
-
environment_receipt
|
|
601
|
-
|
|
602
|
-
|
|
986
|
+
if not _validate_environment_receipt(
|
|
987
|
+
environment_receipt,
|
|
988
|
+
stack_id=stack_id,
|
|
989
|
+
environment_hash=environment_hash,
|
|
603
990
|
):
|
|
604
991
|
return {
|
|
605
992
|
"ok": False,
|
|
606
993
|
"code": "ENVIRONMENT_RECEIPT_INVALID",
|
|
607
|
-
"message": "environment receipt identity does not match
|
|
994
|
+
"message": "environment receipt digest or identity does not match",
|
|
608
995
|
}
|
|
609
996
|
canaries = (
|
|
610
997
|
environment_receipt.get("content", {}).get("canaries")
|
|
@@ -620,10 +1007,94 @@ def acquire_lease(
|
|
|
620
1007
|
"code": "ENVIRONMENT_CONTENT_NOT_READY",
|
|
621
1008
|
"message": "all environment content canaries must pass before acquire",
|
|
622
1009
|
}
|
|
1010
|
+
current_fingerprint = str(current.get("contentFingerprint") or "").strip()
|
|
1011
|
+
requested_fingerprint = str(
|
|
1012
|
+
(environment_receipt or {}).get("contentFingerprint") or ""
|
|
1013
|
+
).strip()
|
|
1014
|
+
same_content = bool(current_fingerprint) and (
|
|
1015
|
+
current_fingerprint == requested_fingerprint
|
|
1016
|
+
)
|
|
1017
|
+
owner = current.get("owner")
|
|
1018
|
+
owner_verified = False
|
|
1019
|
+
if isinstance(owner, dict):
|
|
1020
|
+
try:
|
|
1021
|
+
owner_pid = int(owner.get("pid"))
|
|
1022
|
+
except (TypeError, ValueError):
|
|
1023
|
+
owner_pid = 0
|
|
1024
|
+
owner_verified = hservice.verify_process_identity(
|
|
1025
|
+
{
|
|
1026
|
+
"pid": owner_pid,
|
|
1027
|
+
"startedAt": owner.get("startedAt"),
|
|
1028
|
+
"processIdentity": {"executable": owner.get("executable")},
|
|
1029
|
+
}
|
|
1030
|
+
) is True
|
|
1031
|
+
expires_at = _parse_expiry(current.get("expiresAt"))
|
|
1032
|
+
unexpired = (
|
|
1033
|
+
expires_at is not None
|
|
1034
|
+
and dt.datetime.now(dt.timezone.utc) < expires_at
|
|
1035
|
+
)
|
|
1036
|
+
if (
|
|
1037
|
+
current
|
|
1038
|
+
and str(current.get("changeId") or "") == change_id
|
|
1039
|
+
and mode == "change-session"
|
|
1040
|
+
and str(current.get("mode") or "change-session") == "change-session"
|
|
1041
|
+
and current.get("status") == "ACTIVE"
|
|
1042
|
+
and current.get("environmentHash") == environment_hash
|
|
1043
|
+
and current.get("writableVolumes") == volumes
|
|
1044
|
+
and owner_verified
|
|
1045
|
+
and unexpired
|
|
1046
|
+
and same_content
|
|
1047
|
+
):
|
|
1048
|
+
current["heartbeatAt"] = started.isoformat(timespec="seconds")
|
|
1049
|
+
current["expiresAt"] = expires.isoformat(timespec="seconds")
|
|
1050
|
+
current["reuseCount"] = int(current.get("reuseCount") or 0) + 1
|
|
1051
|
+
_write_json(path, current)
|
|
1052
|
+
action_receipt = _write_environment_action_receipt(
|
|
1053
|
+
root,
|
|
1054
|
+
action="reuse",
|
|
1055
|
+
lease=current,
|
|
1056
|
+
receipt_dir=receipt_dir,
|
|
1057
|
+
)
|
|
1058
|
+
return {
|
|
1059
|
+
"ok": True,
|
|
1060
|
+
"code": "LEASE_REUSED",
|
|
1061
|
+
"lease": current,
|
|
1062
|
+
"path": str(path),
|
|
1063
|
+
"receiptPath": str(action_receipt),
|
|
1064
|
+
}
|
|
1065
|
+
previous_session_id = str(current.get("sessionId") or "").strip() or None
|
|
1066
|
+
reset_count = int(current.get("resetCount") or 0)
|
|
1067
|
+
is_reset = bool(current)
|
|
1068
|
+
reset_operation = _operation_evidence(
|
|
1069
|
+
reset_evidence,
|
|
1070
|
+
operation="reset",
|
|
1071
|
+
lease_root=root,
|
|
1072
|
+
lease=current,
|
|
1073
|
+
)
|
|
1074
|
+
if is_reset and reset_operation is None:
|
|
1075
|
+
return {
|
|
1076
|
+
"ok": False,
|
|
1077
|
+
"code": "ENVIRONMENT_RESET_REQUIRED",
|
|
1078
|
+
"message": (
|
|
1079
|
+
"existing environment cannot be replaced until a matching "
|
|
1080
|
+
"provider reset receipt is supplied"
|
|
1081
|
+
),
|
|
1082
|
+
"changeId": change_id,
|
|
1083
|
+
"stackId": stack_id,
|
|
1084
|
+
"previousSessionId": previous_session_id,
|
|
1085
|
+
}
|
|
1086
|
+
if is_reset and not _consume_operation_evidence(root, reset_operation):
|
|
1087
|
+
return {
|
|
1088
|
+
"ok": False,
|
|
1089
|
+
"code": "ENVIRONMENT_RESET_EVIDENCE_REPLAYED",
|
|
1090
|
+
"message": "provider reset evidence was already consumed",
|
|
1091
|
+
}
|
|
623
1092
|
lease = {
|
|
624
|
-
"schemaVersion":
|
|
1093
|
+
"schemaVersion": 3,
|
|
625
1094
|
"changeId": change_id,
|
|
626
1095
|
"stackId": stack_id,
|
|
1096
|
+
"sessionId": uuid.uuid4().hex,
|
|
1097
|
+
"mode": mode,
|
|
627
1098
|
"environmentHash": environment_hash,
|
|
628
1099
|
"writableVolumes": volumes,
|
|
629
1100
|
"acquiredAt": started.isoformat(timespec="seconds"),
|
|
@@ -631,6 +1102,8 @@ def acquire_lease(
|
|
|
631
1102
|
"expiresAt": expires.isoformat(timespec="seconds"),
|
|
632
1103
|
"projectRoot": str(project.resolve()),
|
|
633
1104
|
"status": "ACTIVE",
|
|
1105
|
+
"reuseCount": 0,
|
|
1106
|
+
"resetCount": reset_count + (1 if is_reset else 0),
|
|
634
1107
|
"owner": {
|
|
635
1108
|
"pid": os.getpid(),
|
|
636
1109
|
"executable": hservice.get_process_executable(os.getpid())
|
|
@@ -645,15 +1118,79 @@ def acquire_lease(
|
|
|
645
1118
|
lease["contentFingerprint"] = environment_receipt["contentFingerprint"]
|
|
646
1119
|
lease["environmentReceiptId"] = environment_receipt.get("receiptId")
|
|
647
1120
|
_write_json(path, lease)
|
|
648
|
-
|
|
1121
|
+
action = "reset" if is_reset else "prepare"
|
|
1122
|
+
action_receipt = _write_environment_action_receipt(
|
|
1123
|
+
root,
|
|
1124
|
+
action=action,
|
|
1125
|
+
lease=lease,
|
|
1126
|
+
previous_session_id=previous_session_id,
|
|
1127
|
+
receipt_dir=receipt_dir,
|
|
1128
|
+
operation_evidence=reset_operation,
|
|
1129
|
+
)
|
|
1130
|
+
return {
|
|
1131
|
+
"ok": True,
|
|
1132
|
+
"code": "LEASE_RESET" if is_reset else "LEASE_ACQUIRED",
|
|
1133
|
+
"lease": lease,
|
|
1134
|
+
"path": str(path),
|
|
1135
|
+
"receiptPath": str(action_receipt),
|
|
1136
|
+
}
|
|
649
1137
|
|
|
650
1138
|
|
|
651
|
-
def
|
|
1139
|
+
def acquire_lease(
|
|
652
1140
|
project: Path,
|
|
653
1141
|
*,
|
|
654
1142
|
change_id: str,
|
|
655
1143
|
stack_id: str,
|
|
1144
|
+
environment_hash: str,
|
|
656
1145
|
lease_root: Path | None = None,
|
|
1146
|
+
writable_volumes: list[str] | None = None,
|
|
1147
|
+
ttl_seconds: int = 3600,
|
|
1148
|
+
environment_receipt: dict[str, Any] | None = None,
|
|
1149
|
+
mode: str = "change-session",
|
|
1150
|
+
reset_evidence: dict[str, Any] | None = None,
|
|
1151
|
+
) -> dict[str, Any]:
|
|
1152
|
+
root = lease_root or default_lease_root(project)
|
|
1153
|
+
receipt_dir: Path | None = None
|
|
1154
|
+
if lease_root is None:
|
|
1155
|
+
contract_dir = project / ".harness" / "changes" / change_id
|
|
1156
|
+
receipt_dir = (
|
|
1157
|
+
resolve_state_dir_for_contract(contract_dir, project)
|
|
1158
|
+
/ "runtime"
|
|
1159
|
+
/ "environment-receipts"
|
|
1160
|
+
)
|
|
1161
|
+
try:
|
|
1162
|
+
with _lease_registry_guard(root):
|
|
1163
|
+
return _acquire_lease_locked(
|
|
1164
|
+
project,
|
|
1165
|
+
change_id=change_id,
|
|
1166
|
+
stack_id=stack_id,
|
|
1167
|
+
environment_hash=environment_hash,
|
|
1168
|
+
lease_root=root,
|
|
1169
|
+
writable_volumes=writable_volumes,
|
|
1170
|
+
ttl_seconds=ttl_seconds,
|
|
1171
|
+
environment_receipt=environment_receipt,
|
|
1172
|
+
mode=mode,
|
|
1173
|
+
reset_evidence=reset_evidence,
|
|
1174
|
+
receipt_dir=receipt_dir,
|
|
1175
|
+
)
|
|
1176
|
+
except RuntimeError as exc:
|
|
1177
|
+
if str(exc) != "ENVIRONMENT_LEASE_BUSY":
|
|
1178
|
+
raise
|
|
1179
|
+
return {
|
|
1180
|
+
"ok": False,
|
|
1181
|
+
"code": "ENVIRONMENT_LEASE_BUSY",
|
|
1182
|
+
"message": "another lease acquisition is still in progress",
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def _release_lease_locked(
|
|
1187
|
+
project: Path,
|
|
1188
|
+
*,
|
|
1189
|
+
change_id: str,
|
|
1190
|
+
stack_id: str,
|
|
1191
|
+
lease_root: Path | None = None,
|
|
1192
|
+
cleanup_evidence: dict[str, Any] | None = None,
|
|
1193
|
+
receipt_dir: Path | None = None,
|
|
657
1194
|
) -> dict[str, Any]:
|
|
658
1195
|
root = lease_root or default_lease_root(project)
|
|
659
1196
|
path = _lease_path(root, stack_id)
|
|
@@ -677,8 +1214,78 @@ def release_lease(
|
|
|
677
1214
|
"holder": lease.get("changeId"),
|
|
678
1215
|
"changeId": change_id,
|
|
679
1216
|
}
|
|
1217
|
+
cleanup_operation = _operation_evidence(
|
|
1218
|
+
cleanup_evidence,
|
|
1219
|
+
operation="cleanup",
|
|
1220
|
+
lease_root=root,
|
|
1221
|
+
lease=lease,
|
|
1222
|
+
)
|
|
1223
|
+
if cleanup_evidence is not None and cleanup_operation is None:
|
|
1224
|
+
return {
|
|
1225
|
+
"ok": False,
|
|
1226
|
+
"code": "ENVIRONMENT_CLEANUP_EVIDENCE_INVALID",
|
|
1227
|
+
"message": "provider cleanup evidence does not match the held lease",
|
|
1228
|
+
}
|
|
1229
|
+
if cleanup_operation is not None and not _consume_operation_evidence(
|
|
1230
|
+
root, cleanup_operation
|
|
1231
|
+
):
|
|
1232
|
+
return {
|
|
1233
|
+
"ok": False,
|
|
1234
|
+
"code": "ENVIRONMENT_CLEANUP_EVIDENCE_REPLAYED",
|
|
1235
|
+
"message": "provider cleanup evidence was already consumed",
|
|
1236
|
+
}
|
|
1237
|
+
action_receipt = _write_environment_action_receipt(
|
|
1238
|
+
root,
|
|
1239
|
+
action="cleanup" if cleanup_operation is not None else "release",
|
|
1240
|
+
lease=lease,
|
|
1241
|
+
receipt_dir=receipt_dir,
|
|
1242
|
+
operation_evidence=cleanup_operation,
|
|
1243
|
+
)
|
|
680
1244
|
path.unlink(missing_ok=True)
|
|
681
|
-
return {
|
|
1245
|
+
return {
|
|
1246
|
+
"ok": True,
|
|
1247
|
+
"code": "LEASE_RELEASED",
|
|
1248
|
+
"changeId": change_id,
|
|
1249
|
+
"stackId": stack_id,
|
|
1250
|
+
"receiptPath": str(action_receipt),
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
def release_lease(
|
|
1255
|
+
project: Path,
|
|
1256
|
+
*,
|
|
1257
|
+
change_id: str,
|
|
1258
|
+
stack_id: str,
|
|
1259
|
+
lease_root: Path | None = None,
|
|
1260
|
+
cleanup_evidence: dict[str, Any] | None = None,
|
|
1261
|
+
) -> dict[str, Any]:
|
|
1262
|
+
root = lease_root or default_lease_root(project)
|
|
1263
|
+
receipt_dir: Path | None = None
|
|
1264
|
+
if lease_root is None:
|
|
1265
|
+
contract_dir = project / ".harness" / "changes" / change_id
|
|
1266
|
+
receipt_dir = (
|
|
1267
|
+
resolve_state_dir_for_contract(contract_dir, project)
|
|
1268
|
+
/ "runtime"
|
|
1269
|
+
/ "environment-receipts"
|
|
1270
|
+
)
|
|
1271
|
+
try:
|
|
1272
|
+
with _lease_registry_guard(root):
|
|
1273
|
+
return _release_lease_locked(
|
|
1274
|
+
project,
|
|
1275
|
+
change_id=change_id,
|
|
1276
|
+
stack_id=stack_id,
|
|
1277
|
+
lease_root=root,
|
|
1278
|
+
cleanup_evidence=cleanup_evidence,
|
|
1279
|
+
receipt_dir=receipt_dir,
|
|
1280
|
+
)
|
|
1281
|
+
except RuntimeError as exc:
|
|
1282
|
+
if str(exc) != "ENVIRONMENT_LEASE_BUSY":
|
|
1283
|
+
raise
|
|
1284
|
+
return {
|
|
1285
|
+
"ok": False,
|
|
1286
|
+
"code": "ENVIRONMENT_LEASE_BUSY",
|
|
1287
|
+
"message": "another lease operation is still in progress",
|
|
1288
|
+
}
|
|
682
1289
|
|
|
683
1290
|
|
|
684
1291
|
def cmd_fingerprint(args: argparse.Namespace) -> int:
|
|
@@ -822,11 +1429,12 @@ def cmd_prepare(args: argparse.Namespace) -> int:
|
|
|
822
1429
|
|
|
823
1430
|
def cmd_acquire(args: argparse.Namespace) -> int:
|
|
824
1431
|
project = Path(args.project).resolve()
|
|
825
|
-
root = Path(args.lease_root).resolve() if args.lease_root else
|
|
1432
|
+
root = Path(args.lease_root).resolve() if args.lease_root else None
|
|
826
1433
|
env_hash = args.environment_hash or compute_environment_hash(project)
|
|
827
1434
|
volumes = [v for v in (args.writable_volume or []) if v]
|
|
828
1435
|
try:
|
|
829
1436
|
receipt = _load_receipt_arg(args.environment_receipt)
|
|
1437
|
+
reset_evidence = _load_receipt_arg(args.reset_evidence)
|
|
830
1438
|
result = acquire_lease(
|
|
831
1439
|
project,
|
|
832
1440
|
change_id=args.change,
|
|
@@ -836,6 +1444,8 @@ def cmd_acquire(args: argparse.Namespace) -> int:
|
|
|
836
1444
|
writable_volumes=volumes,
|
|
837
1445
|
ttl_seconds=int(args.ttl_seconds or 3600),
|
|
838
1446
|
environment_receipt=receipt,
|
|
1447
|
+
mode=args.mode,
|
|
1448
|
+
reset_evidence=reset_evidence,
|
|
839
1449
|
)
|
|
840
1450
|
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
841
1451
|
result = {
|
|
@@ -848,16 +1458,72 @@ def cmd_acquire(args: argparse.Namespace) -> int:
|
|
|
848
1458
|
|
|
849
1459
|
def cmd_release(args: argparse.Namespace) -> int:
|
|
850
1460
|
project = Path(args.project).resolve()
|
|
851
|
-
root = Path(args.lease_root).resolve() if args.lease_root else
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
1461
|
+
root = Path(args.lease_root).resolve() if args.lease_root else None
|
|
1462
|
+
try:
|
|
1463
|
+
cleanup_evidence = _load_receipt_arg(args.cleanup_evidence)
|
|
1464
|
+
result = release_lease(
|
|
1465
|
+
project,
|
|
1466
|
+
change_id=args.change,
|
|
1467
|
+
stack_id=args.stack_id,
|
|
1468
|
+
lease_root=root,
|
|
1469
|
+
cleanup_evidence=cleanup_evidence,
|
|
1470
|
+
)
|
|
1471
|
+
except (OSError, json.JSONDecodeError, ValueError) as exc:
|
|
1472
|
+
result = {
|
|
1473
|
+
"ok": False,
|
|
1474
|
+
"code": "ENVIRONMENT_CLEANUP_EVIDENCE_INVALID",
|
|
1475
|
+
"message": str(exc),
|
|
1476
|
+
}
|
|
858
1477
|
return emit_json(result, ok=bool(result.get("ok")))
|
|
859
1478
|
|
|
860
1479
|
|
|
1480
|
+
def cmd_provider_operation(args: argparse.Namespace) -> int:
|
|
1481
|
+
project = Path(args.project).resolve()
|
|
1482
|
+
root = Path(args.lease_root).resolve() if args.lease_root else None
|
|
1483
|
+
try:
|
|
1484
|
+
argv = json.loads(args.argv_json)
|
|
1485
|
+
if not isinstance(argv, list):
|
|
1486
|
+
raise ValueError("provider argv must be a JSON array")
|
|
1487
|
+
result = execute_provider_operation(
|
|
1488
|
+
project,
|
|
1489
|
+
change_id=args.change,
|
|
1490
|
+
stack_id=args.stack_id,
|
|
1491
|
+
operation=args.operation,
|
|
1492
|
+
argv=argv,
|
|
1493
|
+
lease_root=root,
|
|
1494
|
+
timeout_seconds=float(args.timeout_seconds),
|
|
1495
|
+
)
|
|
1496
|
+
except (
|
|
1497
|
+
OSError,
|
|
1498
|
+
json.JSONDecodeError,
|
|
1499
|
+
ValueError,
|
|
1500
|
+
subprocess.SubprocessError,
|
|
1501
|
+
) as exc:
|
|
1502
|
+
return emit_json(
|
|
1503
|
+
{
|
|
1504
|
+
"ok": False,
|
|
1505
|
+
"code": "ENVIRONMENT_PROVIDER_OPERATION_FAILED",
|
|
1506
|
+
"message": str(exc),
|
|
1507
|
+
},
|
|
1508
|
+
ok=False,
|
|
1509
|
+
)
|
|
1510
|
+
if args.output:
|
|
1511
|
+
_write_json(Path(args.output).resolve(), result)
|
|
1512
|
+
return emit_json(
|
|
1513
|
+
{
|
|
1514
|
+
"ok": result.get("status") == "OK",
|
|
1515
|
+
"code": (
|
|
1516
|
+
"ENVIRONMENT_PROVIDER_OPERATION_OK"
|
|
1517
|
+
if result.get("status") == "OK"
|
|
1518
|
+
else "ENVIRONMENT_PROVIDER_OPERATION_FAILED"
|
|
1519
|
+
),
|
|
1520
|
+
"receiptPath": result.get("receiptPath"),
|
|
1521
|
+
"receiptDigest": result.get("receiptDigest"),
|
|
1522
|
+
},
|
|
1523
|
+
ok=result.get("status") == "OK",
|
|
1524
|
+
)
|
|
1525
|
+
|
|
1526
|
+
|
|
861
1527
|
def cmd_require(args: argparse.Namespace) -> int:
|
|
862
1528
|
project = Path(args.project).resolve()
|
|
863
1529
|
root = Path(args.lease_root).resolve() if args.lease_root else default_lease_root(project)
|
|
@@ -933,9 +1599,15 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
933
1599
|
ac.add_argument("--stack-id", required=True)
|
|
934
1600
|
ac.add_argument("--environment-hash")
|
|
935
1601
|
ac.add_argument("--environment-receipt")
|
|
1602
|
+
ac.add_argument("--reset-evidence")
|
|
936
1603
|
ac.add_argument("--lease-root")
|
|
937
1604
|
ac.add_argument("--writable-volume", action="append", default=[])
|
|
938
1605
|
ac.add_argument("--ttl-seconds", type=int, default=3600)
|
|
1606
|
+
ac.add_argument(
|
|
1607
|
+
"--mode",
|
|
1608
|
+
choices=("change-session", "ephemeral"),
|
|
1609
|
+
default="change-session",
|
|
1610
|
+
)
|
|
939
1611
|
ac.set_defaults(func=cmd_acquire)
|
|
940
1612
|
|
|
941
1613
|
rel = sub.add_parser("release")
|
|
@@ -943,8 +1615,20 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
943
1615
|
rel.add_argument("--change", required=True)
|
|
944
1616
|
rel.add_argument("--stack-id", required=True)
|
|
945
1617
|
rel.add_argument("--lease-root")
|
|
1618
|
+
rel.add_argument("--cleanup-evidence")
|
|
946
1619
|
rel.set_defaults(func=cmd_release)
|
|
947
1620
|
|
|
1621
|
+
provider = sub.add_parser("provider-operation")
|
|
1622
|
+
provider.add_argument("--project", required=True)
|
|
1623
|
+
provider.add_argument("--change", required=True)
|
|
1624
|
+
provider.add_argument("--stack-id", required=True)
|
|
1625
|
+
provider.add_argument("--operation", choices=("reset", "cleanup"), required=True)
|
|
1626
|
+
provider.add_argument("--argv-json", required=True)
|
|
1627
|
+
provider.add_argument("--lease-root")
|
|
1628
|
+
provider.add_argument("--timeout-seconds", type=float, default=900.0)
|
|
1629
|
+
provider.add_argument("--output")
|
|
1630
|
+
provider.set_defaults(func=cmd_provider_operation)
|
|
1631
|
+
|
|
948
1632
|
req = sub.add_parser("require")
|
|
949
1633
|
req.add_argument("--project", required=True)
|
|
950
1634
|
req.add_argument("--change", required=True)
|