workflowval 0.1.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.
- workflowval/__init__.py +3 -0
- workflowval/cli.py +584 -0
- workflowval/gen/__init__.py +194 -0
- workflowval/gen/source.py +109 -0
- workflowval/gen/style.md +17 -0
- workflowval/gen/templates/.gitkeep +0 -0
- workflowval/gen/templates/conftest.py.tmpl +332 -0
- workflowval/loop/__init__.py +55 -0
- workflowval/loop/runner.py +488 -0
- workflowval/loop/stats.py +178 -0
- workflowval/loop/trace.py +29 -0
- workflowval/models/__init__.py +19 -0
- workflowval/models/base.py +68 -0
- workflowval/models/fake.py +55 -0
- workflowval/models/litellm_adapter.py +153 -0
- workflowval/pipeline.py +906 -0
- workflowval/planner/__init__.py +62 -0
- workflowval/prompts/__init__.py +34 -0
- workflowval/prompts/planner.md +37 -0
- workflowval/prompts/reviewer.md +30 -0
- workflowval/prompts/task_answer_review.md +14 -0
- workflowval/prompts/task_finding.md +17 -0
- workflowval/prompts/task_fix_test.md +6 -0
- workflowval/prompts/task_fix_workflow.md +7 -0
- workflowval/prompts/task_patch.md +9 -0
- workflowval/prompts/task_plan.md +13 -0
- workflowval/prompts/task_plan_gaps.md +6 -0
- workflowval/prompts/task_report.md +9 -0
- workflowval/prompts/task_review_plan.md +17 -0
- workflowval/prompts/task_review_results.md +19 -0
- workflowval/prompts/task_triage.md +9 -0
- workflowval/prompts/writer.md +11 -0
- workflowval/py.typed +0 -0
- workflowval/redact.py +97 -0
- workflowval/report/__init__.py +162 -0
- workflowval/report/curl.py +75 -0
- workflowval/schemas/__init__.py +78 -0
- workflowval/schemas/_base.py +43 -0
- workflowval/schemas/auth.py +66 -0
- workflowval/schemas/labels.py +31 -0
- workflowval/schemas/results.py +222 -0
- workflowval/schemas/review.py +94 -0
- workflowval/schemas/state.py +32 -0
- workflowval/schemas/stats.py +36 -0
- workflowval/schemas/workflow.py +148 -0
- workflowval/spec/__init__.py +38 -0
- workflowval/spec/graph.py +196 -0
- workflowval/spec/loader.py +102 -0
- workflowval/spec/validation.py +98 -0
- workflowval/testrun.py +159 -0
- workflowval/tools/__init__.py +6 -0
- workflowval/tools/base.py +49 -0
- workflowval/tools/registry.py +637 -0
- workflowval/triage/__init__.py +199 -0
- workflowval-0.1.0.dist-info/METADATA +215 -0
- workflowval-0.1.0.dist-info/RECORD +59 -0
- workflowval-0.1.0.dist-info/WHEEL +4 -0
- workflowval-0.1.0.dist-info/entry_points.txt +2 -0
- workflowval-0.1.0.dist-info/licenses/LICENSE +21 -0
workflowval/pipeline.py
ADDED
|
@@ -0,0 +1,906 @@
|
|
|
1
|
+
"""The `workflowval test` pipeline: plan, render tests, run, label, fix, and write results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import contextlib
|
|
6
|
+
import json
|
|
7
|
+
from collections.abc import Mapping, Sequence
|
|
8
|
+
from dataclasses import dataclass, field, replace
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from pydantic import ValidationError
|
|
13
|
+
|
|
14
|
+
from workflowval.gen import (
|
|
15
|
+
GenerationError,
|
|
16
|
+
generate,
|
|
17
|
+
hash_line,
|
|
18
|
+
render_conftest,
|
|
19
|
+
test_path,
|
|
20
|
+
)
|
|
21
|
+
from workflowval.loop import (
|
|
22
|
+
Budget,
|
|
23
|
+
CodeStep,
|
|
24
|
+
FixLoop,
|
|
25
|
+
Pipeline,
|
|
26
|
+
RunContext,
|
|
27
|
+
RunResult,
|
|
28
|
+
StopReason,
|
|
29
|
+
StopRun,
|
|
30
|
+
TaskLimitReached,
|
|
31
|
+
TestOutcome,
|
|
32
|
+
make_role,
|
|
33
|
+
run,
|
|
34
|
+
)
|
|
35
|
+
from workflowval.models import Model
|
|
36
|
+
from workflowval.planner import load_workflows, plan, plan_call_limit, uncovered_operations
|
|
37
|
+
from workflowval.prompts import render_prompt
|
|
38
|
+
from workflowval.report import write_reports
|
|
39
|
+
from workflowval.schemas import (
|
|
40
|
+
Action,
|
|
41
|
+
ActionKind,
|
|
42
|
+
Answer,
|
|
43
|
+
AuthConfig,
|
|
44
|
+
Basis,
|
|
45
|
+
Confidence,
|
|
46
|
+
Decision,
|
|
47
|
+
Evidence,
|
|
48
|
+
Finding,
|
|
49
|
+
HttpExchange,
|
|
50
|
+
KnownIssue,
|
|
51
|
+
Label,
|
|
52
|
+
LoginAuth,
|
|
53
|
+
ReviewLog,
|
|
54
|
+
RunResults,
|
|
55
|
+
RunState,
|
|
56
|
+
TestResult,
|
|
57
|
+
TestStatus,
|
|
58
|
+
Verdict,
|
|
59
|
+
Workflow,
|
|
60
|
+
atomic_write_text,
|
|
61
|
+
load_json,
|
|
62
|
+
save_json,
|
|
63
|
+
)
|
|
64
|
+
from workflowval.spec import (
|
|
65
|
+
ResponseChecker,
|
|
66
|
+
SpecError,
|
|
67
|
+
SpecGraph,
|
|
68
|
+
build_graph,
|
|
69
|
+
load_raw_spec,
|
|
70
|
+
load_spec,
|
|
71
|
+
resolve_base_url,
|
|
72
|
+
spec_hash,
|
|
73
|
+
)
|
|
74
|
+
from workflowval.testrun import Outcome, is_reachable, run_tests, test_id
|
|
75
|
+
from workflowval.tools import ToolContext
|
|
76
|
+
from workflowval.triage import (
|
|
77
|
+
MismatchGroup,
|
|
78
|
+
StoredVerdict,
|
|
79
|
+
classify,
|
|
80
|
+
group_mismatches,
|
|
81
|
+
label_failures,
|
|
82
|
+
read_verdicts,
|
|
83
|
+
request_patch,
|
|
84
|
+
request_report,
|
|
85
|
+
review_finding,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
PLANNER_TOOLS = frozenset({"read_spec", "read_docs", "read_workflows", "write_workflow"})
|
|
89
|
+
REVIEWER_TOOLS = frozenset(
|
|
90
|
+
{"read_spec", "read_docs", "read_workflows", "read_results", "write_verdict"}
|
|
91
|
+
)
|
|
92
|
+
REVIEW_TOOLS = frozenset(
|
|
93
|
+
{"read_spec", "read_docs", "read_workflows", "read_results", "write_review"}
|
|
94
|
+
)
|
|
95
|
+
ANSWER_TOOLS = frozenset(
|
|
96
|
+
{"read_spec", "read_docs", "read_workflows", "write_workflow", "answer_review"}
|
|
97
|
+
)
|
|
98
|
+
REPORT_TOOLS = frozenset({"read_spec", "read_docs", "write_bug_report", "propose_spec_patch"})
|
|
99
|
+
MAX_REVIEWED_FINDINGS = 20
|
|
100
|
+
EXPECTABLE_LABELS = frozenset({"API_BUG", "SPEC_DRIFT", "CONFLICT"})
|
|
101
|
+
WRITER_TOOLS = frozenset(
|
|
102
|
+
{"read_spec", "read_workflows", "read_results", "read_tests", "write_test"}
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class TestConfig:
|
|
108
|
+
"""Everything a `workflowval test` run needs."""
|
|
109
|
+
|
|
110
|
+
__test__ = False
|
|
111
|
+
|
|
112
|
+
spec_source: str
|
|
113
|
+
base_url: str
|
|
114
|
+
workdir: Path
|
|
115
|
+
models: Mapping[str, Model]
|
|
116
|
+
budget: Budget = field(default_factory=Budget)
|
|
117
|
+
docs_dir: Path | None = None
|
|
118
|
+
auth: AuthConfig | None = None
|
|
119
|
+
reports_dir: Path | None = None
|
|
120
|
+
read_only: bool = False
|
|
121
|
+
no_redact: bool = False
|
|
122
|
+
allowed_hosts: tuple[str, ...] = ()
|
|
123
|
+
plan_only: bool = False
|
|
124
|
+
verbose: bool = False
|
|
125
|
+
cost_tracked: bool = True
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass(frozen=True)
|
|
129
|
+
class TestRunReport:
|
|
130
|
+
"""What a run did."""
|
|
131
|
+
|
|
132
|
+
__test__ = False
|
|
133
|
+
|
|
134
|
+
run: RunResult
|
|
135
|
+
results: RunResults
|
|
136
|
+
results_path: Path
|
|
137
|
+
expectation_changes: list[str]
|
|
138
|
+
planned: bool
|
|
139
|
+
looks_fixed: list[str] = field(default_factory=list)
|
|
140
|
+
resumed: bool = False
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def auth_env_names(auth: AuthConfig | None) -> list[str]:
|
|
144
|
+
"""The env vars an auth config names."""
|
|
145
|
+
if auth is None:
|
|
146
|
+
return []
|
|
147
|
+
names = []
|
|
148
|
+
for key, value in auth.scheme.model_dump().items():
|
|
149
|
+
if key.endswith("_env"):
|
|
150
|
+
names += list(value.values()) if isinstance(value, dict) else [value]
|
|
151
|
+
return sorted(names)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _needs_planning(config: TestConfig, current_hash: str) -> bool:
|
|
155
|
+
"""Plan unless the same spec was fully planned already and workflow files exist."""
|
|
156
|
+
state_path = config.workdir / ".workflowval" / "state.json"
|
|
157
|
+
has_workflows = any((config.workdir / "workflows").glob("*.json"))
|
|
158
|
+
if not (state_path.is_file() and has_workflows):
|
|
159
|
+
return True
|
|
160
|
+
try:
|
|
161
|
+
previous = load_json(RunState, state_path)
|
|
162
|
+
except (ValidationError, OSError):
|
|
163
|
+
return True
|
|
164
|
+
return previous.spec_hash != current_hash or "plan" not in previous.completed_steps
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _write_test_if_changed(workflow: Workflow, graph: SpecGraph, workdir: Path) -> None:
|
|
168
|
+
"""Render a workflow's test, keeping a test that's already up to date with its workflow."""
|
|
169
|
+
path = workdir / test_path(workflow.id)
|
|
170
|
+
if path.is_file() and path.read_text(encoding="utf-8").startswith(hash_line(workflow)):
|
|
171
|
+
return
|
|
172
|
+
atomic_write_text(path, generate(workflow, graph).source)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _resumable(config: TestConfig, current_hash: str) -> RunState | None:
|
|
176
|
+
"""Return the state of a run that was killed partway, on this same spec (FR-13).
|
|
177
|
+
|
|
178
|
+
A run that ended by itself has a stop reason, so it starts afresh.
|
|
179
|
+
"""
|
|
180
|
+
path = config.workdir / ".workflowval" / "state.json"
|
|
181
|
+
try:
|
|
182
|
+
state = load_json(RunState, path) if path.is_file() else None
|
|
183
|
+
except (ValidationError, OSError):
|
|
184
|
+
return None
|
|
185
|
+
if (
|
|
186
|
+
state is None
|
|
187
|
+
or state.stop_reason is not None
|
|
188
|
+
or state.spec_hash != current_hash
|
|
189
|
+
or "plan" not in state.completed_steps
|
|
190
|
+
):
|
|
191
|
+
return None
|
|
192
|
+
return state
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _is_unedited(workflow: Workflow, graph: SpecGraph, workdir: Path) -> bool:
|
|
196
|
+
"""True when a workflow's test is exactly what code renders, so no fix would be lost."""
|
|
197
|
+
path = workdir / test_path(workflow.id)
|
|
198
|
+
return path.is_file() and path.read_text(encoding="utf-8") == generate(workflow, graph).source
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _known_issue_from(
|
|
202
|
+
workflow: Workflow, outcome: Outcome, verdict: StoredVerdict
|
|
203
|
+
) -> KnownIssue | None:
|
|
204
|
+
"""Pin a reviewed failure to its step and check. None when it isn't tied to a response."""
|
|
205
|
+
index = outcome.steps_run - 1
|
|
206
|
+
if not 0 <= index < len(workflow.steps) or not outcome.exchanges:
|
|
207
|
+
return None
|
|
208
|
+
seen = outcome.exchanges[-1].get("status")
|
|
209
|
+
if seen is None:
|
|
210
|
+
return None
|
|
211
|
+
wrong_status = seen != workflow.steps[index].expect.status
|
|
212
|
+
return KnownIssue.model_validate(
|
|
213
|
+
{
|
|
214
|
+
"label": verdict.label.value,
|
|
215
|
+
"confidence": verdict.confidence,
|
|
216
|
+
"reason": verdict.reason,
|
|
217
|
+
"step": index,
|
|
218
|
+
"check": "status" if wrong_status else "body",
|
|
219
|
+
"status": seen if wrong_status else None,
|
|
220
|
+
}
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _failure_text(outcome: Outcome) -> str:
|
|
225
|
+
return outcome.failing_assertion or f"the test ended with status {outcome.status}"
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
class _Session:
|
|
229
|
+
"""State the pipeline's steps share within one run."""
|
|
230
|
+
|
|
231
|
+
def __init__(
|
|
232
|
+
self, config: TestConfig, graph: SpecGraph, base_url: str, checker: ResponseChecker
|
|
233
|
+
) -> None:
|
|
234
|
+
self.config = config
|
|
235
|
+
self.graph = graph
|
|
236
|
+
self.checker = checker
|
|
237
|
+
self.findings: list[Finding] = []
|
|
238
|
+
self.base_url = base_url
|
|
239
|
+
self.workdir = config.workdir
|
|
240
|
+
self.outcomes: dict[str, Outcome] = {}
|
|
241
|
+
self.needs_planning = True
|
|
242
|
+
self.looks_fixed: list[str] = []
|
|
243
|
+
self.test_files: dict[str, str] = {}
|
|
244
|
+
scheme = config.auth.scheme if config.auth else None
|
|
245
|
+
self.login_operations = {scheme.op} if isinstance(scheme, LoginAuth) else set()
|
|
246
|
+
self.planner = make_role("planner", config.models["planner"], PLANNER_TOOLS)
|
|
247
|
+
self.reviewer = make_role("reviewer", config.models["reviewer"], REVIEWER_TOOLS)
|
|
248
|
+
self.reporter = make_role("reviewer", config.models["reviewer"], REPORT_TOOLS)
|
|
249
|
+
self.plan_reviewer = make_role("reviewer", config.models["reviewer"], REVIEW_TOOLS)
|
|
250
|
+
self.answerer = make_role("planner", config.models["planner"], ANSWER_TOOLS)
|
|
251
|
+
self.review_finished = False
|
|
252
|
+
self.filed: set[str] = set()
|
|
253
|
+
self.writer = make_role("writer", config.models["writer"], WRITER_TOOLS)
|
|
254
|
+
|
|
255
|
+
def plan_step(self, ctx: RunContext) -> None:
|
|
256
|
+
if not self.needs_planning:
|
|
257
|
+
return
|
|
258
|
+
if not plan(ctx, self.planner, self.graph, self.login_operations):
|
|
259
|
+
raise StopRun(StopReason.INVALID_OUTPUT, "the Planner wrote no workflows")
|
|
260
|
+
|
|
261
|
+
def _run_tests(self, workflow_ids: list[str] | None) -> list[Outcome]:
|
|
262
|
+
return run_tests(
|
|
263
|
+
self.workdir / "tests" / "wf",
|
|
264
|
+
base_url=self.base_url,
|
|
265
|
+
workflow_ids=workflow_ids,
|
|
266
|
+
env_names=auth_env_names(self.config.auth),
|
|
267
|
+
read_only=self.config.read_only,
|
|
268
|
+
allowed_hosts=self.config.allowed_hosts,
|
|
269
|
+
no_redact=self.config.no_redact,
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
def _save_progress(self) -> None:
|
|
273
|
+
"""Keep what a resumed run needs, since a killed process loses it."""
|
|
274
|
+
atomic_write_text(
|
|
275
|
+
self.workdir / ".workflowval" / "progress.json",
|
|
276
|
+
json.dumps(
|
|
277
|
+
{
|
|
278
|
+
"outcomes": {key: vars(value) for key, value in self.outcomes.items()},
|
|
279
|
+
"test_files": self.test_files,
|
|
280
|
+
"filed": sorted(self.filed),
|
|
281
|
+
},
|
|
282
|
+
default=str,
|
|
283
|
+
),
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
def load_progress(self) -> None:
|
|
287
|
+
"""Pick up where a killed run stopped."""
|
|
288
|
+
path = self.workdir / ".workflowval" / "progress.json"
|
|
289
|
+
if path.is_file():
|
|
290
|
+
saved = json.loads(path.read_text(encoding="utf-8"))
|
|
291
|
+
self.outcomes = {key: Outcome(**value) for key, value in saved["outcomes"].items()}
|
|
292
|
+
self.test_files = saved["test_files"]
|
|
293
|
+
self.filed = set(saved["filed"])
|
|
294
|
+
|
|
295
|
+
def _review_log(self) -> ReviewLog:
|
|
296
|
+
path = self.workdir / "review.json"
|
|
297
|
+
return load_json(ReviewLog, path) if path.is_file() else ReviewLog()
|
|
298
|
+
|
|
299
|
+
def _review_round(self, ctx: RunContext, instruction: str, *, after_tests: bool) -> bool:
|
|
300
|
+
"""Run one review round and get every item answered. True if it proposed anything."""
|
|
301
|
+
if ctx.state.review_rounds >= ctx.budget.max_review_rounds or ctx.tool_context is None:
|
|
302
|
+
return False
|
|
303
|
+
ctx.state.review_rounds += 1
|
|
304
|
+
rounds_before = len(self._review_log().rounds)
|
|
305
|
+
ctx.tool_context = replace(ctx.tool_context, new_workflows_only=after_tests)
|
|
306
|
+
try:
|
|
307
|
+
with contextlib.suppress(TaskLimitReached):
|
|
308
|
+
ctx.run_role(self.plan_reviewer, instruction)
|
|
309
|
+
log = self._review_log()
|
|
310
|
+
if len(log.rounds) == rounds_before or not log.rounds[-1].items():
|
|
311
|
+
return False
|
|
312
|
+
note = (
|
|
313
|
+
"Tests that have run can't change, so give each workflow a new id."
|
|
314
|
+
if after_tests
|
|
315
|
+
else ""
|
|
316
|
+
)
|
|
317
|
+
for _ in range(2): # A model can stop early, so it gets one more chance.
|
|
318
|
+
open_ids = set(self._review_log().unanswered())
|
|
319
|
+
if not open_ids:
|
|
320
|
+
break
|
|
321
|
+
items = [
|
|
322
|
+
item.model_dump(exclude_none=True)
|
|
323
|
+
for item in self._review_log().rounds[-1].items()
|
|
324
|
+
if item.id in open_ids
|
|
325
|
+
]
|
|
326
|
+
with contextlib.suppress(TaskLimitReached):
|
|
327
|
+
ctx.run_role(
|
|
328
|
+
self.answerer,
|
|
329
|
+
render_prompt(
|
|
330
|
+
"task_answer_review", items=json.dumps(items, indent=1), note=note
|
|
331
|
+
),
|
|
332
|
+
max_calls=plan_call_limit(self.graph),
|
|
333
|
+
)
|
|
334
|
+
self._reject_unanswered()
|
|
335
|
+
return True
|
|
336
|
+
finally:
|
|
337
|
+
ctx.tool_context = replace(ctx.tool_context, new_workflows_only=False)
|
|
338
|
+
ctx.checkpoint()
|
|
339
|
+
|
|
340
|
+
def _reject_unanswered(self) -> None:
|
|
341
|
+
"""Nothing is dropped silently, so an item the Planner left open is recorded as such."""
|
|
342
|
+
log = self._review_log()
|
|
343
|
+
refusal = Answer(decision=Decision.REJECTED, reason="The Planner gave no answer.")
|
|
344
|
+
for review in log.rounds:
|
|
345
|
+
for item in review.items():
|
|
346
|
+
if item.answer is None:
|
|
347
|
+
item.answer = refusal
|
|
348
|
+
save_json(log, self.workdir / "review.json")
|
|
349
|
+
|
|
350
|
+
def review_plan(self, ctx: RunContext) -> None:
|
|
351
|
+
"""Review the plan before any test runs, keeping a round for after them if possible."""
|
|
352
|
+
if not self.needs_planning:
|
|
353
|
+
return
|
|
354
|
+
rounds = ctx.budget.max_review_rounds
|
|
355
|
+
before_tests = rounds if self.config.plan_only else max(rounds - 1, min(rounds, 1))
|
|
356
|
+
for _ in range(before_tests):
|
|
357
|
+
instruction = render_prompt("task_review_plan")
|
|
358
|
+
if not self._review_round(ctx, instruction, after_tests=False):
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
def _how_far(self, workflow: Workflow) -> str:
|
|
362
|
+
outcome = self.outcomes.get(test_id(workflow.id))
|
|
363
|
+
if outcome is None:
|
|
364
|
+
return f"- {workflow.id}: {workflow.rule}. It hasn't run."
|
|
365
|
+
return (
|
|
366
|
+
f"- {workflow.id}: {workflow.rule}. It {outcome.status}, and ran "
|
|
367
|
+
f"{outcome.steps_run} of {len(workflow.steps)} steps."
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
def review_results(self, ctx: RunContext) -> None:
|
|
371
|
+
"""Look for what the tests never reached, and add new tests. Existing ones don't change."""
|
|
372
|
+
if not self.needs_planning or self.review_finished:
|
|
373
|
+
return
|
|
374
|
+
workflows = load_workflows(self.workdir)
|
|
375
|
+
known = [
|
|
376
|
+
f"- {workflow.id} ({workflow.known_issue.label}): {workflow.known_issue.reason}"
|
|
377
|
+
for workflow in workflows
|
|
378
|
+
if workflow.known_issue is not None
|
|
379
|
+
]
|
|
380
|
+
known += [
|
|
381
|
+
f"- {group.message}, on {', '.join(sorted(group.operations))}"
|
|
382
|
+
for group in group_mismatches(
|
|
383
|
+
{key: outcome.calls for key, outcome in self.outcomes.items()},
|
|
384
|
+
self.checker,
|
|
385
|
+
self.graph,
|
|
386
|
+
)
|
|
387
|
+
]
|
|
388
|
+
instruction = render_prompt(
|
|
389
|
+
"task_review_results",
|
|
390
|
+
results="\n".join(self._how_far(workflow) for workflow in workflows),
|
|
391
|
+
known="\n".join(known) or "none",
|
|
392
|
+
)
|
|
393
|
+
if not self._review_round(ctx, instruction, after_tests=True):
|
|
394
|
+
self.review_finished = True
|
|
395
|
+
|
|
396
|
+
def pending_tests(self, ctx: RunContext) -> frozenset[str]:
|
|
397
|
+
"""Tests with no final result yet: all of them at first, then only new ones."""
|
|
398
|
+
ids = {test_id(workflow.id) for workflow in load_workflows(self.workdir)}
|
|
399
|
+
return frozenset(ids - set(ctx.state.closed_tests))
|
|
400
|
+
|
|
401
|
+
def render_tests(self, ctx: RunContext) -> None:
|
|
402
|
+
atomic_write_text(
|
|
403
|
+
self.workdir / "tests" / "wf" / "conftest.py",
|
|
404
|
+
render_conftest(self.base_url, self.config.auth, self.graph),
|
|
405
|
+
)
|
|
406
|
+
for workflow in load_workflows(self.workdir):
|
|
407
|
+
try:
|
|
408
|
+
_write_test_if_changed(workflow, self.graph, self.workdir)
|
|
409
|
+
except GenerationError as exc:
|
|
410
|
+
raise StopRun(StopReason.INVALID_OUTPUT, f"{workflow.id}: {exc}") from exc
|
|
411
|
+
|
|
412
|
+
def execute(self, ctx: RunContext, ids: frozenset[str] | None) -> list[TestOutcome]:
|
|
413
|
+
if not is_reachable(self.base_url):
|
|
414
|
+
raise StopRun(StopReason.API_UNREACHABLE, f"no response from {self.base_url}")
|
|
415
|
+
wanted = None if ids is None else [i.rsplit("::test_", 1)[1] for i in ids]
|
|
416
|
+
outcomes = self._run_tests(wanted)
|
|
417
|
+
for outcome in outcomes:
|
|
418
|
+
self.outcomes[test_id(outcome.workflow_id)] = outcome
|
|
419
|
+
self._save_progress()
|
|
420
|
+
atomic_write_text(
|
|
421
|
+
self.workdir / ".workflowval" / "last_results.json",
|
|
422
|
+
json.dumps(
|
|
423
|
+
{
|
|
424
|
+
key: {name: item for name, item in vars(value).items() if name != "calls"}
|
|
425
|
+
for key, value in self.outcomes.items()
|
|
426
|
+
},
|
|
427
|
+
default=str,
|
|
428
|
+
),
|
|
429
|
+
)
|
|
430
|
+
return [
|
|
431
|
+
TestOutcome(test_id(o.workflow_id), o.ok, "" if o.ok else _failure_text(o))
|
|
432
|
+
for o in outcomes
|
|
433
|
+
]
|
|
434
|
+
|
|
435
|
+
@property
|
|
436
|
+
def credentials(self) -> str:
|
|
437
|
+
scheme = self.config.auth.scheme if self.config.auth else None
|
|
438
|
+
return scheme.type if scheme else "none, so requests carry no credentials"
|
|
439
|
+
|
|
440
|
+
def _hint(self, outcome: Outcome) -> str:
|
|
441
|
+
last = outcome.calls[-1] if outcome.calls else {}
|
|
442
|
+
status = last.get("status")
|
|
443
|
+
mismatches = [m.message for m in self.checker.check_call(last)] if last else []
|
|
444
|
+
return classify(status if isinstance(status, int) else None, mismatches)
|
|
445
|
+
|
|
446
|
+
def label(self, ctx: RunContext, failures: Sequence[TestOutcome]) -> Mapping[str, Label]:
|
|
447
|
+
hints = {
|
|
448
|
+
failure.test_id: self._hint(self.outcomes[failure.test_id])
|
|
449
|
+
for failure in failures
|
|
450
|
+
if failure.test_id in self.outcomes
|
|
451
|
+
}
|
|
452
|
+
return label_failures(ctx, self.reviewer, failures, self.credentials, hints)
|
|
453
|
+
|
|
454
|
+
def _save_evidence(self, evidence: Mapping[str, Mapping[str, object]]) -> None:
|
|
455
|
+
path = self.workdir / ".workflowval" / "evidence.json"
|
|
456
|
+
saved = json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
|
|
457
|
+
atomic_write_text(path, json.dumps({**saved, **evidence}, default=str))
|
|
458
|
+
|
|
459
|
+
def _written_reports(self) -> dict[str, str]:
|
|
460
|
+
path = self.workdir / ".workflowval" / "reports.json"
|
|
461
|
+
written: dict[str, str] = (
|
|
462
|
+
json.loads(path.read_text(encoding="utf-8")) if path.is_file() else {}
|
|
463
|
+
)
|
|
464
|
+
return written
|
|
465
|
+
|
|
466
|
+
def _ask_for_file(
|
|
467
|
+
self, ctx: RunContext, label: str, reason: str, target: str, tests: list[str], call: object
|
|
468
|
+
) -> None:
|
|
469
|
+
if label == Label.SPEC_DRIFT.value:
|
|
470
|
+
request_patch(ctx, self.reporter, reason=reason, target=target, call=call) # type: ignore[arg-type]
|
|
471
|
+
elif label in EXPECTABLE_LABELS:
|
|
472
|
+
request_report(
|
|
473
|
+
ctx, self.reporter, label=label, reason=reason, test_id=target, tests=tests
|
|
474
|
+
)
|
|
475
|
+
|
|
476
|
+
def write_failure_files(self, ctx: RunContext) -> None:
|
|
477
|
+
"""Have the Reviewer write one report or patch per root cause, not one per test."""
|
|
478
|
+
verdicts = read_verdicts(self.workdir)
|
|
479
|
+
groups: dict[tuple[object, ...], list[str]] = {}
|
|
480
|
+
for key, status in sorted(ctx.state.closed_tests.items()):
|
|
481
|
+
outcome = self.outcomes.get(key)
|
|
482
|
+
verdict = verdicts.get(key)
|
|
483
|
+
if status not in EXPECTABLE_LABELS or outcome is None or verdict is None:
|
|
484
|
+
continue
|
|
485
|
+
if key in self.filed:
|
|
486
|
+
continue
|
|
487
|
+
self.filed.add(key)
|
|
488
|
+
if not outcome.calls:
|
|
489
|
+
continue
|
|
490
|
+
last = outcome.calls[-1]
|
|
491
|
+
root = (status, last.get("method"), last.get("template"))
|
|
492
|
+
# One conflict is one report. Other labels can differ by status on the same operation.
|
|
493
|
+
groups.setdefault(
|
|
494
|
+
root if status == "CONFLICT" else (*root, last.get("status")), []
|
|
495
|
+
).append(key)
|
|
496
|
+
for tests in groups.values():
|
|
497
|
+
first = tests[0]
|
|
498
|
+
outcome = self.outcomes[first]
|
|
499
|
+
verdict = verdicts[first]
|
|
500
|
+
self._save_evidence({first: {"calls": outcome.calls, "tests": tests}})
|
|
501
|
+
self._ask_for_file(
|
|
502
|
+
ctx, verdict.label.value, verdict.reason, first, tests, outcome.calls[-1]
|
|
503
|
+
)
|
|
504
|
+
written = self._written_reports().get(first)
|
|
505
|
+
if written is not None:
|
|
506
|
+
self.test_files.update(dict.fromkeys(tests, written))
|
|
507
|
+
self._save_progress()
|
|
508
|
+
|
|
509
|
+
def review_mismatches(self, ctx: RunContext) -> None:
|
|
510
|
+
"""Have the Reviewer rule once on each distinct way responses broke the spec."""
|
|
511
|
+
calls = {key: outcome.calls for key, outcome in self.outcomes.items()}
|
|
512
|
+
groups = group_mismatches(calls, self.checker, self.graph)
|
|
513
|
+
self.findings = [_unreviewed_finding(group) for group in groups]
|
|
514
|
+
for index, group in enumerate(groups[:MAX_REVIEWED_FINDINGS]):
|
|
515
|
+
verdict = review_finding(ctx, self.reviewer, group, self.credentials)
|
|
516
|
+
if verdict is None:
|
|
517
|
+
continue
|
|
518
|
+
self._save_evidence(
|
|
519
|
+
{
|
|
520
|
+
group.finding_id: {
|
|
521
|
+
"calls": [group.example],
|
|
522
|
+
"keys": [group.key],
|
|
523
|
+
"tests": sorted(group.tests),
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
)
|
|
527
|
+
self._ask_for_file(
|
|
528
|
+
ctx,
|
|
529
|
+
verdict.label.value,
|
|
530
|
+
verdict.reason,
|
|
531
|
+
group.finding_id,
|
|
532
|
+
sorted(group.tests),
|
|
533
|
+
group.example,
|
|
534
|
+
)
|
|
535
|
+
written = self._written_reports().get(group.finding_id)
|
|
536
|
+
self.findings[index] = self.findings[index].model_copy(
|
|
537
|
+
update={
|
|
538
|
+
"label": verdict.label,
|
|
539
|
+
"confidence": Confidence(verdict.confidence),
|
|
540
|
+
"reason": verdict.reason,
|
|
541
|
+
"action": _action_for(verdict.label.value, written),
|
|
542
|
+
}
|
|
543
|
+
)
|
|
544
|
+
|
|
545
|
+
def execute_once(self, ctx: RunContext) -> None:
|
|
546
|
+
"""Run every saved test once, for `drift`. Nothing is labeled or fixed."""
|
|
547
|
+
if not is_reachable(self.base_url):
|
|
548
|
+
raise StopRun(StopReason.API_UNREACHABLE, f"no response from {self.base_url}")
|
|
549
|
+
for outcome in self._run_tests(None):
|
|
550
|
+
key = test_id(outcome.workflow_id)
|
|
551
|
+
self.outcomes[key] = outcome
|
|
552
|
+
ctx.state.closed_tests[key] = "passed" if outcome.ok else Label.UNRESOLVED.value
|
|
553
|
+
|
|
554
|
+
def fix(self, ctx: RunContext, failure: TestOutcome) -> None:
|
|
555
|
+
verdict = read_verdicts(self.workdir)[failure.test_id]
|
|
556
|
+
workflow_id = failure.test_id.rsplit("::test_", 1)[1]
|
|
557
|
+
values = {"workflow_id": workflow_id, "reason": verdict.reason, "failure": failure.message}
|
|
558
|
+
if verdict.fix_target == "test":
|
|
559
|
+
ctx.run_role(self.writer, render_prompt("task_fix_test", **values), workflow_id)
|
|
560
|
+
return
|
|
561
|
+
ctx.run_role(self.planner, render_prompt("task_fix_workflow", **values), workflow_id)
|
|
562
|
+
for workflow in load_workflows(self.workdir):
|
|
563
|
+
if workflow.id == workflow_id:
|
|
564
|
+
try:
|
|
565
|
+
_write_test_if_changed(workflow, self.graph, self.workdir)
|
|
566
|
+
except GenerationError as exc:
|
|
567
|
+
raise StopRun(StopReason.INVALID_OUTPUT, f"{workflow_id}: {exc}") from exc
|
|
568
|
+
|
|
569
|
+
def _save_and_render(self, workflow: Workflow) -> None:
|
|
570
|
+
atomic_write_text(
|
|
571
|
+
self.workdir / "workflows" / f"{workflow.id}.json",
|
|
572
|
+
workflow.model_dump_json(indent=2, exclude_none=True) + "\n",
|
|
573
|
+
)
|
|
574
|
+
atomic_write_text(
|
|
575
|
+
self.workdir / test_path(workflow.id), generate(workflow, self.graph).source
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
def update_known_issues(self, ctx: RunContext) -> None:
|
|
579
|
+
"""Mark real failures as expected, then confirm the mark with one run. No model involved.
|
|
580
|
+
|
|
581
|
+
A locked file is never edited, and a test the Writer fixed by hand is never regenerated, so
|
|
582
|
+
those failures simply stay failing.
|
|
583
|
+
"""
|
|
584
|
+
verdicts = read_verdicts(self.workdir)
|
|
585
|
+
marked: list[str] = []
|
|
586
|
+
for workflow in load_workflows(self.workdir):
|
|
587
|
+
key = test_id(workflow.id)
|
|
588
|
+
outcome = self.outcomes.get(key)
|
|
589
|
+
verdict = verdicts.get(key)
|
|
590
|
+
if (
|
|
591
|
+
outcome is None
|
|
592
|
+
or outcome.passed
|
|
593
|
+
or outcome.expected
|
|
594
|
+
or verdict is None
|
|
595
|
+
or verdict.label.value not in EXPECTABLE_LABELS
|
|
596
|
+
or ctx.state.closed_tests.get(key) != verdict.label.value
|
|
597
|
+
or workflow.locked
|
|
598
|
+
or not _is_unedited(workflow, self.graph, self.workdir)
|
|
599
|
+
):
|
|
600
|
+
continue
|
|
601
|
+
issue = _known_issue_from(workflow, outcome, verdict)
|
|
602
|
+
if issue is not None:
|
|
603
|
+
issue = issue.model_copy(update={"report": self.test_files.get(key)})
|
|
604
|
+
self._save_and_render(workflow.model_copy(update={"known_issue": issue}))
|
|
605
|
+
marked.append(workflow.id)
|
|
606
|
+
if marked:
|
|
607
|
+
for outcome in self._run_tests(marked):
|
|
608
|
+
self.outcomes[test_id(outcome.workflow_id)] = outcome
|
|
609
|
+
self._save_progress()
|
|
610
|
+
self._clear_fixed()
|
|
611
|
+
|
|
612
|
+
def _clear_fixed(self) -> None:
|
|
613
|
+
"""Remove the mark from a test that now passes. The bug looks fixed."""
|
|
614
|
+
for workflow in load_workflows(self.workdir):
|
|
615
|
+
outcome = self.outcomes.get(test_id(workflow.id))
|
|
616
|
+
if (
|
|
617
|
+
workflow.known_issue is not None
|
|
618
|
+
and outcome is not None
|
|
619
|
+
and outcome.passed
|
|
620
|
+
and not workflow.locked
|
|
621
|
+
):
|
|
622
|
+
self.looks_fixed.append(f"{workflow.id}: {workflow.known_issue.reason}")
|
|
623
|
+
self._save_and_render(workflow.model_copy(update={"known_issue": None}))
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def _unreviewed_finding(group: MismatchGroup) -> Finding:
|
|
627
|
+
return Finding(
|
|
628
|
+
id=group.finding_id,
|
|
629
|
+
message=group.message,
|
|
630
|
+
operations=sorted(group.operations),
|
|
631
|
+
tests=sorted(group.tests),
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _action_for(label: str, path: str | None) -> Action:
|
|
636
|
+
if path is None:
|
|
637
|
+
return Action(kind=ActionKind.NONE)
|
|
638
|
+
kind = ActionKind.SPEC_PATCH if label == Label.SPEC_DRIFT.value else ActionKind.BUG_REPORT
|
|
639
|
+
return Action(kind=kind, path=path)
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def _evidence_for(outcome: Outcome, graph: SpecGraph) -> Evidence:
|
|
643
|
+
last = outcome.calls[-1] if outcome.calls else {}
|
|
644
|
+
operation = graph.operation_at(str(last.get("method")), str(last.get("template")))
|
|
645
|
+
excerpt = (
|
|
646
|
+
operation.model_dump_json(by_alias=True, exclude_none=True)[:2000] if operation else None
|
|
647
|
+
)
|
|
648
|
+
return Evidence(
|
|
649
|
+
exchanges=[HttpExchange.model_validate(e) for e in outcome.exchanges], spec_excerpt=excerpt
|
|
650
|
+
)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _final_verdict(
|
|
654
|
+
status: str,
|
|
655
|
+
stored: StoredVerdict | None,
|
|
656
|
+
outcome: Outcome,
|
|
657
|
+
issue: KnownIssue | None,
|
|
658
|
+
graph: SpecGraph,
|
|
659
|
+
report: str | None = None,
|
|
660
|
+
) -> Verdict | None:
|
|
661
|
+
if outcome.expected and issue is not None:
|
|
662
|
+
return Verdict(
|
|
663
|
+
label=Label(issue.label),
|
|
664
|
+
confidence=Confidence(issue.confidence),
|
|
665
|
+
reason=issue.reason,
|
|
666
|
+
evidence=_evidence_for(outcome, graph),
|
|
667
|
+
action=_action_for(issue.label, issue.report),
|
|
668
|
+
)
|
|
669
|
+
if status == "passed" or outcome.passed:
|
|
670
|
+
return None
|
|
671
|
+
label = Label(status)
|
|
672
|
+
if stored is not None and stored.label is label:
|
|
673
|
+
confidence, reason = Confidence(stored.confidence), stored.reason
|
|
674
|
+
else:
|
|
675
|
+
confidence = Confidence.LOW
|
|
676
|
+
reason = f"not resolved: {_failure_text(outcome)}"
|
|
677
|
+
return Verdict(
|
|
678
|
+
label=label,
|
|
679
|
+
confidence=confidence,
|
|
680
|
+
reason=reason,
|
|
681
|
+
evidence=_evidence_for(outcome, graph),
|
|
682
|
+
action=_action_for(status, report),
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def _build_results(session: _Session, run_result: RunResult) -> RunResults:
|
|
687
|
+
verdicts = read_verdicts(session.workdir)
|
|
688
|
+
workflows = {workflow.id: workflow for workflow in load_workflows(session.workdir)}
|
|
689
|
+
state = run_result.state
|
|
690
|
+
tests = []
|
|
691
|
+
for key, status in sorted(run_result.tests.items()):
|
|
692
|
+
workflow_id = key.rsplit("::test_", 1)[1]
|
|
693
|
+
outcome = session.outcomes.get(key) or Outcome(workflow_id, "error")
|
|
694
|
+
workflow = workflows.get(workflow_id)
|
|
695
|
+
if workflow is None:
|
|
696
|
+
continue
|
|
697
|
+
verdict = _final_verdict(
|
|
698
|
+
status,
|
|
699
|
+
verdicts.get(key),
|
|
700
|
+
outcome,
|
|
701
|
+
workflow.known_issue,
|
|
702
|
+
session.graph,
|
|
703
|
+
session.test_files.get(key),
|
|
704
|
+
)
|
|
705
|
+
tests.append(
|
|
706
|
+
TestResult(
|
|
707
|
+
id=key,
|
|
708
|
+
workflow_id=workflow_id,
|
|
709
|
+
basis=workflow.basis,
|
|
710
|
+
status=TestStatus(outcome.status),
|
|
711
|
+
duration_s=max(outcome.duration_s, 0.0),
|
|
712
|
+
failing_assertion=outcome.failing_assertion,
|
|
713
|
+
stdout=outcome.stdout,
|
|
714
|
+
exchanges=[HttpExchange.model_validate(e) for e in outcome.exchanges[-3:]],
|
|
715
|
+
fix_attempts=state.fix_attempts.get(key, 0),
|
|
716
|
+
verdict=verdict,
|
|
717
|
+
)
|
|
718
|
+
)
|
|
719
|
+
return RunResults(
|
|
720
|
+
uncovered_operations=uncovered_operations(
|
|
721
|
+
workflows.values(), session.graph, session.login_operations
|
|
722
|
+
),
|
|
723
|
+
stop_reason=run_result.stop_reason.value,
|
|
724
|
+
stop_detail=run_result.detail or None,
|
|
725
|
+
tests=tests,
|
|
726
|
+
findings=session.findings,
|
|
727
|
+
reviews=session._review_log().rounds,
|
|
728
|
+
tasks_stopped=state.tasks_stopped,
|
|
729
|
+
input_tokens=state.input_tokens,
|
|
730
|
+
output_tokens=state.output_tokens,
|
|
731
|
+
cost_usd=state.cost_usd,
|
|
732
|
+
unpriced_calls=state.unpriced_calls,
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def _read_expectation_changes(workdir: Path) -> list[str]:
|
|
737
|
+
path = workdir / ".workflowval" / "expectation_changes.jsonl"
|
|
738
|
+
if not path.is_file():
|
|
739
|
+
return []
|
|
740
|
+
lines = []
|
|
741
|
+
for line in path.read_text(encoding="utf-8").splitlines():
|
|
742
|
+
entry = json.loads(line)
|
|
743
|
+
lines += [f"{entry['workflow_id']}: {change}" for change in entry["changes"]]
|
|
744
|
+
return lines
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
def _reports_folder(config: TestConfig) -> Path:
|
|
748
|
+
return config.reports_dir or config.workdir / "reports"
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _tool_context(config: TestConfig, graph: SpecGraph, raw: dict[str, Any]) -> ToolContext:
|
|
752
|
+
return ToolContext(
|
|
753
|
+
config.workdir, graph, config.docs_dir, _reports_folder(config), raw, config.auth
|
|
754
|
+
)
|
|
755
|
+
|
|
756
|
+
|
|
757
|
+
def results_from_outcomes(outcomes: Sequence[Outcome], workdir: Path) -> RunResults:
|
|
758
|
+
"""Build results from a model-free `run`. Only marks already reviewed carry a label."""
|
|
759
|
+
workflows = {workflow.id: workflow for workflow in load_workflows(workdir)}
|
|
760
|
+
tests = []
|
|
761
|
+
for outcome in outcomes:
|
|
762
|
+
workflow = workflows.get(outcome.workflow_id)
|
|
763
|
+
issue = workflow.known_issue if workflow else None
|
|
764
|
+
verdict = None
|
|
765
|
+
if outcome.expected and issue is not None:
|
|
766
|
+
verdict = Verdict(
|
|
767
|
+
label=Label(issue.label),
|
|
768
|
+
confidence=Confidence(issue.confidence),
|
|
769
|
+
reason=issue.reason,
|
|
770
|
+
action=_action_for(issue.label, issue.report),
|
|
771
|
+
)
|
|
772
|
+
tests.append(
|
|
773
|
+
TestResult(
|
|
774
|
+
id=test_id(outcome.workflow_id),
|
|
775
|
+
workflow_id=outcome.workflow_id,
|
|
776
|
+
basis=workflow.basis if workflow else Basis.ASSUMED,
|
|
777
|
+
status=TestStatus(outcome.status),
|
|
778
|
+
duration_s=max(outcome.duration_s, 0.0),
|
|
779
|
+
failing_assertion=outcome.failing_assertion,
|
|
780
|
+
stdout=outcome.stdout,
|
|
781
|
+
exchanges=[HttpExchange.model_validate(e) for e in outcome.exchanges[-3:]],
|
|
782
|
+
verdict=verdict,
|
|
783
|
+
)
|
|
784
|
+
)
|
|
785
|
+
return RunResults(stop_reason="completed", tests=tests)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def run_drift_pipeline(config: TestConfig) -> TestRunReport:
|
|
789
|
+
"""Run `workflowval drift`: re-run saved tests and rule only on what breaks the spec."""
|
|
790
|
+
spec = load_spec(config.spec_source)
|
|
791
|
+
if not any((config.workdir / "workflows").glob("*.json")):
|
|
792
|
+
raise SpecError("no workflows here yet: run `workflowval test` first")
|
|
793
|
+
graph = build_graph(spec)
|
|
794
|
+
base_url = resolve_base_url(config.base_url, graph)
|
|
795
|
+
raw = load_raw_spec(config.spec_source)
|
|
796
|
+
session = _Session(config, graph, base_url, ResponseChecker(raw))
|
|
797
|
+
for name in ("verdicts.json", "evidence.json", "reports.json"):
|
|
798
|
+
(config.workdir / ".workflowval" / name).unlink(missing_ok=True)
|
|
799
|
+
# `drift` isn't a test run, so the next `test` must still know the plan is done.
|
|
800
|
+
state_path = config.workdir / ".workflowval" / "state.json"
|
|
801
|
+
earlier_state = state_path.read_bytes() if state_path.is_file() else None
|
|
802
|
+
result = run(
|
|
803
|
+
Pipeline(
|
|
804
|
+
(
|
|
805
|
+
CodeStep("render_tests", session.render_tests),
|
|
806
|
+
CodeStep("execute_once", session.execute_once),
|
|
807
|
+
CodeStep("review_mismatches", session.review_mismatches),
|
|
808
|
+
)
|
|
809
|
+
),
|
|
810
|
+
config.budget,
|
|
811
|
+
workdir=config.workdir,
|
|
812
|
+
spec_hash=spec_hash(spec),
|
|
813
|
+
verbose=config.verbose,
|
|
814
|
+
tool_context=_tool_context(config, graph, raw),
|
|
815
|
+
cost_tracked=config.cost_tracked,
|
|
816
|
+
)
|
|
817
|
+
if earlier_state is not None:
|
|
818
|
+
state_path.write_bytes(earlier_state)
|
|
819
|
+
results = _build_results(session, result)
|
|
820
|
+
results_path = config.workdir / "results.json"
|
|
821
|
+
save_json(results, results_path)
|
|
822
|
+
write_reports(results, _reports_folder(config))
|
|
823
|
+
return TestRunReport(result, results, results_path, [], False)
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def run_test_pipeline(config: TestConfig) -> TestRunReport:
|
|
827
|
+
"""Run `workflowval test`. Spec problems raise SpecError before any model is called."""
|
|
828
|
+
spec = load_spec(config.spec_source)
|
|
829
|
+
graph = build_graph(spec)
|
|
830
|
+
current_hash = spec_hash(spec)
|
|
831
|
+
base_url = resolve_base_url(config.base_url, graph)
|
|
832
|
+
raw = load_raw_spec(config.spec_source)
|
|
833
|
+
session = _Session(config, graph, base_url, ResponseChecker(raw))
|
|
834
|
+
resume = None if config.plan_only else _resumable(config, current_hash)
|
|
835
|
+
session.needs_planning = resume is not None or _needs_planning(config, current_hash)
|
|
836
|
+
state_dir = config.workdir / ".workflowval"
|
|
837
|
+
if resume is not None:
|
|
838
|
+
session.load_progress()
|
|
839
|
+
else:
|
|
840
|
+
# Each run reports only its own expectation changes and verdicts.
|
|
841
|
+
for name in (
|
|
842
|
+
"expectation_changes.jsonl",
|
|
843
|
+
"verdicts.json",
|
|
844
|
+
"last_results.json",
|
|
845
|
+
"evidence.json",
|
|
846
|
+
"reports.json",
|
|
847
|
+
"progress.json",
|
|
848
|
+
):
|
|
849
|
+
(state_dir / name).unlink(missing_ok=True)
|
|
850
|
+
if session.needs_planning and resume is None:
|
|
851
|
+
(config.workdir / "review.json").unlink(missing_ok=True)
|
|
852
|
+
steps: list[CodeStep | FixLoop] = [
|
|
853
|
+
CodeStep("plan", session.plan_step),
|
|
854
|
+
CodeStep("review_plan", session.review_plan),
|
|
855
|
+
]
|
|
856
|
+
if not config.plan_only:
|
|
857
|
+
steps += [
|
|
858
|
+
CodeStep("render_tests", session.render_tests),
|
|
859
|
+
FixLoop(
|
|
860
|
+
"test_and_fix", session.execute, session.label, session.fix, session.pending_tests
|
|
861
|
+
),
|
|
862
|
+
CodeStep("write_failure_files", session.write_failure_files),
|
|
863
|
+
CodeStep("update_known_issues", session.update_known_issues),
|
|
864
|
+
]
|
|
865
|
+
# Rounds left after the first review each look for what the tests never reached.
|
|
866
|
+
for index in range(config.budget.max_review_rounds):
|
|
867
|
+
steps += [
|
|
868
|
+
CodeStep(f"review_results_{index}", session.review_results),
|
|
869
|
+
CodeStep(f"render_new_tests_{index}", session.render_tests),
|
|
870
|
+
FixLoop(
|
|
871
|
+
f"test_new_{index}",
|
|
872
|
+
session.execute,
|
|
873
|
+
session.label,
|
|
874
|
+
session.fix,
|
|
875
|
+
session.pending_tests,
|
|
876
|
+
),
|
|
877
|
+
CodeStep(f"write_new_failure_files_{index}", session.write_failure_files),
|
|
878
|
+
CodeStep(f"update_new_known_issues_{index}", session.update_known_issues),
|
|
879
|
+
]
|
|
880
|
+
steps += [
|
|
881
|
+
CodeStep("review_mismatches", session.review_mismatches),
|
|
882
|
+
]
|
|
883
|
+
result = run(
|
|
884
|
+
Pipeline(tuple(steps)),
|
|
885
|
+
config.budget,
|
|
886
|
+
workdir=config.workdir,
|
|
887
|
+
spec_hash=current_hash,
|
|
888
|
+
verbose=config.verbose,
|
|
889
|
+
tool_context=_tool_context(config, graph, raw),
|
|
890
|
+
cost_tracked=config.cost_tracked,
|
|
891
|
+
resume=resume,
|
|
892
|
+
)
|
|
893
|
+
results = _build_results(session, result)
|
|
894
|
+
results_path = config.workdir / "results.json"
|
|
895
|
+
save_json(results, results_path)
|
|
896
|
+
if not config.plan_only:
|
|
897
|
+
write_reports(results, _reports_folder(config))
|
|
898
|
+
return TestRunReport(
|
|
899
|
+
result,
|
|
900
|
+
results,
|
|
901
|
+
results_path,
|
|
902
|
+
_read_expectation_changes(config.workdir),
|
|
903
|
+
session.needs_planning,
|
|
904
|
+
session.looks_fixed,
|
|
905
|
+
resume is not None,
|
|
906
|
+
)
|