browserctl 0.10.0 → 0.12.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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +66 -0
  3. data/README.md +2 -1
  4. data/bin/browserctl +168 -78
  5. data/bin/browserd +8 -1
  6. data/lib/browserctl/client.rb +50 -6
  7. data/lib/browserctl/commands/cli_output.rb +36 -3
  8. data/lib/browserctl/commands/flow.rb +123 -0
  9. data/lib/browserctl/commands/migrate.rb +94 -0
  10. data/lib/browserctl/commands/state.rb +193 -0
  11. data/lib/browserctl/commands/trace.rb +187 -0
  12. data/lib/browserctl/commands/workflow.rb +62 -4
  13. data/lib/browserctl/constants.rb +4 -2
  14. data/lib/browserctl/crash_report.rb +96 -0
  15. data/lib/browserctl/detectors/auth_required.rb +128 -0
  16. data/lib/browserctl/detectors.rb +2 -0
  17. data/lib/browserctl/error/codes.rb +44 -0
  18. data/lib/browserctl/error/exit_codes.rb +54 -0
  19. data/lib/browserctl/error/suggested_actions.rb +41 -0
  20. data/lib/browserctl/errors.rb +72 -12
  21. data/lib/browserctl/flow.rb +22 -1
  22. data/lib/browserctl/flow_registry.rb +66 -0
  23. data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
  24. data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
  25. data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
  26. data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
  27. data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
  28. data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
  29. data/lib/browserctl/format_version.rb +37 -0
  30. data/lib/browserctl/logger.rb +102 -9
  31. data/lib/browserctl/migrations.rb +216 -0
  32. data/lib/browserctl/recording.rb +246 -28
  33. data/lib/browserctl/redactor.rb +58 -0
  34. data/lib/browserctl/replay/context.rb +40 -0
  35. data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
  36. data/lib/browserctl/replay/snapshot_diff.rb +51 -0
  37. data/lib/browserctl/replay/telemetry.rb +60 -0
  38. data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
  39. data/lib/browserctl/runner.rb +50 -10
  40. data/lib/browserctl/secret_resolver_registry.rb +23 -4
  41. data/lib/browserctl/server/command_dispatcher.rb +13 -1
  42. data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
  43. data/lib/browserctl/server/handlers/error_payload.rb +27 -0
  44. data/lib/browserctl/server/handlers/interaction.rb +21 -3
  45. data/lib/browserctl/server/handlers/navigation.rb +50 -5
  46. data/lib/browserctl/server/handlers/observation.rb +43 -2
  47. data/lib/browserctl/server/handlers/state.rb +149 -0
  48. data/lib/browserctl/server/page_session.rb +9 -7
  49. data/lib/browserctl/server/snapshot_builder.rb +21 -45
  50. data/lib/browserctl/session.rb +1 -1
  51. data/lib/browserctl/snapshot/annotator.rb +75 -0
  52. data/lib/browserctl/snapshot/extractor.rb +21 -0
  53. data/lib/browserctl/snapshot/fingerprint.rb +88 -0
  54. data/lib/browserctl/snapshot/ref.rb +70 -0
  55. data/lib/browserctl/snapshot/serializer.rb +17 -0
  56. data/lib/browserctl/state/bundle.rb +283 -0
  57. data/lib/browserctl/state/transport.rb +64 -0
  58. data/lib/browserctl/state/transports/file.rb +35 -0
  59. data/lib/browserctl/state/transports/one_password.rb +67 -0
  60. data/lib/browserctl/state/transports/s3.rb +42 -0
  61. data/lib/browserctl/state.rb +208 -0
  62. data/lib/browserctl/version.rb +1 -1
  63. data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
  64. data/lib/browserctl/workflow/promoter.rb +96 -0
  65. data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
  66. data/lib/browserctl/workflow.rb +235 -16
  67. metadata +44 -7
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "cli_output"
5
+ require_relative "../flow_registry"
6
+ require_relative "../runner"
7
+
8
+ module Browserctl
9
+ module Commands
10
+ module Flow
11
+ extend CliOutput
12
+
13
+ USAGE = "Usage: browserctl flow <run|list|describe> [args]"
14
+
15
+ def self.run(client, args)
16
+ sub = args.shift or abort USAGE
17
+ case sub
18
+ when "run" then run_flow(client, args)
19
+ when "list" then run_list
20
+ when "describe" then run_describe(args)
21
+ else abort "unknown flow subcommand '#{sub}'\n#{USAGE}"
22
+ end
23
+ end
24
+
25
+ def self.run_flow(client, args)
26
+ name = args.shift or
27
+ abort "usage: browserctl flow run <name|file> [--page NAME] [--params FILE] [--key value ...]"
28
+
29
+ flow = resolve(name)
30
+ page_name, params = parse_run_args(args)
31
+ page_proxy = page_name ? Browserctl::PageProxy.new(page_name, client) : nil
32
+
33
+ result = flow.run(page: page_proxy, client: client, **params)
34
+ puts JSON.generate(ok: true, flow: flow.name, result: serialisable(result))
35
+ rescue Browserctl::FlowError => e
36
+ warn "Error: #{e.message}"
37
+ puts JSON.generate(ok: false, code: e.code, error: e.message)
38
+ exit 1
39
+ end
40
+
41
+ def self.run_list
42
+ entries = Browserctl::FlowRegistry.list
43
+ puts JSON.generate(flows: entries)
44
+ end
45
+
46
+ def self.run_describe(args)
47
+ name = args.shift or abort "usage: browserctl flow describe <name>"
48
+ flow = resolve(name)
49
+ puts JSON.pretty_generate(
50
+ name: flow.name,
51
+ desc: flow.description,
52
+ version: flow.version_string,
53
+ requires_browserctl: flow.min_browserctl_version,
54
+ params: format_params(flow),
55
+ preconditions: flow.preconditions.map(&:label),
56
+ steps: flow.steps.map(&:label),
57
+ postconditions: flow.postconditions.map(&:label),
58
+ produces_state: !flow.produces_state_block.nil?
59
+ )
60
+ end
61
+
62
+ def self.resolve(name_or_path)
63
+ if File.exist?(name_or_path)
64
+ before = Browserctl.flow_registry_snapshot.keys
65
+ load File.expand_path(name_or_path)
66
+ new_name = (Browserctl.flow_registry_snapshot.keys - before).first
67
+ new_name ||= File.basename(name_or_path, ".rb")
68
+ flow = Browserctl.lookup_flow(new_name)
69
+ else
70
+ flow = Browserctl::FlowRegistry.resolve(name_or_path)
71
+ end
72
+ flow or abort "flow '#{name_or_path}' not found"
73
+ end
74
+
75
+ def self.parse_run_args(args)
76
+ page_name = take_option(args, "--page")
77
+ params_path = take_option(args, "--params")
78
+ file_params = params_path ? load_params_file(params_path) : {}
79
+ cli_params = pair_args(args)
80
+ [page_name, file_params.merge(cli_params)]
81
+ end
82
+
83
+ def self.take_option(args, flag)
84
+ idx = args.index(flag)
85
+ return nil unless idx
86
+
87
+ value = args.delete_at(idx + 1)
88
+ args.delete_at(idx)
89
+ value
90
+ end
91
+
92
+ def self.load_params_file(path)
93
+ Browserctl::Runner.load_params_file(path)
94
+ rescue StandardError => e
95
+ abort "Error loading params file: #{e.message}"
96
+ end
97
+
98
+ def self.pair_args(args)
99
+ out = {}
100
+ args.each_slice(2) do |flag, val|
101
+ out[flag.sub(/\A--/, "").to_sym] = val
102
+ end
103
+ out
104
+ end
105
+
106
+ def self.format_params(flow)
107
+ flow.param_defs.transform_values do |p|
108
+ entry = { required: p.required, secret: p.secret, default: p.default }
109
+ entry[:secret_ref] = p.secret_ref if p.secret_ref
110
+ entry
111
+ end
112
+ end
113
+
114
+ def self.serialisable(value)
115
+ case value
116
+ when nil, true, false, Numeric, String, Hash, Array then value
117
+ when Symbol then value.to_s
118
+ else value.respond_to?(:to_h) ? value.to_h : value.to_s
119
+ end
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../migrations"
4
+ require_relative "../errors"
5
+ require_relative "../error/codes"
6
+ require_relative "../error/exit_codes"
7
+
8
+ module Browserctl
9
+ module Commands
10
+ # `browserctl migrate <path> [--to-version N] [--dry-run]` — operator
11
+ # entry point for the {Browserctl::Migrations} registry. Detects the
12
+ # artifact's format and version, plans a chain of registered upgraders,
13
+ # and applies them in order (unless `--dry-run`).
14
+ #
15
+ # The registry ships empty in v0.12; this command exists so operators
16
+ # have a stable invocation the moment a real migration lands. On an
17
+ # already-current artifact the command is a no-op and exits 0.
18
+ module Migrate
19
+ USAGE = "Usage: browserctl migrate <path> [--to-version N] [--dry-run]"
20
+
21
+ def self.run(args, out: $stdout, err: $stderr)
22
+ abort USAGE if args.empty? || args.include?("-h") || args.include?("--help")
23
+ args = args.dup
24
+
25
+ dry_run = !args.delete("--dry-run").nil?
26
+ target_idx = args.index("--to-version")
27
+ target = if target_idx
28
+ args.delete_at(target_idx)
29
+ Integer(args.delete_at(target_idx))
30
+ end
31
+ path = args.shift
32
+ abort USAGE unless path
33
+
34
+ unless File.exist?(path)
35
+ err.puts "Error: file not found: #{path}"
36
+ exit Browserctl::Error::ExitCodes::GENERIC
37
+ end
38
+
39
+ execute(path, target_version: target, dry_run: dry_run, out: out, err: err)
40
+ rescue Browserctl::ProtocolMismatch => e
41
+ err.puts "Error: #{e.message}"
42
+ exit Browserctl::Error::ExitCodes.for(e.code)
43
+ end
44
+
45
+ def self.execute(path, target_version:, dry_run:, out:, err:)
46
+ format = Browserctl::Migrations.detect_format(path)
47
+ unless format
48
+ err.puts "Error: could not detect format for #{path} (expected .bctl, .jsonl, or .rb)"
49
+ exit Browserctl::Error::ExitCodes::PROTOCOL_MISMATCH
50
+ end
51
+
52
+ current = Browserctl::Migrations.detect_version(path, format)
53
+ out.puts "Detected: format=#{format} version=#{current.inspect} path=#{path}"
54
+
55
+ if dry_run
56
+ plan_dry_run(format, current, target_version, out)
57
+ return
58
+ end
59
+
60
+ result = Browserctl::Migrations.run(path, target_version: target_version)
61
+ if result.applied.empty?
62
+ out.puts "No migrations registered for #{format} v#{current}; nothing to do."
63
+ else
64
+ out.puts "Applied #{result.applied.size} migration(s): #{result.from} -> #{result.to}"
65
+ result.applied.each { |m| out.puts " - #{format} v#{m.from_version} -> v#{m.to_version}" }
66
+ end
67
+ end
68
+
69
+ def self.plan_dry_run(format, current, target_version, out)
70
+ target = target_version || latest_target(format, current)
71
+ chain = Browserctl::Migrations.find_path(format: format, from: current, to: target)
72
+
73
+ if chain.nil?
74
+ out.puts "No migration path #{format} v#{current} -> v#{target} (registered: " \
75
+ "#{registered_for(format).inspect})"
76
+ elsif chain.empty?
77
+ out.puts "Already at v#{target}; no migrations would run."
78
+ else
79
+ out.puts "Plan (#{chain.size} step(s)):"
80
+ chain.each { |m| out.puts " - #{format} v#{m.from_version} -> v#{m.to_version}" }
81
+ end
82
+ end
83
+
84
+ def self.latest_target(format, current)
85
+ targets = registered_for(format)
86
+ targets.empty? ? current : targets.max
87
+ end
88
+
89
+ def self.registered_for(format)
90
+ Browserctl::Migrations.all.select { |m| m.format == format }.map(&:to_version)
91
+ end
92
+ end
93
+ end
94
+ end
@@ -0,0 +1,193 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "io/console"
4
+ require "json"
5
+ require_relative "cli_output"
6
+
7
+ module Browserctl
8
+ module Commands
9
+ # `browserctl state` — top-level command for portable, encrypted, origin-
10
+ # scoped browser state. Wraps the daemon's state_* RPCs.
11
+ module State
12
+ extend CliOutput
13
+
14
+ USAGE = "Usage: browserctl state <save|load|list|info|delete|rotate|export|import> [args]"
15
+
16
+ DAEMON_SUBCOMMANDS = {
17
+ "save" => :run_save, "load" => :run_load, "list" => :run_list,
18
+ "info" => :run_info, "delete" => :run_delete, "rotate" => :run_rotate
19
+ }.freeze
20
+
21
+ LOCAL_SUBCOMMANDS = { "export" => :run_export, "import" => :run_import }.freeze
22
+
23
+ def self.run(client, args)
24
+ sub = args.shift or abort USAGE
25
+
26
+ if (m = DAEMON_SUBCOMMANDS[sub])
27
+ sub == "list" ? send(m, client) : send(m, client, args)
28
+ elsif (m = LOCAL_SUBCOMMANDS[sub])
29
+ send(m, args)
30
+ else
31
+ abort "unknown state subcommand '#{sub}'\n#{USAGE}"
32
+ end
33
+ end
34
+
35
+ def self.run_save(client, args)
36
+ encrypt = args.delete("--encrypt")
37
+ origins = extract_value!(args, "--origins")
38
+ flow = extract_value!(args, "--flow")
39
+ name = args.shift or abort "usage: browserctl state save <name> [--encrypt] " \
40
+ "[--origins a,b] [--flow NAME]"
41
+
42
+ passphrase = encrypt ? prompt_passphrase(confirm: true) : nil
43
+ origin_list = parse_origins(origins)
44
+
45
+ print_result(client.state_save(name, origins: origin_list, flow: flow, passphrase: passphrase))
46
+ end
47
+
48
+ def self.parse_origins(value)
49
+ return nil unless value
50
+
51
+ value.split(",").map(&:strip).reject(&:empty?)
52
+ end
53
+
54
+ def self.run_load(client, args)
55
+ name = args.shift or abort "usage: browserctl state load <name>"
56
+ passphrase = state_needs_passphrase?(client, name) ? prompt_passphrase : nil
57
+ print_result(client.state_load(name, passphrase: passphrase))
58
+ end
59
+
60
+ def self.run_list(client)
61
+ print_result(client.state_list)
62
+ end
63
+
64
+ def self.run_info(client, args)
65
+ name = args.shift or abort "usage: browserctl state info <name>"
66
+ print_result(client.state_info(name))
67
+ end
68
+
69
+ def self.run_delete(client, args)
70
+ name = args.shift or abort "usage: browserctl state delete <name>"
71
+ print_result(client.state_delete(name))
72
+ end
73
+
74
+ # Re-runs the flow bound to <name> and re-saves the bundle. The flow is
75
+ # read from the manifest (set when the bundle was originally produced
76
+ # via `state save --flow ...`). Params come from --params or k=v pairs.
77
+ def self.run_rotate(client, args)
78
+ require "browserctl/flow_registry"
79
+ page_name = extract_value!(args, "--page")
80
+ params_path = extract_value!(args, "--params")
81
+ name = args.shift or abort "usage: browserctl state rotate <name> " \
82
+ "[--page NAME] [--params FILE] [--key value ...]"
83
+
84
+ manifest = read_manifest!(client, name)
85
+ flow = resolve_bound_flow!(manifest)
86
+ params = build_rotate_params(params_path, args)
87
+ page_proxy = page_name ? Browserctl::PageProxy.new(page_name, client) : nil
88
+
89
+ flow.run(page: page_proxy, client: client, **params)
90
+
91
+ save_result = client.state_save(name,
92
+ flow: flow.name,
93
+ flow_version: flow.version_string,
94
+ origins: manifest[:origins])
95
+ print_result(save_result.merge(rotated_flow: flow.name))
96
+ rescue Browserctl::FlowError => e
97
+ warn "Error: #{e.message}"
98
+ exit 1
99
+ end
100
+
101
+ def self.read_manifest!(client, name)
102
+ info = client.state_info(name)
103
+ abort "Error: #{info[:error] || info['error']}" if info[:error] || info["error"]
104
+
105
+ info[:info] || info["info"] || {}
106
+ end
107
+ private_class_method :read_manifest!
108
+
109
+ def self.resolve_bound_flow!(manifest)
110
+ flow_name = manifest[:flow] || manifest["flow"]
111
+ abort "Error: state has no bound flow — re-save with `state save --flow NAME` first" if flow_name.nil? ||
112
+ flow_name.to_s.empty?
113
+
114
+ flow = Browserctl::FlowRegistry.resolve(flow_name)
115
+ abort "Error: flow '#{flow_name}' not found in registry" unless flow
116
+
117
+ flow
118
+ end
119
+ private_class_method :resolve_bound_flow!
120
+
121
+ def self.build_rotate_params(params_path, args)
122
+ require "browserctl/runner"
123
+ file_params = params_path ? Browserctl::Runner.load_params_file(params_path) : {}
124
+ cli_params = args.each_slice(2).to_h { |flag, val| [flag.to_s.sub(/\A--/, "").to_sym, val] }
125
+ file_params.merge(cli_params)
126
+ end
127
+ private_class_method :build_rotate_params
128
+
129
+ def self.run_export(args)
130
+ name = args.shift or abort "usage: browserctl state export <name> <destination>"
131
+ destination = args.shift or abort "usage: browserctl state export <name> <destination>"
132
+ require "browserctl/state"
133
+ result = Browserctl::State.export(name, destination)
134
+ puts result.to_json
135
+ rescue Browserctl::State::Transport::TransportError, Browserctl::Error, ArgumentError => e
136
+ warn "Error: #{e.message}"
137
+ exit 1
138
+ end
139
+
140
+ def self.run_import(args)
141
+ name_override = extract_value!(args, "--name")
142
+ source = args.shift or abort "usage: browserctl state import <source> [--name NAME]"
143
+ require "browserctl/state"
144
+ result = Browserctl::State.import(source, name: name_override)
145
+ puts result.to_json
146
+ rescue Browserctl::State::Transport::TransportError,
147
+ Browserctl::State::Bundle::BundleError,
148
+ Browserctl::Error, ArgumentError => e
149
+ warn "Error: #{e.message}"
150
+ exit 1
151
+ end
152
+
153
+ private_class_method :parse_origins
154
+
155
+ def self.extract_value!(args, flag)
156
+ idx = args.index(flag)
157
+ return nil unless idx
158
+
159
+ args.delete_at(idx)
160
+ args.delete_at(idx) or abort "missing value for #{flag}"
161
+ end
162
+ private_class_method :extract_value!
163
+
164
+ def self.prompt_passphrase(confirm: false)
165
+ return ENV["BROWSERCTL_STATE_PASSPHRASE"] if ENV["BROWSERCTL_STATE_PASSPHRASE"]
166
+
167
+ $stderr.print "Passphrase: "
168
+ pass = $stdin.noecho(&:gets).to_s.chomp
169
+ $stderr.puts
170
+
171
+ if confirm
172
+ $stderr.print "Confirm passphrase: "
173
+ confirm_pass = $stdin.noecho(&:gets).to_s.chomp
174
+ $stderr.puts
175
+ abort "Passphrases do not match." unless pass == confirm_pass
176
+ end
177
+
178
+ pass
179
+ end
180
+ private_class_method :prompt_passphrase
181
+
182
+ # Peek at the manifest first so we only prompt for a passphrase when needed.
183
+ def self.state_needs_passphrase?(client, name)
184
+ info = client.state_info(name)
185
+ return false if info[:error] || info["error"]
186
+
187
+ manifest = info[:info] || info["info"] || {}
188
+ manifest[:encrypted] || manifest["encrypted"] || false
189
+ end
190
+ private_class_method :state_needs_passphrase?
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "time"
5
+ require_relative "../logger"
6
+ require_relative "../redactor"
7
+ require_relative "../secret_resolver_registry"
8
+
9
+ module Browserctl
10
+ module Commands
11
+ # `browserctl trace [<session>] [--no-redact]` — pretty timeline of
12
+ # structured log events across cli.log + daemon.log. Defaults to most
13
+ # recent session.
14
+ #
15
+ # Loose categorisation by inspecting common keys (event/snapshot/request/
16
+ # error). No schema is enforced — this command is tolerant of any JSONL
17
+ # produced by Browserctl::JsonlFormatter.
18
+ #
19
+ # Redaction: ON by default. Secret values are sourced from current ENV
20
+ # patterns (`*_TOKEN`, `*_KEY`, `*_SECRET`, `*_PASSWORD`) and any values
21
+ # captured by `SecretResolverRegistry` during this process. Pass
22
+ # `--no-redact` to disable (local debugging only). Note: when replaying
23
+ # historical traces from a previous process, registry-captured values are
24
+ # gone — only current ENV patterns apply.
25
+ module Trace
26
+ USAGE = "Usage: browserctl trace [<session>] [--no-redact]"
27
+ NO_REDACT_WARNING = "[browserctl] traces include unredacted secret values; " \
28
+ "do not paste this output publicly."
29
+
30
+ LEVEL_COLORS = {
31
+ "DEBUG" => "\e[2;37m", # dim grey
32
+ "INFO" => "\e[36m", # cyan
33
+ "WARN" => "\e[33m", # yellow
34
+ "ERROR" => "\e[31m" # red
35
+ }.freeze
36
+ RESET = "\e[0m"
37
+
38
+ CATEGORY_ICONS = {
39
+ error: "!",
40
+ snapshot: "S",
41
+ network: "N",
42
+ event: "."
43
+ }.freeze
44
+
45
+ OMIT_KEYS = %w[ts level component event msg].freeze
46
+
47
+ def self.run(args, log_dir: Browserctl.log_dir, out: $stdout, err: $stderr)
48
+ abort USAGE if args.include?("-h") || args.include?("--help")
49
+ args = args.dup
50
+ redact = !args.delete("--no-redact")
51
+ session_filter = args.shift
52
+
53
+ if redact
54
+ redactor = build_redactor
55
+ else
56
+ redactor = nil
57
+ warn_no_redact(err)
58
+ end
59
+
60
+ records = load_records(log_dir)
61
+ if records.empty?
62
+ out.puts "No log entries found in #{log_dir}"
63
+ return
64
+ end
65
+
66
+ records = filter_session(records, session_filter)
67
+ if records.empty?
68
+ out.puts "No entries match session=#{session_filter}"
69
+ return
70
+ end
71
+
72
+ render(records, out: out, redactor: redactor)
73
+ end
74
+
75
+ def self.build_redactor
76
+ extra = if defined?(Browserctl::SecretResolverRegistry)
77
+ Browserctl::SecretResolverRegistry.resolved_values
78
+ else
79
+ []
80
+ end
81
+ Browserctl::Redactor.from_env(extra: extra)
82
+ rescue StandardError
83
+ Browserctl::Redactor.new(secrets: [])
84
+ end
85
+
86
+ def self.warn_no_redact(err)
87
+ err&.puts NO_REDACT_WARNING
88
+ end
89
+
90
+ def self.load_records(log_dir)
91
+ paths = Dir.glob(File.join(log_dir, "{cli,daemon}.log"))
92
+ records = paths.flat_map do |path|
93
+ File.foreach(path).filter_map { |line| parse_line(line) }
94
+ end
95
+ records.sort_by { |r| r["ts"].to_s }
96
+ end
97
+
98
+ def self.parse_line(line)
99
+ line = line.strip
100
+ return nil if line.empty?
101
+
102
+ JSON.parse(line)
103
+ rescue JSON::ParserError
104
+ nil
105
+ end
106
+
107
+ # Session resolution. When session_id is stamped on records (future PR),
108
+ # filter/select by it. Otherwise, treat the entire merged stream as one
109
+ # session — caller can scope by tailing/rotating logs.
110
+ # TODO: stamp session_id on every log line so this scopes correctly.
111
+ def self.filter_session(records, session_filter)
112
+ if session_filter
113
+ records.select { |r| r["session_id"].to_s == session_filter }
114
+ else
115
+ ids = records.map { |r| r["session_id"] }.compact.uniq
116
+ if ids.empty?
117
+ records
118
+ else
119
+ recent = ids.last
120
+ records.select { |r| r["session_id"] == recent }
121
+ end
122
+ end
123
+ end
124
+
125
+ def self.render(records, out:, redactor: nil)
126
+ tty = out.respond_to?(:tty?) && out.tty?
127
+ records.each { |r| out.puts(format_line(r, tty: tty, redactor: redactor)) }
128
+ end
129
+
130
+ def self.format_line(record, tty:, redactor: nil)
131
+ level = (record["level"] || "INFO").to_s
132
+ line = format("%-12<ts>s %<icon>s %-5<level>s %-7<comp>s %-22<label>s %<ctx>s",
133
+ ts: format_ts(record["ts"]),
134
+ icon: CATEGORY_ICONS.fetch(categorise(record), "."),
135
+ level: level,
136
+ comp: (record["component"] || "?").to_s,
137
+ label: event_label(record),
138
+ ctx: context_snippet(record)).rstrip
139
+
140
+ line = redactor.redact(line) if redactor
141
+ tty ? colourise(line, level) : line
142
+ end
143
+
144
+ def self.format_ts(timestamp)
145
+ Time.iso8601(timestamp.to_s).strftime("%H:%M:%S.%L")
146
+ rescue ArgumentError, TypeError
147
+ "??:??:??.???"
148
+ end
149
+
150
+ def self.categorise(record)
151
+ return :error if record["level"] == "ERROR" || record["error"]
152
+ return :snapshot if record["snapshot"]
153
+ return :network if record["request"] || record["response"] || record["url"]
154
+
155
+ :event
156
+ end
157
+
158
+ def self.event_label(record)
159
+ (record["event"] || record["snapshot"] || record["request"] ||
160
+ record["msg"] || "-").to_s.slice(0, 22)
161
+ end
162
+
163
+ # Compact "k=v k=v" snippet of remaining structured keys, capped to keep
164
+ # the timeline scannable. Skips fields already shown in fixed columns.
165
+ def self.context_snippet(record)
166
+ pairs = record.except(*OMIT_KEYS)
167
+ return "" if pairs.empty?
168
+
169
+ pairs.map { |k, v| "#{k}=#{format_value(v)}" }.join(" ").slice(0, 120)
170
+ end
171
+
172
+ def self.format_value(value)
173
+ case value
174
+ when String then value.length > 40 ? "#{value[0, 37]}..." : value
175
+ when Array then "[#{value.length}]"
176
+ when Hash then "{#{value.keys.length}}"
177
+ else value.to_s
178
+ end
179
+ end
180
+
181
+ def self.colourise(line, level)
182
+ colour = LEVEL_COLORS[level] || ""
183
+ "#{colour}#{line}#{RESET}"
184
+ end
185
+ end
186
+ end
187
+ end