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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +14 -0
- data/Cargo.lock +284 -0
- data/Cargo.toml +6 -0
- data/README.md +31 -7
- data/RELEASING.md +24 -6
- data/docs/README.md +4 -1
- data/docs/artifacts.md +26 -2
- data/docs/bambu-studio.md +7 -1
- data/docs/color-model.md +2 -0
- data/docs/command.md +48 -7
- data/docs/config.md +31 -4
- data/docs/getting-started.md +10 -5
- data/docs/images/cmyw-lithophane-preview.png +0 -0
- data/docs/local-ui.md +114 -0
- data/docs/tiling.md +134 -0
- data/docs/troubleshooting.md +22 -0
- data/ext/lithop_native/Cargo.toml +17 -0
- data/ext/lithop_native/build.rs +4 -0
- data/ext/lithop_native/extconf.rb +6 -0
- data/ext/lithop_native/src/column_mesh.rs +388 -0
- data/ext/lithop_native/src/lib.rs +37 -0
- data/ext/lithop_native/src/mesh.rs +187 -0
- data/ext/lithop_native/src/palette.rs +67 -0
- data/ext/lithop_native/src/xml.rs +134 -0
- data/lib/lithop/calibration/export.rb +21 -8
- data/lib/lithop/cli/calibration.rb +3 -1
- data/lib/lithop/cli/image_options.rb +41 -2
- data/lib/lithop/cli/options.rb +13 -0
- data/lib/lithop/cli.rb +36 -1
- data/lib/lithop/export/color_metadata.rb +55 -0
- data/lib/lithop/export/generation.rb +131 -21
- data/lib/lithop/export/job.rb +30 -16
- data/lib/lithop/export/metadata.rb +54 -4
- data/lib/lithop/export/planning.rb +97 -0
- data/lib/lithop/export/previews.rb +25 -1
- data/lib/lithop/export/print_readme.rb +22 -4
- data/lib/lithop/export/result.rb +26 -4
- data/lib/lithop/formats/three_mf/bambu_metadata.rb +111 -24
- data/lib/lithop/formats/three_mf/mesh.rb +72 -3
- data/lib/lithop/formats/three_mf/model_writer.rb +249 -24
- data/lib/lithop/formats/three_mf/part_writer.rb +51 -6
- data/lib/lithop/formats/three_mf.rb +9 -6
- data/lib/lithop/image/pipeline.rb +66 -10
- data/lib/lithop/image/svg_loader.rb +5 -5
- data/lib/lithop/job_directory.rb +41 -0
- data/lib/lithop/job_execution.rb +378 -0
- data/lib/lithop/layer_structure.rb +11 -0
- data/lib/lithop/mesh/box_geometry.rb +41 -0
- data/lib/lithop/mesh/column_geometry.rb +10 -2
- data/lib/lithop/mesh/column_topology.rb +2 -0
- data/lib/lithop/mesh/geometry.rb +21 -13
- data/lib/lithop/model/layer_region_model.rb +24 -0
- data/lib/lithop/model/material_model.rb +17 -5
- data/lib/lithop/model/palette.rb +21 -3
- data/lib/lithop/native.rb +39 -0
- data/lib/lithop/reference_three_mf.rb +17 -1
- data/lib/lithop/settings.rb +116 -1
- data/lib/lithop/settings_file.rb +10 -4
- data/lib/lithop/tiling.rb +127 -0
- data/lib/lithop/version.rb +1 -1
- data/lib/lithop/web/app.rb +513 -0
- data/lib/lithop/web/bambu_studio_launcher.rb +82 -0
- data/lib/lithop/web/job_launcher.rb +214 -0
- data/lib/lithop/web/job_store.rb +390 -0
- data/lib/lithop/web/preview_store.rb +182 -0
- data/lib/lithop/web/public/app.css +319 -0
- data/lib/lithop/web/public/app.js +1847 -0
- data/lib/lithop/web/server.rb +21 -0
- data/lib/lithop/web/settings_form.rb +64 -0
- data/lib/lithop/web/settings_library.rb +154 -0
- data/lib/lithop/web/views/index.erb +364 -0
- data/lib/lithop/web/worker.rb +27 -0
- data/lib/lithop.rb +5 -0
- data/sig/lithop.rbs +102 -2
- 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
|