lemans 0.2.3 → 1.0.0.pre.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.
Files changed (69) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +30 -10
  3. data/exe/lemans-remote +94 -29
  4. data/lib/lemans/agent.rb +39 -0
  5. data/lib/lemans/agents/miniswen.rb +28 -25
  6. data/lib/lemans/agents/miniswen_installed.rb +13 -9
  7. data/lib/lemans/agents/nop.rb +3 -3
  8. data/lib/lemans/agents/oracle.rb +8 -8
  9. data/lib/lemans/cli/board_reporter.rb +16 -16
  10. data/lib/lemans/cli/progress_reporter.rb +19 -19
  11. data/lib/lemans/cli/report/aggregate.rb +117 -0
  12. data/lib/lemans/cli/report.rb +164 -0
  13. data/lib/lemans/cli.rb +58 -63
  14. data/lib/lemans/clobber.rb +17 -55
  15. data/lib/lemans/config/agent.rb +51 -0
  16. data/lib/lemans/config/conversion.rb +62 -0
  17. data/lib/lemans/config/environment.rb +39 -0
  18. data/lib/lemans/config/image_spec.rb +42 -0
  19. data/lib/lemans/config/network_policy.rb +55 -0
  20. data/lib/lemans/config/revision.rb +42 -0
  21. data/lib/lemans/config/setup.rb +57 -0
  22. data/lib/lemans/config/tree_digest.rb +26 -0
  23. data/lib/lemans/config/verifier.rb +72 -0
  24. data/lib/lemans/config.rb +107 -0
  25. data/lib/lemans/environment.rb +54 -0
  26. data/lib/lemans/environments/daytona/faraday_transfer.rb +172 -0
  27. data/lib/lemans/environments/daytona/sdk_tweaks.rb +1 -1
  28. data/lib/lemans/environments/daytona/shell.rb +1 -1
  29. data/lib/lemans/environments/daytona/snapshot_store.rb +11 -11
  30. data/lib/lemans/environments/daytona.rb +21 -35
  31. data/lib/lemans/result.rb +270 -0
  32. data/lib/lemans/runner/executor.rb +64 -0
  33. data/lib/lemans/runner/task.rb +62 -0
  34. data/lib/lemans/runner.rb +82 -0
  35. data/lib/lemans/store.rb +44 -0
  36. data/lib/lemans/stores/fs.rb +122 -0
  37. data/lib/lemans/task_definition.rb +194 -0
  38. data/lib/lemans/trial/patch.rb +76 -0
  39. data/lib/lemans/trial/setup.rb +66 -0
  40. data/lib/lemans/trial/snapshot.rb +57 -0
  41. data/lib/lemans/trial/verifier.rb +190 -0
  42. data/lib/lemans/trial.rb +113 -147
  43. data/lib/lemans/version.rb +1 -1
  44. data/lib/lemans.rb +3 -2
  45. data/lib/miniswen/trajectory.rb +2 -0
  46. metadata +58 -25
  47. data/lib/lemans/agents/base.rb +0 -30
  48. data/lib/lemans/bench.rb +0 -280
  49. data/lib/lemans/environments/base.rb +0 -55
  50. data/lib/lemans/network_policy.rb +0 -66
  51. data/lib/lemans/patch.rb +0 -70
  52. data/lib/lemans/restore_paths.rb +0 -21
  53. data/lib/lemans/results/aggregate.rb +0 -114
  54. data/lib/lemans/results/cost_source.rb +0 -13
  55. data/lib/lemans/results/outcome.rb +0 -36
  56. data/lib/lemans/results/report.rb +0 -149
  57. data/lib/lemans/results/sorting.rb +0 -24
  58. data/lib/lemans/results/tally.rb +0 -19
  59. data/lib/lemans/results/usage.rb +0 -24
  60. data/lib/lemans/run.rb +0 -152
  61. data/lib/lemans/setup.rb +0 -59
  62. data/lib/lemans/setup_files.rb +0 -36
  63. data/lib/lemans/snapshot.rb +0 -55
  64. data/lib/lemans/task.rb +0 -207
  65. data/lib/lemans/tree_digest.rb +0 -24
  66. data/lib/lemans/units.rb +0 -44
  67. data/lib/lemans/verifier.rb +0 -199
  68. /data/lib/lemans/{verifier → trial/verifier}/assets/eport-lemans.rb +0 -0
  69. /data/lib/lemans/{verifier → trial/verifier}/assets/lemans_minitest_reporter.rb +0 -0
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Lemans
4
+ # What every backend must do, and nothing more: a narrow contract is what
5
+ # makes a second backend a day of work instead of a subsystem. The
6
+ # name-to-class registry lives on the Environments module.
7
+ class Environment
8
+ # Backends interleave a command's streams before we ever see them, so one
9
+ # output field is the honest shape.
10
+ ExecResult = Data.define(:command, :exit_code, :output, :duration) do
11
+ def success? = exit_code.zero?
12
+ end
13
+
14
+ DEFAULT_TIMEOUT = 60
15
+
16
+ attr_reader :image, :resources, :network, :env, :labels, :build_timeout
17
+
18
+ # `labels` is backend-agnostic trial metadata (task, trial id, phase);
19
+ # every backend receives it even if it has nowhere to put it.
20
+ def initialize(image:, resources:, network:, env: {}, labels: {}, build_timeout: nil)
21
+ @image = image
22
+ @resources = resources
23
+ @network = network
24
+ @env = env
25
+ @labels = labels
26
+ @build_timeout = build_timeout
27
+ end
28
+
29
+ # Build the image and bring the sandbox up under the network policy it was
30
+ # constructed with; a backend that cannot honour the policy must raise.
31
+ def start = raise(NotImplementedError)
32
+
33
+ def exec(command, timeout: nil, env: {}) = raise(NotImplementedError)
34
+
35
+ def upload(local_path, remote_path) = raise(NotImplementedError)
36
+
37
+ def download(remote_path, local_path) = raise(NotImplementedError)
38
+
39
+ # Phases change what the sandbox may reach: setup pulls packages, the agent
40
+ # reaches the model API and nothing else, the verifier reaches nothing.
41
+ def switch_network_policy!(policy)
42
+ raise NotImplementedError
43
+ end
44
+
45
+ def stop = raise(NotImplementedError)
46
+
47
+ def exec!(command, **)
48
+ result = exec(command, **)
49
+ return result if result.success?
50
+
51
+ raise InfrastructureError, "#{command} exited #{result.exit_code}: #{result.output.to_s[0, 2000]}"
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "daytona"
4
+ require "faraday"
5
+ require "faraday/multipart"
6
+ require "json"
7
+
8
+ module Lemans
9
+ module Environments
10
+ class Daytona
11
+ # Reroutes the SDK's streaming file transfers from typhoeus/libcurl to
12
+ # Faraday over Net::HTTP: pure-Ruby I/O is immune to the libcurl GC race
13
+ # that segfaults the VM under concurrent transfers.
14
+ # Only the FileTransfer entry points move — the generated JSON clients
15
+ # stay on typhoeus. Opt-in via .apply!
16
+ module FaradayTransfer
17
+ def self.apply!
18
+ ::Daytona::FileTransfer.singleton_class.prepend(Transfers)
19
+ end
20
+
21
+ # The prepend seam over ::Daytona::FileTransfer's entry points.
22
+ module Transfers
23
+ def stream_download(api_client:, remote_path:, timeout:, on_progress: nil, cancel_event: nil, &)
24
+ FaradayTransfer.download(api_client:, remote_path:, timeout:, on_progress:, cancel_event:, &)
25
+ end
26
+
27
+ def stream_upload(api_client:, remote_path:, source:, timeout:, on_progress: nil, cancel_event: nil)
28
+ FaradayTransfer.upload(api_client:, remote_path:, source:, timeout:, on_progress:, cancel_event:)
29
+ end
30
+ end
31
+
32
+ # Counts bytes as multipart-post drains the file and surfaces
33
+ # cancellation as an exception, which tears the connection down.
34
+ class UploadIO
35
+ def initialize(io, remote_path, on_progress:, cancel_event:)
36
+ @io = io
37
+ @remote_path = remote_path
38
+ @on_progress = on_progress
39
+ @cancel_event = cancel_event
40
+ @bytes_sent = 0
41
+ end
42
+
43
+ def read(...)
44
+ raise ::Daytona::Sdk::Error, "Upload cancelled: #{@remote_path}" if @cancel_event&.set?
45
+
46
+ chunk = @io.read(...)
47
+ if chunk && @on_progress
48
+ @bytes_sent += chunk.bytesize
49
+ @on_progress.call(::Daytona::UploadProgress.new(bytes_sent: @bytes_sent))
50
+ end
51
+ chunk
52
+ end
53
+
54
+ def size = @io.size
55
+ def length = @io.size
56
+ def rewind = @io.rewind
57
+ def eof? = @io.eof?
58
+ def close = @io.close
59
+ end
60
+
61
+ class << self
62
+ def download(api_client:, remote_path:, timeout:, on_progress:, cancel_event:, &block)
63
+ parser = nil
64
+ bytes_received = 0
65
+ sink = proc do |chunk|
66
+ raise ::Daytona::Sdk::Error, "Download cancelled: #{remote_path}" if cancel_event&.set?
67
+
68
+ if on_progress
69
+ bytes_received += chunk.bytesize
70
+ on_progress.call(::Daytona::DownloadProgress.new(bytes_received:, total_bytes: parser&.part_total_bytes))
71
+ end
72
+ block.call(chunk)
73
+ end
74
+ parser = ::Daytona::MultipartDownloadStreamParser.new(&sink)
75
+
76
+ error_body = String.new.b
77
+ response = perform_download(api_client, remote_path, timeout, parser, error_body, cancel_event)
78
+
79
+ raise ::Daytona::Sdk::Error, "Download cancelled: #{remote_path}" if cancel_event&.set?
80
+ raise ::Daytona::Sdk::Error, parser.error_message if parser.error_message
81
+ raise ::Daytona::Sdk::Error, "HTTP #{response.status}: #{error_body}" unless response.success?
82
+
83
+ parser.finish!
84
+ ::Daytona::FileTransfer.assert_download_length!(parser, remote_path)
85
+ nil
86
+ rescue Faraday::TimeoutError
87
+ raise ::Daytona::Sdk::Error, "Download timed out: #{remote_path}"
88
+ rescue Faraday::Error => e
89
+ raise ::Daytona::Sdk::Error, "Download failed for #{remote_path}: #{e.message}"
90
+ end
91
+
92
+ def upload(api_client:, remote_path:, source:, timeout:, on_progress:, cancel_event:)
93
+ ::Daytona::FileTransfer.with_upload_file(source, cancel_event, remote_path) do |upload_path|
94
+ expected_bytes = File.size(upload_path)
95
+ response = File.open(upload_path, "rb") do |file|
96
+ io = UploadIO.new(file, remote_path, on_progress:, cancel_event:)
97
+ perform_upload(api_client, remote_path, timeout, io)
98
+ end
99
+ raise ::Daytona::Sdk::Error, "HTTP #{response.status}: #{response.body}" unless response.success?
100
+
101
+ verify_upload(response.body, remote_path, expected_bytes)
102
+ end
103
+ rescue Faraday::TimeoutError
104
+ raise ::Daytona::Sdk::Error, "Upload timed out: #{remote_path}"
105
+ rescue Faraday::Error => e
106
+ raise ::Daytona::Sdk::Error, "Upload failed for #{remote_path}: #{e.message}"
107
+ end
108
+
109
+ private
110
+
111
+ def perform_download(api_client, remote_path, timeout, parser, error_body, cancel_event)
112
+ boundary_known = false
113
+ connection(api_client.config).post("#{api_client.config.base_url}/files/bulk-download") do |req|
114
+ req.headers.update(api_client.default_headers)
115
+ req.headers["Accept"] = "multipart/form-data"
116
+ req.headers["Content-Type"] = "application/json"
117
+ req.body = JSON.generate(paths: [remote_path])
118
+ apply_timeout(req, timeout)
119
+ req.options.on_data = proc do |chunk, _received, env|
120
+ raise ::Daytona::Sdk::Error, "Download cancelled: #{remote_path}" if cancel_event&.set?
121
+
122
+ if (200..299).cover?(env.status)
123
+ unless boundary_known
124
+ ::Daytona::FileTransfer.assign_download_boundary(parser, env.response_headers["content-type"])
125
+ boundary_known = true
126
+ end
127
+ parser << chunk
128
+ else
129
+ error_body << chunk.b
130
+ end
131
+ end
132
+ end
133
+ end
134
+
135
+ def perform_upload(api_client, remote_path, timeout, io)
136
+ connection(api_client.config, multipart: true).post("#{api_client.config.base_url}/files/bulk-upload") do |req|
137
+ req.headers.update(api_client.default_headers)
138
+ # The multipart middleware supplies its own boundary-bearing type.
139
+ req.headers.delete("Content-Type")
140
+ apply_timeout(req, timeout)
141
+ req.body = {
142
+ "files[0].path" => remote_path,
143
+ "files[0].file" => Faraday::Multipart::FilePart.new(io, "application/octet-stream", File.basename(remote_path))
144
+ }
145
+ end
146
+ end
147
+
148
+ def verify_upload(body, remote_path, expected_bytes)
149
+ recorded = ::Daytona::FileTransfer.recorded_upload_bytes(body, remote_path)
150
+ return if recorded.nil? || recorded == expected_bytes
151
+
152
+ raise ::Daytona::Sdk::Error,
153
+ "Upload size mismatch for #{remote_path}: sent #{expected_bytes} bytes, daemon recorded #{recorded}"
154
+ end
155
+
156
+ def connection(config, multipart: false)
157
+ Faraday.new(ssl: { verify: config.verify_ssl, verify_hostname: config.verify_ssl_host }) do |f|
158
+ f.request :multipart if multipart
159
+ f.adapter :net_http
160
+ end
161
+ end
162
+
163
+ # Typhoeus treats 0 as "no deadline"; Faraday has no such switch, so
164
+ # 0 falls back to Net::HTTP's per-read defaults instead.
165
+ def apply_timeout(req, timeout)
166
+ req.options.timeout = timeout if timeout&.positive?
167
+ end
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
@@ -8,7 +8,7 @@ module Lemans
8
8
  class Daytona
9
9
  # Repairs the Daytona SDK needs to be usable from a harness. Upstream ask: per-request
10
10
  # timeouts like the Python SDK's — tracked in tmp/upstream-daytona-sdk-timeouts.md.
11
- module SdkTweaks
11
+ module SDKTweaks
12
12
  GENERATED_CLIENTS = [
13
13
  ::DaytonaApiClient, ::DaytonaToolboxApiClient, ::DaytonaAnalyticsApiClient
14
14
  ].freeze
@@ -33,7 +33,7 @@ module Lemans
33
33
  exec_directly(command, timeout: timeout, env: env)
34
34
  end
35
35
 
36
- Base::ExecResult.new(command: command, duration_sec: (now - started).round(3), **response)
36
+ Environment::ExecResult.new(command: command, duration: (now - started).round(3), **response)
37
37
  end
38
38
 
39
39
  private
@@ -28,11 +28,11 @@ module Lemans
28
28
  LOCKS.compute_if_absent(name) { Mutex.new }
29
29
  end
30
30
 
31
- def initialize(client:, image:, resources:, build_timeout_sec:, logger: nil)
31
+ def initialize(client:, image:, resources:, build_timeout:, logger: nil)
32
32
  @client = client
33
33
  @image = image
34
34
  @resources = resources
35
- @build_timeout_sec = build_timeout_sec
35
+ @build_timeout = build_timeout
36
36
  @logger = logger
37
37
  end
38
38
 
@@ -49,21 +49,21 @@ module Lemans
49
49
  # shape, because a sandbox inherits resources from its snapshot.
50
50
  def name
51
51
  @name ||= begin
52
- shape = [image.digest, resources.cpus, resources.memory_mb, resources.storage_mb].join(":")
52
+ shape = [image.digest, resources.cpus, resources.memory, resources.storage].join(":")
53
53
  "lemans-#{Digest::SHA256.hexdigest(shape)[0, 32]}"
54
54
  end
55
55
  end
56
56
 
57
57
  private
58
58
 
59
- attr_reader :client, :image, :resources, :build_timeout_sec, :logger
59
+ attr_reader :client, :image, :resources, :build_timeout, :logger
60
60
 
61
61
  # A failed build keeps the name, so it would wedge every future trial
62
62
  # sharing that image. The state is terminal; rebuilding is all that is left.
63
63
  def discard(snapshot)
64
64
  client.snapshot.delete(snapshot)
65
65
 
66
- deadline = now + build_timeout_sec
66
+ deadline = now + build_timeout
67
67
  while find(name)
68
68
  raise InfrastructureError, "daytona: failed snapshot #{name} would not go away" if now > deadline
69
69
 
@@ -96,8 +96,8 @@ module Lemans
96
96
  image: image.built? ? ::Daytona::Image.from_dockerfile(image.dockerfile_path.to_s) : base_image,
97
97
  resources: daytona_resources
98
98
  )
99
- Timeout.timeout(build_timeout_sec, InfrastructureError,
100
- "daytona: snapshot #{name} did not build within #{build_timeout_sec}s") do
99
+ Timeout.timeout(build_timeout, InfrastructureError,
100
+ "daytona: snapshot #{name} did not build within #{build_timeout}s") do
101
101
  client.snapshot.create(params, on_logs: logger)
102
102
  end
103
103
  rescue *SDK_ERRORS => e
@@ -117,7 +117,7 @@ module Lemans
117
117
  # A snapshot that exists may still be building for whoever won the race,
118
118
  # or deactivated from disuse — a weekly run will hit that.
119
119
  def await_ready(snapshot)
120
- deadline = now + build_timeout_sec
120
+ deadline = now + build_timeout
121
121
  activated = false
122
122
 
123
123
  loop do
@@ -131,7 +131,7 @@ module Lemans
131
131
  activated = true
132
132
  end
133
133
 
134
- raise InfrastructureError, "daytona: snapshot #{name} was still #{state} after #{build_timeout_sec}s" if now > deadline
134
+ raise InfrastructureError, "daytona: snapshot #{name} was still #{state} after #{build_timeout}s" if now > deadline
135
135
 
136
136
  sleep POLL_INTERVAL_SEC
137
137
  snapshot = find(name)
@@ -149,8 +149,8 @@ module Lemans
149
149
  def daytona_resources
150
150
  ::Daytona::Resources.new(
151
151
  cpu: resources.cpus,
152
- memory: to_gib(resources.memory_mb),
153
- disk: to_gib(resources.storage_mb)
152
+ memory: to_gib(resources.memory),
153
+ disk: to_gib(resources.storage)
154
154
  )
155
155
  end
156
156
 
@@ -8,22 +8,20 @@ module Lemans
8
8
  module Environments
9
9
  # Daytona sandboxes. Daytona builds images server-side into reusable content-named
10
10
  # snapshots and enforces the network policy itself.
11
- class Daytona < Base
11
+ class Daytona < Environment
12
12
  TTL_MINUTES = 120
13
13
 
14
- DEFAULT_BUILD_TIMEOUT_SEC = 600
14
+ DEFAULT_BUILD_TIMEOUT = 600
15
15
 
16
16
  # Workspace tarballs ride uploads/downloads, so transfers get their own
17
17
  # budget through the SDK's streaming API instead of the global HTTP cap.
18
18
  TRANSFER_TIMEOUT = 900
19
19
 
20
- # File transfers ride the SDK's typhoeus/libcurl stack, which segfaults
21
- # the VM under enough concurrent easy_perform calls (a GC race on string
22
- # options libcurl is still copying). Transfers are seconds each, so
23
- # capping them costs little; execs and lifecycle stay fully parallel.
24
- TRANSFER_SLOTS = Concurrent::Semaphore.new(6)
25
-
26
- SdkTweaks.apply!
20
+ SDKTweaks.apply!
21
+ # The SDK's typhoeus/libcurl transfers segfault the VM under concurrent
22
+ # easy_perform calls; Faraday/Net::HTTP is pure Ruby, so transfers need
23
+ # no throttling at all.
24
+ FaradayTransfer.apply!
27
25
 
28
26
  attr_reader :sandbox
29
27
 
@@ -43,9 +41,9 @@ module Lemans
43
41
  config
44
42
  end
45
43
 
46
- def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout_sec: nil)
47
- super(image: image, resources: resources, network: network, env: env, labels: labels,
48
- build_timeout_sec: build_timeout_sec || DEFAULT_BUILD_TIMEOUT_SEC)
44
+ def initialize(image:, resources:, network:, env: {}, labels: {}, logger: nil, build_timeout: nil)
45
+ super(image:, resources:, network:, env:, labels:,
46
+ build_timeout: build_timeout || DEFAULT_BUILD_TIMEOUT)
49
47
  @logger = logger
50
48
  end
51
49
 
@@ -67,12 +65,10 @@ module Lemans
67
65
  end
68
66
 
69
67
  def upload(local_path, remote_path)
70
- transfer do
71
- # An open handle, not a path string: the SDK uploads a non-existent
72
- # path AS ITS OWN BYTES, so a missing file must die here as ENOENT.
73
- Pathname(local_path).open("rb") do |file|
74
- sandbox.fs.upload_file_stream(file, remote_path.to_s, timeout: TRANSFER_TIMEOUT)
75
- end
68
+ # An open handle, not a path string: the SDK uploads a non-existent
69
+ # path AS ITS OWN BYTES, so a missing file must die here as ENOENT.
70
+ Pathname(local_path).open("rb") do |file|
71
+ sandbox.fs.upload_file_stream(file, remote_path.to_s, timeout: TRANSFER_TIMEOUT)
76
72
  end
77
73
  rescue *Retries::SDK_ERRORS => e
78
74
  raise InfrastructureError, "daytona: could not upload #{local_path}: #{e.message}"
@@ -81,16 +77,14 @@ module Lemans
81
77
  def download(remote_path, local_path)
82
78
  local_path = Pathname(local_path)
83
79
  local_path.dirname.mkpath
84
- transfer do
85
- local_path.open("wb") do |file|
86
- sandbox.fs.download_file_stream(remote_path.to_s, timeout: TRANSFER_TIMEOUT) { file.write(_1) }
87
- end
80
+ local_path.open("wb") do |file|
81
+ sandbox.fs.download_file_stream(remote_path.to_s, timeout: TRANSFER_TIMEOUT) { file.write(_1) }
88
82
  end
89
83
  rescue *Retries::SDK_ERRORS, SystemCallError => e
90
84
  raise InfrastructureError, "daytona: could not download #{remote_path}: #{e.message}"
91
85
  end
92
86
 
93
- def network_policy=(policy)
87
+ def switch_network_policy!(policy)
94
88
  sandbox.update_network_settings(**network_kwargs(policy, for_update: true))
95
89
  @network = policy
96
90
  rescue *Retries::SDK_ERRORS => e
@@ -120,13 +114,6 @@ module Lemans
120
114
 
121
115
  private
122
116
 
123
- def transfer
124
- TRANSFER_SLOTS.acquire
125
- yield
126
- ensure
127
- TRANSFER_SLOTS.release
128
- end
129
-
130
117
  def client = self.class.client
131
118
 
132
119
  # A sandbox inherits the snapshot's resources, so the profile's are
@@ -146,17 +133,16 @@ module Lemans
146
133
  end
147
134
 
148
135
  def snapshot_store
149
- SnapshotStore.new(client: client, image: image, resources: resources,
150
- build_timeout_sec: build_timeout_sec, logger: @logger)
136
+ SnapshotStore.new(client:, image:, resources:, build_timeout:, logger: @logger)
151
137
  end
152
138
 
153
139
  def network_kwargs(policy, for_update: false)
154
140
  case policy.mode
155
- when :none
141
+ when "none"
156
142
  { network_block_all: true }
157
- when :public
143
+ when "public"
158
144
  for_update ? { network_block_all: false } : {}
159
- when :allowlist
145
+ when "allowlist"
160
146
  raise ConfigError, "daytona: an allowlist cannot mix domains and IP targets (#{policy.hosts.join(", ")})" if policy.domains.any? && policy.ip_targets.any?
161
147
 
162
148
  {