norn-cli 0.7.1__py3-none-any.whl
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.
- norf_harness/__init__.py +6 -0
- norf_harness/cli.py +303 -0
- norf_harness/config.py +398 -0
- norf_harness/doctor.py +103 -0
- norf_harness/e2e_evidence.py +163 -0
- norf_harness/gate.py +140 -0
- norf_harness/models.py +93 -0
- norf_harness/pipeline.py +841 -0
- norf_harness/project_gates.py +225 -0
- norf_harness/providers/__init__.py +32 -0
- norf_harness/providers/base.py +395 -0
- norf_harness/providers/claude.py +127 -0
- norf_harness/providers/codex.py +145 -0
- norf_harness/providers/command.py +118 -0
- norf_harness/providers/grok.py +140 -0
- norf_harness/providers/registry.py +21 -0
- norf_harness/providers/router.py +137 -0
- norf_harness/reporting.py +285 -0
- norf_harness/resources/default.yaml +124 -0
- norf_harness/resources/skills/norn/SKILL.md +74 -0
- norf_harness/resources/skills/norn/agents/openai.yaml +7 -0
- norf_harness/resources/skills/norn/references/cli.md +123 -0
- norf_harness/run_store.py +205 -0
- norf_harness/skill_install.py +116 -0
- norf_harness/worktree.py +109 -0
- norn_cli-0.7.1.dist-info/METADATA +149 -0
- norn_cli-0.7.1.dist-info/RECORD +31 -0
- norn_cli-0.7.1.dist-info/WHEEL +5 -0
- norn_cli-0.7.1.dist-info/entry_points.txt +3 -0
- norn_cli-0.7.1.dist-info/licenses/LICENSE +201 -0
- norn_cli-0.7.1.dist-info/top_level.txt +1 -0
norf_harness/config.py
ADDED
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
"""Configuration loading, merging, and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import os
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from importlib.resources import files
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class ConfigError(ValueError):
|
|
17
|
+
"""Raised when harness configuration is invalid."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class QualityGateConfig:
|
|
22
|
+
critical: int
|
|
23
|
+
high: int
|
|
24
|
+
medium: int
|
|
25
|
+
low: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class LimitsConfig:
|
|
30
|
+
max_total_rounds: int
|
|
31
|
+
max_planning_retries: int
|
|
32
|
+
max_implementation_rounds: int
|
|
33
|
+
max_final_replans: int
|
|
34
|
+
max_provider_retries: int
|
|
35
|
+
role_timeout_seconds: int
|
|
36
|
+
test_timeout_seconds: int
|
|
37
|
+
e2e_timeout_seconds: int
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ProviderConfig:
|
|
42
|
+
name: str
|
|
43
|
+
adapter: str
|
|
44
|
+
command: str
|
|
45
|
+
auth: str
|
|
46
|
+
args: tuple[str, ...] = ()
|
|
47
|
+
output: str = "json"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class ModelCandidate:
|
|
52
|
+
provider: str
|
|
53
|
+
model: str
|
|
54
|
+
effort: str
|
|
55
|
+
model_id: str = ""
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def resolved_model(self) -> str:
|
|
59
|
+
"""Provider-facing model identifier, preserving a stable logical alias."""
|
|
60
|
+
return self.model_id or self.model
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class ProfileConfig:
|
|
65
|
+
name: str
|
|
66
|
+
candidates: tuple[ModelCandidate, ...]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass(frozen=True)
|
|
70
|
+
class GateCommandConfig:
|
|
71
|
+
name: str
|
|
72
|
+
kind: str
|
|
73
|
+
command: tuple[str, ...]
|
|
74
|
+
timeout_seconds: int | None = None
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass(frozen=True)
|
|
78
|
+
class ProjectGatesConfig:
|
|
79
|
+
enabled: bool
|
|
80
|
+
auto_detect: bool
|
|
81
|
+
commands: tuple[GateCommandConfig, ...]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class GitConfig:
|
|
86
|
+
worktree: bool
|
|
87
|
+
auto_commit: bool
|
|
88
|
+
publish: bool
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class HarnessConfig:
|
|
93
|
+
project_root: Path
|
|
94
|
+
quality_gate: QualityGateConfig
|
|
95
|
+
limits: LimitsConfig
|
|
96
|
+
runs_dir: Path
|
|
97
|
+
providers: Mapping[str, ProviderConfig]
|
|
98
|
+
profiles: Mapping[str, ProfileConfig]
|
|
99
|
+
roles: Mapping[str, str]
|
|
100
|
+
project_gates: ProjectGatesConfig
|
|
101
|
+
git: GitConfig
|
|
102
|
+
e2e_enabled: bool
|
|
103
|
+
e2e_required_for_ui_changes: bool
|
|
104
|
+
e2e_require_all_testers: bool
|
|
105
|
+
raw: Mapping[str, Any]
|
|
106
|
+
sources: tuple[Path, ...]
|
|
107
|
+
|
|
108
|
+
def to_dict(self) -> dict[str, Any]:
|
|
109
|
+
return copy.deepcopy(dict(self.raw))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def default_config_path() -> Path:
|
|
113
|
+
return Path(str(files("norf_harness").joinpath("resources/default.yaml")))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def user_config_path() -> Path:
|
|
117
|
+
overridden = os.environ.get("NORN_USER_CONFIG") or os.environ.get("NORF_HARNESS_USER_CONFIG")
|
|
118
|
+
if overridden:
|
|
119
|
+
return Path(overridden).expanduser()
|
|
120
|
+
canonical = Path.home() / ".config" / "norn" / "config.yaml"
|
|
121
|
+
legacy = Path.home() / ".config" / "norf-harness" / "config.yaml"
|
|
122
|
+
if not canonical.is_file() and legacy.is_file():
|
|
123
|
+
return legacy
|
|
124
|
+
return canonical
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def project_config_path(project_root: Path) -> Path:
|
|
128
|
+
return project_root / ".norf" / "harness.yaml"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def load_config(
|
|
132
|
+
project_root: Path | str,
|
|
133
|
+
*,
|
|
134
|
+
config_path: Path | str | None = None,
|
|
135
|
+
overrides: Mapping[str, Any] | None = None,
|
|
136
|
+
) -> HarnessConfig:
|
|
137
|
+
"""Load defaults < user < project/explicit < CLI overrides."""
|
|
138
|
+
|
|
139
|
+
root = Path(project_root).expanduser().resolve()
|
|
140
|
+
sources: list[Path] = []
|
|
141
|
+
|
|
142
|
+
default_path = default_config_path()
|
|
143
|
+
raw = _load_yaml(default_path, required=True)
|
|
144
|
+
sources.append(default_path)
|
|
145
|
+
|
|
146
|
+
user_path = user_config_path()
|
|
147
|
+
if user_path.is_file():
|
|
148
|
+
raw = _deep_merge(raw, _load_yaml(user_path, required=True))
|
|
149
|
+
sources.append(user_path)
|
|
150
|
+
|
|
151
|
+
selected_project_path = (
|
|
152
|
+
Path(config_path).expanduser().resolve()
|
|
153
|
+
if config_path is not None
|
|
154
|
+
else project_config_path(root)
|
|
155
|
+
)
|
|
156
|
+
if selected_project_path.is_file():
|
|
157
|
+
raw = _deep_merge(raw, _load_yaml(selected_project_path, required=True))
|
|
158
|
+
sources.append(selected_project_path)
|
|
159
|
+
elif config_path is not None:
|
|
160
|
+
raise ConfigError(f"Config file does not exist: {selected_project_path}")
|
|
161
|
+
|
|
162
|
+
for dotted_key, value in (overrides or {}).items():
|
|
163
|
+
_set_dotted(raw, dotted_key, value)
|
|
164
|
+
|
|
165
|
+
return _validate(root, raw, tuple(sources))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _load_yaml(path: Path, *, required: bool) -> dict[str, Any]:
|
|
169
|
+
if not path.is_file():
|
|
170
|
+
if required:
|
|
171
|
+
raise ConfigError(f"Config file does not exist: {path}")
|
|
172
|
+
return {}
|
|
173
|
+
try:
|
|
174
|
+
value = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
175
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
176
|
+
raise ConfigError(f"Failed to load config {path}: {exc}") from exc
|
|
177
|
+
if value is None:
|
|
178
|
+
return {}
|
|
179
|
+
if not isinstance(value, dict):
|
|
180
|
+
raise ConfigError(f"Config root must be a mapping: {path}")
|
|
181
|
+
return value
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _deep_merge(base: Mapping[str, Any], override: Mapping[str, Any]) -> dict[str, Any]:
|
|
185
|
+
result = copy.deepcopy(dict(base))
|
|
186
|
+
for key, value in override.items():
|
|
187
|
+
if isinstance(result.get(key), dict) and isinstance(value, dict):
|
|
188
|
+
result[key] = _deep_merge(result[key], value)
|
|
189
|
+
else:
|
|
190
|
+
result[key] = copy.deepcopy(value)
|
|
191
|
+
return result
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _set_dotted(target: dict[str, Any], dotted_key: str, value: Any) -> None:
|
|
195
|
+
parts = dotted_key.split(".")
|
|
196
|
+
if not parts or any(not part for part in parts):
|
|
197
|
+
raise ConfigError(f"Invalid override key: {dotted_key!r}")
|
|
198
|
+
cursor = target
|
|
199
|
+
for part in parts[:-1]:
|
|
200
|
+
child = cursor.setdefault(part, {})
|
|
201
|
+
if not isinstance(child, dict):
|
|
202
|
+
raise ConfigError(f"Cannot override nested key below {part!r}")
|
|
203
|
+
cursor = child
|
|
204
|
+
cursor[parts[-1]] = value
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def _mapping(raw: Mapping[str, Any], key: str) -> Mapping[str, Any]:
|
|
208
|
+
value = raw.get(key)
|
|
209
|
+
if not isinstance(value, dict):
|
|
210
|
+
raise ConfigError(f"{key} must be a mapping")
|
|
211
|
+
return value
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _integer(mapping: Mapping[str, Any], key: str, *, minimum: int = 0) -> int:
|
|
215
|
+
value = mapping.get(key)
|
|
216
|
+
if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
|
|
217
|
+
raise ConfigError(f"{key} must be an integer >= {minimum}")
|
|
218
|
+
return value
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _positive(mapping: Mapping[str, Any], key: str) -> int:
|
|
222
|
+
return _integer(mapping, key, minimum=1)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _boolean(mapping: Mapping[str, Any], key: str) -> bool:
|
|
226
|
+
value = mapping.get(key)
|
|
227
|
+
if not isinstance(value, bool):
|
|
228
|
+
raise ConfigError(f"{key} must be a boolean")
|
|
229
|
+
return value
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _validate(root: Path, raw: dict[str, Any], sources: tuple[Path, ...]) -> HarnessConfig:
|
|
233
|
+
if raw.get("version") != 1:
|
|
234
|
+
raise ConfigError("version must be 1")
|
|
235
|
+
|
|
236
|
+
gate_raw = _mapping(raw, "quality_gate")
|
|
237
|
+
gate = QualityGateConfig(
|
|
238
|
+
critical=_integer(gate_raw, "critical"),
|
|
239
|
+
high=_integer(gate_raw, "high"),
|
|
240
|
+
medium=_integer(gate_raw, "medium"),
|
|
241
|
+
low=_integer(gate_raw, "low", minimum=-1),
|
|
242
|
+
)
|
|
243
|
+
if gate.low < -1:
|
|
244
|
+
raise ConfigError("quality_gate.low must be -1 (unlimited) or >= 0")
|
|
245
|
+
|
|
246
|
+
limits_raw = _mapping(raw, "limits")
|
|
247
|
+
pipeline = _mapping(limits_raw, "pipeline")
|
|
248
|
+
planning = _mapping(limits_raw, "planning")
|
|
249
|
+
implementation = _mapping(limits_raw, "implementation")
|
|
250
|
+
final_gate = _mapping(limits_raw, "final_gate")
|
|
251
|
+
provider_limits = _mapping(limits_raw, "providers")
|
|
252
|
+
timeouts = _mapping(limits_raw, "timeouts")
|
|
253
|
+
limits = LimitsConfig(
|
|
254
|
+
max_total_rounds=_positive(pipeline, "max_total_rounds"),
|
|
255
|
+
max_planning_retries=_integer(planning, "max_retries"),
|
|
256
|
+
max_implementation_rounds=_positive(implementation, "max_rounds"),
|
|
257
|
+
max_final_replans=_integer(final_gate, "max_replans"),
|
|
258
|
+
max_provider_retries=_integer(provider_limits, "max_retries_per_call"),
|
|
259
|
+
role_timeout_seconds=_positive(timeouts, "role_seconds"),
|
|
260
|
+
test_timeout_seconds=_positive(timeouts, "test_seconds"),
|
|
261
|
+
e2e_timeout_seconds=_positive(timeouts, "e2e_seconds"),
|
|
262
|
+
)
|
|
263
|
+
if limits.max_implementation_rounds > limits.max_total_rounds:
|
|
264
|
+
raise ConfigError("implementation.max_rounds cannot exceed pipeline.max_total_rounds")
|
|
265
|
+
|
|
266
|
+
paths = _mapping(raw, "paths")
|
|
267
|
+
runs_dir_value = paths.get("runs_dir")
|
|
268
|
+
if not isinstance(runs_dir_value, str) or not runs_dir_value.strip():
|
|
269
|
+
raise ConfigError("paths.runs_dir must be a non-empty string")
|
|
270
|
+
runs_dir = Path(runs_dir_value).expanduser()
|
|
271
|
+
if not runs_dir.is_absolute():
|
|
272
|
+
runs_dir = root / runs_dir
|
|
273
|
+
runs_dir = runs_dir.resolve()
|
|
274
|
+
|
|
275
|
+
providers_raw = _mapping(raw, "providers")
|
|
276
|
+
providers: dict[str, ProviderConfig] = {}
|
|
277
|
+
for name, value in providers_raw.items():
|
|
278
|
+
if not isinstance(name, str) or not isinstance(value, dict):
|
|
279
|
+
raise ConfigError("providers entries must be named mappings")
|
|
280
|
+
adapter = value.get("adapter")
|
|
281
|
+
command = value.get("command")
|
|
282
|
+
auth = value.get("auth")
|
|
283
|
+
if not all(isinstance(item, str) and item for item in (adapter, command, auth)):
|
|
284
|
+
raise ConfigError(f"provider {name!r} requires adapter, command, and auth")
|
|
285
|
+
args = value.get("args", [])
|
|
286
|
+
output = value.get("output", "json")
|
|
287
|
+
if not isinstance(args, list) or not all(isinstance(item, str) for item in args):
|
|
288
|
+
raise ConfigError(f"provider {name!r} args must be a list of strings")
|
|
289
|
+
if output not in {"json", "jsonl", "text"}:
|
|
290
|
+
raise ConfigError(f"provider {name!r} output must be json, jsonl, or text")
|
|
291
|
+
providers[name] = ProviderConfig(name, adapter, command, auth, tuple(args), output)
|
|
292
|
+
|
|
293
|
+
profiles_raw = _mapping(raw, "profiles")
|
|
294
|
+
profiles: dict[str, ProfileConfig] = {}
|
|
295
|
+
for name, value in profiles_raw.items():
|
|
296
|
+
if not isinstance(name, str) or not isinstance(value, dict):
|
|
297
|
+
raise ConfigError("profiles entries must be named mappings")
|
|
298
|
+
candidate_values = value.get("candidates")
|
|
299
|
+
if not isinstance(candidate_values, list) or not candidate_values:
|
|
300
|
+
raise ConfigError(f"profile {name!r} requires at least one candidate")
|
|
301
|
+
candidates: list[ModelCandidate] = []
|
|
302
|
+
for candidate in candidate_values:
|
|
303
|
+
if not isinstance(candidate, dict):
|
|
304
|
+
raise ConfigError(f"profile {name!r} candidates must be mappings")
|
|
305
|
+
provider = candidate.get("provider")
|
|
306
|
+
model = candidate.get("model")
|
|
307
|
+
model_id = candidate.get("model_id", "")
|
|
308
|
+
effort = candidate.get("effort")
|
|
309
|
+
if provider not in providers:
|
|
310
|
+
raise ConfigError(f"profile {name!r} references unknown provider {provider!r}")
|
|
311
|
+
if not isinstance(model, str) or not model:
|
|
312
|
+
raise ConfigError(f"profile {name!r} candidate model must be non-empty")
|
|
313
|
+
if not isinstance(model_id, str):
|
|
314
|
+
raise ConfigError(f"profile {name!r} candidate model_id must be a string")
|
|
315
|
+
if effort not in {"low", "medium", "high", "xhigh", "max"}:
|
|
316
|
+
raise ConfigError(f"profile {name!r} has invalid effort {effort!r}")
|
|
317
|
+
candidates.append(ModelCandidate(provider, model, effort, model_id))
|
|
318
|
+
profiles[name] = ProfileConfig(name, tuple(candidates))
|
|
319
|
+
|
|
320
|
+
roles_raw = _mapping(raw, "roles")
|
|
321
|
+
roles: dict[str, str] = {}
|
|
322
|
+
required_roles = {
|
|
323
|
+
"orchestrator",
|
|
324
|
+
"planner",
|
|
325
|
+
"plan_reviewer",
|
|
326
|
+
"implementer",
|
|
327
|
+
"spec_tester",
|
|
328
|
+
"clean_tester",
|
|
329
|
+
"security_tester",
|
|
330
|
+
"chaos_tester",
|
|
331
|
+
"triage",
|
|
332
|
+
"final_gate",
|
|
333
|
+
}
|
|
334
|
+
for role, value in roles_raw.items():
|
|
335
|
+
if not isinstance(role, str) or not isinstance(value, dict):
|
|
336
|
+
raise ConfigError("roles entries must be named mappings")
|
|
337
|
+
profile = value.get("profile")
|
|
338
|
+
if profile not in profiles:
|
|
339
|
+
raise ConfigError(f"role {role!r} references unknown profile {profile!r}")
|
|
340
|
+
roles[role] = profile
|
|
341
|
+
missing_roles = sorted(required_roles - roles.keys())
|
|
342
|
+
if missing_roles:
|
|
343
|
+
raise ConfigError(f"missing required roles: {', '.join(missing_roles)}")
|
|
344
|
+
|
|
345
|
+
gates_raw = _mapping(raw, "gates")
|
|
346
|
+
gate_commands_raw = gates_raw.get("commands", [])
|
|
347
|
+
if not isinstance(gate_commands_raw, list):
|
|
348
|
+
raise ConfigError("gates.commands must be a list")
|
|
349
|
+
gate_commands: list[GateCommandConfig] = []
|
|
350
|
+
for index, value in enumerate(gate_commands_raw):
|
|
351
|
+
if not isinstance(value, dict):
|
|
352
|
+
raise ConfigError(f"gates.commands[{index}] must be a mapping")
|
|
353
|
+
name = value.get("name")
|
|
354
|
+
kind = value.get("kind")
|
|
355
|
+
command = value.get("command")
|
|
356
|
+
timeout = value.get("timeout_seconds")
|
|
357
|
+
if not isinstance(name, str) or not name.strip():
|
|
358
|
+
raise ConfigError(f"gates.commands[{index}].name must be non-empty")
|
|
359
|
+
if kind not in {"test", "lint"}:
|
|
360
|
+
raise ConfigError(f"gates.commands[{index}].kind must be test or lint")
|
|
361
|
+
if (
|
|
362
|
+
not isinstance(command, list)
|
|
363
|
+
or not command
|
|
364
|
+
or not all(isinstance(item, str) and item for item in command)
|
|
365
|
+
):
|
|
366
|
+
raise ConfigError(f"gates.commands[{index}].command must be a non-empty argv list")
|
|
367
|
+
if timeout is not None and (
|
|
368
|
+
isinstance(timeout, bool) or not isinstance(timeout, int) or timeout < 1
|
|
369
|
+
):
|
|
370
|
+
raise ConfigError(f"gates.commands[{index}].timeout_seconds must be >= 1")
|
|
371
|
+
gate_commands.append(GateCommandConfig(name, kind, tuple(command), timeout))
|
|
372
|
+
|
|
373
|
+
e2e = _mapping(raw, "e2e")
|
|
374
|
+
git = _mapping(raw, "git")
|
|
375
|
+
return HarnessConfig(
|
|
376
|
+
project_root=root,
|
|
377
|
+
quality_gate=gate,
|
|
378
|
+
limits=limits,
|
|
379
|
+
runs_dir=runs_dir,
|
|
380
|
+
providers=providers,
|
|
381
|
+
profiles=profiles,
|
|
382
|
+
roles=roles,
|
|
383
|
+
project_gates=ProjectGatesConfig(
|
|
384
|
+
enabled=_boolean(gates_raw, "enabled"),
|
|
385
|
+
auto_detect=_boolean(gates_raw, "auto_detect"),
|
|
386
|
+
commands=tuple(gate_commands),
|
|
387
|
+
),
|
|
388
|
+
git=GitConfig(
|
|
389
|
+
worktree=_boolean(git, "worktree"),
|
|
390
|
+
auto_commit=_boolean(git, "auto_commit"),
|
|
391
|
+
publish=_boolean(git, "publish"),
|
|
392
|
+
),
|
|
393
|
+
e2e_enabled=_boolean(e2e, "enabled"),
|
|
394
|
+
e2e_required_for_ui_changes=_boolean(e2e, "required_for_ui_changes"),
|
|
395
|
+
e2e_require_all_testers=_boolean(e2e, "require_all_testers"),
|
|
396
|
+
raw=copy.deepcopy(raw),
|
|
397
|
+
sources=sources,
|
|
398
|
+
)
|
norf_harness/doctor.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Read-only local environment diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
from dataclasses import asdict, dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .config import HarnessConfig, project_config_path
|
|
12
|
+
from .providers import default_registry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class Check:
|
|
17
|
+
name: str
|
|
18
|
+
status: str
|
|
19
|
+
detail: str
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def run_doctor(config: HarnessConfig) -> list[Check]:
|
|
23
|
+
checks: list[Check] = []
|
|
24
|
+
checks.append(
|
|
25
|
+
Check(
|
|
26
|
+
"python",
|
|
27
|
+
"PASS" if sys.version_info >= (3, 12) else "FAIL",
|
|
28
|
+
sys.version.split()[0],
|
|
29
|
+
)
|
|
30
|
+
)
|
|
31
|
+
checks.append(_git_check(config.project_root))
|
|
32
|
+
project_config = project_config_path(config.project_root)
|
|
33
|
+
checks.append(
|
|
34
|
+
Check(
|
|
35
|
+
"project-config",
|
|
36
|
+
"PASS" if project_config.is_file() else "WARN",
|
|
37
|
+
str(project_config) if project_config.is_file() else "using package/user defaults",
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
adapters = default_registry()
|
|
41
|
+
for name, provider in config.providers.items():
|
|
42
|
+
adapter = adapters.get(provider.adapter)
|
|
43
|
+
if adapter is None:
|
|
44
|
+
checks.append(
|
|
45
|
+
Check(
|
|
46
|
+
f"provider:{name}",
|
|
47
|
+
"FAIL",
|
|
48
|
+
f"adapter not registered: {provider.adapter}",
|
|
49
|
+
)
|
|
50
|
+
)
|
|
51
|
+
continue
|
|
52
|
+
probe = adapter.probe(provider)
|
|
53
|
+
status = "PASS" if probe.compatible else ("WARN" if not probe.available else "FAIL")
|
|
54
|
+
checks.append(
|
|
55
|
+
Check(
|
|
56
|
+
f"provider:{name}",
|
|
57
|
+
status,
|
|
58
|
+
f"{probe.command} — {probe.detail}",
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
for name, profile in config.profiles.items():
|
|
62
|
+
available = [
|
|
63
|
+
candidate
|
|
64
|
+
for candidate in profile.candidates
|
|
65
|
+
if shutil.which(config.providers[candidate.provider].command)
|
|
66
|
+
]
|
|
67
|
+
checks.append(
|
|
68
|
+
Check(
|
|
69
|
+
f"profile:{name}",
|
|
70
|
+
"PASS" if available else "FAIL",
|
|
71
|
+
(
|
|
72
|
+
f"{available[0].provider}/{available[0].model}"
|
|
73
|
+
f"->{available[0].resolved_model}/{available[0].effort}"
|
|
74
|
+
if available
|
|
75
|
+
else "no locally available provider candidate"
|
|
76
|
+
),
|
|
77
|
+
)
|
|
78
|
+
)
|
|
79
|
+
return checks
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def checks_as_dict(checks: list[Check]) -> list[dict[str, str]]:
|
|
83
|
+
return [asdict(check) for check in checks]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def doctor_exit_code(checks: list[Check]) -> int:
|
|
87
|
+
return 1 if any(check.status == "FAIL" for check in checks) else 0
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _git_check(root: Path) -> Check:
|
|
91
|
+
try:
|
|
92
|
+
proc = subprocess.run(
|
|
93
|
+
["git", "-C", str(root), "rev-parse", "--show-toplevel"],
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
timeout=5,
|
|
97
|
+
check=False,
|
|
98
|
+
)
|
|
99
|
+
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
100
|
+
return Check("git-repository", "FAIL", str(exc))
|
|
101
|
+
if proc.returncode != 0:
|
|
102
|
+
return Check("git-repository", "FAIL", proc.stderr.strip() or "not a git repository")
|
|
103
|
+
return Check("git-repository", "PASS", proc.stdout.strip())
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Validate and archive tester-produced E2E evidence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shutil
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
MAX_EVIDENCE_FILES = 20
|
|
11
|
+
MAX_EVIDENCE_BYTES = 64 * 1024 * 1024
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class EvidenceError(RuntimeError):
|
|
15
|
+
"""Raised when an isolated evidence directory cannot be prepared safely."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class EvidenceValidation:
|
|
20
|
+
errors: tuple[str, ...]
|
|
21
|
+
archived: tuple[str, ...]
|
|
22
|
+
|
|
23
|
+
@property
|
|
24
|
+
def valid(self) -> bool:
|
|
25
|
+
return not self.errors
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def prepare_evidence_directory(
|
|
29
|
+
workspace_root: Path, run_id: str, round_number: int, role: str
|
|
30
|
+
) -> Path:
|
|
31
|
+
workspace = workspace_root.resolve(strict=True)
|
|
32
|
+
parent = workspace
|
|
33
|
+
for part in (".norf", "e2e-evidence", run_id, f"round-{round_number}"):
|
|
34
|
+
parent = parent / part
|
|
35
|
+
if parent.is_symlink() or (parent.exists() and not parent.is_dir()):
|
|
36
|
+
raise EvidenceError(f"unsafe E2E evidence parent: {parent}")
|
|
37
|
+
parent.mkdir(mode=0o700, exist_ok=True)
|
|
38
|
+
destination = parent / role
|
|
39
|
+
if destination.is_symlink() or (destination.exists() and not destination.is_dir()):
|
|
40
|
+
raise EvidenceError(f"unsafe E2E evidence destination: {destination}")
|
|
41
|
+
if destination.is_dir():
|
|
42
|
+
shutil.rmtree(destination)
|
|
43
|
+
destination.mkdir(parents=True, mode=0o700)
|
|
44
|
+
resolved = destination.resolve(strict=True)
|
|
45
|
+
try:
|
|
46
|
+
resolved.relative_to(workspace)
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
raise EvidenceError(f"E2E evidence destination escaped workspace: {resolved}") from exc
|
|
49
|
+
return resolved
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def validate_and_archive_e2e(
|
|
53
|
+
e2e: dict[str, Any],
|
|
54
|
+
*,
|
|
55
|
+
workspace_root: Path,
|
|
56
|
+
evidence_dir: Path,
|
|
57
|
+
archive_dir: Path,
|
|
58
|
+
) -> EvidenceValidation:
|
|
59
|
+
errors = _validate_execution(e2e)
|
|
60
|
+
status = e2e.get("status")
|
|
61
|
+
if status == "unavailable":
|
|
62
|
+
return EvidenceValidation(tuple(errors), ())
|
|
63
|
+
|
|
64
|
+
artifact_values = e2e.get("artifacts")
|
|
65
|
+
if not isinstance(artifact_values, list) or not artifact_values:
|
|
66
|
+
errors.append("at least one E2E artifact is required")
|
|
67
|
+
return EvidenceValidation(tuple(errors), ())
|
|
68
|
+
if len(artifact_values) > MAX_EVIDENCE_FILES:
|
|
69
|
+
errors.append(f"too many E2E artifacts (maximum {MAX_EVIDENCE_FILES})")
|
|
70
|
+
return EvidenceValidation(tuple(errors), ())
|
|
71
|
+
|
|
72
|
+
evidence_root = evidence_dir.resolve()
|
|
73
|
+
workspace = workspace_root.resolve()
|
|
74
|
+
candidates: list[tuple[Path, Path]] = []
|
|
75
|
+
total_bytes = 0
|
|
76
|
+
for value in artifact_values:
|
|
77
|
+
if not isinstance(value, str) or not value.strip():
|
|
78
|
+
errors.append("E2E artifact paths must be non-empty strings")
|
|
79
|
+
continue
|
|
80
|
+
supplied = Path(value)
|
|
81
|
+
candidate = supplied if supplied.is_absolute() else workspace / supplied
|
|
82
|
+
if candidate.is_symlink():
|
|
83
|
+
errors.append(f"E2E artifact must not be a symlink: {value}")
|
|
84
|
+
continue
|
|
85
|
+
try:
|
|
86
|
+
resolved = candidate.resolve(strict=True)
|
|
87
|
+
except (OSError, RuntimeError):
|
|
88
|
+
errors.append(f"E2E artifact does not exist: {value}")
|
|
89
|
+
continue
|
|
90
|
+
try:
|
|
91
|
+
relative = resolved.relative_to(evidence_root)
|
|
92
|
+
except ValueError:
|
|
93
|
+
errors.append(f"E2E artifact is outside the assigned evidence directory: {value}")
|
|
94
|
+
continue
|
|
95
|
+
if not resolved.is_file():
|
|
96
|
+
errors.append(f"E2E artifact is not a file: {value}")
|
|
97
|
+
continue
|
|
98
|
+
stat = resolved.stat()
|
|
99
|
+
if stat.st_nlink != 1:
|
|
100
|
+
errors.append(f"E2E artifact must not be hard-linked: {value}")
|
|
101
|
+
continue
|
|
102
|
+
size = stat.st_size
|
|
103
|
+
if size <= 0:
|
|
104
|
+
errors.append(f"E2E artifact is empty: {value}")
|
|
105
|
+
continue
|
|
106
|
+
total_bytes += size
|
|
107
|
+
if total_bytes > MAX_EVIDENCE_BYTES:
|
|
108
|
+
errors.append(f"E2E artifacts exceed {MAX_EVIDENCE_BYTES // (1024 * 1024)} MiB total")
|
|
109
|
+
break
|
|
110
|
+
candidates.append((resolved, relative))
|
|
111
|
+
|
|
112
|
+
if errors:
|
|
113
|
+
return EvidenceValidation(tuple(errors), ())
|
|
114
|
+
|
|
115
|
+
archived: list[str] = []
|
|
116
|
+
try:
|
|
117
|
+
if archive_dir.exists():
|
|
118
|
+
shutil.rmtree(archive_dir)
|
|
119
|
+
for source, relative in candidates:
|
|
120
|
+
destination = archive_dir / relative
|
|
121
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
122
|
+
shutil.copy2(source, destination)
|
|
123
|
+
archived.append(str(destination))
|
|
124
|
+
except OSError as exc:
|
|
125
|
+
shutil.rmtree(archive_dir, ignore_errors=True)
|
|
126
|
+
return EvidenceValidation((f"failed to archive E2E evidence: {exc}",), ())
|
|
127
|
+
return EvidenceValidation((), tuple(archived))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _validate_execution(e2e: dict[str, Any]) -> list[str]:
|
|
131
|
+
errors: list[str] = []
|
|
132
|
+
if e2e.get("required") is not True:
|
|
133
|
+
errors.append("tester did not mark the required E2E as required")
|
|
134
|
+
status = e2e.get("status")
|
|
135
|
+
if status == "unavailable":
|
|
136
|
+
return errors
|
|
137
|
+
if status not in {"passed", "failed"}:
|
|
138
|
+
errors.append(f"required E2E has invalid status: {status}")
|
|
139
|
+
|
|
140
|
+
command = e2e.get("command")
|
|
141
|
+
if (
|
|
142
|
+
not isinstance(command, list)
|
|
143
|
+
or not command
|
|
144
|
+
or not all(isinstance(item, str) and item.strip() for item in command)
|
|
145
|
+
):
|
|
146
|
+
errors.append("E2E command must be a non-empty argv list")
|
|
147
|
+
|
|
148
|
+
exit_code = e2e.get("exit_code")
|
|
149
|
+
if isinstance(exit_code, bool) or not isinstance(exit_code, int):
|
|
150
|
+
errors.append("E2E exit_code must be an integer")
|
|
151
|
+
elif status == "passed" and exit_code != 0:
|
|
152
|
+
errors.append("passed E2E must have exit_code 0")
|
|
153
|
+
elif status == "failed" and exit_code == 0:
|
|
154
|
+
errors.append("failed E2E must have a nonzero exit_code")
|
|
155
|
+
|
|
156
|
+
evidence = e2e.get("evidence")
|
|
157
|
+
if (
|
|
158
|
+
not isinstance(evidence, list)
|
|
159
|
+
or not evidence
|
|
160
|
+
or not all(isinstance(item, str) and item.strip() for item in evidence)
|
|
161
|
+
):
|
|
162
|
+
errors.append("E2E evidence summary must contain at least one non-empty item")
|
|
163
|
+
return errors
|