lithop 0.1.0 → 0.3.0

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 (76) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/Cargo.lock +284 -0
  4. data/Cargo.toml +6 -0
  5. data/README.md +31 -7
  6. data/RELEASING.md +24 -6
  7. data/docs/README.md +4 -1
  8. data/docs/artifacts.md +26 -2
  9. data/docs/bambu-studio.md +7 -1
  10. data/docs/color-model.md +2 -0
  11. data/docs/command.md +48 -7
  12. data/docs/config.md +31 -4
  13. data/docs/getting-started.md +10 -5
  14. data/docs/images/cmyw-lithophane-preview.png +0 -0
  15. data/docs/local-ui.md +114 -0
  16. data/docs/tiling.md +134 -0
  17. data/docs/troubleshooting.md +22 -0
  18. data/ext/lithop_native/Cargo.toml +17 -0
  19. data/ext/lithop_native/build.rs +4 -0
  20. data/ext/lithop_native/extconf.rb +6 -0
  21. data/ext/lithop_native/src/column_mesh.rs +388 -0
  22. data/ext/lithop_native/src/lib.rs +37 -0
  23. data/ext/lithop_native/src/mesh.rs +187 -0
  24. data/ext/lithop_native/src/palette.rs +67 -0
  25. data/ext/lithop_native/src/xml.rs +134 -0
  26. data/lib/lithop/calibration/export.rb +21 -8
  27. data/lib/lithop/cli/calibration.rb +3 -1
  28. data/lib/lithop/cli/image_options.rb +41 -2
  29. data/lib/lithop/cli/options.rb +13 -0
  30. data/lib/lithop/cli.rb +36 -1
  31. data/lib/lithop/export/color_metadata.rb +55 -0
  32. data/lib/lithop/export/generation.rb +131 -21
  33. data/lib/lithop/export/job.rb +30 -16
  34. data/lib/lithop/export/metadata.rb +54 -4
  35. data/lib/lithop/export/planning.rb +97 -0
  36. data/lib/lithop/export/previews.rb +25 -1
  37. data/lib/lithop/export/print_readme.rb +22 -4
  38. data/lib/lithop/export/result.rb +26 -4
  39. data/lib/lithop/formats/three_mf/bambu_metadata.rb +111 -24
  40. data/lib/lithop/formats/three_mf/mesh.rb +72 -3
  41. data/lib/lithop/formats/three_mf/model_writer.rb +249 -24
  42. data/lib/lithop/formats/three_mf/part_writer.rb +51 -6
  43. data/lib/lithop/formats/three_mf.rb +9 -6
  44. data/lib/lithop/image/pipeline.rb +66 -10
  45. data/lib/lithop/image/svg_loader.rb +5 -5
  46. data/lib/lithop/job_directory.rb +41 -0
  47. data/lib/lithop/job_execution.rb +378 -0
  48. data/lib/lithop/layer_structure.rb +11 -0
  49. data/lib/lithop/mesh/box_geometry.rb +41 -0
  50. data/lib/lithop/mesh/column_geometry.rb +10 -2
  51. data/lib/lithop/mesh/column_topology.rb +2 -0
  52. data/lib/lithop/mesh/geometry.rb +21 -13
  53. data/lib/lithop/model/layer_region_model.rb +24 -0
  54. data/lib/lithop/model/material_model.rb +17 -5
  55. data/lib/lithop/model/palette.rb +21 -3
  56. data/lib/lithop/native.rb +39 -0
  57. data/lib/lithop/reference_three_mf.rb +17 -1
  58. data/lib/lithop/settings.rb +116 -1
  59. data/lib/lithop/settings_file.rb +10 -4
  60. data/lib/lithop/tiling.rb +127 -0
  61. data/lib/lithop/version.rb +1 -1
  62. data/lib/lithop/web/app.rb +513 -0
  63. data/lib/lithop/web/bambu_studio_launcher.rb +82 -0
  64. data/lib/lithop/web/job_launcher.rb +214 -0
  65. data/lib/lithop/web/job_store.rb +390 -0
  66. data/lib/lithop/web/preview_store.rb +182 -0
  67. data/lib/lithop/web/public/app.css +319 -0
  68. data/lib/lithop/web/public/app.js +1847 -0
  69. data/lib/lithop/web/server.rb +21 -0
  70. data/lib/lithop/web/settings_form.rb +64 -0
  71. data/lib/lithop/web/settings_library.rb +154 -0
  72. data/lib/lithop/web/views/index.erb +364 -0
  73. data/lib/lithop/web/worker.rb +27 -0
  74. data/lib/lithop.rb +5 -0
  75. data/sig/lithop.rbs +102 -2
  76. metadata +89 -2
@@ -0,0 +1,214 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "rbconfig"
6
+ require "securerandom"
7
+ require "time"
8
+
9
+ module Lithop
10
+ module Web
11
+ # Stages an upload and starts the shared conversion engine in a child process.
12
+ # rubocop:disable-next Metrics/ClassLength
13
+ class JobLauncher
14
+ ALLOWED_EXTENSIONS = %w[.jpeg .jpg .png .svg].freeze
15
+ WORK_DIRECTORY = ".web"
16
+
17
+ # rubocop:disable-next Metrics/ParameterLists
18
+ def initialize(
19
+ project_dir:, random: SecureRandom, clock: Time, process: Process,
20
+ sleeper: Kernel.method(:sleep), cancel_grace: 1.0, kill_grace: 1.0
21
+ )
22
+ @project_dir = File.expand_path(project_dir)
23
+ @random = random
24
+ @clock = clock
25
+ @build_dir = File.join(@project_dir, "build")
26
+ @work_root = File.join(@build_dir, WORK_DIRECTORY)
27
+ @process = process
28
+ @sleeper = sleeper
29
+ @cancel_grace = cancel_grace
30
+ @kill_grace = kill_grace
31
+ @children = {}
32
+ @children_mutex = Mutex.new
33
+ recover
34
+ end
35
+
36
+ def start(upload:, filename:, settings:)
37
+ validate_build_directory!
38
+ extension = validate_extension(filename)
39
+ job_id = "#{@clock.now.utc.strftime("%Y%m%dT%H%M%SZ")}-#{@random.hex(4)}"
40
+ work_dir = File.join(@work_root, job_id)
41
+ FileUtils.mkdir_p(work_dir)
42
+ input_path = File.join(work_dir, "input#{extension}")
43
+ settings_path = File.join(work_dir, "settings.json")
44
+ copy_upload(upload, input_path)
45
+ write_settings(settings, settings_path)
46
+ write_request_record(job_id, filename, settings)
47
+ pid = spawn_worker(job_id:, input_path:, settings_path:, work_dir:)
48
+ @children_mutex.synchronize { @children[job_id] = pid }
49
+ write_launch_record(work_dir, job_id, pid)
50
+ reap(job_id, pid, work_dir)
51
+ job_id
52
+ rescue StandardError
53
+ abort_spawn(job_id, pid) if defined?(pid) && pid
54
+ FileUtils.rm_r(work_dir) if defined?(work_dir) && File.directory?(work_dir)
55
+ FileUtils.rm_f(request_record_path(job_id)) if defined?(job_id) && job_id
56
+ raise
57
+ end
58
+
59
+ def cancel(job_id)
60
+ request_path = JobExecution.request_cancel(project_dir: @project_dir, job_id:)
61
+ pid = owned_pid(job_id)
62
+ escalate_cancellation(job_id, pid) if pid
63
+ request_path
64
+ end
65
+
66
+ def active?(job_id) = !owned_pid(job_id).nil?
67
+
68
+ private
69
+
70
+ def validate_build_directory!
71
+ raise Error, "refusing to use a symbolic build directory" if File.symlink?(@build_dir)
72
+ raise Error, "refusing to use a symbolic UI work directory" if File.symlink?(@work_root)
73
+
74
+ FileUtils.mkdir_p(@work_root)
75
+ end
76
+
77
+ def validate_extension(filename)
78
+ extension = File.extname(filename.to_s).downcase
79
+ return extension if ALLOWED_EXTENSIONS.include?(extension)
80
+
81
+ raise InputError, "image must be a JPEG, PNG, or SVG file"
82
+ end
83
+
84
+ def copy_upload(upload, destination)
85
+ upload.rewind if upload.respond_to?(:rewind)
86
+ File.open(destination, "wb", 0o600) { |file| IO.copy_stream(upload, file) }
87
+ end
88
+
89
+ def write_settings(settings, path)
90
+ values = settings.to_h.slice(*Settings::DEFAULTS.keys)
91
+ File.write(path, JSON.pretty_generate({ schema_version: SettingsFile::SCHEMA_VERSION }.merge(values)) << "\n")
92
+ end
93
+
94
+ def write_request_record(job_id, filename, settings)
95
+ FileUtils.mkdir_p(File.join(@build_dir, ".jobs"))
96
+ values = settings.to_h.slice(*Settings::DEFAULTS.keys)
97
+ File.write(
98
+ request_record_path(job_id),
99
+ JSON.generate(
100
+ { input_name: File.basename(filename.to_s) }.merge(values)
101
+ ) << "\n",
102
+ mode: "w", perm: 0o600
103
+ )
104
+ end
105
+
106
+ def request_record_path(job_id) = File.join(@build_dir, ".jobs", "#{job_id}.request.json")
107
+
108
+ def spawn_worker(job_id:, input_path:, settings_path:, work_dir:)
109
+ log_path = File.join(@build_dir, ".jobs", "#{job_id}.log")
110
+ FileUtils.mkdir_p(File.dirname(log_path))
111
+ @process.spawn(
112
+ RbConfig.ruby, "-I", library_root, "-rlithop/web/worker", "-e",
113
+ "exit Lithop::Web::Worker.run(ARGV)", job_id, input_path, settings_path, @project_dir,
114
+ out: [log_path, "a"], err: %i[child out], pgroup: true, chdir: work_dir
115
+ )
116
+ end
117
+
118
+ def write_launch_record(work_dir, job_id, pid)
119
+ File.write(
120
+ File.join(work_dir, "launch.json"),
121
+ JSON.generate(job_id:, pid:, created_at: @clock.now.utc.iso8601(6)) << "\n"
122
+ )
123
+ end
124
+
125
+ def reap(job_id, pid, work_dir)
126
+ Thread.new do
127
+ @process.wait(pid)
128
+ rescue Errno::ECHILD
129
+ nil
130
+ ensure
131
+ @children_mutex.synchronize { @children.delete(job_id) if @children[job_id] == pid }
132
+ reconcile_exited_jobs([job_id])
133
+ FileUtils.rm_r(work_dir) if File.directory?(work_dir)
134
+ end
135
+ end
136
+
137
+ def abort_spawn(job_id, pid)
138
+ @process.kill("KILL", -pid) if @process.getpgid(pid) == pid
139
+ @process.wait(pid)
140
+ rescue SystemCallError
141
+ nil
142
+ ensure
143
+ @children_mutex.synchronize { @children.delete(job_id) if @children[job_id] == pid }
144
+ end
145
+
146
+ def escalate_cancellation(job_id, pid)
147
+ Thread.new do
148
+ @sleeper.call(@cancel_grace)
149
+ terminate_owned(job_id, pid, "TERM")
150
+ @sleeper.call(@kill_grace)
151
+ terminate_owned(job_id, pid, "KILL")
152
+ rescue SystemCallError
153
+ nil
154
+ end
155
+ end
156
+
157
+ def terminate_owned(job_id, pid, signal)
158
+ return unless owned_pid(job_id) == pid
159
+ return unless @process.getpgid(pid) == pid
160
+
161
+ @process.kill(signal, -pid)
162
+ rescue Errno::ESRCH, Errno::ECHILD
163
+ nil
164
+ end
165
+
166
+ def owned_pid(job_id)
167
+ @children_mutex.synchronize { @children[job_id] }
168
+ end
169
+
170
+ def recover
171
+ stale_job_ids = if File.directory?(@work_root)
172
+ stale_work_directories
173
+ else
174
+ []
175
+ end
176
+ reconcile_exited_jobs(stale_job_ids)
177
+ rescue SystemCallError, JSON::ParserError
178
+ nil
179
+ end
180
+
181
+ def stale_work_directories
182
+ Dir.children(@work_root).filter_map do |name|
183
+ next unless name.match?(/\A[0-9A-Za-z][0-9A-Za-z._-]*\z/)
184
+
185
+ directory = File.join(@work_root, name)
186
+ next if File.symlink?(directory) || !File.directory?(directory)
187
+ next if live_launch?(directory)
188
+
189
+ FileUtils.rm_r(directory)
190
+ name
191
+ end
192
+ end
193
+
194
+ def reconcile_exited_jobs(job_ids)
195
+ recovered_ids = JobDirectory.recover(project_dir: @project_dir).map(&:job_id)
196
+ (job_ids + recovered_ids).uniq.each do |job_id|
197
+ JobExecution.finalize_process_exit(project_dir: @project_dir, job_id:, clock: @clock)
198
+ end
199
+ end
200
+
201
+ def live_launch?(directory)
202
+ record = JSON.parse(File.read(File.join(directory, "launch.json")))
203
+ @process.kill(0, Integer(record.fetch("pid")))
204
+ true
205
+ rescue Errno::ESRCH, Errno::ENOENT, KeyError, ArgumentError
206
+ false
207
+ rescue Errno::EPERM
208
+ true
209
+ end
210
+
211
+ def library_root = File.expand_path("../..", __dir__)
212
+ end
213
+ end
214
+ end
@@ -0,0 +1,390 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "fileutils"
5
+ require "securerandom"
6
+ require "zip"
7
+
8
+ module Lithop
9
+ module Web
10
+ # Reads persisted job state and resolves only recorded output artifacts.
11
+ # rubocop:disable-next Metrics/ClassLength
12
+ class JobStore
13
+ JOB_ID_PATTERN = /\A[0-9A-Za-z][0-9A-Za-z._-]*\z/
14
+ INDEX_SCHEMA_VERSION = 1
15
+
16
+ def initialize(project_dir:)
17
+ @project_dir = File.expand_path(project_dir)
18
+ @build_dir = File.join(@project_dir, "build")
19
+ @jobs_dir = File.join(@build_dir, ".jobs")
20
+ @work_root = File.join(@build_dir, JobLauncher::WORK_DIRECTORY)
21
+ @archives_dir = File.join(@build_dir, ".downloads")
22
+ @index_path = File.join(@jobs_dir, ".index.json")
23
+ @index_mutex = Mutex.new
24
+ @index = load_index
25
+ recover_archives
26
+ end
27
+
28
+ def fetch(job_id)
29
+ validate_job_id!(job_id)
30
+ path = File.join(@jobs_dir, "#{job_id}.json")
31
+ return merge_request_record(job_id, JSON.parse(File.read(path))) if File.file?(path)
32
+ return merge_request_record(job_id, pending_record(job_id)) if File.directory?(File.join(@work_root, job_id))
33
+
34
+ nil
35
+ rescue JSON::ParserError, SystemCallError
36
+ nil
37
+ end
38
+
39
+ def recent(limit: 12)
40
+ entries = indexed_entries.sort_by { |entry| entry.fetch("job_id") }.reverse
41
+ active, final = entries.partition { |entry| JobExecution::ACTIVE_STATES.include?(entry["state"]) }
42
+ (active + final).first(limit).filter_map { |entry| fetch(entry.fetch("job_id")) }
43
+ end
44
+
45
+ def page(limit: 12, offset: 0, filters: {})
46
+ records = indexed_entries
47
+ term = filters[:query].to_s.strip.downcase
48
+ records.select! { |record| searchable_text(record).include?(term) } unless term.empty?
49
+ records.select! { |record| state_match?(record.fetch("state", ""), filters[:state]) }
50
+ if present?(filters[:date_from])
51
+ records.select! { |record| record["created_at"].to_s[0, 10] >= filters[:date_from] }
52
+ end
53
+ if present?(filters[:date_to])
54
+ records.select! { |record| record["created_at"].to_s[0, 10] <= filters[:date_to] }
55
+ end
56
+ records = sort_records(records, filters[:sort])
57
+ selected = records.slice(offset, limit) || []
58
+ {
59
+ "jobs" => selected.filter_map { |record| fetch(record.fetch("job_id")) }, "total" => records.length,
60
+ "offset" => offset, "limit" => limit
61
+ }
62
+ end
63
+
64
+ def archive_summary(job_id, scope:)
65
+ names = archive_names(job_id, scope)
66
+ files = names.map do |name|
67
+ path = artifact(job_id, name)
68
+ { "name" => name, "size_bytes" => File.size(path) }
69
+ end
70
+ { "scope" => scope, "files" => files, "file_count" => files.length,
71
+ "size_bytes" => files.sum { |file| file.fetch("size_bytes") } }
72
+ end
73
+
74
+ def create_archive(job_id, scope:)
75
+ summary = archive_summary(job_id, scope:)
76
+ raise InputError, "no matching artifacts" if summary.fetch("files").empty?
77
+
78
+ prepare_archives_directory!
79
+ path = File.join(@archives_dir, "#{job_id}-#{scope}-#{SecureRandom.hex(8)}.zip")
80
+ Zip::File.open(path, create: true) do |archive|
81
+ summary.fetch("files").each do |file|
82
+ name = file.fetch("name")
83
+ archive.add(name, artifact(job_id, name))
84
+ end
85
+ end
86
+ path
87
+ rescue StandardError
88
+ FileUtils.rm_f(path) if path
89
+ raise
90
+ end
91
+
92
+ def artifact(job_id, name)
93
+ record = fetch(job_id)
94
+ raise InputError, "job not found" unless record
95
+ raise InputError, "job is not complete" unless record["state"] == "completed"
96
+ raise InputError, "artifact not found" unless record.fetch("files", []).include?(name)
97
+
98
+ output = contained_realpath(record.fetch("output_path"), @build_dir)
99
+ contained_realpath(File.join(output, name), output).tap do |path|
100
+ raise InputError, "artifact not found" unless File.file?(path)
101
+ end
102
+ rescue KeyError
103
+ raise InputError, "artifact not found"
104
+ end
105
+
106
+ def log(job_id)
107
+ validate_job_id!(job_id)
108
+ raise InputError, "job not found" unless fetch(job_id)
109
+
110
+ path = File.join(@jobs_dir, "#{job_id}.log")
111
+ raise InputError, "job log not found" unless File.file?(path) && !File.symlink?(path)
112
+
113
+ contained_realpath(path, @jobs_dir)
114
+ end
115
+
116
+ def summary(job_id)
117
+ record = fetch(job_id)
118
+ raise InputError, "job not found" unless record
119
+
120
+ output = expected_output(job_id)
121
+ return { "file_count" => 0, "size_bytes" => 0 } unless safe_output?(output)
122
+
123
+ files = regular_files(output)
124
+ { "file_count" => files.length, "size_bytes" => files.sum { |path| File.size(path) } }
125
+ rescue SystemCallError
126
+ { "file_count" => 0, "size_bytes" => 0 }
127
+ end
128
+
129
+ def delete(job_id)
130
+ validate_job_id!(job_id)
131
+ FileUtils.mkdir_p(@jobs_dir)
132
+ File.open(File.join(@jobs_dir, ".delete.lock"), File::RDWR | File::CREAT, 0o600) do |lock|
133
+ raise Error, "another job deletion is in progress" unless lock.flock(File::LOCK_EX | File::LOCK_NB)
134
+
135
+ record = fetch(job_id)
136
+ raise InputError, "job not found" unless record
137
+ unless JobExecution::FINAL_STATES.include?(record["state"])
138
+ raise InputError, "active jobs must be stopped before deletion"
139
+ end
140
+
141
+ output = expected_output(job_id)
142
+ validate_recorded_output!(record, output)
143
+ validate_work_directory!(job_id)
144
+ removed = summary(job_id)
145
+ remove_output(output)
146
+ remove_work_directory(job_id)
147
+ FileUtils.rm_f(File.join(@jobs_dir, "#{job_id}.log"))
148
+ FileUtils.rm_f(File.join(@jobs_dir, "#{job_id}.cancel"))
149
+ FileUtils.rm_f(File.join(@jobs_dir, "#{job_id}.transition.lock"))
150
+ FileUtils.rm_f(File.join(@jobs_dir, "#{job_id}.request.json"))
151
+ FileUtils.rm_f(File.join(@jobs_dir, "#{job_id}.json"))
152
+ remove_index_entry(job_id)
153
+ removed
154
+ end
155
+ end
156
+
157
+ private
158
+
159
+ def indexed_entries
160
+ return [] unless File.directory?(@jobs_dir)
161
+
162
+ @index_mutex.synchronize do
163
+ names = Dir.glob("*.json", base: @jobs_dir).reject { |name| name.end_with?(".request.json") }
164
+ job_ids = names.map { |name| File.basename(name, ".json") }
165
+ existing = job_ids.to_h { |job_id| [job_id, true] }
166
+ changed = @index.keys.any? { |job_id| !existing.key?(job_id) }
167
+ @index.select! { |job_id, _entry| existing.key?(job_id) }
168
+ job_ids.each do |job_id|
169
+ entry = build_index_entry(job_id, @index[job_id])
170
+ next unless entry
171
+ next if entry == @index[job_id]
172
+
173
+ @index[job_id] = entry
174
+ changed = true
175
+ end
176
+ write_index if changed
177
+ @index.values.map(&:dup)
178
+ end
179
+ end
180
+
181
+ def build_index_entry(job_id, previous)
182
+ status_path = File.join(@jobs_dir, "#{job_id}.json")
183
+ request_path = File.join(@jobs_dir, "#{job_id}.request.json")
184
+ signature = file_signature(status_path)
185
+ request_signature = file_signature(request_path)
186
+ if previous && previous["signature"] == signature && previous["request_signature"] == request_signature
187
+ return previous
188
+ end
189
+
190
+ status = JSON.parse(File.read(status_path))
191
+ request = request_signature ? JSON.parse(File.read(request_path)) : {}
192
+ {
193
+ "job_id" => job_id,
194
+ "state" => status.fetch("state", ""),
195
+ "created_at" => status["created_at"].to_s,
196
+ "updated_at" => status["updated_at"].to_s,
197
+ "input_name" => request["input_name"].to_s,
198
+ "signature" => signature,
199
+ "request_signature" => request_signature
200
+ }
201
+ rescue JSON::ParserError, SystemCallError
202
+ previous
203
+ end
204
+
205
+ def file_signature(path)
206
+ return unless File.file?(path) && !File.symlink?(path)
207
+
208
+ stat = File.stat(path)
209
+ [stat.size, stat.mtime.to_f]
210
+ end
211
+
212
+ def load_index
213
+ return {} unless File.file?(@index_path) && !File.symlink?(@index_path)
214
+
215
+ document = JSON.parse(File.read(@index_path))
216
+ return {} unless document["schema_version"] == INDEX_SCHEMA_VERSION
217
+
218
+ document.fetch("jobs", {}).select { |job_id, _entry| job_id.match?(JOB_ID_PATTERN) }
219
+ rescue JSON::ParserError, KeyError, SystemCallError
220
+ {}
221
+ end
222
+
223
+ def write_index
224
+ temporary = "#{@index_path}.tmp-#{Process.pid}"
225
+ File.write(
226
+ temporary,
227
+ JSON.generate("schema_version" => INDEX_SCHEMA_VERSION, "jobs" => @index) << "\n",
228
+ mode: "w", perm: 0o600
229
+ )
230
+ File.rename(temporary, @index_path)
231
+ ensure
232
+ FileUtils.rm_f(temporary) if defined?(temporary)
233
+ end
234
+
235
+ def remove_index_entry(job_id)
236
+ @index_mutex.synchronize do
237
+ @index.delete(job_id)
238
+ write_index if File.directory?(@jobs_dir)
239
+ end
240
+ end
241
+
242
+ def searchable_text(record)
243
+ [record["job_id"], record["input_name"]].compact.join(" ").downcase
244
+ end
245
+
246
+ def state_match?(value, filter)
247
+ return true if filter.nil? || filter == "all"
248
+ return JobExecution::ACTIVE_STATES.include?(value) if filter == "active"
249
+ return %w[failed cancelled].include?(value) if filter == "failed"
250
+
251
+ value == filter
252
+ end
253
+
254
+ def sort_records(records, sort)
255
+ case sort
256
+ when "created_asc" then records.sort_by { |record| [record["created_at"].to_s, record.fetch("job_id")] }
257
+ when "name_asc" then records.sort_by { |record| [record["input_name"].to_s.downcase, record.fetch("job_id")] }
258
+ when "name_desc"
259
+ records.sort_by { |record| [record["input_name"].to_s.downcase, record.fetch("job_id")] }.reverse
260
+ else
261
+ active, final = records.partition { |record| JobExecution::ACTIVE_STATES.include?(record["state"]) }
262
+ [active, final].flat_map do |group|
263
+ group.sort_by { |record| [record["created_at"].to_s, record.fetch("job_id")] }.reverse
264
+ end
265
+ end
266
+ end
267
+
268
+ def archive_names(job_id, scope)
269
+ record = fetch(job_id)
270
+ raise InputError, "job not found" unless record
271
+ raise InputError, "job is not complete" unless record["state"] == "completed"
272
+ raise InputError, "archive scope is invalid" unless %w[models all].include?(scope)
273
+
274
+ names = record.fetch("files", []) || []
275
+ raise InputError, "artifact path is invalid" unless names.all? { |name| safe_archive_name?(name) }
276
+
277
+ scope == "models" ? names.grep(/\.3mf\z/i) : names
278
+ end
279
+
280
+ def safe_archive_name?(name)
281
+ value = name.to_s
282
+ parts = value.split("/", -1)
283
+ !value.empty? && !value.start_with?("/") && !value.include?("\\") &&
284
+ parts.none? { |part| part.empty? || part == "." || part == ".." }
285
+ end
286
+
287
+ def recover_archives
288
+ return unless File.directory?(@archives_dir) && !File.symlink?(@archives_dir)
289
+
290
+ Dir.glob("*.zip", base: @archives_dir).each { |name| FileUtils.rm_f(File.join(@archives_dir, name)) }
291
+ end
292
+
293
+ def prepare_archives_directory!
294
+ raise InputError, "archive directory must not be a symbolic link" if File.symlink?(@archives_dir)
295
+
296
+ FileUtils.mkdir_p(@archives_dir, mode: 0o700)
297
+ raise InputError, "archive path is not a directory" unless File.directory?(@archives_dir)
298
+ end
299
+
300
+ def present?(value) = !value.nil? && !value.to_s.empty?
301
+
302
+ def validate_job_id!(job_id)
303
+ raise InputError, "invalid job ID" unless job_id.to_s.match?(JOB_ID_PATTERN)
304
+ end
305
+
306
+ def contained_realpath(path, parent)
307
+ resolved_parent = File.realpath(parent)
308
+ resolved = File.realpath(path)
309
+ prefix = "#{resolved_parent}#{File::SEPARATOR}"
310
+ raise InputError, "artifact path leaves the build directory" unless resolved.start_with?(prefix)
311
+
312
+ resolved
313
+ rescue SystemCallError
314
+ raise InputError, "artifact not found"
315
+ end
316
+
317
+ def expected_output(job_id) = File.join(@build_dir, job_id)
318
+
319
+ def validate_recorded_output!(record, expected)
320
+ recorded = record["output_path"]
321
+ return if recorded.nil? || recorded.to_s.empty?
322
+ return if File.expand_path(recorded) == expected
323
+
324
+ raise InputError, "recorded output path does not match the job"
325
+ end
326
+
327
+ def safe_output?(path)
328
+ File.directory?(path) && !File.symlink?(path) && File.dirname(path) == @build_dir
329
+ end
330
+
331
+ def regular_files(directory)
332
+ Dir.glob("**/*", base: directory).filter_map do |name|
333
+ path = File.join(directory, name)
334
+ path if File.file?(path) && !File.symlink?(path)
335
+ end
336
+ end
337
+
338
+ def remove_output(path)
339
+ return unless File.exist?(path) || File.symlink?(path)
340
+ raise InputError, "refusing to delete a symbolic job output" if File.symlink?(path)
341
+ raise InputError, "job output is not a directory" unless File.directory?(path)
342
+
343
+ FileUtils.rm_r(path)
344
+ end
345
+
346
+ def remove_work_directory(job_id)
347
+ path = File.join(@work_root, job_id)
348
+ return unless File.exist?(path) || File.symlink?(path)
349
+
350
+ FileUtils.rm_r(path)
351
+ end
352
+
353
+ def validate_work_directory!(job_id)
354
+ path = File.join(@work_root, job_id)
355
+ return unless File.exist?(path) || File.symlink?(path)
356
+ raise InputError, "refusing to delete a symbolic job work directory" if File.symlink?(path)
357
+ raise InputError, "job work path is not a directory" unless File.directory?(path)
358
+ raise InputError, "job process is still exiting" if live_work_process?(path)
359
+ end
360
+
361
+ def live_work_process?(path)
362
+ launch = JSON.parse(File.read(File.join(path, "launch.json")))
363
+ Process.kill(0, Integer(launch.fetch("pid")))
364
+ true
365
+ rescue Errno::ESRCH, Errno::ENOENT, KeyError, ArgumentError, JSON::ParserError
366
+ false
367
+ rescue Errno::EPERM
368
+ true
369
+ end
370
+
371
+ def pending_record(job_id)
372
+ {
373
+ "job_id" => job_id, "state" => "queued", "phase" => "starting", "percent" => 0,
374
+ "message" => "Starting conversion", "files" => nil
375
+ }
376
+ end
377
+
378
+ def merge_request_record(job_id, record)
379
+ path = File.join(@jobs_dir, "#{job_id}.request.json")
380
+ return record unless File.file?(path)
381
+
382
+ keys = ["input_name", *Settings::DEFAULTS.keys.map(&:to_s)]
383
+ metadata = JSON.parse(File.read(path)).slice(*keys)
384
+ record.merge(metadata)
385
+ rescue JSON::ParserError, SystemCallError
386
+ record
387
+ end
388
+ end
389
+ end
390
+ end