evalctl 0.3.0__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.
- evalctl/__init__.py +3 -0
- evalctl/__main__.py +4 -0
- evalctl/cli.py +2352 -0
- evalctl-0.3.0.dist-info/METADATA +381 -0
- evalctl-0.3.0.dist-info/RECORD +9 -0
- evalctl-0.3.0.dist-info/WHEEL +5 -0
- evalctl-0.3.0.dist-info/entry_points.txt +2 -0
- evalctl-0.3.0.dist-info/licenses/LICENSE +201 -0
- evalctl-0.3.0.dist-info/top_level.txt +1 -0
evalctl/cli.py
ADDED
|
@@ -0,0 +1,2352 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import concurrent.futures
|
|
4
|
+
import fnmatch
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shlex
|
|
11
|
+
import shutil
|
|
12
|
+
import signal
|
|
13
|
+
import socket
|
|
14
|
+
import stat
|
|
15
|
+
import subprocess
|
|
16
|
+
import sys
|
|
17
|
+
import tempfile
|
|
18
|
+
import threading
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path, PurePosixPath
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from . import __version__
|
|
26
|
+
|
|
27
|
+
CONTRACT_VERSION = 1
|
|
28
|
+
TOOL = "evalctl"
|
|
29
|
+
DEFAULT_COMMAND_SCORER_TIMEOUT_SECONDS = 30
|
|
30
|
+
DEFAULT_RESERVATION_TTL_SECONDS = 3600
|
|
31
|
+
SAFE_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$")
|
|
32
|
+
BUILTIN_SCORERS = ("contains", "regex", "exact", "json-schema", "numeric-threshold", "file-exists", "exit-code", "workspace-diff")
|
|
33
|
+
|
|
34
|
+
EXIT_CODES = {
|
|
35
|
+
0: {"meaning": "success", "retryable": None},
|
|
36
|
+
1: {"meaning": "user-input error", "retryable": False},
|
|
37
|
+
2: {"meaning": "safety block", "retryable": False},
|
|
38
|
+
3: {"meaning": "tool-environment error", "retryable": None},
|
|
39
|
+
4: {"meaning": "transient failure", "retryable": True},
|
|
40
|
+
5: {"meaning": "conflict", "retryable": False},
|
|
41
|
+
6: {"meaning": "eval failure", "retryable": False},
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
CODE_REGISTRY = {
|
|
45
|
+
"E_CASE_INVALID": {"class": "user-input", "exit": 1, "where": ["validate", "run"], "retryable": False, "surface": "envelope"},
|
|
46
|
+
"E_SCHEMA_VIOLATION": {"class": "user-input", "exit": 1, "where": ["validate", "run"], "retryable": False, "surface": "envelope"},
|
|
47
|
+
"E_SUITE_NOT_FOUND": {"class": "user-input", "exit": 1, "where": ["run", "report", "validate"], "retryable": False, "surface": "envelope"},
|
|
48
|
+
"E_RUN_NOT_FOUND": {"class": "user-input", "exit": 1, "where": ["status", "report", "resume"], "retryable": False, "surface": "envelope"},
|
|
49
|
+
"E_RUN_CORRUPT": {"class": "user-input", "exit": 1, "where": ["resume"], "retryable": False, "surface": "envelope"},
|
|
50
|
+
"E_RUNNER_FAILED": {"class": "tool-env", "exit": 3, "where": ["run"], "retryable": None, "surface": "runner_json"},
|
|
51
|
+
"E_RUNNER_TIMEOUT": {"class": "tool-env", "exit": 3, "where": ["run"], "retryable": None, "surface": "runner_json"},
|
|
52
|
+
"E_SPOOLCTL_UNAVAILABLE": {"class": "tool-env", "exit": 3, "where": ["run", "resume"], "retryable": False, "surface": "envelope"},
|
|
53
|
+
"E_SPOOLCTL_INCOMPATIBLE": {"class": "tool-env", "exit": 3, "where": ["run", "resume"], "retryable": False, "surface": "envelope"},
|
|
54
|
+
"E_JOB_TRANSIENT": {"class": "transient", "exit": 4, "where": ["run", "resume"], "retryable": True, "surface": "envelope"},
|
|
55
|
+
"E_SCORER_FAILED": {"class": "tool-env", "exit": 3, "where": ["run", "report"], "retryable": None, "surface": "envelope"},
|
|
56
|
+
"E_SCORER_CASE_FAILED": {"class": "tool-env", "where": ["run", "replay"], "surface": "score_json"},
|
|
57
|
+
"E_RUN_BUSY": {"class": "transient", "exit": 4, "where": ["run", "resume"], "retryable": True, "surface": "envelope"},
|
|
58
|
+
"E_RUN_CONFLICT": {"class": "conflict", "exit": 5, "where": ["run", "init", "replay", "suite", "case", "scorer"], "retryable": False, "surface": "envelope"},
|
|
59
|
+
"W_UNSANDBOXED_RUNNER": {"class": "warning", "where": ["run", "replay"], "surface": "envelope"},
|
|
60
|
+
"W_REPLAY_CASE_ABSENT": {"class": "warning", "where": ["replay"], "surface": "envelope"},
|
|
61
|
+
"W_NOTHING_TO_REPLAY": {"class": "warning", "where": ["replay"], "surface": "envelope"},
|
|
62
|
+
"W_TEXT_DIFF_APPROXIMATED": {"class": "warning", "where": ["run"], "surface": "envelope"},
|
|
63
|
+
"W_OUTPUT_TRUNCATED": {"class": "warning", "where": ["run"], "surface": "envelope"},
|
|
64
|
+
"W_PATH_UNREADABLE": {"class": "warning", "where": ["run"], "surface": "envelope"},
|
|
65
|
+
"W_PARTIAL_RUN": {"class": "warning", "where": ["run", "report"], "surface": "envelope"},
|
|
66
|
+
"W_RESERVATION_RECLAIMED": {"class": "warning", "where": ["run", "resume"], "surface": "envelope"},
|
|
67
|
+
"W_RESUME_NOTHING_PENDING": {"class": "warning", "where": ["resume"], "surface": "envelope"},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class EvalctlError(Exception):
|
|
72
|
+
def __init__(self, code: str, message: str, hint: str, exit_code: int = 1, **ctx: Any) -> None:
|
|
73
|
+
super().__init__(message)
|
|
74
|
+
self.error = {"code": code, "message": message, "hint": hint, "exit_code": exit_code, **ctx}
|
|
75
|
+
self.exit_code = exit_code
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def now_iso() -> str:
|
|
79
|
+
source_date_epoch = os.environ.get("SOURCE_DATE_EPOCH")
|
|
80
|
+
if source_date_epoch:
|
|
81
|
+
try:
|
|
82
|
+
ts = int(source_date_epoch)
|
|
83
|
+
except ValueError:
|
|
84
|
+
raise EvalctlError("E_CASE_INVALID", f"SOURCE_DATE_EPOCH must be an integer Unix timestamp (got {source_date_epoch})", "unset SOURCE_DATE_EPOCH or set it to seconds since epoch", 1)
|
|
85
|
+
return datetime.fromtimestamp(ts, timezone.utc).isoformat().replace("+00:00", "Z")
|
|
86
|
+
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def stable_json(value: Any) -> str:
|
|
90
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def sha256_text(value: str) -> str:
|
|
94
|
+
return "sha256:" + hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def sha256_bytes(value: bytes) -> str:
|
|
98
|
+
return "sha256:" + hashlib.sha256(value).hexdigest()
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def envelope(data: Any, *, ok: bool = True, warnings: list[dict[str, Any]] | None = None,
|
|
102
|
+
commands: list[dict[str, Any]] | None = None, errors: list[dict[str, Any]] | None = None,
|
|
103
|
+
started: float | None = None) -> dict[str, Any]:
|
|
104
|
+
started = started or time.time()
|
|
105
|
+
payload = data if ok else None
|
|
106
|
+
meta: dict[str, Any] = {
|
|
107
|
+
"request_id": "req_" + uuid.uuid4().hex[:20],
|
|
108
|
+
"ts_iso": now_iso(),
|
|
109
|
+
"data_hash": sha256_text(stable_json(payload)) if ok else None,
|
|
110
|
+
"contract_version": CONTRACT_VERSION,
|
|
111
|
+
"elapsed_ms": int((time.time() - started) * 1000),
|
|
112
|
+
}
|
|
113
|
+
return {
|
|
114
|
+
"ok": ok,
|
|
115
|
+
"tool_version": __version__,
|
|
116
|
+
"data": payload,
|
|
117
|
+
"meta": meta,
|
|
118
|
+
"warnings": warnings or [],
|
|
119
|
+
"commands": commands or [],
|
|
120
|
+
"errors": errors or [],
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def wants_json(argv: list[str]) -> bool:
|
|
125
|
+
return "--json" in argv or "--format" in argv and value_after(argv, "--format") == "json" or not sys.stdout.isatty()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def value_after(argv: list[str], flag: str, default: str | None = None) -> str | None:
|
|
129
|
+
if flag not in argv:
|
|
130
|
+
return default
|
|
131
|
+
idx = argv.index(flag)
|
|
132
|
+
if idx + 1 >= len(argv):
|
|
133
|
+
raise EvalctlError("E_CASE_INVALID", f"{flag} requires a value", f"try: {TOOL} --help", 1)
|
|
134
|
+
return argv[idx + 1]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def has_flag(argv: list[str], flag: str) -> bool:
|
|
138
|
+
return flag in argv
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def strip_flags(argv: list[str], flags_with_values: set[str], bool_flags: set[str]) -> list[str]:
|
|
142
|
+
out: list[str] = []
|
|
143
|
+
i = 0
|
|
144
|
+
while i < len(argv):
|
|
145
|
+
item = argv[i]
|
|
146
|
+
if item in bool_flags:
|
|
147
|
+
i += 1
|
|
148
|
+
elif item in flags_with_values:
|
|
149
|
+
i += 2
|
|
150
|
+
else:
|
|
151
|
+
out.append(item)
|
|
152
|
+
i += 1
|
|
153
|
+
return out
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def print_envelope(data: Any, *, json_mode: bool, human: str | None = None, warnings: list[dict[str, Any]] | None = None,
|
|
157
|
+
commands: list[dict[str, Any]] | None = None, started: float | None = None) -> int:
|
|
158
|
+
if json_mode:
|
|
159
|
+
print(stable_json(envelope(data, warnings=warnings, commands=commands, started=started)))
|
|
160
|
+
else:
|
|
161
|
+
print(human if human is not None else json.dumps(data, indent=2, sort_keys=True))
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def print_error(err: EvalctlError, *, json_mode: bool, started: float | None = None) -> int:
|
|
166
|
+
print(err.error["message"], file=sys.stderr)
|
|
167
|
+
if json_mode:
|
|
168
|
+
print(stable_json(envelope(None, ok=False, errors=[err.error], started=started)))
|
|
169
|
+
return err.exit_code
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def help_text() -> str:
|
|
173
|
+
return f"""evalctl Local-first evals for agents.
|
|
174
|
+
|
|
175
|
+
USAGE: evalctl <command> [flags]
|
|
176
|
+
|
|
177
|
+
COMMANDS:
|
|
178
|
+
capabilities --json Machine contract
|
|
179
|
+
schema <verb> --json Output schema for a verb
|
|
180
|
+
robot-docs guide Agent workflow handbook
|
|
181
|
+
init [--force] Scaffold evals/ with code-review suite
|
|
182
|
+
validate [suite] [--json] Validate suite files
|
|
183
|
+
run <suite> [--jobs N] [--timeout S] [--run-id ID] [--resume ID] [--queue spoolctl] [--slots N] [--reservation-ttl S] [--fail-on-fail] [--json]
|
|
184
|
+
jobs list|get|prune [--yes] [--json]
|
|
185
|
+
replay --failed <run-id|--run-dir PATH> [--suite S] [--run-id NEW] [--force] [--json]
|
|
186
|
+
suite add <name> [--runner-argv ARGV|--runner-command CMD --shell] [--json]
|
|
187
|
+
case add <suite> --task TEXT --workspace PATH [--id ID] [--diff PATH] [--expect-json JSON] [--json]
|
|
188
|
+
scorer add <suite> --name NAME [--required|--advisory] [--id ID] [--argv ARGV|--command CMD --shell] [--json]
|
|
189
|
+
status <run-id|--run-dir PATH> [--json]
|
|
190
|
+
report <run-id|--run-dir PATH> [--format markdown|json] [--json]
|
|
191
|
+
|
|
192
|
+
GLOBAL FLAGS:
|
|
193
|
+
--json Structured envelope output
|
|
194
|
+
--no-color Suppress ANSI
|
|
195
|
+
--version Print version
|
|
196
|
+
--help, -h Show help
|
|
197
|
+
|
|
198
|
+
EXIT CODES: 0 ok; 1 input; 2 safety; 3 environment; 4 transient; 5 conflict; 6 eval failed.
|
|
199
|
+
AGENT/AUTOMATION:
|
|
200
|
+
Machine contract: evalctl capabilities --json
|
|
201
|
+
Workflow guide: evalctl robot-docs guide
|
|
202
|
+
Schemas: evalctl schema <verb> --json
|
|
203
|
+
"""
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def capabilities_data() -> dict[str, Any]:
|
|
207
|
+
try:
|
|
208
|
+
spool = probe_spoolctl()
|
|
209
|
+
spoolctl_status = {"available": True, "planned": False, "minimum_version": "0.4.1", "version": spool.get("version") or spool.get("tool_version")}
|
|
210
|
+
except EvalctlError:
|
|
211
|
+
spoolctl_status = {"available": False, "planned": False, "minimum_version": "0.4.1"}
|
|
212
|
+
verbs = {
|
|
213
|
+
"capabilities": {"description": "Return the machine contract.", "json": True, "mutates": False, "flags": ["--json"], "exit_codes": [0]},
|
|
214
|
+
"schema": {"description": "Return output schemas.", "json": True, "mutates": False, "args": ["verb"], "flags": ["--json"], "exit_codes": [0, 1]},
|
|
215
|
+
"robot-docs": {"description": "Return agent workflow guide.", "json": False, "mutates": False, "args": ["guide"], "exit_codes": [0, 1]},
|
|
216
|
+
"init": {"description": "Scaffold evals/ tree with sample code-review suite.", "json": True, "mutates": True, "flags": ["--json", "--force"], "exit_codes": [0, 5]},
|
|
217
|
+
"validate": {"description": "Validate suite.json, cases.jsonl, fixtures, scorer refs, and runner config.", "json": True, "mutates": False, "args": ["suite"], "flags": ["--json"], "exit_codes": [0, 1]},
|
|
218
|
+
"run": {"description": "Run a suite and produce a portable, resumable run directory.", "json": True, "mutates": True, "args": ["suite"], "flags": ["--json", "--jobs", "--timeout", "--run-id", "--resume", "--queue", "--slots", "--reservation-ttl", "--fail-on-fail"], "exit_codes": [0, 1, 3, 4, 5, 6]},
|
|
219
|
+
"jobs": {"description": "Inspect and prune local run/reservation/queue state.", "json": True, "mutates": True, "args": ["list", "get", "prune"], "flags": ["--json", "--yes", "--force"], "exit_codes": [0, 1]},
|
|
220
|
+
"replay": {"description": "Re-execute failed/errored cases from a source run into a linked partial run.", "json": True, "mutates": True, "args": ["run-id"], "flags": ["--json", "--failed", "--run-dir", "--suite", "--run-id", "--force", "--jobs", "--timeout", "--fail-on-fail"], "exit_codes": [0, 1, 3, 4, 5, 6]},
|
|
221
|
+
"suite": {"description": "Author suites, including suite add.", "json": True, "mutates": True, "args": ["add", "name"], "flags": ["--json", "--runner-argv", "--runner-command", "--shell"], "exit_codes": [0, 1, 5]},
|
|
222
|
+
"case": {"description": "Author cases, including case add.", "json": True, "mutates": True, "args": ["add", "suite"], "flags": ["--json", "--task", "--workspace", "--id", "--diff", "--expect-json"], "exit_codes": [0, 1, 5]},
|
|
223
|
+
"scorer": {"description": "Author scorers, including built-in and command scorers.", "json": True, "mutates": True, "args": ["add", "suite"], "flags": ["--json", "--name", "--required", "--advisory", "--id", "--argv", "--command", "--shell", "--timeout"], "exit_codes": [0, 1, 5]},
|
|
224
|
+
"status": {"description": "Diagnose run state.", "json": True, "mutates": False, "args": ["run-id"], "flags": ["--json", "--run-dir"], "exit_codes": [0, 1]},
|
|
225
|
+
"report": {"description": "Generate markdown or JSON report from run artifacts.", "json": True, "mutates": False, "args": ["run-id"], "flags": ["--json", "--format", "--run-dir"], "exit_codes": [0, 1, 3]},
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
"tool_name": TOOL,
|
|
229
|
+
"contract_version": CONTRACT_VERSION,
|
|
230
|
+
"features": ["universal_envelope", "deterministic_output", "artifact_replay", "workspace_diff", "authoring", "execution_replay", "command_scorer", "durable_runs", "resumable", "run_state_jobs", "queue_spoolctl"],
|
|
231
|
+
"verbs": verbs,
|
|
232
|
+
"global_flags": {"--json": "structured envelope", "--help": "help", "--version": "version", "--no-color": "suppress ANSI"},
|
|
233
|
+
"exit_codes": {str(k): v for k, v in EXIT_CODES.items()},
|
|
234
|
+
"error_codes": CODE_REGISTRY,
|
|
235
|
+
"env_vars": {
|
|
236
|
+
"EVALCTL_CASE_FILE": "materialized case JSON passed to runner",
|
|
237
|
+
"EVALCTL_WORKSPACE": "fresh per-case workspace",
|
|
238
|
+
"EVALCTL_OUTPUT_FILE": "runner response destination",
|
|
239
|
+
"EVALCTL_TASK_FILE": "task text file",
|
|
240
|
+
"EVALCTL_DIFF_FILE": "review diff file when present",
|
|
241
|
+
"SOURCE_DATE_EPOCH": "controls deterministic timestamps, including run created_ts",
|
|
242
|
+
},
|
|
243
|
+
"integrations": {
|
|
244
|
+
"spoolctl": spoolctl_status,
|
|
245
|
+
"inferctl": {"available": False, "planned": True},
|
|
246
|
+
},
|
|
247
|
+
"schemas_uri": "evalctl schema <verb> --json",
|
|
248
|
+
"robot_docs_uri": "evalctl robot-docs guide",
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def schema_object(required: list[str], properties: dict[str, Any], *, additional: bool = True) -> dict[str, Any]:
|
|
253
|
+
return {"type": "object", "required": required, "properties": properties, "additionalProperties": additional}
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
RUN_SUMMARY_SCHEMA = schema_object(
|
|
257
|
+
["ok", "case_count", "status_counts"],
|
|
258
|
+
{
|
|
259
|
+
"ok": {"type": "boolean"},
|
|
260
|
+
"case_count": {"type": "integer", "minimum": 0},
|
|
261
|
+
"status_counts": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 0}},
|
|
262
|
+
},
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
DATA_SCHEMAS = {
|
|
266
|
+
"capabilities": schema_object(
|
|
267
|
+
["tool_name", "contract_version", "features", "verbs", "global_flags", "exit_codes", "error_codes", "env_vars", "integrations", "schemas_uri", "robot_docs_uri"],
|
|
268
|
+
{
|
|
269
|
+
"tool_name": {"type": "string"},
|
|
270
|
+
"contract_version": {"type": "integer"},
|
|
271
|
+
"features": {"type": "array", "items": {"type": "string"}},
|
|
272
|
+
"verbs": {"type": "object", "additionalProperties": {"type": "object"}},
|
|
273
|
+
"global_flags": {"type": "object", "additionalProperties": {"type": "string"}},
|
|
274
|
+
"exit_codes": {"type": "object", "additionalProperties": {"type": "object"}},
|
|
275
|
+
"error_codes": {"type": "object", "additionalProperties": schema_object(["class", "where", "surface"], {"class": {"type": "string"}, "exit": {"type": "integer"}, "where": {"type": "array", "items": {"type": "string"}}, "retryable": {"type": ["boolean", "null"]}, "surface": {"type": "string"}})},
|
|
276
|
+
"env_vars": {"type": "object", "additionalProperties": {"type": "string"}},
|
|
277
|
+
"integrations": {"type": "object", "additionalProperties": {"type": "object"}},
|
|
278
|
+
"schemas_uri": {"type": "string"},
|
|
279
|
+
"robot_docs_uri": {"type": "string"},
|
|
280
|
+
},
|
|
281
|
+
),
|
|
282
|
+
"schema": schema_object(
|
|
283
|
+
["envelope_schema", "schemas", "definitions"],
|
|
284
|
+
{
|
|
285
|
+
"envelope_schema": {"type": "object"},
|
|
286
|
+
"schemas": {"type": "object", "additionalProperties": {"type": "object"}},
|
|
287
|
+
"definitions": {"type": "object"},
|
|
288
|
+
},
|
|
289
|
+
),
|
|
290
|
+
"init": schema_object(
|
|
291
|
+
["created", "suite", "files"],
|
|
292
|
+
{"created": {"type": "string"}, "suite": {"type": "string"}, "files": {"type": "array", "items": {"type": "string"}}},
|
|
293
|
+
),
|
|
294
|
+
"validate": schema_object(
|
|
295
|
+
["suite", "case_count", "valid"],
|
|
296
|
+
{"suite": {"type": "string"}, "case_count": {"type": "integer", "minimum": 0}, "valid": {"type": "boolean"}},
|
|
297
|
+
),
|
|
298
|
+
"run": schema_object(
|
|
299
|
+
["run_id", "run_dir", "run", "report_hash"],
|
|
300
|
+
{"run_id": {"type": "string"}, "run_dir": {"type": "string"}, "run": RUN_SUMMARY_SCHEMA, "report_hash": {"type": "string"}, "existing": {"type": "boolean"}, "queue": {"type": "object"}},
|
|
301
|
+
),
|
|
302
|
+
"jobs": schema_object(
|
|
303
|
+
[],
|
|
304
|
+
{
|
|
305
|
+
"runs": {"type": "array", "items": {"type": "object"}},
|
|
306
|
+
"count": {"type": "integer", "minimum": 0},
|
|
307
|
+
"run_id": {"type": "string"},
|
|
308
|
+
"run_dir": {"type": "string"},
|
|
309
|
+
"state": {"type": "string"},
|
|
310
|
+
"reservation": {"type": "object"},
|
|
311
|
+
"cases": {"type": "object"},
|
|
312
|
+
"queue_jobs": {"type": "array", "items": {"type": "object"}},
|
|
313
|
+
"confirmed": {"type": "boolean"},
|
|
314
|
+
"candidates": {"type": "object"},
|
|
315
|
+
"removed": {"type": "object"},
|
|
316
|
+
"refused": {"type": "array", "items": {"type": "object"}},
|
|
317
|
+
},
|
|
318
|
+
),
|
|
319
|
+
"replay": schema_object(
|
|
320
|
+
["replayed_from", "cases_replayed"],
|
|
321
|
+
{
|
|
322
|
+
"replayed_from": {"type": "string"},
|
|
323
|
+
"cases_replayed": {"type": "integer", "minimum": 0},
|
|
324
|
+
"run_id": {"type": "string"},
|
|
325
|
+
"run_dir": {"type": "string"},
|
|
326
|
+
"run": RUN_SUMMARY_SCHEMA,
|
|
327
|
+
"report_hash": {"type": "string"},
|
|
328
|
+
},
|
|
329
|
+
),
|
|
330
|
+
"suite": schema_object(
|
|
331
|
+
["suite", "suite_dir", "created", "files"],
|
|
332
|
+
{
|
|
333
|
+
"suite": {"type": "string"},
|
|
334
|
+
"suite_dir": {"type": "string"},
|
|
335
|
+
"created": {"type": "boolean"},
|
|
336
|
+
"files": {"type": "array", "items": {"type": "string"}},
|
|
337
|
+
},
|
|
338
|
+
),
|
|
339
|
+
"case": schema_object(
|
|
340
|
+
["suite", "id", "created", "case"],
|
|
341
|
+
{
|
|
342
|
+
"suite": {"type": "string"},
|
|
343
|
+
"id": {"type": "string"},
|
|
344
|
+
"created": {"type": "boolean"},
|
|
345
|
+
"case": {"type": "object"},
|
|
346
|
+
},
|
|
347
|
+
),
|
|
348
|
+
"scorer": schema_object(
|
|
349
|
+
["suite", "scorer", "created"],
|
|
350
|
+
{
|
|
351
|
+
"suite": {"type": "string"},
|
|
352
|
+
"scorer": {"type": "string"},
|
|
353
|
+
"id": {"type": ["string", "null"]},
|
|
354
|
+
"created": {"type": "boolean"},
|
|
355
|
+
},
|
|
356
|
+
),
|
|
357
|
+
"status": schema_object(
|
|
358
|
+
["run_id", "run_dir", "run", "cases", "recommended_action"],
|
|
359
|
+
{
|
|
360
|
+
"run_id": {"type": "string"},
|
|
361
|
+
"run_dir": {"type": "string"},
|
|
362
|
+
"run": RUN_SUMMARY_SCHEMA,
|
|
363
|
+
"cases": {"type": "array", "items": {"type": "object"}},
|
|
364
|
+
"recommended_action": schema_object(["command", "rationale", "alternatives"], {"command": {"type": "string"}, "rationale": {"type": "string"}, "alternatives": {"type": "array", "items": {"type": "string"}}}),
|
|
365
|
+
},
|
|
366
|
+
),
|
|
367
|
+
"report": schema_object(
|
|
368
|
+
["run", "failures", "cases", "run_id", "report_hash"],
|
|
369
|
+
{
|
|
370
|
+
"run": schema_object(["ok", "suite", "case_count", "status_counts"], {"ok": {"type": "boolean"}, "suite": {"type": "string"}, "case_count": {"type": "integer", "minimum": 0}, "status_counts": {"type": "object", "additionalProperties": {"type": "integer", "minimum": 0}}}),
|
|
371
|
+
"failures": {"type": "array", "items": {"type": "object"}},
|
|
372
|
+
"cases": {"type": "array", "items": {"type": "object"}},
|
|
373
|
+
"run_id": {"type": "string"},
|
|
374
|
+
"report_hash": {"type": "string"},
|
|
375
|
+
},
|
|
376
|
+
),
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def schema_data(verb: str | None = None) -> dict[str, Any]:
|
|
381
|
+
envelope_schema = {
|
|
382
|
+
"type": "object",
|
|
383
|
+
"required": ["ok", "tool_version", "data", "meta", "warnings", "commands", "errors"],
|
|
384
|
+
"properties": {
|
|
385
|
+
"ok": {"type": "boolean"},
|
|
386
|
+
"tool_version": {"type": "string"},
|
|
387
|
+
"data": {},
|
|
388
|
+
"meta": {"type": "object"},
|
|
389
|
+
"warnings": {"type": "array"},
|
|
390
|
+
"commands": {"type": "array"},
|
|
391
|
+
"errors": {"type": "array"},
|
|
392
|
+
},
|
|
393
|
+
}
|
|
394
|
+
schemas = DATA_SCHEMAS
|
|
395
|
+
if verb:
|
|
396
|
+
if verb not in schemas:
|
|
397
|
+
raise EvalctlError("E_CASE_INVALID", f"unknown schema verb '{verb}'", "try: evalctl capabilities --json", 1)
|
|
398
|
+
schemas = {verb: schemas[verb]}
|
|
399
|
+
return {"envelope_schema": envelope_schema, "schemas": schemas, "definitions": {}}
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def robot_docs() -> str:
|
|
403
|
+
return """# evalctl - Agent Workflow Guide
|
|
404
|
+
|
|
405
|
+
## Quick reference
|
|
406
|
+
|
|
407
|
+
Capabilities: `evalctl capabilities --json`
|
|
408
|
+
Schemas: `evalctl schema run --json`
|
|
409
|
+
Initialize: `evalctl init --json`
|
|
410
|
+
Validate: `evalctl validate code-review --json`
|
|
411
|
+
Run: `evalctl run code-review --json`
|
|
412
|
+
Inspect: `evalctl status <run-id> --json`
|
|
413
|
+
Report: `evalctl report <run-id> --format json`
|
|
414
|
+
Artifact replay: `evalctl report --run-dir <copied-run-dir> --format json`
|
|
415
|
+
|
|
416
|
+
## v0.3 workflow
|
|
417
|
+
|
|
418
|
+
1. Scaffold `evals/suites/code-review/` with `evalctl init`.
|
|
419
|
+
2. Or author a new suite without hand-editing JSON:
|
|
420
|
+
`evalctl suite add demo --runner-argv "python3 $EVALCTL_WORKSPACE/r.py" --json`;
|
|
421
|
+
create fixtures; `evalctl case add demo --task "..." --workspace fixtures/x --json`;
|
|
422
|
+
`evalctl scorer add demo --name exact --required --json`.
|
|
423
|
+
3. Run `evalctl validate <suite> --json` before executing local code.
|
|
424
|
+
4. Run `evalctl run <suite> --json`. The runner is arbitrary local code; evalctl is not a sandbox.
|
|
425
|
+
5. If a run is interrupted, use `evalctl run --resume <run-id> --json`. Resume uses
|
|
426
|
+
`run.json`, terminal `cases/<id>/state.json` markers, and the original suite snapshot;
|
|
427
|
+
it skips terminal cases and re-runs only unfinished cases.
|
|
428
|
+
6. Use `jobs list|get|prune --json` to inspect completed, running, stale, and orphaned
|
|
429
|
+
local run state. Reservations are TTL files with a background heartbeat; no daemon or
|
|
430
|
+
lock server is required.
|
|
431
|
+
7. Optionally use `evalctl run <suite> --queue spoolctl --json` to delegate runner
|
|
432
|
+
execution to spoolctl. Spoolctl is optional and must be >= 0.4.1; absent or incompatible
|
|
433
|
+
spoolctl is a hard error only when `--queue spoolctl` is requested. The queue DB is
|
|
434
|
+
per-run `.spoolctl.db`, so externally managed cross-machine workers require a shared
|
|
435
|
+
filesystem and are not a general hosted-worker mode.
|
|
436
|
+
8. Use `status` for run state and recommended next command.
|
|
437
|
+
9. Use `report --format json` for a deterministic report envelope or `--format markdown` for a human report.
|
|
438
|
+
10. Copy a completed run directory anywhere and run `report --run-dir <path> --format json`;
|
|
439
|
+
evalctl recomputes scores from report artifacts and does not require durability sidecars.
|
|
440
|
+
11. After fixing a failed runner/fixture, run `evalctl replay --failed <run-id> --json`
|
|
441
|
+
to re-execute only failed/errored cases into a fresh partial run. `replay --run-id`
|
|
442
|
+
names the destination run, not the source.
|
|
443
|
+
|
|
444
|
+
## Command scorers
|
|
445
|
+
|
|
446
|
+
`evalctl scorer add <suite> --name command --id judge1 --argv "python3 scorer.py"`
|
|
447
|
+
adds an external scorer. A command scorer receives `EVALCTL_CASE_FILE`,
|
|
448
|
+
`EVALCTL_OUTPUT_FILE`, and `EVALCTL_WORKSPACE`, emits one JSON verdict, and is executed
|
|
449
|
+
once at run time. Its normalized verdict is stored under
|
|
450
|
+
`cases/<case_id>/scorers/<id>.json`; reports and artifact replay read that artifact and
|
|
451
|
+
never re-execute the scorer binary. Command scorers execute arbitrary local code and are
|
|
452
|
+
covered by the same unsandboxed warning as runners.
|
|
453
|
+
|
|
454
|
+
## Exit-code branching
|
|
455
|
+
|
|
456
|
+
`0` success, `1` input error, `2` safety block, `3` tool environment error, `4` retryable transient, `5` conflict, `6` eval failure from `run --fail-on-fail`.
|
|
457
|
+
|
|
458
|
+
## Error-code surfaces
|
|
459
|
+
|
|
460
|
+
Codes with `surface:"envelope"` appear in `errors[]` or `warnings[]` and predict
|
|
461
|
+
the command's process-exit class. Codes with `surface:"runner_json"` appear as
|
|
462
|
+
per-case `runner.json.error_code` reason codes. Codes with `surface:"score_json"`
|
|
463
|
+
appear as per-case scorer verdict reason codes, for example
|
|
464
|
+
`E_SCORER_CASE_FAILED` in `cases/<id>/scorers/<scorer_id>.json` or `score.json`.
|
|
465
|
+
A runner timeout, runner spawn failure, or command-scorer failure is reportable
|
|
466
|
+
case data: `run`/`replay` exits 0 by default, exits 6 with `--fail-on-fail`, emits
|
|
467
|
+
`W_PARTIAL_RUN`, and does not put the per-case reason code in `errors[]`.
|
|
468
|
+
|
|
469
|
+
## Durable runs
|
|
470
|
+
|
|
471
|
+
Fresh runs write `run.json` once before execution and terminal
|
|
472
|
+
`cases/<case_id>/state.json` markers after each case's report artifacts are complete.
|
|
473
|
+
`manifest.json` is finalized from that durable state. `SOURCE_DATE_EPOCH` controls
|
|
474
|
+
`created_ts`, which makes manifest parity tests deterministic. Durability sidecars are
|
|
475
|
+
operational state and are not part of `report_hash`.
|
|
476
|
+
|
|
477
|
+
## Artifact writes
|
|
478
|
+
|
|
479
|
+
JSON artifacts are written by creating a temporary file in the target directory
|
|
480
|
+
and replacing the final path with `os.replace`. This guarantees atomic visibility:
|
|
481
|
+
readers never see a half-written final JSON file. It is not a full crash-durable
|
|
482
|
+
guarantee; v0.1.1 does not fsync files or directories.
|
|
483
|
+
|
|
484
|
+
## Deferred
|
|
485
|
+
|
|
486
|
+
Compare, inferctl route capture, externally managed shared worker fleets, and
|
|
487
|
+
LLM-as-judge scoring are roadmap items, not v0.3 commands.
|
|
488
|
+
"""
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def resolve_suite(suite: str | None) -> Path:
|
|
492
|
+
suite = suite or "code-review"
|
|
493
|
+
direct = Path(suite)
|
|
494
|
+
if direct.exists():
|
|
495
|
+
return direct
|
|
496
|
+
candidate = Path("evals") / "suites" / suite
|
|
497
|
+
if candidate.exists():
|
|
498
|
+
return candidate
|
|
499
|
+
raise EvalctlError("E_SUITE_NOT_FOUND", f"suite not found: {suite}", "try: evalctl init && evalctl validate code-review --json", 1)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def read_json(path: Path) -> Any:
|
|
503
|
+
try:
|
|
504
|
+
return json.loads(path.read_text())
|
|
505
|
+
except FileNotFoundError:
|
|
506
|
+
raise EvalctlError("E_SUITE_NOT_FOUND", f"missing file: {path}", f"create {path} or run evalctl init", 1)
|
|
507
|
+
except json.JSONDecodeError as exc:
|
|
508
|
+
raise EvalctlError("E_SCHEMA_VIOLATION", f"invalid JSON in {path}: {exc.msg}", f"fix {path}:{exc.lineno}", 1)
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def load_cases(cases_path: Path) -> list[dict[str, Any]]:
|
|
512
|
+
cases: list[dict[str, Any]] = []
|
|
513
|
+
seen: set[str] = set()
|
|
514
|
+
try:
|
|
515
|
+
lines = cases_path.read_text().splitlines()
|
|
516
|
+
except FileNotFoundError:
|
|
517
|
+
raise EvalctlError("E_SUITE_NOT_FOUND", f"missing file: {cases_path}", f"create {cases_path}", 1)
|
|
518
|
+
for line_no, line in enumerate(lines, 1):
|
|
519
|
+
if not line.strip():
|
|
520
|
+
continue
|
|
521
|
+
try:
|
|
522
|
+
case = json.loads(line)
|
|
523
|
+
except json.JSONDecodeError as exc:
|
|
524
|
+
raise EvalctlError("E_CASE_INVALID", f"invalid JSONL at {cases_path}:{line_no}: {exc.msg}", f"fix line {line_no}", 1)
|
|
525
|
+
case_id = case.get("id") or sha256_text(stable_json(case))[7:19]
|
|
526
|
+
if case_id in seen:
|
|
527
|
+
raise EvalctlError("E_CASE_INVALID", f"duplicate case id: {case_id}", "choose unique case ids", 1)
|
|
528
|
+
seen.add(case_id)
|
|
529
|
+
case["id"] = case_id
|
|
530
|
+
cases.append(case)
|
|
531
|
+
return cases
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def validate_suite(suite_dir: Path) -> dict[str, Any]:
|
|
535
|
+
suite = read_json(suite_dir / "suite.json")
|
|
536
|
+
runner = suite.get("runner") or {}
|
|
537
|
+
shell = bool(runner.get("shell", False))
|
|
538
|
+
has_argv = isinstance(runner.get("argv"), list) and bool(runner.get("argv"))
|
|
539
|
+
has_cmd = isinstance(runner.get("command"), str) and bool(runner.get("command"))
|
|
540
|
+
if shell and not has_cmd or not shell and not has_argv:
|
|
541
|
+
raise EvalctlError("E_SCHEMA_VIOLATION", "runner must define argv for shell:false or command for shell:true", f"fix {suite_dir/'suite.json'} runner", 1)
|
|
542
|
+
if has_argv and has_cmd:
|
|
543
|
+
raise EvalctlError("E_SCHEMA_VIOLATION", "runner must not define both argv and command", f"fix {suite_dir/'suite.json'} runner", 1)
|
|
544
|
+
cases = load_cases(suite_dir / suite.get("cases", "cases.jsonl"))
|
|
545
|
+
for case in cases:
|
|
546
|
+
for key in ("task", "workspace"):
|
|
547
|
+
if key not in case:
|
|
548
|
+
raise EvalctlError("E_CASE_INVALID", f"case {case.get('id')} missing {key}", f"add {key} to case", 1)
|
|
549
|
+
workspace = suite_dir / case["workspace"]
|
|
550
|
+
if not workspace.exists():
|
|
551
|
+
raise EvalctlError("E_CASE_INVALID", f"case {case['id']} workspace missing: {case['workspace']}", "fix workspace path", 1)
|
|
552
|
+
if case.get("diff") and not (suite_dir / case["diff"]).exists():
|
|
553
|
+
raise EvalctlError("E_CASE_INVALID", f"case {case['id']} diff missing: {case['diff']}", "fix diff path", 1)
|
|
554
|
+
return {"suite": suite.get("name", suite_dir.name), "case_count": len(cases), "valid": True}
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def init_project(force: bool = False) -> dict[str, Any]:
|
|
558
|
+
root = Path("evals")
|
|
559
|
+
if root.exists() and not force:
|
|
560
|
+
raise EvalctlError("E_RUN_CONFLICT", "evals/ already exists; refusing to overwrite", "try: evalctl validate code-review --json, or evalctl init --force to replace sample files", 5)
|
|
561
|
+
suite = root / "suites" / "code-review"
|
|
562
|
+
if force and suite.exists():
|
|
563
|
+
shutil.rmtree(suite)
|
|
564
|
+
(suite / "fixtures" / "cr-pass").mkdir(parents=True, exist_ok=True)
|
|
565
|
+
(suite / "fixtures" / "cr-fail").mkdir(parents=True, exist_ok=True)
|
|
566
|
+
(root / "runs").mkdir(parents=True, exist_ok=True)
|
|
567
|
+
suite_json = {
|
|
568
|
+
"name": "code-review",
|
|
569
|
+
"cases": "cases.jsonl",
|
|
570
|
+
"acknowledged_unsandboxed_runner": True,
|
|
571
|
+
"runner": {
|
|
572
|
+
"argv": ["python3", "$EVALCTL_WORKSPACE/runner.py"],
|
|
573
|
+
"shell": False,
|
|
574
|
+
"command": None,
|
|
575
|
+
"cwd": None,
|
|
576
|
+
"timeout_seconds": 30,
|
|
577
|
+
"max_output_bytes": 1048576,
|
|
578
|
+
"env_allowlist": ["PATH", "HOME"],
|
|
579
|
+
"redact_env_values": [],
|
|
580
|
+
"redact_patterns": ["(?i)secret[=:]\\s*\\S+", "(?i)token[=:]\\s*\\S+"],
|
|
581
|
+
},
|
|
582
|
+
"scorers": [
|
|
583
|
+
{"name": "contains", "required": True},
|
|
584
|
+
{"name": "exit-code", "required": True},
|
|
585
|
+
{"name": "file-exists", "required": True},
|
|
586
|
+
{"name": "workspace-diff", "required": True},
|
|
587
|
+
],
|
|
588
|
+
}
|
|
589
|
+
cases = [
|
|
590
|
+
{"id": "cr-pass", "task": "Review this diff and report the null dereference.", "workspace": "fixtures/cr-pass", "diff": "fixtures/cr-pass/change.diff", "expect": {"text_contains": ["null dereference", "src/app.py:7"], "files_exist": ["review.md"], "files_changed": ["review.md"], "files_forbidden": ["src/secrets.py"], "exit_code": 0}},
|
|
591
|
+
{"id": "cr-fail", "task": "Review this diff and avoid touching secrets.", "workspace": "fixtures/cr-fail", "diff": "fixtures/cr-fail/change.diff", "expect": {"text_contains": ["bounds check"], "files_exist": ["review.md"], "files_changed": ["review.md"], "files_forbidden": ["src/secrets.py"], "exit_code": 0}},
|
|
592
|
+
]
|
|
593
|
+
(suite / "suite.json").write_text(json.dumps(suite_json, indent=2, sort_keys=True) + "\n")
|
|
594
|
+
(suite / "cases.jsonl").write_text("\n".join(stable_json(c) for c in cases) + "\n")
|
|
595
|
+
for case_name, body, diff, runner in [
|
|
596
|
+
("cr-pass", "value = payload.get('name')\nprint(value.upper())\n", "--- a/src/app.py\n+++ b/src/app.py\n@@ -4,4 +4,4 @@\n-print(value)\n+print(value.upper())\n", "from pathlib import Path\nimport os\nPath(os.environ['EVALCTL_OUTPUT_FILE']).write_text('Found null dereference at src/app.py:7\\n')\nPath('review.md').write_text('Found null dereference at src/app.py:7\\n')\n"),
|
|
597
|
+
("cr-fail", "items = []\nprint(items[3])\n", "--- a/src/app.py\n+++ b/src/app.py\n@@ -1,2 +1,2 @@\n-print(items[0])\n+print(items[3])\n", "from pathlib import Path\nimport os\nPath(os.environ['EVALCTL_OUTPUT_FILE']).write_text('Looks fine\\n')\nPath('src').mkdir(exist_ok=True)\nPath('src/secrets.py').write_text('token=redacted\\n')\n"),
|
|
598
|
+
]:
|
|
599
|
+
case_dir = suite / "fixtures" / case_name
|
|
600
|
+
(case_dir / "src").mkdir(exist_ok=True)
|
|
601
|
+
(case_dir / "src" / "app.py").write_text(body)
|
|
602
|
+
(case_dir / "change.diff").write_text(diff)
|
|
603
|
+
(case_dir / "runner.py").write_text(runner)
|
|
604
|
+
return {"created": str(root), "suite": "code-review", "files": ["evals/suites/code-review/suite.json", "evals/suites/code-review/cases.jsonl"]}
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def normalize_rel(path: Path, root: Path) -> str:
|
|
608
|
+
rel = path.relative_to(root)
|
|
609
|
+
parts = rel.parts
|
|
610
|
+
posix = PurePosixPath(*parts).as_posix()
|
|
611
|
+
if posix in ("", ".") or ".." in parts:
|
|
612
|
+
raise ValueError("invalid relative path")
|
|
613
|
+
posix.encode("utf-8", "strict")
|
|
614
|
+
return posix
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def display_path_name(path: Path) -> str:
|
|
618
|
+
return os.fsencode(path.name).decode("utf-8", "replace")
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
def manifest(root: Path) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
622
|
+
warnings: list[dict[str, Any]] = []
|
|
623
|
+
entries: list[dict[str, Any]] = []
|
|
624
|
+
for path in sorted(root.rglob("*"), key=os.fsencode):
|
|
625
|
+
try:
|
|
626
|
+
rel = normalize_rel(path, root)
|
|
627
|
+
st = path.lstat()
|
|
628
|
+
if stat.S_ISDIR(st.st_mode):
|
|
629
|
+
entries.append({"path": rel, "kind": "directory"})
|
|
630
|
+
elif stat.S_ISLNK(st.st_mode):
|
|
631
|
+
target = os.readlink(path)
|
|
632
|
+
entries.append({"path": rel, "kind": "symlink", "target": target, "sha256": sha256_bytes(target.encode("utf-8")), "broken": not path.exists()})
|
|
633
|
+
elif stat.S_ISREG(st.st_mode):
|
|
634
|
+
entries.append({"path": rel, "kind": "file", "sha256": sha256_bytes(path.read_bytes()), "size": st.st_size})
|
|
635
|
+
else:
|
|
636
|
+
subtype = "fifo" if stat.S_ISFIFO(st.st_mode) else "socket" if stat.S_ISSOCK(st.st_mode) else "device" if stat.S_ISCHR(st.st_mode) or stat.S_ISBLK(st.st_mode) else "unknown"
|
|
637
|
+
entries.append({"path": rel, "kind": "other", "subtype": subtype})
|
|
638
|
+
except Exception as exc:
|
|
639
|
+
warnings.append({"code": "W_PATH_UNREADABLE", "message": f"could not read path {display_path_name(path)}: {exc}"})
|
|
640
|
+
return {"root": ".", "entries": entries}, warnings
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def diff_manifests(before: dict[str, Any], after: dict[str, Any]) -> dict[str, Any]:
|
|
644
|
+
b = {e["path"]: e for e in before["entries"]}
|
|
645
|
+
a = {e["path"]: e for e in after["entries"]}
|
|
646
|
+
changed = []
|
|
647
|
+
for path in sorted(set(b) | set(a)):
|
|
648
|
+
if path not in b:
|
|
649
|
+
changed.append({"path": path, "status": "added", "kind": a[path]["kind"], "sha256_before": None, "sha256_after": a[path].get("sha256")})
|
|
650
|
+
elif path not in a:
|
|
651
|
+
changed.append({"path": path, "status": "deleted", "kind": b[path]["kind"], "sha256_before": b[path].get("sha256"), "sha256_after": None})
|
|
652
|
+
elif b[path].get("sha256") != a[path].get("sha256") or b[path].get("kind") != a[path].get("kind"):
|
|
653
|
+
changed.append({"path": path, "status": "modified", "kind": a[path]["kind"], "sha256_before": b[path].get("sha256"), "sha256_after": a[path].get("sha256")})
|
|
654
|
+
return {"changed_paths": changed}
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def apply_redaction(text: str, patterns: list[str], values: list[str]) -> tuple[str, bool]:
|
|
658
|
+
changed = False
|
|
659
|
+
for value in values:
|
|
660
|
+
if value and value in text:
|
|
661
|
+
text = text.replace(value, "[REDACTED]")
|
|
662
|
+
changed = True
|
|
663
|
+
for pattern in patterns:
|
|
664
|
+
text2 = re.sub(pattern, "[REDACTED]", text)
|
|
665
|
+
changed = changed or text2 != text
|
|
666
|
+
text = text2
|
|
667
|
+
return text, changed
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
def _atomic_write(path: Path, text: str, *, _writer: Any | None = None) -> None:
|
|
671
|
+
writer = _writer or (lambda tmp_path, value: tmp_path.write_text(value))
|
|
672
|
+
tmp_name = None
|
|
673
|
+
with tempfile.NamedTemporaryFile("w", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False) as tmp:
|
|
674
|
+
tmp_name = tmp.name
|
|
675
|
+
tmp_path = Path(tmp_name)
|
|
676
|
+
try:
|
|
677
|
+
writer(tmp_path, text)
|
|
678
|
+
os.replace(tmp_path, path)
|
|
679
|
+
finally:
|
|
680
|
+
if tmp_path.exists():
|
|
681
|
+
tmp_path.unlink()
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def write_json(path: Path, data: Any) -> None:
|
|
685
|
+
_atomic_write(path, json.dumps(data, indent=2, sort_keys=True) + "\n")
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def parse_jobs(argv: list[str]) -> int:
|
|
689
|
+
raw = value_after(argv, "--jobs")
|
|
690
|
+
if raw is None:
|
|
691
|
+
return min(os.cpu_count() or 1, 4)
|
|
692
|
+
try:
|
|
693
|
+
jobs = int(raw)
|
|
694
|
+
except ValueError:
|
|
695
|
+
raise EvalctlError("E_CASE_INVALID", f"--jobs must be a positive integer (got {raw})", "try: evalctl run code-review --jobs 4 --json", 1)
|
|
696
|
+
if jobs < 1:
|
|
697
|
+
raise EvalctlError("E_CASE_INVALID", f"--jobs must be at least 1 (got {jobs})", "try: evalctl run code-review --jobs 1 --json", 1)
|
|
698
|
+
return jobs
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def parse_positive_int_flag(argv: list[str], flag: str, default: int) -> int:
|
|
702
|
+
raw = value_after(argv, flag)
|
|
703
|
+
if raw is None:
|
|
704
|
+
return default
|
|
705
|
+
try:
|
|
706
|
+
value = int(raw)
|
|
707
|
+
except ValueError:
|
|
708
|
+
raise EvalctlError("E_CASE_INVALID", f"{flag} must be a positive integer (got {raw})", f"provide {flag} as a positive integer", 1)
|
|
709
|
+
if value < 1:
|
|
710
|
+
raise EvalctlError("E_CASE_INVALID", f"{flag} must be at least 1 (got {value})", f"provide {flag} as a positive integer", 1)
|
|
711
|
+
return value
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def version_tuple(value: str) -> tuple[int, ...]:
|
|
715
|
+
parts = []
|
|
716
|
+
for item in value.split("."):
|
|
717
|
+
match = re.match(r"(\d+)", item)
|
|
718
|
+
parts.append(int(match.group(1)) if match else 0)
|
|
719
|
+
return tuple(parts)
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
def spoolctl_binary() -> str:
|
|
723
|
+
path = shutil.which("spoolctl")
|
|
724
|
+
if not path:
|
|
725
|
+
raise EvalctlError("E_SPOOLCTL_UNAVAILABLE", "spoolctl is not available on PATH", "install spoolctl >= 0.4.1 or drop --queue spoolctl", 3)
|
|
726
|
+
return path
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def run_spoolctl_json(args: list[str], *, allow_exit_codes: set[int] | None = None) -> tuple[int, Any]:
|
|
730
|
+
allow_exit_codes = allow_exit_codes or {0}
|
|
731
|
+
try:
|
|
732
|
+
result = subprocess.run([spoolctl_binary(), *args], text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
733
|
+
except OSError as exc:
|
|
734
|
+
raise EvalctlError("E_SPOOLCTL_UNAVAILABLE", f"could not run spoolctl: {exc}", "install spoolctl >= 0.4.1 or drop --queue spoolctl", 3)
|
|
735
|
+
if result.returncode == 4:
|
|
736
|
+
raise EvalctlError("E_JOB_TRANSIENT", "spoolctl reported a transient job-system failure", "retry the queued run or resume it later", 4)
|
|
737
|
+
if result.returncode not in allow_exit_codes:
|
|
738
|
+
raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", f"spoolctl command failed: {result.stderr.strip() or result.stdout.strip()}", "upgrade spoolctl to >= 0.4.1 or drop --queue spoolctl", 3)
|
|
739
|
+
try:
|
|
740
|
+
payload = json.loads(result.stdout)
|
|
741
|
+
except json.JSONDecodeError as exc:
|
|
742
|
+
raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", f"spoolctl returned invalid JSON: {exc.msg}", "upgrade spoolctl to >= 0.4.1 or drop --queue spoolctl", 3)
|
|
743
|
+
return result.returncode, payload
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def spoolctl_json(args: list[str], *, allow_exit_codes: set[int] | None = None) -> dict[str, Any]:
|
|
747
|
+
returncode, payload = run_spoolctl_json(args, allow_exit_codes=allow_exit_codes)
|
|
748
|
+
if isinstance(payload, dict) and "ok" in payload:
|
|
749
|
+
if not payload.get("ok") and returncode != 6:
|
|
750
|
+
raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", "spoolctl returned an error envelope", "inspect spoolctl output or drop --queue spoolctl", 3)
|
|
751
|
+
data = payload.get("data")
|
|
752
|
+
return data if isinstance(data, dict) else {"value": data}
|
|
753
|
+
if isinstance(payload, dict):
|
|
754
|
+
return payload
|
|
755
|
+
raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", "spoolctl JSON output must be an object", "upgrade spoolctl to >= 0.4.1 or drop --queue spoolctl", 3)
|
|
756
|
+
|
|
757
|
+
|
|
758
|
+
def spoolctl_flag_names(flags: Any) -> set[str]:
|
|
759
|
+
names: set[str] = set()
|
|
760
|
+
if not isinstance(flags, list):
|
|
761
|
+
return names
|
|
762
|
+
for item in flags:
|
|
763
|
+
if isinstance(item, str):
|
|
764
|
+
names.add(item)
|
|
765
|
+
elif isinstance(item, dict) and isinstance(item.get("flag"), str):
|
|
766
|
+
names.add(item["flag"])
|
|
767
|
+
return names
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def probe_spoolctl() -> dict[str, Any]:
|
|
771
|
+
_, payload = run_spoolctl_json(["capabilities", "--json"])
|
|
772
|
+
if not isinstance(payload, dict):
|
|
773
|
+
raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", "spoolctl capabilities output must be a JSON object", "upgrade spoolctl to >= 0.4.1", 3)
|
|
774
|
+
is_envelope = "ok" in payload
|
|
775
|
+
if is_envelope:
|
|
776
|
+
if not payload.get("ok"):
|
|
777
|
+
raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", "spoolctl capabilities returned an error envelope", "inspect spoolctl output or upgrade spoolctl", 3)
|
|
778
|
+
data = payload.get("data")
|
|
779
|
+
if not isinstance(data, dict):
|
|
780
|
+
raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", "spoolctl capabilities data must be an object", "upgrade spoolctl to >= 0.4.1", 3)
|
|
781
|
+
else:
|
|
782
|
+
data = payload
|
|
783
|
+
envelope_version = str(payload.get("tool_version") or "") if is_envelope else ""
|
|
784
|
+
version = str(envelope_version or data.get("tool_version") or data.get("version") or "")
|
|
785
|
+
contract = str(data.get("contract_version") or "")
|
|
786
|
+
verbs = data.get("verbs", {})
|
|
787
|
+
add_flags = set()
|
|
788
|
+
if isinstance(verbs, dict):
|
|
789
|
+
add_info = verbs.get("add", {})
|
|
790
|
+
if isinstance(add_info, dict):
|
|
791
|
+
add_flags = spoolctl_flag_names(add_info.get("flags", []))
|
|
792
|
+
if version_tuple(version) < (0, 4, 1) or contract != "1" or not {"--cwd", "--env", "--max-crashes"} <= add_flags:
|
|
793
|
+
raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", "spoolctl is missing required evalctl queue capabilities", "upgrade spoolctl to >= 0.4.1", 3)
|
|
794
|
+
return {**data, "version": version} if envelope_version else data
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
def render_runner_arg(arg: str, env: dict[str, str]) -> str:
|
|
798
|
+
for key, value in env.items():
|
|
799
|
+
arg = arg.replace(f"${key}", value)
|
|
800
|
+
return arg
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def is_safe_id(value: str) -> bool:
|
|
804
|
+
return bool(value) and value not in {".", ".."} and ".." not in value and bool(SAFE_ID_RE.fullmatch(value))
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def validate_suite_name(name: str) -> None:
|
|
808
|
+
if not is_safe_id(name) or "/" in name or "\\" in name:
|
|
809
|
+
raise EvalctlError("E_CASE_INVALID", f"invalid suite name: {name}", "use a simple name with letters, numbers, dot, underscore, or dash", 1)
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def normalize_suite_rel(raw: str, *, field: str) -> str:
|
|
813
|
+
if not raw:
|
|
814
|
+
raise EvalctlError("E_CASE_INVALID", f"{field} must not be empty", f"provide {field}", 1)
|
|
815
|
+
if "\\" in raw:
|
|
816
|
+
raise EvalctlError("E_CASE_INVALID", f"{field} must use POSIX-style relative paths", "use forward slashes and stay under the suite directory", 1)
|
|
817
|
+
path = PurePosixPath(raw)
|
|
818
|
+
if path.is_absolute() or any(part == ".." for part in path.parts):
|
|
819
|
+
raise EvalctlError("E_CASE_INVALID", f"{field} must stay under the suite directory", "use a relative path without ..", 1)
|
|
820
|
+
normalized = path.as_posix()
|
|
821
|
+
if normalized in {"", "."}:
|
|
822
|
+
raise EvalctlError("E_CASE_INVALID", f"{field} must name a path", f"provide {field}", 1)
|
|
823
|
+
return normalized
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def decode_subprocess_output(value: str | bytes | None) -> str:
|
|
827
|
+
if value is None:
|
|
828
|
+
return ""
|
|
829
|
+
if isinstance(value, str):
|
|
830
|
+
return value
|
|
831
|
+
return value.decode("utf-8", "replace")
|
|
832
|
+
|
|
833
|
+
|
|
834
|
+
def score_summary(score: dict[str, Any]) -> dict[str, Any]:
|
|
835
|
+
out = {"scorer": score["scorer"], "ok": score["ok"], "score": score["score"]}
|
|
836
|
+
if "id" in score:
|
|
837
|
+
out["id"] = score["id"]
|
|
838
|
+
return out
|
|
839
|
+
|
|
840
|
+
|
|
841
|
+
def case_manifest_entry(case: dict[str, Any], status: str, scores: list[dict[str, Any]]) -> dict[str, Any]:
|
|
842
|
+
return {
|
|
843
|
+
"id": case["id"],
|
|
844
|
+
"input_hash": sha256_text(stable_json(case)),
|
|
845
|
+
"status": status,
|
|
846
|
+
"scores": [score_summary(s) for s in scores],
|
|
847
|
+
"artifacts": {
|
|
848
|
+
"input": f"cases/{case['id']}/input.json",
|
|
849
|
+
"output": f"cases/{case['id']}/output.txt",
|
|
850
|
+
"runner": f"cases/{case['id']}/runner.json",
|
|
851
|
+
"workspace_before": f"cases/{case['id']}/workspace-before.json",
|
|
852
|
+
"workspace_after": f"cases/{case['id']}/workspace-after.json",
|
|
853
|
+
"diff_manifest": f"cases/{case['id']}/workspace-diff.json",
|
|
854
|
+
"diff": f"cases/{case['id']}/workspace.diff",
|
|
855
|
+
"score": f"cases/{case['id']}/score.json",
|
|
856
|
+
},
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
TERMINAL_CASE_STATUSES = {"pass", "fail", "error", "canceled"}
|
|
861
|
+
|
|
862
|
+
|
|
863
|
+
def write_terminal_marker(case_dir: Path, case_id: str, status: str) -> None:
|
|
864
|
+
write_json(case_dir / "state.json", {"id": case_id, "status": status, "completed_ts": now_iso()})
|
|
865
|
+
|
|
866
|
+
|
|
867
|
+
def is_terminal_marker(path: Path) -> bool:
|
|
868
|
+
try:
|
|
869
|
+
marker = read_json(path)
|
|
870
|
+
except Exception:
|
|
871
|
+
return False
|
|
872
|
+
return marker.get("status") in TERMINAL_CASE_STATUSES
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def terminal_marker_count(run_dir: Path) -> int:
|
|
876
|
+
cases_dir = run_dir / "cases"
|
|
877
|
+
if not cases_dir.exists():
|
|
878
|
+
return 0
|
|
879
|
+
return sum(1 for marker in cases_dir.glob("*/state.json") if is_terminal_marker(marker))
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def case_entry_from_artifacts(run_dir: Path, case_id: str) -> dict[str, Any]:
|
|
883
|
+
case_dir = run_dir / "cases" / case_id
|
|
884
|
+
case = read_json(case_dir / "input.json")
|
|
885
|
+
score_doc = read_json(case_dir / "score.json")
|
|
886
|
+
return case_manifest_entry(case, score_doc["status"], score_doc["scores"])
|
|
887
|
+
|
|
888
|
+
|
|
889
|
+
def reservation_path(run_dir: Path) -> Path:
|
|
890
|
+
return run_dir / ".reservation.json"
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def parse_iso_timestamp(value: str) -> datetime:
|
|
894
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
895
|
+
|
|
896
|
+
|
|
897
|
+
def read_reservation(run_dir: Path) -> dict[str, Any] | None:
|
|
898
|
+
path = reservation_path(run_dir)
|
|
899
|
+
if not path.exists():
|
|
900
|
+
return None
|
|
901
|
+
try:
|
|
902
|
+
data = read_json(path)
|
|
903
|
+
except Exception:
|
|
904
|
+
return None
|
|
905
|
+
return data if isinstance(data, dict) else None
|
|
906
|
+
|
|
907
|
+
|
|
908
|
+
def reservation_is_live(record: dict[str, Any], *, now: datetime | None = None) -> bool:
|
|
909
|
+
try:
|
|
910
|
+
ttl_seconds = int(record["ttl_seconds"])
|
|
911
|
+
heartbeat_ts = parse_iso_timestamp(str(record["heartbeat_ts"]))
|
|
912
|
+
except Exception:
|
|
913
|
+
return False
|
|
914
|
+
now = now or datetime.now(timezone.utc)
|
|
915
|
+
return (now - heartbeat_ts).total_seconds() < ttl_seconds
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def write_reservation(run_dir: Path, run_id: str, ttl_seconds: int) -> None:
|
|
919
|
+
write_json(reservation_path(run_dir), {
|
|
920
|
+
"run_id": run_id,
|
|
921
|
+
"pid": os.getpid(),
|
|
922
|
+
"host": socket.gethostname(),
|
|
923
|
+
"started_ts": now_iso(),
|
|
924
|
+
"heartbeat_ts": now_iso(),
|
|
925
|
+
"ttl_seconds": ttl_seconds,
|
|
926
|
+
})
|
|
927
|
+
|
|
928
|
+
|
|
929
|
+
def clear_reservation(run_dir: Path) -> None:
|
|
930
|
+
try:
|
|
931
|
+
reservation_path(run_dir).unlink()
|
|
932
|
+
except FileNotFoundError:
|
|
933
|
+
pass
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
class ReservationHeartbeat:
|
|
937
|
+
def __init__(self, run_dir: Path, run_id: str, ttl_seconds: int) -> None:
|
|
938
|
+
self.run_dir = run_dir
|
|
939
|
+
self.run_id = run_id
|
|
940
|
+
self.ttl_seconds = ttl_seconds
|
|
941
|
+
self.stop = threading.Event()
|
|
942
|
+
self.thread: threading.Thread | None = None
|
|
943
|
+
|
|
944
|
+
def __enter__(self) -> "ReservationHeartbeat":
|
|
945
|
+
write_reservation(self.run_dir, self.run_id, self.ttl_seconds)
|
|
946
|
+
interval = max(0.1, min(5.0, self.ttl_seconds / 4))
|
|
947
|
+
self.thread = threading.Thread(target=self._run, args=(interval,), daemon=True)
|
|
948
|
+
self.thread.start()
|
|
949
|
+
return self
|
|
950
|
+
|
|
951
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
952
|
+
self.stop.set()
|
|
953
|
+
if self.thread:
|
|
954
|
+
self.thread.join(timeout=1)
|
|
955
|
+
|
|
956
|
+
def _run(self, interval: float) -> None:
|
|
957
|
+
while not self.stop.wait(interval):
|
|
958
|
+
try:
|
|
959
|
+
record = read_reservation(self.run_dir) or {}
|
|
960
|
+
record.update({
|
|
961
|
+
"run_id": self.run_id,
|
|
962
|
+
"pid": os.getpid(),
|
|
963
|
+
"host": socket.gethostname(),
|
|
964
|
+
"heartbeat_ts": now_iso(),
|
|
965
|
+
"ttl_seconds": self.ttl_seconds,
|
|
966
|
+
})
|
|
967
|
+
record.setdefault("started_ts", now_iso())
|
|
968
|
+
write_json(reservation_path(self.run_dir), record)
|
|
969
|
+
except Exception:
|
|
970
|
+
pass
|
|
971
|
+
|
|
972
|
+
|
|
973
|
+
def split_completed_and_pending(run_dir: Path, cases: list[dict[str, Any]]) -> tuple[dict[str, dict[str, Any]], list[dict[str, Any]]]:
|
|
974
|
+
completed: dict[str, dict[str, Any]] = {}
|
|
975
|
+
pending: list[dict[str, Any]] = []
|
|
976
|
+
for case in sorted(cases, key=lambda c: c["id"]):
|
|
977
|
+
marker_path = run_dir / "cases" / case["id"] / "state.json"
|
|
978
|
+
if is_terminal_marker(marker_path):
|
|
979
|
+
try:
|
|
980
|
+
completed[case["id"]] = case_entry_from_artifacts(run_dir, case["id"])
|
|
981
|
+
continue
|
|
982
|
+
except Exception:
|
|
983
|
+
pass
|
|
984
|
+
pending.append(case)
|
|
985
|
+
return completed, pending
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def clean_pending_case_dirs(run_dir: Path, pending_cases: list[dict[str, Any]]) -> None:
|
|
989
|
+
for case in pending_cases:
|
|
990
|
+
shutil.rmtree(run_dir / "cases" / case["id"], ignore_errors=True)
|
|
991
|
+
|
|
992
|
+
|
|
993
|
+
def synthesize_case_error(suite_dir: Path, suite: dict[str, Any], case: dict[str, Any], run_dir: Path, exc: BaseException) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
994
|
+
warnings: list[dict[str, Any]] = []
|
|
995
|
+
case_dir = run_dir / "cases" / case["id"]
|
|
996
|
+
case_dir.mkdir(parents=True, exist_ok=True)
|
|
997
|
+
workspace = case_dir / "workspace"
|
|
998
|
+
if not workspace.exists():
|
|
999
|
+
shutil.copytree(suite_dir / case["workspace"], workspace)
|
|
1000
|
+
input_json = case_dir / "input.json"
|
|
1001
|
+
task_txt = case_dir / "task.txt"
|
|
1002
|
+
output_file = case_dir / "output.txt"
|
|
1003
|
+
write_json(input_json, case)
|
|
1004
|
+
task_txt.write_text(case["task"])
|
|
1005
|
+
if case.get("diff"):
|
|
1006
|
+
diff_src = suite_dir / case["diff"]
|
|
1007
|
+
diff_file = case_dir / "input.diff"
|
|
1008
|
+
if not diff_file.exists():
|
|
1009
|
+
shutil.copyfile(diff_src, diff_file)
|
|
1010
|
+
before, mw = manifest(workspace)
|
|
1011
|
+
warnings.extend(mw)
|
|
1012
|
+
after, mw = manifest(workspace)
|
|
1013
|
+
warnings.extend(mw)
|
|
1014
|
+
diff = diff_manifests(before, after)
|
|
1015
|
+
output_file.write_text("")
|
|
1016
|
+
(case_dir / "runner.stdout.txt").write_text("")
|
|
1017
|
+
(case_dir / "runner.stderr.txt").write_text(str(exc))
|
|
1018
|
+
runner_json = {"exit_code": None, "signal": None, "timed_out": False, "spawn_failed": True, "error_code": "E_RUNNER_FAILED", "duration_ms": 0,
|
|
1019
|
+
"stdout_truncated": False, "stderr_truncated": False, "output_truncated": False,
|
|
1020
|
+
"stdout_redacted": False, "stderr_redacted": False, "output_redacted": False}
|
|
1021
|
+
write_json(case_dir / "runner.json", runner_json)
|
|
1022
|
+
write_json(case_dir / "workspace-before.json", before)
|
|
1023
|
+
write_json(case_dir / "workspace-after.json", after)
|
|
1024
|
+
write_json(case_dir / "workspace-diff.json", diff)
|
|
1025
|
+
(case_dir / "workspace.diff").write_text(render_text_diff(diff))
|
|
1026
|
+
scores = score_case(case, "", runner_json, after, diff, suite.get("scorers", []), case_dir=case_dir, execute=False, suite=suite)
|
|
1027
|
+
score_doc = {"case_id": case["id"], "status": "error", "ok": False, "scores": scores}
|
|
1028
|
+
write_json(case_dir / "score.json", score_doc)
|
|
1029
|
+
write_terminal_marker(case_dir, case["id"], "error")
|
|
1030
|
+
return case_manifest_entry(case, "error", scores), warnings
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
def prepare_case_workspace(suite_dir: Path, suite: dict[str, Any], case: dict[str, Any], run_dir: Path,
|
|
1034
|
+
timeout_override: int | None) -> dict[str, Any]:
|
|
1035
|
+
warnings: list[dict[str, Any]] = []
|
|
1036
|
+
case_dir = run_dir / "cases" / case["id"]
|
|
1037
|
+
case_dir.mkdir(parents=True, exist_ok=True)
|
|
1038
|
+
workspace = case_dir / "workspace"
|
|
1039
|
+
shutil.copytree(suite_dir / case["workspace"], workspace)
|
|
1040
|
+
input_json = case_dir / "input.json"
|
|
1041
|
+
task_txt = case_dir / "task.txt"
|
|
1042
|
+
output_file = case_dir / "output.txt"
|
|
1043
|
+
write_json(input_json, case)
|
|
1044
|
+
task_txt.write_text(case["task"])
|
|
1045
|
+
diff_src = suite_dir / case["diff"] if case.get("diff") else None
|
|
1046
|
+
diff_file = case_dir / "input.diff"
|
|
1047
|
+
if diff_src:
|
|
1048
|
+
shutil.copyfile(diff_src, diff_file)
|
|
1049
|
+
before, mw = manifest(workspace)
|
|
1050
|
+
warnings.extend(mw)
|
|
1051
|
+
write_json(case_dir / "workspace-before.json", before)
|
|
1052
|
+
runner = suite["runner"]
|
|
1053
|
+
timeout = int(timeout_override or runner.get("timeout_seconds") or 300)
|
|
1054
|
+
max_bytes = int(runner.get("max_output_bytes") or 5 * 1024 * 1024)
|
|
1055
|
+
env = {k: os.environ[k] for k in runner.get("env_allowlist", []) if k in os.environ}
|
|
1056
|
+
eval_env = {
|
|
1057
|
+
"EVALCTL_CASE_FILE": str(input_json.resolve()),
|
|
1058
|
+
"EVALCTL_WORKSPACE": str(workspace.resolve()),
|
|
1059
|
+
"EVALCTL_OUTPUT_FILE": str(output_file.resolve()),
|
|
1060
|
+
"EVALCTL_TASK_FILE": str(task_txt.resolve()),
|
|
1061
|
+
"EVALCTL_DIFF_FILE": str(diff_file.resolve() if diff_src else ""),
|
|
1062
|
+
}
|
|
1063
|
+
env.update(eval_env)
|
|
1064
|
+
env_values = [os.environ.get(k, "") for k in runner.get("redact_env_values", [])]
|
|
1065
|
+
patterns = runner.get("redact_patterns", [])
|
|
1066
|
+
cwd = Path(render_runner_arg(runner.get("cwd") or str(workspace), eval_env))
|
|
1067
|
+
return {
|
|
1068
|
+
"suite": suite,
|
|
1069
|
+
"case": case,
|
|
1070
|
+
"case_dir": case_dir,
|
|
1071
|
+
"workspace": workspace,
|
|
1072
|
+
"output_file": output_file,
|
|
1073
|
+
"task_txt": task_txt,
|
|
1074
|
+
"diff_file": diff_file,
|
|
1075
|
+
"diff_src": diff_src,
|
|
1076
|
+
"before": before,
|
|
1077
|
+
"runner": runner,
|
|
1078
|
+
"timeout": timeout,
|
|
1079
|
+
"max_bytes": max_bytes,
|
|
1080
|
+
"env": env,
|
|
1081
|
+
"eval_env": eval_env,
|
|
1082
|
+
"env_values": env_values,
|
|
1083
|
+
"patterns": patterns,
|
|
1084
|
+
"cwd": cwd,
|
|
1085
|
+
"warnings": warnings,
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
|
|
1089
|
+
def execute_runner_in_process(prepared: dict[str, Any]) -> dict[str, Any]:
|
|
1090
|
+
runner = prepared["runner"]
|
|
1091
|
+
case = prepared["case"]
|
|
1092
|
+
eval_env = prepared["eval_env"]
|
|
1093
|
+
started = time.time()
|
|
1094
|
+
timed_out = False
|
|
1095
|
+
spawn_failed = False
|
|
1096
|
+
exit_code: int | None = None
|
|
1097
|
+
signal_value: int | None = None
|
|
1098
|
+
stdout = ""
|
|
1099
|
+
stderr = ""
|
|
1100
|
+
proc: subprocess.Popen[str] | None = None
|
|
1101
|
+
try:
|
|
1102
|
+
stdin = subprocess.PIPE if runner.get("stdin") == "task" else None
|
|
1103
|
+
input_text = case["task"] if runner.get("stdin") == "task" else None
|
|
1104
|
+
if runner.get("shell", False):
|
|
1105
|
+
cmd: str | list[str] = render_runner_arg(runner["command"], eval_env)
|
|
1106
|
+
proc = subprocess.Popen(cmd, shell=True, cwd=prepared["cwd"], env=prepared["env"], text=True, stdin=stdin,
|
|
1107
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
|
|
1108
|
+
else:
|
|
1109
|
+
argv = [render_runner_arg(str(a), eval_env) for a in runner["argv"]]
|
|
1110
|
+
proc = subprocess.Popen(argv, shell=False, cwd=prepared["cwd"], env=prepared["env"], text=True, stdin=stdin,
|
|
1111
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
|
|
1112
|
+
stdout, stderr = proc.communicate(input=input_text, timeout=prepared["timeout"])
|
|
1113
|
+
exit_code = proc.returncode
|
|
1114
|
+
if proc.returncode < 0:
|
|
1115
|
+
signal_value = -proc.returncode
|
|
1116
|
+
except subprocess.TimeoutExpired as exc:
|
|
1117
|
+
timed_out = True
|
|
1118
|
+
exit_code = None
|
|
1119
|
+
if proc is not None:
|
|
1120
|
+
try:
|
|
1121
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
1122
|
+
except ProcessLookupError:
|
|
1123
|
+
pass
|
|
1124
|
+
drained_stdout, drained_stderr = proc.communicate()
|
|
1125
|
+
else:
|
|
1126
|
+
drained_stdout, drained_stderr = "", ""
|
|
1127
|
+
stdout = decode_subprocess_output(exc.stdout) + decode_subprocess_output(drained_stdout)
|
|
1128
|
+
stderr = decode_subprocess_output(exc.stderr) + decode_subprocess_output(drained_stderr)
|
|
1129
|
+
except OSError as exc:
|
|
1130
|
+
spawn_failed = True
|
|
1131
|
+
exit_code = None
|
|
1132
|
+
stderr = str(exc)
|
|
1133
|
+
duration_ms = int((time.time() - started) * 1000)
|
|
1134
|
+
return {
|
|
1135
|
+
"stdout": stdout,
|
|
1136
|
+
"stderr": stderr,
|
|
1137
|
+
"timed_out": timed_out,
|
|
1138
|
+
"spawn_failed": spawn_failed,
|
|
1139
|
+
"exit_code": exit_code,
|
|
1140
|
+
"signal": signal_value,
|
|
1141
|
+
"duration_ms": duration_ms,
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
|
|
1145
|
+
def normalize_runner_artifacts(prepared: dict[str, Any], runner_result: dict[str, Any]) -> tuple[str, dict[str, Any], list[dict[str, Any]]]:
|
|
1146
|
+
warnings: list[dict[str, Any]] = []
|
|
1147
|
+
case_dir = prepared["case_dir"]
|
|
1148
|
+
output_file = prepared["output_file"]
|
|
1149
|
+
max_bytes = prepared["max_bytes"]
|
|
1150
|
+
stdout = runner_result["stdout"]
|
|
1151
|
+
stderr = runner_result["stderr"]
|
|
1152
|
+
trunc_stdout = len(stdout.encode()) > max_bytes
|
|
1153
|
+
trunc_stderr = len(stderr.encode()) > max_bytes
|
|
1154
|
+
stdout = stdout.encode()[:max_bytes].decode("utf-8", "replace")
|
|
1155
|
+
stderr = stderr.encode()[:max_bytes].decode("utf-8", "replace")
|
|
1156
|
+
stdout, red_stdout = apply_redaction(stdout, prepared["patterns"], prepared["env_values"])
|
|
1157
|
+
stderr, red_stderr = apply_redaction(stderr, prepared["patterns"], prepared["env_values"])
|
|
1158
|
+
output_truncated = False
|
|
1159
|
+
if output_file.exists():
|
|
1160
|
+
output_bytes = output_file.read_bytes()
|
|
1161
|
+
output_truncated = len(output_bytes) > max_bytes
|
|
1162
|
+
output_text = output_bytes[:max_bytes].decode("utf-8", "replace")
|
|
1163
|
+
else:
|
|
1164
|
+
output_text = stdout
|
|
1165
|
+
output_text, red_output = apply_redaction(output_text, prepared["patterns"], prepared["env_values"])
|
|
1166
|
+
output_file.write_text(output_text)
|
|
1167
|
+
(case_dir / "runner.stdout.txt").write_text(stdout)
|
|
1168
|
+
(case_dir / "runner.stderr.txt").write_text(stderr)
|
|
1169
|
+
error_code = "E_RUNNER_TIMEOUT" if runner_result["timed_out"] else "E_RUNNER_FAILED" if runner_result["spawn_failed"] else None
|
|
1170
|
+
runner_json = {"exit_code": runner_result["exit_code"], "signal": runner_result["signal"], "timed_out": runner_result["timed_out"], "spawn_failed": runner_result["spawn_failed"], "error_code": error_code, "duration_ms": runner_result["duration_ms"],
|
|
1171
|
+
"stdout_truncated": trunc_stdout, "stderr_truncated": trunc_stderr, "output_truncated": output_truncated,
|
|
1172
|
+
"stdout_redacted": red_stdout, "stderr_redacted": red_stderr, "output_redacted": red_output}
|
|
1173
|
+
if trunc_stdout or trunc_stderr or output_truncated:
|
|
1174
|
+
warnings.append({"code": "W_OUTPUT_TRUNCATED", "message": "runner output exceeded max_output_bytes"})
|
|
1175
|
+
write_json(case_dir / "runner.json", runner_json)
|
|
1176
|
+
return output_text, runner_json, warnings
|
|
1177
|
+
|
|
1178
|
+
|
|
1179
|
+
def capture_workspace_after_and_score(prepared: dict[str, Any], output_text: str, runner_json: dict[str, Any]) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
1180
|
+
warnings: list[dict[str, Any]] = []
|
|
1181
|
+
case = prepared["case"]
|
|
1182
|
+
suite = prepared["suite"]
|
|
1183
|
+
case_dir = prepared["case_dir"]
|
|
1184
|
+
after, mw = manifest(prepared["workspace"])
|
|
1185
|
+
warnings.extend(mw)
|
|
1186
|
+
diff = diff_manifests(prepared["before"], after)
|
|
1187
|
+
write_json(case_dir / "workspace-after.json", after)
|
|
1188
|
+
write_json(case_dir / "workspace-diff.json", diff)
|
|
1189
|
+
(case_dir / "workspace.diff").write_text(render_text_diff(diff))
|
|
1190
|
+
status = "error" if runner_json["timed_out"] or runner_json["spawn_failed"] else "pass"
|
|
1191
|
+
scores = score_case(case, output_text, runner_json, after, diff, suite.get("scorers", []), case_dir=case_dir, execute=True, suite=suite, eval_env=prepared["eval_env"])
|
|
1192
|
+
if any(s.get("error") for s in scores):
|
|
1193
|
+
status = "error"
|
|
1194
|
+
elif status != "error" and not all(s["ok"] for s in scores if s.get("required", True)):
|
|
1195
|
+
status = "fail"
|
|
1196
|
+
score_doc = {"case_id": case["id"], "status": status, "ok": status == "pass", "scores": scores}
|
|
1197
|
+
write_json(case_dir / "score.json", score_doc)
|
|
1198
|
+
return case_manifest_entry(case, status, scores), warnings
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def spoolctl_runner_command(prepared: dict[str, Any]) -> list[str]:
|
|
1202
|
+
runner = prepared["runner"]
|
|
1203
|
+
eval_env = prepared["eval_env"]
|
|
1204
|
+
if runner.get("shell", False):
|
|
1205
|
+
command = render_runner_arg(runner["command"], eval_env)
|
|
1206
|
+
if runner.get("stdin") == "task":
|
|
1207
|
+
command = f"exec {command} < \"$EVALCTL_TASK_FILE\""
|
|
1208
|
+
return ["sh", "-c", command]
|
|
1209
|
+
argv = [render_runner_arg(str(a), eval_env) for a in runner["argv"]]
|
|
1210
|
+
if runner.get("stdin") != "task":
|
|
1211
|
+
return argv
|
|
1212
|
+
wrapper = (
|
|
1213
|
+
"import os, subprocess, sys\n"
|
|
1214
|
+
"with open(os.environ['EVALCTL_TASK_FILE']) as stdin:\n"
|
|
1215
|
+
" raise SystemExit(subprocess.call(sys.argv[1:], stdin=stdin))\n"
|
|
1216
|
+
)
|
|
1217
|
+
return [sys.executable, "-c", wrapper, *argv]
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def spoolctl_add_case(db_path: Path, run_id: str, prepared: dict[str, Any]) -> str:
|
|
1221
|
+
case_id = prepared["case"]["id"]
|
|
1222
|
+
args = [
|
|
1223
|
+
"add", "--db", str(db_path), "--json", "--queue", "evalctl",
|
|
1224
|
+
"--key", f"{run_id}:{case_id}",
|
|
1225
|
+
"--tag", f"evalctl_run={run_id}",
|
|
1226
|
+
"--tag", f"evalctl_case={case_id}",
|
|
1227
|
+
"--cwd", str(prepared["cwd"]),
|
|
1228
|
+
"--timeout", str(prepared["timeout"]),
|
|
1229
|
+
"--max-retries", "0",
|
|
1230
|
+
"--max-crashes", "0",
|
|
1231
|
+
]
|
|
1232
|
+
for key, value in sorted(prepared["eval_env"].items()):
|
|
1233
|
+
args.extend(["--env", f"{key}={value}"])
|
|
1234
|
+
args.append("--")
|
|
1235
|
+
args.extend(spoolctl_runner_command(prepared))
|
|
1236
|
+
data = spoolctl_json(args)
|
|
1237
|
+
job_id = str(data.get("job_id") or data.get("id") or "")
|
|
1238
|
+
if not job_id:
|
|
1239
|
+
raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", "spoolctl add did not return data.job_id", "upgrade spoolctl to >= 0.4.1", 3)
|
|
1240
|
+
write_json(prepared["case_dir"] / "job.json", {"job_id": job_id, "state": data.get("state", "queued")})
|
|
1241
|
+
return job_id
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
def latest_terminal_attempt(job_detail: dict[str, Any]) -> dict[str, Any]:
|
|
1245
|
+
attempts = job_detail.get("attempts")
|
|
1246
|
+
if not isinstance(attempts, list) or not attempts:
|
|
1247
|
+
raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", "spoolctl show did not include attempts", "upgrade spoolctl to >= 0.4.1", 3)
|
|
1248
|
+
return attempts[-1]
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
def runner_result_from_spoolctl_attempt(attempt: dict[str, Any], max_bytes: int) -> dict[str, Any]:
|
|
1252
|
+
stdout_path = attempt.get("stdout_path")
|
|
1253
|
+
stderr_path = attempt.get("stderr_path")
|
|
1254
|
+
stdout = Path(stdout_path).read_text(errors="replace") if stdout_path else ""
|
|
1255
|
+
stderr = Path(stderr_path).read_text(errors="replace") if stderr_path else str(attempt.get("error") or "")
|
|
1256
|
+
state = attempt.get("state")
|
|
1257
|
+
exit_code = attempt.get("exit_code")
|
|
1258
|
+
timed_out = state == "timed_out" and exit_code is None
|
|
1259
|
+
spawn_failed = exit_code is None and (state in {"failed", "abandoned", "canceled"} or str(attempt.get("error") or "").startswith("spawn failed:"))
|
|
1260
|
+
return {
|
|
1261
|
+
"stdout": stdout,
|
|
1262
|
+
"stderr": stderr,
|
|
1263
|
+
"timed_out": timed_out,
|
|
1264
|
+
"spawn_failed": spawn_failed,
|
|
1265
|
+
"exit_code": exit_code,
|
|
1266
|
+
"signal": None,
|
|
1267
|
+
"duration_ms": int(attempt.get("duration_ms") or 0),
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
def execute_spoolctl_pending_cases(suite_dir: Path, suite: dict[str, Any], all_cases: list[dict[str, Any]], pending_cases: list[dict[str, Any]],
|
|
1272
|
+
completed_entries: dict[str, dict[str, Any]], run_dir: Path, run_id: str,
|
|
1273
|
+
jobs: int, timeout_override: int | None, slots: int) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
1274
|
+
db_path = run_dir / ".spoolctl.db"
|
|
1275
|
+
clean_pending_case_dirs(run_dir, pending_cases)
|
|
1276
|
+
prepared_by_case: dict[str, dict[str, Any]] = {}
|
|
1277
|
+
job_ids: list[str] = []
|
|
1278
|
+
for case in sorted(pending_cases, key=lambda c: c["id"]):
|
|
1279
|
+
prepared = prepare_case_workspace(suite_dir, suite, case, run_dir, timeout_override)
|
|
1280
|
+
prepared_by_case[case["id"]] = prepared
|
|
1281
|
+
job_ids.append(spoolctl_add_case(db_path, run_id, prepared))
|
|
1282
|
+
worker = subprocess.Popen([spoolctl_binary(), "work", "--db", str(db_path), "--queue", "evalctl", "--slots", str(slots), "--drain"],
|
|
1283
|
+
text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
|
1284
|
+
wait_data = spoolctl_json(["wait", "--db", str(db_path), "--json", *job_ids], allow_exit_codes={0, 6})
|
|
1285
|
+
worker_stdout, worker_stderr = worker.communicate(timeout=60)
|
|
1286
|
+
if worker.returncode == 4:
|
|
1287
|
+
raise EvalctlError("E_JOB_TRANSIENT", "spoolctl worker reported a transient failure", "retry the queued run or resume it later", 4)
|
|
1288
|
+
if worker.returncode not in {0, None}:
|
|
1289
|
+
raise EvalctlError("E_SPOOLCTL_INCOMPATIBLE", f"spoolctl worker failed: {worker_stderr or worker_stdout}", "inspect spoolctl worker output", 3)
|
|
1290
|
+
warnings: list[dict[str, Any]] = []
|
|
1291
|
+
entries_by_id = dict(completed_entries)
|
|
1292
|
+
for case in sorted(pending_cases, key=lambda c: c["id"]):
|
|
1293
|
+
prepared = prepared_by_case[case["id"]]
|
|
1294
|
+
job_doc = read_json(prepared["case_dir"] / "job.json")
|
|
1295
|
+
detail = spoolctl_json(["show", "--db", str(db_path), "--json", job_doc["job_id"]])
|
|
1296
|
+
attempt = latest_terminal_attempt(detail)
|
|
1297
|
+
runner_result = runner_result_from_spoolctl_attempt(attempt, prepared["max_bytes"])
|
|
1298
|
+
output_text, runner_json, normalize_warnings = normalize_runner_artifacts(prepared, runner_result)
|
|
1299
|
+
warnings.extend(prepared["warnings"])
|
|
1300
|
+
warnings.extend(normalize_warnings)
|
|
1301
|
+
entry, score_warnings = capture_workspace_after_and_score(prepared, output_text, runner_json)
|
|
1302
|
+
warnings.extend(score_warnings)
|
|
1303
|
+
write_terminal_marker(prepared["case_dir"], case["id"], entry["status"])
|
|
1304
|
+
write_json(prepared["case_dir"] / "job.json", {"job_id": job_doc["job_id"], "state": detail.get("state") or attempt.get("state")})
|
|
1305
|
+
entries_by_id[case["id"]] = entry
|
|
1306
|
+
case_entries = [entries_by_id[case["id"]] for case in sorted(all_cases, key=lambda c: c["id"])]
|
|
1307
|
+
return case_entries, warnings
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def run_case(suite_dir: Path, suite: dict[str, Any], case: dict[str, Any], run_dir: Path, timeout_override: int | None) -> tuple[dict[str, Any], list[dict[str, Any]]]:
|
|
1311
|
+
prepared = prepare_case_workspace(suite_dir, suite, case, run_dir, timeout_override)
|
|
1312
|
+
all_warnings = list(prepared["warnings"])
|
|
1313
|
+
runner_result = execute_runner_in_process(prepared)
|
|
1314
|
+
output_text, runner_json, warnings = normalize_runner_artifacts(prepared, runner_result)
|
|
1315
|
+
all_warnings.extend(warnings)
|
|
1316
|
+
entry, warnings = capture_workspace_after_and_score(prepared, output_text, runner_json)
|
|
1317
|
+
all_warnings.extend(warnings)
|
|
1318
|
+
write_terminal_marker(prepared["case_dir"], case["id"], entry["status"])
|
|
1319
|
+
return entry, all_warnings
|
|
1320
|
+
|
|
1321
|
+
|
|
1322
|
+
def render_text_diff(diff: dict[str, Any]) -> str:
|
|
1323
|
+
return "\n".join(f"{p['status']}\t{p['path']}" for p in diff["changed_paths"]) + ("\n" if diff["changed_paths"] else "")
|
|
1324
|
+
|
|
1325
|
+
|
|
1326
|
+
def score_case(case: dict[str, Any], output_text: str, runner_json: dict[str, Any], after: dict[str, Any], diff: dict[str, Any],
|
|
1327
|
+
scorers: list[dict[str, Any]], *, case_dir: Path | None = None, execute: bool = False,
|
|
1328
|
+
suite: dict[str, Any] | None = None, eval_env: dict[str, str] | None = None) -> list[dict[str, Any]]:
|
|
1329
|
+
expect = case.get("expect", {})
|
|
1330
|
+
configured = scorers or [{"name": n, "required": True} for n in BUILTIN_SCORERS]
|
|
1331
|
+
out: list[dict[str, Any]] = []
|
|
1332
|
+
for scorer in configured:
|
|
1333
|
+
name = scorer["name"]
|
|
1334
|
+
required = bool(scorer.get("required", True))
|
|
1335
|
+
try:
|
|
1336
|
+
result = run_scorer(scorer, expect, output_text, runner_json, after, diff, required=required,
|
|
1337
|
+
case_dir=case_dir, execute=execute, suite=suite or {}, eval_env=eval_env or {})
|
|
1338
|
+
if result is None:
|
|
1339
|
+
continue
|
|
1340
|
+
result["required"] = required
|
|
1341
|
+
out.append(result)
|
|
1342
|
+
except Exception as exc:
|
|
1343
|
+
result = {"scorer": name, "ok": False, "score": 0.0, "label": "error", "required": required, "findings": [{"why": str(exc)}], "error": True}
|
|
1344
|
+
if scorer.get("id"):
|
|
1345
|
+
result["id"] = scorer["id"]
|
|
1346
|
+
if name == "command":
|
|
1347
|
+
result["error_code"] = "E_SCORER_CASE_FAILED"
|
|
1348
|
+
out.append(result)
|
|
1349
|
+
return out
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
def scorer_failure(scorer: dict[str, Any], required: bool, why: str) -> dict[str, Any]:
|
|
1353
|
+
result = {
|
|
1354
|
+
"scorer": scorer.get("name", "command"),
|
|
1355
|
+
"ok": False,
|
|
1356
|
+
"score": 0.0,
|
|
1357
|
+
"label": "error",
|
|
1358
|
+
"required": required,
|
|
1359
|
+
"findings": [{"why": why}],
|
|
1360
|
+
"error": True,
|
|
1361
|
+
"error_code": "E_SCORER_CASE_FAILED",
|
|
1362
|
+
}
|
|
1363
|
+
if scorer.get("id"):
|
|
1364
|
+
result["id"] = scorer["id"]
|
|
1365
|
+
return result
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def normalize_command_verdict(raw: Any, scorer: dict[str, Any], required: bool) -> dict[str, Any]:
|
|
1369
|
+
if not isinstance(raw, dict):
|
|
1370
|
+
return scorer_failure(scorer, required, "command scorer stdout must be one JSON object")
|
|
1371
|
+
if not isinstance(raw.get("ok"), bool):
|
|
1372
|
+
return scorer_failure(scorer, required, "command scorer verdict requires boolean ok")
|
|
1373
|
+
score = raw.get("score")
|
|
1374
|
+
if not isinstance(score, (int, float)) or isinstance(score, bool) or not math.isfinite(float(score)) or not 0.0 <= float(score) <= 1.0:
|
|
1375
|
+
return scorer_failure(scorer, required, "command scorer verdict requires finite numeric score in 0.0..1.0")
|
|
1376
|
+
label = raw.get("label")
|
|
1377
|
+
if label is None or label == "":
|
|
1378
|
+
label = "pass" if raw["ok"] else "fail"
|
|
1379
|
+
if not isinstance(label, str):
|
|
1380
|
+
return scorer_failure(scorer, required, "command scorer verdict label must be a string")
|
|
1381
|
+
findings_raw = raw.get("findings")
|
|
1382
|
+
if not isinstance(findings_raw, list):
|
|
1383
|
+
return scorer_failure(scorer, required, "command scorer verdict requires findings list")
|
|
1384
|
+
findings: list[dict[str, Any]] = []
|
|
1385
|
+
for item in findings_raw:
|
|
1386
|
+
if isinstance(item, str):
|
|
1387
|
+
findings.append({"why": item})
|
|
1388
|
+
elif isinstance(item, dict):
|
|
1389
|
+
findings.append(item)
|
|
1390
|
+
else:
|
|
1391
|
+
return scorer_failure(scorer, required, "command scorer findings must be objects or strings")
|
|
1392
|
+
result = {
|
|
1393
|
+
"scorer": "command",
|
|
1394
|
+
"id": scorer["id"],
|
|
1395
|
+
"ok": raw["ok"],
|
|
1396
|
+
"score": float(score),
|
|
1397
|
+
"label": label,
|
|
1398
|
+
"required": required,
|
|
1399
|
+
"findings": findings,
|
|
1400
|
+
}
|
|
1401
|
+
if bool(raw.get("error")):
|
|
1402
|
+
result.update({"ok": False, "score": 0.0, "label": "error", "error": True, "error_code": "E_SCORER_CASE_FAILED"})
|
|
1403
|
+
return result
|
|
1404
|
+
|
|
1405
|
+
|
|
1406
|
+
def run_command_scorer(scorer: dict[str, Any], required: bool, case_dir: Path | None, execute: bool,
|
|
1407
|
+
suite: dict[str, Any], eval_env: dict[str, str]) -> dict[str, Any]:
|
|
1408
|
+
scorer_id = str(scorer.get("id", ""))
|
|
1409
|
+
if not is_safe_id(scorer_id):
|
|
1410
|
+
return scorer_failure(scorer, required, "command scorer id must be path-safe")
|
|
1411
|
+
if case_dir is None:
|
|
1412
|
+
return scorer_failure(scorer, required, "command scorer requires case_dir")
|
|
1413
|
+
scorers_dir = case_dir / "scorers"
|
|
1414
|
+
verdict_path = scorers_dir / f"{scorer_id}.json"
|
|
1415
|
+
if not execute:
|
|
1416
|
+
verdict = read_json(verdict_path)
|
|
1417
|
+
if not isinstance(verdict, dict):
|
|
1418
|
+
return scorer_failure(scorer, required, "command scorer verdict artifact must be an object")
|
|
1419
|
+
return verdict
|
|
1420
|
+
scorers_dir.mkdir(parents=True, exist_ok=True)
|
|
1421
|
+
timeout = int(scorer.get("timeout_seconds") or DEFAULT_COMMAND_SCORER_TIMEOUT_SECONDS)
|
|
1422
|
+
runner = suite.get("runner", {})
|
|
1423
|
+
max_bytes = int(scorer.get("max_output_bytes") or runner.get("max_output_bytes") or 5 * 1024 * 1024)
|
|
1424
|
+
env = {k: os.environ[k] for k in runner.get("env_allowlist", []) if k in os.environ}
|
|
1425
|
+
env.update(eval_env)
|
|
1426
|
+
env_values = [os.environ.get(k, "") for k in runner.get("redact_env_values", [])]
|
|
1427
|
+
patterns = runner.get("redact_patterns", [])
|
|
1428
|
+
stdout = ""
|
|
1429
|
+
stderr = ""
|
|
1430
|
+
proc: subprocess.Popen[str] | None = None
|
|
1431
|
+
try:
|
|
1432
|
+
if scorer.get("shell", False):
|
|
1433
|
+
command = scorer.get("command")
|
|
1434
|
+
if not isinstance(command, str) or not command:
|
|
1435
|
+
result = scorer_failure(scorer, required, "command scorer shell mode requires command")
|
|
1436
|
+
write_json(verdict_path, result)
|
|
1437
|
+
return result
|
|
1438
|
+
cmd: str | list[str] = render_runner_arg(command, eval_env)
|
|
1439
|
+
proc = subprocess.Popen(cmd, shell=True, cwd=case_dir / "workspace", env=env, text=True,
|
|
1440
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
|
|
1441
|
+
else:
|
|
1442
|
+
argv_raw = scorer.get("argv")
|
|
1443
|
+
if not isinstance(argv_raw, list) or not argv_raw:
|
|
1444
|
+
result = scorer_failure(scorer, required, "command scorer requires argv when shell:false")
|
|
1445
|
+
write_json(verdict_path, result)
|
|
1446
|
+
return result
|
|
1447
|
+
cmd = [render_runner_arg(str(a), eval_env) for a in argv_raw]
|
|
1448
|
+
proc = subprocess.Popen(cmd, shell=False, cwd=case_dir / "workspace", env=env, text=True,
|
|
1449
|
+
stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
|
|
1450
|
+
stdout, stderr = proc.communicate(timeout=timeout)
|
|
1451
|
+
if proc.returncode != 0:
|
|
1452
|
+
result = scorer_failure(scorer, required, f"command scorer exited {proc.returncode}")
|
|
1453
|
+
else:
|
|
1454
|
+
try:
|
|
1455
|
+
raw = json.loads(stdout.strip())
|
|
1456
|
+
except json.JSONDecodeError as exc:
|
|
1457
|
+
result = scorer_failure(scorer, required, f"invalid command scorer JSON: {exc.msg}")
|
|
1458
|
+
else:
|
|
1459
|
+
result = normalize_command_verdict(raw, scorer, required)
|
|
1460
|
+
except subprocess.TimeoutExpired as exc:
|
|
1461
|
+
if proc is not None:
|
|
1462
|
+
try:
|
|
1463
|
+
os.killpg(os.getpgid(proc.pid), signal.SIGKILL)
|
|
1464
|
+
except ProcessLookupError:
|
|
1465
|
+
pass
|
|
1466
|
+
drained_stdout, drained_stderr = proc.communicate()
|
|
1467
|
+
stdout = decode_subprocess_output(exc.stdout) + decode_subprocess_output(drained_stdout)
|
|
1468
|
+
stderr = decode_subprocess_output(exc.stderr) + decode_subprocess_output(drained_stderr)
|
|
1469
|
+
result = scorer_failure(scorer, required, f"command scorer timed out after {timeout}s")
|
|
1470
|
+
except OSError as exc:
|
|
1471
|
+
result = scorer_failure(scorer, required, f"command scorer spawn failed: {exc}")
|
|
1472
|
+
stdout = stdout.encode()[:max_bytes].decode("utf-8", "replace")
|
|
1473
|
+
stderr = stderr.encode()[:max_bytes].decode("utf-8", "replace")
|
|
1474
|
+
stdout, _ = apply_redaction(stdout, patterns, env_values)
|
|
1475
|
+
stderr, _ = apply_redaction(stderr, patterns, env_values)
|
|
1476
|
+
stdout = stdout.encode()[:max_bytes].decode("utf-8", "replace")
|
|
1477
|
+
stderr = stderr.encode()[:max_bytes].decode("utf-8", "replace")
|
|
1478
|
+
(scorers_dir / f"{scorer_id}.stdout.txt").write_text(stdout)
|
|
1479
|
+
(scorers_dir / f"{scorer_id}.stderr.txt").write_text(stderr)
|
|
1480
|
+
write_json(verdict_path, result)
|
|
1481
|
+
return result
|
|
1482
|
+
|
|
1483
|
+
|
|
1484
|
+
def run_scorer(scorer: dict[str, Any], expect: dict[str, Any], output_text: str, runner_json: dict[str, Any],
|
|
1485
|
+
after: dict[str, Any], diff: dict[str, Any], *, required: bool, case_dir: Path | None,
|
|
1486
|
+
execute: bool, suite: dict[str, Any], eval_env: dict[str, str]) -> dict[str, Any] | None:
|
|
1487
|
+
name = scorer["name"]
|
|
1488
|
+
findings: list[dict[str, Any]] = []
|
|
1489
|
+
if name == "command":
|
|
1490
|
+
return run_command_scorer(scorer, required, case_dir, execute, suite, eval_env)
|
|
1491
|
+
elif name == "exact":
|
|
1492
|
+
if "exact" not in expect:
|
|
1493
|
+
return None
|
|
1494
|
+
ok = output_text == expect["exact"]
|
|
1495
|
+
elif name == "contains":
|
|
1496
|
+
values = expect.get("text_contains", [])
|
|
1497
|
+
if not values:
|
|
1498
|
+
return None
|
|
1499
|
+
missing = [v for v in values if v not in output_text]
|
|
1500
|
+
findings = [{"why": "missing text", "value": v} for v in missing]
|
|
1501
|
+
ok = not missing
|
|
1502
|
+
elif name == "regex":
|
|
1503
|
+
values = expect.get("text_regex", [])
|
|
1504
|
+
if not values:
|
|
1505
|
+
return None
|
|
1506
|
+
missing = [v for v in values if not re.search(v, output_text, re.MULTILINE)]
|
|
1507
|
+
findings = [{"why": "regex did not match", "value": v} for v in missing]
|
|
1508
|
+
ok = not missing
|
|
1509
|
+
elif name == "json-schema":
|
|
1510
|
+
if "json_schema" not in expect:
|
|
1511
|
+
return None
|
|
1512
|
+
try:
|
|
1513
|
+
json.loads(output_text)
|
|
1514
|
+
ok = True
|
|
1515
|
+
except json.JSONDecodeError as exc:
|
|
1516
|
+
ok = False
|
|
1517
|
+
findings = [{"why": f"output is not JSON: {exc.msg}"}]
|
|
1518
|
+
elif name == "numeric-threshold":
|
|
1519
|
+
threshold = expect.get("numeric_threshold")
|
|
1520
|
+
if not threshold:
|
|
1521
|
+
return None
|
|
1522
|
+
data = json.loads(output_text)
|
|
1523
|
+
value = data
|
|
1524
|
+
for part in threshold["path"].split("."):
|
|
1525
|
+
value = value[part]
|
|
1526
|
+
if "gte" in threshold:
|
|
1527
|
+
ok = value >= threshold["gte"]
|
|
1528
|
+
elif "lte" in threshold:
|
|
1529
|
+
ok = value <= threshold["lte"]
|
|
1530
|
+
else:
|
|
1531
|
+
ok = True
|
|
1532
|
+
elif name == "file-exists":
|
|
1533
|
+
values = expect.get("files_exist", [])
|
|
1534
|
+
if not values:
|
|
1535
|
+
return None
|
|
1536
|
+
paths = {e["path"] for e in after["entries"]}
|
|
1537
|
+
missing = [v for v in values if v not in paths]
|
|
1538
|
+
findings = [{"path": v, "why": "expected file missing"} for v in missing]
|
|
1539
|
+
ok = not missing
|
|
1540
|
+
elif name == "exit-code":
|
|
1541
|
+
if "exit_code" not in expect:
|
|
1542
|
+
return None
|
|
1543
|
+
ok = runner_json.get("exit_code") == expect["exit_code"]
|
|
1544
|
+
if not ok:
|
|
1545
|
+
findings = [{"why": "exit code mismatch", "expected": expect["exit_code"], "actual": runner_json.get("exit_code")}]
|
|
1546
|
+
elif name == "workspace-diff":
|
|
1547
|
+
changed = [p["path"] for p in diff["changed_paths"]]
|
|
1548
|
+
missing_changed = [p for p in expect.get("files_changed", []) if not any(fnmatch.fnmatch(c, p) for c in changed)]
|
|
1549
|
+
forbidden = [p for p in expect.get("files_forbidden", []) for c in changed if fnmatch.fnmatch(c, p)]
|
|
1550
|
+
findings = [{"path": p, "why": "expected change missing"} for p in missing_changed] + [{"path": p, "why": "forbidden path modified"} for p in forbidden]
|
|
1551
|
+
ok = not findings
|
|
1552
|
+
else:
|
|
1553
|
+
raise ValueError(f"unknown scorer {name}")
|
|
1554
|
+
return {"scorer": name, "ok": ok, "score": 1.0 if ok else 0.0, "label": "pass" if ok else "fail", "findings": findings}
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
def command_init(argv: list[str], json_mode: bool, started: float) -> int:
|
|
1558
|
+
data = init_project(force=has_flag(argv, "--force"))
|
|
1559
|
+
return print_envelope(data, json_mode=json_mode, human=f"Created {data['created']} with suite {data['suite']}", started=started)
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
def command_validate(argv: list[str], json_mode: bool, started: float) -> int:
|
|
1563
|
+
args = strip_flags(argv, set(), {"--json", "--no-color"})
|
|
1564
|
+
suite_arg = args[1] if len(args) > 1 else "code-review"
|
|
1565
|
+
data = validate_suite(resolve_suite(suite_arg))
|
|
1566
|
+
return print_envelope(data, json_mode=json_mode, human=f"{data['suite']}: {data['case_count']} cases valid", started=started)
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def runner_from_authoring_flags(argv: list[str], *, prefix: str = "--runner") -> dict[str, Any]:
|
|
1570
|
+
argv_value = value_after(argv, f"{prefix}-argv")
|
|
1571
|
+
command_value = value_after(argv, f"{prefix}-command")
|
|
1572
|
+
shell = has_flag(argv, "--shell")
|
|
1573
|
+
if bool(argv_value) == bool(command_value):
|
|
1574
|
+
raise EvalctlError("E_CASE_INVALID", f"{prefix}-argv and {prefix}-command are mutually exclusive", f"try: {TOOL} suite add demo {prefix}-argv \"python3 $EVALCTL_WORKSPACE/r.py\"", 1)
|
|
1575
|
+
if argv_value and shell:
|
|
1576
|
+
raise EvalctlError("E_CASE_INVALID", "--shell requires --runner-command, not --runner-argv", "drop --shell or use --runner-command", 1)
|
|
1577
|
+
if command_value and not shell:
|
|
1578
|
+
raise EvalctlError("E_CASE_INVALID", "--runner-command requires --shell", "use --runner-argv for shell:false runners", 1)
|
|
1579
|
+
runner = {
|
|
1580
|
+
"argv": shlex.split(argv_value) if argv_value else None,
|
|
1581
|
+
"shell": shell,
|
|
1582
|
+
"command": command_value if command_value else None,
|
|
1583
|
+
"cwd": None,
|
|
1584
|
+
"timeout_seconds": 30,
|
|
1585
|
+
"max_output_bytes": 1048576,
|
|
1586
|
+
"env_allowlist": ["PATH", "HOME"],
|
|
1587
|
+
"redact_env_values": [],
|
|
1588
|
+
"redact_patterns": [],
|
|
1589
|
+
}
|
|
1590
|
+
if argv_value and not runner["argv"]:
|
|
1591
|
+
raise EvalctlError("E_CASE_INVALID", "--runner-argv must not be empty", "provide at least one argv token", 1)
|
|
1592
|
+
return runner
|
|
1593
|
+
|
|
1594
|
+
|
|
1595
|
+
def suite_add_data(name: str, runner: dict[str, Any], *, _validator: Any = validate_suite) -> dict[str, Any]:
|
|
1596
|
+
validate_suite_name(name)
|
|
1597
|
+
root = Path("evals") / "suites"
|
|
1598
|
+
dest = root / name
|
|
1599
|
+
suite_json = {
|
|
1600
|
+
"name": name,
|
|
1601
|
+
"cases": "cases.jsonl",
|
|
1602
|
+
"acknowledged_unsandboxed_runner": True,
|
|
1603
|
+
"runner": runner,
|
|
1604
|
+
"scorers": [],
|
|
1605
|
+
}
|
|
1606
|
+
suite_text = json.dumps(suite_json, indent=2, sort_keys=True) + "\n"
|
|
1607
|
+
if dest.exists():
|
|
1608
|
+
existing_suite = dest / "suite.json"
|
|
1609
|
+
if existing_suite.exists() and existing_suite.read_text() == suite_text:
|
|
1610
|
+
return {"suite": name, "suite_dir": str(dest), "created": False, "files": [str(dest / "suite.json"), str(dest / "cases.jsonl")]}
|
|
1611
|
+
raise EvalctlError("E_RUN_CONFLICT", f"suite already exists with different content: {name}", "choose a new suite name or edit the existing suite explicitly", 5)
|
|
1612
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
1613
|
+
tmp_path = Path(tempfile.mkdtemp(prefix=f".{name}.", dir=root))
|
|
1614
|
+
try:
|
|
1615
|
+
(tmp_path / "fixtures").mkdir()
|
|
1616
|
+
(tmp_path / "suite.json").write_text(suite_text)
|
|
1617
|
+
(tmp_path / "cases.jsonl").write_text("")
|
|
1618
|
+
_validator(tmp_path)
|
|
1619
|
+
os.replace(tmp_path, dest)
|
|
1620
|
+
except Exception:
|
|
1621
|
+
shutil.rmtree(tmp_path, ignore_errors=True)
|
|
1622
|
+
raise
|
|
1623
|
+
return {"suite": name, "suite_dir": str(dest), "created": True, "files": [str(dest / "suite.json"), str(dest / "cases.jsonl"), str(dest / "fixtures")]}
|
|
1624
|
+
|
|
1625
|
+
|
|
1626
|
+
def command_suite_add(argv: list[str], json_mode: bool, started: float) -> int:
|
|
1627
|
+
args = strip_flags(argv, {"--runner-argv", "--runner-command"}, {"--json", "--no-color", "--shell"})
|
|
1628
|
+
if len(args) != 3 or args[1] != "add":
|
|
1629
|
+
raise EvalctlError("E_CASE_INVALID", "suite command requires: suite add <name>", "try: evalctl suite add demo --runner-argv \"python3 $EVALCTL_WORKSPACE/r.py\" --json", 1)
|
|
1630
|
+
data = suite_add_data(args[2], runner_from_authoring_flags(argv))
|
|
1631
|
+
return print_envelope(data, json_mode=json_mode, human=f"{'Created' if data['created'] else 'Exists'} suite {data['suite']}", started=started)
|
|
1632
|
+
|
|
1633
|
+
|
|
1634
|
+
def case_add_data(suite_name: str, task: str, workspace_raw: str, *, case_id: str | None = None,
|
|
1635
|
+
diff_raw: str | None = None, expect_raw: str | None = None) -> dict[str, Any]:
|
|
1636
|
+
suite_dir = resolve_suite(suite_name)
|
|
1637
|
+
suite = read_json(suite_dir / "suite.json")
|
|
1638
|
+
workspace = normalize_suite_rel(workspace_raw, field="--workspace")
|
|
1639
|
+
if not (suite_dir / workspace).exists():
|
|
1640
|
+
raise EvalctlError("E_CASE_INVALID", f"workspace missing: {workspace}", "create the fixture before adding the case", 1)
|
|
1641
|
+
case: dict[str, Any] = {"task": task, "workspace": workspace}
|
|
1642
|
+
if diff_raw:
|
|
1643
|
+
diff = normalize_suite_rel(diff_raw, field="--diff")
|
|
1644
|
+
if not (suite_dir / diff).exists():
|
|
1645
|
+
raise EvalctlError("E_CASE_INVALID", f"diff missing: {diff}", "create the diff file before adding the case", 1)
|
|
1646
|
+
case["diff"] = diff
|
|
1647
|
+
if expect_raw:
|
|
1648
|
+
try:
|
|
1649
|
+
expect = json.loads(expect_raw)
|
|
1650
|
+
except json.JSONDecodeError as exc:
|
|
1651
|
+
raise EvalctlError("E_CASE_INVALID", f"--expect-json is invalid JSON: {exc.msg}", "provide a JSON object such as '{\"exact\":\"ok\"}'", 1)
|
|
1652
|
+
if not isinstance(expect, dict):
|
|
1653
|
+
raise EvalctlError("E_CASE_INVALID", "--expect-json must be a JSON object", "provide scorer expectations as an object", 1)
|
|
1654
|
+
case["expect"] = expect
|
|
1655
|
+
final_id = case_id or sha256_text(stable_json(case))[7:19]
|
|
1656
|
+
if not is_safe_id(final_id):
|
|
1657
|
+
raise EvalctlError("E_CASE_INVALID", f"invalid case id: {final_id}", "use a path-safe id", 1)
|
|
1658
|
+
case["id"] = final_id
|
|
1659
|
+
cases_path = suite_dir / suite.get("cases", "cases.jsonl")
|
|
1660
|
+
existing_cases = load_cases(cases_path)
|
|
1661
|
+
for existing in existing_cases:
|
|
1662
|
+
if existing["id"] == final_id:
|
|
1663
|
+
if stable_json(existing) == stable_json(case):
|
|
1664
|
+
return {"suite": suite.get("name", suite_dir.name), "id": final_id, "created": False, "case": case}
|
|
1665
|
+
raise EvalctlError("E_RUN_CONFLICT", f"case id already exists with different content: {final_id}", "choose a new --id or edit cases.jsonl explicitly", 5)
|
|
1666
|
+
old_text = cases_path.read_text()
|
|
1667
|
+
new_text = old_text
|
|
1668
|
+
if new_text and not new_text.endswith("\n"):
|
|
1669
|
+
new_text += "\n"
|
|
1670
|
+
new_text += stable_json(case) + "\n"
|
|
1671
|
+
_atomic_write(cases_path, new_text)
|
|
1672
|
+
try:
|
|
1673
|
+
validate_suite(suite_dir)
|
|
1674
|
+
except Exception:
|
|
1675
|
+
_atomic_write(cases_path, old_text)
|
|
1676
|
+
raise
|
|
1677
|
+
return {"suite": suite.get("name", suite_dir.name), "id": final_id, "created": True, "case": case}
|
|
1678
|
+
|
|
1679
|
+
|
|
1680
|
+
def command_case_add(argv: list[str], json_mode: bool, started: float) -> int:
|
|
1681
|
+
args = strip_flags(argv, {"--task", "--workspace", "--id", "--diff", "--expect-json"}, {"--json", "--no-color"})
|
|
1682
|
+
if len(args) != 3 or args[1] != "add":
|
|
1683
|
+
raise EvalctlError("E_CASE_INVALID", "case command requires: case add <suite>", "try: evalctl case add demo --task \"do X\" --workspace fixtures/x --json", 1)
|
|
1684
|
+
task = value_after(argv, "--task")
|
|
1685
|
+
workspace = value_after(argv, "--workspace")
|
|
1686
|
+
if not task:
|
|
1687
|
+
raise EvalctlError("E_CASE_INVALID", "case add requires --task", "provide --task text", 1)
|
|
1688
|
+
if not workspace:
|
|
1689
|
+
raise EvalctlError("E_CASE_INVALID", "case add requires --workspace", "provide --workspace fixtures/name", 1)
|
|
1690
|
+
data = case_add_data(args[2], task, workspace, case_id=value_after(argv, "--id"), diff_raw=value_after(argv, "--diff"), expect_raw=value_after(argv, "--expect-json"))
|
|
1691
|
+
return print_envelope(data, json_mode=json_mode, human=f"{'Added' if data['created'] else 'Exists'} case {data['id']}", started=started)
|
|
1692
|
+
|
|
1693
|
+
|
|
1694
|
+
def command_scorer_config(argv: list[str]) -> dict[str, Any]:
|
|
1695
|
+
name = value_after(argv, "--name")
|
|
1696
|
+
if not name:
|
|
1697
|
+
raise EvalctlError("E_CASE_INVALID", "scorer add requires --name", "provide --name exact or --name command", 1)
|
|
1698
|
+
if name not in set(BUILTIN_SCORERS) | {"command"}:
|
|
1699
|
+
raise EvalctlError("E_CASE_INVALID", f"unknown scorer name: {name}", f"known scorers: {', '.join(BUILTIN_SCORERS)}, command", 1)
|
|
1700
|
+
if has_flag(argv, "--required") and has_flag(argv, "--advisory"):
|
|
1701
|
+
raise EvalctlError("E_CASE_INVALID", "--required and --advisory are mutually exclusive", "choose one scorer requirement mode", 1)
|
|
1702
|
+
required = not has_flag(argv, "--advisory")
|
|
1703
|
+
config: dict[str, Any] = {"name": name, "required": required}
|
|
1704
|
+
if name != "command":
|
|
1705
|
+
if any(value_after(argv, flag) for flag in ("--argv", "--command", "--timeout")) or has_flag(argv, "--shell"):
|
|
1706
|
+
raise EvalctlError("E_CASE_INVALID", "built-in scorers do not accept command runner flags", "use --name command for external scorers", 1)
|
|
1707
|
+
if value_after(argv, "--id"):
|
|
1708
|
+
config["id"] = value_after(argv, "--id")
|
|
1709
|
+
return config
|
|
1710
|
+
scorer_id = value_after(argv, "--id")
|
|
1711
|
+
if not scorer_id or not is_safe_id(scorer_id):
|
|
1712
|
+
raise EvalctlError("E_CASE_INVALID", "command scorer requires a path-safe --id", "use letters, numbers, dot, underscore, or dash", 1)
|
|
1713
|
+
argv_value = value_after(argv, "--argv")
|
|
1714
|
+
command_value = value_after(argv, "--command")
|
|
1715
|
+
if bool(argv_value) == bool(command_value):
|
|
1716
|
+
raise EvalctlError("E_CASE_INVALID", "--argv and --command are mutually exclusive for command scorers", "provide exactly one command form", 1)
|
|
1717
|
+
shell = has_flag(argv, "--shell")
|
|
1718
|
+
if argv_value and shell:
|
|
1719
|
+
raise EvalctlError("E_CASE_INVALID", "--shell requires --command, not --argv", "drop --shell or use --command", 1)
|
|
1720
|
+
if command_value and not shell:
|
|
1721
|
+
raise EvalctlError("E_CASE_INVALID", "--command requires --shell", "use --argv for shell:false scorers", 1)
|
|
1722
|
+
config["id"] = scorer_id
|
|
1723
|
+
config["shell"] = shell
|
|
1724
|
+
if argv_value:
|
|
1725
|
+
parsed = shlex.split(argv_value)
|
|
1726
|
+
if not parsed:
|
|
1727
|
+
raise EvalctlError("E_CASE_INVALID", "--argv must not be empty", "provide at least one argv token", 1)
|
|
1728
|
+
config["argv"] = parsed
|
|
1729
|
+
else:
|
|
1730
|
+
config["command"] = command_value
|
|
1731
|
+
timeout = value_after(argv, "--timeout")
|
|
1732
|
+
if timeout is not None:
|
|
1733
|
+
try:
|
|
1734
|
+
parsed_timeout = int(timeout)
|
|
1735
|
+
except ValueError:
|
|
1736
|
+
raise EvalctlError("E_CASE_INVALID", f"--timeout must be an integer (got {timeout})", "provide seconds as a positive integer", 1)
|
|
1737
|
+
if parsed_timeout < 1:
|
|
1738
|
+
raise EvalctlError("E_CASE_INVALID", f"--timeout must be at least 1 (got {parsed_timeout})", "provide a positive timeout", 1)
|
|
1739
|
+
config["timeout_seconds"] = parsed_timeout
|
|
1740
|
+
return config
|
|
1741
|
+
|
|
1742
|
+
|
|
1743
|
+
def scorer_key(config: dict[str, Any]) -> tuple[str, str]:
|
|
1744
|
+
if config["name"] == "command":
|
|
1745
|
+
return ("command", str(config.get("id", "")))
|
|
1746
|
+
return ("builtin", config["name"])
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
def scorer_add_data(suite_name: str, config: dict[str, Any]) -> dict[str, Any]:
|
|
1750
|
+
suite_dir = resolve_suite(suite_name)
|
|
1751
|
+
suite_path = suite_dir / "suite.json"
|
|
1752
|
+
suite = read_json(suite_path)
|
|
1753
|
+
old_text = suite_path.read_text()
|
|
1754
|
+
scorers = list(suite.get("scorers", []))
|
|
1755
|
+
new_key = scorer_key(config)
|
|
1756
|
+
for existing in scorers:
|
|
1757
|
+
if scorer_key(existing) == new_key:
|
|
1758
|
+
if stable_json(existing) == stable_json(config):
|
|
1759
|
+
return {"suite": suite.get("name", suite_dir.name), "scorer": config["name"], "id": config.get("id"), "created": False}
|
|
1760
|
+
raise EvalctlError("E_RUN_CONFLICT", f"scorer already exists with different config: {new_key[1]}", "choose a new id/name or edit suite.json explicitly", 5)
|
|
1761
|
+
suite["scorers"] = scorers + [config]
|
|
1762
|
+
write_json(suite_path, suite)
|
|
1763
|
+
try:
|
|
1764
|
+
validate_suite(suite_dir)
|
|
1765
|
+
except Exception:
|
|
1766
|
+
_atomic_write(suite_path, old_text)
|
|
1767
|
+
raise
|
|
1768
|
+
return {"suite": suite.get("name", suite_dir.name), "scorer": config["name"], "id": config.get("id"), "created": True}
|
|
1769
|
+
|
|
1770
|
+
|
|
1771
|
+
def command_scorer_add(argv: list[str], json_mode: bool, started: float) -> int:
|
|
1772
|
+
args = strip_flags(argv, {"--name", "--id", "--argv", "--command", "--timeout"}, {"--json", "--no-color", "--required", "--advisory", "--shell"})
|
|
1773
|
+
if len(args) != 3 or args[1] != "add":
|
|
1774
|
+
raise EvalctlError("E_CASE_INVALID", "scorer command requires: scorer add <suite>", "try: evalctl scorer add demo --name exact --required --json", 1)
|
|
1775
|
+
data = scorer_add_data(args[2], command_scorer_config(argv))
|
|
1776
|
+
label = data["id"] or data["scorer"]
|
|
1777
|
+
return print_envelope(data, json_mode=json_mode, human=f"{'Added' if data['created'] else 'Exists'} scorer {label}", started=started)
|
|
1778
|
+
|
|
1779
|
+
|
|
1780
|
+
def timeout_seconds_for_run(suite: dict[str, Any], timeout_override: int | None) -> int:
|
|
1781
|
+
return int(timeout_override or suite["runner"].get("timeout_seconds", 300))
|
|
1782
|
+
|
|
1783
|
+
|
|
1784
|
+
def build_run_metadata(suite: dict[str, Any], suite_dir: Path, cases: list[dict[str, Any]], run_id: str,
|
|
1785
|
+
jobs: int, timeout_override: int | None, replayed_from: str | None,
|
|
1786
|
+
queue: dict[str, Any] | None = None, mode: str = "synchronous") -> dict[str, Any]:
|
|
1787
|
+
return {
|
|
1788
|
+
"schema_version": 1,
|
|
1789
|
+
"run_id": run_id,
|
|
1790
|
+
"created_ts": now_iso(),
|
|
1791
|
+
"suite_identity": run_identity(suite, suite_dir, cases),
|
|
1792
|
+
"execution": {"mode": mode, "jobs": jobs, "timeout_seconds": timeout_seconds_for_run(suite, timeout_override)},
|
|
1793
|
+
"replayed_from": replayed_from,
|
|
1794
|
+
"queue": queue,
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
|
|
1798
|
+
def write_run_metadata_once(run_dir: Path, metadata: dict[str, Any]) -> None:
|
|
1799
|
+
path = run_dir / "run.json"
|
|
1800
|
+
if path.exists():
|
|
1801
|
+
raise EvalctlError("E_RUN_BUSY", f"run metadata already exists for {metadata['run_id']}", "use evalctl run --resume to continue an incomplete run", 4)
|
|
1802
|
+
write_json(path, metadata)
|
|
1803
|
+
|
|
1804
|
+
|
|
1805
|
+
def manifest_from_run_metadata(metadata: dict[str, Any], case_entries: list[dict[str, Any]]) -> dict[str, Any]:
|
|
1806
|
+
identity = metadata["suite_identity"]
|
|
1807
|
+
manifest_doc: dict[str, Any] = {
|
|
1808
|
+
"schema_version": 1,
|
|
1809
|
+
"run_id": metadata["run_id"],
|
|
1810
|
+
"suite": {"name": identity["suite_name"], "hash": identity["suite_hash"], "case_count": len(identity["cases"])},
|
|
1811
|
+
"created_ts": metadata["created_ts"],
|
|
1812
|
+
"execution": metadata["execution"],
|
|
1813
|
+
"replayed_from": metadata.get("replayed_from"),
|
|
1814
|
+
"cases": case_entries,
|
|
1815
|
+
}
|
|
1816
|
+
if metadata.get("queue") is not None:
|
|
1817
|
+
manifest_doc["queue"] = metadata["queue"]
|
|
1818
|
+
return manifest_doc
|
|
1819
|
+
|
|
1820
|
+
|
|
1821
|
+
def read_run_metadata(run_dir: Path) -> dict[str, Any]:
|
|
1822
|
+
path = run_dir / "run.json"
|
|
1823
|
+
if not path.exists():
|
|
1824
|
+
raise EvalctlError("E_RUN_NOT_FOUND", f"run metadata not found: {path}", "resume requires a run created with durable metadata", 1)
|
|
1825
|
+
try:
|
|
1826
|
+
metadata = json.loads(path.read_text())
|
|
1827
|
+
if not isinstance(metadata, dict):
|
|
1828
|
+
raise ValueError("run.json must be an object")
|
|
1829
|
+
for key in ("schema_version", "run_id", "created_ts", "suite_identity", "execution"):
|
|
1830
|
+
if key not in metadata:
|
|
1831
|
+
raise ValueError(f"run.json missing {key}")
|
|
1832
|
+
identity = metadata["suite_identity"]
|
|
1833
|
+
if not isinstance(identity, dict) or not isinstance(identity.get("cases"), list):
|
|
1834
|
+
raise ValueError("run.json suite_identity is malformed")
|
|
1835
|
+
execution = metadata["execution"]
|
|
1836
|
+
if not isinstance(execution, dict) or not {"mode", "jobs", "timeout_seconds"} <= set(execution):
|
|
1837
|
+
raise ValueError("run.json execution is malformed")
|
|
1838
|
+
return metadata
|
|
1839
|
+
except EvalctlError:
|
|
1840
|
+
raise
|
|
1841
|
+
except Exception as exc:
|
|
1842
|
+
raise EvalctlError("E_RUN_CORRUPT", f"run metadata is corrupt for {run_dir}: {exc}", "inspect or remove run.json, then retry with a valid run", 1)
|
|
1843
|
+
|
|
1844
|
+
|
|
1845
|
+
def finalize_run(run_dir: Path, metadata: dict[str, Any], case_entries: list[dict[str, Any]]) -> tuple[dict[str, Any], bool]:
|
|
1846
|
+
manifest_doc = manifest_from_run_metadata(metadata, case_entries)
|
|
1847
|
+
write_json(run_dir / "manifest.json", manifest_doc)
|
|
1848
|
+
report = report_data(run_dir)
|
|
1849
|
+
(run_dir / "report.md").write_text(markdown_report(report))
|
|
1850
|
+
run_ok = all(c["status"] == "pass" for c in case_entries)
|
|
1851
|
+
data = {
|
|
1852
|
+
"run_id": metadata["run_id"],
|
|
1853
|
+
"run_dir": str(run_dir),
|
|
1854
|
+
"run": {"ok": run_ok, "case_count": len(case_entries), "status_counts": status_counts(case_entries)},
|
|
1855
|
+
"report_hash": report["report_hash"],
|
|
1856
|
+
}
|
|
1857
|
+
if metadata.get("replayed_from") is not None:
|
|
1858
|
+
data["replayed_from"] = metadata["replayed_from"]
|
|
1859
|
+
data["cases_replayed"] = len(case_entries)
|
|
1860
|
+
return data, run_ok
|
|
1861
|
+
|
|
1862
|
+
|
|
1863
|
+
def execute_pending_cases(suite_dir: Path, suite: dict[str, Any], all_cases: list[dict[str, Any]], pending_cases: list[dict[str, Any]],
|
|
1864
|
+
completed_entries: dict[str, dict[str, Any]], run_dir: Path, jobs: int,
|
|
1865
|
+
timeout_override: int | None) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
|
1866
|
+
all_warnings: list[dict[str, Any]] = []
|
|
1867
|
+
case_results: dict[str, tuple[dict[str, Any], list[dict[str, Any]]]] = {}
|
|
1868
|
+
clean_pending_case_dirs(run_dir, pending_cases)
|
|
1869
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=jobs) as executor:
|
|
1870
|
+
future_to_case = {executor.submit(run_case, suite_dir, suite, case, run_dir, timeout_override): case for case in sorted(pending_cases, key=lambda c: c["id"])}
|
|
1871
|
+
for future in concurrent.futures.as_completed(future_to_case):
|
|
1872
|
+
case = future_to_case[future]
|
|
1873
|
+
try:
|
|
1874
|
+
entry, warnings = future.result()
|
|
1875
|
+
except Exception as exc:
|
|
1876
|
+
try:
|
|
1877
|
+
entry, warnings = synthesize_case_error(suite_dir, suite, case, run_dir, exc)
|
|
1878
|
+
except Exception as synth_exc:
|
|
1879
|
+
if not (run_dir / "manifest.json").exists() and terminal_marker_count(run_dir) == 0:
|
|
1880
|
+
shutil.rmtree(run_dir, ignore_errors=True)
|
|
1881
|
+
raise EvalctlError("E_SCORER_FAILED", f"case {case['id']} failed before replayable artifacts could be written: {synth_exc}", "retry evalctl run with a fresh --run-id", 3)
|
|
1882
|
+
case_results[case["id"]] = (entry, warnings)
|
|
1883
|
+
case_entries = []
|
|
1884
|
+
for case in sorted(all_cases, key=lambda c: c["id"]):
|
|
1885
|
+
if case["id"] in completed_entries:
|
|
1886
|
+
entry = completed_entries[case["id"]]
|
|
1887
|
+
warnings: list[dict[str, Any]] = []
|
|
1888
|
+
else:
|
|
1889
|
+
entry, warnings = case_results[case["id"]]
|
|
1890
|
+
all_warnings.extend(warnings)
|
|
1891
|
+
case_entries.append(entry)
|
|
1892
|
+
return case_entries, all_warnings
|
|
1893
|
+
|
|
1894
|
+
|
|
1895
|
+
def execute_cases(suite_dir: Path, suite: dict[str, Any], cases: list[dict[str, Any]], run_dir: Path, run_id: str,
|
|
1896
|
+
jobs: int, timeout_override: int | None, replayed_from: str | None,
|
|
1897
|
+
reservation_ttl: int = DEFAULT_RESERVATION_TTL_SECONDS,
|
|
1898
|
+
queue_backend: str | None = None, slots: int | None = None) -> tuple[dict[str, Any], list[dict[str, Any]], bool]:
|
|
1899
|
+
run_dir.mkdir(parents=True)
|
|
1900
|
+
shutil.copytree(suite_dir, run_dir / "suite-snapshot")
|
|
1901
|
+
queue = {"backend": "spoolctl", "db": ".spoolctl.db", "jobs": {}} if queue_backend == "spoolctl" else None
|
|
1902
|
+
metadata = build_run_metadata(suite, suite_dir, cases, run_id, slots or jobs, timeout_override, replayed_from, queue=queue, mode="queued" if queue_backend == "spoolctl" else "synchronous")
|
|
1903
|
+
write_run_metadata_once(run_dir, metadata)
|
|
1904
|
+
all_warnings = [{"code": "W_UNSANDBOXED_RUNNER", "message": "runner commands execute arbitrary local code; evalctl is not a sandbox"}]
|
|
1905
|
+
with ReservationHeartbeat(run_dir, run_id, reservation_ttl):
|
|
1906
|
+
if queue_backend == "spoolctl":
|
|
1907
|
+
case_entries, run_warnings = execute_spoolctl_pending_cases(suite_dir, suite, cases, cases, {}, run_dir, run_id, jobs, timeout_override, slots or jobs)
|
|
1908
|
+
else:
|
|
1909
|
+
case_entries, run_warnings = execute_pending_cases(suite_dir, suite, cases, cases, {}, run_dir, jobs, timeout_override)
|
|
1910
|
+
all_warnings.extend(run_warnings)
|
|
1911
|
+
if any(c["status"] == "error" for c in case_entries):
|
|
1912
|
+
all_warnings.append({"code": "W_PARTIAL_RUN", "message": "some cases errored; report remains generable"})
|
|
1913
|
+
data, run_ok = finalize_run(run_dir, metadata, case_entries)
|
|
1914
|
+
clear_reservation(run_dir)
|
|
1915
|
+
return data, all_warnings, run_ok
|
|
1916
|
+
|
|
1917
|
+
|
|
1918
|
+
def command_run(argv: list[str], json_mode: bool, started: float) -> int:
|
|
1919
|
+
resume_id = value_after(argv, "--resume")
|
|
1920
|
+
if resume_id is not None:
|
|
1921
|
+
return command_run_resume(argv, resume_id, json_mode, started)
|
|
1922
|
+
args = strip_flags(argv, {"--jobs", "--timeout", "--run-id", "--reservation-ttl", "--queue", "--slots"}, {"--json", "--no-color", "--fail-on-fail"})
|
|
1923
|
+
if len(args) < 2:
|
|
1924
|
+
raise EvalctlError("E_SUITE_NOT_FOUND", "run requires a suite name", "try: evalctl run code-review --json", 1)
|
|
1925
|
+
queue_backend = value_after(argv, "--queue")
|
|
1926
|
+
slots_raw = value_after(argv, "--slots")
|
|
1927
|
+
if slots_raw is not None and queue_backend is None:
|
|
1928
|
+
raise EvalctlError("E_CASE_INVALID", "--slots requires --queue spoolctl", "try: evalctl run code-review --queue spoolctl --slots 4 --json", 1)
|
|
1929
|
+
if queue_backend is not None and queue_backend != "spoolctl":
|
|
1930
|
+
raise EvalctlError("E_CASE_INVALID", f"unsupported queue backend: {queue_backend}", "supported value: --queue spoolctl", 1)
|
|
1931
|
+
if queue_backend == "spoolctl":
|
|
1932
|
+
probe_spoolctl()
|
|
1933
|
+
suite_dir = resolve_suite(args[1])
|
|
1934
|
+
validate_suite(suite_dir)
|
|
1935
|
+
suite = read_json(suite_dir / "suite.json")
|
|
1936
|
+
cases = load_cases(suite_dir / suite.get("cases", "cases.jsonl"))
|
|
1937
|
+
jobs = parse_jobs(argv)
|
|
1938
|
+
slots = parse_positive_int_flag(argv, "--slots", jobs) if queue_backend == "spoolctl" else None
|
|
1939
|
+
reservation_ttl = parse_positive_int_flag(argv, "--reservation-ttl", DEFAULT_RESERVATION_TTL_SECONDS)
|
|
1940
|
+
unsandboxed_warning = {"code": "W_UNSANDBOXED_RUNNER", "message": "runner commands execute arbitrary local code; evalctl is not a sandbox"}
|
|
1941
|
+
if not suite.get("acknowledged_unsandboxed_runner") and sys.stderr.isatty():
|
|
1942
|
+
print(unsandboxed_warning["message"], file=sys.stderr)
|
|
1943
|
+
run_id = value_after(argv, "--run-id") or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H%M%SZ-") + suite.get("name", suite_dir.name)
|
|
1944
|
+
run_dir = Path("evals") / "runs" / run_id
|
|
1945
|
+
if run_dir.exists():
|
|
1946
|
+
manifest_path = run_dir / "manifest.json"
|
|
1947
|
+
if manifest_path.exists():
|
|
1948
|
+
manifest_doc = read_json(manifest_path)
|
|
1949
|
+
if run_identity(suite, suite_dir, cases) != manifest_identity(manifest_doc):
|
|
1950
|
+
existing_count = manifest_doc["suite"]["case_count"]
|
|
1951
|
+
existing_suite = manifest_doc["suite"]["name"]
|
|
1952
|
+
raise EvalctlError("E_RUN_CONFLICT", f"run-id `{run_id}` already completed for suite `{existing_suite}`/{existing_count} cases; refusing to reuse for a different suite/case set", "use a fresh --run-id", 5)
|
|
1953
|
+
data = report_data(run_dir)
|
|
1954
|
+
run_summary = {"ok": data["run"]["ok"], "case_count": data["run"]["case_count"], "status_counts": data["run"]["status_counts"]}
|
|
1955
|
+
existing = {"run_id": run_id, "run_dir": str(run_dir), "existing": True, "run": run_summary, "report_hash": data["report_hash"]}
|
|
1956
|
+
return print_envelope(existing, json_mode=json_mode, warnings=[unsandboxed_warning], started=started)
|
|
1957
|
+
reservation = read_reservation(run_dir)
|
|
1958
|
+
if reservation and reservation_is_live(reservation):
|
|
1959
|
+
raise EvalctlError("E_RUN_BUSY", f"run reservation is live for {run_id}", "wait and retry evalctl run with a new --run-id", 4)
|
|
1960
|
+
raise EvalctlError("E_RUN_BUSY", f"run {run_id} is incomplete and may be resumable", f"retry with: evalctl run --resume {run_id} --json", 4)
|
|
1961
|
+
timeout_override = int(value_after(argv, "--timeout")) if value_after(argv, "--timeout") else None
|
|
1962
|
+
data, all_warnings, run_ok = execute_cases(suite_dir, suite, cases, run_dir, run_id, jobs, timeout_override, None, reservation_ttl, queue_backend, slots)
|
|
1963
|
+
commands = [{"command": f"evalctl report {run_id} --format json", "rationale": "regenerate deterministic JSON report"}]
|
|
1964
|
+
print_envelope(data, json_mode=json_mode, human=f"Run {run_id}: {'pass' if run_ok else 'fail'}", warnings=all_warnings, commands=commands, started=started)
|
|
1965
|
+
return 6 if has_flag(argv, "--fail-on-fail") and not run_ok else 0
|
|
1966
|
+
|
|
1967
|
+
|
|
1968
|
+
def command_run_resume(argv: list[str], run_id: str, json_mode: bool, started: float) -> int:
|
|
1969
|
+
if not is_safe_id(run_id) or "/" in run_id or "\\" in run_id:
|
|
1970
|
+
raise EvalctlError("E_CASE_INVALID", f"invalid run id: {run_id}", "use a simple run id with letters, numbers, dot, underscore, or dash", 1)
|
|
1971
|
+
run_dir = Path("evals") / "runs" / run_id
|
|
1972
|
+
if not run_dir.exists():
|
|
1973
|
+
raise EvalctlError("E_RUN_NOT_FOUND", f"run not found: {run_id}", "resume an existing incomplete run id", 1)
|
|
1974
|
+
nothing_pending_warning = {"code": "W_RESUME_NOTHING_PENDING", "message": "run has no pending cases to resume"}
|
|
1975
|
+
if (run_dir / "manifest.json").exists():
|
|
1976
|
+
data = report_data(run_dir)
|
|
1977
|
+
run_summary = {"ok": data["run"]["ok"], "case_count": data["run"]["case_count"], "status_counts": data["run"]["status_counts"]}
|
|
1978
|
+
existing = {"run_id": run_id, "run_dir": str(run_dir), "existing": True, "run": run_summary, "report_hash": data["report_hash"]}
|
|
1979
|
+
return print_envelope(existing, json_mode=json_mode, warnings=[nothing_pending_warning], started=started)
|
|
1980
|
+
suite_dir = run_dir / "suite-snapshot"
|
|
1981
|
+
if not suite_dir.exists():
|
|
1982
|
+
raise EvalctlError("E_RUN_NOT_FOUND", f"suite snapshot not found for {run_id}", "resume requires the original suite-snapshot directory", 1)
|
|
1983
|
+
metadata = read_run_metadata(run_dir)
|
|
1984
|
+
suite = read_json(suite_dir / "suite.json")
|
|
1985
|
+
cases = load_cases(suite_dir / suite.get("cases", "cases.jsonl"))
|
|
1986
|
+
if stable_json(metadata["suite_identity"]) != stable_json(run_identity(suite, suite_dir, cases)):
|
|
1987
|
+
raise EvalctlError("E_RUN_CONFLICT", f"run metadata does not match the suite snapshot for {run_id}", "inspect run.json and suite-snapshot before retrying", 5)
|
|
1988
|
+
reservation = read_reservation(run_dir)
|
|
1989
|
+
warnings: list[dict[str, Any]] = []
|
|
1990
|
+
if reservation and reservation_is_live(reservation):
|
|
1991
|
+
raise EvalctlError("E_RUN_BUSY", f"run reservation is live for {run_id}", "wait for the run to finish or retry after its reservation TTL", 4)
|
|
1992
|
+
if reservation is not None:
|
|
1993
|
+
warnings.append({"code": "W_RESERVATION_RECLAIMED", "message": "reclaimed stale run reservation"})
|
|
1994
|
+
completed_entries, pending_cases = split_completed_and_pending(run_dir, cases)
|
|
1995
|
+
reservation_ttl = parse_positive_int_flag(argv, "--reservation-ttl", DEFAULT_RESERVATION_TTL_SECONDS)
|
|
1996
|
+
jobs = int(metadata["execution"]["jobs"])
|
|
1997
|
+
timeout_override = int(metadata["execution"]["timeout_seconds"])
|
|
1998
|
+
queued = metadata["execution"].get("mode") == "queued" and metadata.get("queue", {}).get("backend") == "spoolctl"
|
|
1999
|
+
if queued:
|
|
2000
|
+
probe_spoolctl()
|
|
2001
|
+
with ReservationHeartbeat(run_dir, run_id, reservation_ttl):
|
|
2002
|
+
if pending_cases:
|
|
2003
|
+
if queued:
|
|
2004
|
+
case_entries, run_warnings = execute_spoolctl_pending_cases(suite_dir, suite, cases, pending_cases, completed_entries, run_dir, run_id, jobs, timeout_override, jobs)
|
|
2005
|
+
else:
|
|
2006
|
+
case_entries, run_warnings = execute_pending_cases(suite_dir, suite, cases, pending_cases, completed_entries, run_dir, jobs, timeout_override)
|
|
2007
|
+
warnings.extend(run_warnings)
|
|
2008
|
+
else:
|
|
2009
|
+
case_entries = [completed_entries[case["id"]] for case in sorted(cases, key=lambda c: c["id"])]
|
|
2010
|
+
warnings.append(nothing_pending_warning)
|
|
2011
|
+
if any(c["status"] == "error" for c in case_entries):
|
|
2012
|
+
warnings.append({"code": "W_PARTIAL_RUN", "message": "some cases errored; report remains generable"})
|
|
2013
|
+
data, run_ok = finalize_run(run_dir, metadata, case_entries)
|
|
2014
|
+
clear_reservation(run_dir)
|
|
2015
|
+
commands = [{"command": f"evalctl report {run_id} --format json", "rationale": "regenerate deterministic JSON report"}]
|
|
2016
|
+
print_envelope(data, json_mode=json_mode, human=f"Resume {run_id}: {'pass' if run_ok else 'fail'}", warnings=warnings, commands=commands, started=started)
|
|
2017
|
+
return 6 if has_flag(argv, "--fail-on-fail") and not run_ok else 0
|
|
2018
|
+
|
|
2019
|
+
|
|
2020
|
+
def runs_root() -> Path:
|
|
2021
|
+
return Path("evals") / "runs"
|
|
2022
|
+
|
|
2023
|
+
|
|
2024
|
+
def stored_queue_jobs(run_dir: Path) -> list[dict[str, Any]]:
|
|
2025
|
+
jobs: list[dict[str, Any]] = []
|
|
2026
|
+
for path in sorted((run_dir / "cases").glob("*/job.json")):
|
|
2027
|
+
try:
|
|
2028
|
+
data = read_json(path)
|
|
2029
|
+
except Exception:
|
|
2030
|
+
continue
|
|
2031
|
+
case_id = path.parent.name
|
|
2032
|
+
job_id = data.get("job_id") if isinstance(data, dict) else None
|
|
2033
|
+
if job_id is not None:
|
|
2034
|
+
jobs.append({"case_id": case_id, "job_id": job_id, "state": data.get("state"), "spoolctl_available": shutil.which("spoolctl") is not None})
|
|
2035
|
+
return jobs
|
|
2036
|
+
|
|
2037
|
+
|
|
2038
|
+
def run_case_counts(run_dir: Path) -> dict[str, int]:
|
|
2039
|
+
case_count = 0
|
|
2040
|
+
suite_dir = run_dir / "suite-snapshot"
|
|
2041
|
+
if suite_dir.exists():
|
|
2042
|
+
try:
|
|
2043
|
+
suite = read_json(suite_dir / "suite.json")
|
|
2044
|
+
case_count = len(load_cases(suite_dir / suite.get("cases", "cases.jsonl")))
|
|
2045
|
+
except Exception:
|
|
2046
|
+
case_count = 0
|
|
2047
|
+
marker_count = terminal_marker_count(run_dir)
|
|
2048
|
+
if case_count == 0:
|
|
2049
|
+
case_count = len(list((run_dir / "cases").glob("*")))
|
|
2050
|
+
return {"case_count": case_count, "terminal": marker_count, "pending": max(case_count - marker_count, 0)}
|
|
2051
|
+
|
|
2052
|
+
|
|
2053
|
+
def classify_run_dir(run_dir: Path) -> dict[str, Any]:
|
|
2054
|
+
manifest_path = run_dir / "manifest.json"
|
|
2055
|
+
reservation = read_reservation(run_dir)
|
|
2056
|
+
reservation_live = bool(reservation and reservation_is_live(reservation))
|
|
2057
|
+
if manifest_path.exists():
|
|
2058
|
+
state = "completed"
|
|
2059
|
+
elif reservation_live:
|
|
2060
|
+
state = "running"
|
|
2061
|
+
elif reservation is not None:
|
|
2062
|
+
state = "stale"
|
|
2063
|
+
else:
|
|
2064
|
+
state = "orphaned"
|
|
2065
|
+
data: dict[str, Any] = {
|
|
2066
|
+
"run_id": run_dir.name,
|
|
2067
|
+
"run_dir": str(run_dir),
|
|
2068
|
+
"state": state,
|
|
2069
|
+
"reservation": {"present": reservation is not None, "live": reservation_live},
|
|
2070
|
+
"cases": run_case_counts(run_dir),
|
|
2071
|
+
"queue_jobs": stored_queue_jobs(run_dir),
|
|
2072
|
+
}
|
|
2073
|
+
if manifest_path.exists():
|
|
2074
|
+
try:
|
|
2075
|
+
report = report_data(run_dir)
|
|
2076
|
+
data["run"] = report["run"]
|
|
2077
|
+
data["report_hash"] = report["report_hash"]
|
|
2078
|
+
except Exception:
|
|
2079
|
+
pass
|
|
2080
|
+
return data
|
|
2081
|
+
|
|
2082
|
+
|
|
2083
|
+
def command_jobs(argv: list[str], json_mode: bool, started: float) -> int:
|
|
2084
|
+
args = strip_flags(argv, set(), {"--json", "--no-color", "--yes", "--force"})
|
|
2085
|
+
if len(args) < 2:
|
|
2086
|
+
raise EvalctlError("E_CASE_INVALID", "jobs requires list, get, or prune", "try: evalctl jobs list --json", 1)
|
|
2087
|
+
subcommand = args[1]
|
|
2088
|
+
root = runs_root()
|
|
2089
|
+
run_dirs = sorted([p for p in root.iterdir() if p.is_dir()], key=lambda p: p.name) if root.exists() else []
|
|
2090
|
+
if subcommand == "list":
|
|
2091
|
+
runs = [classify_run_dir(path) for path in run_dirs]
|
|
2092
|
+
return print_envelope({"runs": runs, "count": len(runs)}, json_mode=json_mode, human="\n".join(f"{r['run_id']}\t{r['state']}" for r in runs), started=started)
|
|
2093
|
+
if subcommand == "get":
|
|
2094
|
+
if len(args) != 3:
|
|
2095
|
+
raise EvalctlError("E_CASE_INVALID", "jobs get requires a run id", "try: evalctl jobs get <run-id> --json", 1)
|
|
2096
|
+
run_dir = root / args[2]
|
|
2097
|
+
if not run_dir.exists():
|
|
2098
|
+
raise EvalctlError("E_RUN_NOT_FOUND", f"run not found: {args[2]}", "try: evalctl jobs list --json", 1)
|
|
2099
|
+
data = classify_run_dir(run_dir)
|
|
2100
|
+
return print_envelope(data, json_mode=json_mode, human=f"{data['run_id']}: {data['state']}", started=started)
|
|
2101
|
+
if subcommand == "prune":
|
|
2102
|
+
confirmed = has_flag(argv, "--yes") or has_flag(argv, "--force")
|
|
2103
|
+
stale = [classify_run_dir(path) for path in run_dirs if classify_run_dir(path)["state"] == "stale"]
|
|
2104
|
+
orphaned = [classify_run_dir(path) for path in run_dirs if classify_run_dir(path)["state"] == "orphaned"]
|
|
2105
|
+
refused = [classify_run_dir(path) for path in run_dirs if classify_run_dir(path)["state"] in {"completed", "running"}]
|
|
2106
|
+
removed_reservations: list[str] = []
|
|
2107
|
+
removed_runs: list[str] = []
|
|
2108
|
+
if confirmed:
|
|
2109
|
+
for item in stale:
|
|
2110
|
+
clear_reservation(Path(item["run_dir"]))
|
|
2111
|
+
removed_reservations.append(item["run_id"])
|
|
2112
|
+
for item in orphaned:
|
|
2113
|
+
shutil.rmtree(item["run_dir"], ignore_errors=True)
|
|
2114
|
+
removed_runs.append(item["run_id"])
|
|
2115
|
+
data = {
|
|
2116
|
+
"confirmed": confirmed,
|
|
2117
|
+
"candidates": {"stale_reservations": [item["run_id"] for item in stale], "orphaned_runs": [item["run_id"] for item in orphaned]},
|
|
2118
|
+
"removed": {"reservations": removed_reservations, "runs": removed_runs},
|
|
2119
|
+
"refused": [{"run_id": item["run_id"], "state": item["state"]} for item in refused],
|
|
2120
|
+
}
|
|
2121
|
+
return print_envelope(data, json_mode=json_mode, human=json.dumps(data, indent=2, sort_keys=True), started=started)
|
|
2122
|
+
raise EvalctlError("E_CASE_INVALID", f"unknown jobs subcommand '{subcommand}'", "try: evalctl jobs list --json", 1)
|
|
2123
|
+
|
|
2124
|
+
|
|
2125
|
+
def parse_replay_source(argv: list[str]) -> Path:
|
|
2126
|
+
if not has_flag(argv, "--failed"):
|
|
2127
|
+
raise EvalctlError("E_CASE_INVALID", "replay requires --failed in v0.2", "try: evalctl replay --failed <run-id> --json", 1)
|
|
2128
|
+
run_dir = value_after(argv, "--run-dir")
|
|
2129
|
+
args = strip_flags(argv, {"--run-dir", "--run-id", "--suite", "--jobs", "--timeout"}, {"--json", "--no-color", "--failed", "--force", "--fail-on-fail"})
|
|
2130
|
+
positional = args[1:] if args and args[0] == "replay" else args
|
|
2131
|
+
if run_dir and positional:
|
|
2132
|
+
raise EvalctlError("E_CASE_INVALID", "replay source must be either a run id or --run-dir, not both", "try: evalctl replay --failed <run-id> --json", 1)
|
|
2133
|
+
if not run_dir and len(positional) != 1:
|
|
2134
|
+
raise EvalctlError("E_CASE_INVALID", "replay requires a source run id or --run-dir", "try: evalctl replay --failed <run-id> --json", 1)
|
|
2135
|
+
path = Path(run_dir) if run_dir else Path("evals") / "runs" / positional[0]
|
|
2136
|
+
if not (path / "manifest.json").exists():
|
|
2137
|
+
raise EvalctlError("E_RUN_NOT_FOUND", f"run not found: {path}", "try: evalctl run code-review --json", 1)
|
|
2138
|
+
return path
|
|
2139
|
+
|
|
2140
|
+
|
|
2141
|
+
def command_replay(argv: list[str], json_mode: bool, started: float) -> int:
|
|
2142
|
+
source_run = parse_replay_source(argv)
|
|
2143
|
+
source_manifest = read_json(source_run / "manifest.json")
|
|
2144
|
+
source_report = report_data(source_run)
|
|
2145
|
+
failed_ids = [case["id"] for case in source_report["cases"] if case["status"] != "pass"]
|
|
2146
|
+
if not failed_ids:
|
|
2147
|
+
warnings = [{"code": "W_NOTHING_TO_REPLAY", "message": "source run has no failed or errored cases"}]
|
|
2148
|
+
data = {"replayed_from": source_manifest["run_id"], "cases_replayed": 0}
|
|
2149
|
+
return print_envelope(data, json_mode=json_mode, human=f"{source_manifest['run_id']}: nothing to replay", warnings=warnings, started=started)
|
|
2150
|
+
|
|
2151
|
+
suite_arg = value_after(argv, "--suite") or source_manifest["suite"]["name"]
|
|
2152
|
+
try:
|
|
2153
|
+
suite_dir = resolve_suite(suite_arg)
|
|
2154
|
+
except EvalctlError as exc:
|
|
2155
|
+
if exc.error["code"] == "E_SUITE_NOT_FOUND":
|
|
2156
|
+
raise EvalctlError("E_SUITE_NOT_FOUND", exc.error["message"], "pass the current suite with --suite <suite-or-path>", 1)
|
|
2157
|
+
raise
|
|
2158
|
+
validate_suite(suite_dir)
|
|
2159
|
+
suite = read_json(suite_dir / "suite.json")
|
|
2160
|
+
current_cases = load_cases(suite_dir / suite.get("cases", "cases.jsonl"))
|
|
2161
|
+
current_by_id = {case["id"]: case for case in current_cases}
|
|
2162
|
+
warnings: list[dict[str, Any]] = []
|
|
2163
|
+
target_cases = []
|
|
2164
|
+
for case_id in failed_ids:
|
|
2165
|
+
case = current_by_id.get(case_id)
|
|
2166
|
+
if case is None:
|
|
2167
|
+
warnings.append({"code": "W_REPLAY_CASE_ABSENT", "message": f"case {case_id} is absent from the current suite"})
|
|
2168
|
+
else:
|
|
2169
|
+
target_cases.append(case)
|
|
2170
|
+
if not target_cases:
|
|
2171
|
+
warnings.append({"code": "W_NOTHING_TO_REPLAY", "message": "no selected failed cases exist in the current suite"})
|
|
2172
|
+
data = {"replayed_from": source_manifest["run_id"], "cases_replayed": 0}
|
|
2173
|
+
return print_envelope(data, json_mode=json_mode, human=f"{source_manifest['run_id']}: nothing to replay", warnings=warnings, started=started)
|
|
2174
|
+
|
|
2175
|
+
jobs = parse_jobs(argv)
|
|
2176
|
+
timeout_override = int(value_after(argv, "--timeout")) if value_after(argv, "--timeout") else None
|
|
2177
|
+
run_id = value_after(argv, "--run-id") or f"{source_manifest['run_id']}-replay-{datetime.now(timezone.utc).strftime('%Y-%m-%dT%H%M%SZ')}"
|
|
2178
|
+
run_dir = Path("evals") / "runs" / run_id
|
|
2179
|
+
if run_dir.resolve() == source_run.resolve():
|
|
2180
|
+
raise EvalctlError("E_RUN_CONFLICT", "replay destination must not be the source run", "use a fresh --run-id", 5)
|
|
2181
|
+
if run_dir.exists():
|
|
2182
|
+
if (run_dir / "manifest.json").exists():
|
|
2183
|
+
if not has_flag(argv, "--force"):
|
|
2184
|
+
raise EvalctlError("E_RUN_CONFLICT", f"run-id `{run_id}` already completed; refusing to overwrite without --force", "use a fresh --run-id or pass --force", 5)
|
|
2185
|
+
shutil.rmtree(run_dir)
|
|
2186
|
+
else:
|
|
2187
|
+
raise EvalctlError("E_RUN_BUSY", f"run reservation exists for {run_id}", "wait and retry evalctl replay with a new --run-id", 4)
|
|
2188
|
+
if not suite.get("acknowledged_unsandboxed_runner") and sys.stderr.isatty():
|
|
2189
|
+
print("runner commands execute arbitrary local code; evalctl is not a sandbox", file=sys.stderr)
|
|
2190
|
+
data, run_warnings, run_ok = execute_cases(suite_dir, suite, target_cases, run_dir, run_id, jobs, timeout_override, source_manifest["run_id"])
|
|
2191
|
+
all_warnings = run_warnings + warnings
|
|
2192
|
+
commands = [{"command": f"evalctl report {run_id} --format json", "rationale": "inspect replay report"}]
|
|
2193
|
+
print_envelope(data, json_mode=json_mode, human=f"Replay {run_id}: {'pass' if run_ok else 'fail'}", warnings=all_warnings, commands=commands, started=started)
|
|
2194
|
+
return 6 if has_flag(argv, "--fail-on-fail") and not run_ok else 0
|
|
2195
|
+
|
|
2196
|
+
|
|
2197
|
+
def status_counts(cases: list[dict[str, Any]]) -> dict[str, int]:
|
|
2198
|
+
counts = {"pass": 0, "fail": 0, "error": 0}
|
|
2199
|
+
for case in cases:
|
|
2200
|
+
counts[case["status"]] = counts.get(case["status"], 0) + 1
|
|
2201
|
+
return counts
|
|
2202
|
+
|
|
2203
|
+
|
|
2204
|
+
def run_identity(suite: dict[str, Any], suite_dir: Path, cases: list[dict[str, Any]]) -> dict[str, Any]:
|
|
2205
|
+
return {
|
|
2206
|
+
"suite_name": suite.get("name", suite_dir.name),
|
|
2207
|
+
"suite_hash": sha256_text(stable_json(suite)),
|
|
2208
|
+
"cases": sorted((case["id"], sha256_text(stable_json(case))) for case in cases),
|
|
2209
|
+
}
|
|
2210
|
+
|
|
2211
|
+
|
|
2212
|
+
def manifest_identity(manifest_doc: dict[str, Any]) -> dict[str, Any]:
|
|
2213
|
+
return {
|
|
2214
|
+
"suite_name": manifest_doc["suite"]["name"],
|
|
2215
|
+
"suite_hash": manifest_doc["suite"]["hash"],
|
|
2216
|
+
"cases": sorted((case["id"], case["input_hash"]) for case in manifest_doc["cases"]),
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
|
|
2220
|
+
def resolve_run(argv: list[str]) -> Path:
|
|
2221
|
+
run_dir = value_after(argv, "--run-dir")
|
|
2222
|
+
if run_dir:
|
|
2223
|
+
path = Path(run_dir)
|
|
2224
|
+
else:
|
|
2225
|
+
args = strip_flags(argv, {"--run-dir", "--format"}, {"--json", "--no-color"})
|
|
2226
|
+
if len(args) < 2:
|
|
2227
|
+
raise EvalctlError("E_RUN_NOT_FOUND", "run id or --run-dir is required", "try: evalctl status <run-id> --json", 1)
|
|
2228
|
+
path = Path("evals") / "runs" / args[1]
|
|
2229
|
+
if not (path / "manifest.json").exists():
|
|
2230
|
+
raise EvalctlError("E_RUN_NOT_FOUND", f"run not found: {path}", "try: evalctl run code-review --json", 1)
|
|
2231
|
+
return path
|
|
2232
|
+
|
|
2233
|
+
|
|
2234
|
+
def recompute_case_score(run_dir: Path, case_entry: dict[str, Any], suite: dict[str, Any]) -> dict[str, Any]:
|
|
2235
|
+
case_dir = run_dir / "cases" / case_entry["id"]
|
|
2236
|
+
case = read_json(case_dir / "input.json")
|
|
2237
|
+
output = (case_dir / "output.txt").read_text(errors="replace")
|
|
2238
|
+
runner_json = read_json(case_dir / "runner.json")
|
|
2239
|
+
before = read_json(case_dir / "workspace-before.json")
|
|
2240
|
+
after = read_json(case_dir / "workspace-after.json")
|
|
2241
|
+
diff = diff_manifests(before, after)
|
|
2242
|
+
scores = score_case(case, output, runner_json, after, diff, suite.get("scorers", []), case_dir=case_dir, execute=False, suite=suite)
|
|
2243
|
+
status = "error" if runner_json.get("timed_out") or runner_json.get("spawn_failed") or any(s.get("error") for s in scores) else "pass"
|
|
2244
|
+
if status != "error" and not all(s["ok"] for s in scores if s.get("required", True)):
|
|
2245
|
+
status = "fail"
|
|
2246
|
+
return {"case_id": case["id"], "status": status, "ok": status == "pass", "scores": scores}
|
|
2247
|
+
|
|
2248
|
+
|
|
2249
|
+
def report_data(run_dir: Path) -> dict[str, Any]:
|
|
2250
|
+
manifest_doc = read_json(run_dir / "manifest.json")
|
|
2251
|
+
suite = read_json(run_dir / "suite-snapshot" / "suite.json")
|
|
2252
|
+
cases = []
|
|
2253
|
+
for entry in sorted(manifest_doc["cases"], key=lambda c: c["id"]):
|
|
2254
|
+
score = recompute_case_score(run_dir, entry, suite)
|
|
2255
|
+
aggregate = sum(s["score"] for s in score["scores"]) / max(len(score["scores"]), 1)
|
|
2256
|
+
cases.append({"id": score["case_id"], "status": score["status"], "ok": score["ok"], "aggregate_score": aggregate, "scores": score["scores"], "artifacts": entry["artifacts"]})
|
|
2257
|
+
failures = [c for c in cases if c["status"] != "pass"]
|
|
2258
|
+
failures.sort(key=lambda c: (0 if c["status"] == "error" else 1, c["aggregate_score"], c["id"]))
|
|
2259
|
+
def report_score(score: dict[str, Any]) -> dict[str, Any]:
|
|
2260
|
+
out = {"scorer": score["scorer"], "ok": score["ok"], "label": score["label"], "findings": score["findings"]}
|
|
2261
|
+
if "id" in score:
|
|
2262
|
+
out["id"] = score["id"]
|
|
2263
|
+
return out
|
|
2264
|
+
|
|
2265
|
+
normalized = {"run": {"ok": not failures, "suite": manifest_doc["suite"]["name"], "case_count": len(cases), "status_counts": status_counts(cases)}, "failures": [{"id": f["id"], "status": f["status"], "scores": [report_score(s) for s in f["scores"]]} for f in failures], "cases": [{"id": c["id"], "status": c["status"], "ok": c["ok"]} for c in cases]}
|
|
2266
|
+
return {**normalized, "run_id": manifest_doc["run_id"], "report_hash": sha256_text(stable_json(normalized))}
|
|
2267
|
+
|
|
2268
|
+
|
|
2269
|
+
def markdown_report(data: dict[str, Any]) -> str:
|
|
2270
|
+
lines = [f"# evalctl report: {data['run_id']}", "", f"Status: {'pass' if data['run']['ok'] else 'fail'}", f"Report hash: `{data['report_hash']}`", "", "## Failures"]
|
|
2271
|
+
if not data["failures"]:
|
|
2272
|
+
lines.append("None.")
|
|
2273
|
+
for failure in data["failures"]:
|
|
2274
|
+
lines.append(f"- `{failure['id']}` {failure['status']}")
|
|
2275
|
+
for score in failure["scores"]:
|
|
2276
|
+
if not score["ok"]:
|
|
2277
|
+
lines.append(f" - {score['scorer']}: {score['label']} {score['findings']}")
|
|
2278
|
+
return "\n".join(lines) + "\n"
|
|
2279
|
+
|
|
2280
|
+
|
|
2281
|
+
def command_status(argv: list[str], json_mode: bool, started: float) -> int:
|
|
2282
|
+
run_dir = resolve_run(argv)
|
|
2283
|
+
manifest_doc = read_json(run_dir / "manifest.json")
|
|
2284
|
+
report = report_data(run_dir)
|
|
2285
|
+
data = {"run_id": manifest_doc["run_id"], "run_dir": str(run_dir), "run": report["run"], "cases": report["cases"], "recommended_action": {"command": f"evalctl report --run-dir {run_dir} --format json", "rationale": "inspect deterministic report and ranked failures", "alternatives": []}}
|
|
2286
|
+
return print_envelope(data, json_mode=json_mode, human=f"{manifest_doc['run_id']}: {'pass' if report['run']['ok'] else 'fail'}", started=started)
|
|
2287
|
+
|
|
2288
|
+
|
|
2289
|
+
def command_report(argv: list[str], json_mode: bool, started: float) -> int:
|
|
2290
|
+
run_dir = resolve_run(argv)
|
|
2291
|
+
data = report_data(run_dir)
|
|
2292
|
+
fmt = value_after(argv, "--format", "json" if json_mode else "markdown")
|
|
2293
|
+
if fmt == "markdown" and not has_flag(argv, "--json"):
|
|
2294
|
+
print(markdown_report(data), end="")
|
|
2295
|
+
return 0
|
|
2296
|
+
if fmt not in {"json", "markdown"}:
|
|
2297
|
+
raise EvalctlError("E_CASE_INVALID", f"--format must be markdown or json (got {fmt})", "try: evalctl report <run-id> --format json", 1)
|
|
2298
|
+
commands = [{"command": f"evalctl report --run-dir {run_dir} --format json", "rationale": "regenerate this report"}]
|
|
2299
|
+
return print_envelope(data, json_mode=True, commands=commands, started=started)
|
|
2300
|
+
|
|
2301
|
+
|
|
2302
|
+
def main(argv: list[str] | None = None) -> int:
|
|
2303
|
+
started = time.time()
|
|
2304
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
2305
|
+
json_mode = wants_json(argv)
|
|
2306
|
+
try:
|
|
2307
|
+
if not argv or argv[0] in {"--help", "-h"}:
|
|
2308
|
+
print(help_text())
|
|
2309
|
+
return 0
|
|
2310
|
+
if argv[0] == "--version":
|
|
2311
|
+
print(__version__)
|
|
2312
|
+
return 0
|
|
2313
|
+
cmd = argv[0]
|
|
2314
|
+
if cmd == "capabilities":
|
|
2315
|
+
return print_envelope(capabilities_data(), json_mode=True, started=started)
|
|
2316
|
+
if cmd == "schema":
|
|
2317
|
+
args = strip_flags(argv, set(), {"--json", "--no-color"})
|
|
2318
|
+
return print_envelope(schema_data(args[1] if len(args) > 1 else None), json_mode=True, started=started)
|
|
2319
|
+
if cmd == "robot-docs" and len(argv) > 1 and argv[1] == "guide":
|
|
2320
|
+
print(robot_docs(), end="")
|
|
2321
|
+
return 0
|
|
2322
|
+
if cmd == "init":
|
|
2323
|
+
return command_init(argv, json_mode, started)
|
|
2324
|
+
if cmd == "validate":
|
|
2325
|
+
return command_validate(argv, json_mode, started)
|
|
2326
|
+
if cmd == "suite" and len(argv) > 1 and argv[1] == "add":
|
|
2327
|
+
return command_suite_add(argv, json_mode, started)
|
|
2328
|
+
if cmd == "case" and len(argv) > 1 and argv[1] == "add":
|
|
2329
|
+
return command_case_add(argv, json_mode, started)
|
|
2330
|
+
if cmd == "scorer" and len(argv) > 1 and argv[1] == "add":
|
|
2331
|
+
return command_scorer_add(argv, json_mode, started)
|
|
2332
|
+
if cmd == "run":
|
|
2333
|
+
return command_run(argv, json_mode, started)
|
|
2334
|
+
if cmd == "jobs":
|
|
2335
|
+
return command_jobs(argv, json_mode, started)
|
|
2336
|
+
if cmd == "replay":
|
|
2337
|
+
return command_replay(argv, json_mode, started)
|
|
2338
|
+
if cmd == "status":
|
|
2339
|
+
return command_status(argv, json_mode, started)
|
|
2340
|
+
if cmd == "report":
|
|
2341
|
+
return command_report(argv, json_mode, started)
|
|
2342
|
+
raise EvalctlError("E_CASE_INVALID", f"unknown command '{cmd}'", "try: evalctl capabilities --json", 1)
|
|
2343
|
+
except EvalctlError as exc:
|
|
2344
|
+
return print_error(exc, json_mode=json_mode, started=started)
|
|
2345
|
+
except KeyboardInterrupt:
|
|
2346
|
+
return print_error(EvalctlError("E_RUNNER_FAILED", "interrupted", "retry the command", 3), json_mode=json_mode, started=started)
|
|
2347
|
+
except Exception as exc:
|
|
2348
|
+
return print_error(EvalctlError("E_RUNNER_FAILED", f"internal error: {exc}", "run with --json and inspect errors[0]", 3), json_mode=json_mode, started=started)
|
|
2349
|
+
|
|
2350
|
+
|
|
2351
|
+
if __name__ == "__main__":
|
|
2352
|
+
raise SystemExit(main())
|