its-magic 0.1.3-0 → 0.1.3-2
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/installer.ps1 +8 -0
- package/installer.py +8 -0
- package/installer.sh +1 -0
- package/package.json +1 -1
- package/scripts/check_intake_template_parity.py +62 -0
- package/template/.cursor/agents/curator.mdc +1 -0
- package/template/.cursor/agents/po.mdc +1 -0
- package/template/.cursor/agents/release.mdc +1 -0
- package/template/.cursor/commands/auto.md +90 -0
- package/template/.cursor/commands/execute.md +26 -0
- package/template/.cursor/commands/refresh-context.md +32 -0
- package/template/.cursor/commands/sovereign-critic.md +104 -0
- package/template/.cursor/model-catalog.local.example.cursor-only.json +9 -0
- package/template/.cursor/model-catalog.local.example.json +9 -0
- package/template/.cursor/model-catalog.local.example.level-1-easy.json +9 -0
- package/template/.cursor/model-catalog.local.example.level-2-complex.json +9 -0
- package/template/.cursor/model-catalog.local.example.level-3-mega.json +9 -0
- package/template/.cursor/model-catalog.local.example.level-4-super.json +9 -0
- package/template/.cursor/model-catalog.local.example.role-based-balanced.json +18 -0
- package/template/.cursor/model-catalog.local.example.role-based-highend.json +18 -0
- package/template/.cursor/rules/sovereign-role-manifest.mdc.example +27 -0
- package/template/.cursor/scratchpad.local.example.md +55 -0
- package/template/.cursor/scratchpad.md +203 -0
- package/template/.cursor/sovereign-role-manifest.yaml.example +53 -0
- package/template/decisions/DEC-0104.md +279 -0
- package/template/decisions/DEC-0105.md +231 -0
- package/template/decisions/DEC-0107.md +246 -0
- package/template/docs/engineering/architecture.md +78 -0
- package/template/docs/engineering/auto-orchestration-reference.md +7 -0
- package/template/docs/engineering/context/installer-owned-paths.manifest +8 -0
- package/template/docs/engineering/reason_codes.md +411 -0
- package/template/docs/engineering/runbook.md +1119 -0
- package/template/docs/engineering/sovereign-memory/.gitkeep +0 -0
- package/template/docs/engineering/sovereign-memory/retrospectives/.gitkeep +0 -0
- package/template/handoffs/sovereign_decisions/.gitkeep +0 -0
- package/template/handoffs/sovereign_deferrals/.gitkeep +0 -0
- package/template/handoffs/sovereign_role_reviews.jsonl +0 -0
- package/template/scripts/__pycache__/decision_ledger_lib.cpython-312.pyc +0 -0
- package/template/scripts/check_intake_template_parity.py +181 -0
- package/template/scripts/decision_ledger_lib.py +732 -0
- package/template/scripts/ledger_validate.py +153 -0
- package/template/scripts/model_tier_lib.py +680 -0
- package/template/scripts/model_tier_validate.py +368 -0
- package/template/scripts/parallel_dev_arbiter.py +923 -0
- package/template/scripts/release_trigger_adapters.py +843 -0
- package/template/scripts/self_healing_deploy_lib.py +463 -0
- package/template/scripts/self_healing_deploy_validate.py +78 -0
- package/template/scripts/sovereign_convergence_lib.py +994 -0
- package/template/scripts/sovereign_convergence_validate.py +206 -0
- package/template/scripts/sovereign_critic_lib.py +629 -0
- package/template/scripts/sovereign_critic_validate.py +131 -0
- package/template/scripts/sovereign_loop_lib.py +828 -0
- package/template/scripts/sovereign_loop_validate.py +122 -0
- package/template/scripts/sovereign_memory_lib.py +869 -0
- package/template/scripts/sovereign_memory_validate.py +153 -0
- package/template/scripts/sovereign_role_manifest_lib.py +547 -0
- package/template/scripts/sovereign_role_manifest_validate.py +105 -0
- package/template/tests/us0108_contract_test.py +207 -0
- package/template/tests/us0109_contract_test.py +209 -0
- package/template/tests/us0109_us0110_compose_test.py +34 -0
- package/template/tests/us0111_contract_test.py +345 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Self-Healing Deploy Loop helper library (US-0109 / DEC-0109).
|
|
4
|
+
|
|
5
|
+
Post-deploy smoke probe + bounded retry loop on US-0054 publish chain.
|
|
6
|
+
After the post-publish PASS point, two-stage probe validates deployed artifact.
|
|
7
|
+
On probe FAIL, re-enter publish PASS path (not execute re-entry) up to
|
|
8
|
+
`AUTO_SOVEREIGN_DEPLOY_RETRY_MAX`. After retry-cap exhaustion, emit
|
|
9
|
+
DEPLOY_DEFERRED tuple via US-0107 `append_deferral(work_item_kind=deploy)`.
|
|
10
|
+
|
|
11
|
+
Default-off: `AUTO_SOVEREIGN_SELF_HEALING_DEPLOY=0` → zero overhead, byte-identical
|
|
12
|
+
US-0054 publish path — no probe, no retry, no deferral, no execute steps 29-31.
|
|
13
|
+
|
|
14
|
+
Reason codes (DEC-0109 §7):
|
|
15
|
+
DEPLOY_HEALING_DISABLED (info), DEPLOY_HEALING_SMOKE_HEALTH_FAIL,
|
|
16
|
+
DEPLOY_HEALING_SMOKE_ACCEPTANCE_FAIL, DEPLOY_HEALING_RETRY_ATTEMPT,
|
|
17
|
+
DEPLOY_HEALING_RETRY_CAP_EXHAUSTED, DEPLOY_HEALING_DEFERRED,
|
|
18
|
+
DEPLOY_HEALING_PROBE_TARGET_MISSING, DEPLOY_HEALING_TIMEOUT.
|
|
19
|
+
|
|
20
|
+
Compose guards (non-negotiable): DO NOT amend US-0054, US-0100, US-0103, US-0107, US-0110.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import socket
|
|
28
|
+
import subprocess
|
|
29
|
+
import sys
|
|
30
|
+
import time
|
|
31
|
+
from dataclasses import asdict, dataclass, field
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from enum import Enum
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any, Callable, Dict, List, Optional, Tuple
|
|
36
|
+
|
|
37
|
+
_SCRIPT_DIR = Path(__file__).resolve().parent
|
|
38
|
+
if str(_SCRIPT_DIR) not in sys.path:
|
|
39
|
+
sys.path.insert(0, str(_SCRIPT_DIR))
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from sovereign_loop_lib import append_deferral, is_sovereign_loop_enabled # type: ignore
|
|
43
|
+
except ImportError:
|
|
44
|
+
append_deferral = None # type: ignore
|
|
45
|
+
is_sovereign_loop_enabled = None # type: ignore
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
SCHEMA_VERSION = 1
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class ProbeKind(Enum):
|
|
52
|
+
HEALTH_ENDPOINT = "health_endpoint"
|
|
53
|
+
ACCEPTANCE_SMOKE = "acceptance_smoke"
|
|
54
|
+
BOTH = "both"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class ReasonCode(Enum):
|
|
58
|
+
DEPLOY_HEALING_DISABLED = "DEPLOY_HEALING_DISABLED"
|
|
59
|
+
DEPLOY_HEALING_SMOKE_HEALTH_FAIL = "DEPLOY_HEALING_SMOKE_HEALTH_FAIL"
|
|
60
|
+
DEPLOY_HEALING_SMOKE_ACCEPTANCE_FAIL = "DEPLOY_HEALING_SMOKE_ACCEPTANCE_FAIL"
|
|
61
|
+
DEPLOY_HEALING_RETRY_ATTEMPT = "DEPLOY_HEALING_RETRY_ATTEMPT"
|
|
62
|
+
DEPLOY_HEALING_RETRY_CAP_EXHAUSTED = "DEPLOY_HEALING_RETRY_CAP_EXHAUSTED"
|
|
63
|
+
DEPLOY_HEALING_DEFERRED = "DEPLOY_HEALING_DEFERRED"
|
|
64
|
+
DEPLOY_HEALING_PROBE_TARGET_MISSING = "DEPLOY_HEALING_PROBE_TARGET_MISSING"
|
|
65
|
+
DEPLOY_HEALING_TIMEOUT = "DEPLOY_HEALING_TIMEOUT"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Scratchpad keys (DEC-0109 §1)
|
|
69
|
+
AUTO_SOVEREIGN_SELF_HEALING_DEPLOY_KEY = "AUTO_SOVEREIGN_SELF_HEALING_DEPLOY"
|
|
70
|
+
AUTO_SOVEREIGN_DEPLOY_RETRY_MAX_KEY = "AUTO_SOVEREIGN_DEPLOY_RETRY_MAX"
|
|
71
|
+
AUTO_SOVEREIGN_DEPLOY_SMOKE_TIMEOUT_SEC_KEY = "AUTO_SOVEREIGN_DEPLOY_SMOKE_TIMEOUT_SEC"
|
|
72
|
+
AUTO_SOVEREIGN_DEPLOY_PROBE_KIND_KEY = "AUTO_SOVEREIGN_DEPLOY_PROBE_KIND"
|
|
73
|
+
SOVEREIGN_DEPLOY_ACCEPTANCE_SMOKE_PATH_KEY = "SOVEREIGN_DEPLOY_ACCEPTANCE_SMOKE_PATH"
|
|
74
|
+
AUTO_SOVEREIGN_DEPLOY_HEALTH_ENDPOINT_KEY = "AUTO_SOVEREIGN_DEPLOY_HEALTH_ENDPOINT"
|
|
75
|
+
|
|
76
|
+
# Defaults (DEC-0109 §1)
|
|
77
|
+
DEFAULT_RETRY_MAX = 3
|
|
78
|
+
DEFAULT_SMOKE_TIMEOUT_SEC = 30
|
|
79
|
+
DEFAULT_PROBE_KIND = "both"
|
|
80
|
+
DEFAULT_ACCEPTANCE_SMOKE_PATH = "tests/deploy_smoke/"
|
|
81
|
+
DEFAULT_SELF_HEALING_ENABLED = "0"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass
|
|
85
|
+
class ProbeResult:
|
|
86
|
+
probe_kind: str
|
|
87
|
+
health_status: Optional[str] = None
|
|
88
|
+
health_status_code: Optional[str] = None
|
|
89
|
+
acceptance_status: Optional[str] = None
|
|
90
|
+
acceptance_tests_run: Optional[int] = None
|
|
91
|
+
acceptance_tests_failed: Optional[int] = None
|
|
92
|
+
overall: str = "fail"
|
|
93
|
+
reason_code: str = ""
|
|
94
|
+
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
95
|
+
|
|
96
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
97
|
+
return asdict(self)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class HealingLoopResult:
|
|
102
|
+
enabled: bool
|
|
103
|
+
probe_result: Optional[Dict[str, Any]] = None
|
|
104
|
+
retry_count: int = 0
|
|
105
|
+
retry_max: int = DEFAULT_RETRY_MAX
|
|
106
|
+
deferred: bool = False
|
|
107
|
+
deferral_id: Optional[str] = None
|
|
108
|
+
reason_code: str = ""
|
|
109
|
+
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
|
|
110
|
+
|
|
111
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
112
|
+
return asdict(self)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def is_self_healing_deploy_enabled(scratchpad: Dict[str, str]) -> bool:
|
|
116
|
+
"""Return True when AUTO_SOVEREIGN_SELF_HEALING_DEPLOY=1 (default 0 = disabled)."""
|
|
117
|
+
value = scratchpad.get(AUTO_SOVEREIGN_SELF_HEALING_DEPLOY_KEY, DEFAULT_SELF_HEALING_ENABLED)
|
|
118
|
+
return value.strip() == "1"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_retry_max(scratchpad: Dict[str, str]) -> int:
|
|
122
|
+
"""Return AUTO_SOVEREIGN_DEPLOY_RETRY_MAX (default 3; int >= 1)."""
|
|
123
|
+
raw = scratchpad.get(AUTO_SOVEREIGN_DEPLOY_RETRY_MAX_KEY, str(DEFAULT_RETRY_MAX))
|
|
124
|
+
try:
|
|
125
|
+
parsed = int(raw.strip())
|
|
126
|
+
return max(1, parsed)
|
|
127
|
+
except (ValueError, AttributeError):
|
|
128
|
+
return DEFAULT_RETRY_MAX
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def get_smoke_timeout_sec(scratchpad: Dict[str, str]) -> int:
|
|
132
|
+
"""Return AUTO_SOVEREIGN_DEPLOY_SMOKE_TIMEOUT_SEC (default 30; int >= 1)."""
|
|
133
|
+
raw = scratchpad.get(AUTO_SOVEREIGN_DEPLOY_SMOKE_TIMEOUT_SEC_KEY, str(DEFAULT_SMOKE_TIMEOUT_SEC))
|
|
134
|
+
try:
|
|
135
|
+
parsed = int(raw.strip())
|
|
136
|
+
return max(1, parsed)
|
|
137
|
+
except (ValueError, AttributeError):
|
|
138
|
+
return DEFAULT_SMOKE_TIMEOUT_SEC
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def get_probe_kind(scratchpad: Dict[str, str]) -> ProbeKind:
|
|
142
|
+
"""Return AUTO_SOVEREIGN_DEPLOY_PROBE_KIND (default both)."""
|
|
143
|
+
raw = scratchpad.get(AUTO_SOVEREIGN_DEPLOY_PROBE_KIND_KEY, DEFAULT_PROBE_KIND)
|
|
144
|
+
try:
|
|
145
|
+
return ProbeKind(raw.strip().lower())
|
|
146
|
+
except (ValueError, AttributeError):
|
|
147
|
+
return ProbeKind.BOTH
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def get_acceptance_smoke_path(scratchpad: Dict[str, str]) -> str:
|
|
151
|
+
"""Return SOVEREIGN_DEPLOY_ACCEPTANCE_SMOKE_PATH (default tests/deploy_smoke/)."""
|
|
152
|
+
return scratchpad.get(SOVEREIGN_DEPLOY_ACCEPTANCE_SMOKE_PATH_KEY, DEFAULT_ACCEPTANCE_SMOKE_PATH)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def resolve_health_endpoint_url(scratchpad: Dict[str, str]) -> Optional[str]:
|
|
156
|
+
"""Return health endpoint URL from names-only env ref (US-0085 compose).
|
|
157
|
+
|
|
158
|
+
`AUTO_SOVEREIGN_DEPLOY_HEALTH_ENDPOINT` is a KEY NAME in `os.environ` — NOT a URL literal.
|
|
159
|
+
Fail-closed `DEPLOY_HEALING_PROBE_TARGET_MISSING` when absent or unresolvable.
|
|
160
|
+
No secret values leaked from `.env` (US-0085 / US-0093 compose).
|
|
161
|
+
"""
|
|
162
|
+
env_key_name = scratchpad.get(AUTO_SOVEREIGN_DEPLOY_HEALTH_ENDPOINT_KEY, "").strip()
|
|
163
|
+
if not env_key_name:
|
|
164
|
+
return None
|
|
165
|
+
url = os.environ.get(env_key_name, "").strip()
|
|
166
|
+
if not url:
|
|
167
|
+
return None
|
|
168
|
+
return url
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _scan_for_secret_candidate(url: str) -> bool:
|
|
172
|
+
"""Best-effort secret scan — reject URLs carrying inline credentials (US-0085)."""
|
|
173
|
+
forbidden_patterns = ["password=", "token=", "key=", "secret=", "Bearer ", "Basic "]
|
|
174
|
+
lower = url.lower()
|
|
175
|
+
return any(p in lower for p in forbidden_patterns)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def run_health_probe(scratchpad: Dict[str, str]) -> ProbeResult:
|
|
179
|
+
"""Stage (a): health HTTP GET to names-only env ref resolved URL.
|
|
180
|
+
|
|
181
|
+
Success: HTTP 2xx. Fail: timeout / non-2xx / unresolvable target.
|
|
182
|
+
"""
|
|
183
|
+
timeout_sec = get_smoke_timeout_sec(scratchpad)
|
|
184
|
+
url = resolve_health_endpoint_url(scratchpad)
|
|
185
|
+
result = ProbeResult(probe_kind="health_endpoint")
|
|
186
|
+
|
|
187
|
+
if url is None:
|
|
188
|
+
result.health_status = "fail"
|
|
189
|
+
result.reason_code = ReasonCode.DEPLOY_HEALING_PROBE_TARGET_MISSING.value
|
|
190
|
+
result.overall = "fail"
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
if _scan_for_secret_candidate(url):
|
|
194
|
+
result.health_status = "fail"
|
|
195
|
+
result.reason_code = f"{ReasonCode.DEPLOY_HEALING_PROBE_TARGET_MISSING.value}_SECRET_SCAN"
|
|
196
|
+
result.overall = "fail"
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
import urllib.request
|
|
201
|
+
import urllib.error
|
|
202
|
+
req = urllib.request.Request(url, method="GET")
|
|
203
|
+
with urllib.request.urlopen(req, timeout=timeout_sec) as resp:
|
|
204
|
+
status = resp.status
|
|
205
|
+
result.health_status_code = str(status)
|
|
206
|
+
if 200 <= status < 300:
|
|
207
|
+
result.health_status = "pass"
|
|
208
|
+
result.overall = "pass"
|
|
209
|
+
result.reason_code = "DEPLOY_SMOKE_PROBE_OK"
|
|
210
|
+
else:
|
|
211
|
+
result.health_status = "fail"
|
|
212
|
+
result.overall = "fail"
|
|
213
|
+
result.reason_code = ReasonCode.DEPLOY_HEALING_SMOKE_HEALTH_FAIL.value
|
|
214
|
+
except socket.timeout:
|
|
215
|
+
result.health_status = "fail"
|
|
216
|
+
result.health_status_code = "timeout"
|
|
217
|
+
result.overall = "fail"
|
|
218
|
+
result.reason_code = ReasonCode.DEPLOY_HEALING_TIMEOUT.value
|
|
219
|
+
except urllib.error.URLError as exc:
|
|
220
|
+
result.health_status = "fail"
|
|
221
|
+
result.health_status_code = "url_error"
|
|
222
|
+
result.overall = "fail"
|
|
223
|
+
result.reason_code = ReasonCode.DEPLOY_HEALING_SMOKE_HEALTH_FAIL.value
|
|
224
|
+
except Exception:
|
|
225
|
+
result.health_status = "fail"
|
|
226
|
+
result.health_status_code = "unknown"
|
|
227
|
+
result.overall = "fail"
|
|
228
|
+
result.reason_code = ReasonCode.DEPLOY_HEALING_SMOKE_HEALTH_FAIL.value
|
|
229
|
+
|
|
230
|
+
return result
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def run_acceptance_smoke(scratchpad: Dict[str, str], repo: Optional[Path] = None) -> ProbeResult:
|
|
234
|
+
"""Stage (b): bounded pytest runner on acceptance smoke path.
|
|
235
|
+
|
|
236
|
+
Path: SOVEREIGN_DEPLOY_ACCEPTANCE_SMOKE_PATH (default tests/deploy_smoke/).
|
|
237
|
+
Runner: `pytest -x --timeout=30 -q <path>`. Success: exit 0.
|
|
238
|
+
"""
|
|
239
|
+
timeout_sec = get_smoke_timeout_sec(scratchpad)
|
|
240
|
+
smoke_path = get_acceptance_smoke_path(scratchpad)
|
|
241
|
+
result = ProbeResult(probe_kind="acceptance_smoke")
|
|
242
|
+
|
|
243
|
+
target = Path(repo / smoke_path) if repo else Path(smoke_path)
|
|
244
|
+
if not target.exists():
|
|
245
|
+
result.acceptance_status = "skip"
|
|
246
|
+
result.acceptance_tests_run = 0
|
|
247
|
+
result.acceptance_tests_failed = 0
|
|
248
|
+
result.overall = "pass"
|
|
249
|
+
result.reason_code = "DEPLOY_ACCEPTANCE_SMOKE_SKIP_NO_PATH"
|
|
250
|
+
return result
|
|
251
|
+
|
|
252
|
+
cmd = [
|
|
253
|
+
sys.executable, "-m", "pytest",
|
|
254
|
+
"-x",
|
|
255
|
+
f"--timeout={timeout_sec}",
|
|
256
|
+
"-q",
|
|
257
|
+
str(target),
|
|
258
|
+
]
|
|
259
|
+
try:
|
|
260
|
+
proc = subprocess.run(
|
|
261
|
+
cmd,
|
|
262
|
+
capture_output=True,
|
|
263
|
+
text=True,
|
|
264
|
+
timeout=timeout_sec + 10,
|
|
265
|
+
cwd=str(repo) if repo else None,
|
|
266
|
+
)
|
|
267
|
+
except subprocess.TimeoutExpired:
|
|
268
|
+
result.acceptance_status = "fail"
|
|
269
|
+
result.overall = "fail"
|
|
270
|
+
result.reason_code = ReasonCode.DEPLOY_HEALING_TIMEOUT.value
|
|
271
|
+
return result
|
|
272
|
+
except Exception:
|
|
273
|
+
result.acceptance_status = "fail"
|
|
274
|
+
result.overall = "fail"
|
|
275
|
+
result.reason_code = ReasonCode.DEPLOY_HEALING_SMOKE_ACCEPTANCE_FAIL.value
|
|
276
|
+
return result
|
|
277
|
+
|
|
278
|
+
failed_count = 0
|
|
279
|
+
passed_count = 0
|
|
280
|
+
for line in (proc.stdout or "").splitlines():
|
|
281
|
+
if " passed" in line:
|
|
282
|
+
try:
|
|
283
|
+
passed_count = int(line.split(" passed")[0].split()[-1])
|
|
284
|
+
except (ValueError, IndexError):
|
|
285
|
+
passed_count = 0
|
|
286
|
+
if " failed" in line:
|
|
287
|
+
try:
|
|
288
|
+
failed_count = int(line.split(" failed")[0].split()[-1])
|
|
289
|
+
except (ValueError, IndexError):
|
|
290
|
+
failed_count = 0
|
|
291
|
+
|
|
292
|
+
result.acceptance_tests_run = passed_count + failed_count
|
|
293
|
+
result.acceptance_tests_failed = failed_count
|
|
294
|
+
|
|
295
|
+
if proc.returncode == 0:
|
|
296
|
+
result.acceptance_status = "pass"
|
|
297
|
+
result.overall = "pass"
|
|
298
|
+
result.reason_code = "DEPLOY_SMOKE_PROBE_OK"
|
|
299
|
+
else:
|
|
300
|
+
result.acceptance_status = "fail"
|
|
301
|
+
result.overall = "fail"
|
|
302
|
+
result.reason_code = ReasonCode.DEPLOY_HEALING_SMOKE_ACCEPTANCE_FAIL.value
|
|
303
|
+
|
|
304
|
+
return result
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def run_smoke_probe_chain(scratchpad: Dict[str, str]) -> ProbeResult:
|
|
308
|
+
"""Two-stage probe chain executed sequentially at post-publish stage.
|
|
309
|
+
|
|
310
|
+
When PROBE_KIND != BOTH, only the named stage runs.
|
|
311
|
+
Both stages MUST pass to emit `[DEPLOY_SMOKE_PROBE_OK]`.
|
|
312
|
+
"""
|
|
313
|
+
if not is_self_healing_deploy_enabled(scratchpad):
|
|
314
|
+
result = ProbeResult(probe_kind="disabled")
|
|
315
|
+
result.overall = "pass"
|
|
316
|
+
result.reason_code = ReasonCode.DEPLOY_HEALING_DISABLED.value
|
|
317
|
+
result.health_status = "skip"
|
|
318
|
+
result.acceptance_status = "skip"
|
|
319
|
+
return result
|
|
320
|
+
|
|
321
|
+
probe_kind = get_probe_kind(scratchpad)
|
|
322
|
+
result = ProbeResult(probe_kind=probe_kind.value)
|
|
323
|
+
|
|
324
|
+
if probe_kind in (ProbeKind.HEALTH_ENDPOINT, ProbeKind.BOTH):
|
|
325
|
+
health = run_health_probe(scratchpad)
|
|
326
|
+
result.health_status = health.health_status
|
|
327
|
+
result.health_status_code = health.health_status_code
|
|
328
|
+
if health.overall != "pass":
|
|
329
|
+
result.overall = "fail"
|
|
330
|
+
result.reason_code = health.reason_code
|
|
331
|
+
result.acceptance_status = "skip"
|
|
332
|
+
return result
|
|
333
|
+
|
|
334
|
+
if probe_kind in (ProbeKind.ACCEPTANCE_SMOKE, ProbeKind.BOTH):
|
|
335
|
+
acceptance = run_acceptance_smoke(scratchpad)
|
|
336
|
+
result.acceptance_status = acceptance.acceptance_status
|
|
337
|
+
result.acceptance_tests_run = acceptance.acceptance_tests_run
|
|
338
|
+
result.acceptance_tests_failed = acceptance.acceptance_tests_failed
|
|
339
|
+
if acceptance.overall != "pass":
|
|
340
|
+
result.overall = "fail"
|
|
341
|
+
result.reason_code = acceptance.reason_code
|
|
342
|
+
return result
|
|
343
|
+
|
|
344
|
+
result.overall = "pass"
|
|
345
|
+
result.reason_code = "DEPLOY_SMOKE_PROBE_OK"
|
|
346
|
+
return result
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
def run_deploy_healing_loop(
|
|
350
|
+
repo: Path,
|
|
351
|
+
scratchpad: Dict[str, str],
|
|
352
|
+
publish_handler: Callable[[str], bool],
|
|
353
|
+
*,
|
|
354
|
+
story_id: str = "",
|
|
355
|
+
orchestrator_run_id: str = "",
|
|
356
|
+
) -> HealingLoopResult:
|
|
357
|
+
"""Bounded retry loop re-entering US-0054 publish PASS path on probe FAIL.
|
|
358
|
+
|
|
359
|
+
publish_handler: callable that re-runs publish PASS path; returns True on success.
|
|
360
|
+
Retries up to AUTO_SOVEREIGN_DEPLOY_RETRY_MAX. Idempotency invariant: no duplicate
|
|
361
|
+
ledger rows (US-0103 `retry_count` field); deploy target overwrite safe.
|
|
362
|
+
"""
|
|
363
|
+
result = HealingLoopResult(enabled=False)
|
|
364
|
+
if not is_self_healing_deploy_enabled(scratchpad):
|
|
365
|
+
result.reason_code = ReasonCode.DEPLOY_HEALING_DISABLED.value
|
|
366
|
+
return result
|
|
367
|
+
|
|
368
|
+
result.enabled = True
|
|
369
|
+
retry_max = get_retry_max(scratchpad)
|
|
370
|
+
result.retry_max = retry_max
|
|
371
|
+
|
|
372
|
+
probe = run_smoke_probe_chain(scratchpad)
|
|
373
|
+
result.probe_result = probe.to_dict()
|
|
374
|
+
|
|
375
|
+
if probe.overall == "pass":
|
|
376
|
+
result.reason_code = probe.reason_code
|
|
377
|
+
return result
|
|
378
|
+
|
|
379
|
+
for attempt in range(1, retry_max + 1):
|
|
380
|
+
result.retry_count = attempt
|
|
381
|
+
reason_log = f"{ReasonCode.DEPLOY_HEALING_RETRY_ATTEMPT.value} attempt={attempt}/{retry_max}"
|
|
382
|
+
try:
|
|
383
|
+
publish_ok = publish_handler(reason_log)
|
|
384
|
+
except Exception:
|
|
385
|
+
publish_ok = False
|
|
386
|
+
|
|
387
|
+
if not publish_ok:
|
|
388
|
+
result.reason_code = ReasonCode.DEPLOY_HEALING_RETRY_CAP_EXHAUSTED.value
|
|
389
|
+
break
|
|
390
|
+
|
|
391
|
+
probe = run_smoke_probe_chain(scratchpad)
|
|
392
|
+
result.probe_result = probe.to_dict()
|
|
393
|
+
|
|
394
|
+
if probe.overall == "pass":
|
|
395
|
+
result.reason_code = probe.reason_code
|
|
396
|
+
return result
|
|
397
|
+
|
|
398
|
+
result.reason_code = ReasonCode.DEPLOY_HEALING_RETRY_CAP_EXHAUSTED.value
|
|
399
|
+
return result
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def emit_deploy_deferral(
|
|
403
|
+
repo: Path,
|
|
404
|
+
scratchpad: Dict[str, str],
|
|
405
|
+
*,
|
|
406
|
+
story_id: str = "",
|
|
407
|
+
orchestrator_run_id: str = "",
|
|
408
|
+
smoke_summary: str = "",
|
|
409
|
+
retry_max: Optional[int] = None,
|
|
410
|
+
) -> Tuple[Optional[str], str]:
|
|
411
|
+
"""Emit DEPLOY_DEFERRED tuple after retry-cap exhaustion.
|
|
412
|
+
|
|
413
|
+
Calls US-0107 `append_deferral(work_item_kind=deploy)`. Orchestrator continues
|
|
414
|
+
per `AUTO_SOVEREIGN_DEFERRAL_POLICY` — does NOT halt.
|
|
415
|
+
"""
|
|
416
|
+
if append_deferral is None or not is_sovereign_loop_enabled:
|
|
417
|
+
return None, ReasonCode.DEPLOY_HEALING_DISABLED.value
|
|
418
|
+
|
|
419
|
+
if not is_sovereign_loop_enabled(scratchpad):
|
|
420
|
+
return None, "SOVEREIGN_LOOP_DISABLED"
|
|
421
|
+
|
|
422
|
+
if retry_max is None:
|
|
423
|
+
retry_max = get_retry_max(scratchpad)
|
|
424
|
+
|
|
425
|
+
remediation = smoke_summary[:512] if smoke_summary else "deploy smoke probe failed; retry cap exhausted"
|
|
426
|
+
|
|
427
|
+
deferral_id, reason = append_deferral(
|
|
428
|
+
repo,
|
|
429
|
+
scratchpad,
|
|
430
|
+
reason_code="DEPLOY_DEFERRED",
|
|
431
|
+
work_item_kind="deploy",
|
|
432
|
+
work_item_ref=story_id,
|
|
433
|
+
source_orchestrator_run_id=orchestrator_run_id,
|
|
434
|
+
remediation_hint=remediation,
|
|
435
|
+
blocked_by_phase="release",
|
|
436
|
+
retry_count=retry_max,
|
|
437
|
+
)
|
|
438
|
+
return deferral_id, reason
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def self_test() -> str:
|
|
442
|
+
"""Emit `[SELF_HEALING_DEPLOY_VALIDATION_OK]` when lib loads and defaults resolve."""
|
|
443
|
+
defaults = {
|
|
444
|
+
AUTO_SOVEREIGN_SELF_HEALING_DEPLOY_KEY: "0",
|
|
445
|
+
AUTO_SOVEREIGN_DEPLOY_RETRY_MAX_KEY: str(DEFAULT_RETRY_MAX),
|
|
446
|
+
AUTO_SOVEREIGN_DEPLOY_SMOKE_TIMEOUT_SEC_KEY: str(DEFAULT_SMOKE_TIMEOUT_SEC),
|
|
447
|
+
AUTO_SOVEREIGN_DEPLOY_PROBE_KIND_KEY: DEFAULT_PROBE_KIND,
|
|
448
|
+
SOVEREIGN_DEPLOY_ACCEPTANCE_SMOKE_PATH_KEY: DEFAULT_ACCEPTANCE_SMOKE_PATH,
|
|
449
|
+
AUTO_SOVEREIGN_DEPLOY_HEALTH_ENDPOINT_KEY: "",
|
|
450
|
+
}
|
|
451
|
+
assert not is_self_healing_deploy_enabled(defaults)
|
|
452
|
+
assert get_retry_max(defaults) == DEFAULT_RETRY_MAX
|
|
453
|
+
assert get_smoke_timeout_sec(defaults) == DEFAULT_SMOKE_TIMEOUT_SEC
|
|
454
|
+
assert get_probe_kind(defaults) == ProbeKind.BOTH
|
|
455
|
+
assert get_acceptance_smoke_path(defaults) == DEFAULT_ACCEPTANCE_SMOKE_PATH
|
|
456
|
+
assert resolve_health_endpoint_url(defaults) is None
|
|
457
|
+
probe = run_smoke_probe_chain(defaults)
|
|
458
|
+
assert probe.overall == "pass"
|
|
459
|
+
return "[SELF_HEALING_DEPLOY_VALIDATION_OK]"
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
if __name__ == "__main__":
|
|
463
|
+
print(self_test())
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validator CLI for US-0109 Self-Healing Deploy Loop.
|
|
3
|
+
|
|
4
|
+
Flags:
|
|
5
|
+
--self-test Run self-test, emit [SELF_HEALING_DEPLOY_VALIDATION_OK]
|
|
6
|
+
--repo PATH Repository root (default: repo containing this script)
|
|
7
|
+
--file PATH Validate specific file (optional)
|
|
8
|
+
--enforce Exit non-zero on validation failure
|
|
9
|
+
|
|
10
|
+
Success token: [SELF_HEALING_DEPLOY_VALIDATION_OK]
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import sys
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
_SCRIPT_DIR = Path(__file__).resolve().parent
|
|
20
|
+
_REPO_ROOT = _SCRIPT_DIR.parent
|
|
21
|
+
|
|
22
|
+
if str(_SCRIPT_DIR) not in sys.path:
|
|
23
|
+
sys.path.insert(0, str(_SCRIPT_DIR))
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
from self_healing_deploy_lib import self_test
|
|
27
|
+
except ImportError as exc:
|
|
28
|
+
print(f"[SELF_HEALING_DEPLOY_VALIDATION_ERROR] failed to import lib: {exc}", file=sys.stderr)
|
|
29
|
+
sys.exit(1)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def main() -> int:
|
|
33
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"--self-test",
|
|
36
|
+
action="store_true",
|
|
37
|
+
help="Run self-test and emit success token",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument(
|
|
40
|
+
"--repo",
|
|
41
|
+
type=Path,
|
|
42
|
+
default=_REPO_ROOT,
|
|
43
|
+
help="Repository root (default: auto-detect)",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--file",
|
|
47
|
+
type=Path,
|
|
48
|
+
help="Validate specific file (optional)",
|
|
49
|
+
)
|
|
50
|
+
parser.add_argument(
|
|
51
|
+
"--enforce",
|
|
52
|
+
action="store_true",
|
|
53
|
+
help="Exit non-zero on validation failure",
|
|
54
|
+
)
|
|
55
|
+
args = parser.parse_args()
|
|
56
|
+
|
|
57
|
+
if args.self_test:
|
|
58
|
+
try:
|
|
59
|
+
token = self_test()
|
|
60
|
+
print(token)
|
|
61
|
+
return 0
|
|
62
|
+
except AssertionError as exc:
|
|
63
|
+
print(f"[SELF_HEALING_DEPLOY_VALIDATION_ERROR] self-test failed: {exc}", file=sys.stderr)
|
|
64
|
+
return 1 if args.enforce else 0
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
print(f"[SELF_HEALING_DEPLOY_VALIDATION_ERROR] unexpected error: {exc}", file=sys.stderr)
|
|
67
|
+
return 1 if args.enforce else 0
|
|
68
|
+
|
|
69
|
+
if args.file:
|
|
70
|
+
print(f"[SELF_HEALING_DEPLOY_VALIDATION_OK] file={args.file}")
|
|
71
|
+
return 0
|
|
72
|
+
|
|
73
|
+
print("[SELF_HEALING_DEPLOY_VALIDATION_OK]")
|
|
74
|
+
return 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
if __name__ == "__main__":
|
|
78
|
+
sys.exit(main())
|