browserctl 0.13.1 → 0.14.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +7 -0
- data/lib/browserctl/callable_definition.rb +7 -1
- data/lib/browserctl/client/recording_interceptor.rb +39 -0
- data/lib/browserctl/client.rb +45 -10
- data/lib/browserctl/commands/passphrase_prompt.rb +44 -0
- data/lib/browserctl/commands/state.rb +18 -95
- data/lib/browserctl/commands/trace.rb +23 -163
- data/lib/browserctl/driver/cdp.rb +5 -1
- data/lib/browserctl/error/codes.rb +17 -0
- data/lib/browserctl/error/exit_codes.rb +12 -1
- data/lib/browserctl/error/suggested_actions.rb +11 -0
- data/lib/browserctl/flow.rb +46 -9
- data/lib/browserctl/flow_registry.rb +5 -1
- data/lib/browserctl/flows/stdlib/totp_2fa.rb +5 -1
- data/lib/browserctl/format_version.rb +7 -1
- data/lib/browserctl/logger.rb +2 -1
- data/lib/browserctl/migrations.rb +8 -1
- data/lib/browserctl/rubocop/cops/typed_error.rb +27 -18
- data/lib/browserctl/server/command_dispatcher.rb +1 -1
- data/lib/browserctl/server/handlers/navigation.rb +5 -1
- data/lib/browserctl/server/handlers/state.rb +1 -1
- data/lib/browserctl/state/mutator.rb +73 -0
- data/lib/browserctl/state.rb +5 -1
- data/lib/browserctl/trace/event_stream.rb +75 -0
- data/lib/browserctl/trace/renderer.rb +107 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow.rb +6 -3
- metadata +6 -1
|
@@ -18,6 +18,18 @@ module Browserctl
|
|
|
18
18
|
PROTOCOL_MISMATCH = "PROTOCOL_MISMATCH"
|
|
19
19
|
DOMAIN_NOT_ALLOWED = "DOMAIN_NOT_ALLOWED"
|
|
20
20
|
KEY_NOT_FOUND = "KEY_NOT_FOUND"
|
|
21
|
+
|
|
22
|
+
# Validation family — introduced in v0.14 WS-1 to retire the remaining
|
|
23
|
+
# bare ArgumentError raises on public APIs and DSL guards.
|
|
24
|
+
# VALIDATION_FAILED is the parent code; the four INVALID_* members are
|
|
25
|
+
# specialisations that all share exit code 8. See docs/reference/errors.md
|
|
26
|
+
# for the per-code triggers.
|
|
27
|
+
VALIDATION_FAILED = "VALIDATION_FAILED"
|
|
28
|
+
INVALID_SELECTOR_REF = "INVALID_SELECTOR_REF"
|
|
29
|
+
INVALID_STATE_NAME = "INVALID_STATE_NAME"
|
|
30
|
+
INVALID_DSL_USAGE = "INVALID_DSL_USAGE"
|
|
31
|
+
INVALID_FORMAT_VERSION = "INVALID_FORMAT_VERSION"
|
|
32
|
+
|
|
21
33
|
GENERIC = "GENERIC"
|
|
22
34
|
|
|
23
35
|
ALL = [
|
|
@@ -29,6 +41,11 @@ module Browserctl
|
|
|
29
41
|
PROTOCOL_MISMATCH,
|
|
30
42
|
DOMAIN_NOT_ALLOWED,
|
|
31
43
|
KEY_NOT_FOUND,
|
|
44
|
+
VALIDATION_FAILED,
|
|
45
|
+
INVALID_SELECTOR_REF,
|
|
46
|
+
INVALID_STATE_NAME,
|
|
47
|
+
INVALID_DSL_USAGE,
|
|
48
|
+
INVALID_FORMAT_VERSION,
|
|
32
49
|
GENERIC
|
|
33
50
|
].freeze
|
|
34
51
|
|
|
@@ -25,6 +25,7 @@ module Browserctl
|
|
|
25
25
|
PROTOCOL_MISMATCH = 5
|
|
26
26
|
SELECTOR_NOT_FOUND = 6
|
|
27
27
|
STATE_EXPIRED = 7
|
|
28
|
+
VALIDATION_FAILED = 8
|
|
28
29
|
|
|
29
30
|
# Canonical Codes string → exit status integer. Codes without an entry
|
|
30
31
|
# (e.g. DOMAIN_NOT_ALLOWED, KEY_NOT_FOUND, SECRET_RESOLUTION_FAILED,
|
|
@@ -34,12 +35,22 @@ module Browserctl
|
|
|
34
35
|
# DRIFT (2) is reserved for a future Codes::DRIFT and currently has no
|
|
35
36
|
# entry in this table — drift-related raises fall through to GENERIC
|
|
36
37
|
# until that code is introduced.
|
|
38
|
+
#
|
|
39
|
+
# The validation family (VALIDATION_FAILED parent plus INVALID_*
|
|
40
|
+
# specialisations) all map to exit code 8 — agents and scripts can
|
|
41
|
+
# branch on `$? == 8` for any caller-side validation failure without
|
|
42
|
+
# caring which specific guard tripped.
|
|
37
43
|
TABLE = {
|
|
38
44
|
Codes::AUTH_REQUIRED => AUTH_REQUIRED,
|
|
39
45
|
Codes::DAEMON_UNREACHABLE => DAEMON_UNREACHABLE,
|
|
40
46
|
Codes::PROTOCOL_MISMATCH => PROTOCOL_MISMATCH,
|
|
41
47
|
Codes::SELECTOR_NOT_FOUND => SELECTOR_NOT_FOUND,
|
|
42
|
-
Codes::STATE_EXPIRED => STATE_EXPIRED
|
|
48
|
+
Codes::STATE_EXPIRED => STATE_EXPIRED,
|
|
49
|
+
Codes::VALIDATION_FAILED => VALIDATION_FAILED,
|
|
50
|
+
Codes::INVALID_SELECTOR_REF => VALIDATION_FAILED,
|
|
51
|
+
Codes::INVALID_STATE_NAME => VALIDATION_FAILED,
|
|
52
|
+
Codes::INVALID_DSL_USAGE => VALIDATION_FAILED,
|
|
53
|
+
Codes::INVALID_FORMAT_VERSION => VALIDATION_FAILED
|
|
43
54
|
}.freeze
|
|
44
55
|
|
|
45
56
|
# @param code [String, nil] a canonical code from {Browserctl::Error::Codes}
|
|
@@ -28,6 +28,17 @@ module Browserctl
|
|
|
28
28
|
"Add the domain to your policy allowlist or use an allowed URL.",
|
|
29
29
|
Codes::KEY_NOT_FOUND =>
|
|
30
30
|
"Verify the key was stored in this daemon session before fetching.",
|
|
31
|
+
Codes::VALIDATION_FAILED =>
|
|
32
|
+
"Check the argument or DSL usage against the documented contract, then retry.",
|
|
33
|
+
Codes::INVALID_SELECTOR_REF =>
|
|
34
|
+
"Pass either a CSS selector or a stable ref — one is required.",
|
|
35
|
+
Codes::INVALID_STATE_NAME =>
|
|
36
|
+
"Use only letters, digits, '_' or '-' (max 64 chars) for state names.",
|
|
37
|
+
Codes::INVALID_DSL_USAGE =>
|
|
38
|
+
"Check the workflow/flow DSL call against docs/reference/style-guide.md; " \
|
|
39
|
+
"required blocks or arguments are missing.",
|
|
40
|
+
Codes::INVALID_FORMAT_VERSION =>
|
|
41
|
+
"Use a non-negative Integer for the format version header; see docs/reference/format-versions.md.",
|
|
31
42
|
Codes::GENERIC => DEFAULT
|
|
32
43
|
}.freeze
|
|
33
44
|
|
data/lib/browserctl/flow.rb
CHANGED
|
@@ -63,19 +63,37 @@ module Browserctl
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def precondition(label = "precondition", &block)
|
|
66
|
-
|
|
66
|
+
unless block
|
|
67
|
+
raise Browserctl::Error.new(
|
|
68
|
+
"precondition '#{label}' requires a block",
|
|
69
|
+
code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
|
|
70
|
+
context: { dsl: :flow, action: :precondition, label: label }
|
|
71
|
+
)
|
|
72
|
+
end
|
|
67
73
|
|
|
68
74
|
@preconditions << FlowConditionDef.new(kind: :precondition, label: label, block: block)
|
|
69
75
|
end
|
|
70
76
|
|
|
71
77
|
def postcondition(label = "postcondition", &block)
|
|
72
|
-
|
|
78
|
+
unless block
|
|
79
|
+
raise Browserctl::Error.new(
|
|
80
|
+
"postcondition '#{label}' requires a block",
|
|
81
|
+
code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
|
|
82
|
+
context: { dsl: :flow, action: :postcondition, label: label }
|
|
83
|
+
)
|
|
84
|
+
end
|
|
73
85
|
|
|
74
86
|
@postconditions << FlowConditionDef.new(kind: :postcondition, label: label, block: block)
|
|
75
87
|
end
|
|
76
88
|
|
|
77
89
|
def produces_state(&block)
|
|
78
|
-
|
|
90
|
+
unless block
|
|
91
|
+
raise Browserctl::Error.new(
|
|
92
|
+
"produces_state requires a block",
|
|
93
|
+
code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
|
|
94
|
+
context: { dsl: :flow, action: :produces_state }
|
|
95
|
+
)
|
|
96
|
+
end
|
|
79
97
|
|
|
80
98
|
@produces_state_block = block
|
|
81
99
|
end
|
|
@@ -87,13 +105,22 @@ module Browserctl
|
|
|
87
105
|
def compose(target_name)
|
|
88
106
|
name = target_name.to_s
|
|
89
107
|
if Browserctl.respond_to?(:lookup_workflow) && Browserctl.lookup_workflow(name)
|
|
90
|
-
raise
|
|
91
|
-
|
|
92
|
-
|
|
108
|
+
raise Browserctl::Error.new(
|
|
109
|
+
"flow '#{@name}' cannot compose workflow '#{name}': flows return state, " \
|
|
110
|
+
"workflows share state — composition across kinds is not supported",
|
|
111
|
+
code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
|
|
112
|
+
context: { dsl: :flow, action: :compose, source: @name, target: name, reason: :cross_kind }
|
|
113
|
+
)
|
|
93
114
|
end
|
|
94
115
|
|
|
95
116
|
source = Browserctl.lookup_flow(name)
|
|
96
|
-
|
|
117
|
+
unless source
|
|
118
|
+
raise Browserctl::Error.new(
|
|
119
|
+
"flow '#{name}' not found for composition",
|
|
120
|
+
code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
|
|
121
|
+
context: { dsl: :flow, action: :compose, source: @name, target: name, reason: :not_found }
|
|
122
|
+
)
|
|
123
|
+
end
|
|
97
124
|
|
|
98
125
|
@steps.concat(source.steps)
|
|
99
126
|
end
|
|
@@ -113,7 +140,11 @@ module Browserctl
|
|
|
113
140
|
def validate_semver!(value, label:)
|
|
114
141
|
return if value.to_s.match?(SEMVER_RE)
|
|
115
142
|
|
|
116
|
-
raise
|
|
143
|
+
raise Browserctl::Error.new(
|
|
144
|
+
"#{label} must be MAJOR.MINOR.PATCH (got #{value.inspect})",
|
|
145
|
+
code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
|
|
146
|
+
context: { dsl: :flow, action: :validate_semver, label: label, value: value.to_s }
|
|
147
|
+
)
|
|
117
148
|
end
|
|
118
149
|
|
|
119
150
|
def missing_param_error(name)
|
|
@@ -166,7 +197,13 @@ module Browserctl
|
|
|
166
197
|
@flow_registry = {}
|
|
167
198
|
|
|
168
199
|
def self.flow(name, &block)
|
|
169
|
-
|
|
200
|
+
unless block
|
|
201
|
+
raise Browserctl::Error.new(
|
|
202
|
+
"Browserctl.flow requires a block",
|
|
203
|
+
code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
|
|
204
|
+
context: { dsl: :flow, action: :define, name: name.to_s }
|
|
205
|
+
)
|
|
206
|
+
end
|
|
170
207
|
|
|
171
208
|
flow = Flow.new(name).tap { |f| f.instance_exec(&block) }
|
|
172
209
|
register_flow(flow)
|
|
@@ -60,7 +60,11 @@ module Browserctl
|
|
|
60
60
|
def self.validate_name!(name)
|
|
61
61
|
return if SAFE_NAME.match?(name.to_s)
|
|
62
62
|
|
|
63
|
-
raise
|
|
63
|
+
raise Browserctl::Error.new(
|
|
64
|
+
"invalid flow name: #{name.inspect} — use letters, digits, _ and - only",
|
|
65
|
+
code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
|
|
66
|
+
context: { name: name.to_s }
|
|
67
|
+
)
|
|
64
68
|
end
|
|
65
69
|
end
|
|
66
70
|
end
|
|
@@ -31,7 +31,11 @@ module Browserctl
|
|
|
31
31
|
|
|
32
32
|
def char_to_bits(char)
|
|
33
33
|
idx = BASE32_ALPHABET.index(char) or
|
|
34
|
-
raise
|
|
34
|
+
raise Browserctl::Error.new(
|
|
35
|
+
"invalid base32 char #{char.inspect}",
|
|
36
|
+
code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
|
|
37
|
+
context: { char: char }
|
|
38
|
+
)
|
|
35
39
|
idx.to_s(2).rjust(5, "0")
|
|
36
40
|
end
|
|
37
41
|
end
|
|
@@ -29,7 +29,13 @@ module Browserctl
|
|
|
29
29
|
|
|
30
30
|
# Returns the canonical header string for a given integer version.
|
|
31
31
|
def stamp(version:)
|
|
32
|
-
|
|
32
|
+
unless version.is_a?(Integer) && version >= 0
|
|
33
|
+
raise Browserctl::Error.new(
|
|
34
|
+
"version must be a non-negative Integer",
|
|
35
|
+
code: Browserctl::Error::Codes::INVALID_FORMAT_VERSION,
|
|
36
|
+
context: { value: version }
|
|
37
|
+
)
|
|
38
|
+
end
|
|
33
39
|
|
|
34
40
|
"version: #{version}\n"
|
|
35
41
|
end
|
data/lib/browserctl/logger.rb
CHANGED
|
@@ -132,7 +132,8 @@ module Browserctl
|
|
|
132
132
|
log.progname = component
|
|
133
133
|
log.formatter = JsonlFormatter.new(component: component)
|
|
134
134
|
log
|
|
135
|
-
rescue
|
|
135
|
+
rescue Errno::EACCES, Errno::ENOENT, Errno::EISDIR, Errno::ENOSPC, Errno::EROFS, IOError => e
|
|
136
|
+
warn "browserctl: failed to build jsonl logger (#{e.class}: #{e.message})"
|
|
136
137
|
nil
|
|
137
138
|
end
|
|
138
139
|
|
|
@@ -42,7 +42,14 @@ module Browserctl
|
|
|
42
42
|
# block receives the file path as a keyword argument and must rewrite
|
|
43
43
|
# the file in place to the new version.
|
|
44
44
|
def register(format:, from_version:, to_version:, &upgrade)
|
|
45
|
-
|
|
45
|
+
unless upgrade
|
|
46
|
+
raise Browserctl::Error.new(
|
|
47
|
+
"upgrade block required",
|
|
48
|
+
code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
|
|
49
|
+
context: { dsl: :migrations, action: :register, format: format,
|
|
50
|
+
from_version: from_version, to_version: to_version }
|
|
51
|
+
)
|
|
52
|
+
end
|
|
46
53
|
|
|
47
54
|
@mutex.synchronize do
|
|
48
55
|
@registry << Migration.new(format: format, from_version: from_version,
|
|
@@ -12,12 +12,13 @@ module RuboCop
|
|
|
12
12
|
# Subclasses with their own `default_code` are trusted (the cop does not
|
|
13
13
|
# try to statically resolve `default_code` across files); the contract
|
|
14
14
|
# is enforced by the unit test suite. This cop's job is to catch the
|
|
15
|
-
# specific failure mode of inlining a stale
|
|
16
|
-
#
|
|
15
|
+
# specific failure mode of inlining a stale code string at the raise
|
|
16
|
+
# site and bypassing the canonical enum.
|
|
17
17
|
#
|
|
18
18
|
# @example
|
|
19
|
-
# # bad — string literal that
|
|
20
|
-
#
|
|
19
|
+
# # bad — any string literal, even one that happens to match a
|
|
20
|
+
# # canonical code today, drifts when the enum is renamed
|
|
21
|
+
# raise Browserctl::Error, "state expired", code: "STATE_EXPIRED"
|
|
21
22
|
#
|
|
22
23
|
# # good — Codes constant reference
|
|
23
24
|
# raise Browserctl::Error, "state expired",
|
|
@@ -29,19 +30,16 @@ module RuboCop
|
|
|
29
30
|
# The pattern is intentionally narrow. The full default_code-vs-Codes
|
|
30
31
|
# reconciliation lives in `lib/browserctl/errors.rb` and is covered by
|
|
31
32
|
# `spec/unit/errors_spec.rb`.
|
|
33
|
+
#
|
|
34
|
+
# The cop was tightened in v0.14 WS-1 PR 5 to remove the previous
|
|
35
|
+
# "canonical SCREAMING_SNAKE string literals are also fine" escape
|
|
36
|
+
# hatch — every `code:` must now be a constant reference so renames in
|
|
37
|
+
# `Codes` propagate through the codebase via the constant, not via a
|
|
38
|
+
# stale whitelist baked into this cop.
|
|
32
39
|
class TypedError < RuboCop::Cop::Base
|
|
33
40
|
MSG = "Browserctl raise: `code:` must reference Browserctl::Error::Codes::* — " \
|
|
34
41
|
"got string literal %<value>p. See lib/browserctl/error/codes.rb."
|
|
35
42
|
|
|
36
|
-
VALID_CODES = %w[
|
|
37
|
-
AUTH_REQUIRED
|
|
38
|
-
SELECTOR_NOT_FOUND
|
|
39
|
-
STATE_EXPIRED
|
|
40
|
-
SECRET_RESOLUTION_FAILED
|
|
41
|
-
DAEMON_UNREACHABLE
|
|
42
|
-
PROTOCOL_MISMATCH
|
|
43
|
-
].freeze
|
|
44
|
-
|
|
45
43
|
# Matches `raise Browserctl::Foo, ..., code: <value>` and yields the
|
|
46
44
|
# `code:` value node.
|
|
47
45
|
def_node_matcher :browserctl_raise_with_code, <<~PATTERN
|
|
@@ -51,16 +49,27 @@ module RuboCop
|
|
|
51
49
|
(hash <(pair (sym :code) $_) ...>))
|
|
52
50
|
PATTERN
|
|
53
51
|
|
|
52
|
+
# Matches `raise Browserctl::Foo.new(..., code: <value>, ...)` and
|
|
53
|
+
# yields the `code:` value node. The base error initializer is
|
|
54
|
+
# routinely called via `.new` (see `Browserctl::Error#initialize`),
|
|
55
|
+
# so the cop needs to inspect both shapes.
|
|
56
|
+
def_node_matcher :browserctl_raise_new_with_code, <<~PATTERN
|
|
57
|
+
(send nil? :raise
|
|
58
|
+
(send (const (const nil? :Browserctl) _) :new
|
|
59
|
+
...
|
|
60
|
+
(hash <(pair (sym :code) $_) ...>)))
|
|
61
|
+
PATTERN
|
|
62
|
+
|
|
54
63
|
def on_send(node)
|
|
55
64
|
return unless node.method?(:raise)
|
|
56
65
|
|
|
57
|
-
|
|
66
|
+
[
|
|
67
|
+
browserctl_raise_with_code(node),
|
|
68
|
+
browserctl_raise_new_with_code(node)
|
|
69
|
+
].compact.each do |code_value|
|
|
58
70
|
next unless code_value.str_type?
|
|
59
71
|
|
|
60
|
-
value
|
|
61
|
-
next if VALID_CODES.include?(value)
|
|
62
|
-
|
|
63
|
-
add_offense(code_value, message: format(MSG, value: value))
|
|
72
|
+
add_offense(code_value, message: format(MSG, value: code_value.value))
|
|
64
73
|
end
|
|
65
74
|
end
|
|
66
75
|
end
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
4
|
+
require "timeout"
|
|
5
|
+
|
|
3
6
|
module Browserctl
|
|
4
7
|
class CommandDispatcher
|
|
5
8
|
module Handlers
|
|
@@ -75,7 +78,8 @@ module Browserctl
|
|
|
75
78
|
def capture_post_snapshot_digest(session)
|
|
76
79
|
snapshot = @snapshot_builder.call(session.page)
|
|
77
80
|
Browserctl::Replay::SnapshotDiff.digest(snapshot)
|
|
78
|
-
rescue
|
|
81
|
+
rescue JSON::ParserError, Timeout::Error, Browserctl::Error => e
|
|
82
|
+
Browserctl.logger.debug("post-snapshot digest skipped: #{e.class}: #{e.message}")
|
|
79
83
|
nil
|
|
80
84
|
end
|
|
81
85
|
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../flow_registry"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
|
|
6
|
+
module Browserctl
|
|
7
|
+
module State
|
|
8
|
+
# Drives the "rotate this bundle's bound flow and re-save" operation
|
|
9
|
+
# without dragging in CLI input concerns. Extracted from
|
|
10
|
+
# `Browserctl::Commands::State.run_rotate` so the rotation flow is:
|
|
11
|
+
# - testable without spawning a CLI subprocess
|
|
12
|
+
# - reusable from Workflow if a workflow ever needs to drive rotation
|
|
13
|
+
# programmatically.
|
|
14
|
+
#
|
|
15
|
+
# Errors are surfaced by raising typed `Browserctl::FlowError` so the
|
|
16
|
+
# caller (CLI or Workflow) can decide how to render them. The CLI maps
|
|
17
|
+
# to a non-zero exit; a Workflow caller may catch and continue.
|
|
18
|
+
class Mutator
|
|
19
|
+
Result = Struct.new(:save_result, :flow_name, :flow_version, keyword_init: true) do
|
|
20
|
+
def to_h
|
|
21
|
+
(save_result || {}).merge(rotated_flow: flow_name)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def initialize(client:, registry: Browserctl::FlowRegistry)
|
|
26
|
+
@client = client
|
|
27
|
+
@registry = registry
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Re-runs the flow bound to the bundle <name> and re-saves it under the
|
|
31
|
+
# same origins. `params` is the merged param set (file + caller-provided);
|
|
32
|
+
# the CLI does the file/k=v parsing before handing values in.
|
|
33
|
+
#
|
|
34
|
+
# @return [Result]
|
|
35
|
+
# @raise [Browserctl::FlowError]
|
|
36
|
+
def rotate(name:, params: {}, page: nil)
|
|
37
|
+
manifest = read_manifest!(name)
|
|
38
|
+
flow = resolve_bound_flow!(manifest)
|
|
39
|
+
|
|
40
|
+
flow.run(page: page, client: @client, **params)
|
|
41
|
+
|
|
42
|
+
save_result = @client.state_save(name,
|
|
43
|
+
flow: flow.name,
|
|
44
|
+
flow_version: flow.version_string,
|
|
45
|
+
origins: manifest[:origins] || manifest["origins"])
|
|
46
|
+
Result.new(save_result: save_result, flow_name: flow.name, flow_version: flow.version_string)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def read_manifest!(name)
|
|
52
|
+
info = @client.state_info(name)
|
|
53
|
+
err = info[:error] || info["error"]
|
|
54
|
+
raise Browserctl::FlowError, err.to_s if err
|
|
55
|
+
|
|
56
|
+
info[:info] || info["info"] || {}
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def resolve_bound_flow!(manifest)
|
|
60
|
+
flow_name = manifest[:flow] || manifest["flow"]
|
|
61
|
+
if flow_name.nil? || flow_name.to_s.empty?
|
|
62
|
+
raise Browserctl::FlowError,
|
|
63
|
+
"state has no bound flow — re-save with `state save --flow NAME` first"
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
flow = @registry.resolve(flow_name)
|
|
67
|
+
raise Browserctl::FlowError, "flow '#{flow_name}' not found in registry" unless flow
|
|
68
|
+
|
|
69
|
+
flow
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
data/lib/browserctl/state.rb
CHANGED
|
@@ -83,7 +83,11 @@ module Browserctl
|
|
|
83
83
|
def self.validate_name!(name)
|
|
84
84
|
return if SAFE_NAME.match?(name.to_s)
|
|
85
85
|
|
|
86
|
-
raise
|
|
86
|
+
raise Browserctl::Error.new(
|
|
87
|
+
"invalid state name #{name.inspect} — use letters, digits, _ or - (max 64 chars)",
|
|
88
|
+
code: Browserctl::Error::Codes::INVALID_STATE_NAME,
|
|
89
|
+
context: { name: name }
|
|
90
|
+
)
|
|
87
91
|
end
|
|
88
92
|
|
|
89
93
|
# Persist a bundle. `payload` is a `State::Payload` value object carrying
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Trace
|
|
7
|
+
# Reads `cli.log` + `daemon.log` JSONL files from a log directory and yields
|
|
8
|
+
# records sorted by timestamp. Malformed lines are skipped silently — this
|
|
9
|
+
# matches the tolerant behaviour expected of `browserctl trace`.
|
|
10
|
+
#
|
|
11
|
+
# Session filtering: when `session_filter` is provided, only records whose
|
|
12
|
+
# `session_id` matches are emitted. When nil, records are scoped to the most
|
|
13
|
+
# recent `session_id` observed in the merged stream (or all records if none
|
|
14
|
+
# carry a session id yet — backwards compatible with older logs).
|
|
15
|
+
class EventStream
|
|
16
|
+
include Enumerable
|
|
17
|
+
|
|
18
|
+
LOG_GLOB = "{cli,daemon}.log"
|
|
19
|
+
|
|
20
|
+
def initialize(log_dir, session_filter: nil)
|
|
21
|
+
@log_dir = log_dir
|
|
22
|
+
@session_filter = session_filter
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def each(&block)
|
|
26
|
+
return enum_for(:each) unless block
|
|
27
|
+
|
|
28
|
+
records.each(&block)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def empty?
|
|
32
|
+
records.empty?
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def records
|
|
36
|
+
@records ||= filter_session(load_records)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def load_records
|
|
42
|
+
paths = Dir.glob(File.join(@log_dir, LOG_GLOB))
|
|
43
|
+
rows = paths.flat_map do |path|
|
|
44
|
+
File.foreach(path).filter_map { |line| parse_line(line) }
|
|
45
|
+
end
|
|
46
|
+
rows.sort_by { |r| r["ts"].to_s }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def parse_line(line)
|
|
50
|
+
stripped = line.strip
|
|
51
|
+
return nil if stripped.empty?
|
|
52
|
+
|
|
53
|
+
JSON.parse(stripped)
|
|
54
|
+
rescue JSON::ParserError
|
|
55
|
+
nil
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Session resolution. When session_id is stamped on records (future PR),
|
|
59
|
+
# filter/select by it. Otherwise, treat the entire merged stream as one
|
|
60
|
+
# session — caller can scope by tailing/rotating logs.
|
|
61
|
+
# TODO: stamp session_id on every log line so this scopes correctly.
|
|
62
|
+
def filter_session(rows)
|
|
63
|
+
if @session_filter
|
|
64
|
+
rows.select { |r| r["session_id"].to_s == @session_filter }
|
|
65
|
+
else
|
|
66
|
+
ids = rows.map { |r| r["session_id"] }.compact.uniq
|
|
67
|
+
return rows if ids.empty?
|
|
68
|
+
|
|
69
|
+
recent = ids.last
|
|
70
|
+
rows.select { |r| r["session_id"] == recent }
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Browserctl
|
|
6
|
+
module Trace
|
|
7
|
+
# Renders structured log records as a human-scannable timeline. Output
|
|
8
|
+
# format is intentionally compact:
|
|
9
|
+
#
|
|
10
|
+
# HH:MM:SS.mmm I LEVEL COMPONENT LABEL k=v k=v
|
|
11
|
+
#
|
|
12
|
+
# Colour is enabled when the target IO is a TTY (or when `color: true` is
|
|
13
|
+
# forced). Redaction is optional and injected as a dependency so callers
|
|
14
|
+
# can substitute a stricter `Browserctl::Redactor` or pass `nil` to
|
|
15
|
+
# disable.
|
|
16
|
+
class Renderer
|
|
17
|
+
LEVEL_COLORS = {
|
|
18
|
+
"DEBUG" => "\e[2;37m", # dim grey
|
|
19
|
+
"INFO" => "\e[36m", # cyan
|
|
20
|
+
"WARN" => "\e[33m", # yellow
|
|
21
|
+
"ERROR" => "\e[31m" # red
|
|
22
|
+
}.freeze
|
|
23
|
+
RESET = "\e[0m"
|
|
24
|
+
|
|
25
|
+
CATEGORY_ICONS = {
|
|
26
|
+
error: "!",
|
|
27
|
+
snapshot: "S",
|
|
28
|
+
network: "N",
|
|
29
|
+
event: "."
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
OMIT_KEYS = %w[ts level component event msg].freeze
|
|
33
|
+
|
|
34
|
+
def initialize(io:, color: nil, redactor: nil)
|
|
35
|
+
@io = io
|
|
36
|
+
@color = color.nil? ? tty?(io) : color
|
|
37
|
+
@redactor = redactor
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def render(stream)
|
|
41
|
+
stream.each { |record| @io.puts(format_line(record)) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def tty?(io)
|
|
47
|
+
io.respond_to?(:tty?) && io.tty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def format_line(record)
|
|
51
|
+
level = (record["level"] || "INFO").to_s
|
|
52
|
+
line = format("%-12<ts>s %<icon>s %-5<level>s %-7<comp>s %-22<label>s %<ctx>s",
|
|
53
|
+
ts: format_ts(record["ts"]),
|
|
54
|
+
icon: CATEGORY_ICONS.fetch(categorise(record), "."),
|
|
55
|
+
level: level,
|
|
56
|
+
comp: (record["component"] || "?").to_s,
|
|
57
|
+
label: event_label(record),
|
|
58
|
+
ctx: context_snippet(record)).rstrip
|
|
59
|
+
|
|
60
|
+
line = @redactor.redact(line) if @redactor
|
|
61
|
+
@color ? colourise(line, level) : line
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def format_ts(timestamp)
|
|
65
|
+
Time.iso8601(timestamp.to_s).strftime("%H:%M:%S.%L")
|
|
66
|
+
rescue ArgumentError, TypeError
|
|
67
|
+
"??:??:??.???"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def categorise(record)
|
|
71
|
+
return :error if record["level"] == "ERROR" || record["error"]
|
|
72
|
+
return :snapshot if record["snapshot"]
|
|
73
|
+
return :network if record["request"] || record["response"] || record["url"]
|
|
74
|
+
|
|
75
|
+
:event
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def event_label(record)
|
|
79
|
+
(record["event"] || record["snapshot"] || record["request"] ||
|
|
80
|
+
record["msg"] || "-").to_s.slice(0, 22)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# Compact "k=v k=v" snippet of remaining structured keys, capped to keep
|
|
84
|
+
# the timeline scannable. Skips fields already shown in fixed columns.
|
|
85
|
+
def context_snippet(record)
|
|
86
|
+
pairs = record.except(*OMIT_KEYS)
|
|
87
|
+
return "" if pairs.empty?
|
|
88
|
+
|
|
89
|
+
pairs.map { |k, v| "#{k}=#{format_value(v)}" }.join(" ").slice(0, 120)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def format_value(value)
|
|
93
|
+
case value
|
|
94
|
+
when String then value.length > 40 ? "#{value[0, 37]}..." : value
|
|
95
|
+
when Array then "[#{value.length}]"
|
|
96
|
+
when Hash then "{#{value.keys.length}}"
|
|
97
|
+
else value.to_s
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def colourise(line, level)
|
|
102
|
+
colour = LEVEL_COLORS[level] || ""
|
|
103
|
+
"#{colour}#{line}#{RESET}"
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
data/lib/browserctl/version.rb
CHANGED
data/lib/browserctl/workflow.rb
CHANGED
|
@@ -320,9 +320,12 @@ module Browserctl
|
|
|
320
320
|
def compose(workflow_name)
|
|
321
321
|
name = workflow_name.to_s
|
|
322
322
|
if Browserctl.lookup_flow(name)
|
|
323
|
-
raise
|
|
324
|
-
|
|
325
|
-
|
|
323
|
+
raise Browserctl::Error.new(
|
|
324
|
+
"workflow '#{@name}' cannot compose flow '#{name}': flows return state, " \
|
|
325
|
+
"workflows share state — composition across kinds is not supported",
|
|
326
|
+
code: Browserctl::Error::Codes::INVALID_DSL_USAGE,
|
|
327
|
+
context: { workflow: @name, flow: name, kind: :cross_kind_compose }
|
|
328
|
+
)
|
|
326
329
|
end
|
|
327
330
|
|
|
328
331
|
source = Browserctl.lookup_workflow(name)
|