rufio 0.50.0 → 0.60.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.
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Rufio
6
+ # 複数の設定ファイルからscript_pathsをロード・マージするクラス
7
+ # 優先順位: ローカル > ユーザー > システム
8
+ class ScriptConfigLoader
9
+ # デフォルトの設定ファイルパス
10
+ DEFAULT_LOCAL_PATH = './rufio.yml'
11
+ DEFAULT_USER_PATH = File.expand_path('~/.config/rufio/config.yml')
12
+ DEFAULT_SYSTEM_PATH = '/etc/rufio/config.yml'
13
+
14
+ # @param local_path [String, nil] ローカル設定ファイルのパス
15
+ # @param user_path [String, nil] ユーザー設定ファイルのパス
16
+ # @param system_path [String, nil] システム設定ファイルのパス
17
+ def initialize(local_path: nil, user_path: nil, system_path: nil)
18
+ @local_path = local_path || DEFAULT_LOCAL_PATH
19
+ @user_path = user_path || DEFAULT_USER_PATH
20
+ @system_path = system_path || DEFAULT_SYSTEM_PATH
21
+ end
22
+
23
+ # マージされたscript_pathsを取得
24
+ # @return [Array<String>] スクリプトパスの配列(優先順位順、重複なし)
25
+ def script_paths
26
+ paths = []
27
+ seen = Set.new
28
+
29
+ # 優先順位順に読み込み(ローカル > ユーザー > システム)
30
+ [@local_path, @user_path, @system_path].each do |config_path|
31
+ next unless config_path && File.exist?(config_path)
32
+
33
+ config_paths = load_paths_from_file(config_path)
34
+ config_paths.each do |path|
35
+ expanded = File.expand_path(path)
36
+ next if seen.include?(expanded)
37
+
38
+ seen.add(expanded)
39
+ paths << expanded
40
+ end
41
+ end
42
+
43
+ paths
44
+ end
45
+
46
+ # 全設定をマージして取得
47
+ # @return [Hash] マージされた設定
48
+ def merged_config
49
+ config = {}
50
+
51
+ # 逆順で読み込み(システム < ユーザー < ローカル)
52
+ [@system_path, @user_path, @local_path].each do |config_path|
53
+ next unless config_path && File.exist?(config_path)
54
+
55
+ file_config = load_config(config_path)
56
+ config = deep_merge(config, file_config)
57
+ end
58
+
59
+ # script_pathsは特別処理(マージではなく優先順位付き結合)
60
+ config['script_paths'] = script_paths
61
+ config
62
+ end
63
+
64
+ private
65
+
66
+ # 設定ファイルからscript_pathsを読み込む
67
+ # @param path [String] 設定ファイルのパス
68
+ # @return [Array<String>] パスの配列
69
+ def load_paths_from_file(path)
70
+ config = load_config(path)
71
+ config['script_paths'] || []
72
+ end
73
+
74
+ # 設定ファイルを読み込む
75
+ # @param path [String] 設定ファイルのパス
76
+ # @return [Hash] 設定内容
77
+ def load_config(path)
78
+ return {} unless File.exist?(path)
79
+
80
+ yaml = YAML.safe_load(File.read(path), symbolize_names: false)
81
+ yaml || {}
82
+ rescue StandardError => e
83
+ warn "Warning: Failed to load config #{path}: #{e.message}"
84
+ {}
85
+ end
86
+
87
+ # ハッシュを深くマージ
88
+ # @param base [Hash] ベースとなるハッシュ
89
+ # @param override [Hash] 上書きするハッシュ
90
+ # @return [Hash] マージされたハッシュ
91
+ def deep_merge(base, override)
92
+ base.merge(override) do |_key, old_val, new_val|
93
+ if old_val.is_a?(Hash) && new_val.is_a?(Hash)
94
+ deep_merge(old_val, new_val)
95
+ else
96
+ new_val
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,386 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'yaml'
4
+
5
+ module Rufio
6
+ # スクリプトパスを管理するクラス
7
+ # 設定ファイルからパスを読み込み、スクリプト名で解決する
8
+ class ScriptPathManager
9
+ attr_reader :paths
10
+
11
+ # サポートするスクリプト拡張子
12
+ SUPPORTED_EXTENSIONS = %w[.sh .rb .py .pl .js .ts .ps1].freeze
13
+
14
+ # 履歴の最大サイズ
15
+ MAX_HISTORY_SIZE = 100
16
+
17
+ # @param config_path [String] 設定ファイルのパス
18
+ def initialize(config_path)
19
+ @config_path = config_path
20
+ @config = load_config
21
+ @paths = expand_paths(@config['script_paths'] || [])
22
+ @cache = {}
23
+ @scripts_cache = nil
24
+ @execution_history = []
25
+ @execution_count = Hash.new(0)
26
+ end
27
+
28
+ # スクリプト名で解決
29
+ # @param command_name [String] スクリプト名(拡張子あり/なし)
30
+ # @return [String, nil] スクリプトのフルパス、見つからない場合はnil
31
+ def resolve(command_name)
32
+ # キャッシュをチェック
33
+ return @cache[command_name] if @cache.key?(command_name)
34
+
35
+ scripts = find_scripts(command_name)
36
+
37
+ case scripts.size
38
+ when 0
39
+ nil
40
+ when 1
41
+ @cache[command_name] = scripts.first
42
+ else
43
+ # 複数見つかった場合は最初のものを返す(on_multiple_match: 'first')
44
+ @cache[command_name] = scripts.first
45
+ end
46
+ end
47
+
48
+ # 全スクリプト一覧を取得(タブ補完用)
49
+ # @return [Array<String>] スクリプト名(拡張子なし)の配列
50
+ def all_scripts
51
+ scripts = []
52
+ seen = Set.new
53
+
54
+ @paths.each do |path|
55
+ next unless Dir.exist?(path)
56
+
57
+ Dir.glob(File.join(path, '*')).each do |file|
58
+ next unless File.file?(file)
59
+
60
+ basename = File.basename(file)
61
+ next if basename.start_with?('.')
62
+ next unless executable_script?(file)
63
+
64
+ name = File.basename(file, '.*')
65
+ next if seen.include?(name)
66
+
67
+ seen.add(name)
68
+ scripts << name
69
+ end
70
+ end
71
+
72
+ scripts.sort
73
+ end
74
+
75
+ # パスを追加
76
+ # @param path [String] 追加するディレクトリパス
77
+ # @return [Boolean] 追加成功した場合true、重複の場合false
78
+ def add_path(path)
79
+ expanded_path = File.expand_path(path)
80
+ return false if @paths.include?(expanded_path)
81
+
82
+ @paths << expanded_path
83
+ save_config
84
+ invalidate_cache
85
+ true
86
+ end
87
+
88
+ # パスを削除
89
+ # @param path [String] 削除するディレクトリパス
90
+ # @return [Boolean] 削除成功した場合true
91
+ def remove_path(path)
92
+ expanded_path = File.expand_path(path)
93
+ result = @paths.delete(expanded_path)
94
+ if result
95
+ save_config
96
+ invalidate_cache
97
+ end
98
+ !!result
99
+ end
100
+
101
+ # --- Phase 4: 複数マッチ ---
102
+
103
+ # すべてのマッチを取得(複数マッチ対応)
104
+ # @param command_name [String] コマンド名
105
+ # @return [Array<String>] マッチしたスクリプトのパス
106
+ def find_all_matches(command_name)
107
+ find_scripts_all_paths(command_name)
108
+ end
109
+
110
+ # --- Phase 4: タブ補完 ---
111
+
112
+ # スクリプト名を補完
113
+ # @param prefix [String] 入力中の文字列
114
+ # @return [Array<String>] 補完候補(拡張子なし)
115
+ def complete(prefix)
116
+ scripts = all_scripts
117
+ return scripts if prefix.empty?
118
+
119
+ scripts.select { |name| name.downcase.start_with?(prefix.downcase) }
120
+ end
121
+
122
+ # --- Phase 4: fuzzy matching ---
123
+
124
+ # fuzzy matchingで候補を取得
125
+ # @param query [String] 検索クエリ
126
+ # @return [Array<String>] マッチしたスクリプト名
127
+ def fuzzy_match(query)
128
+ return all_scripts if query.empty?
129
+
130
+ scripts = all_scripts
131
+ query_chars = query.downcase.chars
132
+
133
+ # スコア計算してソート
134
+ scored = scripts.map do |name|
135
+ score = fuzzy_score(name.downcase, query_chars)
136
+ [name, score]
137
+ end
138
+
139
+ # スコアが0より大きいものをスコア降順で返す
140
+ scored.select { |_, score| score > 0 }
141
+ .sort_by { |_, score| -score }
142
+ .map { |name, _| name }
143
+ end
144
+
145
+ # --- Phase 4: キャッシュ ---
146
+
147
+ # キャッシュを無効化(public)
148
+ def invalidate_cache
149
+ @cache.clear
150
+ @scripts_cache = nil
151
+ end
152
+
153
+ # --- Phase 4: 実行履歴 ---
154
+
155
+ # 実行を記録
156
+ # @param script_name [String] スクリプト名
157
+ def record_execution(script_name)
158
+ # 履歴の先頭に追加
159
+ @execution_history.unshift(script_name)
160
+ @execution_history = @execution_history.take(MAX_HISTORY_SIZE)
161
+
162
+ # カウントを増やす
163
+ @execution_count[script_name] += 1
164
+ end
165
+
166
+ # 実行履歴を取得
167
+ # @return [Array<String>] 最近実行したスクリプト名(新しい順)
168
+ def execution_history
169
+ @execution_history.dup
170
+ end
171
+
172
+ # 実行頻度順にスクリプトを取得
173
+ # @return [Array<String>] スクリプト名(頻度順)
174
+ def scripts_by_frequency
175
+ scripts = all_scripts
176
+ scripts.sort_by { |name| -@execution_count[name] }
177
+ end
178
+
179
+ # --- セクション9: エラーハンドリング ---
180
+
181
+ # 類似スクリプトの候補を取得
182
+ # @param query [String] 検索クエリ(typoを含む可能性あり)
183
+ # @return [Array<String>] 類似スクリプト名
184
+ def suggest(query)
185
+ scripts = all_scripts
186
+ return [] if scripts.empty?
187
+
188
+ # レーベンシュタイン距離でソート
189
+ scored = scripts.map do |name|
190
+ distance = levenshtein_distance(query.downcase, name.downcase)
191
+ [name, distance]
192
+ end
193
+
194
+ # 距離が3以下のものを距離順で返す
195
+ scored.select { |_, dist| dist <= 3 }
196
+ .sort_by { |_, dist| dist }
197
+ .map { |name, _| name }
198
+ end
199
+
200
+ # ファイルが実行可能かどうかをチェック
201
+ # @param path [String] ファイルパス
202
+ # @return [Boolean]
203
+ def executable?(path)
204
+ return false unless File.exist?(path)
205
+
206
+ File.executable?(path)
207
+ end
208
+
209
+ # ファイルに実行権限を付与
210
+ # @param path [String] ファイルパス
211
+ # @return [Boolean] 成功した場合true
212
+ def fix_permissions(path)
213
+ return false unless File.exist?(path)
214
+
215
+ current_mode = File.stat(path).mode
216
+ new_mode = current_mode | 0111 # 実行権限を追加
217
+ File.chmod(new_mode, path)
218
+ true
219
+ rescue StandardError
220
+ false
221
+ end
222
+
223
+ private
224
+
225
+ # 設定ファイルを読み込む
226
+ # @return [Hash] 設定内容
227
+ def load_config
228
+ return {} unless File.exist?(@config_path)
229
+
230
+ yaml = YAML.safe_load(File.read(@config_path), symbolize_names: false)
231
+ yaml || {}
232
+ rescue StandardError => e
233
+ warn "Failed to load config: #{e.message}"
234
+ {}
235
+ end
236
+
237
+ # 設定ファイルを保存
238
+ def save_config
239
+ @config['script_paths'] = @paths
240
+ File.write(@config_path, YAML.dump(@config))
241
+ end
242
+
243
+ # パスを展開(チルダ展開)
244
+ # @param paths [Array<String>] パスの配列
245
+ # @return [Array<String>] 展開済みのパス
246
+ def expand_paths(paths)
247
+ paths.map { |p| File.expand_path(p) }
248
+ end
249
+
250
+ # スクリプトを検索
251
+ # @param command_name [String] コマンド名
252
+ # @return [Array<String>] 見つかったスクリプトのパス
253
+ def find_scripts(command_name)
254
+ scripts = []
255
+ basename_without_ext = command_name.sub(/\.[^.]+$/, '')
256
+ has_extension = command_name.include?('.')
257
+
258
+ @paths.each do |path|
259
+ next unless Dir.exist?(path)
260
+
261
+ Dir.glob(File.join(path, '*')).each do |file|
262
+ next unless File.file?(file)
263
+
264
+ file_basename = File.basename(file)
265
+ next if file_basename.start_with?('.')
266
+ next unless executable_script?(file)
267
+
268
+ if has_extension
269
+ # 拡張子付きで完全一致
270
+ scripts << file if file_basename.downcase == command_name.downcase
271
+ else
272
+ # 拡張子なしで比較
273
+ file_name_without_ext = File.basename(file, '.*')
274
+ scripts << file if file_name_without_ext.downcase == basename_without_ext.downcase
275
+ end
276
+ end
277
+
278
+ # 最初のパスで見つかったらそれを優先(on_multiple_match: 'first'相当)
279
+ return scripts unless scripts.empty?
280
+ end
281
+
282
+ scripts
283
+ end
284
+
285
+ # 実行可能なスクリプトかどうかを判定
286
+ # @param path [String] ファイルパス
287
+ # @return [Boolean]
288
+ def executable_script?(path)
289
+ ext = File.extname(path).downcase
290
+ return true if SUPPORTED_EXTENSIONS.include?(ext)
291
+
292
+ File.executable?(path)
293
+ end
294
+
295
+ # すべてのパスからスクリプトを検索(最初のパスで止まらない)
296
+ # @param command_name [String] コマンド名
297
+ # @return [Array<String>] 見つかったスクリプトのパス
298
+ def find_scripts_all_paths(command_name)
299
+ scripts = []
300
+ basename_without_ext = command_name.sub(/\.[^.]+$/, '')
301
+ has_extension = command_name.include?('.')
302
+
303
+ @paths.each do |path|
304
+ next unless Dir.exist?(path)
305
+
306
+ Dir.glob(File.join(path, '*')).each do |file|
307
+ next unless File.file?(file)
308
+
309
+ file_basename = File.basename(file)
310
+ next if file_basename.start_with?('.')
311
+ next unless executable_script?(file)
312
+
313
+ if has_extension
314
+ scripts << file if file_basename.downcase == command_name.downcase
315
+ else
316
+ file_name_without_ext = File.basename(file, '.*')
317
+ scripts << file if file_name_without_ext.downcase == basename_without_ext.downcase
318
+ end
319
+ end
320
+ end
321
+
322
+ scripts
323
+ end
324
+
325
+ # レーベンシュタイン距離を計算
326
+ # @param s1 [String] 文字列1
327
+ # @param s2 [String] 文字列2
328
+ # @return [Integer] 編集距離
329
+ def levenshtein_distance(s1, s2)
330
+ m = s1.length
331
+ n = s2.length
332
+ return n if m == 0
333
+ return m if n == 0
334
+
335
+ # 動的計画法でテーブルを構築
336
+ d = Array.new(m + 1) { Array.new(n + 1, 0) }
337
+
338
+ (0..m).each { |i| d[i][0] = i }
339
+ (0..n).each { |j| d[0][j] = j }
340
+
341
+ (1..m).each do |i|
342
+ (1..n).each do |j|
343
+ cost = s1[i - 1] == s2[j - 1] ? 0 : 1
344
+ d[i][j] = [
345
+ d[i - 1][j] + 1, # 削除
346
+ d[i][j - 1] + 1, # 挿入
347
+ d[i - 1][j - 1] + cost # 置換
348
+ ].min
349
+ end
350
+ end
351
+
352
+ d[m][n]
353
+ end
354
+
355
+ # fuzzy matchingのスコアを計算
356
+ # @param text [String] 対象テキスト
357
+ # @param query_chars [Array<String>] クエリの文字配列
358
+ # @return [Integer] スコア(マッチしない場合は0)
359
+ def fuzzy_score(text, query_chars)
360
+ score = 0
361
+ text_index = 0
362
+
363
+ query_chars.each do |char|
364
+ # テキスト内で文字を探す
365
+ found_index = text.index(char, text_index)
366
+ return 0 unless found_index # マッチしない場合は0
367
+
368
+ # 連続していればボーナス
369
+ if found_index == text_index
370
+ score += 2
371
+ else
372
+ score += 1
373
+ end
374
+
375
+ # 単語の先頭ならボーナス
376
+ if found_index == 0 || text[found_index - 1] == '_' || text[found_index - 1] == '-'
377
+ score += 1
378
+ end
379
+
380
+ text_index = found_index + 1
381
+ end
382
+
383
+ score
384
+ end
385
+ end
386
+ end
@@ -0,0 +1,216 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'open3'
4
+
5
+ module Rufio
6
+ # スクリプトパスからスクリプトを検索・実行するクラス
7
+ # ジョブマネージャーと連携してバックグラウンド実行を管理
8
+ class ScriptRunner
9
+ # サポートするスクリプト拡張子
10
+ SUPPORTED_EXTENSIONS = %w[.sh .rb .py .pl .js .ts .ps1].freeze
11
+
12
+ # @param script_paths [Array<String>] スクリプトを検索するディレクトリのリスト
13
+ # @param job_manager [JobManager, nil] ジョブマネージャー(nilの場合は同期実行)
14
+ def initialize(script_paths:, job_manager: nil)
15
+ @script_paths = script_paths.map { |p| File.expand_path(p) }
16
+ @job_manager = job_manager
17
+ @scripts_cache = nil
18
+ end
19
+
20
+ # 利用可能なスクリプト一覧を取得
21
+ # @return [Array<Hash>] スクリプト情報の配列 [{ name:, path:, dir: }, ...]
22
+ def available_scripts
23
+ @scripts_cache ||= scan_scripts
24
+ end
25
+
26
+ # スクリプト名で検索
27
+ # @param name [String] スクリプト名(拡張子あり/なし)
28
+ # @return [Hash, nil] スクリプト情報 { name:, path:, dir: } または nil
29
+ def find_script(name)
30
+ # 完全一致を優先
31
+ script = available_scripts.find { |s| s[:name] == name }
32
+ return script if script
33
+
34
+ # 拡張子なしで検索
35
+ SUPPORTED_EXTENSIONS.each do |ext|
36
+ script = available_scripts.find { |s| s[:name] == "#{name}#{ext}" }
37
+ return script if script
38
+ end
39
+
40
+ nil
41
+ end
42
+
43
+ # スクリプト名の補完候補を取得
44
+ # @param prefix [String] 入力中の文字列
45
+ # @return [Array<String>] 補完候補のスクリプト名
46
+ def complete(prefix)
47
+ available_scripts
48
+ .map { |s| s[:name] }
49
+ .select { |name| name.start_with?(prefix) }
50
+ .uniq
51
+ .sort
52
+ end
53
+
54
+ # スクリプトをジョブとして実行
55
+ # @param name [String] スクリプト名
56
+ # @param working_dir [String] 作業ディレクトリ
57
+ # @param selected_file [String, nil] 選択中のファイル
58
+ # @param selected_dir [String, nil] 選択中のディレクトリ
59
+ # @return [TaskStatus, nil] 作成されたジョブ、またはスクリプトが見つからない場合nil
60
+ def run(name, working_dir:, selected_file: nil, selected_dir: nil)
61
+ script = find_script(name)
62
+ return nil unless script
63
+
64
+ env = build_environment(working_dir, selected_file, selected_dir)
65
+ execute_script(script, working_dir, env)
66
+ end
67
+
68
+ # キャッシュをクリア
69
+ def clear_cache
70
+ @scripts_cache = nil
71
+ end
72
+
73
+ private
74
+
75
+ # スクリプトディレクトリをスキャンしてスクリプトを収集
76
+ # @return [Array<Hash>] スクリプト情報の配列
77
+ def scan_scripts
78
+ scripts = []
79
+ seen_names = Set.new
80
+
81
+ @script_paths.each do |dir|
82
+ next unless Dir.exist?(dir)
83
+
84
+ Dir.glob(File.join(dir, '*')).each do |path|
85
+ next unless File.file?(path)
86
+ next unless executable_script?(path)
87
+
88
+ name = File.basename(path)
89
+ # 最初に見つかったものを優先(同名スクリプトは先のパスが優先)
90
+ next if seen_names.include?(name)
91
+
92
+ seen_names.add(name)
93
+ scripts << {
94
+ name: name,
95
+ path: path,
96
+ dir: dir
97
+ }
98
+ end
99
+ end
100
+
101
+ scripts.sort_by { |s| s[:name] }
102
+ end
103
+
104
+ # 実行可能なスクリプトかどうかを判定
105
+ # @param path [String] ファイルパス
106
+ # @return [Boolean]
107
+ def executable_script?(path)
108
+ ext = File.extname(path).downcase
109
+ return true if SUPPORTED_EXTENSIONS.include?(ext)
110
+
111
+ # 拡張子がなくても実行可能なら対象
112
+ File.executable?(path)
113
+ end
114
+
115
+ # 環境変数を構築
116
+ # @param working_dir [String] 作業ディレクトリ
117
+ # @param selected_file [String, nil] 選択中のファイル
118
+ # @param selected_dir [String, nil] 選択中のディレクトリ
119
+ # @return [Hash] 環境変数のハッシュ
120
+ def build_environment(working_dir, selected_file, selected_dir)
121
+ env = {
122
+ 'RUFIO_CURRENT_DIR' => working_dir
123
+ }
124
+ env['RUFIO_SELECTED_FILE'] = selected_file if selected_file
125
+ env['RUFIO_SELECTED_DIR'] = selected_dir if selected_dir
126
+ env
127
+ end
128
+
129
+ # スクリプトを実行してジョブを作成
130
+ # @param script [Hash] スクリプト情報
131
+ # @param working_dir [String] 作業ディレクトリ
132
+ # @param env [Hash] 環境変数
133
+ # @return [TaskStatus] 作成されたジョブ
134
+ def execute_script(script, working_dir, env = {})
135
+ command = build_command(script)
136
+
137
+ if @job_manager
138
+ # ジョブマネージャーにジョブを追加
139
+ job = @job_manager.add_job(
140
+ name: script[:name],
141
+ path: working_dir,
142
+ command: command
143
+ )
144
+ job.start
145
+
146
+ # バックグラウンドで実行
147
+ Thread.new do
148
+ execute_in_background(job, command, working_dir, env)
149
+ end
150
+
151
+ job
152
+ else
153
+ # 同期実行(ジョブマネージャーがない場合)
154
+ stdout, stderr, status = Open3.capture3(env, command, chdir: working_dir)
155
+ {
156
+ success: status.success?,
157
+ output: stdout,
158
+ error: stderr,
159
+ exit_code: status.exitstatus
160
+ }
161
+ end
162
+ end
163
+
164
+ # スクリプトの実行コマンドを構築
165
+ # @param script [Hash] スクリプト情報
166
+ # @return [String] 実行コマンド
167
+ def build_command(script)
168
+ path = script[:path]
169
+ ext = File.extname(path).downcase
170
+
171
+ case ext
172
+ when '.rb'
173
+ "ruby #{path.shellescape}"
174
+ when '.py'
175
+ "python3 #{path.shellescape}"
176
+ when '.js'
177
+ "node #{path.shellescape}"
178
+ when '.ts'
179
+ "ts-node #{path.shellescape}"
180
+ when '.pl'
181
+ "perl #{path.shellescape}"
182
+ when '.ps1'
183
+ "pwsh #{path.shellescape}"
184
+ else
185
+ # shスクリプトまたは実行可能ファイル
186
+ path.shellescape
187
+ end
188
+ end
189
+
190
+ # バックグラウンドでスクリプトを実行
191
+ # @param job [TaskStatus] ジョブ
192
+ # @param command [String] 実行コマンド
193
+ # @param working_dir [String] 作業ディレクトリ
194
+ # @param env [Hash] 環境変数
195
+ def execute_in_background(job, command, working_dir, env = {})
196
+ stdout, stderr, status = Open3.capture3(env, command, chdir: working_dir)
197
+
198
+ # ログを追加
199
+ job.append_log(stdout) unless stdout.empty?
200
+ job.append_log(stderr) unless stderr.empty?
201
+
202
+ if status.success?
203
+ job.complete(exit_code: status.exitstatus)
204
+ else
205
+ job.fail(exit_code: status.exitstatus)
206
+ end
207
+
208
+ # 通知を送信
209
+ @job_manager&.notify_completion(job)
210
+ rescue StandardError => e
211
+ job.append_log("Error: #{e.message}")
212
+ job.fail(exit_code: -1)
213
+ @job_manager&.notify_completion(job)
214
+ end
215
+ end
216
+ end