openc3 7.2.1 → 7.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +97 -6
  3. data/bin/pipinstall +5 -4
  4. data/bin/pipuninstall +5 -1
  5. data/bin/uvinstall +149 -0
  6. data/data/config/item_modifiers.yaml +2 -1
  7. data/data/config/microservice.yaml +5 -1
  8. data/data/config/screen.yaml +2 -1
  9. data/data/config/target.yaml +21 -0
  10. data/data/config/tool.yaml +3 -1
  11. data/data/config/widgets.yaml +16 -3
  12. data/data/xtce_schemas/SpaceSystem_20180204.xsd +5918 -0
  13. data/data/xtce_schemas/xml.xsd +116 -0
  14. data/ext/openc3/ext/telemetry/telemetry.c +1 -1
  15. data/lib/openc3/accessors/template_accessor.rb +2 -2
  16. data/lib/openc3/api/interface_api.rb +2 -2
  17. data/lib/openc3/api/limits_api.rb +27 -13
  18. data/lib/openc3/api/router_api.rb +3 -3
  19. data/lib/openc3/api/tlm_api.rb +1 -1
  20. data/lib/openc3/interfaces/http_client_interface.rb +1 -1
  21. data/lib/openc3/io/json_drb.rb +24 -8
  22. data/lib/openc3/io/json_rpc.rb +0 -2
  23. data/lib/openc3/logs/log_writer.rb +43 -7
  24. data/lib/openc3/logs/packet_log_writer.rb +1 -2
  25. data/lib/openc3/microservices/interface_microservice.rb +1 -1
  26. data/lib/openc3/migrations/20260731000000_system_health_json.rb +41 -0
  27. data/lib/openc3/models/cvt_model.rb +5 -5
  28. data/lib/openc3/models/gem_model.rb +1 -1
  29. data/lib/openc3/models/interface_model.rb +1 -1
  30. data/lib/openc3/models/microservice_model.rb +21 -0
  31. data/lib/openc3/models/plugin_model.rb +204 -50
  32. data/lib/openc3/models/python_package_model.rb +207 -11
  33. data/lib/openc3/models/scope_model.rb +34 -30
  34. data/lib/openc3/models/target_model.rb +180 -6
  35. data/lib/openc3/models/tool_model.rb +33 -20
  36. data/lib/openc3/operators/microservice_operator.rb +33 -4
  37. data/lib/openc3/packets/commands.rb +4 -4
  38. data/lib/openc3/packets/limits.rb +15 -7
  39. data/lib/openc3/packets/packet.rb +1 -1
  40. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  41. data/lib/openc3/packets/parsers/xtce_converter.rb +183 -103
  42. data/lib/openc3/packets/parsers/xtce_parser.rb +71 -23
  43. data/lib/openc3/packets/structure.rb +2 -2
  44. data/lib/openc3/packets/telemetry.rb +3 -3
  45. data/lib/openc3/script/commands.rb +1 -1
  46. data/lib/openc3/script/extract.rb +20 -25
  47. data/lib/openc3/script/web_socket_api.rb +44 -4
  48. data/lib/openc3/topics/command_decom_topic.rb +16 -1
  49. data/lib/openc3/topics/limits_event_topic.rb +1 -1
  50. data/lib/openc3/utilities/aws_bucket.rb +10 -2
  51. data/lib/openc3/utilities/csv.rb +5 -5
  52. data/lib/openc3/utilities/local_mode.rb +4 -1
  53. data/lib/openc3/utilities/ruby_lex_utils.rb +5 -1
  54. data/lib/openc3/utilities/running_script.rb +145 -90
  55. data/lib/openc3/utilities/script.rb +46 -10
  56. data/lib/openc3/version.rb +6 -6
  57. data/templates/plugin/plugin.gemspec +1 -1
  58. data/templates/tool_angular/package.json +2 -2
  59. data/templates/tool_react/package.json +1 -1
  60. data/templates/tool_svelte/package.json +1 -1
  61. data/templates/tool_vue/package.json +3 -3
  62. data/templates/widget/package.json +2 -2
  63. metadata +5 -3
  64. data/templates/tool_vue/.nycrc +0 -3
  65. data/templates/widget/.nycrc +0 -3
@@ -59,7 +59,7 @@ module OpenC3
59
59
  def packets(target_name)
60
60
  upcase_target_name = target_name.to_s.upcase
61
61
  target_packets = @config.telemetry[upcase_target_name]
62
- raise "Telemetry target '#{upcase_target_name}' does not exist" unless target_packets
62
+ raise "Telemetry target '#{upcase_target_name}' does not exist (packets lookup)" unless target_packets
63
63
 
64
64
  target_packets
65
65
  end
@@ -177,7 +177,7 @@ module OpenC3
177
177
  if LATEST_PACKET_NAME.casecmp(packet_name).zero?
178
178
  target_upcase = target_name.to_s.upcase
179
179
  target_latest_data = @config.latest_data[target_upcase]
180
- raise "Telemetry Target '#{target_upcase}' does not exist" unless target_latest_data
180
+ raise "Telemetry target '#{target_upcase}' does not exist (item_names lookup)" unless target_latest_data
181
181
 
182
182
  item_names = target_latest_data.keys
183
183
  else
@@ -208,7 +208,7 @@ module OpenC3
208
208
  target_upcase = target_name.to_s.upcase
209
209
  item_upcase = item_name.to_s.upcase
210
210
  target_latest_data = @config.latest_data[target_upcase]
211
- raise "Telemetry target '#{target_upcase}' does not exist" unless target_latest_data
211
+ raise "Telemetry target '#{target_upcase}' does not exist (latest_packets lookup)" unless target_latest_data
212
212
 
213
213
  packets = @config.latest_data[target_upcase][item_upcase]
214
214
  raise "Telemetry item '#{target_upcase} #{LATEST_PACKET_NAME} #{item_upcase}' does not exist" unless packets
@@ -106,7 +106,7 @@ module OpenC3
106
106
  cmd_params.each do |param_name, _param_value|
107
107
  param = command['items'].find { |item| item['name'] == param_name }
108
108
  unless param
109
- raise "Packet item '#{target_name} #{cmd_name} #{param_name}' does not exist"
109
+ raise "Item '#{target_name} #{cmd_name} #{param_name}' does not exist (command parameter validation)"
110
110
  end
111
111
  end
112
112
  _log_cmd(command, raw, no_range, no_hazardous)
@@ -20,7 +20,11 @@ require 'openc3/utilities/store'
20
20
 
21
21
  module OpenC3
22
22
  module Extract
23
- SCANNING_REGULAR_EXPRESSION = %r{ (?:"(?:[^\\"]|\\.)*") | (?:'(?:[^\\']|\\.)*') | (?:\[(?:[^\\\[\]]|\\.)*\]) | \S+ }x # "
23
+ # Tokenizer for command parameters: matches double quoted strings, single quoted strings,
24
+ # bracket delimited arrays (one level of nesting), a bare comma delimiter, or bare words
25
+ # (runs of non-whitespace, non-comma characters). Commas are tokenized separately so
26
+ # whitespace around them is optional.
27
+ SCANNING_REGULAR_EXPRESSION = %r{ (?:"(?:[^\\"]|\\.)*") | (?:'(?:[^\\']|\\.)*') | (?:\[(?:[^\\\[\]]|\\.|\[(?:[^\\\[\]]|\\.)*\])*\]) | , | [^\s,]+ }x # "
24
28
 
25
29
  private
26
30
 
@@ -88,35 +92,26 @@ module OpenC3
88
92
  second_half = split_string[1].scan(SCANNING_REGULAR_EXPRESSION)
89
93
  keyword = nil
90
94
  value = nil
91
- comma = nil
92
95
  second_half.each do |item|
93
- unless keyword
96
+ if item == ','
97
+ # A comma completes the current keyword / value pair.
98
+ # A comma with nothing pending is a leading or duplicated comma.
99
+ raise "Missing command parameter before comma: #{text}" unless keyword
100
+ raise "Missing value for last command parameter: #{text}" unless value
101
+ add_cmd_parameter(keyword, value, packet, cmd_params)
102
+ keyword = nil
103
+ value = nil
104
+ elsif keyword.nil?
94
105
  keyword = item
95
- next
96
- end
97
- unless value
98
- if item[-1..-1] == ','
99
- value = item[0..-2]
100
- comma = true
101
- else
102
- value = item
103
- next
104
- end
105
- end
106
- unless comma
107
- raise "Missing comma in command parameters: #{text}" if item != ','
106
+ elsif value.nil?
107
+ value = item
108
+ else
109
+ raise "Missing comma in command parameters: #{text}"
108
110
  end
109
- add_cmd_parameter(keyword, value, packet, cmd_params)
110
- keyword = nil
111
- value = nil
112
- comma = nil
113
111
  end
114
112
  if keyword
115
- if value
116
- add_cmd_parameter(keyword, value, packet, cmd_params)
117
- else
118
- raise "Missing value for last command parameter: #{text}"
119
- end
113
+ raise "Missing value for last command parameter: #{text}" unless value
114
+ add_cmd_parameter(keyword, value, packet, cmd_params)
120
115
  end
121
116
  end
122
117
 
@@ -22,7 +22,10 @@ module OpenC3
22
22
 
23
23
  # Create the WebsocketApi object. If a block is given will automatically connect/disconnect
24
24
  def initialize(url:, write_timeout: 10.0, read_timeout: 10.0, connect_timeout: 5.0, authentication: nil, scope: $openc3_scope, &block)
25
- @scope = scope
25
+ # $openc3_scope is only set inside the Script Runner / microservice
26
+ # environment. Fall back to OPENC3_SCOPE (mirrors the Python client) so a
27
+ # bare `ruby script.rb` doesn't send a nil scope that the server rejects.
28
+ @scope = scope || ENV.fetch('OPENC3_SCOPE', 'DEFAULT')
26
29
  @authentication = authentication.nil? ? generate_auth() : authentication
27
30
  @url = url
28
31
  @write_timeout = write_timeout
@@ -100,6 +103,34 @@ module OpenC3
100
103
  json_hash['identifier'] = JSON.generate(@identifier, allow_nan: true)
101
104
  @stream.write(JSON.generate(json_hash, allow_nan: true))
102
105
  @subscribed = true
106
+ wait_for_subscribed()
107
+ end
108
+ end
109
+
110
+ # Block until the server confirms the subscription. ActionCable / anycable-go
111
+ # process 'subscribe' and 'message' commands as independent RPCs, so an action
112
+ # (add/remove) written immediately after subscribe can reach
113
+ # StreamingChannel#add before the subscription's broadcaster exists, where it
114
+ # is silently dropped (a no-op) and no data ever streams. Waiting for
115
+ # confirm_subscription guarantees the broadcaster is ready before any action
116
+ # is written.
117
+ def wait_for_subscribed
118
+ while true
119
+ message = @stream.read
120
+ raise "WebSocket closed before subscription was confirmed" if message.nil? || message.empty?
121
+
122
+ json_hash = JSON.parse(message, allow_nan: true, create_additions: true)
123
+ case json_hash['type']
124
+ when 'confirm_subscription'
125
+ return
126
+ when 'reject_subscription'
127
+ raise "Subscription Rejected"
128
+ when 'disconnect'
129
+ raise "Unauthorized" if json_hash['reason'] == 'unauthorized'
130
+ else
131
+ # Ignore welcome / ping and keep waiting for confirmation
132
+ next
133
+ end
103
134
  end
104
135
  end
105
136
 
@@ -116,6 +147,13 @@ module OpenC3
116
147
 
117
148
  # Send an ActionCable command
118
149
  def write_action(data_hash)
150
+ # Subscribe first so the token is present in @identifier before we
151
+ # serialize it below. ActionCable matches a 'message' command to its
152
+ # subscription by the exact identifier string; if subscribe() injected the
153
+ # token only afterward, the message identifier (no token) would not match
154
+ # the subscription identifier (with token) and the server would silently
155
+ # ignore the action.
156
+ subscribe()
119
157
  json_hash = {}
120
158
  json_hash['command'] = 'message'
121
159
  json_hash['identifier'] = JSON.generate(@identifier, allow_nan: true)
@@ -354,7 +392,8 @@ module OpenC3
354
392
  # PACKET - Packet name
355
393
  # VALUETYPE - RAW, CONVERTED, FORMATTED, or PURE (pure means all types as stored in log)
356
394
  #
357
- def add(items: nil, packets: nil, start_time: nil, end_time: nil, scope: $openc3_scope)
395
+ def add(items: nil, packets: nil, start_time: nil, end_time: nil, scope: nil)
396
+ scope ||= @scope
358
397
  data_hash = {}
359
398
  data_hash['action'] = 'add'
360
399
  if start_time
@@ -395,7 +434,8 @@ module OpenC3
395
434
  # PACKET - Packet name
396
435
  # VALUETYPE - RAW, CONVERTED, FORMATTED, or PURE (pure means all types as stored in log)
397
436
  #
398
- def remove(items: nil, packets: nil, scope: $openc3_scope)
437
+ def remove(items: nil, packets: nil, scope: nil)
438
+ scope ||= @scope
399
439
  data_hash = {}
400
440
  data_hash['action'] = 'remove'
401
441
  data_hash['items'] = items if items
@@ -407,7 +447,7 @@ module OpenC3
407
447
 
408
448
  # Convenience method to read all data until end marker is received.
409
449
  # Warning: DATA IS STORED IN RAM. Do not use this with large queries
410
- def self.read_all(items: nil, packets: nil, start_time: nil, end_time:, scope: $openc3_scope, timeout: nil)
450
+ def self.read_all(items: nil, packets: nil, start_time: nil, end_time:, scope: nil, timeout: nil)
411
451
  read_all_start_time = Time.now
412
452
  data = []
413
453
  self.new do |api|
@@ -30,13 +30,28 @@ module OpenC3
30
30
  received_count: packet.received_count }
31
31
  # Read all RAW values at once - optimized by accessor
32
32
  json_hash = packet.read_items(packet.sorted_items)
33
+ # Items with a write conversion have already been transformed by the time the
34
+ # value lands in the buffer, so reading the buffer back does not recover what
35
+ # the user actually commanded. Use the given values (as passed to build_cmd)
36
+ # for those items. State items are the exception: the user can give either the
37
+ # state name or the state value, so they always read from the buffer to log the
38
+ # normalized state name. Raw commands bypass the write conversions entirely so
39
+ # their given values are raw and must not be logged as converted.
40
+ given_values = packet.raw ? nil : packet.given_values
41
+ given_values = given_values.transform_keys { |key| key.to_s.upcase } if given_values
33
42
  # Read additional value types using given_raw to avoid re-reading from buffer
34
43
  packet.sorted_items.each do |item|
35
44
  if item.hidden
36
45
  json_hash.delete(item.name)
37
46
  else
38
47
  given_raw = json_hash[item.name]
39
- json_hash[item.name + "__C"] = packet.read_item(item, :CONVERTED, packet.buffer, given_raw) if item.write_conversion or item.states
48
+ if item.write_conversion or item.states
49
+ if item.write_conversion and !item.states and given_values and given_values.key?(item.name)
50
+ json_hash[item.name + "__C"] = given_values[item.name]
51
+ else
52
+ json_hash[item.name + "__C"] = packet.read_item(item, :CONVERTED, packet.buffer, given_raw)
53
+ end
54
+ end
40
55
  json_hash[item.name + "__F"] = packet.read_item(item, :FORMATTED, packet.buffer, given_raw) if item.format_string
41
56
  end
42
57
  end
@@ -100,7 +100,7 @@ module OpenC3
100
100
 
101
101
  when :LIMITS_SET
102
102
  sets = sets(scope: scope)
103
- raise "Set '#{event[:set]}' does not exist!" unless sets.key?(event[:set])
103
+ raise "Limits set '#{event[:set]}' does not exist" unless sets.key?(event[:set])
104
104
 
105
105
  # Set all existing sets to "false"
106
106
  sets = sets.transform_values! { |_key, _value| "false" }
@@ -213,6 +213,14 @@ module OpenC3
213
213
  # If the key is not found return nil
214
214
  rescue Aws::S3::Errors::NoSuchKey
215
215
  nil
216
+ # AWS S3 returns 403 AccessDenied rather than 404 NoSuchKey when the caller
217
+ # does not have s3:ListBucket on the bucket. Callers which probe for an
218
+ # optional key (i.e. TargetFile.body checking targets_modified first) would
219
+ # otherwise raise instead of falling through, so treat it as not found.
220
+ # Warn since a genuine permission problem looks identical from here.
221
+ rescue Aws::S3::Errors::AccessDenied
222
+ Logger.warn("Access denied reading #{bucket}/#{key}. Treating as not found. Verify bucket permissions if this key should exist.")
223
+ nil
216
224
  end
217
225
 
218
226
  def list_objects(bucket:, prefix: nil, max_request: 1000, max_total: 100_000)
@@ -233,7 +241,7 @@ module OpenC3
233
241
  # Array of objects with key and size methods
234
242
  result
235
243
  rescue Aws::S3::Errors::NoSuchBucket
236
- raise NotFound, "Bucket '#{bucket}' does not exist."
244
+ raise NotFound, "Bucket '#{bucket}' does not exist (list_objects)"
237
245
  end
238
246
 
239
247
  # Lists the files under a specified path
@@ -283,7 +291,7 @@ module OpenC3
283
291
  end
284
292
  result
285
293
  rescue Aws::S3::Errors::NoSuchBucket
286
- raise NotFound, "Bucket '#{bucket}' does not exist."
294
+ raise NotFound, "Bucket '#{bucket}' does not exist (list_files)"
287
295
  end
288
296
 
289
297
  # get metadata for a specific object
@@ -59,7 +59,7 @@ module OpenC3
59
59
  # @param index [Integer] Which value to return
60
60
  # @return [Boolean] Single value converted to a boolean (true or false)
61
61
  def bool(item, index = 0)
62
- raise "#{item} not found" unless keys.include?(item)
62
+ raise "CSV key '#{item}' not found for bool conversion" unless keys.include?(item)
63
63
 
64
64
  if Range === index
65
65
  @hash[item][index].map do |x|
@@ -91,7 +91,7 @@ module OpenC3
91
91
  # @param index [Integer] Which value to return
92
92
  # @return [Integer] Single value converted to an integer
93
93
  def int(item, index = 0)
94
- raise "#{item} not found" unless keys.include?(item)
94
+ raise "CSV key '#{item}' not found for int conversion" unless keys.include?(item)
95
95
 
96
96
  if Range === index
97
97
  @hash[item][index].map { |x| x.to_i }
@@ -107,7 +107,7 @@ module OpenC3
107
107
  # @param index [Integer] Which value to return
108
108
  # @return [Float] Single value converted to a float
109
109
  def float(item, index = 0)
110
- raise "#{item} not found" unless keys.include?(item)
110
+ raise "CSV key '#{item}' not found for float conversion" unless keys.include?(item)
111
111
 
112
112
  if Range === index
113
113
  @hash[item][index].map { |x| x.to_f }
@@ -122,7 +122,7 @@ module OpenC3
122
122
  # @param index [Integer] Which value to return
123
123
  # @return [String] Single value converted to a string
124
124
  def string(item, index = 0)
125
- raise "#{item} not found" unless keys.include?(item)
125
+ raise "CSV key '#{item}' not found for string lookup" unless keys.include?(item)
126
126
 
127
127
  if Range === index
128
128
  @hash[item][index].map { |x| x.to_s }
@@ -138,7 +138,7 @@ module OpenC3
138
138
  # @param index [Integer] Which value to return
139
139
  # @return [Symbol] Single value converted to a symbol
140
140
  def symbol(item, index = 0)
141
- raise "#{item} not found" unless keys.include?(item)
141
+ raise "CSV key '#{item}' not found for symbol lookup" unless keys.include?(item)
142
142
 
143
143
  if Range === index
144
144
  @hash[item][index].map { |x| x.intern }
@@ -485,7 +485,10 @@ module OpenC3
485
485
  config_path = "#{OPENC3_LOCAL_MODE_PATH}/#{scope}/settings/#{name}.json"
486
486
  return unless File.expand_path(config_path).start_with?(OPENC3_LOCAL_MODE_PATH)
487
487
  FileUtils.mkdir_p(File.dirname(config_path))
488
- # Anything can be stored as a setting so write it out directly
488
+ # Anything can be stored as a setting. Strings are written directly since
489
+ # they're already the serialized form. Anything else is written as JSON so
490
+ # sync_settings can read it back rather than a Ruby inspect string.
491
+ data = JSON.generate(data.as_json(:allow_nan => true)) unless String === data
489
492
  File.write(config_path, data)
490
493
  end
491
494
 
@@ -94,7 +94,11 @@ And so is this
94
94
  # of the file and process the last line
95
95
  if next_token.type == :EOF
96
96
  if !line.empty?
97
- yield line, instrumentable, inside_begin, orig_line_no
97
+ # orig_line_no is normally set below (after this EOF check) but a final
98
+ # line that never reaches that assignment (e.g. a trailing bareword call
99
+ # with no parentheses and no newline at end of file) would yield nil.
100
+ # Fall back to the current token's start line so the line number is valid.
101
+ yield line, instrumentable, inside_begin, orig_line_no || token.location.start_line
98
102
  end
99
103
  break
100
104
  end
@@ -432,7 +432,7 @@ class RunningScript
432
432
  self.class.message_log
433
433
  end
434
434
 
435
- 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)
435
+ 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, python_venv = nil)
436
436
  extension = File.extname(name).to_s.downcase
437
437
  script_engine = nil
438
438
  if extension == '.py'
@@ -468,103 +468,158 @@ class RunningScript
468
468
  user_full_name ||= 'Anonymous'
469
469
  start_time = Time.now.utc.iso8601
470
470
 
471
- process = ChildProcess.build(process_name, runner_path.to_s, running_script_id.to_s, scope)
472
- process.io.inherit! # Helps with debugging
473
- script_cwd = File.join(RAILS_ROOT, 'tmp', "script_#{running_script_id}")
474
- FileUtils.mkdir_p(script_cwd)
475
- process.cwd = script_cwd
476
-
477
- # Check for offline access token
478
- model = nil
479
- model = OpenC3::OfflineAccessModel.get_model(name: username, scope: scope) if username != 'Anonymous'
480
-
481
- # Load the global environment variables
482
- status_environment = {}
483
- values = OpenC3::EnvironmentModel.all(scope: scope).values
484
- values.each do |env|
485
- process.environment[env['key']] = env['value']
486
- status_environment[env['key']] = env['value']
487
- end
488
- # Load the script specific ENV vars set by the GUI
489
- # These can override the previously defined global env vars
490
- if environment
491
- environment.each do |env|
471
+ begin
472
+ process = ChildProcess.build(process_name, runner_path.to_s, running_script_id.to_s, scope)
473
+ process.io.inherit! # Helps with debugging
474
+ script_cwd = File.join(RAILS_ROOT, 'tmp', "script_#{running_script_id}")
475
+ FileUtils.mkdir_p(script_cwd)
476
+ process.cwd = script_cwd
477
+
478
+ # Check for offline access token
479
+ model = nil
480
+ model = OpenC3::OfflineAccessModel.get_model(name: username, scope: scope) if username != 'Anonymous'
481
+
482
+ # Load the global environment variables
483
+ status_environment = {}
484
+ values = OpenC3::EnvironmentModel.all(scope: scope).values
485
+ values.each do |env|
492
486
  process.environment[env['key']] = env['value']
493
487
  status_environment[env['key']] = env['value']
494
488
  end
495
- end
489
+ # Load the script specific ENV vars set by the GUI
490
+ # These can override the previously defined global env vars
491
+ if environment
492
+ environment.each do |env|
493
+ process.environment[env['key']] = env['value']
494
+ status_environment[env['key']] = env['value']
495
+ end
496
+ end
496
497
 
497
- script_status = OpenC3::ScriptStatusModel.new(
498
- name: running_script_id.to_s, # Unique id for this script
499
- state: 'spawning', # State will be spawning until the script is running
500
- shard: 0, # Future enhancement of script runner shards
501
- filename: name, # Initial filename never changes
502
- current_filename: name, # Current filename updates while we are running
503
- line_no: 0, # 0 means not running yet
504
- start_line_no: line_no || 1, # Line number to start running the script
505
- end_line_no: end_line_no || nil, # Line number to stop running the script
506
- username: username, # username of the person who started the script
507
- user_full_name: user_full_name, # full name of the person who started the script
508
- start_time: start_time, # Time the script started ISO format
509
- end_time: nil, # Time the script ended ISO format
510
- disconnect: disconnect, # Disconnect is set to true if the script is running in a disconnected mode
511
- environment: status_environment, # hash of environment variables set for the script
512
- suite_runner: suite_runner ? suite_runner : nil, # current suite information if any
513
- errors: nil, # array of errors that occurred during the script run
514
- pid: nil, # pid of the script process - set by the script itself when it starts
515
- script_engine: script_engine, # script engine filename
516
- updated_at: nil, # Set by create/update
517
- scope: scope # Scope of the script
518
- )
519
- script_status.create()
520
-
521
- # Set proper secrets for running script
522
- process.environment['SECRET_KEY_BASE'] = nil
523
- process.environment['OPENC3_REDIS_USERNAME'] = ENV['OPENC3_SR_REDIS_USERNAME']
524
- process.environment['OPENC3_REDIS_PASSWORD'] = ENV['OPENC3_SR_REDIS_PASSWORD']
525
- process.environment['OPENC3_BUCKET_USERNAME'] = ENV['OPENC3_SR_BUCKET_USERNAME']
526
- process.environment['OPENC3_BUCKET_PASSWORD'] = ENV['OPENC3_SR_BUCKET_PASSWORD']
527
- process.environment['OPENC3_SR_REDIS_USERNAME'] = nil
528
- process.environment['OPENC3_SR_REDIS_PASSWORD'] = nil
529
- process.environment['OPENC3_SR_BUCKET_USERNAME'] = nil
530
- process.environment['OPENC3_SR_BUCKET_PASSWORD'] = nil
531
- process.environment['OPENC3_API_CLIENT'] = ENV['OPENC3_API_CLIENT']
532
- if model and model.offline_access_token
533
- auth = OpenC3::OpenC3KeycloakAuthentication.new(ENV['OPENC3_KEYCLOAK_URL'])
534
- valid_token = auth.get_token_from_refresh_token(model.offline_access_token)
535
- if valid_token
536
- process.environment['OPENC3_API_TOKEN'] = model.offline_access_token
498
+ script_status = OpenC3::ScriptStatusModel.new(
499
+ name: running_script_id.to_s, # Unique id for this script
500
+ state: 'spawning', # State will be spawning until the script is running
501
+ shard: 0, # Future enhancement of script runner shards
502
+ filename: name, # Initial filename never changes
503
+ current_filename: name, # Current filename updates while we are running
504
+ line_no: 0, # 0 means not running yet
505
+ start_line_no: line_no || 1, # Line number to start running the script
506
+ end_line_no: end_line_no || nil, # Line number to stop running the script
507
+ username: username, # username of the person who started the script
508
+ user_full_name: user_full_name, # full name of the person who started the script
509
+ start_time: start_time, # Time the script started ISO format
510
+ end_time: nil, # Time the script ended ISO format
511
+ disconnect: disconnect, # Disconnect is set to true if the script is running in a disconnected mode
512
+ environment: status_environment, # hash of environment variables set for the script
513
+ suite_runner: suite_runner ? suite_runner : nil, # current suite information if any
514
+ errors: nil, # array of errors that occurred during the script run
515
+ pid: nil, # pid of the script process - set by the script itself when it starts
516
+ script_engine: script_engine, # script engine filename
517
+ updated_at: nil, # Set by create/update
518
+ scope: scope # Scope of the script
519
+ )
520
+ script_status.create()
521
+
522
+ # Set proper secrets for running script
523
+ process.environment['SECRET_KEY_BASE'] = nil
524
+ process.environment['OPENC3_REDIS_USERNAME'] = ENV['OPENC3_SR_REDIS_USERNAME']
525
+ process.environment['OPENC3_REDIS_PASSWORD'] = ENV['OPENC3_SR_REDIS_PASSWORD']
526
+ process.environment['OPENC3_BUCKET_USERNAME'] = ENV['OPENC3_SR_BUCKET_USERNAME']
527
+ process.environment['OPENC3_BUCKET_PASSWORD'] = ENV['OPENC3_SR_BUCKET_PASSWORD']
528
+ process.environment['OPENC3_SR_REDIS_USERNAME'] = nil
529
+ process.environment['OPENC3_SR_REDIS_PASSWORD'] = nil
530
+ process.environment['OPENC3_SR_BUCKET_USERNAME'] = nil
531
+ process.environment['OPENC3_SR_BUCKET_PASSWORD'] = nil
532
+ process.environment['OPENC3_API_CLIENT'] = ENV['OPENC3_API_CLIENT']
533
+ if model and model.offline_access_token
534
+ auth = OpenC3::OpenC3KeycloakAuthentication.new(ENV['OPENC3_KEYCLOAK_URL'])
535
+ valid_token = auth.get_token_from_refresh_token(model.offline_access_token)
536
+ if valid_token
537
+ process.environment['OPENC3_API_TOKEN'] = model.offline_access_token
538
+ else
539
+ model.offline_access_token = nil
540
+ model.update
541
+ raise "offline_access token invalid for script"
542
+ end
537
543
  else
538
- model.offline_access_token = nil
539
- model.update
540
- raise "offline_access token invalid for script"
544
+ process.environment['OPENC3_API_USER'] = ENV['OPENC3_API_USER']
545
+ if ENV['OPENC3_SERVICE_PASSWORD']
546
+ process.environment['OPENC3_API_PASSWORD'] = ENV['OPENC3_SERVICE_PASSWORD']
547
+ else
548
+ raise "No authentication available for script"
549
+ end
541
550
  end
542
- else
543
- process.environment['OPENC3_API_USER'] = ENV['OPENC3_API_USER']
544
- if ENV['OPENC3_SERVICE_PASSWORD']
545
- process.environment['OPENC3_API_PASSWORD'] = ENV['OPENC3_SERVICE_PASSWORD']
551
+ process.environment['GEM_HOME'] = ENV['GEM_HOME']
552
+
553
+ # Resolve the per-plugin Python venv for this script. For saved scripts
554
+ # we look up which plugin owns the target and check whether that plugin
555
+ # has an isolated UV venv. For temp scripts the frontend can pass a
556
+ # python_venv name directly, skipping the target lookup entirely.
557
+ python_venv_dir = nil
558
+ begin
559
+ target_name = name.split('/')[0].to_s.upcase
560
+ if target_name == '__TEMP__' && python_venv
561
+ # File.basename prevents path traversal (strips directory components)
562
+ safe_name = File.basename(python_venv.to_s)
563
+ candidate = "/gems/plugin_venvs/#{safe_name}/.venv"
564
+ python_venv_dir = candidate if File.directory?(candidate)
565
+ else
566
+ target_info = OpenC3::TargetModel.get(name: target_name, scope: scope)
567
+ if target_info && target_info['plugin']
568
+ sanitized_name = "#{scope}__#{target_info['plugin']}".tr('^a-zA-Z0-9_-', '_')
569
+ candidate = "/gems/plugin_venvs/#{sanitized_name}/.venv"
570
+ python_venv_dir = candidate if File.directory?(candidate)
571
+ end
572
+ end
573
+ rescue => e
574
+ OpenC3::Logger.debug("Could not resolve plugin venv for script '#{name}': #{e.message}")
575
+ end
576
+
577
+ if python_venv_dir
578
+ process.environment['VIRTUAL_ENV'] = python_venv_dir
579
+ process.environment['PATH'] = "#{python_venv_dir}/bin:#{ENV.fetch('PATH', '')}"
580
+ process.environment['PYTHONUSERBASE'] = python_venv_dir
581
+ # Add plugin venv site-packages to PYTHONPATH so the base venv's Python
582
+ # binary can find plugin-specific packages. PYTHONPATH is always respected
583
+ # by CPython regardless of venv activation state.
584
+ site_packages = Dir.glob("#{python_venv_dir}/lib/python*/site-packages").first
585
+ existing_pythonpath = ENV.fetch('PYTHONPATH', '')
586
+ if site_packages
587
+ process.environment['PYTHONPATH'] = existing_pythonpath.empty? ? site_packages : "#{site_packages}:#{existing_pythonpath}"
588
+ else
589
+ process.environment['PYTHONPATH'] = existing_pythonpath.empty? ? nil : existing_pythonpath
590
+ end
546
591
  else
547
- raise "No authentication available for script"
592
+ system_venv = File.dirname(ENV['OPENC3_PYTHON_BIN'] || '/openc3/python/.venv/bin/python').chomp('/bin')
593
+ process.environment['VIRTUAL_ENV'] = system_venv
594
+ process.environment['PYTHONUSERBASE'] = ENV['PYTHONUSERBASE']
595
+ process.environment['PYTHONPATH'] = ENV['PYTHONPATH']
548
596
  end
549
- end
550
- process.environment['GEM_HOME'] = ENV['GEM_HOME']
551
- process.environment['PYTHONUSERBASE'] = ENV['PYTHONUSERBASE']
552
- # Preserve PYTHONPATH to ensure Python can find both UV venv and user packages
553
- process.environment['PYTHONPATH'] = ENV['PYTHONPATH']
554
-
555
- # Spawned process should not be controlled by same Bundler constraints as spawning process
556
- ENV.each do |key, _value|
557
- if key =~ /^BUNDLE/
558
- process.environment[key] = nil
597
+
598
+ # Spawned process should not be controlled by same Bundler constraints as spawning process
599
+ ENV.each do |key, _value|
600
+ if key =~ /^BUNDLE/
601
+ process.environment[key] = nil
602
+ end
559
603
  end
560
- end
561
- process.environment['RUBYOPT'] = nil # Removes loading bundler setup
562
- process.environment['OPENC3_SCOPE'] = scope
563
- process.environment['RAILS_ROOT'] = RAILS_ROOT
604
+ process.environment['RUBYOPT'] = nil # Removes loading bundler setup
605
+ process.environment['OPENC3_SCOPE'] = scope
606
+ process.environment['RAILS_ROOT'] = RAILS_ROOT
564
607
 
565
- process.detach = true
566
- process.start
567
- running_script_id
608
+ process.detach = true
609
+ process.start
610
+ running_script_id
611
+ rescue => e
612
+ begin
613
+ # Clean up the orphaned script status record so the script
614
+ # does not remain stuck in 'spawning' state forever.
615
+ # Guard against nil in case the error occurred before these were assigned.
616
+ script_status.destroy() if script_status
617
+ FileUtils.rm_rf(script_cwd) if script_cwd
618
+ rescue => cleanup_error
619
+ OpenC3::Logger.error("Failed to cleanup after spawn failure: #{cleanup_error.message}")
620
+ end
621
+ raise e
622
+ end
568
623
  end
569
624
 
570
625
  def initialize(script_status)
@@ -601,7 +656,7 @@ class RunningScript
601
656
 
602
657
  # Retrieve file
603
658
  @body = ::Script.body(@script_status.scope, @script_status.filename)
604
- raise "Script not found: #{@script_status.filename}" if @body.nil?
659
+ raise "Script not found during initialization: #{@script_status.filename}" if @body.nil?
605
660
  breakpoints = @@breakpoints[@script_status.filename]&.filter { |_, present| present }&.map { |line_number, _| line_number - 1 } # -1 because frontend lines are 0-indexed
606
661
  breakpoints ||= []
607
662
  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 })
@@ -1476,7 +1531,7 @@ class RunningScript
1476
1531
  running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :file, filename: filename, text: @body.to_utf8, breakpoints: breakpoints })
1477
1532
  else
1478
1533
  text = ::Script.body(@script_status.scope, filename)
1479
- raise "Script not found: #{filename}" if text.nil?
1534
+ raise "Script not found during load/require: #{filename}" if text.nil?
1480
1535
  @@file_cache[filename] = text
1481
1536
  @body = text
1482
1537
  running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :file, filename: filename, text: @body.to_utf8, breakpoints: breakpoints })