openc3 7.2.0 → 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 (94) hide show
  1. checksums.yaml +4 -4
  2. data/bin/openc3cli +108 -29
  3. data/bin/pipinstall +5 -4
  4. data/bin/pipuninstall +5 -1
  5. data/bin/uvinstall +149 -0
  6. data/data/config/interface_modifiers.yaml +15 -0
  7. data/data/config/item_modifiers.yaml +2 -1
  8. data/data/config/microservice.yaml +5 -1
  9. data/data/config/screen.yaml +2 -1
  10. data/data/config/target.yaml +38 -0
  11. data/data/config/tool.yaml +3 -1
  12. data/data/config/widgets.yaml +43 -3
  13. data/data/xtce_schemas/SpaceSystem_20180204.xsd +5918 -0
  14. data/data/xtce_schemas/xml.xsd +116 -0
  15. data/ext/openc3/ext/telemetry/telemetry.c +1 -1
  16. data/lib/openc3/accessors/binary_accessor.rb +4 -0
  17. data/lib/openc3/accessors/template_accessor.rb +2 -2
  18. data/lib/openc3/api/cmd_api.rb +5 -1
  19. data/lib/openc3/api/interface_api.rb +2 -2
  20. data/lib/openc3/api/limits_api.rb +89 -3
  21. data/lib/openc3/api/router_api.rb +3 -3
  22. data/lib/openc3/api/tlm_api.rb +1 -1
  23. data/lib/openc3/interfaces/http_client_interface.rb +1 -1
  24. data/lib/openc3/io/json_drb.rb +24 -8
  25. data/lib/openc3/io/json_rpc.rb +0 -2
  26. data/lib/openc3/logs/log_writer.rb +43 -7
  27. data/lib/openc3/logs/packet_log_writer.rb +1 -2
  28. data/lib/openc3/microservices/decom_common.rb +15 -4
  29. data/lib/openc3/microservices/decom_microservice.rb +6 -1
  30. data/lib/openc3/microservices/interface_microservice.rb +26 -12
  31. data/lib/openc3/microservices/queue_microservice.rb +4 -2
  32. data/lib/openc3/migrations/20260701000000_updated_at_to_int.rb +55 -0
  33. data/lib/openc3/migrations/20260731000000_system_health_json.rb +41 -0
  34. data/lib/openc3/models/cvt_model.rb +7 -7
  35. data/lib/openc3/models/db_sharded_model.rb +2 -2
  36. data/lib/openc3/models/gem_model.rb +1 -1
  37. data/lib/openc3/models/interface_model.rb +17 -11
  38. data/lib/openc3/models/interface_status_model.rb +2 -2
  39. data/lib/openc3/models/metric_model.rb +2 -2
  40. data/lib/openc3/models/microservice_model.rb +21 -0
  41. data/lib/openc3/models/microservice_status_model.rb +2 -2
  42. data/lib/openc3/models/model.rb +2 -6
  43. data/lib/openc3/models/plugin_model.rb +204 -49
  44. data/lib/openc3/models/python_package_model.rb +210 -12
  45. data/lib/openc3/models/reingest_job_model.rb +1 -1
  46. data/lib/openc3/models/scope_model.rb +34 -30
  47. data/lib/openc3/models/script_status_model.rb +73 -38
  48. data/lib/openc3/models/target_model.rb +193 -6
  49. data/lib/openc3/models/tool_model.rb +33 -20
  50. data/lib/openc3/operators/microservice_operator.rb +33 -4
  51. data/lib/openc3/operators/operator.rb +15 -5
  52. data/lib/openc3/packets/commands.rb +7 -7
  53. data/lib/openc3/packets/limits.rb +33 -2
  54. data/lib/openc3/packets/packet.rb +6 -4
  55. data/lib/openc3/packets/packet_config.rb +5 -1
  56. data/lib/openc3/packets/parsers/state_parser.rb +1 -1
  57. data/lib/openc3/packets/parsers/xtce_converter.rb +183 -103
  58. data/lib/openc3/packets/parsers/xtce_parser.rb +71 -23
  59. data/lib/openc3/packets/structure.rb +16 -12
  60. data/lib/openc3/packets/telemetry.rb +3 -3
  61. data/lib/openc3/script/commands.rb +1 -1
  62. data/lib/openc3/script/extract.rb +20 -25
  63. data/lib/openc3/script/limits.rb +1 -1
  64. data/lib/openc3/script/suite_runner.rb +43 -9
  65. data/lib/openc3/script/web_socket_api.rb +45 -5
  66. data/lib/openc3/system/target.rb +4 -1
  67. data/lib/openc3/tools/table_manager/table_config.rb +4 -1
  68. data/lib/openc3/top_level.rb +1 -1
  69. data/lib/openc3/topics/command_decom_topic.rb +16 -1
  70. data/lib/openc3/topics/limits_event_topic.rb +63 -4
  71. data/lib/openc3/topics/telemetry_decom_topic.rb +2 -2
  72. data/lib/openc3/utilities/aws_bucket.rb +10 -2
  73. data/lib/openc3/utilities/cli_generator.rb +5 -1
  74. data/lib/openc3/utilities/csv.rb +5 -5
  75. data/lib/openc3/utilities/env_helper.rb +13 -1
  76. data/lib/openc3/utilities/local_mode.rb +4 -1
  77. data/lib/openc3/utilities/pypi_url.rb +39 -0
  78. data/lib/openc3/utilities/reingest_job.rb +1 -1
  79. data/lib/openc3/utilities/ruby_lex_utils.rb +5 -1
  80. data/lib/openc3/utilities/running_script.rb +160 -100
  81. data/lib/openc3/utilities/script.rb +60 -13
  82. data/lib/openc3/utilities/store_autoload.rb +39 -1
  83. data/lib/openc3/utilities/store_queued.rb +5 -1
  84. data/lib/openc3/version.rb +5 -5
  85. data/lib/openc3.rb +6 -1
  86. data/templates/plugin/plugin.gemspec +1 -1
  87. data/templates/tool_angular/package.json +2 -2
  88. data/templates/tool_react/package.json +1 -1
  89. data/templates/tool_svelte/package.json +1 -1
  90. data/templates/tool_vue/package.json +3 -3
  91. data/templates/widget/package.json +2 -2
  92. metadata +7 -3
  93. data/templates/tool_vue/.nycrc +0 -3
  94. data/templates/widget/.nycrc +0 -3
@@ -0,0 +1,39 @@
1
+ # encoding: ascii-8bit
2
+
3
+ # Copyright 2026 OpenC3, Inc.
4
+ # All Rights Reserved.
5
+ #
6
+ # This program is distributed in the hope that it will be useful,
7
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
8
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
9
+ # See LICENSE.md for more details.
10
+ #
11
+ # This file may also be used under the terms of a commercial license
12
+ # if purchased from OpenC3, Inc.
13
+
14
+ require 'uri'
15
+ require 'openc3/utilities/logger'
16
+
17
+ module OpenC3
18
+ class PypiUrl
19
+ DEFAULT = 'https://pypi.org/simple'
20
+
21
+ # Validate that a resolved pypi_url is an http(s) URL before it is handed to
22
+ # pip. The value can come from a user-writable setting or ENV, so a malformed
23
+ # or non-http value is rejected and replaced with the default rather than
24
+ # passed through to pip.
25
+ #
26
+ # @param pypi_url [String] the resolved pypi index url to validate
27
+ # @return [String] the original url if valid, otherwise DEFAULT
28
+ def self.validate(pypi_url)
29
+ uri = URI.parse(pypi_url)
30
+ unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
31
+ raise URI::InvalidURIError, "not an http(s) URL"
32
+ end
33
+ pypi_url
34
+ rescue URI::InvalidURIError => e
35
+ Logger.error("Invalid pypi_url '#{pypi_url}' (#{e.message}); falling back to #{DEFAULT}")
36
+ DEFAULT
37
+ end
38
+ end
39
+ end
@@ -1,4 +1,4 @@
1
- # encoding: ascii-8bit
1
+ # encoding: utf-8
2
2
 
3
3
  # Copyright 2026 OpenC3, Inc.
4
4
  # All Rights Reserved.
@@ -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 - ISO format
517
- scope: scope # Scope of the script
518
- )
519
- script_status.create(isoformat: true)
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 })
@@ -827,17 +882,22 @@ class RunningScript
827
882
 
828
883
  def run
829
884
  if @script_status.suite_runner
830
- if @script_status.suite_runner['options']
831
- parse_options(@script_status.suite_runner['options'])
832
- else
833
- parse_options({}) # Set default options
834
- end
835
- if @script_status.suite_runner['script']
836
- run_text("OpenC3::SuiteRunner.start(#{@script_status.suite_runner['suite']}, #{@script_status.suite_runner['group']}, '#{@script_status.suite_runner['script']}')", initial_filename: "SCRIPTRUNNER")
837
- elsif script_status.suite_runner['group']
838
- run_text("OpenC3::SuiteRunner.#{@script_status.suite_runner['method']}(#{@script_status.suite_runner['suite']}, #{@script_status.suite_runner['group']})", initial_filename: "SCRIPTRUNNER")
885
+ # Normalize the received hash through the shared helper so options/method
886
+ # defaults and validation match what openc3cli and the GUI construct.
887
+ sr = OpenC3::SuiteRunner.build_options(
888
+ suite: @script_status.suite_runner['suite'],
889
+ group: @script_status.suite_runner['group'],
890
+ script: @script_status.suite_runner['script'],
891
+ method: @script_status.suite_runner['method'],
892
+ options: @script_status.suite_runner['options'],
893
+ )
894
+ parse_options(sr['options'])
895
+ if sr['script']
896
+ run_text("OpenC3::SuiteRunner.start(#{sr['suite']}, #{sr['group']}, '#{sr['script']}')", initial_filename: "SCRIPTRUNNER")
897
+ elsif sr['group']
898
+ run_text("OpenC3::SuiteRunner.#{sr['method']}(#{sr['suite']}, #{sr['group']})", initial_filename: "SCRIPTRUNNER")
839
899
  else
840
- run_text("OpenC3::SuiteRunner.#{@script_status.suite_runner['method']}(#{@script_status.suite_runner['suite']})", initial_filename: "SCRIPTRUNNER")
900
+ run_text("OpenC3::SuiteRunner.#{sr['method']}(#{sr['suite']})", initial_filename: "SCRIPTRUNNER")
841
901
  end
842
902
  else
843
903
  if not @script_engine and (@script_status.start_line_no != 1 or !@script_status.end_line_no.nil?)
@@ -1471,7 +1531,7 @@ class RunningScript
1471
1531
  running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :file, filename: filename, text: @body.to_utf8, breakpoints: breakpoints })
1472
1532
  else
1473
1533
  text = ::Script.body(@script_status.scope, filename)
1474
- raise "Script not found: #{filename}" if text.nil?
1534
+ raise "Script not found during load/require: #{filename}" if text.nil?
1475
1535
  @@file_cache[filename] = text
1476
1536
  @body = text
1477
1537
  running_script_anycable_publish("running-script-channel:#{@script_status.id}", { type: :file, filename: filename, text: @body.to_utf8, breakpoints: breakpoints })
@@ -25,33 +25,69 @@ require 'openc3/tools/test_runner/test'
25
25
  OpenC3.require_file 'openc3/utilities/store'
26
26
 
27
27
  class Script < OpenC3::TargetFile
28
+ # Script lifecycle states and the transitions allowed from each state
29
+ LIFECYCLE_STATES = %w(development review approved)
30
+ LIFECYCLE_TRANSITIONS = {
31
+ 'development' => %w(review approved),
32
+ 'review' => %w(development approved),
33
+ 'approved' => %w(review development),
34
+ }
35
+
28
36
  def self.all(scope, target = nil)
29
37
  super(scope, nil, target: target) # No path matchers
30
38
  end
31
39
 
40
+ # Split off the '*' that indicates a file is modified on the server
41
+ def self.strip_modified(name)
42
+ name.split('*')[0]
43
+ end
44
+ private_class_method :strip_modified
45
+
32
46
  def self.lock(scope, name, username)
33
- name = name.split('*')[0] # Split '*' that indicates modified
34
- OpenC3::Store.hset("#{scope}__script-locks", name, username)
47
+ OpenC3::Store.hset("#{scope}__script-locks", strip_modified(name), username)
35
48
  end
36
49
 
37
50
  def self.unlock(scope, name)
38
- name = name.split('*')[0] # Split '*' that indicates modified
39
- OpenC3::Store.hdel("#{scope}__script-locks", name)
51
+ OpenC3::Store.hdel("#{scope}__script-locks", strip_modified(name))
40
52
  end
41
53
 
42
54
  def self.locked?(scope, name)
43
- name = name.split('*')[0] # Split '*' that indicates modified
44
- locked_by = OpenC3::Store.hget("#{scope}__script-locks", name)
55
+ locked_by = OpenC3::Store.hget("#{scope}__script-locks", strip_modified(name))
45
56
  locked_by ||= false
46
57
  locked_by
47
58
  end
48
59
 
49
60
  def self.get_breakpoints(scope, name)
50
- breakpoints = OpenC3::Store.hget("#{scope}__script-breakpoints", name.split('*')[0]) # Split '*' that indicates modified
61
+ breakpoints = OpenC3::Store.hget("#{scope}__script-breakpoints", strip_modified(name))
51
62
  return JSON.parse(breakpoints, allow_nan: true, create_additions: true) if breakpoints
52
63
  []
53
64
  end
54
65
 
66
+ def self.temp_file?(name)
67
+ strip_modified(name).start_with?("#{TEMP_FOLDER}/")
68
+ end
69
+
70
+ def self.lifecycle_enabled?
71
+ defined?(::VersionStore) && ::VersionStore.enabled?
72
+ end
73
+
74
+ def self.lifecycle(scope, name)
75
+ return { 'state' => 'development', 'history' => [] } if temp_file?(name)
76
+ return { 'state' => 'development', 'history' => [] } unless lifecycle_enabled?
77
+ ::VersionStore.lifecycle(scope: scope, name: strip_modified(name))
78
+ end
79
+
80
+ # current: the caller-known current state; pass it to avoid a redundant git
81
+ # read when the controller has already fetched it for transition validation.
82
+ def self.set_lifecycle(scope, name, state, username, comment, current: nil)
83
+ raise "Cannot set lifecycle on temporary files" if temp_file?(name)
84
+ raise "Script lifecycle requires the version history store" unless lifecycle_enabled?
85
+ name = strip_modified(name)
86
+ current ||= lifecycle(scope, name)['state']
87
+ ::VersionStore.set_lifecycle(scope: scope, name: name, from: current, to: state,
88
+ username: username, comment: comment)
89
+ end
90
+
55
91
  def self.process_suite(name, contents, new_process: true, username: nil, scope:)
56
92
  python = false
57
93
  python = true if File.extname(name) == '.py'
@@ -139,16 +175,23 @@ class Script < OpenC3::TargetFile
139
175
  process.io.stdout = stdout
140
176
  process.io.stderr = stderr
141
177
  process.start
142
- process.wait
178
+ begin
179
+ process.poll_for_exit(10) # wait for max 10s
180
+ rescue ChildProcess::TimeoutError
181
+ process.stop
182
+ stderr_results = "Suite analysis timed out - possible infinite loop in script\n"
183
+ success = false
184
+ end
143
185
  stdout.rewind
144
186
  stdout_results = stdout.read
145
187
  stdout.close
146
188
  stdout.unlink
147
189
  stderr.rewind
148
- stderr_results = stderr.read
190
+ stderr_results ||= ""
191
+ stderr_results += stderr.read
149
192
  stderr.close
150
193
  stderr.unlink
151
- success = process.exit_code == 0
194
+ success = process.exit_code == 0 if success
152
195
  else
153
196
  require temp.path
154
197
  stdout_results = OpenC3::SuiteRunner.build_suites.as_json().to_json(allow_nan: true)
@@ -182,7 +225,6 @@ class Script < OpenC3::TargetFile
182
225
  def self.delete_temp(scope)
183
226
  files = super(scope)
184
227
  files.each do |name|
185
- # Remove any breakpoints associated with the temp files
186
228
  OpenC3::Store.hdel("#{scope}__script-breakpoints", "#{TEMP_FOLDER}/#{File.basename(name)}")
187
229
  end
188
230
  end
@@ -201,9 +243,14 @@ class Script < OpenC3::TargetFile
201
243
  user_full_name = nil,
202
244
  username = nil,
203
245
  line_no = nil,
204
- end_line_no = nil
246
+ end_line_no = nil,
247
+ python_venv = nil
205
248
  )
206
- RunningScript.spawn(scope, name, suite_runner, disconnect, environment, user_full_name, username, line_no, end_line_no)
249
+ # Verify the script exists before spawning a run. Without this check a run
250
+ # is started and the missing file only surfaces as a runtime error inside
251
+ # the spawned process. Returning nil lets the caller return a 404.
252
+ return nil unless Script.body(scope, name)
253
+ RunningScript.spawn(scope, name, suite_runner, disconnect, environment, user_full_name, username, line_no, end_line_no, python_venv)
207
254
  end
208
255
 
209
256
  def self.instrumented(filename, text)
@@ -123,8 +123,46 @@ module OpenC3
123
123
  @redis_pool = StoreConnectionPool.new(size: pool_size) { build_redis() }
124
124
  end
125
125
 
126
+ # cap/base for the equal-jitter reconnect backoff (seconds)
127
+ REDIS_BACKOFF_CAP = 5.0
128
+ REDIS_BACKOFF_BASE = 0.625
129
+
126
130
  def build_redis
127
- return Redis.new(url: @redis_url, username: @redis_username, password: @redis_key)
131
+ # reconnect_attempts retries the connection a few times with equal-jitter
132
+ # backoff so a transient network blip is handled inside the client instead
133
+ # of immediately surfacing a connection error to callers. The jitter
134
+ # de-syncs many clients retrying the same blip to avoid a thundering herd
135
+ # on recovery.
136
+ #
137
+ # This mirrors the Python store's Retry(EqualJitterBackoff(cap: 5, base:
138
+ # 0.625), 3): per-retry backoff tops out at 5s on the final (3rd) retry
139
+ # (~0.6-1.25s, ~1.25-2.5s, ~2.5-5s). redis-rb takes a fixed Array of delays
140
+ # (no per-failure backoff callable), so we sample the jittered delays once
141
+ # per connection here.
142
+ # Connection, read, and write timeouts are left as the default: 1s
143
+ return Redis.new(
144
+ url: @redis_url,
145
+ username: @redis_username,
146
+ password: @redis_key,
147
+ reconnect_attempts: reconnect_backoff_delays()
148
+ )
149
+ end
150
+
151
+ # Equal-jitter backoff delays for 3 retries, matching Python's
152
+ # EqualJitterBackoff. Each retry's delay is randomized within the upper half
153
+ # of an exponentially-growing ceiling.
154
+ def reconnect_backoff_delays
155
+ (1..3).map do |failures|
156
+ # ceiling = exponential growth (base doubles each retry), clamped to cap.
157
+ # For base=0.625, cap=5: failures 1,2,3 -> 1.25, 2.5, 5.0 seconds.
158
+ # temp = half the ceiling: the guaranteed minimum wait for this retry.
159
+ temp = [REDIS_BACKOFF_CAP, REDIS_BACKOFF_BASE * (2 ** failures)].min / 2.0
160
+ # Final delay = fixed half (temp) + random half (rand*temp, in [0, temp)),
161
+ # i.e. a value uniformly in [temp, 2*temp) = [ceiling/2, ceiling).
162
+ # The fixed half keeps a sane floor; the random half de-syncs clients so
163
+ # they don't all reconnect in lockstep (thundering herd) after a blip.
164
+ temp + rand * temp
165
+ end
128
166
  end
129
167
 
130
168
  ###########################################################################
@@ -103,7 +103,11 @@ module OpenC3
103
103
  OpenC3.kill_thread(self, @update_thread) if @update_thread
104
104
  @update_thread = nil
105
105
  # Drain the queue before shutdown
106
- process_queue()
106
+ begin
107
+ process_queue()
108
+ rescue
109
+ # Best effort - Redis may already be unavailable during shutdown
110
+ end
107
111
  end
108
112
 
109
113
  MessageStruct = Struct.new(:message, :args, :kwargs, :block)
@@ -1,14 +1,14 @@
1
1
  # encoding: ascii-8bit
2
2
 
3
- OPENC3_VERSION = '7.2.0'
3
+ OPENC3_VERSION = '7.3.0'
4
4
  module OpenC3
5
5
  module Version
6
6
  MAJOR = '7'
7
- MINOR = '2'
7
+ MINOR = '3'
8
8
  PATCH = '0'
9
9
  OTHER = ''
10
- BUILD = '57bc6819061782287e6e4c866874e13853dc6b40'
10
+ BUILD = 'ec6ac203728234fada5e3a2ecab59fba8a94b935'
11
11
  end
12
- VERSION = '7.2.0'
13
- GEM_VERSION = '7.2.0'
12
+ VERSION = '7.3.0'
13
+ GEM_VERSION = '7.3.0'
14
14
  end
data/lib/openc3.rb CHANGED
@@ -19,7 +19,12 @@ require 'resolv-replace'
19
19
  # Set default encodings
20
20
  saved_verbose = $VERBOSE; $VERBOSE = nil
21
21
  Encoding.default_external = Encoding::ASCII_8BIT
22
- Encoding.default_internal = Encoding::ASCII_8BIT
22
+ # NOTE: Do NOT set Encoding.default_internal = ASCII_8BIT. Doing so makes every
23
+ # read that specifies an encoding (e.g. File.read(path, encoding: 'UTF-8'))
24
+ # transcode into ASCII-8BIT, which raises Encoding::UndefinedConversionError on
25
+ # any non-ASCII byte. On Ruby 3.4 this breaks bootsnap's compile cache (it reads
26
+ # source as UTF-8 to work around Ruby bug #22023) for any file containing
27
+ # non-ASCII characters, including stdlib files like 'matrix'. Leave it nil.
23
28
  $VERBOSE = saved_verbose
24
29
 
25
30
  # Add OpenC3 bin folder to PATH
@@ -23,7 +23,7 @@ Gem::Specification.new do |s|
23
23
  s.files = Dir.glob("{targets,lib,public,tools,microservices}/**/*") + %w(Rakefile README.md LICENSE.md plugin.txt)
24
24
 
25
25
  s.metadata = {
26
- # These fields are used when you submit your plugin to our App Store at store.openc3.com
26
+ # These fields are used when you submit your plugin to the OpenC3 Store at store.openc3.com
27
27
  # See this help page for more detail: https://store.openc3.com/help/guidelines
28
28
  "source_code_uri" => "https://github.com/your-github/plugin-repo",
29
29
  "openc3_store_title" => "<%= plugin_orig %>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "<%= tool_name %>",
3
- "version": "7.2.0",
3
+ "version": "7.3.0",
4
4
  "scripts": {
5
5
  "ng": "ng",
6
6
  "start": "ng serve",
@@ -23,7 +23,7 @@
23
23
  "@angular/platform-browser-dynamic": "^18.2.6",
24
24
  "@angular/router": "^18.2.6",
25
25
  "@astrouxds/astro-web-components": "^7.24.0",
26
- "@openc3/js-common": "7.2.0",
26
+ "@openc3/js-common": "7.3.0",
27
27
  "rxjs": "~7.8.0",
28
28
  "single-spa": "^5.9.5",
29
29
  "single-spa-angular": "^9.2.0",
@@ -16,7 +16,7 @@
16
16
  "@emotion/react": "^11.13.3",
17
17
  "@emotion/styled": "^11.11.0",
18
18
  "@mui/material": "^6.1.1",
19
- "@openc3/js-common": "7.2.0",
19
+ "@openc3/js-common": "7.3.0",
20
20
  "react": "^18.2.0",
21
21
  "react-dom": "^18.2.0",
22
22
  "single-spa-react": "^5.1.4"
@@ -12,7 +12,7 @@
12
12
  },
13
13
  "dependencies": {
14
14
  "@astrouxds/astro-web-components": "^7.24.0",
15
- "@openc3/js-common": "7.2.0",
15
+ "@openc3/js-common": "7.3.0",
16
16
  "@smui/button": "^7.0.0",
17
17
  "@smui/common": "^7.0.0",
18
18
  "@smui/card": "^7.0.0",