lemans 0.0.0.pre → 0.2.1
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +9 -0
- data/LICENSE.txt +21 -0
- data/README.md +228 -0
- data/exe/lemans +17 -0
- data/exe/lemans-remote +984 -0
- data/lib/lemans/agents/base.rb +30 -0
- data/lib/lemans/agents/miniswen.rb +119 -0
- data/lib/lemans/agents/miniswen_installed.rb +67 -0
- data/lib/lemans/agents/nop.rb +15 -0
- data/lib/lemans/agents/oracle.rb +53 -0
- data/lib/lemans/agents.rb +21 -0
- data/lib/lemans/bench.rb +280 -0
- data/lib/lemans/cli/board_reporter.rb +135 -0
- data/lib/lemans/cli/progress_reporter.rb +67 -0
- data/lib/lemans/cli.rb +181 -0
- data/lib/lemans/clobber.rb +79 -0
- data/lib/lemans/environments/base.rb +55 -0
- data/lib/lemans/environments/daytona/retries.rb +49 -0
- data/lib/lemans/environments/daytona/sdk_tweaks.rb +50 -0
- data/lib/lemans/environments/daytona/shell.rb +142 -0
- data/lib/lemans/environments/daytona/snapshot_store.rb +163 -0
- data/lib/lemans/environments/daytona.rb +175 -0
- data/lib/lemans/environments.rb +16 -0
- data/lib/lemans/network_policy.rb +66 -0
- data/lib/lemans/patch.rb +70 -0
- data/lib/lemans/restore_paths.rb +21 -0
- data/lib/lemans/results/aggregate.rb +114 -0
- data/lib/lemans/results/cost_source.rb +13 -0
- data/lib/lemans/results/outcome.rb +36 -0
- data/lib/lemans/results/report.rb +149 -0
- data/lib/lemans/results/sorting.rb +24 -0
- data/lib/lemans/results/tally.rb +19 -0
- data/lib/lemans/results/usage.rb +24 -0
- data/lib/lemans/run.rb +152 -0
- data/lib/lemans/setup.rb +59 -0
- data/lib/lemans/setup_files.rb +36 -0
- data/lib/lemans/snapshot.rb +55 -0
- data/lib/lemans/task.rb +207 -0
- data/lib/lemans/tree_digest.rb +24 -0
- data/lib/lemans/trial.rb +187 -0
- data/lib/lemans/units.rb +44 -0
- data/lib/lemans/verifier/assets/eport-lemans.rb +36 -0
- data/lib/lemans/verifier/assets/lemans_minitest_reporter.rb +61 -0
- data/lib/lemans/verifier.rb +199 -0
- data/lib/lemans/version.rb +5 -0
- data/lib/lemans.rb +29 -0
- data/lib/miniswen/agent.rb +678 -0
- data/lib/miniswen/cli.rb +224 -0
- data/lib/miniswen/environment.rb +14 -0
- data/lib/miniswen/local.rb +42 -0
- data/lib/miniswen/ruby_llm.rb +42 -0
- data/lib/miniswen/testing.rb +134 -0
- data/lib/miniswen/trajectory.rb +110 -0
- data/lib/miniswen/version.rb +5 -0
- data/lib/miniswen.rb +48 -0
- metadata +161 -7
data/lib/lemans/task.rb
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "pathname"
|
|
6
|
+
|
|
7
|
+
module Lemans
|
|
8
|
+
# One task: an instruction, an environment, a verifier, a solution.
|
|
9
|
+
# Anything lemans does not understand belongs under `metadata`, copied untouched.
|
|
10
|
+
class Task
|
|
11
|
+
INSTRUCTION = "instruction.md"
|
|
12
|
+
ENVIRONMENT_DIR = "environment"
|
|
13
|
+
TESTS_DIR = "tests"
|
|
14
|
+
SOLUTION_DIR = "solution"
|
|
15
|
+
|
|
16
|
+
FLAT_TEST = "verification_test.rb"
|
|
17
|
+
FLAT_SOLUTION = "solution.patch"
|
|
18
|
+
FLAT_SEED = "environment.patch"
|
|
19
|
+
|
|
20
|
+
FRONTMATTER = /\A---\n(.*?)\n---\n/m
|
|
21
|
+
|
|
22
|
+
# An image, either already published or built from a task's Dockerfile.
|
|
23
|
+
# Built images are named by content digest, so reuse is only ever of the identical thing.
|
|
24
|
+
class ImageSpec
|
|
25
|
+
attr_reader :reference, :dockerfile_path, :slug, :digest
|
|
26
|
+
|
|
27
|
+
def self.registry(reference) = new(reference: reference)
|
|
28
|
+
|
|
29
|
+
def self.dockerfile(path, slug:)
|
|
30
|
+
path = Pathname(path)
|
|
31
|
+
raise ConfigError, "no Dockerfile at #{path}" unless path.file?
|
|
32
|
+
|
|
33
|
+
new(dockerfile_path: path, slug: slug)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(reference: nil, dockerfile_path: nil, slug: nil)
|
|
37
|
+
@reference = reference
|
|
38
|
+
@dockerfile_path = dockerfile_path
|
|
39
|
+
@slug = slug
|
|
40
|
+
# Hashing the reference gives backends one answer to "is this the same image" either way.
|
|
41
|
+
@digest = built? ? TreeDigest.call(context_dir) : Digest::SHA256.hexdigest(reference.to_s)
|
|
42
|
+
freeze
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def built? = !dockerfile_path.nil?
|
|
46
|
+
|
|
47
|
+
def context_dir = dockerfile_path&.dirname
|
|
48
|
+
|
|
49
|
+
def name
|
|
50
|
+
built? ? "lemans-#{digest[0, 32]}" : reference
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def to_s = name
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
attr_reader :dir, :name, :description, :difficulty, :tags, :metadata, :bench, :digest
|
|
57
|
+
|
|
58
|
+
def self.load(dir, bench:)
|
|
59
|
+
dir = Pathname(dir)
|
|
60
|
+
new(frontmatter(dir), dir: dir, bench: bench)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def self.frontmatter(dir)
|
|
64
|
+
content = dir.join(INSTRUCTION).read
|
|
65
|
+
match = content.match(FRONTMATTER)
|
|
66
|
+
unless match
|
|
67
|
+
# Opens like frontmatter but never matches: silently dropping every
|
|
68
|
+
# declared key (and leaking the raw block to the agent) is worse than
|
|
69
|
+
# refusing. CRLF endings and a missing final newline are the usual causes.
|
|
70
|
+
raise ConfigError, "#{dir.join(INSTRUCTION)}: frontmatter opens with --- but never closes" if
|
|
71
|
+
content.start_with?("---")
|
|
72
|
+
|
|
73
|
+
return {}
|
|
74
|
+
end
|
|
75
|
+
config = YAML.safe_load(match[1], aliases: true) || {}
|
|
76
|
+
raise ConfigError, "#{dir.join(INSTRUCTION)}: frontmatter must be a mapping" unless config.is_a?(Hash)
|
|
77
|
+
|
|
78
|
+
config
|
|
79
|
+
rescue Errno::ENOENT
|
|
80
|
+
{} # fallback to validate!
|
|
81
|
+
rescue Psych::Exception => e
|
|
82
|
+
raise ConfigError, "#{dir.join(INSTRUCTION)}: #{e.message}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def initialize(config, dir:, bench:)
|
|
86
|
+
@dir = Pathname(dir)
|
|
87
|
+
@bench = bench
|
|
88
|
+
@name = config["name"] || @dir.basename.to_s
|
|
89
|
+
@description = config["description"]
|
|
90
|
+
@difficulty = config["difficulty"]
|
|
91
|
+
@tags = Array(config["tags"]).freeze
|
|
92
|
+
@metadata = (config["metadata"] || {}).freeze
|
|
93
|
+
@files = SetupFiles.call(config["files"], root: @dir, label: @dir)
|
|
94
|
+
@restore = config.key?("restore") ? RestorePaths.call(config["restore"], label: "#{@dir}: restore") : nil
|
|
95
|
+
|
|
96
|
+
validate!(config)
|
|
97
|
+
# Recorded on every result: without it a reward cannot say which task version it measured.
|
|
98
|
+
@digest = TreeDigest.call(@dir)[0, 16]
|
|
99
|
+
freeze
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The story alone: frontmatter is for the harness, never for the agent.
|
|
103
|
+
def instruction = instruction_path.read.sub(FRONTMATTER, "")
|
|
104
|
+
|
|
105
|
+
def instruction_path = dir.join(INSTRUCTION)
|
|
106
|
+
|
|
107
|
+
def environment_context = dir.join(ENVIRONMENT_DIR)
|
|
108
|
+
|
|
109
|
+
def environment_dockerfile = environment_context.join("Dockerfile")
|
|
110
|
+
|
|
111
|
+
# Stays on the harness side while the agent works; uploaded into the sandbox only at verification.
|
|
112
|
+
def tests_dir = dir.join(TESTS_DIR)
|
|
113
|
+
|
|
114
|
+
# [absolute, remote-relative] pairs.
|
|
115
|
+
def test_files
|
|
116
|
+
if tests_dir.directory?
|
|
117
|
+
expand(tests_dir)
|
|
118
|
+
else
|
|
119
|
+
flat(FLAT_TEST)
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def solution_files
|
|
124
|
+
if solution_context.directory?
|
|
125
|
+
expand(solution_context)
|
|
126
|
+
else
|
|
127
|
+
flat(FLAT_SOLUTION)
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def environment_image
|
|
132
|
+
if bench.environment.image
|
|
133
|
+
ImageSpec.registry(bench.environment.image)
|
|
134
|
+
else
|
|
135
|
+
ImageSpec.dockerfile(environment_dockerfile, slug: name)
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def setup_files(phase)
|
|
140
|
+
declared = @files.fetch(phase.to_sym, [])
|
|
141
|
+
seed = Pathname(FLAT_SEED)
|
|
142
|
+
return declared unless phase.to_sym == :environment && dir.join(seed).file? && !declared.include?(seed)
|
|
143
|
+
|
|
144
|
+
declared + [seed]
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def solution_context = dir.join(SOLUTION_DIR)
|
|
148
|
+
|
|
149
|
+
def solution? = solution_files.any?
|
|
150
|
+
|
|
151
|
+
def restore_paths = @restore || bench.verifier.restore_paths
|
|
152
|
+
|
|
153
|
+
def to_h
|
|
154
|
+
{
|
|
155
|
+
name: name,
|
|
156
|
+
description: description,
|
|
157
|
+
difficulty: difficulty,
|
|
158
|
+
tags: tags,
|
|
159
|
+
metadata: metadata
|
|
160
|
+
}.compact
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
# Dotfiles included, matching TreeDigest: the files a digest records are
|
|
166
|
+
# exactly the files that ship.
|
|
167
|
+
def expand(root)
|
|
168
|
+
root.glob("**/*", File::FNM_DOTMATCH).select(&:file?).map { [_1, _1.relative_path_from(root).to_s] }
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def flat(filename)
|
|
172
|
+
path = dir.join(filename)
|
|
173
|
+
path.file? ? [[path, filename]] : []
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def validate!(config)
|
|
177
|
+
raise ConfigError, "#{dir}: #{INSTRUCTION} is required" unless instruction_path.file?
|
|
178
|
+
|
|
179
|
+
refuse_bench_collisions!
|
|
180
|
+
|
|
181
|
+
raise ConfigError, "#{dir}: #{ENVIRONMENT_DIR}/Dockerfile is required when bench.yml names no shared image" unless bench.environment.image || environment_dockerfile.file?
|
|
182
|
+
|
|
183
|
+
if test_files.empty?
|
|
184
|
+
raise ConfigError, "#{dir}: #{TESTS_DIR}/ or a flat #{FLAT_TEST} is required — " \
|
|
185
|
+
"the verifier uploads it at verification time"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
return unless config.key?("overrides")
|
|
189
|
+
|
|
190
|
+
declared = config["overrides"]
|
|
191
|
+
named = declared.is_a?(Hash) && declared.any? ? " (#{declared.keys.join(", ")})" : ""
|
|
192
|
+
raise ConfigError, "#{dir}: a task cannot override the frozen profile#{named} — " \
|
|
193
|
+
"what has to vary belongs in bench.yml, where it varies for every trial"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Collisions would be resolved by upload order, so a task never gets to shadow the bench-wide copy.
|
|
197
|
+
def refuse_bench_collisions!
|
|
198
|
+
SetupFiles::PHASES.each do |phase|
|
|
199
|
+
shadowed = @files.fetch(phase) & bench.setup_files(phase)
|
|
200
|
+
next if shadowed.empty?
|
|
201
|
+
|
|
202
|
+
raise ConfigError, "#{dir}: files.#{phase} names #{shadowed.first}, which #{bench.path.basename} " \
|
|
203
|
+
"already ships to every task"
|
|
204
|
+
end
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Lemans
|
|
6
|
+
# A directory's contents reduced to one number. Paths hash alongside
|
|
7
|
+
# contents, and dotfiles are included — glob skips them by default.
|
|
8
|
+
module TreeDigest
|
|
9
|
+
def self.call(dir)
|
|
10
|
+
dir = Pathname(dir)
|
|
11
|
+
sha = Digest::SHA256.new
|
|
12
|
+
dir.glob("**/*", File::FNM_DOTMATCH).sort.each do |entry|
|
|
13
|
+
next unless entry.file?
|
|
14
|
+
|
|
15
|
+
path = entry.relative_path_from(dir).to_s
|
|
16
|
+
contents = entry.binread
|
|
17
|
+
sha << [path.bytesize, contents.bytesize].pack("Q>Q>")
|
|
18
|
+
sha << path
|
|
19
|
+
sha << contents
|
|
20
|
+
end
|
|
21
|
+
sha.hexdigest
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/lemans/trial.rb
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "securerandom"
|
|
5
|
+
require "time"
|
|
6
|
+
|
|
7
|
+
module Lemans
|
|
8
|
+
# One task, one agent, one reward. Only what happens inside the agent phase
|
|
9
|
+
# is a statement about the model; everything else is the harness's fault.
|
|
10
|
+
class Trial
|
|
11
|
+
attr_reader :task, :bench, :agent_name, :model, :backend, :dir, :id
|
|
12
|
+
|
|
13
|
+
def initialize(task:, bench:, agent_name:, runs_dir:, model: nil, backend: "daytona")
|
|
14
|
+
@task = task
|
|
15
|
+
@bench = bench
|
|
16
|
+
@agent_name = agent_name
|
|
17
|
+
@model = model
|
|
18
|
+
@backend = backend
|
|
19
|
+
@id = "#{task.name}__#{SecureRandom.alphanumeric(7)}"
|
|
20
|
+
@dir = Pathname(runs_dir).join(model_dir, @id)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run
|
|
24
|
+
logs_dir.mkpath
|
|
25
|
+
started_at = Time.now.utc
|
|
26
|
+
reward = nil
|
|
27
|
+
usage = nil
|
|
28
|
+
outcome = Results::Outcome.new(:completed)
|
|
29
|
+
@phases = {}
|
|
30
|
+
# Declared outside the phase blocks they are assigned in, or `ensure`
|
|
31
|
+
# could not stop a sandbox whose setup raised.
|
|
32
|
+
environment = nil
|
|
33
|
+
snapshot = nil
|
|
34
|
+
patch = nil
|
|
35
|
+
|
|
36
|
+
begin
|
|
37
|
+
agent = Agents.build(agent_name, profile: bench.agent, model: model)
|
|
38
|
+
|
|
39
|
+
phase(:environment_setup) do
|
|
40
|
+
environment = start_environment
|
|
41
|
+
prepare(environment)
|
|
42
|
+
|
|
43
|
+
snapshot = Snapshot.new(environment, bench: bench, task: task,
|
|
44
|
+
timeout: bench.environment.build_timeout_sec)
|
|
45
|
+
snapshot.capture!
|
|
46
|
+
|
|
47
|
+
patch = Patch.new(environment, bench: bench, dir: dir)
|
|
48
|
+
patch.seal!
|
|
49
|
+
|
|
50
|
+
agent.install(environment, task: task)
|
|
51
|
+
environment.network_policy = bench.agent.network
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
agent_result = phase(:agent) do
|
|
55
|
+
in_agent_phase { agent.call(environment, task: task, logs_dir: logs_dir) }
|
|
56
|
+
end
|
|
57
|
+
usage = agent_result.usage
|
|
58
|
+
outcome = over_ceiling(agent_result) || agent_result.outcome
|
|
59
|
+
|
|
60
|
+
patch.collect!
|
|
61
|
+
|
|
62
|
+
if outcome.scored?
|
|
63
|
+
phase(:verifier) do
|
|
64
|
+
# The sandbox is sealed before the tests arrive
|
|
65
|
+
environment.network_policy = NetworkPolicy.none
|
|
66
|
+
reward = Verifier.new(bench: bench, task: task, dir: dir, snapshot: snapshot).call(environment)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
rescue VerifierError => e
|
|
70
|
+
outcome = Results::Outcome.new(:verifier_error, detail: e.message)
|
|
71
|
+
rescue ::Miniswen::AccountingError => e
|
|
72
|
+
outcome = Results::Outcome.new(:accounting_error, detail: e.message)
|
|
73
|
+
rescue InfrastructureError, ::Miniswen::InfrastructureError => e
|
|
74
|
+
outcome = Results::Outcome.new(@agent_phase ? :agent_error : :environment_error, detail: e.message)
|
|
75
|
+
rescue ConfigError
|
|
76
|
+
# A malformed bench is the author's bug to fix - raise!
|
|
77
|
+
raise
|
|
78
|
+
rescue StandardError => e
|
|
79
|
+
# A harness bug must leave evidence.
|
|
80
|
+
outcome = Results::Outcome.new(:harness_crash, detail: crash_detail(e))
|
|
81
|
+
ensure
|
|
82
|
+
environment&.stop
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
write_result(started_at: started_at, reward: reward, outcome: outcome, usage: usage)
|
|
86
|
+
rescue SystemCallError, JSON::GeneratorError => e
|
|
87
|
+
# runs_dir unwritable, disk full
|
|
88
|
+
raise ConfigError, "cannot record trial #{id}: #{e.message}"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def model_dir
|
|
94
|
+
(model || bench.agent.model || agent_name).to_s.split("/").last
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def logs_dir = dir
|
|
98
|
+
|
|
99
|
+
def over_ceiling(result)
|
|
100
|
+
limit = bench.agent.cost_limit
|
|
101
|
+
cost = result.usage&.cost_usd
|
|
102
|
+
return nil unless limit && cost && result.outcome.scored? && cost > limit
|
|
103
|
+
|
|
104
|
+
Results::Outcome.new(:cost_ceiling_reached,
|
|
105
|
+
detail: format("spent $%<cost>.4f against a $%<limit>.4f limit", cost: cost, limit: limit))
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def crash_detail(error)
|
|
109
|
+
["#{error.class}: #{error.message}", *Array(error.backtrace).first(5)].join("\n")
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def phase(name)
|
|
113
|
+
@phases[name] = { started_at: Time.now.utc.iso8601(6) }
|
|
114
|
+
yield
|
|
115
|
+
ensure
|
|
116
|
+
@phases[name][:finished_at] = Time.now.utc.iso8601(6)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def in_agent_phase
|
|
120
|
+
@agent_phase = true
|
|
121
|
+
result = yield
|
|
122
|
+
@agent_phase = false
|
|
123
|
+
result
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def start_environment
|
|
127
|
+
Environments.build(
|
|
128
|
+
backend,
|
|
129
|
+
image: task.environment_image,
|
|
130
|
+
resources: bench.environment.resources,
|
|
131
|
+
network: bench.environment.network,
|
|
132
|
+
build_timeout_sec: bench.environment.build_timeout_sec,
|
|
133
|
+
labels: { "lemans.task" => task.name, "lemans.trial" => id, "lemans.phase" => "agent" }
|
|
134
|
+
).start
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def prepare(environment)
|
|
138
|
+
Setup.new(
|
|
139
|
+
commands: bench.environment.setup,
|
|
140
|
+
task: task,
|
|
141
|
+
phase: :environment,
|
|
142
|
+
timeout_sec: bench.environment.build_timeout_sec
|
|
143
|
+
).call(environment)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def write_result(started_at:, reward:, outcome:, usage:)
|
|
147
|
+
finished_at = Time.now.utc
|
|
148
|
+
result = {
|
|
149
|
+
trial: id,
|
|
150
|
+
task: task.name,
|
|
151
|
+
agent: agent_name,
|
|
152
|
+
model: model || bench.agent.model,
|
|
153
|
+
reward: outcome.scored? ? reward : nil,
|
|
154
|
+
outcome: outcome.to_h,
|
|
155
|
+
usage: usage&.to_h,
|
|
156
|
+
lemans_version: VERSION,
|
|
157
|
+
# The digest already hashes the bench's shipped files along with its
|
|
158
|
+
# config, so the per-file listing added bulk, not pinning.
|
|
159
|
+
profile_digest: bench.digest,
|
|
160
|
+
task_digest: task.digest,
|
|
161
|
+
bench: bench.revision.to_h,
|
|
162
|
+
started_at: started_at.iso8601,
|
|
163
|
+
finished_at: finished_at.iso8601,
|
|
164
|
+
duration_sec: (finished_at - started_at).round(1),
|
|
165
|
+
# Where the wall clock went: without this, a slow sandbox morning
|
|
166
|
+
# reads as a slow model.
|
|
167
|
+
phases: @phases,
|
|
168
|
+
tags: task.tags,
|
|
169
|
+
metadata: task.metadata
|
|
170
|
+
}
|
|
171
|
+
atomic_write(result_path, "#{JSON.pretty_generate(result)}\n")
|
|
172
|
+
result
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def result_path = dir.join("result.json")
|
|
176
|
+
|
|
177
|
+
# --resume treats any result.json as a finished attempt, so the write must
|
|
178
|
+
# be atomic: a rename is either all there or not there at all.
|
|
179
|
+
def atomic_write(path, content)
|
|
180
|
+
tmp = path.dirname.join(".#{path.basename}.#{Process.pid}.#{SecureRandom.hex(4)}")
|
|
181
|
+
tmp.write(content)
|
|
182
|
+
tmp.rename(path)
|
|
183
|
+
ensure
|
|
184
|
+
tmp&.delete if tmp&.exist?
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
end
|
data/lib/lemans/units.rb
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Lemans
|
|
4
|
+
# Durations and sizes as a human writes them ("30m", "2GB"), stored as
|
|
5
|
+
# seconds and megabytes. A bare number gets the obvious reading.
|
|
6
|
+
module Units
|
|
7
|
+
DURATION = /\A(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?\z/
|
|
8
|
+
DURATION_FACTORS = { "ms" => 0.001, "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
|
|
9
|
+
|
|
10
|
+
SIZE = /\A(\d+(?:\.\d+)?)\s*(MB|GB|TB)?\z/i
|
|
11
|
+
SIZE_FACTORS = { "mb" => 1, "gb" => 1024, "tb" => 1024 * 1024 }.freeze
|
|
12
|
+
|
|
13
|
+
class << self
|
|
14
|
+
def seconds(value, field:)
|
|
15
|
+
return nil if value.nil?
|
|
16
|
+
return finite_nonnegative(value, field, "a duration") if value.is_a?(Numeric)
|
|
17
|
+
|
|
18
|
+
match = DURATION.match(value.to_s.strip)
|
|
19
|
+
raise ConfigError, "#{field}: cannot read #{value.inspect} as a duration (try 30m, 300s, 1h)" unless match
|
|
20
|
+
|
|
21
|
+
match[1].to_f * DURATION_FACTORS.fetch(match[2] || "s")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def megabytes(value, field:)
|
|
25
|
+
return nil if value.nil?
|
|
26
|
+
return finite_nonnegative(value, field, "a size").round if value.is_a?(Numeric)
|
|
27
|
+
|
|
28
|
+
match = SIZE.match(value.to_s.strip)
|
|
29
|
+
raise ConfigError, "#{field}: cannot read #{value.inspect} as a size (try 2GB, 512MB)" unless match
|
|
30
|
+
|
|
31
|
+
(match[1].to_f * SIZE_FACTORS.fetch((match[2] || "mb").downcase)).round
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def finite_nonnegative(value, field, noun)
|
|
37
|
+
number = value.to_f
|
|
38
|
+
raise ConfigError, "#{field}: cannot read #{value.inspect} as #{noun}" if number.negative? || !number.finite?
|
|
39
|
+
|
|
40
|
+
number
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded when a verifier command opts in with `ruby -report-lemans …`
|
|
4
|
+
# (that is `-r eport-lemans`, resolved from /tests on the LOAD_PATH).
|
|
5
|
+
module LemansReport
|
|
6
|
+
def self.registered? = @registered
|
|
7
|
+
|
|
8
|
+
def self.register
|
|
9
|
+
return if @registered
|
|
10
|
+
return unless defined?(::Minitest) && ::Minitest.respond_to?(:extensions)
|
|
11
|
+
|
|
12
|
+
@registered = true
|
|
13
|
+
require_relative "lemans_minitest_reporter"
|
|
14
|
+
::Minitest.singleton_class.define_method(:plugin_lemans_report_init) do |_options|
|
|
15
|
+
dir = ENV["LOGS"]
|
|
16
|
+
reporter << Reporter.new(dir) if dir && File.directory?(dir)
|
|
17
|
+
end
|
|
18
|
+
(::Minitest.extensions ||= []) << "lemans_report"
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
if defined?(Minitest)
|
|
23
|
+
LemansReport.register
|
|
24
|
+
else
|
|
25
|
+
# `-r` runs before bundler picks the app's minitest, so requiring minitest
|
|
26
|
+
# here would activate the wrong version. Instead watch class definitions and
|
|
27
|
+
# register the moment minitest's own module body closes; the probe disarms
|
|
28
|
+
# itself and nothing foreign is patched.
|
|
29
|
+
trace = TracePoint.new(:end) do |event|
|
|
30
|
+
next unless event.self.is_a?(Module) && event.self.name == "Minitest"
|
|
31
|
+
|
|
32
|
+
LemansReport.register
|
|
33
|
+
trace.disable if LemansReport.registered?
|
|
34
|
+
end
|
|
35
|
+
trace.enable
|
|
36
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module LemansReport
|
|
6
|
+
# Appends every Minitest result to $LOGS/checks.json. Required by
|
|
7
|
+
# eport-lemans once Minitest is loaded; never load this file directly.
|
|
8
|
+
class Reporter < Minitest::AbstractReporter
|
|
9
|
+
def initialize(dir)
|
|
10
|
+
super()
|
|
11
|
+
@dir = dir
|
|
12
|
+
@results = []
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def record(result)
|
|
16
|
+
@results << result
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def report
|
|
20
|
+
graded = @results.select { graded?(_1) }
|
|
21
|
+
prior = existing.fetch("checks", {})
|
|
22
|
+
return if graded.empty? && prior.empty?
|
|
23
|
+
|
|
24
|
+
checks = prior.merge(graded.to_h { [name(_1), status(_1)] }).sort.to_h
|
|
25
|
+
File.write(
|
|
26
|
+
File.join(@dir, "checks.json"),
|
|
27
|
+
JSON.pretty_generate(checks: checks, failures: checks.reject { |_, status| status == "pass" }.keys)
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# A skip inside the harness-shipped tests is an unverified requirement and
|
|
32
|
+
# fails the run. The app's own suite keeps vanilla skip semantics.
|
|
33
|
+
def passed?
|
|
34
|
+
@results.none? { |result| graded?(result) && result.skipped? }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def graded?(result)
|
|
40
|
+
dir = ENV["TESTS"]
|
|
41
|
+
# The trailing slash matters: /testsuite must not count as /tests.
|
|
42
|
+
dir && result.source_location.first.to_s.start_with?("#{dir.chomp("/")}/")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def existing
|
|
46
|
+
JSON.parse(File.read(File.join(@dir, "checks.json")))
|
|
47
|
+
rescue StandardError
|
|
48
|
+
{}
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def name(result) = "#{result.klass}##{result.name}"
|
|
52
|
+
|
|
53
|
+
def status(result)
|
|
54
|
+
if result.skipped? then "skip"
|
|
55
|
+
elsif result.error? then "error"
|
|
56
|
+
elsif result.passed? then "pass"
|
|
57
|
+
else "fail"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|