openc3 6.4.2 → 6.5.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,1460 @@
1
+ # encoding: ascii-8bit
2
+
3
+ # Copyright 2022 Ball Aerospace & Technologies Corp.
4
+ # All Rights Reserved.
5
+ #
6
+ # This program is free software; you can modify and/or redistribute it
7
+ # under the terms of the GNU Affero General Public License
8
+ # as published by the Free Software Foundation; version 3 with
9
+ # attribution addendums as found in the LICENSE.txt
10
+ #
11
+ # This program is distributed in the hope that it will be useful,
12
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ # GNU Affero General Public License for more details.
15
+
16
+ # Modified by OpenC3, Inc.
17
+ # All changes Copyright 2025, OpenC3, Inc.
18
+ # All Rights Reserved
19
+ #
20
+ # This file may also be used under the terms of a commercial license
21
+ # if purchased from OpenC3, Inc.
22
+
23
+ require 'json'
24
+ require 'securerandom'
25
+ require 'openc3'
26
+ require 'openc3/utilities/bucket_utilities'
27
+ require 'openc3/script'
28
+ require 'openc3/io/stdout'
29
+ require 'openc3/io/stderr'
30
+ require 'childprocess'
31
+ require 'openc3/script/suite_runner'
32
+ require 'openc3/utilities/store'
33
+ require 'openc3/utilities/store_queued'
34
+ require 'openc3/utilities/bucket_require'
35
+ require 'openc3/models/offline_access_model'
36
+ require 'openc3/models/environment_model'
37
+ require 'openc3/models/script_engine_model'
38
+ require 'openc3/models/script_status_model'
39
+
40
+ if not defined? RAILS_ROOT
41
+ if ENV['RAILS_ROOT']
42
+ RAILS_ROOT = ENV['RAILS_ROOT']
43
+ elsif defined? Rails
44
+ RAILS_ROOT = Rails.root
45
+ else
46
+ RAILS_ROOT = File.expand_path(File.join(__dir__, '..', '..'))
47
+ end
48
+ end
49
+
50
+ SCRIPT_API = 'script-api'
51
+
52
+ def running_script_publish(channel_name, data)
53
+ stream_name = [SCRIPT_API, channel_name].compact.join(":")
54
+ OpenC3::Store.publish(stream_name, JSON.generate(data))
55
+ end
56
+
57
+ def running_script_anycable_publish(channel_name, data)
58
+ stream_name = [SCRIPT_API, channel_name].compact.join(":")
59
+ stream_data = {"stream" => stream_name, "data" => JSON.generate(data)}
60
+ OpenC3::Store.publish("__anycable__", JSON.generate(stream_data))
61
+ end
62
+
63
+ module OpenC3
64
+ module Script
65
+ private
66
+ # Define all the user input methods used in scripting which we need to broadcast to the frontend
67
+ # Note: This list matches the list in run_script.rb:116
68
+ SCRIPT_METHODS = %i[ask ask_string message_box vertical_message_box combo_box prompt prompt_for_hazardous
69
+ prompt_for_critical_cmd metadata_input open_file_dialog open_files_dialog]
70
+ SCRIPT_METHODS.each do |method|
71
+ define_method(method) do |*args, **kwargs|
72
+ while true
73
+ if RunningScript.instance
74
+ RunningScript.instance.scriptrunner_puts("#{method}(#{args.join(', ')})")
75
+ prompt_id = SecureRandom.uuid
76
+ RunningScript.instance.perform_wait({ 'method' => method, 'id' => prompt_id, 'args' => args, 'kwargs' => kwargs })
77
+ input = RunningScript.instance.user_input
78
+ # All ask and prompt dialogs should include a 'Cancel' button
79
+ # If they cancel we wait so they can potentially stop
80
+ if input == 'Cancel'
81
+ RunningScript.instance.perform_pause
82
+ else
83
+ if (method.to_s.include?('open_file'))
84
+ files = input.map do |filename|
85
+ file = _get_storage_file("tmp/#{filename}", scope: RunningScript.instance.scope)
86
+ # Set filename method we added to Tempfile in the core_ext
87
+ file.filename = filename
88
+ file
89
+ end
90
+ files = files[0] if method.to_s == 'open_file_dialog' # Simply return the only file
91
+ return files
92
+ elsif method.to_s == 'prompt_for_critical_cmd'
93
+ if input == 'REJECTED'
94
+ raise "Critical Cmd Rejected"
95
+ end
96
+ return input
97
+ else
98
+ return input
99
+ end
100
+ end
101
+ else
102
+ raise "Script input method called outside of running script"
103
+ end
104
+ end
105
+ end
106
+ end
107
+
108
+ def step_mode
109
+ RunningScript.instance.step
110
+ end
111
+
112
+ def run_mode
113
+ RunningScript.instance.go
114
+ end
115
+
116
+ OpenC3.disable_warnings do
117
+ def start(procedure_name, line_no: 1, end_line_no: nil, bind_variables: false, complete: false)
118
+ RunningScript.instance.execute_while_paused_info = nil
119
+ path = procedure_name
120
+
121
+ # Decide if using script engine
122
+ use_script_engine = false
123
+ if RunningScript.instance.script_engine and procedure_name
124
+ extension = File.extname(procedure_name).to_s.downcase
125
+ if extension != ".rb"
126
+ use_script_engine = true
127
+ end
128
+ end
129
+
130
+ # Check RAM based instrumented cache
131
+ breakpoints = RunningScript.breakpoints[path]&.filter { |_, present| present }&.map { |line_number, _| line_number - 1 } # -1 because frontend lines are 0-indexed
132
+ breakpoints ||= []
133
+
134
+ instrumented_script = nil
135
+ instrumented_cache = nil
136
+ text = nil
137
+ if line_no == 1 and end_line_no.nil?
138
+ instrumented_cache, text = RunningScript.instrumented_cache[path]
139
+ end
140
+
141
+ if instrumented_cache
142
+ # Use cached instrumentation
143
+ instrumented_script = instrumented_cache
144
+ cached = true
145
+ running_script_anycable_publish("running-script-channel:#{RunningScript.instance.id}", { type: :file, filename: procedure_name, text: text.to_utf8, breakpoints: breakpoints })
146
+ else
147
+ # Retrieve file
148
+ text = ::Script.body(RunningScript.instance.scope, procedure_name)
149
+ raise "Unable to retrieve: #{procedure_name}" unless text
150
+ running_script_anycable_publish("running-script-channel:#{RunningScript.instance.id}", { type: :file, filename: procedure_name, text: text.to_utf8, breakpoints: breakpoints })
151
+
152
+ # Cache instrumentation into RAM
153
+ if line_no == 1 and end_line_no.nil?
154
+ if use_script_engine
155
+ # Don't instrument if using a script engine
156
+ instrumented_script = text
157
+ else
158
+ instrumented_script = RunningScript.instrument_script(text, path, true)
159
+ end
160
+ RunningScript.instrumented_cache[path] = [instrumented_script, text]
161
+ else
162
+ if line_no > 1 or not end_line_no.nil?
163
+ text_lines = text.lines
164
+
165
+ # Instrument only the specified lines
166
+ if end_line_no.nil?
167
+ end_line_no = text_lines.length
168
+ end
169
+
170
+ if line_no < 1 or line_no > text_lines.length
171
+ raise "Invalid start line number: #{line_no} for #{procedure_name}"
172
+ end
173
+
174
+ if end_line_no < 1 or end_line_no > text_lines.length
175
+ raise "Invalid end line number: #{end_line_no} for #{procedure_name}"
176
+ end
177
+
178
+ if line_no > end_line_no
179
+ raise "Start line number #{line_no} is greater than end line number #{end_line_no} for #{procedure_name}"
180
+ end
181
+
182
+ if not use_script_engine
183
+ text = text_lines[(line_no - 1)...end_line_no].join
184
+ end
185
+ end
186
+
187
+ if use_script_engine
188
+ instrumented_script = text
189
+ else
190
+ if bind_variables
191
+ instrumented_script = RunningScript.instrument_script(text, path, false, line_offset: line_no - 1, cache: false)
192
+ else
193
+ instrumented_script = RunningScript.instrument_script(text, path, true, line_offset: line_no - 1, cache: false)
194
+ end
195
+ end
196
+ end
197
+
198
+ cached = false
199
+ end
200
+ running = ScriptStatusModel.all(scope: RunningScript.instance.scope, type: 'running')
201
+ running_script_anycable_publish("all-scripts-channel", { type: :start, filename: procedure_name, active_scripts: running.length, scope: RunningScript.instance.scope })
202
+
203
+ if use_script_engine
204
+ if line_no != 1 or !end_line_no.nil?
205
+ if end_line_no.nil?
206
+ # Goto line
207
+ RunningScript.instance.script_engine.run_text(instrumented_script, filename: procedure_name, line_no: line_no)
208
+ else
209
+ # Execute selection
210
+ RunningScript.instance.script_engine.run_text(instrumented_script, filename: procedure_name, line_no: line_no, end_line_no: end_line_no)
211
+ end
212
+ else
213
+ RunningScript.instance.script_engine.run_text(instrumented_script, filename: procedure_name)
214
+ end
215
+ else
216
+ if bind_variables
217
+ eval(instrumented_script, RunningScript.instance.script_binding, path, line_no)
218
+ else
219
+ Object.class_eval(instrumented_script, path, line_no)
220
+ end
221
+ end
222
+
223
+ if complete
224
+ RunningScript.instance.script_status.state = 'completed'
225
+ RunningScript.instance.script_status.end_time = Time.now.utc.iso8601
226
+ RunningScript.instance.script_status.update(queued: true)
227
+ raise OpenC3::StopScript
228
+ end
229
+
230
+ # Return whether we had to load and instrument this file, i.e. it was not cached
231
+ !cached
232
+ end
233
+
234
+ def goto(line_no_or_procedure_name, line_no = nil)
235
+ if line_no.nil?
236
+ start(RunningScript.instance.current_filename, line_no: line_no_or_procedure_name, bind_variables: true, complete: true)
237
+ else
238
+ start(line_no_or_procedure_name, line_no: line_no, bind_variables: true, complete: true)
239
+ end
240
+ end
241
+
242
+ # Require an additional ruby file
243
+ def load_utility(procedure_name)
244
+ # Ensure load_utility works like require where you don't need the .rb extension
245
+ if File.extname(procedure_name) != '.rb'
246
+ procedure_name += '.rb'
247
+ end
248
+ not_cached = false
249
+ if defined? RunningScript and RunningScript.instance
250
+ saved = RunningScript.instance.use_instrumentation
251
+ begin
252
+ RunningScript.instance.use_instrumentation = false
253
+ not_cached = start(procedure_name)
254
+ ensure
255
+ RunningScript.instance.use_instrumentation = saved
256
+ end
257
+ else # Just call require
258
+ not_cached = require(procedure_name)
259
+ end
260
+ # Return whether we had to load and instrument this file, i.e. it was not cached
261
+ # This is designed to match the behavior of Ruby's require and load keywords
262
+ not_cached
263
+ end
264
+ alias require_utility load_utility
265
+
266
+ # sleep in a script - returns true if canceled mid sleep
267
+ def openc3_script_sleep(sleep_time = nil)
268
+ return true if $disconnect
269
+ RunningScript.instance.update_running_script_store("waiting")
270
+ if RunningScript.instance.use_instrumentation
271
+ running_script_anycable_publish("running-script-channel:#{RunningScript.instance.id}", { type: :line, filename: RunningScript.instance.current_filename, line_no: RunningScript.instance.current_line_number, state: :waiting })
272
+ end
273
+
274
+ sleep_time = 30000000 unless sleep_time # Handle infinite wait
275
+ if sleep_time > 0.0
276
+ end_time = Time.now.sys + sleep_time
277
+ count = 0
278
+ until Time.now.sys >= end_time
279
+ sleep(0.01)
280
+ count += 1
281
+ if RunningScript.instance.use_instrumentation and (count % 100) == 0 # Approximately Every Second
282
+ running_script_anycable_publish("running-script-channel:#{RunningScript.instance.id}", { type: :line, filename: RunningScript.instance.current_filename, line_no: RunningScript.instance.current_line_number, state: :waiting })
283
+ end
284
+ if RunningScript.instance.pause?
285
+ RunningScript.instance.perform_pause
286
+ return true
287
+ end
288
+ return true if RunningScript.instance.go?
289
+ raise StopScript if RunningScript.instance.stop?
290
+ end
291
+ end
292
+ return false
293
+ end
294
+
295
+ def display_screen(target_name, screen_name, x = nil, y = nil, scope: RunningScript.instance.scope)
296
+ definition = get_screen_definition(target_name, screen_name, scope: scope)
297
+ running_script_anycable_publish("running-script-channel:#{RunningScript.instance.id}", { type: :screen, target_name: target_name, screen_name: screen_name, definition: definition, x: x, y: y })
298
+ end
299
+
300
+ def clear_screen(target_name, screen_name)
301
+ running_script_anycable_publish("running-script-channel:#{RunningScript.instance.id}", { type: :clearscreen, target_name: target_name, screen_name: screen_name })
302
+ end
303
+
304
+ def clear_all_screens
305
+ running_script_anycable_publish("running-script-channel:#{RunningScript.instance.id}", { type: :clearallscreens })
306
+ end
307
+
308
+ def local_screen(screen_name, definition, x = nil, y = nil)
309
+ running_script_anycable_publish("running-script-channel:#{RunningScript.instance.id}", { type: :screen, target_name: "LOCAL", screen_name: screen_name, definition: definition, x: x, y: y })
310
+ end
311
+
312
+ def download_file(path, scope: RunningScript.instance.scope)
313
+ url = _get_download_url(path, scope: scope)
314
+ running_script_anycable_publish("running-script-channel:#{RunningScript.instance.id}", { type: :downloadfile, filename: File.basename(path), url: url })
315
+ end
316
+ end
317
+ end
318
+ end
319
+
320
+ class RunningScript
321
+ def id
322
+ return @script_status.id
323
+ end
324
+ def scope
325
+ return @script_status.scope
326
+ end
327
+ def filename
328
+ return @script_status.filename
329
+ end
330
+ def current_filename
331
+ return @script_status.current_filename
332
+ end
333
+ def current_line_number
334
+ return @script_status.line_no
335
+ end
336
+
337
+ attr_accessor :use_instrumentation
338
+ attr_accessor :continue_after_error
339
+ attr_accessor :exceptions
340
+ attr_accessor :script_binding
341
+ attr_accessor :script_engine
342
+ attr_accessor :user_input
343
+ attr_accessor :prompt_id
344
+ attr_reader :script_status
345
+ attr_accessor :execute_while_paused_info
346
+
347
+ # This REGEX is also found in scripts_controller.rb
348
+ # Matches the following test cases:
349
+ # class MySuite < TestSuite
350
+ # class MySuite < OpenC3::Suite
351
+ # class MySuite < Cosmos::TestSuite
352
+ # class MySuite < Suite # comment
353
+ # # class MySuite < Suite # <-- doesn't match commented out
354
+ SUITE_REGEX = /^(\s*)?class\s+\w+\s+<\s+(Cosmos::|OpenC3::)?(Suite|TestSuite)/
355
+
356
+ @@instance = nil
357
+ @@message_log = nil
358
+ @@run_thread = nil
359
+ @@breakpoints = {}
360
+ @@line_delay = 0.1
361
+ @@max_output_characters = 50000
362
+ @@instrumented_cache = {}
363
+ @@file_cache = {}
364
+ @@output_thread = nil
365
+ @@pause_on_error = true
366
+ @@error = nil
367
+ @@output_sleeper = OpenC3::Sleeper.new
368
+ @@cancel_output = false
369
+
370
+ def self.message_log
371
+ return @@message_log if @@message_log
372
+
373
+ if @@instance
374
+ scope = @@instance.scope
375
+ tags = [File.basename(@@instance.filename, '.rb').gsub(/(\s|\W)/, '_')]
376
+ else
377
+ scope = $openc3_scope
378
+ tags = []
379
+ end
380
+ @@message_log = OpenC3::MessageLog.new("sr", File.join(RAILS_ROOT, 'log'), tags: tags, scope: scope)
381
+ end
382
+
383
+ def message_log
384
+ self.class.message_log
385
+ end
386
+
387
+ def self.spawn(scope, name, suite_runner = nil, disconnect = false, environment = nil, user_full_name = nil, username = nil, line_no = nil, end_line_no = nil)
388
+ extension = File.extname(name).to_s.downcase
389
+ script_engine = nil
390
+ if extension == '.py'
391
+ process_name = 'python'
392
+ runner_path = File.join(RAILS_ROOT, 'scripts', 'run_script.py')
393
+ elsif extension == '.rb'
394
+ process_name = 'ruby'
395
+ runner_path = File.join(RAILS_ROOT, 'scripts', 'run_script.rb')
396
+ else
397
+ raise "Suite Runner is not supported for this file type: #{extension}" if suite_runner
398
+
399
+ # Extension possibly supported by a script engine
400
+ script_engine_model = OpenC3::ScriptEngineModel.get_model(name: extension, scope: scope)
401
+ if script_engine_model
402
+ script_engine = script_engine_model.filename
403
+ if File.extname(script_engine).to_s.downcase == '.py'
404
+ process_name = 'python'
405
+ runner_path = File.join(RAILS_ROOT, 'scripts', 'run_script.py')
406
+ else
407
+ process_name = 'ruby'
408
+ runner_path = File.join(RAILS_ROOT, 'scripts', 'run_script.rb')
409
+ end
410
+ else
411
+ raise "Unsupported script file type: #{extension}"
412
+ end
413
+ end
414
+
415
+ running_script_id = OpenC3::Store.incr('running-script-id')
416
+
417
+ # COSMOS Core username (Enterprise has the actual name)
418
+ username ||= 'Anonymous'
419
+ # COSMOS Core full name (Enterprise has the actual name)
420
+ user_full_name ||= 'Anonymous'
421
+ start_time = Time.now.utc.iso8601
422
+
423
+ process = ChildProcess.build(process_name, runner_path.to_s, running_script_id.to_s, scope)
424
+ process.io.inherit! # Helps with debugging
425
+ process.cwd = File.join(RAILS_ROOT, 'scripts')
426
+
427
+ # Check for offline access token
428
+ model = nil
429
+ model = OpenC3::OfflineAccessModel.get_model(name: username, scope: scope) if username != 'Anonymous'
430
+
431
+ # Load the global environment variables
432
+ status_environment = {}
433
+ values = OpenC3::EnvironmentModel.all(scope: scope).values
434
+ values.each do |env|
435
+ process.environment[env['key']] = env['value']
436
+ status_environment[env['key']] = env['value']
437
+ end
438
+ # Load the script specific ENV vars set by the GUI
439
+ # These can override the previously defined global env vars
440
+ if environment
441
+ environment.each do |env|
442
+ process.environment[env['key']] = env['value']
443
+ status_environment[env['key']] = env['value']
444
+ end
445
+ end
446
+
447
+ script_status = OpenC3::ScriptStatusModel.new(
448
+ name: running_script_id.to_s, # Unique id for this script
449
+ state: 'spawning', # State will be spawning until the script is running
450
+ shard: 0, # Future enhancement of script runner shards
451
+ filename: name, # Initial filename never changes
452
+ current_filename: name, # Current filename updates while we are running
453
+ line_no: 0, # 0 means not running yet
454
+ start_line_no: line_no || 1, # Line number to start running the script
455
+ end_line_no: end_line_no || nil, # Line number to stop running the script
456
+ username: username, # username of the person who started the script
457
+ user_full_name: user_full_name, # full name of the person who started the script
458
+ start_time: start_time, # Time the script started ISO format
459
+ end_time: nil, # Time the script ended ISO format
460
+ disconnect: disconnect, # Disconnect is set to true if the script is running in a disconnected mode
461
+ environment: status_environment.as_json(:allow_nan => true).to_json(:allow_nan => true), # nil or Hash of key/value pairs for environment variables
462
+ suite_runner: suite_runner ? suite_runner.as_json(:allow_nan => true).to_json(:allow_nan => true) : nil,
463
+ errors: nil, # array of errors that occurred during the script run
464
+ pid: nil, # pid of the script process - set by the script itself when it starts
465
+ script_engine: script_engine, # script engine filename
466
+ updated_at: nil, # Set by create/update - ISO format
467
+ scope: scope # Scope of the script
468
+ )
469
+ script_status.create(isoformat: true)
470
+
471
+ # Set proper secrets for running script
472
+ process.environment['SECRET_KEY_BASE'] = nil
473
+ process.environment['OPENC3_REDIS_USERNAME'] = ENV['OPENC3_SR_REDIS_USERNAME']
474
+ process.environment['OPENC3_REDIS_PASSWORD'] = ENV['OPENC3_SR_REDIS_PASSWORD']
475
+ process.environment['OPENC3_BUCKET_USERNAME'] = ENV['OPENC3_SR_BUCKET_USERNAME']
476
+ process.environment['OPENC3_BUCKET_PASSWORD'] = ENV['OPENC3_SR_BUCKET_PASSWORD']
477
+ process.environment['OPENC3_SR_REDIS_USERNAME'] = nil
478
+ process.environment['OPENC3_SR_REDIS_PASSWORD'] = nil
479
+ process.environment['OPENC3_SR_BUCKET_USERNAME'] = nil
480
+ process.environment['OPENC3_SR_BUCKET_PASSWORD'] = nil
481
+ process.environment['OPENC3_API_CLIENT'] = ENV['OPENC3_API_CLIENT']
482
+ if model and model.offline_access_token
483
+ auth = OpenC3::OpenC3KeycloakAuthentication.new(ENV['OPENC3_KEYCLOAK_URL'])
484
+ valid_token = auth.get_token_from_refresh_token(model.offline_access_token)
485
+ if valid_token
486
+ process.environment['OPENC3_API_TOKEN'] = model.offline_access_token
487
+ else
488
+ model.offline_access_token = nil
489
+ model.update
490
+ raise "offline_access token invalid for script"
491
+ end
492
+ else
493
+ process.environment['OPENC3_API_USER'] = ENV['OPENC3_API_USER']
494
+ if ENV['OPENC3_SERVICE_PASSWORD']
495
+ process.environment['OPENC3_API_PASSWORD'] = ENV['OPENC3_SERVICE_PASSWORD']
496
+ else
497
+ raise "No authentication available for script"
498
+ end
499
+ end
500
+ process.environment['GEM_HOME'] = ENV['GEM_HOME']
501
+ process.environment['PYTHONUSERBASE'] = ENV['PYTHONUSERBASE']
502
+
503
+ # Spawned process should not be controlled by same Bundler constraints as spawning process
504
+ ENV.each do |key, _value|
505
+ if key =~ /^BUNDLE/
506
+ process.environment[key] = nil
507
+ end
508
+ end
509
+ process.environment['RUBYOPT'] = nil # Removes loading bundler setup
510
+ process.environment['OPENC3_SCOPE'] = scope
511
+ process.environment['RAILS_ROOT'] = RAILS_ROOT
512
+
513
+ process.detach = true
514
+ process.start
515
+ running_script_id
516
+ end
517
+
518
+ def initialize(script_status)
519
+ @@instance = self
520
+ @script_status = script_status
521
+ @script_status.pid = Process.pid
522
+ @user_input = ''
523
+ @prompt_id = nil
524
+ @line_offset = 0
525
+ @output_io = StringIO.new('', 'r+')
526
+ @output_io_mutex = Mutex.new
527
+ @continue_after_error = true
528
+ @debug_text = nil
529
+ @debug_history = []
530
+ @debug_code_completion = nil
531
+ @output_time = Time.now.sys
532
+
533
+ initialize_variables()
534
+ update_running_script_store("init")
535
+ redirect_io() # Redirect $stdout and $stderr
536
+ mark_breakpoints(@script_status.filename)
537
+ disconnect_script() if @script_status.disconnect
538
+
539
+ @script_engine = nil
540
+ if @script_status.script_engine
541
+ klass = OpenC3.require_class(@script_status.script_engine)
542
+ @script_engine = klass.new(self)
543
+ end
544
+
545
+ # Retrieve file
546
+ @body = ::Script.body(@script_status.scope, @script_status.filename)
547
+ raise "Script not found: #{@script_status.filename}" if @body.nil?
548
+ breakpoints = @@breakpoints[@script_status.filename]&.filter { |_, present| present }&.map { |line_number, _| line_number - 1 } # -1 because frontend lines are 0-indexed
549
+ breakpoints ||= []
550
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :file, filename: @script_status.filename, scope: @script_status.scope, text: @body.to_utf8, breakpoints: breakpoints })
551
+ if not @script_status.script_engine and @body =~ SUITE_REGEX
552
+ # Process the suite file in this context so we can load it
553
+ # TODO: Do we need to worry about success or failure of the suite processing?
554
+ ::Script.process_suite(@script_status.filename, @body, new_process: false, scope: @script_status.scope)
555
+ # Call load_utility to parse the suite and allow for individual methods to be executed
556
+ load_utility(@script_status.filename)
557
+ end
558
+ end
559
+
560
+ # Called to update the running script state every time the state or line_no changes
561
+ def update_running_script_store(state = nil)
562
+ @script_status.state = state if state
563
+ @script_status.update(queued: true)
564
+ end
565
+
566
+ def parse_options(options)
567
+ settings = {}
568
+ if options.include?('manual')
569
+ settings['Manual'] = true
570
+ $manual = true
571
+ else
572
+ settings['Manual'] = false
573
+ $manual = false
574
+ end
575
+ if options.include?('pauseOnError')
576
+ settings['Pause on Error'] = true
577
+ @@pause_on_error = true
578
+ else
579
+ settings['Pause on Error'] = false
580
+ @@pause_on_error = false
581
+ end
582
+ if options.include?('continueAfterError')
583
+ settings['Continue After Error'] = true
584
+ @continue_after_error = true
585
+ else
586
+ settings['Continue After Error'] = false
587
+ @continue_after_error = false
588
+ end
589
+ if options.include?('abortAfterError')
590
+ settings['Abort After Error'] = true
591
+ OpenC3::Test.abort_on_exception = true
592
+ else
593
+ settings['Abort After Error'] = false
594
+ OpenC3::Test.abort_on_exception = false
595
+ end
596
+ if options.include?('loop')
597
+ settings['Loop'] = true
598
+ else
599
+ settings['Loop'] = false
600
+ end
601
+ if options.include?('breakLoopOnError')
602
+ settings['Break Loop On Error'] = true
603
+ else
604
+ settings['Break Loop On Error'] = false
605
+ end
606
+ OpenC3::SuiteRunner.settings = settings
607
+ end
608
+
609
+ # Let the script continue pausing if in step mode
610
+ def continue
611
+ @go = true
612
+ @pause = true if @step
613
+ end
614
+
615
+ # Sets step mode and lets the script continue but with pause set
616
+ def step
617
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :step, filename: @script_status.current_filename, line_no: @script_status.line_no, state: @script_status.state })
618
+ @step = true
619
+ @go = true
620
+ @pause = true
621
+ end
622
+
623
+ # Clears step mode and lets the script continue
624
+ def go
625
+ @step = false
626
+ @go = true
627
+ @pause = false
628
+ end
629
+
630
+ def go?
631
+ temp = @go
632
+ @go = false
633
+ temp
634
+ end
635
+
636
+ def pause
637
+ @pause = true
638
+ @go = false
639
+ end
640
+
641
+ def pause?
642
+ @pause
643
+ end
644
+
645
+ def stop
646
+ if @@run_thread
647
+ @stop = true
648
+ @script_status.end_time = Time.now.utc.iso8601
649
+ update_running_script_store("stopped")
650
+ OpenC3.kill_thread(self, @@run_thread)
651
+ @@run_thread = nil
652
+ end
653
+ end
654
+
655
+ def stop?
656
+ @stop
657
+ end
658
+
659
+ def clear_prompt
660
+ # Allow things to continue once the prompt is cleared
661
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :script, prompt_complete: @prompt_id })
662
+ @prompt_id = nil
663
+ end
664
+
665
+ # Private methods
666
+
667
+ def graceful_kill
668
+ @stop = true
669
+ end
670
+
671
+ def initialize_variables
672
+ @@error = nil
673
+ @go = false
674
+ @pause = false
675
+ @step = false
676
+ @stop = false
677
+ @retry_needed = false
678
+ @use_instrumentation = true
679
+ @call_stack = []
680
+ @pre_line_time = Time.now.sys
681
+ @exceptions = nil
682
+ @script_binding = nil
683
+ @inline_eval = nil
684
+ @script_status.current_filename = @script_status.filename
685
+ @script_status.line_no = 0
686
+ @current_file = nil
687
+ @execute_while_paused_info = nil
688
+ end
689
+
690
+ def unique_filename
691
+ if @script_status.filename and !@script_status.filename.empty?
692
+ return @script_status.filename
693
+ else
694
+ return "Untitled" + @script_status.id.to_s
695
+ end
696
+ end
697
+
698
+ def stop_message_log
699
+ metadata = {
700
+ "id" => @script_status.id,
701
+ "user" => @script_status.username,
702
+ "scriptname" => unique_filename()
703
+ }
704
+ if @@message_log
705
+ @script_status.log = @@message_log.stop(true, metadata: metadata)
706
+ @script_status.update()
707
+ end
708
+ @@message_log = nil
709
+ end
710
+
711
+ def self.instance
712
+ @@instance
713
+ end
714
+
715
+ def self.instance=(value)
716
+ @@instance = value
717
+ end
718
+
719
+ def self.line_delay
720
+ @@line_delay
721
+ end
722
+
723
+ def self.line_delay=(value)
724
+ @@line_delay = value
725
+ end
726
+
727
+ def self.max_output_characters
728
+ @@max_output_characters
729
+ end
730
+
731
+ def self.max_output_characters=(value)
732
+ @@max_output_characters = value
733
+ end
734
+
735
+ def self.breakpoints
736
+ @@breakpoints
737
+ end
738
+
739
+ def self.instrumented_cache
740
+ @@instrumented_cache
741
+ end
742
+
743
+ def self.instrumented_cache=(value)
744
+ @@instrumented_cache = value
745
+ end
746
+
747
+ def self.file_cache
748
+ @@file_cache
749
+ end
750
+
751
+ def self.file_cache=(value)
752
+ @@file_cache = value
753
+ end
754
+
755
+ def self.pause_on_error
756
+ @@pause_on_error
757
+ end
758
+
759
+ def self.pause_on_error=(value)
760
+ @@pause_on_error = value
761
+ end
762
+
763
+ def text
764
+ @body
765
+ end
766
+
767
+ def retry_needed
768
+ @retry_needed = true
769
+ end
770
+
771
+ def run
772
+ if @script_status.suite_runner
773
+ @script_status.suite_runner = JSON.parse(@script_status.suite_runner, :allow_nan => true, :create_additions => true) # Convert to hash
774
+ parse_options(@script_status.suite_runner['options'])
775
+ if @script_status.suite_runner['script']
776
+ run_text("OpenC3::SuiteRunner.start(#{@script_status.suite_runner['suite']}, #{@script_status.suite_runner['group']}, '#{@script_status.suite_runner['script']}')", initial_filename: "SCRIPTRUNNER")
777
+ elsif script_status.suite_runner['group']
778
+ run_text("OpenC3::SuiteRunner.#{@script_status.suite_runner['method']}(#{@script_status.suite_runner['suite']}, #{@script_status.suite_runner['group']})", initial_filename: "SCRIPTRUNNER")
779
+ else
780
+ run_text("OpenC3::SuiteRunner.#{@script_status.suite_runner['method']}(#{@script_status.suite_runner['suite']})", initial_filename: "SCRIPTRUNNER")
781
+ end
782
+ else
783
+ if not @script_engine and (@script_status.start_line_no != 1 or !@script_status.end_line_no.nil?)
784
+ run_text("", initial_filename: "SCRIPTRUNNER")
785
+ else
786
+ run_text(@body)
787
+ end
788
+ end
789
+ end
790
+
791
+ def self.instrument_script(text, filename, mark_private = false, line_offset: 0, cache: true)
792
+ if cache and filename and !filename.empty?
793
+ @@file_cache[filename] = text.clone
794
+ end
795
+
796
+ ruby_lex_utils = RubyLexUtils.new
797
+ instrumented_text = ''
798
+
799
+ @cancel_instrumentation = false
800
+ num_lines = text.num_lines.to_f
801
+ num_lines = 1 if num_lines < 1
802
+ instrumented_text =
803
+ instrument_script_implementation(ruby_lex_utils,
804
+ text,
805
+ num_lines,
806
+ filename,
807
+ mark_private,
808
+ line_offset)
809
+
810
+ raise OpenC3::StopScript if @cancel_instrumentation
811
+ instrumented_text
812
+ end
813
+
814
+ def self.instrument_script_implementation(ruby_lex_utils,
815
+ text,
816
+ _num_lines,
817
+ filename,
818
+ mark_private = false,
819
+ line_offset = 0)
820
+ if mark_private
821
+ instrumented_text = 'private; '
822
+ else
823
+ instrumented_text = ''
824
+ end
825
+
826
+ ruby_lex_utils.each_lexed_segment(text) do |segment, instrumentable, inside_begin, line_no|
827
+ return nil if @cancel_instrumentation
828
+ instrumented_line = ''
829
+ if instrumentable
830
+ # Add a newline if it's empty to ensure the instrumented code has
831
+ # the same number of lines as the original script
832
+ if segment.strip.empty?
833
+ instrumented_text << "\n"
834
+ next
835
+ end
836
+
837
+ # Create a variable to hold the segment's return value
838
+ instrumented_line << "__return_val = nil; "
839
+
840
+ # If not inside a begin block then create one to catch exceptions
841
+ unless inside_begin
842
+ instrumented_line << 'begin; '
843
+ end
844
+
845
+ # Add preline instrumentation
846
+ instrumented_line << "RunningScript.instance.script_binding = binding(); "\
847
+ "RunningScript.instance.pre_line_instrumentation('#{filename}', #{line_no + line_offset}); "
848
+
849
+ # Add the actual line
850
+ instrumented_line << "__return_val = begin; "
851
+ instrumented_line << segment
852
+ instrumented_line.chomp!
853
+
854
+ # Add postline instrumentation
855
+ instrumented_line << " end; RunningScript.instance.post_line_instrumentation('#{filename}', #{line_no + line_offset}); "
856
+
857
+ # Complete begin block to catch exceptions
858
+ unless inside_begin
859
+ instrumented_line << "rescue Exception => eval_error; "\
860
+ "retry if RunningScript.instance.exception_instrumentation(eval_error, '#{filename}', #{line_no + line_offset}); end; "
861
+ end
862
+
863
+ instrumented_line << " __return_val\n"
864
+ else
865
+ unless segment =~ /^\s*end\s*$/ or segment =~ /^\s*when .*$/
866
+ num_left_brackets = segment.count('{')
867
+ num_right_brackets = segment.count('}')
868
+ num_left_square_brackets = segment.count('[')
869
+ num_right_square_brackets = segment.count(']')
870
+
871
+ if (num_right_brackets > num_left_brackets) ||
872
+ (num_right_square_brackets > num_left_square_brackets)
873
+ instrumented_line = segment
874
+ else
875
+ instrumented_line = "RunningScript.instance.pre_line_instrumentation('#{filename}', #{line_no + line_offset}); " + segment
876
+ end
877
+ else
878
+ instrumented_line = segment
879
+ end
880
+ end
881
+
882
+ instrumented_text << instrumented_line
883
+ end
884
+ instrumented_text
885
+ end
886
+
887
+ def pre_line_instrumentation(filename, line_number)
888
+ @pre_line_time = Time.now.sys
889
+ @script_status.current_filename = filename
890
+ @script_status.line_no = line_number
891
+ if @use_instrumentation
892
+ # Clear go
893
+ @go = false
894
+
895
+ # Handle stopping mid-script if necessary
896
+ raise OpenC3::StopScript if @stop
897
+
898
+ handle_potential_tab_change(filename)
899
+
900
+ # Adjust line number for offset in main script
901
+ line_number = line_number + @line_offset
902
+ detail_string = nil
903
+ if filename
904
+ detail_string = File.basename(filename) << ':' << line_number.to_s
905
+ OpenC3::Logger.detail_string = detail_string
906
+ end
907
+
908
+ update_running_script_store("running")
909
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :line, filename: @script_status.current_filename, line_no: @script_status.line_no, state: @script_status.state })
910
+ handle_pause(filename, line_number)
911
+ handle_line_delay()
912
+ end
913
+ end
914
+
915
+ def post_line_instrumentation(filename, line_number)
916
+ if @use_instrumentation
917
+ line_number = line_number + @line_offset
918
+ handle_output_io(filename, line_number)
919
+ end
920
+ end
921
+
922
+ def exception_instrumentation(error, filename, line_number)
923
+ if error.class <= OpenC3::StopScript || error.class <= OpenC3::SkipScript || !@use_instrumentation
924
+ raise error
925
+ elsif !error.eql?(@@error)
926
+ line_number = line_number + @line_offset
927
+ handle_exception(error, false, filename, line_number)
928
+ end
929
+ end
930
+
931
+ def perform_wait(prompt)
932
+ mark_waiting()
933
+ wait_for_go_or_stop(prompt: prompt)
934
+ end
935
+
936
+ def perform_pause
937
+ mark_paused()
938
+ wait_for_go_or_stop()
939
+ end
940
+
941
+ def perform_breakpoint(filename, line_number)
942
+ mark_breakpoint()
943
+ scriptrunner_puts "Hit Breakpoint at #{filename}:#{line_number}"
944
+ handle_output_io(filename, line_number)
945
+ wait_for_go_or_stop()
946
+ end
947
+
948
+ def debug(debug_text)
949
+ handle_output_io()
950
+
951
+ use_script_engine = false
952
+ extension = File.extname(current_filename()).to_s.downcase
953
+ if @script_engine and extension != ".py"
954
+ use_script_engine = true
955
+ end
956
+
957
+ if not use_script_engine
958
+ if @script_binding
959
+ # Check for accessing an instance variable or local
960
+ if debug_text =~ /^@\S+$/ || @script_binding.local_variables.include?(debug_text.to_sym)
961
+ debug_text = "puts #{debug_text}" # Automatically add puts to print it
962
+ end
963
+ eval(debug_text, @script_binding, 'debug', 1)
964
+ else
965
+ Object.class_eval(debug_text, 'debug', 1)
966
+ end
967
+ else
968
+ @script_engine.debug(debug_text)
969
+ end
970
+
971
+ handle_output_io()
972
+ rescue Exception => e
973
+ if e.class == DRb::DRbConnError
974
+ OpenC3::Logger.error("Error Connecting to Command and Telemetry Server")
975
+ else
976
+ OpenC3::Logger.error(e.formatted)
977
+ end
978
+ handle_output_io()
979
+ end
980
+
981
+ def self.set_breakpoint(filename, line_number)
982
+ @@breakpoints[filename] ||= {}
983
+ @@breakpoints[filename][line_number] = true
984
+ end
985
+
986
+ def self.clear_breakpoint(filename, line_number)
987
+ @@breakpoints[filename] ||= {}
988
+ @@breakpoints[filename].delete(line_number) if @@breakpoints[filename][line_number]
989
+ end
990
+
991
+ def self.clear_breakpoints(filename = nil)
992
+ if filename == nil or filename.empty?
993
+ @@breakpoints = {}
994
+ else
995
+ @@breakpoints.delete(filename)
996
+ end
997
+ end
998
+
999
+ def clear_breakpoints
1000
+ ScriptRunnerFrame.clear_breakpoints(unique_filename())
1001
+ end
1002
+
1003
+ def current_backtrace
1004
+ trace = []
1005
+ if @@run_thread
1006
+ temp_trace = @@run_thread.backtrace
1007
+ temp_trace.each do |line|
1008
+ next if line.include?(OpenC3::PATH) # Ignore OpenC3 internals
1009
+ next if line.include?('lib/ruby/gems') # Ignore system gems
1010
+ next if line.include?('app/models/running_script') # Ignore this file
1011
+ trace << line
1012
+ end
1013
+ end
1014
+ trace
1015
+ end
1016
+
1017
+ def execute_while_paused(filename, line_no = 1, end_line_no = nil)
1018
+ if @script_status.state == 'paused' or @script_status.state == 'error' or @script_status.state == 'breakpoint'
1019
+ @execute_while_paused_info = { filename: filename, line_no: line_no, end_line_no: end_line_no }
1020
+ else
1021
+ scriptrunner_puts("Cannot execute selection or goto unless script is paused, breakpoint, or in error state")
1022
+ end
1023
+ end
1024
+
1025
+ def scriptrunner_puts(string, color = 'BLACK')
1026
+ line_to_write = Time.now.sys.formatted + " (SCRIPTRUNNER): " + string
1027
+ $stdout.puts line_to_write
1028
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :output, line: line_to_write, color: color })
1029
+ end
1030
+
1031
+ def handle_output_io(filename = nil, line_number = nil)
1032
+ filename = @script_status.current_filename if filename.nil?
1033
+ line_number = @script_status.line_no if line_number.nil?
1034
+
1035
+ @output_time = Time.now.sys
1036
+ if @output_io.string[-1..-1] == "\n"
1037
+ time_formatted = Time.now.sys.formatted
1038
+ color = 'BLACK'
1039
+ lines_to_write = ''
1040
+ out_line_number = line_number.to_s
1041
+ out_filename = File.basename(filename) if filename
1042
+
1043
+ # Build each line to write
1044
+ string = @output_io.string.clone
1045
+ @output_io.string = @output_io.string[string.length..-1]
1046
+ line_count = 0
1047
+ string.each_line(chomp: true) do |out_line|
1048
+ begin
1049
+ json = JSON.parse(out_line, :allow_nan => true, :create_additions => true)
1050
+ time_formatted = Time.parse(json["@timestamp"]).sys.formatted if json["@timestamp"]
1051
+ if json["log"]
1052
+ out_line = json["log"]
1053
+ elsif json["message"]
1054
+ out_line = json["message"]
1055
+ end
1056
+ rescue
1057
+ # Regular output
1058
+ end
1059
+
1060
+ if out_line.length >= 25 and out_line[0..1] == '20' and out_line[10] == ' ' and out_line[23..24] == ' ('
1061
+ line_to_write = out_line
1062
+ else
1063
+ if filename
1064
+ line_to_write = time_formatted + " (#{out_filename}:#{out_line_number}): " + out_line
1065
+ else
1066
+ line_to_write = time_formatted + " (SCRIPTRUNNER): " + out_line
1067
+ color = 'BLUE'
1068
+ end
1069
+ end
1070
+ lines_to_write << (line_to_write + "\n")
1071
+ line_count += 1
1072
+ end # string.each_line
1073
+
1074
+ if lines_to_write.length > @@max_output_characters
1075
+ # We want the full @@max_output_characters so don't subtract the additional "ERROR: ..." text
1076
+ published_lines = lines_to_write[0...@@max_output_characters]
1077
+ published_lines << "\nERROR: Too much to publish. Truncating #{lines_to_write.length} characters of output to #{@@max_output_characters} characters.\n"
1078
+ else
1079
+ published_lines = lines_to_write
1080
+ end
1081
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :output, line: published_lines.as_json(:allow_nan => true), color: color })
1082
+ # Add to the message log
1083
+ message_log.write(lines_to_write)
1084
+ end
1085
+ end
1086
+
1087
+ def graceful_kill
1088
+ # Just to avoid warning
1089
+ end
1090
+
1091
+ def wait_for_go_or_stop(error = nil, prompt: nil)
1092
+ count = -1
1093
+ @go = false
1094
+ @prompt_id = prompt['id'] if prompt
1095
+ until (@go or @stop)
1096
+ check_execute_while_paused()
1097
+ sleep(0.01)
1098
+ count += 1
1099
+ if count % 100 == 0 # Approximately Every Second
1100
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :line, filename: @script_status.current_filename, line_no: @script_status.line_no, state: @script_status.state })
1101
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :script, method: prompt['method'], prompt_id: prompt['id'], args: prompt['args'], kwargs: prompt['kwargs'] }) if prompt
1102
+ end
1103
+ end
1104
+ clear_prompt() if prompt
1105
+ RunningScript.instance.prompt_id = nil
1106
+ @go = false
1107
+ mark_running()
1108
+ raise OpenC3::StopScript if @stop
1109
+ raise error if error and !@continue_after_error
1110
+ end
1111
+
1112
+ def wait_for_go_or_stop_or_retry(error = nil)
1113
+ count = 0
1114
+ @go = false
1115
+ until (@go or @stop or @retry_needed)
1116
+ check_execute_while_paused()
1117
+ sleep(0.01)
1118
+ count += 1
1119
+ if (count % 100) == 0 # Approximately Every Second
1120
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :line, filename: @script_status.current_filename, line_no: @script_status.line_no, state: @script_status.state })
1121
+ end
1122
+ end
1123
+ @go = false
1124
+ mark_running()
1125
+ raise OpenC3::StopScript if @stop
1126
+ raise error if error and !@continue_after_error
1127
+ end
1128
+
1129
+ def check_execute_while_paused
1130
+ if @execute_while_paused_info
1131
+ if @script_status.current_filename == @execute_while_paused_info[:filename]
1132
+ bind_variables = true
1133
+ else
1134
+ bind_variables = false
1135
+ end
1136
+ if @execute_while_paused_info[:end_line_no]
1137
+ # Execute Selection While Paused
1138
+ state = @script_status.state
1139
+ current_filename = @script_status.current_filename
1140
+ line_no = @script_status.line_no
1141
+ start(@execute_while_paused_info[:filename], line_no: @execute_while_paused_info[:line_no], end_line_no: @execute_while_paused_info[:end_line_no], bind_variables: bind_variables)
1142
+ # Need to restore state after returning so that the correct line will be shown in ScriptRunner
1143
+ @script_status.state = state
1144
+ @script_status.current_filename = current_filename
1145
+ @script_status.line_no = line_no
1146
+ @script_status.update(queued: true)
1147
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :line, filename: @script_status.current_filename, line_no: @script_status.line_no, state: @script_status.state })
1148
+ else
1149
+ # Goto While Paused
1150
+ start(@execute_while_paused_info[:filename], line_no: @execute_while_paused_info[:line_no], bind_variables: bind_variables, complete: true)
1151
+ end
1152
+ end
1153
+ ensure
1154
+ @execute_while_paused_info = nil
1155
+ end
1156
+
1157
+ def mark_running
1158
+ update_running_script_store("running")
1159
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :line, filename: @script_status.current_filename, line_no: @script_status.line_no, state: @script_status.state })
1160
+ end
1161
+
1162
+ def mark_paused
1163
+ update_running_script_store("paused")
1164
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :line, filename: @script_status.current_filename, line_no: @script_status.line_no, state: @script_status.state })
1165
+ end
1166
+
1167
+ def mark_waiting
1168
+ update_running_script_store("waiting")
1169
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :line, filename: @script_status.current_filename, line_no: @script_status.line_no, state: @script_status.state })
1170
+ end
1171
+
1172
+ def mark_error
1173
+ update_running_script_store("error")
1174
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :line, filename: @script_status.current_filename, line_no: @script_status.line_no, state: @script_status.state })
1175
+ end
1176
+
1177
+ def mark_crashed
1178
+ @script_status.end_time = Time.now.utc.iso8601
1179
+ update_running_script_store("crashed")
1180
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :line, filename: @script_status.current_filename, line_no: @script_status.line_no, state: @script_status.state })
1181
+ end
1182
+
1183
+ def mark_completed
1184
+ @script_status.end_time = Time.now.utc.iso8601
1185
+ update_running_script_store("completed")
1186
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :line, filename: @script_status.current_filename, line_no: @script_status.line_no, state: @script_status.state })
1187
+ if OpenC3::SuiteRunner.suite_results
1188
+ OpenC3::SuiteRunner.suite_results.complete
1189
+ # context looks like the following:
1190
+ # MySuite:ExampleGroup:script_2
1191
+ # MySuite:ExampleGroup Manual Setup
1192
+ # MySuite Manual Teardown
1193
+ init_split = OpenC3::SuiteRunner.suite_results.context.split()
1194
+ parts = init_split[0].split(':')
1195
+ if parts[2]
1196
+ # Remove test_ or script_ because it doesn't add any info
1197
+ parts[2] = parts[2].sub(/^test_/, '').sub(/^script_/, '')
1198
+ end
1199
+ parts.map! { |part| part[0..9] } # Only take the first 10 characters to prevent huge filenames
1200
+ # If the initial split on whitespace has more than 1 item it means
1201
+ # a Manual Setup or Teardown was performed. Add this to the filename.
1202
+ # NOTE: We're doing this here with a single underscore to preserve
1203
+ # double underscores as Suite, Group, Script delimiters
1204
+ if parts[1] and init_split.length > 1
1205
+ parts[1] += "_#{init_split[-1]}"
1206
+ elsif parts[0] and init_split.length > 1
1207
+ parts[0] += "_#{init_split[-1]}"
1208
+ end
1209
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :report, report: OpenC3::SuiteRunner.suite_results.report })
1210
+ # Write out the report to a local file
1211
+ log_dir = File.join(RAILS_ROOT, 'log')
1212
+ filename = File.join(log_dir, File.build_timestamped_filename(['sr', parts.join('__')]))
1213
+ File.open(filename, 'wb') do |file|
1214
+ file.write(OpenC3::SuiteRunner.suite_results.report)
1215
+ end
1216
+ # Generate the bucket key by removing the date underscores in the filename to create the bucket file structure
1217
+ bucket_key = File.join("#{@script_status.scope}/tool_logs/sr/", File.basename(filename)[0..9].gsub("_", ""), File.basename(filename))
1218
+ metadata = {
1219
+ # Note: The chars '(' and ')' are used by RunningScripts.vue to differentiate between script logs
1220
+ "id" => @script_status.id,
1221
+ "user" => @script_status.username,
1222
+ "scriptname" => "#{@script_status.current_filename} (#{OpenC3::SuiteRunner.suite_results.context.strip})"
1223
+ }
1224
+ thread = OpenC3::BucketUtilities.move_log_file_to_bucket(filename, bucket_key, metadata: metadata)
1225
+ # Wait for the file to get moved to S3 because after this the process will likely die
1226
+ @script_status.report = bucket_key
1227
+ @script_status.update(queued: true)
1228
+ thread.join
1229
+ end
1230
+ running_script_publish("cmd-running-script-channel:#{@script_status.id}", "shutdown")
1231
+ end
1232
+
1233
+ def mark_breakpoint
1234
+ update_running_script_store("breakpoint")
1235
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :line, filename: @script_status.current_filename, line_no: @script_status.line_no, state: @script_status.state })
1236
+ end
1237
+
1238
+ def run_text(text,
1239
+ initial_filename: nil)
1240
+ initialize_variables()
1241
+ saved_instance = @@instance
1242
+ saved_run_thread = @@run_thread
1243
+ @@instance = self
1244
+
1245
+ @@run_thread = Thread.new do
1246
+ begin
1247
+ # Capture STDOUT and STDERR
1248
+ $stdout.add_stream(@output_io)
1249
+ $stderr.add_stream(@output_io)
1250
+
1251
+ output = "Starting script: #{File.basename(@script_status.filename)}"
1252
+ output += " in DISCONNECT mode" if $disconnect
1253
+ output += ", line_delay = #{@@line_delay}"
1254
+ scriptrunner_puts(output)
1255
+ handle_output_io()
1256
+
1257
+ # Start Output Thread
1258
+ @@output_thread = Thread.new { output_thread() } unless @@output_thread
1259
+
1260
+ if @script_engine
1261
+ if @script_status.start_line_no != 1 or !@script_status.end_line_no.nil?
1262
+ if @script_status.end_line_no.nil?
1263
+ # Goto line
1264
+ start(@script_status.filename, line_no: @script_status.start_line_no, complete: true)
1265
+ else
1266
+ # Execute selection
1267
+ start(@script_status.filename, line_no: @script_status.start_line_no, end_line_no: @script_status.end_line_no, complete: true)
1268
+ end
1269
+ else
1270
+ @script_engine.run_text(text, filename: @script_status.filename)
1271
+ end
1272
+ else
1273
+ if initial_filename == 'SCRIPTRUNNER'
1274
+ # Don't instrument pseudo scripts
1275
+ instrument_filename = initial_filename
1276
+ instrumented_script = text
1277
+ else
1278
+ # Instrument everything else
1279
+ instrument_filename = @script_status.filename
1280
+ instrument_filename = initial_filename if initial_filename
1281
+ instrumented_script = self.class.instrument_script(text, instrument_filename, true)
1282
+ end
1283
+
1284
+ # Execute the script with warnings disabled
1285
+ OpenC3.disable_warnings do
1286
+ @pre_line_time = Time.now.sys
1287
+ Object.class_eval(instrumented_script, instrument_filename, 1)
1288
+ end
1289
+ end
1290
+
1291
+ handle_output_io()
1292
+ scriptrunner_puts "Script completed: #{@script_status.filename}"
1293
+
1294
+ rescue Exception => e # rubocop:disable Lint/RescueException
1295
+ if e.class <= OpenC3::StopScript or e.class <= OpenC3::SkipScript
1296
+ handle_output_io()
1297
+ scriptrunner_puts "Script stopped: #{@script_status.filename}"
1298
+ else
1299
+ filename, line_number = e.source
1300
+ handle_exception(e, true, filename, line_number)
1301
+ handle_output_io()
1302
+ scriptrunner_puts "Exception in Control Statement - Script stopped: #{@script_status.filename}"
1303
+ mark_crashed()
1304
+ end
1305
+ ensure
1306
+ # Stop Capturing STDOUT and STDERR
1307
+ # Check for remove_stream because if the tool is quitting the
1308
+ # OpenC3::restore_io may have been called which sets $stdout and
1309
+ # $stderr to the IO constant
1310
+ $stdout.remove_stream(@output_io) if $stdout.respond_to? :remove_stream
1311
+ $stderr.remove_stream(@output_io) if $stderr.respond_to? :remove_stream
1312
+
1313
+ # Clear run thread and instance to indicate we are no longer running
1314
+ @@instance = saved_instance
1315
+ @@run_thread = saved_run_thread
1316
+ @active_script = @script
1317
+ @script_binding = nil
1318
+ # Set the current_filename to the original file and the line_no to 0
1319
+ # so the mark_complete method will signal the frontend to reset to the original
1320
+ @script_status.current_filename = @script_status.filename
1321
+ @script_status.line_no = 0
1322
+ if @@output_thread and not @@instance
1323
+ @@cancel_output = true
1324
+ @@output_sleeper.cancel
1325
+ OpenC3.kill_thread(self, @@output_thread)
1326
+ @@output_thread = nil
1327
+ end
1328
+ mark_completed()
1329
+ end
1330
+ end
1331
+ end
1332
+
1333
+ def handle_potential_tab_change(filename)
1334
+ # Make sure the correct file is shown in script runner
1335
+ if @current_file != filename
1336
+ if @call_stack.include?(filename)
1337
+ index = @call_stack.index(filename)
1338
+ else # new file
1339
+ @call_stack.push(filename.dup)
1340
+ load_file_into_script(filename)
1341
+ end
1342
+
1343
+ @current_file = filename
1344
+ end
1345
+ end
1346
+
1347
+ def handle_pause(filename, line_number)
1348
+ breakpoint = false
1349
+ breakpoint = true if @@breakpoints[filename] and @@breakpoints[filename][line_number]
1350
+
1351
+ filename = File.basename(filename)
1352
+ if @pause
1353
+ @pause = false unless @step
1354
+ if breakpoint
1355
+ perform_breakpoint(filename, line_number)
1356
+ else
1357
+ perform_pause()
1358
+ end
1359
+ else
1360
+ perform_breakpoint(filename, line_number) if breakpoint
1361
+ end
1362
+ end
1363
+
1364
+ def handle_line_delay
1365
+ if @@line_delay > 0.0
1366
+ sleep_time = @@line_delay - (Time.now.sys - @pre_line_time)
1367
+ sleep(sleep_time) if sleep_time > 0.0
1368
+ end
1369
+ end
1370
+
1371
+ def handle_exception(error, fatal, filename = nil, line_number = 0)
1372
+ @exceptions ||= []
1373
+ @exceptions << error
1374
+ @script_status.errors ||= []
1375
+ @script_status.errors << error.formatted
1376
+ @@error = error
1377
+
1378
+ if error.class == DRb::DRbConnError
1379
+ OpenC3::Logger.error("Error Connecting to Command and Telemetry Server")
1380
+ elsif error.class == OpenC3::CheckError
1381
+ OpenC3::Logger.error(error.message)
1382
+ else
1383
+ OpenC3::Logger.error(error.class.to_s.split('::')[-1] + ' : ' + error.message)
1384
+ if ENV['OPENC3_FULL_BACKTRACE']
1385
+ OpenC3::Logger.error(error.backtrace.join("\n\n"))
1386
+ end
1387
+ end
1388
+ handle_output_io(filename, line_number)
1389
+
1390
+ raise error if !@@pause_on_error and !@continue_after_error and !fatal
1391
+
1392
+ if !fatal and @@pause_on_error
1393
+ mark_error()
1394
+ wait_for_go_or_stop_or_retry(error)
1395
+ end
1396
+
1397
+ if @retry_needed
1398
+ @retry_needed = false
1399
+ true
1400
+ else
1401
+ false
1402
+ end
1403
+ end
1404
+
1405
+ def load_file_into_script(filename)
1406
+ mark_breakpoints(filename)
1407
+ breakpoints = @@breakpoints[filename]&.filter { |_, present| present }&.map { |line_number, _| line_number - 1 } # -1 because frontend lines are 0-indexed
1408
+ breakpoints ||= []
1409
+ cached = @@file_cache[filename]
1410
+ if cached
1411
+ @body = cached
1412
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :file, filename: filename, text: @body.to_utf8, breakpoints: breakpoints })
1413
+ else
1414
+ text = ::Script.body(@script_status.scope, filename)
1415
+ raise "Script not found: #{filename}" if text.nil?
1416
+ @@file_cache[filename] = text
1417
+ @body = text
1418
+ running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :file, filename: filename, text: @body.to_utf8, breakpoints: breakpoints })
1419
+ end
1420
+ end
1421
+
1422
+ def mark_breakpoints(filename)
1423
+ breakpoints = @@breakpoints[filename]
1424
+ if breakpoints
1425
+ breakpoints.each do |line_number, present|
1426
+ RunningScript.set_breakpoint(filename, line_number) if present
1427
+ end
1428
+ else
1429
+ ::Script.get_breakpoints(@script_status.scope, filename).each do |line_number|
1430
+ RunningScript.set_breakpoint(filename, line_number + 1)
1431
+ end
1432
+ end
1433
+ end
1434
+
1435
+ def redirect_io
1436
+ # Redirect Standard Output and Standard Error
1437
+ $stdout = OpenC3::Stdout.instance
1438
+ $stderr = OpenC3::Stderr.instance
1439
+ OpenC3::Logger.stdout = true
1440
+ OpenC3::Logger.level = OpenC3::Logger::INFO
1441
+ end
1442
+
1443
+ def output_thread
1444
+ @@cancel_output = false
1445
+ @@output_sleeper = OpenC3::Sleeper.new
1446
+ begin
1447
+ loop do
1448
+ break if @@cancel_output
1449
+ handle_output_io() if (Time.now.sys - @output_time) > 5.0
1450
+ break if @@cancel_output
1451
+ break if @@output_sleeper.sleep(1.0)
1452
+ end # loop
1453
+ rescue => e
1454
+ # Qt.execute_in_main_thread(true) do
1455
+ # ExceptionDialog.new(self, error, "Output Thread")
1456
+ # end
1457
+ end
1458
+ end
1459
+
1460
+ end