pluginbench 0.5.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.
- pluginbench/__init__.py +3 -0
- pluginbench/__main__.py +3 -0
- pluginbench/assessment-charts.html +790 -0
- pluginbench/assessment.py +316 -0
- pluginbench/assessment_charts.py +1225 -0
- pluginbench/assessment_metrics.py +1956 -0
- pluginbench/assessment_models.py +127 -0
- pluginbench/baselines.py +792 -0
- pluginbench/claude.py +374 -0
- pluginbench/cli.py +672 -0
- pluginbench/code_diff.py +207 -0
- pluginbench/codex.py +451 -0
- pluginbench/codex_events.py +585 -0
- pluginbench/collectors.py +591 -0
- pluginbench/compare.py +927 -0
- pluginbench/components.py +129 -0
- pluginbench/coverage_report.py +610 -0
- pluginbench/datasets.py +207 -0
- pluginbench/evaluation.example.yaml +45 -0
- pluginbench/evaluation.py +1651 -0
- pluginbench/evaluation_report.py +1004 -0
- pluginbench/fallow_parse.mjs +101 -0
- pluginbench/harbor_backend.py +2946 -0
- pluginbench/isolation.py +314 -0
- pluginbench/judge.py +683 -0
- pluginbench/language_analysis.py +915 -0
- pluginbench/ledger.py +217 -0
- pluginbench/measurement_profiles.py +616 -0
- pluginbench/native_treatment_runtime.js +221 -0
- pluginbench/native_treatments.py +155 -0
- pluginbench/observer_data/go-analysis-v1/go.mod +10 -0
- pluginbench/observer_data/go-analysis-v1/go.sum +10 -0
- pluginbench/observer_data/go-analysis-v1/main.go +64 -0
- pluginbench/observer_data/oxc-parser-0.126.0/package-lock.json +449 -0
- pluginbench/observer_data/oxc-parser-0.126.0/package.json +5 -0
- pluginbench/parallel.py +101 -0
- pluginbench/processes.py +100 -0
- pluginbench/remeasure.py +992 -0
- pluginbench/run_lock.py +227 -0
- pluginbench/run_status.py +518 -0
- pluginbench/study.py +1858 -0
- pluginbench/study_review.py +924 -0
- pluginbench/submissions.py +486 -0
- pluginbench/task_sources.py +403 -0
- pluginbench/treatments.py +388 -0
- pluginbench/trial_containers.py +94 -0
- pluginbench/vendor/ECHARTS-LICENSE +222 -0
- pluginbench/vendor/ECHARTS-NOTICE +5 -0
- pluginbench/vendor/ZRENDER-LICENSE +29 -0
- pluginbench/vendor/echarts.min.js +48 -0
- pluginbench-0.5.0.dist-info/METADATA +294 -0
- pluginbench-0.5.0.dist-info/RECORD +58 -0
- pluginbench-0.5.0.dist-info/WHEEL +4 -0
- pluginbench-0.5.0.dist-info/entry_points.txt +2 -0
- pluginbench-0.5.0.dist-info/licenses/LICENSE +201 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-LICENSE +222 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ECHARTS-NOTICE +5 -0
- pluginbench-0.5.0.dist-info/licenses/src/pluginbench/vendor/ZRENDER-LICENSE +29 -0
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
"""Resolve and freeze native instruction files, skill trees, and plugin packages.
|
|
2
|
+
|
|
3
|
+
This validates package contents, not whether a particular runtime can load them.
|
|
4
|
+
Git internals are excluded from snapshots. Ambient dotenv files are rejected;
|
|
5
|
+
only explicitly named example/sample/template dotenv files are allowed.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
import shutil
|
|
15
|
+
import stat
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import yaml
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class Treatment(BaseModel):
|
|
24
|
+
model_config = ConfigDict(extra="forbid")
|
|
25
|
+
|
|
26
|
+
guidance: list[Path] = Field(default_factory=list)
|
|
27
|
+
skills: list[Path] = Field(default_factory=list)
|
|
28
|
+
plugins: list[Path] = Field(default_factory=list)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _checked_path(path: Path, base: Path) -> Path:
|
|
32
|
+
"""Check components before normalization, so symlink/../ cannot hide a link."""
|
|
33
|
+
value = path.expanduser()
|
|
34
|
+
if not value.is_absolute():
|
|
35
|
+
value = base.absolute() / value
|
|
36
|
+
if ".git" in value.parts:
|
|
37
|
+
raise ValueError(f"treatment must not select Git internals: {value}")
|
|
38
|
+
current = Path(value.anchor)
|
|
39
|
+
for part in value.parts[1:]:
|
|
40
|
+
if part == "..":
|
|
41
|
+
current = current.parent
|
|
42
|
+
continue
|
|
43
|
+
current = current / part
|
|
44
|
+
try:
|
|
45
|
+
mode = current.lstat().st_mode
|
|
46
|
+
except OSError as error:
|
|
47
|
+
raise ValueError(f"treatment path is missing or unreadable: {value}") from error
|
|
48
|
+
if stat.S_ISLNK(mode):
|
|
49
|
+
raise ValueError(f"treatment path must not contain a symlink: {current}")
|
|
50
|
+
return current
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _tree(root: Path) -> list[Path]:
|
|
54
|
+
_checked_path(root, Path.cwd())
|
|
55
|
+
paths = [root]
|
|
56
|
+
if not root.is_dir():
|
|
57
|
+
raise ValueError(f"treatment package must be a directory: {root}")
|
|
58
|
+
def unreadable(error: OSError) -> None:
|
|
59
|
+
raise ValueError(f"treatment contains an unreadable directory: {error.filename}") from error
|
|
60
|
+
|
|
61
|
+
for directory, dirs, files in os.walk(root, followlinks=False, onerror=unreadable):
|
|
62
|
+
parent = Path(directory)
|
|
63
|
+
for name in sorted([*dirs, *files]):
|
|
64
|
+
path = parent / name
|
|
65
|
+
mode = path.lstat().st_mode
|
|
66
|
+
if stat.S_ISLNK(mode):
|
|
67
|
+
raise ValueError(f"treatment contains a symlink: {path}")
|
|
68
|
+
if not (stat.S_ISDIR(mode) or stat.S_ISREG(mode)):
|
|
69
|
+
raise ValueError(f"treatment contains a nonregular asset: {path}")
|
|
70
|
+
if name == ".git":
|
|
71
|
+
continue
|
|
72
|
+
if (name == ".env" or name.startswith(".env.")) and name not in {
|
|
73
|
+
".env.example", ".env.sample", ".env.template"
|
|
74
|
+
}:
|
|
75
|
+
raise ValueError(f"treatment contains an ambient dotenv file: {path}")
|
|
76
|
+
paths.append(path)
|
|
77
|
+
dirs[:] = sorted(name for name in dirs if name != ".git")
|
|
78
|
+
return sorted(paths, key=lambda path: path.relative_to(root).as_posix())
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _guidance(path: Path, base: Path) -> Path:
|
|
82
|
+
path = _checked_path(path, base)
|
|
83
|
+
if not stat.S_ISREG(path.stat().st_mode) or path.suffix.lower() not in {".md", ".txt"}:
|
|
84
|
+
raise ValueError(f"guidance must select regular .md/.txt files: {path}")
|
|
85
|
+
try:
|
|
86
|
+
path.read_text(encoding="utf-8")
|
|
87
|
+
except UnicodeError as error:
|
|
88
|
+
raise ValueError(f"guidance must be UTF-8: {path}") from error
|
|
89
|
+
return path
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _skill_name(root: Path) -> str:
|
|
93
|
+
skill = root / "SKILL.md"
|
|
94
|
+
try:
|
|
95
|
+
text = skill.read_text(encoding="utf-8")
|
|
96
|
+
except (OSError, UnicodeError) as error:
|
|
97
|
+
raise ValueError(f"skill requires a UTF-8 SKILL.md: {root}") from error
|
|
98
|
+
lines = text.splitlines()
|
|
99
|
+
if not lines or lines[0] != "---" or "---" not in lines[1:]:
|
|
100
|
+
raise ValueError(f"skill requires YAML frontmatter: {skill}")
|
|
101
|
+
try:
|
|
102
|
+
metadata = yaml.safe_load("\n".join(lines[1 : lines.index("---", 1)]))
|
|
103
|
+
except yaml.YAMLError as error:
|
|
104
|
+
raise ValueError(f"invalid skill frontmatter: {skill}") from error
|
|
105
|
+
if not isinstance(metadata, dict):
|
|
106
|
+
raise ValueError(f"skill frontmatter must be an object: {skill}")
|
|
107
|
+
name = metadata.get("name")
|
|
108
|
+
if not isinstance(name, str) or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
|
|
109
|
+
raise ValueError(f"skill requires a kebab-case name: {skill}")
|
|
110
|
+
description = metadata.get("description")
|
|
111
|
+
if not isinstance(description, str) or not description.strip():
|
|
112
|
+
raise ValueError(f"skill requires a description: {skill}")
|
|
113
|
+
_skill_dependencies(metadata, skill)
|
|
114
|
+
agent_file = root / "agents/openai.yaml"
|
|
115
|
+
if agent_file.exists():
|
|
116
|
+
try:
|
|
117
|
+
agent = yaml.safe_load(agent_file.read_text(encoding="utf-8"))
|
|
118
|
+
except (OSError, UnicodeError, yaml.YAMLError) as error:
|
|
119
|
+
raise ValueError(f"invalid skill agent metadata: {agent_file}") from error
|
|
120
|
+
if not isinstance(agent, dict):
|
|
121
|
+
raise ValueError(f"skill agent metadata must be an object: {agent_file}")
|
|
122
|
+
_skill_dependencies(agent, agent_file)
|
|
123
|
+
if (root / "SKILL.json").exists():
|
|
124
|
+
_skill_dependencies(_json_object(root / "SKILL.json"), root / "SKILL.json")
|
|
125
|
+
return name
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _skill_dependencies(metadata: dict[str, Any], source: Path) -> None:
|
|
129
|
+
dependencies = metadata.get("dependencies", {})
|
|
130
|
+
if (not isinstance(dependencies, dict)
|
|
131
|
+
or any(dependencies.values())):
|
|
132
|
+
raise ValueError(f"unsupported declared skill dependencies: {source}")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _skill_roots(path: Path) -> list[Path]:
|
|
136
|
+
_tree(path)
|
|
137
|
+
roots = [path] if (path / "SKILL.md").exists() else [
|
|
138
|
+
child for child in sorted(path.iterdir())
|
|
139
|
+
if child.is_dir() and child.name != ".git" and (child / "SKILL.md").exists()
|
|
140
|
+
]
|
|
141
|
+
if not roots:
|
|
142
|
+
raise ValueError(f"skill directory contains no direct SKILL.md roots: {path}")
|
|
143
|
+
names = [_skill_name(root) for root in roots]
|
|
144
|
+
if len(set(names)) != len(names):
|
|
145
|
+
raise ValueError(f"duplicate skill names in collection: {path}")
|
|
146
|
+
return roots
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _json_object(path: Path) -> dict[str, Any]:
|
|
150
|
+
try:
|
|
151
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
152
|
+
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
|
153
|
+
raise ValueError(f"invalid or missing plugin JSON: {path}") from error
|
|
154
|
+
if not isinstance(value, dict):
|
|
155
|
+
raise ValueError(f"plugin JSON must be an object: {path}")
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _component(root: Path, value: object) -> Path:
|
|
160
|
+
if not isinstance(value, str) or not value.startswith("./"):
|
|
161
|
+
raise ValueError(f"plugin component path must start with ./ inside {root}: {value}")
|
|
162
|
+
relative = Path(value)
|
|
163
|
+
if ".." in relative.parts:
|
|
164
|
+
raise ValueError(f"plugin component path escapes its package: {value}")
|
|
165
|
+
path = _checked_path(relative, root)
|
|
166
|
+
if ".git" in path.relative_to(root).parts:
|
|
167
|
+
raise ValueError(f"plugin component must not refer to excluded Git metadata: {value}")
|
|
168
|
+
return path
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _hooks(root: Path, value: object) -> list[dict[str, Any]]:
|
|
172
|
+
values = value if isinstance(value, list) else [value]
|
|
173
|
+
configs = []
|
|
174
|
+
for entry in values:
|
|
175
|
+
config = _json_object(_component(root, entry)) if isinstance(entry, str) else entry
|
|
176
|
+
if not isinstance(config, dict):
|
|
177
|
+
raise ValueError(f"plugin hooks must be JSON paths or objects: {root}")
|
|
178
|
+
if config and "hooks" not in config:
|
|
179
|
+
raise ValueError(f"plugin hooks require the full hook-file object with hooks: {root}")
|
|
180
|
+
events = config.get("hooks", {})
|
|
181
|
+
if not isinstance(events, dict):
|
|
182
|
+
raise ValueError(f"plugin hook events must be an object: {root}")
|
|
183
|
+
for groups in events.values():
|
|
184
|
+
if not isinstance(groups, list):
|
|
185
|
+
raise ValueError(f"plugin hook event must contain a list: {root}")
|
|
186
|
+
for group in groups:
|
|
187
|
+
if not isinstance(group, dict) or not isinstance(group.get("hooks"), list):
|
|
188
|
+
raise ValueError(f"invalid plugin hook group: {root}")
|
|
189
|
+
for hook in group["hooks"]:
|
|
190
|
+
if (not isinstance(hook, dict) or hook.get("type") != "command"
|
|
191
|
+
or not isinstance(hook.get("command"), str)
|
|
192
|
+
or not hook["command"].strip()):
|
|
193
|
+
raise ValueError(f"only command plugin hooks are supported: {root}")
|
|
194
|
+
if hook.get("async") or hook.get("asyncRewake"):
|
|
195
|
+
raise ValueError(f"asynchronous plugin hooks are unsupported: {root}")
|
|
196
|
+
if any(events.values()):
|
|
197
|
+
configs.append(config)
|
|
198
|
+
return configs
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
_METADATA = {
|
|
202
|
+
"$schema", "name", "version", "description", "author", "homepage", "repository",
|
|
203
|
+
"license", "keywords", "interface", "extensions", "onboardingSkill",
|
|
204
|
+
}
|
|
205
|
+
_COMPONENTS = {"skills", "hooks", "mcpServers", "apps", "agents", "commands", "dependencies"}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _plugin(root: Path, harness: str | None = None) -> dict[str, Any]:
|
|
209
|
+
_tree(root)
|
|
210
|
+
portable = root / "plugin.json"
|
|
211
|
+
codex = root / ".codex-plugin/plugin.json"
|
|
212
|
+
claude = root / ".claude-plugin/plugin.json"
|
|
213
|
+
manifest_path = portable if portable.exists() else (
|
|
214
|
+
claude if harness == "claude" else codex
|
|
215
|
+
)
|
|
216
|
+
if harness is None and not manifest_path.exists() and claude.exists():
|
|
217
|
+
manifest_path = claude
|
|
218
|
+
manifest = _json_object(manifest_path)
|
|
219
|
+
name = manifest.get("name")
|
|
220
|
+
if not isinstance(name, str) or not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name):
|
|
221
|
+
raise ValueError(f"plugin requires a kebab-case name: {manifest_path}")
|
|
222
|
+
unknown = set(manifest) - _METADATA - _COMPONENTS
|
|
223
|
+
if unknown:
|
|
224
|
+
raise ValueError(f"unsupported plugin manifest fields: {sorted(unknown)}")
|
|
225
|
+
settings = manifest
|
|
226
|
+
if manifest_path == portable:
|
|
227
|
+
extensions = manifest.get("extensions", {})
|
|
228
|
+
if not isinstance(extensions, dict):
|
|
229
|
+
raise ValueError(f"plugin extensions must be an object: {manifest_path}")
|
|
230
|
+
unknown_extensions = set(extensions) - {"com.openai"}
|
|
231
|
+
if unknown_extensions:
|
|
232
|
+
raise ValueError(f"unsupported plugin extensions: {sorted(unknown_extensions)}")
|
|
233
|
+
if "com.openai" in extensions:
|
|
234
|
+
settings = extensions["com.openai"]
|
|
235
|
+
if not isinstance(settings, dict):
|
|
236
|
+
raise ValueError(f"plugin com.openai extension must be an object: {manifest_path}")
|
|
237
|
+
elif codex.exists() and harness != "claude":
|
|
238
|
+
settings = _json_object(codex)
|
|
239
|
+
unknown = set(settings) - _METADATA - _COMPONENTS
|
|
240
|
+
if unknown:
|
|
241
|
+
raise ValueError(f"unsupported plugin settings: {sorted(unknown)}")
|
|
242
|
+
onboarding = [settings["onboardingSkill"]] if "onboardingSkill" in settings else []
|
|
243
|
+
if manifest_path != portable and "extensions" in settings:
|
|
244
|
+
extensions = settings["extensions"]
|
|
245
|
+
if not isinstance(extensions, dict) or set(extensions) - {"com.openai"}:
|
|
246
|
+
raise ValueError(f"unsupported plugin compatibility extensions: {manifest_path}")
|
|
247
|
+
extension = extensions.get("com.openai", {})
|
|
248
|
+
if not isinstance(extension, dict) or set(extension) - {"onboardingSkill"}:
|
|
249
|
+
raise ValueError(f"unsupported plugin compatibility extension fields: {manifest_path}")
|
|
250
|
+
if "onboardingSkill" in extension:
|
|
251
|
+
onboarding.append(extension["onboardingSkill"])
|
|
252
|
+
for document in (manifest, settings):
|
|
253
|
+
for field in ("mcpServers", "apps", "agents", "commands", "dependencies"):
|
|
254
|
+
if document.get(field):
|
|
255
|
+
raise ValueError(f"unsupported declared plugin component {field}: {root}")
|
|
256
|
+
# Default MCP files are active components even without a manifest declaration.
|
|
257
|
+
for filename in ("mcp.json", ".mcp.json", ".app.json"):
|
|
258
|
+
if (root / filename).exists():
|
|
259
|
+
config = _json_object(root / filename)
|
|
260
|
+
if config.get("mcpServers") or config.get("apps"):
|
|
261
|
+
raise ValueError(f"unsupported plugin MCP/app configuration: {root / filename}")
|
|
262
|
+
skill_paths = settings.get("skills")
|
|
263
|
+
if skill_paths is None:
|
|
264
|
+
roots = _skill_roots(root / "skills") if (root / "skills").exists() else []
|
|
265
|
+
else:
|
|
266
|
+
paths = skill_paths if isinstance(skill_paths, list) else [skill_paths]
|
|
267
|
+
roots = [skill for value in paths for skill in _skill_roots(_component(root, value))]
|
|
268
|
+
names = [_skill_name(skill) for skill in roots]
|
|
269
|
+
if len(set(names)) != len(names):
|
|
270
|
+
raise ValueError(f"duplicate plugin skill names: {root}")
|
|
271
|
+
if "hooks" in settings:
|
|
272
|
+
hooks = _hooks(root, settings["hooks"])
|
|
273
|
+
elif (root / "hooks/hooks.json").exists():
|
|
274
|
+
hooks = _hooks(root, "./hooks/hooks.json")
|
|
275
|
+
else:
|
|
276
|
+
hooks = []
|
|
277
|
+
for value in onboarding:
|
|
278
|
+
path = _component(root, value)
|
|
279
|
+
if path.name != "SKILL.md" or path.parent not in roots:
|
|
280
|
+
raise ValueError(f"plugin onboardingSkill must reference a packaged skill: {path}")
|
|
281
|
+
interface = settings.get("interface", {})
|
|
282
|
+
if not isinstance(interface, dict):
|
|
283
|
+
raise ValueError(f"plugin interface must be an object: {root}")
|
|
284
|
+
for field in ("composerIcon", "logo", "screenshots"):
|
|
285
|
+
if field in interface:
|
|
286
|
+
values = interface[field] if isinstance(interface[field], list) else [interface[field]]
|
|
287
|
+
for value in values:
|
|
288
|
+
if not _component(root, value).is_file():
|
|
289
|
+
raise ValueError(f"plugin interface asset must be a file: {value}")
|
|
290
|
+
return {
|
|
291
|
+
"name": name,
|
|
292
|
+
"manifest_path": manifest_path.relative_to(root).as_posix(),
|
|
293
|
+
"manifest": manifest,
|
|
294
|
+
"settings": settings,
|
|
295
|
+
"skills": [{"name": _skill_name(skill), "path": skill.relative_to(root).as_posix()}
|
|
296
|
+
for skill in roots],
|
|
297
|
+
"hooks": hooks,
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def resolve_treatment(treatment: Treatment, base: Path, harness: str) -> Treatment:
|
|
302
|
+
"""Validate selected inputs and return absolute files and actual skill roots."""
|
|
303
|
+
if harness not in {"codex", "claude"}:
|
|
304
|
+
raise ValueError(f"unsupported treatment harness: {harness}")
|
|
305
|
+
if harness == "claude" and treatment.plugins:
|
|
306
|
+
raise ValueError("native plugin qualification currently supports only Codex")
|
|
307
|
+
guidance = [_guidance(path, base) for path in treatment.guidance]
|
|
308
|
+
if len(set(guidance)) != len(guidance):
|
|
309
|
+
raise ValueError("duplicate guidance files in treatment")
|
|
310
|
+
skills = [root for path in treatment.skills
|
|
311
|
+
for root in _skill_roots(_checked_path(path, base))]
|
|
312
|
+
names = [_skill_name(root) for root in skills]
|
|
313
|
+
if len(set(names)) != len(names):
|
|
314
|
+
raise ValueError("duplicate skill names in treatment")
|
|
315
|
+
if len({root.name for root in skills}) != len(skills):
|
|
316
|
+
raise ValueError("duplicate skill directory basenames in treatment")
|
|
317
|
+
plugins = [_checked_path(path, base) for path in treatment.plugins]
|
|
318
|
+
names = [_plugin(root, harness)["name"] for root in plugins]
|
|
319
|
+
if len(set(names)) != len(names):
|
|
320
|
+
raise ValueError("duplicate plugin identity in treatment")
|
|
321
|
+
return Treatment(guidance=guidance, skills=skills, plugins=plugins)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _entries(root: Path) -> list[dict[str, Any]]:
|
|
325
|
+
entries = []
|
|
326
|
+
for path in _tree(root):
|
|
327
|
+
mode = stat.S_IMODE(path.stat().st_mode)
|
|
328
|
+
entry: dict[str, Any] = {
|
|
329
|
+
"path": path.relative_to(root).as_posix(),
|
|
330
|
+
"kind": "directory" if path.is_dir() else "file", "mode": mode,
|
|
331
|
+
}
|
|
332
|
+
if path.is_file():
|
|
333
|
+
content = path.read_bytes()
|
|
334
|
+
entry.update(sha256=hashlib.sha256(content).hexdigest(), size=len(content))
|
|
335
|
+
entries.append(entry)
|
|
336
|
+
return entries
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _package_inventory(root: Path) -> dict[str, Any]:
|
|
340
|
+
entries = _entries(root)
|
|
341
|
+
digest = hashlib.sha256(json.dumps(entries, sort_keys=True).encode()).hexdigest()
|
|
342
|
+
return {"directory": root.name, "sha256": digest, "entries": entries}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def treatment_inventory(treatment: Treatment) -> dict[str, Any]:
|
|
346
|
+
"""Portable content/mode identities, independent of installation paths."""
|
|
347
|
+
guidance = [_guidance(path, Path.cwd()) for path in treatment.guidance]
|
|
348
|
+
return {
|
|
349
|
+
"guidance": [{"name": path.name, "mode": stat.S_IMODE(path.stat().st_mode),
|
|
350
|
+
"sha256": hashlib.sha256(path.read_bytes()).hexdigest(),
|
|
351
|
+
"size": path.stat().st_size} for path in guidance],
|
|
352
|
+
"skills": [{**_package_inventory(root), "name": _skill_name(root)}
|
|
353
|
+
for root in treatment.skills],
|
|
354
|
+
"plugins": [{**_package_inventory(root), **_plugin(root)} for root in treatment.plugins],
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def freeze_treatment(treatment: Treatment, destination: Path) -> Treatment:
|
|
359
|
+
"""Copy complete packages into deterministic locations without Git internals."""
|
|
360
|
+
# Inventory first: reject invalid packages before writing a partial snapshot.
|
|
361
|
+
before = treatment_inventory(treatment)
|
|
362
|
+
for path in [destination, *destination.parents]:
|
|
363
|
+
if path.exists() or path.is_symlink():
|
|
364
|
+
_checked_path(path, Path.cwd())
|
|
365
|
+
normalized = Path(os.path.abspath(destination))
|
|
366
|
+
if any(normalized.is_relative_to(root.absolute()) for root in [
|
|
367
|
+
*treatment.skills, *treatment.plugins,
|
|
368
|
+
]):
|
|
369
|
+
raise ValueError("treatment snapshot destination must be outside its input packages")
|
|
370
|
+
if destination.exists() and not destination.is_dir():
|
|
371
|
+
raise ValueError(f"treatment snapshot destination must be a directory: {destination}")
|
|
372
|
+
if destination.exists() and any(destination.iterdir()):
|
|
373
|
+
raise ValueError(f"treatment snapshot destination must be empty: {destination}")
|
|
374
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
375
|
+
frozen: dict[str, list[Path]] = {"guidance": [], "skills": [], "plugins": []}
|
|
376
|
+
for kind in frozen:
|
|
377
|
+
for index, source in enumerate(getattr(treatment, kind)):
|
|
378
|
+
target = destination / kind / f"{index:03d}" / source.name
|
|
379
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
380
|
+
if kind == "guidance":
|
|
381
|
+
shutil.copy2(source, target)
|
|
382
|
+
else:
|
|
383
|
+
shutil.copytree(source, target, ignore=shutil.ignore_patterns(".git"))
|
|
384
|
+
frozen[kind].append(target.absolute())
|
|
385
|
+
result = Treatment(**frozen)
|
|
386
|
+
if treatment_inventory(result) != before:
|
|
387
|
+
raise ValueError("treatment changed while its snapshot was being frozen")
|
|
388
|
+
return result
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Find the containers a run's Harbor trials left running, and stop them; never remove any.
|
|
2
|
+
|
|
3
|
+
A killed run leaves its trial's compose project up: the kill skips the cleanup that removes it.
|
|
4
|
+
Ownership is proven from the run's own files. Harbor starts each trial's compose project with
|
|
5
|
+
`--project-directory` set inside the run's frozen task (evidence/frozen/suite/<i>/task), and
|
|
6
|
+
Docker Compose records that directory as the `com.docker.compose.project.working_dir` label.
|
|
7
|
+
The trial directory itself is no proof: it lives in a temporary area outside the run.
|
|
8
|
+
|
|
9
|
+
Removal stays with harbor_backend.cleanup(); this module only lists and stops.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import re
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
from pluginbench import harbor_backend
|
|
20
|
+
|
|
21
|
+
_WORKING_DIR = "com.docker.compose.project.working_dir"
|
|
22
|
+
# harbor_backend names each trial `pluginbench-<uuid4 hex>`; Harbor adds `__env` for the agent's
|
|
23
|
+
# project and `__verifier__<key>` for a separate verifier's.
|
|
24
|
+
_TRIAL_PROJECT = re.compile(r"pluginbench-[0-9a-f]{32}__.+")
|
|
25
|
+
_FORMAT = (
|
|
26
|
+
"[{{json .ID}},{{json .Names}},"
|
|
27
|
+
'{{json (.Label "com.docker.compose.project")}},'
|
|
28
|
+
f'{{{{json (.Label "{_WORKING_DIR}")}}}}]'
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def running(evidence: Path, *, expected_runtime: dict[str, object] | None) -> list[dict[str, str]]:
|
|
33
|
+
"""Running containers of this run's Harbor trials: id, name and compose project.
|
|
34
|
+
|
|
35
|
+
`expected_runtime` pins the Docker CLI and engine, as for every other Docker call of a run;
|
|
36
|
+
None uses the selected Docker context, which is how a coding attempt picks its engine.
|
|
37
|
+
"""
|
|
38
|
+
suite = (evidence / "frozen" / "suite").resolve()
|
|
39
|
+
if not suite.is_dir():
|
|
40
|
+
return [] # No task was frozen, so no trial ever started.
|
|
41
|
+
# `docker ps` without --all lists only running containers.
|
|
42
|
+
output = harbor_backend._docker(
|
|
43
|
+
"ps",
|
|
44
|
+
"--no-trunc",
|
|
45
|
+
"--filter",
|
|
46
|
+
"status=running",
|
|
47
|
+
"--filter",
|
|
48
|
+
f"label={_WORKING_DIR}",
|
|
49
|
+
"--format",
|
|
50
|
+
_FORMAT,
|
|
51
|
+
expected_runtime=expected_runtime,
|
|
52
|
+
)
|
|
53
|
+
found = []
|
|
54
|
+
for line in output.splitlines():
|
|
55
|
+
if not line.strip():
|
|
56
|
+
continue
|
|
57
|
+
container, name, project, working_dir = json.loads(line)
|
|
58
|
+
if (
|
|
59
|
+
_TRIAL_PROJECT.fullmatch(project)
|
|
60
|
+
and Path(working_dir).is_absolute()
|
|
61
|
+
and Path(working_dir).resolve().is_relative_to(suite)
|
|
62
|
+
):
|
|
63
|
+
found.append({"id": container, "name": name, "project": project})
|
|
64
|
+
return found
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def stop_left_running(
|
|
68
|
+
evidence: Path,
|
|
69
|
+
*,
|
|
70
|
+
expected_runtime: dict[str, object],
|
|
71
|
+
progress: Callable[[str], None] | None,
|
|
72
|
+
) -> None:
|
|
73
|
+
"""Stop every running container of this run's trials. Only the run.lock holder calls this.
|
|
74
|
+
|
|
75
|
+
The holder is the only process working on this run, so whatever is still running was left
|
|
76
|
+
by an attempt that a kill interrupted.
|
|
77
|
+
"""
|
|
78
|
+
found = running(evidence, expected_runtime=expected_runtime)
|
|
79
|
+
for project in dict.fromkeys(container["project"] for container in found):
|
|
80
|
+
ids = [container["id"] for container in found if container["project"] == project]
|
|
81
|
+
try:
|
|
82
|
+
harbor_backend._docker("stop", "--time", "10", *ids, expected_runtime=expected_runtime)
|
|
83
|
+
except ValueError:
|
|
84
|
+
# A surviving Harbor worker may remove its own containers in the meantime; only a
|
|
85
|
+
# container that is still running is a failure.
|
|
86
|
+
still = running(evidence, expected_runtime=expected_runtime)
|
|
87
|
+
if any(container["id"] in ids for container in still):
|
|
88
|
+
raise
|
|
89
|
+
continue
|
|
90
|
+
if progress:
|
|
91
|
+
progress(
|
|
92
|
+
f"Stopped {project} ({len(ids)} container{'s' * (len(ids) != 1)}): left running "
|
|
93
|
+
"by an attempt that a kill interrupted"
|
|
94
|
+
)
|