Viewing files at commit a3ea065926c9Commit detailsView latest

suites/scale/scalesuite/ops.py 936 lines · 42.3 KB · python
1
"""The scale-lane operation catalog: every user-facing op, git and oak.
2
3
Each op is one function per subject kind. Inside it, ONLY ``ctx.timed(...)``
4
calls are measured (their elapsed times are summed into the row); setup,
5
oracle and teardown use ``ctx.untimed(...)`` and run outside the timed path
6
(ADR-0011: oracles untimed). An op whose timed command fails emits no oracle
7
verdict (the row carries the failure); an op with no equivalent for a kind
8
returns a skip (returncode 77 with a reason) so coverage gaps are rows, never
9
silent omissions.
10
11
Oracle evidence classes (``oracle_reader``):
12
- ``git_fixture_instrument``: expected values read from the generated
13
  fixture with git plumbing, compared against the subject's worktree bytes
14
  hashed by the harness (independent of the subject under test);
15
- ``subject_output``: the subject's own output parsed by the harness
16
  (counts, names, JSON fields);
17
- ``subject_file_inspect``: Oak's ``oak file inspect`` (subject-produced)
18
  or git's ``git show``/``cat-file`` of the subject repository.
19
20
t/perf references cite upstream git test FILE NAMES only (GPL-2.0); the ops
21
are re-implemented here, nothing is copied (docs/prior-art.md).
22
"""
23
24
from __future__ import annotations
25
26
import hashlib
27
import json
28
import os
29
import random
30
import shutil
31
from dataclasses import dataclass, field
32
from pathlib import Path
33
from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple
34
35
from . import generator as gen
36
37
UNIT_ORDER: Tuple[Tuple[str, ...], ...] = (
38
    ("status.clean",),
39
    ("log.full",),
40
    ("log.n",),
41
    ("log.path",),
42
    ("blame",),
43
    ("file.show",),
44
    ("branch.list",),
45
    ("review.branch_diff",),
46
    ("merge.preview",),
47
    ("merge.preview.conflict",),
48
    ("switch.existing", "switch.back"),
49
    ("branch.create",),
50
    ("status.dirty", "diff.dirty", "commit.dirty"),
51
    ("merge.local",),
52
    ("merge.conflict",),
53
    ("push.branch", "pull.ff"),
54
    ("pull.diverged",),
55
    ("clone.full",),
56
    ("gc",),
57
)
58
59
60
@dataclass(frozen=True)
61
class OpInfo:
62
    name: str
63
    description: str
64
    tperf: Tuple[str, ...] = ()
65
    core_yaml: Tuple[str, ...] = ()
66
    mutates: bool = False
67
68
69
OP_INFO: Dict[str, OpInfo] = {info.name: info for info in (
70
    OpInfo("status.clean", "Status of a clean working tree (porcelain).",
71
           ("p0005-status.sh", "p7519-fsmonitor.sh"), ("status.clean",)),
72
    OpInfo("status.dirty", "Status after the tier's dirty shape (modify/add/delete/rename).",
73
           ("p0005-status.sh", "p7519-fsmonitor.sh"), ("status.dirty",)),
74
    OpInfo("diff.dirty", "Full worktree diff of the dirty shape.",
75
           ("p4000-diff-algorithms.sh", "p0009-diff-pathspec.sh"), ("diff.dirty",)),
76
    OpInfo("commit.dirty", "Commit the dirty shape (git: add -A + commit, summed).",
77
           ("p0007-write-cache.sh", "p0090-cache-tree.sh"), ("snapshot.dirty",), mutates=True),
78
    OpInfo("log.full", "Entire history reachable from the current branch, one line per commit.",
79
           ("p0001-rev-list.sh", "p4205-log-pretty-formats.sh"), ("history.log.range",)),
80
    OpInfo("log.n", "The newest 20 commits.", ("p4205-log-pretty-formats.sh",), ("history.log.tip",)),
81
    OpInfo("log.path", "History of one path (the fixture's probe path).",
82
           ("p4211-line-log.sh", "p0001-rev-list.sh"), ("history.log.range",)),
83
    OpInfo("blame", "Line authorship of the probe path.", ("p8020-last-modified.sh",), ()),
84
    OpInfo("file.show", "Committed bytes of the probe path at HEAD.", ("p1006-cat-file.sh",), ()),
85
    OpInfo("branch.list", "Enumerate branches (oak: open branches; harness branches are closed, git deletes them).", ("p6300-for-each-ref.sh",), ("branch.list",)),
86
    OpInfo("branch.create", "Create and switch to a new branch.", ("p1400-update-ref.sh",),
87
           ("branch.create",), mutates=True),
88
    OpInfo("switch.existing", "Switch to the fixture's side branch b00000.",
89
           ("p0006-read-tree-checkout.sh", "p2000-sparse-operations.sh"), ("branch.switch",), mutates=True),
90
    OpInfo("switch.back", "Switch back to the base branch.", ("p0006-read-tree-checkout.sh",),
91
           ("history.restore_head",), mutates=True),
92
    OpInfo("review.branch_diff", "Checkout-free diff of b00000 against main.",
93
           ("p4000-diff-algorithms.sh", "p6010-merge-base.sh"), ()),
94
    OpInfo("merge.preview", "Dry-run merge prediction of b00000 into main.",
95
           ("p6010-merge-base.sh",), ()),
96
    OpInfo("merge.preview.conflict", "Dry-run merge prediction of the conflict-shape branches.",
97
           ("p6010-merge-base.sh",), ("merge.conflict",)),
98
    OpInfo("merge.local", "Local (non-fast-forward) merge of b00000 into a scratch branch.",
99
           ("p6010-merge-base.sh", "p3400-rebase.sh"), (), mutates=True),
100
    OpInfo("merge.conflict", "Local merge of the conflict-shape branches (expected to conflict), then abort.",
101
           ("p6010-merge-base.sh",), ("merge.conflict", "merge.abort"), mutates=True),
102
    OpInfo("push.branch", "Push one new commit over loopback HTTP.",
103
           ("p5313-pack-objects.sh", "p5310-pack-bitmaps.sh"), (), mutates=True),
104
    OpInfo("pull.ff", "Pull one upstream commit (fast-forward).",
105
           ("p5311-pack-bitmaps-fetch.sh", "p5551-fetch-rescan.sh"), (), mutates=True),
106
    OpInfo("pull.diverged", "Pull when local and upstream both have one new commit.",
107
           ("p5311-pack-bitmaps-fetch.sh", "p3400-rebase.sh"), (), mutates=True),
108
    OpInfo("clone.full", "Full clone over loopback HTTP into a new directory.",
109
           ("p5600-partial-clone.sh", "p5601-clone-reference.sh"), ()),
110
    OpInfo("gc", "Repository maintenance (git gc / oak maintenance compact).",
111
           ("p5304-prune.sh", "p5313-pack-objects.sh", "p5303-many-packs.sh"), (), mutates=True),
112
)}
113
114
ALL_OPS: Tuple[str, ...] = tuple(op for unit in UNIT_ORDER for op in unit)
115
assert set(ALL_OPS) == set(OP_INFO), "UNIT_ORDER and OP_INFO must list the same operations"
116
117
118
@dataclass
119
class Timed:
120
    label: str
121
    command: List[str]
122
    capture: Any
123
124
125
@dataclass
126
class Outcome:
127
    timed: List[Timed] = field(default_factory=list)
128
    failed: Optional[str] = None
129
    skip_reason: Optional[str] = None
130
    oracle_ok: Optional[bool] = None
131
    oracle_method: Optional[str] = None
132
    oracle_reader: Optional[str] = None
133
    oracle_detail: Dict[str, Any] = field(default_factory=dict)
134
    semantic_class: Optional[str] = None
135
    extra: Dict[str, Any] = field(default_factory=dict)
136
137
138
class OpFailed(Exception):
139
    """A timed command failed; the row records it and the unit aborts."""
140
141
142
class OpSkipped(Exception):
143
    pass
144
145
146
class Ctx:
147
    """Per (subject, run, op) execution context."""
148
149
    def __init__(self, prepared: Any, subject: Any, run: int, runner: Callable[..., Any],
150
                 scratch: Path, timeout_s: float) -> None:
151
        self.p = prepared
152
        self.subject = subject
153
        self.kind = subject.kind
154
        self.bin = str(subject.bin)
155
        self.run = run
156
        self._run_timed = runner
157
        self.scratch = scratch
158
        self.timeout_s = timeout_s
159
        self.outcome = Outcome()
160
        self.tag = f"{subject.name}-r{run}"
161
162
    # -- command helpers ----------------------------------------------------
163
164
    def timed(self, args: Sequence[str], cwd: Path, *, label: str = "main", accept: Sequence[int] = (0,),
165
              raw: bool = False) -> Any:
166
        command = [self.bin, *args]
167
        capture = self._run_timed(command, cwd, self.p.env, 4000, full_output_bytes=64 << 20,
168
                                  watchdog_seconds=self.timeout_s, raw_output_bytes=(64 << 20) if raw else None)
169
        self.outcome.timed.append(Timed(label, command, capture))
170
        if capture.returncode not in accept:
171
            tail = (capture.stderr_text or capture.stdout_text or "")[-400:]
172
            raise OpFailed(f"{label}: exit {capture.returncode}: {tail}")
173
        return capture
174
175
    def untimed(self, args: Sequence[str], cwd: Path, *, check: bool = True, binary: Optional[str] = None
176
                ) -> Any:
177
        from .workspace import run
178
        return run([binary or self.bin, *args], cwd=cwd, env=self.p.env, check=check)
179
180
    def git(self, args: Sequence[str], cwd: Path, check: bool = True) -> Any:
181
        """The oracle's git instrument (the resolved real git), never the subject binary."""
182
        return self.untimed(args, cwd, check=check, binary=self.p.git_bin)
183
184
    def oracle(self, ok: bool, method: str, reader: str, **detail: Any) -> None:
185
        self.outcome.oracle_ok = None if "reader_unavailable" in detail else bool(ok)
186
        self.outcome.oracle_method = method
187
        self.outcome.oracle_reader = reader
188
        self.outcome.oracle_detail.update(detail)
189
190
    # -- workspace paths ----------------------------------------------------
191
192
    @property
193
    def work(self) -> Path:
194
        return self.p.path(self.kind, "work")
195
196
    def client(self, name: str) -> Path:
197
        return self.p.path(self.kind, name)
198
199
    @property
200
    def state(self) -> Dict[str, Any]:
201
        return self.p.state.setdefault(self.kind, {"harness_commits": 0, "deleted": set(), "server_extra": 0,
202
                                                     "branch_seq": 0})
203
204
205
# ---------------------------------------------------------------------------
206
# Shared oracle helpers (harness-side, independent of the subject)
207
# ---------------------------------------------------------------------------
208
209
210
def worktree_files(root: Path) -> List[str]:
211
    out = []
212
    for base, dirs, files in os.walk(root):
213
        dirs[:] = [d for d in dirs if d not in (".git", ".oak")]
214
        rel = os.path.relpath(base, root)
215
        for name in files:
216
            out.append(name if rel == "." else f"{rel}/{name}".replace(os.sep, "/"))
217
    return out
218
219
220
def blob_id_of(path: Path) -> str:
221
    digest = hashlib.sha1()
222
    size = path.stat().st_size
223
    digest.update(b"blob %d\0" % size)
224
    with path.open("rb") as fh:
225
        for chunk in iter(lambda: fh.read(1 << 20), b""):
226
            digest.update(chunk)
227
    return digest.hexdigest()
228
229
230
def check_worktree(ctx: Ctx, root: Path, expected_count: Optional[int], ignore_prefixes: Sequence[str] = ()
231
                   ) -> Tuple[bool, Dict[str, Any]]:
232
    """Sampled content check against the fixture plus an exact file count."""
233
    exp = ctx.p.expected
234
    files = [f for f in worktree_files(root) if not any(f.startswith(prefix) for prefix in ignore_prefixes)]
235
    mismatched = []
236
    for rel, blob in exp.sample.items():
237
        if rel in ctx.state["deleted"]:
238
            continue
239
        target = root / rel
240
        if not target.is_file() or blob_id_of(target) != blob:
241
            mismatched.append(rel)
242
    detail = {"files_seen": len(files), "files_expected": expected_count, "sample_checked": len(exp.sample),
243
              "sample_mismatched": mismatched[:10], "sample_mismatch_count": len(mismatched)}
244
    ok = not mismatched and (expected_count is None or len(files) == expected_count)
245
    return ok, detail
246
247
248
def porcelain_paths(text: str) -> List[str]:
249
    paths = []
250
    for line in text.splitlines():
251
        line = line.rstrip()
252
        if len(line) < 3:
253
            continue
254
        rest = line[2:].strip() if line[2] == " " or line[1] == " " else line.split(None, 1)[-1]
255
        if " -> " in rest:
256
            old, new = rest.split(" -> ", 1)
257
            paths.extend([old.strip('"'), new.strip('"')])
258
        else:
259
            paths.append(rest.strip('"'))
260
    return paths
261
262
263
def committed_bytes(ctx: Ctx, repo: Path, paths: Sequence[str]) -> Tuple[Dict[str, bytes], str, Dict[str, Any]]:
264
    """Committed HEAD bytes of ``paths`` read WITHOUT the subject binary.
265
266
    git: ``git cat-file`` with the oracle's own git. oak: the independent
267
    stdlib reader of Oak's SQLite store (oakbench.oak_sqlite_reader). Returns
268
    (payloads, reader label, meta). A reader bound (Unavailable) puts
269
    ``reader_unavailable`` in meta; Ctx.oracle then records oracle_ok None
270
    (unmeasured, ADR-0002) instead of a guess.
271
    """
272
    if _is_git(ctx):
273
        out: Dict[str, bytes] = {}
274
        for rel in paths:
275
            proc = ctx.git(["cat-file", "blob", f"HEAD:{rel}"], repo, check=False)
276
            if proc.returncode == 0:
277
                out[rel] = proc.stdout
278
        return out, "git_cat_file_oracle_instrument", {}
279
    from oakbench import oak_sqlite_reader as reader
280
    try:
281
        payloads, meta = reader.committed_payloads(Path(repo), list(paths))
282
    except reader.Unavailable as exc:
283
        # A harness/reader bound, not a property of Oak's data: the oracle
284
        # becomes unmeasured (Ctx.oracle turns this into oracle_ok None).
285
        return {}, "oak_sqlite_reader_independent", {"reader_unavailable": str(exc)[:300]}
286
    except reader.Invalid as exc:
287
        return {}, "oak_sqlite_reader_independent", {"invalid": str(exc)[:300]}
288
    return payloads, "oak_sqlite_reader_independent", {"head": meta.get("head"),
289
                                                       "reader_version": meta.get("reader_version")}
290
291
292
# ---------------------------------------------------------------------------
293
# Dirty shape (untimed setup)
294
# ---------------------------------------------------------------------------
295
296
297
def apply_dirty(ctx: Ctx) -> Dict[str, Any]:
298
    spec = ctx.p.tier.spec
299
    shape = ctx.p.tier.dirty
300
    cache = gen.layout(spec)
301
    factory = gen.ContentFactory(spec)
302
    protected = set(ctx.p.expected.protected)
303
    rng = random.Random(f"{spec.seed}:dirty:{ctx.tag}")
304
    deleted: set = ctx.state["deleted"]
305
    wanted = shape.modified + shape.deleted + shape.renamed
306
    pool: List[int] = []
307
    attempts = 0
308
    seen = set()
309
    while len(pool) < wanted and attempts < wanted * 20 + 100:
310
        attempts += 1
311
        index = rng.randrange(spec.files)
312
        path = gen.file_path(spec, index, cache)
313
        if index in seen or path in protected or path in deleted:
314
            continue
315
        seen.add(index)
316
        pool.append(index)
317
    modified = pool[:shape.modified]
318
    removed = pool[shape.modified:shape.modified + shape.deleted]
319
    renamed = pool[shape.modified + shape.deleted:]
320
    root = ctx.work
321
    mod_paths, del_paths, ren_pairs, add_paths = [], [], [], []
322
    for index in modified:
323
        path = gen.file_path(spec, index, cache)
324
        (root / path).write_bytes(factory.text(index, f"dirty-{ctx.tag}"))
325
        mod_paths.append(path)
326
    for index in removed:
327
        path = gen.file_path(spec, index, cache)
328
        (root / path).unlink()
329
        del_paths.append(path)
330
    for index in renamed:
331
        path = gen.file_path(spec, index, cache)
332
        target = f"renamed/{ctx.tag}/f{index}.txt"
333
        (root / target).parent.mkdir(parents=True, exist_ok=True)
334
        os.replace(root / path, root / target)
335
        ren_pairs.append((path, target))
336
    for number in range(shape.added):
337
        rel = f"added/{ctx.tag}/n{number}.txt"
338
        (root / rel).parent.mkdir(parents=True, exist_ok=True)
339
        (root / rel).write_bytes(factory.text(10_000_000 + number, f"add-{ctx.tag}"))
340
        add_paths.append(rel)
341
    dirty = {"modified": mod_paths, "deleted": del_paths, "renamed": ren_pairs, "added": add_paths,
342
             "requested": [shape.modified, shape.added, shape.deleted, shape.renamed],
343
             "applied": [len(mod_paths), len(add_paths), len(del_paths), len(ren_pairs)]}
344
    ctx.state["dirty"] = dirty
345
    return dirty
346
347
348
# ---------------------------------------------------------------------------
349
# Operations. Each returns None; results land in ctx.outcome.
350
# ---------------------------------------------------------------------------
351
352
353
def _is_git(ctx: Ctx) -> bool:
354
    return ctx.kind == "git"
355
356
357
def op_status_clean(ctx: Ctx) -> None:
358
    args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
359
    cap = ctx.timed(args, ctx.work)
360
    paths = porcelain_paths(cap.stdout_full_text or "")
361
    ctx.oracle(not paths, "clean_status_reports_no_paths", "subject_output", reported_paths=paths[:10])
362
363
364
def _dirty_branch_enter(ctx: Ctx) -> None:
365
    """The dirty cycle runs on a throwaway branch (untimed switch), so the base
366
    branch never accumulates harness commits: every run's switch/log/merge ops
367
    see the same repository state (no run-over-run drift)."""
368
    name = f"dirty-{ctx.tag}"
369
    if _is_git(ctx):
370
        ctx.git(["switch", "-q", "-c", name, "main"], ctx.work)
371
    else:
372
        ctx.untimed(["switch", "-c", name], ctx.work)
373
    ctx.state["dirty_branch"] = name
374
375
376
def _dirty_branch_leave(ctx: Ctx) -> None:
377
    name = ctx.state.pop("dirty_branch", None)
378
    if name is None:
379
        return
380
    _drop_branch(ctx, name)
381
382
383
def _drop_branch(ctx: Ctx, name: str) -> None:
384
    """Untimed teardown: back to base, then remove the harness branch so branch
385
    counts (branch.list) stay fixed across runs. git deletes it; Oak has no
386
    local delete, so it is closed (and oak branch.list lists open branches)."""
387
    if _is_git(ctx):
388
        ctx.git(["switch", "-q", "-f", "main"], ctx.work)
389
        ctx.git(["branch", "-q", "-D", name], ctx.work, check=False)
390
    else:
391
        ctx.untimed(["switch", _base_branch(ctx)], ctx.work)
392
        ctx.untimed(["close", name], ctx.work, check=False)
393
394
395
def recover(ctx: Ctx) -> Optional[str]:
396
    """Untimed repair after a failed unit: discard worktree changes, return to base."""
397
    try:
398
        ctx.state.pop("dirty", None)
399
        stale = ctx.state.pop("dirty_branch", None)
400
        if _is_git(ctx):
401
            ctx.git(["merge", "--abort"], ctx.work, check=False)
402
            ctx.git(["reset", "-q", "--hard"], ctx.work, check=False)
403
            ctx.git(["clean", "-fdq", "-e", "build/"], ctx.work, check=False)
404
            ctx.git(["switch", "-q", "-f", "main"], ctx.work)
405
        else:
406
            ctx.untimed(["reset", "-f"], ctx.work, check=False)
407
            for extra in ("added", "renamed"):
408
                shutil.rmtree(ctx.work / extra, ignore_errors=True)
409
            ctx.untimed(["switch", _base_branch(ctx)], ctx.work)
410
        if stale:
411
            _drop_branch(ctx, stale)
412
        return None
413
    except Exception as exc:  # noqa: BLE001 - reported on the row, never hidden
414
        return f"{type(exc).__name__}: {str(exc)[-300:]}"
415
416
417
def op_status_dirty(ctx: Ctx) -> None:
418
    _dirty_branch_enter(ctx)
419
    dirty = apply_dirty(ctx)
420
    ctx.outcome.extra["dirty_applied"] = dirty["applied"]
421
    args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
422
    cap = ctx.timed(args, ctx.work)
423
    text = cap.stdout_full_text or ""
424
    reported = set(porcelain_paths(text))
425
    must = set(dirty["modified"]) | set(dirty["deleted"]) | {old for old, _ in dirty["renamed"]}
426
    missing = sorted(must - reported)
427
    # Git collapses untracked directories ("?? added/<tag>/"); accept a
428
    # reported ancestor directory for added/renamed targets.
429
    new_paths = dirty["added"] + [new for _, new in dirty["renamed"]]
430
    uncovered = [p for p in new_paths if p not in reported and not any(
431
        p.startswith(r.rstrip("/") + "/") for r in reported if r.endswith("/") or r in ("added", "renamed"))]
432
    ctx.oracle(not missing and not uncovered, "dirty_paths_reported", "subject_output",
433
               missing=missing[:10], missing_count=len(missing), uncovered_new=uncovered[:10],
434
               uncovered_new_count=len(uncovered), stdout_bytes=cap.stdout_bytes)
435
436
437
def op_diff_dirty(ctx: Ctx) -> None:
438
    dirty = ctx.state.get("dirty")
439
    if not dirty:
440
        raise OpFailed("diff.dirty needs status.dirty's dirty shape")
441
    args = ["diff", "--no-ext-diff"] if _is_git(ctx) else ["diff", "--print"]
442
    cap = ctx.timed(args, ctx.work)
443
    text = cap.stdout_full_text
444
    if text is None:
445
        ctx.oracle(cap.stdout_bytes > 0, "diff_output_nonempty_over_8MiB", "subject_output",
446
                   stdout_bytes=cap.stdout_bytes)
447
        return
448
    must = dirty["modified"] + dirty["deleted"]
449
    missing = [p for p in must if p not in text]
450
    ctx.oracle(not missing, "diff_mentions_modified_and_deleted_paths", "subject_output",
451
               missing=missing[:10], missing_count=len(missing), stdout_bytes=cap.stdout_bytes)
452
453
454
def op_commit_dirty(ctx: Ctx) -> None:
455
    dirty = ctx.state.pop("dirty", None)
456
    if not dirty:
457
        raise OpFailed("commit.dirty needs status.dirty's dirty shape")
458
    if _is_git(ctx):
459
        ctx.timed(["add", "-A"], ctx.work, label="add")
460
        ctx.timed(["commit", "-q", "--no-verify", "-m", f"dirty {ctx.tag}"], ctx.work, label="commit")
461
    else:
462
        ctx.timed(["commit", "--json"], ctx.work)
463
    probe = dirty["modified"][0] if dirty["modified"] else None
464
    gone = dirty["deleted"][0] if dirty["deleted"] else None
465
    status_args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
466
    left = porcelain_paths(ctx.untimed(status_args, ctx.work).stdout.decode(errors="replace"))
467
    wanted = [p for p in (probe, gone) if p]
468
    payloads, reader, meta = committed_bytes(ctx, ctx.work, wanted)
469
    committed_ok = probe is None or payloads.get(probe) == (ctx.work / probe).read_bytes()
470
    deleted_ok = gone is None or gone not in payloads
471
    ctx.oracle(not left and committed_ok and deleted_ok and "invalid" not in meta and "reader_unavailable" not in meta,
472
               "tree_clean_modified_bytes_committed_deleted_path_absent", reader,
473
               left_after_commit=left[:10], committed_bytes_match=committed_ok, deleted_absent=deleted_ok, **meta)
474
    _dirty_branch_leave(ctx)
475
476
477
def _log_count(text: str) -> int:
478
    return sum(1 for line in text.splitlines() if line.strip() and not line.startswith("No matching commits"))
479
480
481
def op_log_full(ctx: Ctx) -> None:
482
    args = ["log", "--oneline"] if _is_git(ctx) else ["log", "--oneline", "-n", "100000000"]
483
    cap = ctx.timed(args, ctx.work)
484
    count = _log_count(cap.stdout_full_text) if cap.stdout_full_text is not None else None
485
    expected = ctx.p.expected.main_commits + ctx.state["harness_commits"]
486
    ctx.oracle(count == expected, "log_line_count_equals_history_length", "subject_output",
487
               lines=count, expected=expected, stdout_bytes=cap.stdout_bytes,
488
               output_over_capture_cap=count is None)
489
490
491
def op_log_n(ctx: Ctx) -> None:
492
    cap = ctx.timed(["log", "--oneline", "-n", "20"], ctx.work)
493
    count = _log_count(cap.stdout_full_text or "")
494
    expected = min(20, ctx.p.expected.main_commits + ctx.state["harness_commits"])
495
    ctx.oracle(count == expected, "log_n_line_count", "subject_output", lines=count, expected=expected)
496
497
498
def op_log_path(ctx: Ctx) -> None:
499
    probe = ctx.p.expected.probe_path
500
    args = ["log", "--oneline", "--", probe] if _is_git(ctx) else ["log", "--oneline", "-n", "100000000", probe]
501
    cap = ctx.timed(args, ctx.work)
502
    count = _log_count(cap.stdout_full_text or "")
503
    expected = ctx.p.expected.probe_commit_count
504
    ctx.oracle(count == expected, "path_log_count_equals_generator_touch_count", "subject_output",
505
               lines=count, expected=expected, probe_path=probe)
506
507
508
def op_blame(ctx: Ctx) -> None:
509
    if not _is_git(ctx):
510
        raise OpSkipped("oak_cli_has_no_blame_or_annotate_command")
511
    probe = ctx.p.expected.probe_path
512
    cap = ctx.timed(["blame", "-s", "--", probe], ctx.work)
513
    lines = _log_count(cap.stdout_full_text or "")
514
    ctx.oracle(lines == ctx.p.expected.probe_lines, "blame_line_count_equals_file_lines", "subject_output",
515
               lines=lines, expected=ctx.p.expected.probe_lines)
516
517
518
def op_file_show(ctx: Ctx) -> None:
519
    probe = ctx.p.expected.probe_path
520
    if _is_git(ctx):
521
        cap = ctx.timed(["show", f"HEAD:{probe}"], ctx.work, raw=True)
522
        got = cap.stdout_raw or b""
523
        reader = "subject_output"
524
    else:
525
        out = ctx.scratch / f"show-{ctx.tag}.bin"
526
        ctx.timed(["file", "inspect", "--at", "HEAD", "--json", probe, "--output", str(out)], ctx.work)
527
        got = out.read_bytes() if out.exists() else b""
528
        if out.exists():
529
            out.unlink()
530
        reader = "subject_file_inspect"
531
    ctx.oracle(got == ctx.p.expected.probe_bytes, "shown_bytes_equal_fixture_blob", reader,
532
               bytes=len(got), expected_bytes=len(ctx.p.expected.probe_bytes))
533
534
535
def op_branch_list(ctx: Ctx) -> None:
536
    if _is_git(ctx):
537
        cap = ctx.timed(["branch", "--list"], ctx.work)
538
        names = [line[2:].strip() for line in (cap.stdout_full_text or "").splitlines() if line.strip()]
539
        reader = "subject_output"
540
    else:
541
        # Open branches: harness branches are closed on teardown (Oak has no local delete).
542
        cap = ctx.timed(["branch", "list", "--status", "open", "--json"], ctx.work)
543
        try:
544
            names = [item.get("name") for item in json.loads(cap.stdout_full_text or "[]")]
545
        except ValueError:
546
            names = []
547
        reader = "subject_output_json"
548
    spec = ctx.p.tier.spec
549
    fixture_branches = spec.branches if _is_git(ctx) else ctx.p.oak_branches_created
550
    need = [gen.branch_name(0)] if fixture_branches else []
551
    ok = len(names) >= fixture_branches and all(n in names for n in need)
552
    ctx.oracle(ok, "branch_count_at_least_fixture_branches", reader, listed=len(names),
553
               fixture_branches=fixture_branches)
554
555
556
def _branch_paths_match(ctx: Ctx, root: Path, want: Dict[str, bytes]) -> Tuple[bool, List[str]]:
557
    bad = [rel for rel, data in want.items() if not (root / rel).is_file() or (root / rel).read_bytes() != data]
558
    return not bad, bad
559
560
561
def op_switch_existing(ctx: Ctx) -> None:
562
    exp = ctx.p.expected
563
    args = ["switch", "-q", exp.branch] if _is_git(ctx) else ["switch", exp.branch]
564
    ctx.timed(args, ctx.work)
565
    ok, bad = _branch_paths_match(ctx, ctx.work, exp.branch_bytes)
566
    ctx.oracle(ok, "branch_paths_bytes_equal_fixture", "git_fixture_instrument", mismatched=bad[:10])
567
568
569
def _base_branch(ctx: Ctx) -> str:
570
    return "main" if _is_git(ctx) else str(ctx.p.oak_base_branch)
571
572
573
def op_switch_back(ctx: Ctx) -> None:
574
    exp = ctx.p.expected
575
    args = ["switch", "-q", _base_branch(ctx)] if _is_git(ctx) else ["switch", _base_branch(ctx)]
576
    ctx.timed(args, ctx.work)
577
    ok, bad = _branch_paths_match(ctx, ctx.work, exp.main_bytes_of_branch_paths)
578
    ctx.oracle(ok, "base_paths_bytes_equal_fixture_main", "git_fixture_instrument", mismatched=bad[:10])
579
580
581
def current_branch(ctx: Ctx, root: Path) -> str:
582
    if _is_git(ctx):
583
        return ctx.git(["symbolic-ref", "--short", "HEAD"], root, check=False).stdout.decode().strip()
584
    out = ctx.untimed(["status", "--json"], root, check=False).stdout
585
    try:
586
        return str(json.loads(out).get("branch"))
587
    except ValueError:
588
        return ""
589
590
591
def op_branch_create(ctx: Ctx) -> None:
592
    ctx.state["branch_seq"] += 1
593
    name = f"new-{ctx.tag}-{ctx.state['branch_seq']}"
594
    args = ["switch", "-q", "-c", name] if _is_git(ctx) else ["switch", "-c", name]
595
    ctx.timed(args, ctx.work)
596
    now = current_branch(ctx, ctx.work)
597
    ctx.outcome.semantic_class = "git_branch_from_HEAD" if _is_git(ctx) else "oak_branch_from_main"
598
    _drop_branch(ctx, name)
599
    ctx.oracle(now == name, "current_branch_is_new_branch", "subject_output", current=now, wanted=name)
600
601
602
def op_review_branch_diff(ctx: Ctx) -> None:
603
    exp = ctx.p.expected
604
    if _is_git(ctx):
605
        cap = ctx.timed(["diff", "--name-status", f"main...{exp.branch}"], ctx.work)
606
        paths = [line.split("\t", 1)[-1] for line in (cap.stdout_full_text or "").splitlines() if "\t" in line]
607
        reader = "subject_output"
608
    else:
609
        cap = ctx.timed(["branch", "diff", exp.branch, "--json"], ctx.work)
610
        try:
611
            paths = [item["path"] for item in json.loads(cap.stdout_full_text or "{}").get("changed_files", [])]
612
        except (ValueError, KeyError, AttributeError):
613
            paths = []
614
        reader = "subject_output_json"
615
    ctx.oracle(sorted(paths) == sorted(exp.branch_paths), "changed_paths_equal_generator_branch_paths", reader,
616
               reported=sorted(paths)[:10], expected=sorted(exp.branch_paths)[:10])
617
618
619
def op_merge_preview(ctx: Ctx) -> None:
620
    exp = ctx.p.expected
621
    if _is_git(ctx):
622
        cap = ctx.timed(["merge-tree", "--write-tree", "main", exp.branch], ctx.work)
623
        tree = (cap.stdout_full_text or "").split("\n", 1)[0].strip()
624
        bad = []
625
        for rel, data in exp.branch_bytes.items():
626
            got = ctx.git(["cat-file", "blob", f"{tree}:{rel}"], ctx.work, check=False).stdout
627
            if got != data:
628
                bad.append(rel)
629
        ctx.oracle(bool(tree) and not bad, "clean_prediction_tree_has_branch_bytes", "git_fixture_instrument",
630
                   mismatched=bad[:10])
631
        ctx.outcome.semantic_class = "git_merge_tree_two_refs"
632
        return
633
    ctx.untimed(["switch", exp.branch], ctx.work)
634
    try:
635
        # Exit 7 = prediction computed but not certified against the remote
636
        # target head (documented exit code); the prediction itself is timed.
637
        cap = ctx.timed(["merge", "--dry-run", "--json"], ctx.work, accept=(0, 7))
638
    finally:
639
        ctx.untimed(["switch", _base_branch(ctx)], ctx.work)
640
    try:
641
        data = json.loads((cap.stdout_full_text or "").splitlines()[0])
642
    except (ValueError, IndexError):
643
        data = {}
644
    changed = sorted(item.get("path") if isinstance(item, dict) else item for item in data.get("changed_files", []))
645
    ctx.outcome.semantic_class = "oak_merge_dry_run_current_into_main"
646
    ctx.outcome.extra["merge_certified"] = (data.get("merge_safety") or {}).get("certified")
647
    ctx.oracle(data.get("clean") is True and changed == sorted(exp.branch_paths),
648
               "clean_prediction_lists_branch_paths", "subject_output_json", clean=data.get("clean"),
649
               changed=changed[:10], expected=sorted(exp.branch_paths)[:10])
650
651
652
def op_merge_preview_conflict(ctx: Ctx) -> None:
653
    conflict = ctx.p.expected.conflict
654
    if not conflict:
655
        raise OpSkipped("fixture_has_no_conflict_shape")
656
    if not _is_git(ctx):
657
        raise OpSkipped("oak_merge_dry_run_predicts_current_branch_into_main_only")
658
    cap = ctx.timed(["merge-tree", "--write-tree", "--name-only", conflict["ours"], conflict["theirs"]],
659
                    ctx.work, accept=(1,))
660
    lines = (cap.stdout_full_text or "").split("\n\n", 1)[0].splitlines()[1:]
661
    ctx.oracle(len(lines) == conflict["paths"], "conflicted_path_count_equals_fixture", "subject_output",
662
               conflicted=len(lines), expected=conflict["paths"])
663
664
665
def op_merge_local(ctx: Ctx) -> None:
666
    if not _is_git(ctx):
667
        raise OpSkipped("oak_local_merge_needs_non_main_parent_unreachable_from_cli; "
668
                        "oak_serve_lacks_merge_to_main")
669
    exp = ctx.p.expected
670
    scratch = f"merge-{ctx.tag}"
671
    ctx.git(["switch", "-q", "-c", scratch, "main"], ctx.work)
672
    try:
673
        ctx.timed(["merge", "-q", "--no-ff", "--no-edit", exp.branch], ctx.work)
674
        parents = ctx.git(["rev-list", "--parents", "-n", "1", "HEAD"], ctx.work).stdout.split()
675
        ok, bad = _branch_paths_match(ctx, ctx.work, exp.branch_bytes)
676
        ctx.oracle(ok and len(parents) == 3, "merge_commit_two_parents_and_branch_bytes", "git_fixture_instrument",
677
                   parents=len(parents) - 1, mismatched=bad[:10])
678
    finally:
679
        ctx.git(["merge", "--abort"], ctx.work, check=False)
680
        ctx.git(["switch", "-q", "-f", "main"], ctx.work)
681
        ctx.git(["branch", "-q", "-D", scratch], ctx.work, check=False)
682
683
684
def op_merge_conflict(ctx: Ctx) -> None:
685
    conflict = ctx.p.expected.conflict
686
    if not conflict:
687
        raise OpSkipped("fixture_has_no_conflict_shape")
688
    if not _is_git(ctx):
689
        raise OpSkipped("oak_local_merge_needs_non_main_parent_unreachable_from_cli")
690
    scratch = f"conflict-{ctx.tag}"
691
    ctx.git(["switch", "-q", "-c", scratch, conflict["ours"]], ctx.work)
692
    try:
693
        ctx.timed(["merge", "-q", "--no-edit", conflict["theirs"]], ctx.work, accept=(1,))
694
        unmerged = ctx.git(["diff", "--name-only", "--diff-filter=U"], ctx.work).stdout.decode().split()
695
        ctx.oracle(len(unmerged) == conflict["paths"], "unmerged_path_count_equals_fixture", "subject_output",
696
                   unmerged=len(unmerged), expected=conflict["paths"])
697
    finally:
698
        ctx.git(["merge", "--abort"], ctx.work, check=False)
699
        ctx.git(["switch", "-q", "-f", "main"], ctx.work)
700
        ctx.git(["branch", "-q", "-D", scratch], ctx.work, check=False)
701
702
703
def _client_commit(ctx: Ctx, client: Path, rel: str, content: bytes) -> str:
704
    target = client / rel
705
    target.parent.mkdir(parents=True, exist_ok=True)
706
    target.write_bytes(content)
707
    if _is_git(ctx):
708
        ctx.git(["add", "-A"], client)
709
        ctx.git(["commit", "-q", "--no-verify", "-m", f"sync {rel}"], client)
710
        return ctx.git(["rev-parse", "HEAD"], client).stdout.decode().strip()
711
    ctx.untimed(["commit", "--json"], client)
712
    return json.loads(ctx.untimed(["status", "--json"], client).stdout)["head"]
713
714
715
def _head(ctx: Ctx, client: Path) -> str:
716
    if _is_git(ctx):
717
        return ctx.git(["rev-parse", "HEAD"], client).stdout.decode().strip()
718
    return json.loads(ctx.untimed(["status", "--json"], client).stdout)["head"]
719
720
721
def _oak_remote(ctx: Ctx) -> None:
722
    """Sync ops need seeded loopback servers; label an Oak snapshot seed."""
723
    if not getattr(ctx.p.tier, "sync", True):
724
        raise OpSkipped("tier_excludes_sync_clients_disk_budget")
725
    if _is_git(ctx):
726
        return
727
    if getattr(ctx.p, "oak_remote_unavailable", None):
728
        raise OpSkipped(str(ctx.p.oak_remote_unavailable))
729
    seed = getattr(ctx.p, "oak_seed", None)
730
    ctx.outcome.extra["oak_server_seed"] = seed
731
    if seed and seed != "full_history":
732
        ctx.outcome.semantic_class = f"oak_server_seed_{seed}"
733
734
735
def op_push_branch(ctx: Ctx) -> None:
736
    _oak_remote(ctx)
737
    client_b = ctx.client("clientB")
738
    rel = f"sync/push-{ctx.tag}.txt"
739
    head = _client_commit(ctx, client_b, rel, f"push {ctx.tag}\n".encode())
740
    ctx.state["pushed"] = (rel, head)
741
    if _is_git(ctx):
742
        ctx.timed(["push", "-q", "origin", "HEAD:main"], client_b)
743
        remote = ctx.git(["ls-remote", "origin", "refs/heads/main"], client_b).stdout.decode().split()
744
        ctx.state["server_extra"] += 1
745
        ctx.oracle(bool(remote) and remote[0] == head, "server_ref_equals_pushed_head", "git_ls_remote",
746
                   remote=remote[:1], pushed=head)
747
    else:
748
        cap = ctx.timed(["push", "--json"], client_b)
749
        try:
750
            receipt = json.loads((cap.stdout_full_text or "").splitlines()[-1])
751
        except (ValueError, IndexError):
752
            receipt = {}
753
        # Server-side confirmation comes from clientA's pull.ff oracle in the
754
        # same unit (independent checkout sees the pushed commit and bytes).
755
        ctx.outcome.extra["push_receipt_pushed"] = receipt.get("pushed")
756
        ctx.oracle(receipt.get("remote_contacted") is True, "push_receipt_remote_contacted_confirmed_by_pull_ff",
757
                   "subject_output_json", pushed=receipt.get("pushed"))
758
759
760
def op_pull_ff(ctx: Ctx) -> None:
761
    _oak_remote(ctx)
762
    pushed = ctx.state.pop("pushed", None)
763
    if not pushed:
764
        raise OpFailed("pull.ff needs push.branch in the same unit")
765
    rel, head = pushed
766
    client_a = ctx.client("clientA")
767
    if _is_git(ctx):
768
        ctx.timed(["pull", "-q", "--ff-only"], client_a)
769
    else:
770
        ctx.timed(["pull", "--json"], client_a)
771
    now = _head(ctx, client_a)
772
    want = f"push {ctx.tag}\n".encode()
773
    data_ok = (client_a / rel).is_file() and (client_a / rel).read_bytes() == want
774
    payloads, reader, meta = committed_bytes(ctx, client_a, [rel])
775
    committed_ok = payloads.get(rel) == want
776
    ctx.oracle(now == head and data_ok and committed_ok, "puller_head_worktree_and_committed_bytes_equal_pusher",
777
               reader, puller_head=now, pusher_head=head, bytes_ok=data_ok, committed_ok=committed_ok, **meta)
778
779
780
def op_pull_diverged(ctx: Ctx) -> None:
781
    _oak_remote(ctx)
782
    client_a, client_b = ctx.client("clientA"), ctx.client("clientB")
783
    local_rel, up_rel = f"sync/local-{ctx.tag}.txt", f"sync/up-{ctx.tag}.txt"
784
    _client_commit(ctx, client_a, local_rel, b"local\n")
785
    _client_commit(ctx, client_b, up_rel, b"upstream\n")
786
    if _is_git(ctx):
787
        ctx.git(["push", "-q", "origin", "HEAD:main"], client_b)
788
        ctx.state["server_extra"] += 1
789
        ctx.timed(["pull", "-q", "--no-rebase", "--no-edit"], client_a)
790
        ctx.outcome.semantic_class = "git_merge_commit"
791
    else:
792
        ctx.untimed(["push", "--json"], client_b)
793
        cap = ctx.timed(["pull", "--json"], client_a)
794
        try:
795
            receipt = json.loads((cap.stdout_full_text or "").splitlines()[-1])
796
        except (ValueError, IndexError):
797
            receipt = {}
798
        ctx.outcome.semantic_class = (ctx.outcome.semantic_class or "") + f"|oak_pull_{receipt.get('convergence', 'unknown')}"
799
    both = (client_a / local_rel).is_file() and (client_a / up_rel).is_file()
800
    status_args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
801
    left = porcelain_paths(ctx.untimed(status_args, client_a).stdout.decode(errors="replace"))
802
    payloads, reader, meta = committed_bytes(ctx, client_a, [local_rel, up_rel])
803
    committed_ok = payloads.get(local_rel) == b"local\n" and payloads.get(up_rel) == b"upstream\n"
804
    ctx.oracle(both and not left and committed_ok, "both_sides_committed_present_and_tree_clean", reader,
805
               local_present=(client_a / local_rel).is_file(), upstream_present=(client_a / up_rel).is_file(),
806
               committed_ok=committed_ok, left=left[:10], **meta)
807
    # Re-converge (untimed): A publishes its merge, B catches up. A failure
808
    # here is an observed subject defect in an UNTIMED step: it is recorded on
809
    # the row (teardown_error) without discarding the timed sample.
810
    if _is_git(ctx):
811
        ctx.git(["push", "-q", "origin", "HEAD:main"], client_a)
812
        ctx.state["server_extra"] += 1
813
        ctx.git(["pull", "-q", "--ff-only"], client_b)
814
        return
815
    ctx.untimed(["push", "--json"], client_a)
816
    done = ctx.untimed(["pull", "--json"], client_b, check=False)
817
    if done.returncode != 0:
818
        try:
819
            receipt = json.loads(done.stdout.decode(errors="replace").splitlines()[-1])
820
        except (ValueError, IndexError):
821
            receipt = {}
822
        ctx.outcome.extra["teardown_error"] = (
823
            f"peer pull after reparent exited {done.returncode}: branch_update={receipt.get('branch_update')} "
824
            f"parent_sync_error_code={receipt.get('parent_sync_error_code')}")
825
        if _head(ctx, client_b) != _head(ctx, client_a):
826
            raise OpFailed("peer checkout did not converge after reparent: " + ctx.outcome.extra["teardown_error"])
827
828
829
def op_clone_full(ctx: Ctx) -> None:
830
    _oak_remote(ctx)
831
    dest = ctx.scratch / f"clone-{ctx.tag}"
832
    if dest.exists():
833
        shutil.rmtree(dest)
834
    try:
835
        if _is_git(ctx):
836
            ctx.timed(["clone", "-q", str(ctx.p.git_url), str(dest)], ctx.scratch)
837
            # server main = fixture + files pushed to main by the sync ops
838
            expected = ctx.p.expected.main_file_count + ctx.state["server_extra"]
839
            extra_prefix: Tuple[str, ...] = ()
840
        else:
841
            ctx.timed(["clone", "-r", str(ctx.p.oak_url), "bench/fx", str(dest)], ctx.scratch)
842
            expected = ctx.p.expected.main_file_count  # oak sync ops publish to a branch, not main
843
            # (a snapshot seed has the same main tree, one commit of history)
844
            extra_prefix = ()
845
        ok, detail = check_worktree(ctx, dest, expected, extra_prefix)
846
        ctx.oracle(ok, "clone_file_count_and_sampled_blob_ids_equal_fixture", "git_fixture_instrument", **detail)
847
    finally:
848
        shutil.rmtree(dest, ignore_errors=True)
849
850
851
def op_gc(ctx: Ctx) -> None:
852
    status_args = ["status", "--porcelain=v1"] if _is_git(ctx) else ["status", "--porcelain"]
853
    if _is_git(ctx):
854
        before = ctx.git(["rev-list", "--count", "HEAD"], ctx.work).stdout.strip()
855
        ctx.timed(["gc", "--quiet"], ctx.work)
856
        after = ctx.git(["rev-list", "--count", "HEAD"], ctx.work).stdout.strip()
857
        ctx.outcome.semantic_class = "git_gc"
858
    else:
859
        before = ctx.untimed(["hash"], ctx.work).stdout.strip()
860
        ctx.timed(["maintenance", "compact"], ctx.work)
861
        after = ctx.untimed(["hash"], ctx.work).stdout.strip()
862
        ctx.outcome.semantic_class = "oak_maintenance_compact"
863
    left = porcelain_paths(ctx.untimed(status_args, ctx.work).stdout.decode(errors="replace"))
864
    probe = ctx.p.expected.probe_path
865
    payloads, reader, meta = committed_bytes(ctx, ctx.work, [probe])
866
    probe_ok = payloads.get(probe) == ctx.p.expected.probe_bytes
867
    ctx.oracle(before == after and not left and probe_ok, "head_status_and_committed_probe_unchanged",
868
               reader, before=before.decode() if isinstance(before, bytes) else before,
869
               after=after.decode() if isinstance(after, bytes) else after, left=left[:10],
870
               committed_probe_ok=probe_ok, **meta)
871
872
873
OPS: Dict[str, Callable[[Ctx], None]] = {
874
    "status.clean": op_status_clean,
875
    "status.dirty": op_status_dirty,
876
    "diff.dirty": op_diff_dirty,
877
    "commit.dirty": op_commit_dirty,
878
    "log.full": op_log_full,
879
    "log.n": op_log_n,
880
    "log.path": op_log_path,
881
    "blame": op_blame,
882
    "file.show": op_file_show,
883
    "branch.list": op_branch_list,
884
    "branch.create": op_branch_create,
885
    "switch.existing": op_switch_existing,
886
    "switch.back": op_switch_back,
887
    "review.branch_diff": op_review_branch_diff,
888
    "merge.preview": op_merge_preview,
889
    "merge.preview.conflict": op_merge_preview_conflict,
890
    "merge.local": op_merge_local,
891
    "merge.conflict": op_merge_conflict,
892
    "push.branch": op_push_branch,
893
    "pull.ff": op_pull_ff,
894
    "pull.diverged": op_pull_diverged,
895
    "clone.full": op_clone_full,
896
    "gc": op_gc,
897
}
898
assert set(OPS) == set(OP_INFO)
899
900
901
def catalog_yaml() -> str:
902
    """scenarios/scale.yaml, generated from this catalog (a test keeps them equal)."""
903
    lines = [
904
        "schema_version: 1",
905
        "scenario_set: scale",
906
        "description: Scale and coverage lane (suites/scale). Every user-facing VCS operation timed for git and oak"
907
        " across repository tiers. Generated from suites/scale/scalesuite/ops.py (catalog_yaml); "
908
        "tests/test_scale_suite.py keeps the two equal.",
909
        "",
910
        "operation_catalog:",
911
    ]
912
    for unit in UNIT_ORDER:
913
        for op in unit:
914
            info = OP_INFO[op]
915
            lines += [f"  {op}:", f"    description: {info.description}", "    timed: true",
916
                      f"    mutates: {str(info.mutates).lower()}"]
917
            for key, values in (("tperf_refs", info.tperf), ("core_yaml_ops", info.core_yaml)):
918
                if values:
919
                    lines.append(f"    {key}:")
920
                    lines.extend(f"      - {value}" for value in values)
921
                else:
922
                    lines.append(f"    {key}: []")
923
    return "\n".join(lines) + "\n"
924
925
926
def execute(ctx: Ctx, op: str) -> Outcome:
927
    try:
928
        OPS[op](ctx)
929
    except OpSkipped as exc:
930
        ctx.outcome.skip_reason = str(exc)
931
    except OpFailed as exc:
932
        ctx.outcome.failed = str(exc)
933
    except Exception as exc:  # noqa: BLE001 - harness errors are recorded, never hidden
934
        ctx.outcome.failed = f"harness_error: {type(exc).__name__}: {str(exc)[-800:]}"
935
        ctx.outcome.extra["harness_error"] = True
936
    return ctx.outcome