@hunter-harness/workflow-harness 0.2.21 → 0.2.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/harness/bundles/general/claude-code/.harness-build.json +1 -1
- package/harness/bundles/general/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-archive/checklist.md +14 -0
- package/harness/bundles/general/claude-code/harness-archive/templates/render-summary.mjs +24 -3
- 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-submit/SKILL.md +1 -1
- package/harness/bundles/general/claude-code/harness-submit/checklist.md +6 -0
- 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/harness-test/checklist.md +6 -0
- package/harness/bundles/general/claude-code/scripts/harness_archive.py +606 -28
- package/harness/bundles/general/claude-code/scripts/harness_environment.py +459 -0
- package/harness/bundles/general/claude-code/scripts/harness_events.py +93 -14
- package/harness/bundles/general/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-archive/checklist.md +14 -0
- package/harness/bundles/general/codebuddy/harness-archive/templates/render-summary.mjs +24 -3
- 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-submit/SKILL.md +1 -1
- package/harness/bundles/general/codebuddy/harness-submit/checklist.md +6 -0
- 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/harness-test/checklist.md +6 -0
- package/harness/bundles/general/codebuddy/scripts/harness_archive.py +606 -28
- package/harness/bundles/general/codebuddy/scripts/harness_environment.py +459 -0
- package/harness/bundles/general/codebuddy/scripts/harness_events.py +93 -14
- package/harness/bundles/general/codex/.harness-build.json +1 -1
- package/harness/bundles/general/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-archive/checklist.md +14 -0
- package/harness/bundles/general/codex/harness-archive/templates/render-summary.mjs +24 -3
- 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-submit/SKILL.md +1 -1
- package/harness/bundles/general/codex/harness-submit/checklist.md +6 -0
- 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/harness-test/checklist.md +6 -0
- package/harness/bundles/general/codex/scripts/harness_archive.py +606 -28
- package/harness/bundles/general/codex/scripts/harness_environment.py +459 -0
- package/harness/bundles/general/codex/scripts/harness_events.py +93 -14
- package/harness/bundles/general/cursor/.harness-build.json +1 -1
- package/harness/bundles/general/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-archive/checklist.md +14 -0
- package/harness/bundles/general/cursor/harness-archive/templates/render-summary.mjs +24 -3
- 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-submit/SKILL.md +1 -1
- package/harness/bundles/general/cursor/harness-submit/checklist.md +6 -0
- 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/harness-test/checklist.md +6 -0
- package/harness/bundles/general/cursor/scripts/harness_archive.py +606 -28
- package/harness/bundles/general/cursor/scripts/harness_environment.py +459 -0
- package/harness/bundles/general/cursor/scripts/harness_events.py +93 -14
- package/harness/bundles/java/claude-code/.harness-build.json +1 -1
- package/harness/bundles/java/claude-code/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/claude-code/harness-archive/checklist.md +14 -0
- package/harness/bundles/java/claude-code/harness-archive/templates/render-summary.mjs +24 -3
- 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 +1 -1
- package/harness/bundles/java/claude-code/harness-submit/checklist.md +6 -0
- 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 +606 -28
- package/harness/bundles/java/claude-code/scripts/harness_environment.py +459 -0
- package/harness/bundles/java/claude-code/scripts/harness_events.py +93 -14
- package/harness/bundles/java/codebuddy/.harness-build.json +1 -1
- package/harness/bundles/java/codebuddy/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codebuddy/harness-archive/checklist.md +14 -0
- package/harness/bundles/java/codebuddy/harness-archive/templates/render-summary.mjs +24 -3
- 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 +1 -1
- package/harness/bundles/java/codebuddy/harness-submit/checklist.md +6 -0
- 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 +606 -28
- package/harness/bundles/java/codebuddy/scripts/harness_environment.py +459 -0
- package/harness/bundles/java/codebuddy/scripts/harness_events.py +93 -14
- package/harness/bundles/java/codex/.harness-build.json +1 -1
- package/harness/bundles/java/codex/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/codex/harness-archive/checklist.md +14 -0
- package/harness/bundles/java/codex/harness-archive/templates/render-summary.mjs +24 -3
- 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 +1 -1
- package/harness/bundles/java/codex/harness-submit/checklist.md +6 -0
- 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 +606 -28
- package/harness/bundles/java/codex/scripts/harness_environment.py +459 -0
- package/harness/bundles/java/codex/scripts/harness_events.py +93 -14
- package/harness/bundles/java/cursor/.harness-build.json +1 -1
- package/harness/bundles/java/cursor/harness-apidoc/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/SKILL.md +1 -1
- package/harness/bundles/java/cursor/harness-archive/checklist.md +14 -0
- package/harness/bundles/java/cursor/harness-archive/templates/render-summary.mjs +24 -3
- 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 +1 -1
- package/harness/bundles/java/cursor/harness-submit/checklist.md +6 -0
- 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 +606 -28
- package/harness/bundles/java/cursor/scripts/harness_environment.py +459 -0
- package/harness/bundles/java/cursor/scripts/harness_events.py +93 -14
- package/harness/manifests/general/claude-code.json +21 -17
- package/harness/manifests/general/codebuddy.json +21 -17
- package/harness/manifests/general/codex.json +21 -17
- package/harness/manifests/general/cursor.json +21 -17
- package/harness/manifests/java/claude-code.json +22 -18
- package/harness/manifests/java/codebuddy.json +22 -18
- package/harness/manifests/java/codex.json +22 -18
- package/harness/manifests/java/cursor.json +22 -18
- package/hunter-workflow-family.json +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Minimal environment fingerprint + lease manager (IA-3 Wave-A).
|
|
3
|
+
|
|
4
|
+
Contract: prepare → fingerprint → acquire lease → reset/clone → run → record → release.
|
|
5
|
+
This module implements fingerprint/lease gates with a file-backed stub suitable for
|
|
6
|
+
unit tests. Full Docker orchestration is out of scope for Wave-A.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import datetime as dt
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
if hasattr(sys.stdout, "reconfigure"):
|
|
21
|
+
sys.stdout.reconfigure(encoding="utf-8")
|
|
22
|
+
if hasattr(sys.stderr, "reconfigure"):
|
|
23
|
+
sys.stderr.reconfigure(encoding="utf-8")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
FINGERPRINT_CANDIDATES = (
|
|
27
|
+
"package-lock.json",
|
|
28
|
+
"npm-shrinkwrap.json",
|
|
29
|
+
"pnpm-lock.yaml",
|
|
30
|
+
"yarn.lock",
|
|
31
|
+
"poetry.lock",
|
|
32
|
+
"Pipfile.lock",
|
|
33
|
+
"requirements.txt",
|
|
34
|
+
"requirements.lock",
|
|
35
|
+
"Cargo.lock",
|
|
36
|
+
"go.sum",
|
|
37
|
+
"composer.lock",
|
|
38
|
+
".nvmrc",
|
|
39
|
+
".node-version",
|
|
40
|
+
".python-version",
|
|
41
|
+
"Dockerfile",
|
|
42
|
+
"docker-compose.yml",
|
|
43
|
+
"docker-compose.yaml",
|
|
44
|
+
"compose.yml",
|
|
45
|
+
"compose.yaml",
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def now_iso() -> str:
|
|
50
|
+
return dt.datetime.now().astimezone().isoformat(timespec="seconds")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def emit_json(payload: dict[str, Any], *, ok: bool = True) -> int:
|
|
54
|
+
print(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
55
|
+
return 0 if ok else 1
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _sha256_bytes(data: bytes) -> str:
|
|
59
|
+
return hashlib.sha256(data).hexdigest()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _sha256_file(path: Path) -> str:
|
|
63
|
+
h = hashlib.sha256()
|
|
64
|
+
with path.open("rb") as fh:
|
|
65
|
+
for chunk in iter(lambda: fh.read(65536), b""):
|
|
66
|
+
h.update(chunk)
|
|
67
|
+
return h.hexdigest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _read_json(path: Path) -> Any:
|
|
71
|
+
return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _write_json(path: Path, data: Any) -> None:
|
|
75
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
76
|
+
path.write_text(
|
|
77
|
+
json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
|
78
|
+
encoding="utf-8",
|
|
79
|
+
newline="\n",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def default_lease_root(project: Path) -> Path:
|
|
84
|
+
return project / ".harness" / "runtime" / "env-leases"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _migration_head_token(project: Path) -> str:
|
|
88
|
+
cfg = project / ".harness" / "config" / "migration-head.json"
|
|
89
|
+
if cfg.is_file():
|
|
90
|
+
try:
|
|
91
|
+
data = _read_json(cfg)
|
|
92
|
+
if isinstance(data, dict):
|
|
93
|
+
head = data.get("head") or data.get("revision") or data.get("version")
|
|
94
|
+
if head:
|
|
95
|
+
return f"migration-head:{head}"
|
|
96
|
+
except (OSError, json.JSONDecodeError, TypeError):
|
|
97
|
+
pass
|
|
98
|
+
versions = project / "alembic" / "versions"
|
|
99
|
+
if versions.is_dir():
|
|
100
|
+
names = sorted(p.name for p in versions.glob("*.py"))
|
|
101
|
+
if names:
|
|
102
|
+
return "alembic:" + ",".join(names[-3:])
|
|
103
|
+
return "migration-head:none"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def compute_environment_hash(
|
|
107
|
+
project: Path,
|
|
108
|
+
*,
|
|
109
|
+
extra_paths: list[str] | None = None,
|
|
110
|
+
) -> str:
|
|
111
|
+
"""Stable hash over lockfiles/toolchain/compose/migration head inputs."""
|
|
112
|
+
project = project.resolve()
|
|
113
|
+
lines: list[str] = []
|
|
114
|
+
seen: set[str] = set()
|
|
115
|
+
for rel in list(FINGERPRINT_CANDIDATES) + list(extra_paths or []):
|
|
116
|
+
if rel in seen:
|
|
117
|
+
continue
|
|
118
|
+
seen.add(rel)
|
|
119
|
+
path = project / rel
|
|
120
|
+
if path.is_file():
|
|
121
|
+
lines.append(f"file:{rel}:{_sha256_file(path)}")
|
|
122
|
+
lines.append(_migration_head_token(project))
|
|
123
|
+
# Desensitized runner hint (presence only).
|
|
124
|
+
for runner in ("playwright.config.ts", "playwright.config.js", "vitest.config.ts"):
|
|
125
|
+
if (project / runner).is_file():
|
|
126
|
+
lines.append(f"runner:{runner}:present")
|
|
127
|
+
payload = "\n".join(sorted(lines)).encode("utf-8")
|
|
128
|
+
return "sha256:" + _sha256_bytes(payload)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _lease_path(lease_root: Path, stack_id: str) -> Path:
|
|
132
|
+
safe = "".join(ch if ch.isalnum() or ch in "-_." else "_" for ch in stack_id)
|
|
133
|
+
return lease_root / f"{safe}.json"
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def list_leases(lease_root: Path) -> list[dict[str, Any]]:
|
|
137
|
+
if not lease_root.is_dir():
|
|
138
|
+
return []
|
|
139
|
+
out: list[dict[str, Any]] = []
|
|
140
|
+
for path in sorted(lease_root.glob("*.json")):
|
|
141
|
+
try:
|
|
142
|
+
data = _read_json(path)
|
|
143
|
+
except (OSError, json.JSONDecodeError):
|
|
144
|
+
continue
|
|
145
|
+
if isinstance(data, dict):
|
|
146
|
+
out.append(data)
|
|
147
|
+
return out
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def require_writable_lease(
|
|
151
|
+
project: Path,
|
|
152
|
+
*,
|
|
153
|
+
change_id: str,
|
|
154
|
+
stack_id: str,
|
|
155
|
+
lease_root: Path | None = None,
|
|
156
|
+
) -> dict[str, Any]:
|
|
157
|
+
"""Gate: a writable stack may be used only with an active matching lease."""
|
|
158
|
+
root = lease_root or default_lease_root(project)
|
|
159
|
+
path = _lease_path(root, stack_id)
|
|
160
|
+
if not path.is_file():
|
|
161
|
+
return {
|
|
162
|
+
"ok": False,
|
|
163
|
+
"code": "ENVIRONMENT_LEASE_REQUIRED",
|
|
164
|
+
"message": f"no lease for stack={stack_id}; acquire before writable use",
|
|
165
|
+
"changeId": change_id,
|
|
166
|
+
"stackId": stack_id,
|
|
167
|
+
}
|
|
168
|
+
try:
|
|
169
|
+
lease = _read_json(path)
|
|
170
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
171
|
+
return {
|
|
172
|
+
"ok": False,
|
|
173
|
+
"code": "ENVIRONMENT_LEASE_REQUIRED",
|
|
174
|
+
"message": f"lease unreadable: {exc}",
|
|
175
|
+
"changeId": change_id,
|
|
176
|
+
"stackId": stack_id,
|
|
177
|
+
}
|
|
178
|
+
if str(lease.get("changeId") or "") != change_id:
|
|
179
|
+
return {
|
|
180
|
+
"ok": False,
|
|
181
|
+
"code": "ENVIRONMENT_LEASE_REQUIRED",
|
|
182
|
+
"message": (
|
|
183
|
+
f"stack={stack_id} leased by change={lease.get('changeId')}; "
|
|
184
|
+
f"requested change={change_id}"
|
|
185
|
+
),
|
|
186
|
+
"changeId": change_id,
|
|
187
|
+
"stackId": stack_id,
|
|
188
|
+
"holder": lease.get("changeId"),
|
|
189
|
+
}
|
|
190
|
+
# Review Y2: expired leases must not remain writable.
|
|
191
|
+
expires_raw = lease.get("expiresAt")
|
|
192
|
+
if isinstance(expires_raw, str) and expires_raw.strip():
|
|
193
|
+
try:
|
|
194
|
+
raw = expires_raw.strip().replace("Z", "+00:00")
|
|
195
|
+
expires_at = dt.datetime.fromisoformat(raw)
|
|
196
|
+
# Normalize both sides to UTC so naive/aware/offset mixes compare safely.
|
|
197
|
+
if expires_at.tzinfo is None:
|
|
198
|
+
expires_at = expires_at.replace(tzinfo=dt.timezone.utc)
|
|
199
|
+
else:
|
|
200
|
+
expires_at = expires_at.astimezone(dt.timezone.utc)
|
|
201
|
+
now_utc = dt.datetime.now(dt.timezone.utc)
|
|
202
|
+
if now_utc >= expires_at:
|
|
203
|
+
return {
|
|
204
|
+
"ok": False,
|
|
205
|
+
"code": "ENVIRONMENT_LEASE_EXPIRED",
|
|
206
|
+
"message": (
|
|
207
|
+
f"lease for stack={stack_id} expired at {expires_raw}; "
|
|
208
|
+
"re-acquire before writable use"
|
|
209
|
+
),
|
|
210
|
+
"changeId": change_id,
|
|
211
|
+
"stackId": stack_id,
|
|
212
|
+
"expiresAt": expires_raw,
|
|
213
|
+
}
|
|
214
|
+
except ValueError:
|
|
215
|
+
return {
|
|
216
|
+
"ok": False,
|
|
217
|
+
"code": "ENVIRONMENT_LEASE_REQUIRED",
|
|
218
|
+
"message": f"lease expiresAt unreadable: {expires_raw}",
|
|
219
|
+
"changeId": change_id,
|
|
220
|
+
"stackId": stack_id,
|
|
221
|
+
}
|
|
222
|
+
return {"ok": True, "code": "LEASE_HELD", "lease": lease}
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def acquire_lease(
|
|
226
|
+
project: Path,
|
|
227
|
+
*,
|
|
228
|
+
change_id: str,
|
|
229
|
+
stack_id: str,
|
|
230
|
+
environment_hash: str,
|
|
231
|
+
lease_root: Path | None = None,
|
|
232
|
+
writable_volumes: list[str] | None = None,
|
|
233
|
+
ttl_seconds: int = 3600,
|
|
234
|
+
) -> dict[str, Any]:
|
|
235
|
+
"""Acquire a lease; reject cross-change sharing of the same writable volume."""
|
|
236
|
+
root = lease_root or default_lease_root(project)
|
|
237
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
volumes = [str(v).replace("\\", "/") for v in (writable_volumes or [])]
|
|
239
|
+
for existing in list_leases(root):
|
|
240
|
+
holder = str(existing.get("changeId") or "")
|
|
241
|
+
if holder == change_id:
|
|
242
|
+
continue
|
|
243
|
+
other_vols = {
|
|
244
|
+
str(v).replace("\\", "/")
|
|
245
|
+
for v in (existing.get("writableVolumes") or [])
|
|
246
|
+
}
|
|
247
|
+
overlap = sorted(set(volumes) & other_vols)
|
|
248
|
+
if overlap:
|
|
249
|
+
return {
|
|
250
|
+
"ok": False,
|
|
251
|
+
"code": "ENVIRONMENT_LEASE_CROSS_CHANGE",
|
|
252
|
+
"message": (
|
|
253
|
+
f"writable volume(s) {overlap} already held by change={holder}"
|
|
254
|
+
),
|
|
255
|
+
"changeId": change_id,
|
|
256
|
+
"holder": holder,
|
|
257
|
+
"overlap": overlap,
|
|
258
|
+
}
|
|
259
|
+
if existing.get("stackId") == stack_id and holder and holder != change_id:
|
|
260
|
+
return {
|
|
261
|
+
"ok": False,
|
|
262
|
+
"code": "ENVIRONMENT_LEASE_CROSS_CHANGE",
|
|
263
|
+
"message": f"stack={stack_id} already leased by change={holder}",
|
|
264
|
+
"changeId": change_id,
|
|
265
|
+
"holder": holder,
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
path = _lease_path(root, stack_id)
|
|
269
|
+
if path.is_file():
|
|
270
|
+
try:
|
|
271
|
+
current = _read_json(path)
|
|
272
|
+
except (OSError, json.JSONDecodeError):
|
|
273
|
+
current = {}
|
|
274
|
+
if str(current.get("changeId") or "") not in {"", change_id}:
|
|
275
|
+
return {
|
|
276
|
+
"ok": False,
|
|
277
|
+
"code": "ENVIRONMENT_LEASE_CROSS_CHANGE",
|
|
278
|
+
"message": (
|
|
279
|
+
f"stack={stack_id} already leased by change={current.get('changeId')}"
|
|
280
|
+
),
|
|
281
|
+
"changeId": change_id,
|
|
282
|
+
"holder": current.get("changeId"),
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
started = dt.datetime.now().astimezone()
|
|
286
|
+
expires = started + dt.timedelta(seconds=max(60, int(ttl_seconds)))
|
|
287
|
+
lease = {
|
|
288
|
+
"schemaVersion": 1,
|
|
289
|
+
"changeId": change_id,
|
|
290
|
+
"stackId": stack_id,
|
|
291
|
+
"environmentHash": environment_hash,
|
|
292
|
+
"writableVolumes": volumes,
|
|
293
|
+
"acquiredAt": started.isoformat(timespec="seconds"),
|
|
294
|
+
"expiresAt": expires.isoformat(timespec="seconds"),
|
|
295
|
+
"projectRoot": str(project.resolve()),
|
|
296
|
+
}
|
|
297
|
+
_write_json(path, lease)
|
|
298
|
+
return {"ok": True, "code": "LEASE_ACQUIRED", "lease": lease, "path": str(path)}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def release_lease(
|
|
302
|
+
project: Path,
|
|
303
|
+
*,
|
|
304
|
+
change_id: str,
|
|
305
|
+
stack_id: str,
|
|
306
|
+
lease_root: Path | None = None,
|
|
307
|
+
) -> dict[str, Any]:
|
|
308
|
+
root = lease_root or default_lease_root(project)
|
|
309
|
+
path = _lease_path(root, stack_id)
|
|
310
|
+
if not path.is_file():
|
|
311
|
+
return {
|
|
312
|
+
"ok": True,
|
|
313
|
+
"code": "LEASE_ABSENT",
|
|
314
|
+
"message": "nothing to release",
|
|
315
|
+
"changeId": change_id,
|
|
316
|
+
"stackId": stack_id,
|
|
317
|
+
}
|
|
318
|
+
try:
|
|
319
|
+
lease = _read_json(path)
|
|
320
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
321
|
+
return {"ok": False, "code": "LEASE_UNREADABLE", "message": str(exc)}
|
|
322
|
+
if str(lease.get("changeId") or "") != change_id:
|
|
323
|
+
return {
|
|
324
|
+
"ok": False,
|
|
325
|
+
"code": "ENVIRONMENT_LEASE_CROSS_CHANGE",
|
|
326
|
+
"message": "cannot release lease owned by another change",
|
|
327
|
+
"holder": lease.get("changeId"),
|
|
328
|
+
"changeId": change_id,
|
|
329
|
+
}
|
|
330
|
+
path.unlink(missing_ok=True)
|
|
331
|
+
return {"ok": True, "code": "LEASE_RELEASED", "changeId": change_id, "stackId": stack_id}
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def cmd_fingerprint(args: argparse.Namespace) -> int:
|
|
335
|
+
project = Path(args.project).resolve()
|
|
336
|
+
digest = compute_environment_hash(project)
|
|
337
|
+
return emit_json(
|
|
338
|
+
{
|
|
339
|
+
"ok": True,
|
|
340
|
+
"action": "fingerprint",
|
|
341
|
+
"projectRoot": str(project),
|
|
342
|
+
"environmentHash": digest,
|
|
343
|
+
}
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def cmd_acquire(args: argparse.Namespace) -> int:
|
|
348
|
+
project = Path(args.project).resolve()
|
|
349
|
+
root = Path(args.lease_root).resolve() if args.lease_root else default_lease_root(project)
|
|
350
|
+
env_hash = args.environment_hash or compute_environment_hash(project)
|
|
351
|
+
volumes = [v for v in (args.writable_volume or []) if v]
|
|
352
|
+
result = acquire_lease(
|
|
353
|
+
project,
|
|
354
|
+
change_id=args.change,
|
|
355
|
+
stack_id=args.stack_id,
|
|
356
|
+
environment_hash=env_hash,
|
|
357
|
+
lease_root=root,
|
|
358
|
+
writable_volumes=volumes,
|
|
359
|
+
ttl_seconds=int(args.ttl_seconds or 3600),
|
|
360
|
+
)
|
|
361
|
+
return emit_json(result, ok=bool(result.get("ok")))
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def cmd_release(args: argparse.Namespace) -> int:
|
|
365
|
+
project = Path(args.project).resolve()
|
|
366
|
+
root = Path(args.lease_root).resolve() if args.lease_root else default_lease_root(project)
|
|
367
|
+
result = release_lease(
|
|
368
|
+
project,
|
|
369
|
+
change_id=args.change,
|
|
370
|
+
stack_id=args.stack_id,
|
|
371
|
+
lease_root=root,
|
|
372
|
+
)
|
|
373
|
+
return emit_json(result, ok=bool(result.get("ok")))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def cmd_require(args: argparse.Namespace) -> int:
|
|
377
|
+
project = Path(args.project).resolve()
|
|
378
|
+
root = Path(args.lease_root).resolve() if args.lease_root else default_lease_root(project)
|
|
379
|
+
result = require_writable_lease(
|
|
380
|
+
project,
|
|
381
|
+
change_id=args.change,
|
|
382
|
+
stack_id=args.stack_id,
|
|
383
|
+
lease_root=root,
|
|
384
|
+
)
|
|
385
|
+
return emit_json(result, ok=bool(result.get("ok")))
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def cmd_status(args: argparse.Namespace) -> int:
|
|
389
|
+
project = Path(args.project).resolve()
|
|
390
|
+
root = Path(args.lease_root).resolve() if args.lease_root else default_lease_root(project)
|
|
391
|
+
return emit_json(
|
|
392
|
+
{
|
|
393
|
+
"ok": True,
|
|
394
|
+
"action": "status",
|
|
395
|
+
"projectRoot": str(project),
|
|
396
|
+
"environmentHash": compute_environment_hash(project),
|
|
397
|
+
"leases": list_leases(root),
|
|
398
|
+
"leaseRoot": str(root),
|
|
399
|
+
"contract": [
|
|
400
|
+
"prepare",
|
|
401
|
+
"fingerprint",
|
|
402
|
+
"acquire",
|
|
403
|
+
"reset/clone",
|
|
404
|
+
"run",
|
|
405
|
+
"record",
|
|
406
|
+
"release",
|
|
407
|
+
],
|
|
408
|
+
}
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
413
|
+
parser = argparse.ArgumentParser(prog="harness_environment.py")
|
|
414
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
415
|
+
|
|
416
|
+
fp = sub.add_parser("fingerprint")
|
|
417
|
+
fp.add_argument("--project", required=True)
|
|
418
|
+
fp.set_defaults(func=cmd_fingerprint)
|
|
419
|
+
|
|
420
|
+
ac = sub.add_parser("acquire")
|
|
421
|
+
ac.add_argument("--project", required=True)
|
|
422
|
+
ac.add_argument("--change", required=True)
|
|
423
|
+
ac.add_argument("--stack-id", required=True)
|
|
424
|
+
ac.add_argument("--environment-hash")
|
|
425
|
+
ac.add_argument("--lease-root")
|
|
426
|
+
ac.add_argument("--writable-volume", action="append", default=[])
|
|
427
|
+
ac.add_argument("--ttl-seconds", type=int, default=3600)
|
|
428
|
+
ac.set_defaults(func=cmd_acquire)
|
|
429
|
+
|
|
430
|
+
rel = sub.add_parser("release")
|
|
431
|
+
rel.add_argument("--project", required=True)
|
|
432
|
+
rel.add_argument("--change", required=True)
|
|
433
|
+
rel.add_argument("--stack-id", required=True)
|
|
434
|
+
rel.add_argument("--lease-root")
|
|
435
|
+
rel.set_defaults(func=cmd_release)
|
|
436
|
+
|
|
437
|
+
req = sub.add_parser("require")
|
|
438
|
+
req.add_argument("--project", required=True)
|
|
439
|
+
req.add_argument("--change", required=True)
|
|
440
|
+
req.add_argument("--stack-id", required=True)
|
|
441
|
+
req.add_argument("--lease-root")
|
|
442
|
+
req.set_defaults(func=cmd_require)
|
|
443
|
+
|
|
444
|
+
st = sub.add_parser("status")
|
|
445
|
+
st.add_argument("--project", required=True)
|
|
446
|
+
st.add_argument("--lease-root")
|
|
447
|
+
st.set_defaults(func=cmd_status)
|
|
448
|
+
|
|
449
|
+
return parser
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def main(argv: list[str] | None = None) -> int:
|
|
453
|
+
parser = build_parser()
|
|
454
|
+
args = parser.parse_args(argv)
|
|
455
|
+
return int(args.func(args))
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
if __name__ == "__main__":
|
|
459
|
+
raise SystemExit(main())
|
|
@@ -968,28 +968,89 @@ def late_event_stats(phase_events: list[dict[str, Any]]) -> dict[str, int]:
|
|
|
968
968
|
return {"lateEventCount": len(late_stamps), "lateEventSpanMs": span}
|
|
969
969
|
|
|
970
970
|
|
|
971
|
-
def
|
|
972
|
-
|
|
971
|
+
def _seal_timestamp_for_attempt(
|
|
972
|
+
events_in: list[dict[str, Any]],
|
|
973
|
+
*,
|
|
974
|
+
cutoff_ts: str | None,
|
|
975
|
+
next_attempt_start: Any = None,
|
|
976
|
+
) -> tuple[Any, str | None]:
|
|
977
|
+
"""Pick INCOMPLETE seal time: next start, recovery, or report cutoff."""
|
|
978
|
+
recovery_ts = None
|
|
979
|
+
for event in events_in:
|
|
980
|
+
if str(event.get("type") or "").lower() in {
|
|
981
|
+
"recovery",
|
|
982
|
+
"phase.recovery",
|
|
983
|
+
"attempt.recovery",
|
|
984
|
+
}:
|
|
985
|
+
recovery_ts = event.get("timestamp") or recovery_ts
|
|
986
|
+
if next_attempt_start:
|
|
987
|
+
return next_attempt_start, "INCOMPLETE"
|
|
988
|
+
if recovery_ts:
|
|
989
|
+
return recovery_ts, "INCOMPLETE"
|
|
990
|
+
if cutoff_ts:
|
|
991
|
+
return cutoff_ts, "INCOMPLETE"
|
|
992
|
+
# Fall back to last event timestamp so wall clock is not silently dropped.
|
|
993
|
+
for event in reversed(events_in):
|
|
994
|
+
if event.get("timestamp"):
|
|
995
|
+
return event.get("timestamp"), "INCOMPLETE"
|
|
996
|
+
return None, "INCOMPLETE"
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def attempt_invocations(
|
|
1000
|
+
phase_events: list[dict[str, Any]],
|
|
1001
|
+
*,
|
|
1002
|
+
cutoff_ts: str | None = None,
|
|
1003
|
+
) -> list[dict[str, Any]]:
|
|
1004
|
+
"""Per-attempt invocation view: attempt, status, durationMs (RET-22 / IA-2).
|
|
1005
|
+
|
|
1006
|
+
Closed attempts keep phase.end status. Unclosed attempts are sealed as
|
|
1007
|
+
INCOMPLETE at the next attempt start, an explicit recovery event, or cutoff.
|
|
1008
|
+
"""
|
|
1009
|
+
attempts = split_phase_attempts(phase_events)
|
|
973
1010
|
invocations: list[dict[str, Any]] = []
|
|
974
|
-
for attempt in
|
|
1011
|
+
for index, attempt in enumerate(attempts):
|
|
975
1012
|
events_in = attempt.get("events") or []
|
|
976
1013
|
start_ts = None
|
|
977
1014
|
end_ts = None
|
|
978
1015
|
status = None
|
|
1016
|
+
closed_by_end = False
|
|
979
1017
|
for event in events_in:
|
|
980
1018
|
if event.get("type") == "phase.start" and start_ts is None:
|
|
981
1019
|
start_ts = event.get("timestamp")
|
|
982
1020
|
elif event.get("type") == "phase.end":
|
|
983
1021
|
end_ts = event.get("timestamp")
|
|
984
|
-
|
|
1022
|
+
if "status" in event:
|
|
1023
|
+
status = event.get("status")
|
|
1024
|
+
closed_by_end = True
|
|
1025
|
+
sealed_incomplete = False
|
|
1026
|
+
if not closed_by_end:
|
|
1027
|
+
next_start = None
|
|
1028
|
+
if index + 1 < len(attempts):
|
|
1029
|
+
for event in attempts[index + 1].get("events") or []:
|
|
1030
|
+
if event.get("type") == "phase.start" and event.get("timestamp"):
|
|
1031
|
+
next_start = event.get("timestamp")
|
|
1032
|
+
break
|
|
1033
|
+
seal_ts, seal_status = _seal_timestamp_for_attempt(
|
|
1034
|
+
events_in,
|
|
1035
|
+
cutoff_ts=cutoff_ts,
|
|
1036
|
+
next_attempt_start=next_start,
|
|
1037
|
+
)
|
|
1038
|
+
if seal_ts is not None:
|
|
1039
|
+
end_ts = seal_ts
|
|
1040
|
+
status = seal_status
|
|
1041
|
+
sealed_incomplete = True
|
|
985
1042
|
duration = None
|
|
986
1043
|
if start_ts and end_ts:
|
|
987
1044
|
duration = duration_ms_between(start_ts, end_ts)
|
|
1045
|
+
# Active execution counts phase.end-closed attempts only (INCOMPLETE seals excluded).
|
|
1046
|
+
active_eligible = closed_by_end and not sealed_incomplete
|
|
988
1047
|
invocations.append(
|
|
989
1048
|
{
|
|
990
1049
|
"attempt": attempt.get("attempt"),
|
|
991
1050
|
"status": status,
|
|
992
1051
|
"durationMs": duration,
|
|
1052
|
+
"activeEligible": active_eligible,
|
|
1053
|
+
"sealedIncomplete": sealed_incomplete,
|
|
993
1054
|
}
|
|
994
1055
|
)
|
|
995
1056
|
return invocations
|
|
@@ -1005,30 +1066,48 @@ def phase_final_state(phase_events: list[dict[str, Any]]) -> dict[str, Any]:
|
|
|
1005
1066
|
"attempt": latest.get("attempt"),
|
|
1006
1067
|
"status": latest.get("status"),
|
|
1007
1068
|
"durationMs": latest.get("durationMs"),
|
|
1008
|
-
"closed": latest.get("status") is not None,
|
|
1069
|
+
"closed": latest.get("status") is not None and latest.get("status") != "INCOMPLETE",
|
|
1009
1070
|
}
|
|
1010
1071
|
|
|
1011
1072
|
|
|
1012
|
-
def canonical_phase_timing(
|
|
1013
|
-
|
|
1073
|
+
def canonical_phase_timing(
|
|
1074
|
+
phase_events: list[dict[str, Any]],
|
|
1075
|
+
*,
|
|
1076
|
+
cutoff_ts: str | None = None,
|
|
1077
|
+
) -> dict[str, Any]:
|
|
1078
|
+
"""Single reducer for every duration view (RET-20 / IA-2).
|
|
1014
1079
|
|
|
1015
|
-
activeExecutionMs:
|
|
1016
|
-
wallClockSpanMs: first → last timestamp
|
|
1080
|
+
activeExecutionMs: sum of closed attempt start→end only (INCOMPLETE excluded).
|
|
1081
|
+
wallClockSpanMs: first → last/cutoff timestamp; unclosed attempts retain span.
|
|
1017
1082
|
"""
|
|
1083
|
+
invocations = attempt_invocations(phase_events, cutoff_ts=cutoff_ts)
|
|
1018
1084
|
stamps = [e.get("timestamp") for e in phase_events if e.get("timestamp")]
|
|
1019
|
-
|
|
1085
|
+
# Cutoff seals INCOMPLETE attempts but must not inflate stage wall by itself.
|
|
1086
|
+
# Include cutoff in the wall span only when it sealed an otherwise-open attempt.
|
|
1087
|
+
if cutoff_ts and any(inv.get("sealedIncomplete") for inv in invocations):
|
|
1088
|
+
stamps = list(stamps) + [cutoff_ts]
|
|
1089
|
+
closed_durations = [
|
|
1020
1090
|
invocation["durationMs"]
|
|
1021
|
-
for invocation in
|
|
1022
|
-
if invocation.get("durationMs") is not None
|
|
1091
|
+
for invocation in invocations
|
|
1092
|
+
if invocation.get("activeEligible") and invocation.get("durationMs") is not None
|
|
1023
1093
|
]
|
|
1024
|
-
active = sum(
|
|
1094
|
+
active = sum(closed_durations) if closed_durations else (0 if invocations else None)
|
|
1025
1095
|
late = late_event_stats(phase_events)
|
|
1026
|
-
|
|
1096
|
+
if len(stamps) >= 2:
|
|
1097
|
+
wall = duration_ms_between(stamps[0], stamps[-1])
|
|
1098
|
+
else:
|
|
1099
|
+
wall = active
|
|
1100
|
+
unclosed = sum(
|
|
1101
|
+
1
|
|
1102
|
+
for invocation in invocations
|
|
1103
|
+
if invocation.get("sealedIncomplete") or invocation.get("status") == "INCOMPLETE"
|
|
1104
|
+
)
|
|
1027
1105
|
return {
|
|
1028
1106
|
"activeExecutionMs": active,
|
|
1029
1107
|
"wallClockSpanMs": wall,
|
|
1030
1108
|
"lateEventCount": late["lateEventCount"],
|
|
1031
1109
|
"lateEventSpanMs": late["lateEventSpanMs"],
|
|
1110
|
+
"unclosedAttemptCount": unclosed,
|
|
1032
1111
|
}
|
|
1033
1112
|
|
|
1034
1113
|
|
|
@@ -3,7 +3,7 @@ name: harness-archive
|
|
|
3
3
|
description: 归档所有变更产出(计划/测试报告/审查/SQL/API文档)到
|
|
4
4
|
.harness/archive/,含归档元数据和可视化最终总结。使用场景:归档、完成归档、收尾、打包产出物
|
|
5
5
|
---
|
|
6
|
-
<!-- generated by harness_deploy.py; core=
|
|
6
|
+
<!-- generated by harness_deploy.py; core=36597f7648520b90; overlay=none; agent=cursor; do not edit -->
|
|
7
7
|
# harness-archive — 变更归档
|
|
8
8
|
|
|
9
9
|
## Purpose
|
|
@@ -14,6 +14,20 @@ description: harness-archive 的归档前检查项和归档后验证项。仅在
|
|
|
14
14
|
- before/after checksum 不一致时,不得删除原目录。
|
|
15
15
|
- 默认渲染器 `templates/render-summary.mjs`(finalize 内嵌调用)。
|
|
16
16
|
|
|
17
|
+
### Wave-A 状态机与身份(IA-1 / IA-4)
|
|
18
|
+
|
|
19
|
+
硬顺序:feature frozen → local gates → product candidate CI → **CI green** → merge → authoritative CI → archive product candidate → archive-only governance → release。
|
|
20
|
+
|
|
21
|
+
- [ ] `evidence/product-candidate-ci.json`(或等价 ledger 字段)`conclusion=success`,含 `runUrl` + `commit`;否则 `PRODUCT_CI_NOT_GREEN` 阻断
|
|
22
|
+
- [ ] summary/identity 含 `productCommit` / `productTreeHash` / `archiveCommit`;`productTreeHash` 排除 `.harness/**`
|
|
23
|
+
- [ ] 产品输入在 archive 后变化 → `ARCHIVE_EVIDENCE_REOPEN_REQUIRED`,旧 archive 不可作发布证据
|
|
24
|
+
- [ ] Manifest:coverage 字节在最终 snapshot 后不得静默漂移;后写报告必须 `exclusionReasons`,禁止假绿 `checksumStatus=OK`
|
|
25
|
+
|
|
26
|
+
### Environment 合同(IA-3)
|
|
27
|
+
|
|
28
|
+
- [ ] 使用可写环境栈前:`harness_environment.py fingerprint` → `acquire` lease → run → `release`
|
|
29
|
+
- [ ] 租约落在主仓 `.harness/runtime/env-leases/`;跨 change 默认不得共享可写 volume
|
|
30
|
+
|
|
17
31
|
## 归档前检查(Phase 1)
|
|
18
32
|
|
|
19
33
|
> ⚠️ **单一所有权**:finalize 内部负责且仅负责一次 `phase.start` / `phase.end`。归档前检查不得自行追加 archive 阶段边界。
|