quality-loop 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.
- quality_loop/OPERATOR.md +103 -0
- quality_loop/__init__.py +15 -0
- quality_loop/__main__.py +48 -0
- quality_loop/adapter.py +88 -0
- quality_loop/audit.py +144 -0
- quality_loop/mcp.py +161 -0
- quality_loop/operator.py +186 -0
- quality_loop/pages.py +84 -0
- quality_loop/report.py +170 -0
- quality_loop/room.py +105 -0
- quality_loop/runner.py +160 -0
- quality_loop/scenario.py +160 -0
- quality_loop/screens/__init__.py +192 -0
- quality_loop/screens/_mac.py +152 -0
- quality_loop/screens/_windows.py +110 -0
- quality_loop/session.py +301 -0
- quality_loop/standin.py +193 -0
- quality_loop/transcript.py +130 -0
- quality_loop-0.1.0.dist-info/METADATA +74 -0
- quality_loop-0.1.0.dist-info/RECORD +23 -0
- quality_loop-0.1.0.dist-info/WHEEL +5 -0
- quality_loop-0.1.0.dist-info/licenses/LICENSE +21 -0
- quality_loop-0.1.0.dist-info/top_level.txt +1 -0
quality_loop/runner.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""A run: for each scenario, a room, its operator, and a report.
|
|
2
|
+
|
|
3
|
+
build the room (TASK.md is the scenario's Prompt, verbatim)
|
|
4
|
+
stand fixtures in for the operator's files
|
|
5
|
+
serve the fixture pages, write the screen manifest
|
|
6
|
+
operator phase 1 - puts the screen up; the room must come out unchanged
|
|
7
|
+
the room's session
|
|
8
|
+
collect evidence, put the operator's files back
|
|
9
|
+
audit the transcript
|
|
10
|
+
operator phase 2 - writes REPORT.md
|
|
11
|
+
close any screen still open
|
|
12
|
+
...
|
|
13
|
+
FINDINGS.md and findings.json, concatenated from the reports
|
|
14
|
+
|
|
15
|
+
There is no verdict. A room that could not be measured - a cancelled phase 1,
|
|
16
|
+
an isolation breach, a lost product or model - still gets a report, written
|
|
17
|
+
here as a harness problem, so the human reading the findings sees it.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import contextlib
|
|
23
|
+
import datetime
|
|
24
|
+
import json
|
|
25
|
+
import pathlib
|
|
26
|
+
import shutil
|
|
27
|
+
import signal
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
|
|
31
|
+
from quality_loop import (audit, operator, pages, report, room, scenario, screens,
|
|
32
|
+
transcript)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class RunnerError(Exception):
|
|
36
|
+
"""A room the runner will not start."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def refuse_a_reachable_marker(marker: pathlib.Path, built: pathlib.Path,
|
|
40
|
+
add_dirs: list[pathlib.Path]) -> None:
|
|
41
|
+
"""The operator's set-aside files must be out of the room's reach.
|
|
42
|
+
|
|
43
|
+
The first run of the toy put them inside the one directory the room could
|
|
44
|
+
write to. The room saw a second style.txt beside the fixture and ignored
|
|
45
|
+
it. A room that read it would have measured the harness, and one that
|
|
46
|
+
wrote to it could have touched the operator's own file.
|
|
47
|
+
"""
|
|
48
|
+
marker = pathlib.Path(marker).resolve()
|
|
49
|
+
for reach in [built, *add_dirs]:
|
|
50
|
+
reach = pathlib.Path(reach).resolve()
|
|
51
|
+
if marker == reach or reach in marker.parents:
|
|
52
|
+
raise RunnerError(
|
|
53
|
+
f"the stand-in keeps the operator's files in {marker}, inside {reach}, "
|
|
54
|
+
f"which the room can reach - put the marker outside it")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _cancelled(case, what: str, problem_id: str, evidence: str) -> str:
|
|
58
|
+
return (f"# Report: {case.title}\n\nThis room could not be measured.\n\n"
|
|
59
|
+
f"### [critical] {what}\n- **Id:** `{problem_id}`\n- **Kind:** harness\n"
|
|
60
|
+
f"- **Evidence:** {evidence}\n- **Where it would be fixed:** the runner\n"
|
|
61
|
+
f"- **How a fix is confirmed:** the room runs and is reported on\n")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def run(adapter, names: list[str] | None = None, model: str | None = None,
|
|
65
|
+
operator_model: str | None = None, say=print) -> pathlib.Path:
|
|
66
|
+
"""Run every scenario ``names`` picks, and return FINDINGS.md's path."""
|
|
67
|
+
sys.stdout.reconfigure(line_buffering=True)
|
|
68
|
+
# SIGTERM unwinds like Ctrl-C, so the stand-in puts the operator's files
|
|
69
|
+
# back instead of dying with them set aside.
|
|
70
|
+
signal.signal(signal.SIGTERM, lambda *_: (_ for _ in ()).throw(KeyboardInterrupt()))
|
|
71
|
+
cases = scenario.load(adapter.scenarios, names)
|
|
72
|
+
stamp = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
73
|
+
parent = adapter.rooms / f"{adapter.version}-{stamp}"
|
|
74
|
+
parent.mkdir(parents=True)
|
|
75
|
+
say(f"rooms: {parent}")
|
|
76
|
+
reports: list[tuple[str, str, str]] = []
|
|
77
|
+
bundle = adapter.bundle()
|
|
78
|
+
try:
|
|
79
|
+
with adapter.product():
|
|
80
|
+
for case, variant in adapter.runs(cases, named=bool(names)):
|
|
81
|
+
label = case.name + (f".{variant}" if variant else "")
|
|
82
|
+
say(f"\n== {label}: {case.title}")
|
|
83
|
+
reports.append((label, case.title, _one(
|
|
84
|
+
adapter, case, variant, parent / label, bundle, model,
|
|
85
|
+
operator_model, say)))
|
|
86
|
+
except KeyboardInterrupt:
|
|
87
|
+
say("\n interrupted - the room in progress was collected and the "
|
|
88
|
+
"operator's files put back")
|
|
89
|
+
findings = report.write_findings(parent, reports)
|
|
90
|
+
say(f"\n findings: {findings}")
|
|
91
|
+
return findings
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _one(adapter, case, variant, here: pathlib.Path, bundle, model, operator_model,
|
|
95
|
+
say) -> str:
|
|
96
|
+
built = room.build(here, prompt=case.prompt, rules=adapter.rules(), bundle=bundle,
|
|
97
|
+
skill_name=adapter.skill_name, rooms=adapter.rooms,
|
|
98
|
+
checkout=adapter.checkout)
|
|
99
|
+
evidence = built / "evidence"
|
|
100
|
+
evidence.mkdir()
|
|
101
|
+
private = pathlib.Path(tempfile.mkdtemp(prefix="quality-loop-private-"))
|
|
102
|
+
directory = here.with_name(here.name + ".operator")
|
|
103
|
+
session = adapter.session(built)
|
|
104
|
+
if model:
|
|
105
|
+
session.model = model
|
|
106
|
+
stand_in = adapter.stand_in(case, variant, built)
|
|
107
|
+
if stand_in is not None:
|
|
108
|
+
refuse_a_reachable_marker(stand_in.marker, built, session.add_dirs)
|
|
109
|
+
stand_in = stand_in or contextlib.nullcontext()
|
|
110
|
+
served = pages.Pages(adapter.fixtures) if adapter.fixtures else contextlib.nullcontext()
|
|
111
|
+
op = None
|
|
112
|
+
try:
|
|
113
|
+
with stand_in, served as site:
|
|
114
|
+
manifest = screens.write_manifest(
|
|
115
|
+
private / "manifest.json", adapter.screens,
|
|
116
|
+
fixtures=adapter.fixtures or adapter.checkout,
|
|
117
|
+
origin=site.origin if site else "", python=adapter.python,
|
|
118
|
+
state=directory, evidence=adapter.evidence)
|
|
119
|
+
op = operator.Operator(
|
|
120
|
+
room=built, directory=directory, scenario=case.path,
|
|
121
|
+
checkout=adapter.checkout,
|
|
122
|
+
evidence_help=adapter.evidence_help, manifest=manifest,
|
|
123
|
+
python=adapter.python, model=operator_model)
|
|
124
|
+
say(" operator: phase 1 - the screen")
|
|
125
|
+
op.phase_one(sys.platform, say=say)
|
|
126
|
+
say(" room: running")
|
|
127
|
+
ran = session.run(built, alive=adapter.alive, say=say)
|
|
128
|
+
if site:
|
|
129
|
+
site.write(evidence / "screen-events.jsonl")
|
|
130
|
+
(evidence / "run.json").write_text(json.dumps(
|
|
131
|
+
{"scenario": case.name, "variant": variant, "platform": sys.platform,
|
|
132
|
+
"exit_code": ran.exit_code, "product_lost": ran.product_lost,
|
|
133
|
+
"model_lost": transcript.lost_the_model(ran.transcript)}, indent=2),
|
|
134
|
+
encoding="utf-8")
|
|
135
|
+
breaches, refused = audit.audit(built, adapter.policy(built))
|
|
136
|
+
(evidence / "audit.json").write_text(json.dumps(
|
|
137
|
+
{"breaches": breaches, "refused": refused}, indent=2), encoding="utf-8")
|
|
138
|
+
for line in breaches:
|
|
139
|
+
say(f" BREACH: {line}")
|
|
140
|
+
say(" operator: phase 2 - the report")
|
|
141
|
+
written = op.phase_two(say=say)
|
|
142
|
+
text = written.read_text(encoding="utf-8") if written.exists() else ""
|
|
143
|
+
if breaches:
|
|
144
|
+
text = _cancelled(case, "The room broke isolation; nothing it did is evidence",
|
|
145
|
+
"isolation-breach", "; ".join(breaches)) + "\n" + text
|
|
146
|
+
if ran.product_lost or transcript.lost_the_model(ran.transcript):
|
|
147
|
+
what = "the product" if ran.product_lost else "the model's API"
|
|
148
|
+
text = _cancelled(case, f"{what} stopped answering during the run",
|
|
149
|
+
"lost-" + ("product" if ran.product_lost else "model"),
|
|
150
|
+
"run.json") + "\n" + text
|
|
151
|
+
return text
|
|
152
|
+
except operator.OperatorError as error:
|
|
153
|
+
say(f" CANCELLED: {error}")
|
|
154
|
+
return _cancelled(case, "Operator phase 1 changed the room", "phase-one-touched-room",
|
|
155
|
+
str(error))
|
|
156
|
+
finally:
|
|
157
|
+
closed = screens.close_all(directory) if directory.exists() else []
|
|
158
|
+
if closed:
|
|
159
|
+
say(f" closed: {', '.join(closed)} (left open by the operator)")
|
|
160
|
+
shutil.rmtree(private, ignore_errors=True)
|
quality_loop/scenario.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
"""Scenarios: one Markdown file per task, written in natural language.
|
|
2
|
+
|
|
3
|
+
```markdown
|
|
4
|
+
+++
|
|
5
|
+
title = "Stop F1 doing anything in VS Code"
|
|
6
|
+
edits_config = true
|
|
7
|
+
+++
|
|
8
|
+
|
|
9
|
+
## Prompt
|
|
10
|
+
|
|
11
|
+
What a user would actually type - the only part that reaches the room.
|
|
12
|
+
|
|
13
|
+
## Screen
|
|
14
|
+
|
|
15
|
+
What has to be in front before the room starts, for the operator to put up.
|
|
16
|
+
|
|
17
|
+
## Must
|
|
18
|
+
|
|
19
|
+
- The observable outcomes, then what to read for.
|
|
20
|
+
|
|
21
|
+
## Must not
|
|
22
|
+
|
|
23
|
+
- What a run must not do.
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
The front matter is TOML between ``+++`` lines. ``title`` is required; every
|
|
27
|
+
other key is the product's to define (which configs to run on, which input
|
|
28
|
+
files to stage, a screen only the operator can supply) and is handed to it
|
|
29
|
+
as-is in ``Scenario.meta``.
|
|
30
|
+
|
|
31
|
+
The sections are the ``## `` headings. **Prompt** and **Must** are required.
|
|
32
|
+
The prompt is taken verbatim - the text between its heading and the next one -
|
|
33
|
+
because it is the one thing that reaches the room, and the runner, not the
|
|
34
|
+
operator agent, is what copies it there. A Must line that also appears in the
|
|
35
|
+
prompt refuses the scenario: that is the scoring key leaking, and it has
|
|
36
|
+
happened through a copy-paste one line too long.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import dataclasses
|
|
42
|
+
import pathlib
|
|
43
|
+
import re
|
|
44
|
+
import tomllib
|
|
45
|
+
|
|
46
|
+
#: Sections every scenario has.
|
|
47
|
+
REQUIRED = ("Prompt", "Must")
|
|
48
|
+
|
|
49
|
+
_HEADING = re.compile(r"^## +(.+?)\s*$")
|
|
50
|
+
_FENCE = re.compile(r"^(```|~~~)")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ScenarioError(Exception):
|
|
54
|
+
"""A scenario file that cannot be run as written."""
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclasses.dataclass
|
|
58
|
+
class Scenario:
|
|
59
|
+
"""One task, as its file states it.
|
|
60
|
+
|
|
61
|
+
Attributes:
|
|
62
|
+
path: The file.
|
|
63
|
+
name: Its stem: ``11_f1_off_in_vscode``.
|
|
64
|
+
number: The leading number of the stem, or None.
|
|
65
|
+
title: From the front matter.
|
|
66
|
+
meta: The whole front matter, for the product to read.
|
|
67
|
+
sections: ``{heading: text}``, in file order.
|
|
68
|
+
"""
|
|
69
|
+
path: pathlib.Path
|
|
70
|
+
name: str
|
|
71
|
+
number: int | None
|
|
72
|
+
title: str
|
|
73
|
+
meta: dict
|
|
74
|
+
sections: dict[str, str]
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def prompt(self) -> str:
|
|
78
|
+
"""What the room is handed, exactly as written."""
|
|
79
|
+
return self.sections["Prompt"]
|
|
80
|
+
|
|
81
|
+
def section(self, heading: str) -> str:
|
|
82
|
+
"""A section's text, or "" when the scenario has none."""
|
|
83
|
+
return self.sections.get(heading, "")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def parse(path: pathlib.Path) -> Scenario:
|
|
87
|
+
"""Read one scenario file, or say what is wrong with it."""
|
|
88
|
+
path = pathlib.Path(path)
|
|
89
|
+
text = path.read_text(encoding="utf-8").replace("\r\n", "\n")
|
|
90
|
+
meta, body = _front_matter(text, path)
|
|
91
|
+
if not isinstance(meta.get("title"), str) or not meta["title"].strip():
|
|
92
|
+
raise ScenarioError(f"{path.name}: the front matter has no title")
|
|
93
|
+
sections = _sections(body)
|
|
94
|
+
if missing := [h for h in REQUIRED if not sections.get(h, "").strip()]:
|
|
95
|
+
raise ScenarioError(f"{path.name}: no {', '.join(missing)} section")
|
|
96
|
+
_refuse_a_leak(sections, path)
|
|
97
|
+
number = re.match(r"(\d+)", path.stem)
|
|
98
|
+
return Scenario(path=path, name=path.stem,
|
|
99
|
+
number=int(number.group(1)) if number else None,
|
|
100
|
+
title=meta["title"].strip(), meta=meta, sections=sections)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def load(directory: pathlib.Path, names: list[str] | None = None) -> list[Scenario]:
|
|
104
|
+
"""Every scenario in ``directory`` in file order, or those ``names`` picks -
|
|
105
|
+
by number, by stem, or by the stem without its number."""
|
|
106
|
+
found = [parse(path) for path in sorted(pathlib.Path(directory).glob("*.md"))
|
|
107
|
+
if not path.name.startswith(("_", "README"))]
|
|
108
|
+
if not names:
|
|
109
|
+
return found
|
|
110
|
+
wanted = []
|
|
111
|
+
for name in map(str, names):
|
|
112
|
+
match = [s for s in found
|
|
113
|
+
if name in (s.name, s.name.split("_", 1)[-1])
|
|
114
|
+
or (name.isdigit() and s.number == int(name))]
|
|
115
|
+
if not match:
|
|
116
|
+
raise ScenarioError(f"no scenario {name!r}; have: "
|
|
117
|
+
+ ", ".join(s.name for s in found))
|
|
118
|
+
wanted += match
|
|
119
|
+
return wanted
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _front_matter(text: str, path: pathlib.Path) -> tuple[dict, str]:
|
|
123
|
+
if not text.startswith("+++\n"):
|
|
124
|
+
raise ScenarioError(f"{path.name}: does not start with +++ front matter")
|
|
125
|
+
end = text.find("\n+++\n", 4)
|
|
126
|
+
if end < 0:
|
|
127
|
+
raise ScenarioError(f"{path.name}: the front matter is never closed with +++")
|
|
128
|
+
try:
|
|
129
|
+
meta = tomllib.loads(text[4:end])
|
|
130
|
+
except tomllib.TOMLDecodeError as error:
|
|
131
|
+
raise ScenarioError(f"{path.name}: front matter: {error}") from None
|
|
132
|
+
return meta, text[end + len("\n+++\n"):]
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _sections(body: str) -> dict[str, str]:
|
|
136
|
+
"""``## `` headings and the text under each, ignoring fenced code."""
|
|
137
|
+
sections: dict[str, list[str]] = {}
|
|
138
|
+
current = None
|
|
139
|
+
fenced = False
|
|
140
|
+
for line in body.split("\n"):
|
|
141
|
+
if _FENCE.match(line):
|
|
142
|
+
fenced = not fenced
|
|
143
|
+
heading = None if fenced else _HEADING.match(line)
|
|
144
|
+
if heading:
|
|
145
|
+
current = heading.group(1)
|
|
146
|
+
sections.setdefault(current, [])
|
|
147
|
+
elif current is not None:
|
|
148
|
+
sections[current].append(line)
|
|
149
|
+
return {h: "\n".join(lines).strip("\n") + "\n" for h, lines in sections.items()}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _refuse_a_leak(sections: dict[str, str], path: pathlib.Path) -> None:
|
|
153
|
+
prompt = " ".join(sections["Prompt"].split()).lower()
|
|
154
|
+
for heading in ("Must", "Must not"):
|
|
155
|
+
for line in sections.get(heading, "").splitlines():
|
|
156
|
+
said = " ".join(line.strip().lstrip("-*").split()).lower()
|
|
157
|
+
if len(said) >= 20 and said in prompt:
|
|
158
|
+
raise ScenarioError(
|
|
159
|
+
f"{path.name}: the prompt carries a line of {heading}: {line.strip()!r}"
|
|
160
|
+
f" - that is the scoring key reaching the room")
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Putting a scenario's screen up, by name, on either OS.
|
|
2
|
+
|
|
3
|
+
The operator agent reads a scenario's Screen section and decides what has to be
|
|
4
|
+
in front. It can only ask for a screen **by name**, from the list the
|
|
5
|
+
product's adapter declares, and it never passes a path, a URL or a script. An
|
|
6
|
+
agent that can write files and also run a script it names can run anything,
|
|
7
|
+
so the mechanics belong in code:
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
SCREENS = {
|
|
11
|
+
"approval-queue": Page("dialog.html", title="Approval queue"),
|
|
12
|
+
"deploy": Terminal("deploy.py", title="deploy - acme"),
|
|
13
|
+
}
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The runner writes a manifest of those declarations, with every path made
|
|
17
|
+
absolute, into a directory the operator cannot write to, and points
|
|
18
|
+
``QUALITY_LOOP_MANIFEST`` at it. ``quality-loop screen open <name>`` opens
|
|
19
|
+
one, waits until its window is up, and records it. ``close`` closes only what
|
|
20
|
+
``open`` recorded. Both OSes are implemented here, one module each. The
|
|
21
|
+
window's title is the contract between the declaration and the check that
|
|
22
|
+
the screen is up.
|
|
23
|
+
|
|
24
|
+
The screen is checked with the OS's own window list, never with the product
|
|
25
|
+
under test: a broken product would otherwise hide a failed setup and take
|
|
26
|
+
the blame for it.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import dataclasses
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import pathlib
|
|
35
|
+
import shutil
|
|
36
|
+
import sys
|
|
37
|
+
import tempfile
|
|
38
|
+
import time
|
|
39
|
+
|
|
40
|
+
MANIFEST = "QUALITY_LOOP_MANIFEST"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ScreenError(Exception):
|
|
44
|
+
"""A screen that could not be put up or taken down."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclasses.dataclass(frozen=True)
|
|
48
|
+
class Page:
|
|
49
|
+
"""An HTML page from the product's fixtures, served by the runner.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
file: Its path under the served fixture directory.
|
|
53
|
+
title: Its ``<title>``, which is how its window is recognised.
|
|
54
|
+
"""
|
|
55
|
+
file: str
|
|
56
|
+
title: str
|
|
57
|
+
kind: str = "page"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclasses.dataclass(frozen=True)
|
|
61
|
+
class Terminal:
|
|
62
|
+
"""A Python script run in a terminal window of its own.
|
|
63
|
+
|
|
64
|
+
Python so that one script serves both OSes; run with the product's
|
|
65
|
+
interpreter, from a copy outside the checkout, because a terminal shows
|
|
66
|
+
its command line and a path under the repository tells the room where the
|
|
67
|
+
source it may not read is.
|
|
68
|
+
|
|
69
|
+
Attributes:
|
|
70
|
+
script: Its path, relative to the adapter's fixture directory.
|
|
71
|
+
title: The window's title, set by the backend rather than the script.
|
|
72
|
+
"""
|
|
73
|
+
script: str
|
|
74
|
+
title: str
|
|
75
|
+
kind: str = "terminal"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def write_manifest(path: pathlib.Path, screens: dict, *, fixtures: pathlib.Path,
|
|
79
|
+
origin: str, python: str, state: pathlib.Path,
|
|
80
|
+
evidence: dict | None = None) -> pathlib.Path:
|
|
81
|
+
"""The declarations, made absolute, for the operator's tools to read.
|
|
82
|
+
|
|
83
|
+
``evidence`` is ``{name: {"argv": [...], "help": "..."}}``: the product's
|
|
84
|
+
evidence commands, run by the operator's MCP server and nothing else.
|
|
85
|
+
"""
|
|
86
|
+
entries = {}
|
|
87
|
+
for name, screen in screens.items():
|
|
88
|
+
entry = dataclasses.asdict(screen)
|
|
89
|
+
if isinstance(screen, Page):
|
|
90
|
+
entry["url"] = f"{origin}/{screen.file}"
|
|
91
|
+
else:
|
|
92
|
+
entry["script"] = str((pathlib.Path(fixtures) / screen.script).resolve())
|
|
93
|
+
entries[name] = entry
|
|
94
|
+
path.write_text(json.dumps({"screens": entries, "python": python,
|
|
95
|
+
"state": str(state), "evidence": evidence or {}},
|
|
96
|
+
indent=2), encoding="utf-8")
|
|
97
|
+
return path
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _manifest() -> dict:
|
|
101
|
+
where = os.environ.get(MANIFEST)
|
|
102
|
+
if not where:
|
|
103
|
+
raise ScreenError(f"{MANIFEST} is not set - the runner sets it for the operator")
|
|
104
|
+
return json.loads(pathlib.Path(where).read_text(encoding="utf-8"))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _state_file(manifest: dict) -> pathlib.Path:
|
|
108
|
+
return pathlib.Path(manifest["state"]) / "screens.json"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _opened(manifest: dict) -> list[dict]:
|
|
112
|
+
path = _state_file(manifest)
|
|
113
|
+
return json.loads(path.read_text(encoding="utf-8")) if path.exists() else []
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _record(manifest: dict, opened: list[dict]) -> None:
|
|
117
|
+
path = _state_file(manifest)
|
|
118
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
119
|
+
path.write_text(json.dumps(opened, indent=2), encoding="utf-8")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def backend():
|
|
123
|
+
"""This OS's implementation."""
|
|
124
|
+
if sys.platform == "darwin":
|
|
125
|
+
from quality_loop.screens import _mac as module
|
|
126
|
+
elif os.name == "nt":
|
|
127
|
+
from quality_loop.screens import _windows as module
|
|
128
|
+
else:
|
|
129
|
+
raise ScreenError(f"no screen backend for {sys.platform}")
|
|
130
|
+
return module
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def names() -> dict:
|
|
134
|
+
"""``{name: entry}`` - what may be opened."""
|
|
135
|
+
return _manifest()["screens"]
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def open_screen(name: str, patience: float = 15.0, os_backend=None) -> dict:
|
|
139
|
+
"""Open the screen ``name`` and wait until its window is up."""
|
|
140
|
+
manifest = _manifest()
|
|
141
|
+
screens = manifest["screens"]
|
|
142
|
+
if name not in screens:
|
|
143
|
+
raise ScreenError(f"no screen {name!r}; the adapter declares: "
|
|
144
|
+
+ ", ".join(sorted(screens)))
|
|
145
|
+
entry = screens[name]
|
|
146
|
+
os_backend = os_backend or backend()
|
|
147
|
+
if entry["kind"] == "page":
|
|
148
|
+
handle = os_backend.open_page(entry["url"], entry["title"], patience)
|
|
149
|
+
elif entry["kind"] == "terminal":
|
|
150
|
+
copy = pathlib.Path(tempfile.mkdtemp(prefix="quality-loop-screen-"))
|
|
151
|
+
script = copy / pathlib.Path(entry["script"]).name
|
|
152
|
+
shutil.copy2(entry["script"], script)
|
|
153
|
+
handle = os_backend.open_terminal([manifest["python"], str(script)],
|
|
154
|
+
entry["title"], patience)
|
|
155
|
+
else:
|
|
156
|
+
raise ScreenError(f"{name}: unknown kind {entry['kind']!r}")
|
|
157
|
+
opened = _opened(manifest)
|
|
158
|
+
record = {"id": f"{name}-{len(opened) + 1}", "name": name, "kind": entry["kind"],
|
|
159
|
+
"title": entry["title"], "handle": handle, "at": time.time()}
|
|
160
|
+
_record(manifest, opened + [record])
|
|
161
|
+
return record
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def close_screen(screen_id: str, os_backend=None) -> dict:
|
|
165
|
+
"""Close one screen ``open_screen`` recorded, and nothing else."""
|
|
166
|
+
manifest = _manifest()
|
|
167
|
+
opened = _opened(manifest)
|
|
168
|
+
record = next((r for r in opened if r["id"] == screen_id), None)
|
|
169
|
+
if record is None:
|
|
170
|
+
raise ScreenError(f"{screen_id!r} was not opened here; opened: "
|
|
171
|
+
+ (", ".join(r["id"] for r in opened) or "nothing"))
|
|
172
|
+
(os_backend or backend()).close(record["kind"], record["handle"])
|
|
173
|
+
_record(manifest, [r for r in opened if r["id"] != screen_id])
|
|
174
|
+
return record
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def close_all(state: pathlib.Path, os_backend=None) -> list[str]:
|
|
178
|
+
"""Close everything still recorded in ``state`` - the runner's own sweep,
|
|
179
|
+
after the operator has had its turn, so a screen it forgot to close does
|
|
180
|
+
not become the next room's second window."""
|
|
181
|
+
path = pathlib.Path(state) / "screens.json"
|
|
182
|
+
if not path.exists():
|
|
183
|
+
return []
|
|
184
|
+
closed = []
|
|
185
|
+
for record in json.loads(path.read_text(encoding="utf-8")):
|
|
186
|
+
try:
|
|
187
|
+
(os_backend or backend()).close(record["kind"], record["handle"])
|
|
188
|
+
closed.append(record["id"])
|
|
189
|
+
except ScreenError:
|
|
190
|
+
pass
|
|
191
|
+
path.unlink()
|
|
192
|
+
return closed
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Screens on macOS: Safari for pages, Terminal for scripts, through AppleScript.
|
|
2
|
+
|
|
3
|
+
Every step of the page load was paid for by a run of the harness this
|
|
4
|
+
replaces. `make new document with properties {URL:...}` returns a document
|
|
5
|
+
and does not load it, and a plain two-step sets the URL before the new
|
|
6
|
+
document is ready. So the window is made, given a moment, given its URL, and
|
|
7
|
+
then waited for by the title the page will take. A screen that never arrives
|
|
8
|
+
refuses the run instead of buying it: a room that started without its screen
|
|
9
|
+
once hunted for its task across every application the operator had open.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import pathlib
|
|
15
|
+
import subprocess
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
from quality_loop.screens import ScreenError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _quote(text: str) -> str:
|
|
23
|
+
return '"' + text.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _osascript(script: str) -> str:
|
|
27
|
+
done = subprocess.run(["osascript", "-e", script], capture_output=True,
|
|
28
|
+
text=True, check=False)
|
|
29
|
+
if done.returncode != 0:
|
|
30
|
+
raise ScreenError(done.stderr.strip() or "osascript failed")
|
|
31
|
+
return done.stdout.strip()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def page_script(url: str) -> str:
|
|
35
|
+
"""The AppleScript that opens ``url`` in a Safari window of its own and
|
|
36
|
+
answers that window's id."""
|
|
37
|
+
return f'''tell application "Safari"
|
|
38
|
+
activate
|
|
39
|
+
make new document
|
|
40
|
+
delay 0.5
|
|
41
|
+
set URL of front document to {_quote(url)}
|
|
42
|
+
return id of front window
|
|
43
|
+
end tell'''
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
#: The shell a terminal screen runs in: bash, without the operator's startup
|
|
47
|
+
#: files and with a fixed prompt, so the screen reads the same on every machine.
|
|
48
|
+
SHELL = "/bin/bash --noprofile --norc"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def wrapper(command: list[str]) -> str:
|
|
52
|
+
"""A bash script that clears the screen, runs ``command``, and then stays in
|
|
53
|
+
the fixed shell, so the output is still on screen for the room to read."""
|
|
54
|
+
run = " ".join(_shell_quote(part) for part in command)
|
|
55
|
+
return ("#!/bin/bash\n"
|
|
56
|
+
"printf '\\033c'\n"
|
|
57
|
+
f"{run}\n"
|
|
58
|
+
"export PS1='$ '\n"
|
|
59
|
+
# macOS's bash announces, every time, that zsh is the default now.
|
|
60
|
+
"export BASH_SILENCE_DEPRECATION_WARNING=1\n"
|
|
61
|
+
f"exec {SHELL} -i\n")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def terminal_script(launch: str, title: str) -> str:
|
|
65
|
+
"""The AppleScript that runs ``launch`` in a new Terminal window titled
|
|
66
|
+
``title`` and answers that window's id.
|
|
67
|
+
|
|
68
|
+
Terminal types its command into the operator's own default shell, which
|
|
69
|
+
may not be a POSIX shell at all - on one machine it was a shell that knows
|
|
70
|
+
no ``exec``, and the screen never ran. So what is typed is a single
|
|
71
|
+
absolute path with no arguments, which any shell runs, and everything
|
|
72
|
+
else happens in the script it names.
|
|
73
|
+
"""
|
|
74
|
+
return f'''tell application "Terminal"
|
|
75
|
+
activate
|
|
76
|
+
set t to do script {_quote(launch)}
|
|
77
|
+
set custom title of t to {_quote(title)}
|
|
78
|
+
set title displays custom title of t to true
|
|
79
|
+
return id of front window
|
|
80
|
+
end tell'''
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _shell_quote(part: str) -> str:
|
|
84
|
+
return "'" + part.replace("'", "'\\''") + "'"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _wait(application: str, window_id: int, title: str, patience: float) -> None:
|
|
88
|
+
deadline = time.monotonic() + patience
|
|
89
|
+
while time.monotonic() < deadline:
|
|
90
|
+
try:
|
|
91
|
+
name = _osascript(f'tell application "{application}" to get name of '
|
|
92
|
+
f'(first window whose id is {window_id})')
|
|
93
|
+
except ScreenError:
|
|
94
|
+
name = ""
|
|
95
|
+
if title in name:
|
|
96
|
+
return
|
|
97
|
+
time.sleep(0.5)
|
|
98
|
+
raise ScreenError(f"{application} window {window_id} never showed {title!r}. A "
|
|
99
|
+
f"sheet left over a window blocks this and shows nowhere else, "
|
|
100
|
+
f"and {application} may need Automation permission.")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def open_page(url: str, title: str, patience: float) -> int:
|
|
104
|
+
window = int(_osascript(page_script(url)))
|
|
105
|
+
_wait("Safari", window, title, patience)
|
|
106
|
+
return window
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def open_terminal(command: list[str], title: str, patience: float) -> int:
|
|
110
|
+
directory = pathlib.Path(tempfile.mkdtemp(prefix="quality-loop-terminal-"))
|
|
111
|
+
launch = directory / "screen"
|
|
112
|
+
launch.write_text(wrapper(command), encoding="utf-8")
|
|
113
|
+
launch.chmod(0o755)
|
|
114
|
+
window = int(_osascript(terminal_script(str(launch), title)))
|
|
115
|
+
_wait("Terminal", window, title, patience)
|
|
116
|
+
return window
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _exists(application: str, handle: int) -> bool:
|
|
120
|
+
return _osascript(f'''if application "{application}" is running then
|
|
121
|
+
tell application "{application}" to return (exists (first window whose id is {int(handle)}))
|
|
122
|
+
end if
|
|
123
|
+
return false''') == "true"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def close(kind: str, handle: int, patience: float = 5.0) -> None:
|
|
127
|
+
"""Close the window ``handle``, and make sure it went.
|
|
128
|
+
|
|
129
|
+
A Terminal window whose tab still lists a process answers a close with a
|
|
130
|
+
"terminate running processes?" sheet and stays, saying nothing - which is
|
|
131
|
+
how a closed screen used to stay on the desktop. So the processes on that
|
|
132
|
+
window's own terminal are ended first. They are the script this module
|
|
133
|
+
started and the shell around it, found by the tab's tty, so nothing of
|
|
134
|
+
the operator's is touched.
|
|
135
|
+
"""
|
|
136
|
+
application = "Safari" if kind == "page" else "Terminal"
|
|
137
|
+
if not _exists(application, handle):
|
|
138
|
+
return
|
|
139
|
+
if kind == "terminal":
|
|
140
|
+
tty = _osascript(f'tell application "Terminal" to get tty of selected tab of '
|
|
141
|
+
f'(first window whose id is {int(handle)})')
|
|
142
|
+
if tty.startswith("/dev/tty"):
|
|
143
|
+
subprocess.run(["pkill", "-t", tty.removeprefix("/dev/")], check=False)
|
|
144
|
+
time.sleep(0.5)
|
|
145
|
+
_osascript(f'tell application "{application}" to close '
|
|
146
|
+
f'(first window whose id is {int(handle)})')
|
|
147
|
+
deadline = time.monotonic() + patience
|
|
148
|
+
while time.monotonic() < deadline:
|
|
149
|
+
if not _exists(application, handle):
|
|
150
|
+
return
|
|
151
|
+
time.sleep(0.25)
|
|
152
|
+
raise ScreenError(f"{application} window {handle} is still open after closing it")
|