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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +66 -0
- data/README.md +2 -1
- data/bin/browserctl +168 -78
- data/bin/browserd +8 -1
- data/lib/browserctl/client.rb +50 -6
- data/lib/browserctl/commands/cli_output.rb +36 -3
- data/lib/browserctl/commands/flow.rb +123 -0
- data/lib/browserctl/commands/migrate.rb +94 -0
- data/lib/browserctl/commands/state.rb +193 -0
- data/lib/browserctl/commands/trace.rb +187 -0
- data/lib/browserctl/commands/workflow.rb +62 -4
- data/lib/browserctl/constants.rb +4 -2
- data/lib/browserctl/crash_report.rb +96 -0
- data/lib/browserctl/detectors/auth_required.rb +128 -0
- data/lib/browserctl/detectors.rb +2 -0
- data/lib/browserctl/error/codes.rb +44 -0
- data/lib/browserctl/error/exit_codes.rb +54 -0
- data/lib/browserctl/error/suggested_actions.rb +41 -0
- data/lib/browserctl/errors.rb +72 -12
- data/lib/browserctl/flow.rb +22 -1
- data/lib/browserctl/flow_registry.rb +66 -0
- data/lib/browserctl/flows/stdlib/basic_auth.rb +30 -0
- data/lib/browserctl/flows/stdlib/cloudflare_solve.rb +59 -0
- data/lib/browserctl/flows/stdlib/magic_link_email.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_github.rb +28 -0
- data/lib/browserctl/flows/stdlib/oauth_google.rb +30 -0
- data/lib/browserctl/flows/stdlib/totp_2fa.rb +61 -0
- data/lib/browserctl/format_version.rb +37 -0
- data/lib/browserctl/logger.rb +102 -9
- data/lib/browserctl/migrations.rb +216 -0
- data/lib/browserctl/recording.rb +246 -28
- data/lib/browserctl/redactor.rb +58 -0
- data/lib/browserctl/replay/context.rb +40 -0
- data/lib/browserctl/replay/fingerprint_matcher.rb +86 -0
- data/lib/browserctl/replay/snapshot_diff.rb +51 -0
- data/lib/browserctl/replay/telemetry.rb +60 -0
- data/lib/browserctl/rubocop/cops/typed_error.rb +69 -0
- data/lib/browserctl/runner.rb +50 -10
- data/lib/browserctl/secret_resolver_registry.rb +23 -4
- data/lib/browserctl/server/command_dispatcher.rb +13 -1
- data/lib/browserctl/server/handlers/daemon_control.rb +5 -1
- data/lib/browserctl/server/handlers/error_payload.rb +27 -0
- data/lib/browserctl/server/handlers/interaction.rb +21 -3
- data/lib/browserctl/server/handlers/navigation.rb +50 -5
- data/lib/browserctl/server/handlers/observation.rb +43 -2
- data/lib/browserctl/server/handlers/state.rb +149 -0
- data/lib/browserctl/server/page_session.rb +9 -7
- data/lib/browserctl/server/snapshot_builder.rb +21 -45
- data/lib/browserctl/session.rb +1 -1
- data/lib/browserctl/snapshot/annotator.rb +75 -0
- data/lib/browserctl/snapshot/extractor.rb +21 -0
- data/lib/browserctl/snapshot/fingerprint.rb +88 -0
- data/lib/browserctl/snapshot/ref.rb +70 -0
- data/lib/browserctl/snapshot/serializer.rb +17 -0
- data/lib/browserctl/state/bundle.rb +283 -0
- data/lib/browserctl/state/transport.rb +64 -0
- data/lib/browserctl/state/transports/file.rb +35 -0
- data/lib/browserctl/state/transports/one_password.rb +67 -0
- data/lib/browserctl/state/transports/s3.rb +42 -0
- data/lib/browserctl/state.rb +208 -0
- data/lib/browserctl/version.rb +1 -1
- data/lib/browserctl/workflow/flow_wrapper.rb +81 -0
- data/lib/browserctl/workflow/promoter.rb +96 -0
- data/lib/browserctl/workflow/promotion_ledger.rb +72 -0
- data/lib/browserctl/workflow.rb +235 -16
- metadata +44 -7
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "json"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "constants"
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
require_relative "version"
|
|
9
|
+
require_relative "state/bundle"
|
|
10
|
+
require_relative "state/transport"
|
|
11
|
+
require_relative "state/transports/file"
|
|
12
|
+
require_relative "state/transports/s3"
|
|
13
|
+
require_relative "state/transports/one_password"
|
|
14
|
+
|
|
15
|
+
module Browserctl
|
|
16
|
+
# Top-level state store: a single .bctl bundle per name under
|
|
17
|
+
# ~/.browserctl/state/<name>.bctl. Wraps the Bundle codec with on-disk
|
|
18
|
+
# naming, validation, and a small inventory API used by `state list/info`.
|
|
19
|
+
#
|
|
20
|
+
# Data shape inside a bundle:
|
|
21
|
+
#
|
|
22
|
+
# manifest = {
|
|
23
|
+
# name: String,
|
|
24
|
+
# version: 1, # bundle schema version
|
|
25
|
+
# producer: "browserctl/<gem-ver>",
|
|
26
|
+
# created_at: ISO-8601,
|
|
27
|
+
# origins: [String, ...],
|
|
28
|
+
# flow: String | nil, # bound flow name, for `state rotate`
|
|
29
|
+
# flow_version: String | nil,
|
|
30
|
+
# expires_at: ISO-8601 | nil, # earliest cookie expiry
|
|
31
|
+
# encrypted: Boolean
|
|
32
|
+
# }
|
|
33
|
+
#
|
|
34
|
+
# payload = {
|
|
35
|
+
# cookies: [Hash, ...],
|
|
36
|
+
# local_storage: { origin => { key => value } },
|
|
37
|
+
# session_storage: { origin => { key => value } }
|
|
38
|
+
# }
|
|
39
|
+
module State
|
|
40
|
+
BASE_DIR = File.join(BROWSERCTL_DIR, "state")
|
|
41
|
+
SAFE_NAME = /\A[a-zA-Z0-9_-]{1,64}\z/
|
|
42
|
+
EXTENSION = ".bctl"
|
|
43
|
+
MANIFEST_VERSION = 1
|
|
44
|
+
|
|
45
|
+
def self.path(name) = File.join(BASE_DIR, "#{name}#{EXTENSION}")
|
|
46
|
+
def self.exist?(name) = File.exist?(path(name))
|
|
47
|
+
|
|
48
|
+
def self.validate_name!(name)
|
|
49
|
+
return if SAFE_NAME.match?(name.to_s)
|
|
50
|
+
|
|
51
|
+
raise ArgumentError, "invalid state name #{name.inspect} — use letters, digits, _ or - (max 64 chars)"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Persist a bundle. `payload` is { cookies:, local_storage:, session_storage: }.
|
|
55
|
+
# `manifest_extras` may carry origins (override), flow, flow_version.
|
|
56
|
+
def self.save(name, payload:, origins: nil, flow: nil, flow_version: nil, passphrase: nil) # rubocop:disable Metrics/ParameterLists
|
|
57
|
+
validate_name!(name)
|
|
58
|
+
FileUtils.mkdir_p(BASE_DIR)
|
|
59
|
+
|
|
60
|
+
manifest = build_manifest(
|
|
61
|
+
name: name,
|
|
62
|
+
origins: origins || derive_origins(payload),
|
|
63
|
+
flow: flow,
|
|
64
|
+
flow_version: flow_version,
|
|
65
|
+
cookies: payload[:cookies] || payload["cookies"] || [],
|
|
66
|
+
encrypted: !passphrase.nil?
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
blob = Bundle.encode(manifest: manifest, payload: payload, passphrase: passphrase)
|
|
70
|
+
File.open(path(name), "wb", 0o600) { |f| f.write(blob) }
|
|
71
|
+
manifest
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Load and decode a bundle. Returns { manifest:, payload:, encrypted: }.
|
|
75
|
+
def self.load(name, passphrase: nil)
|
|
76
|
+
validate_name!(name)
|
|
77
|
+
raise Browserctl::Error, "state '#{name}' not found" unless exist?(name)
|
|
78
|
+
|
|
79
|
+
Bundle.decode(File.binread(path(name)), passphrase: passphrase)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def self.delete(name)
|
|
83
|
+
validate_name!(name)
|
|
84
|
+
FileUtils.rm_f(path(name))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Copies the on-disk .bctl bundle to a transport-addressable destination
|
|
88
|
+
# (file path, s3://bucket/key, op://Vault/Item, or any registered scheme).
|
|
89
|
+
# Bundle bytes are written verbatim — no re-encoding — so the receiving
|
|
90
|
+
# side can verify the manifest/payload exactly as produced.
|
|
91
|
+
def self.export(name, destination)
|
|
92
|
+
validate_name!(name)
|
|
93
|
+
raise Browserctl::Error, "state '#{name}' not found" unless exist?(name)
|
|
94
|
+
|
|
95
|
+
transport, parsed = Transport.for(destination)
|
|
96
|
+
blob = ::File.binread(path(name))
|
|
97
|
+
transport.write(parsed, blob)
|
|
98
|
+
{ name: name, destination: destination, bytes: blob.bytesize }
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Pulls a bundle from a transport-addressable source and stores it as a
|
|
102
|
+
# local state. Validates the magic header before persisting so we never
|
|
103
|
+
# leave a corrupt bundle in the state directory. `name` defaults to the
|
|
104
|
+
# source's basename without `.bctl`.
|
|
105
|
+
def self.import(source, name: nil)
|
|
106
|
+
transport, parsed = Transport.for(source)
|
|
107
|
+
blob = transport.read(parsed)
|
|
108
|
+
raise Bundle::BundleError, "imported blob is not a .bctl bundle" unless blob.start_with?(Bundle::MAGIC)
|
|
109
|
+
|
|
110
|
+
manifest = Bundle.peek_manifest(blob)
|
|
111
|
+
target_name = name || derive_name(source) || manifest[:name]
|
|
112
|
+
validate_name!(target_name)
|
|
113
|
+
|
|
114
|
+
FileUtils.mkdir_p(BASE_DIR)
|
|
115
|
+
::File.open(path(target_name), "wb", 0o600) { |f| f.write(blob) }
|
|
116
|
+
{ name: target_name, source: source, bytes: blob.bytesize, encrypted: manifest[:encrypted] }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def self.derive_name(uri)
|
|
120
|
+
base = ::File.basename(uri.to_s.split("?").first.to_s, EXTENSION)
|
|
121
|
+
return nil if base.empty?
|
|
122
|
+
|
|
123
|
+
base
|
|
124
|
+
end
|
|
125
|
+
private_class_method :derive_name
|
|
126
|
+
|
|
127
|
+
# Read manifests for all stored bundles. Errors on a single file are
|
|
128
|
+
# surfaced via { error: "...", path: "..." } rather than aborting the list.
|
|
129
|
+
def self.all
|
|
130
|
+
return [] unless Dir.exist?(BASE_DIR)
|
|
131
|
+
|
|
132
|
+
Dir[File.join(BASE_DIR, "*#{EXTENSION}")].map do |file|
|
|
133
|
+
info_for(file)
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
# Inspect a single bundle without decrypting the payload.
|
|
138
|
+
def self.info(name)
|
|
139
|
+
validate_name!(name)
|
|
140
|
+
raise Browserctl::Error, "state '#{name}' not found" unless exist?(name)
|
|
141
|
+
|
|
142
|
+
info_for(path(name))
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def self.info_for(file)
|
|
146
|
+
blob = File.binread(file)
|
|
147
|
+
manifest = Bundle.peek_manifest(blob)
|
|
148
|
+
manifest.merge(
|
|
149
|
+
path: file,
|
|
150
|
+
size: blob.bytesize
|
|
151
|
+
)
|
|
152
|
+
rescue Bundle::BundleError => e
|
|
153
|
+
{ name: File.basename(file, EXTENSION), path: file, error: e.message }
|
|
154
|
+
end
|
|
155
|
+
private_class_method :info_for
|
|
156
|
+
|
|
157
|
+
def self.build_manifest(name:, origins:, flow:, flow_version:, cookies:, encrypted:) # rubocop:disable Metrics/ParameterLists
|
|
158
|
+
{
|
|
159
|
+
name: name,
|
|
160
|
+
version: MANIFEST_VERSION,
|
|
161
|
+
producer: "browserctl/#{Browserctl::VERSION}",
|
|
162
|
+
created_at: Time.now.utc.iso8601,
|
|
163
|
+
origins: Array(origins).compact.uniq,
|
|
164
|
+
flow: flow,
|
|
165
|
+
flow_version: flow_version,
|
|
166
|
+
expires_at: earliest_expiry(cookies),
|
|
167
|
+
encrypted: encrypted
|
|
168
|
+
}
|
|
169
|
+
end
|
|
170
|
+
private_class_method :build_manifest
|
|
171
|
+
|
|
172
|
+
# Origins captured from the payload itself when not overridden. Pulls from
|
|
173
|
+
# cookie domains and storage keys to cover both navigation-tracked and
|
|
174
|
+
# cookie-only auth.
|
|
175
|
+
def self.derive_origins(payload)
|
|
176
|
+
cookies = fetch_either(payload, :cookies, "cookies", default: [])
|
|
177
|
+
ls = fetch_either(payload, :local_storage, "local_storage", default: {})
|
|
178
|
+
ss = fetch_either(payload, :session_storage, "session_storage", default: {})
|
|
179
|
+
|
|
180
|
+
(origins_from_cookies(cookies) + ls.keys.map(&:to_s) + ss.keys.map(&:to_s)).uniq
|
|
181
|
+
end
|
|
182
|
+
private_class_method :derive_origins
|
|
183
|
+
|
|
184
|
+
def self.origins_from_cookies(cookies)
|
|
185
|
+
cookies.filter_map do |c|
|
|
186
|
+
domain = (c[:domain] || c["domain"]).to_s
|
|
187
|
+
next if domain.empty?
|
|
188
|
+
|
|
189
|
+
domain.start_with?(".") ? "https://#{domain[1..]}" : "https://#{domain}"
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
private_class_method :origins_from_cookies
|
|
193
|
+
|
|
194
|
+
def self.fetch_either(hash, sym_key, str_key, default:)
|
|
195
|
+
hash[sym_key] || hash[str_key] || default
|
|
196
|
+
end
|
|
197
|
+
private_class_method :fetch_either
|
|
198
|
+
|
|
199
|
+
def self.earliest_expiry(cookies)
|
|
200
|
+
times = cookies.filter_map do |c|
|
|
201
|
+
v = c[:expires] || c["expires"] || c[:expiresAt] || c["expiresAt"]
|
|
202
|
+
v&.to_f&.positive? ? Time.at(v.to_f).utc.iso8601 : nil
|
|
203
|
+
end
|
|
204
|
+
times.min
|
|
205
|
+
end
|
|
206
|
+
private_class_method :earliest_expiry
|
|
207
|
+
end
|
|
208
|
+
end
|
data/lib/browserctl/version.rb
CHANGED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "../constants"
|
|
5
|
+
|
|
6
|
+
module Browserctl
|
|
7
|
+
module Workflow
|
|
8
|
+
# Renders a `Browserctl.flow` definition that wraps a promoted workflow.
|
|
9
|
+
# The flow becomes a globally-registered, parameterised handle that runs
|
|
10
|
+
# the underlying workflow via `Runner#run_workflow`. Params are inferred
|
|
11
|
+
# from the workflow's `param_defs` so callers see the same surface area
|
|
12
|
+
# they would on the workflow itself.
|
|
13
|
+
#
|
|
14
|
+
# Wrapping (rather than translating step-by-step) keeps the workflow as
|
|
15
|
+
# the single source of truth: edits to the workflow file flow through
|
|
16
|
+
# to the wrapper without regeneration.
|
|
17
|
+
module FlowWrapper
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def target_dir
|
|
21
|
+
File.join(Browserctl::BROWSERCTL_DIR, "flows")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def target_path(name)
|
|
25
|
+
File.join(target_dir, "#{name}.rb")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# @param defn [Browserctl::WorkflowDefinition]
|
|
29
|
+
# @return [String] Ruby source for a flow file
|
|
30
|
+
def render(defn)
|
|
31
|
+
params = defn.param_defs.values.map { |p| render_param(p) }.join("\n")
|
|
32
|
+
desc = defn.description || "Promoted from workflow '#{defn.name}'"
|
|
33
|
+
<<~RUBY
|
|
34
|
+
# frozen_string_literal: true
|
|
35
|
+
|
|
36
|
+
require "browserctl/flow"
|
|
37
|
+
require "browserctl/runner"
|
|
38
|
+
|
|
39
|
+
# Auto-generated flow wrapper for workflow '#{defn.name}'.
|
|
40
|
+
# Edit the underlying workflow file rather than this wrapper.
|
|
41
|
+
Browserctl.flow(#{defn.name.inspect}) do
|
|
42
|
+
version "1.0.0"
|
|
43
|
+
requires_browserctl "0.11.0"
|
|
44
|
+
desc #{desc.inspect}
|
|
45
|
+
|
|
46
|
+
#{params.gsub(/^/, ' ') unless params.empty?}
|
|
47
|
+
|
|
48
|
+
step("run workflow #{defn.name}") do
|
|
49
|
+
Browserctl::Runner.new.run_workflow(#{defn.name.inspect}, **params)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
RUBY
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# @param defn [Browserctl::WorkflowDefinition]
|
|
56
|
+
# @param overwrite [Boolean]
|
|
57
|
+
# @param dir [String, nil] override target dir (testing)
|
|
58
|
+
# @return [String] path written
|
|
59
|
+
def write(defn, overwrite: true, dir: nil)
|
|
60
|
+
path = dir ? File.join(dir, "#{defn.name}.rb") : target_path(defn.name)
|
|
61
|
+
if File.exist?(path) && !overwrite
|
|
62
|
+
raise Browserctl::WorkflowError, "flow wrapper already exists at #{path} (pass overwrite: true to replace)"
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
66
|
+
File.write(path, render(defn))
|
|
67
|
+
path
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def render_param(param)
|
|
71
|
+
opts = []
|
|
72
|
+
opts << "required: true" if param.required
|
|
73
|
+
opts << "secret: true" if param.secret && !param.secret_ref
|
|
74
|
+
opts << "secret_ref: #{param.secret_ref.inspect}" if param.secret_ref
|
|
75
|
+
opts << "default: #{param.default.inspect}" unless param.default.nil?
|
|
76
|
+
suffix = opts.empty? ? "" : ", #{opts.join(', ')}"
|
|
77
|
+
"param :#{param.name}#{suffix}"
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require_relative "../constants"
|
|
5
|
+
require_relative "promotion_ledger"
|
|
6
|
+
require_relative "flow_wrapper"
|
|
7
|
+
|
|
8
|
+
module Browserctl
|
|
9
|
+
module Workflow
|
|
10
|
+
# Promotes a workflow file from the project-local `.browserctl/workflows/`
|
|
11
|
+
# directory to the user-global `~/.browserctl/workflows/` directory, where
|
|
12
|
+
# it is invocable from any project.
|
|
13
|
+
#
|
|
14
|
+
# Promotion is gated by `PromotionLedger.clean_streak`: a workflow must
|
|
15
|
+
# have at least `threshold` consecutive clean `--check` runs before it
|
|
16
|
+
# can be promoted. `--force` overrides the gate.
|
|
17
|
+
module Promoter
|
|
18
|
+
class IneligibleError < StandardError
|
|
19
|
+
attr_reader :streak, :threshold
|
|
20
|
+
|
|
21
|
+
def initialize(workflow:, streak:, threshold:)
|
|
22
|
+
@streak = streak
|
|
23
|
+
@threshold = threshold
|
|
24
|
+
super(
|
|
25
|
+
"workflow '#{workflow}' has #{streak} clean --check run(s); " \
|
|
26
|
+
"needs #{threshold}. Run `browserctl workflow run #{workflow} --check` " \
|
|
27
|
+
"until clean, or pass --force to override."
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
class NotFoundError < StandardError; end
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
DEFAULT_SOURCE_DIR = ".browserctl/workflows"
|
|
37
|
+
|
|
38
|
+
def target_dir
|
|
39
|
+
File.join(Browserctl::BROWSERCTL_DIR, "workflows")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def source_path(workflow, source_dir: DEFAULT_SOURCE_DIR)
|
|
43
|
+
File.join(source_dir, "#{workflow}.rb")
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def target_path(workflow)
|
|
47
|
+
File.join(target_dir, "#{workflow}.rb")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @param workflow [String]
|
|
51
|
+
# @param force [Boolean]
|
|
52
|
+
# @param threshold [Integer]
|
|
53
|
+
# @param source_dir [String] override the source directory (testing)
|
|
54
|
+
# @param ledger_path [String] override the ledger path (testing)
|
|
55
|
+
# @return [Hash] `{ workflow:, source:, target:, streak:, threshold:, forced: }`
|
|
56
|
+
def promote(workflow:, force: false, threshold: PromotionLedger::DEFAULT_THRESHOLD, # rubocop:disable Metrics/ParameterLists
|
|
57
|
+
as_flow: false, source_dir: DEFAULT_SOURCE_DIR,
|
|
58
|
+
ledger_path: PromotionLedger.ledger_path, flow_dir: nil)
|
|
59
|
+
src = source_path(workflow, source_dir: source_dir)
|
|
60
|
+
raise NotFoundError, "workflow file not found: #{src}" unless File.exist?(src)
|
|
61
|
+
|
|
62
|
+
streak = PromotionLedger.clean_streak(workflow: workflow, path: ledger_path)
|
|
63
|
+
unless force || streak >= threshold
|
|
64
|
+
raise IneligibleError.new(workflow: workflow, streak: streak, threshold: threshold)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
dst = target_path(workflow)
|
|
68
|
+
FileUtils.mkdir_p(File.dirname(dst))
|
|
69
|
+
FileUtils.cp(src, dst)
|
|
70
|
+
|
|
71
|
+
result = {
|
|
72
|
+
workflow: workflow,
|
|
73
|
+
source: src,
|
|
74
|
+
target: dst,
|
|
75
|
+
streak: streak,
|
|
76
|
+
threshold: threshold,
|
|
77
|
+
forced: force && streak < threshold
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
result[:flow] = wrap_as_flow(workflow, dst, flow_dir) if as_flow
|
|
81
|
+
result
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Loads the just-promoted workflow file, infers params from its
|
|
85
|
+
# WorkflowDefinition, and writes a flow wrapper at `flow_dir`
|
|
86
|
+
# (defaults to `~/.browserctl/flows/<name>.rb`).
|
|
87
|
+
def wrap_as_flow(workflow, workflow_path, flow_dir)
|
|
88
|
+
load workflow_path
|
|
89
|
+
defn = Browserctl.lookup_workflow(workflow.to_s) or
|
|
90
|
+
raise NotFoundError, "workflow '#{workflow}' did not register after load"
|
|
91
|
+
|
|
92
|
+
FlowWrapper.write(defn, overwrite: true, dir: flow_dir)
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "time"
|
|
6
|
+
require_relative "../constants"
|
|
7
|
+
|
|
8
|
+
module Browserctl
|
|
9
|
+
module Workflow
|
|
10
|
+
# Append-only JSONL ledger of `workflow run --check` outcomes per workflow.
|
|
11
|
+
# Used as the gate for `workflow promote`: only workflows with a sufficient
|
|
12
|
+
# streak of clean runs are eligible for promotion to `~/.browserctl/workflows/`.
|
|
13
|
+
#
|
|
14
|
+
# Record schema (one JSONL line):
|
|
15
|
+
# { "ts": "2026-05-10T12:00:00Z", "workflow": "name", "verdict": "clean" }
|
|
16
|
+
module PromotionLedger
|
|
17
|
+
LEDGER_BASENAME = "check_ledger.jsonl"
|
|
18
|
+
DEFAULT_THRESHOLD = 3
|
|
19
|
+
VALID_VERDICTS = %i[clean drift fail].freeze
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
def ledger_path
|
|
24
|
+
File.join(Browserctl::BROWSERCTL_DIR, LEDGER_BASENAME)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Append a verdict for a workflow run.
|
|
28
|
+
# @param workflow [String]
|
|
29
|
+
# @param verdict [Symbol] :clean, :drift, or :fail
|
|
30
|
+
# @param path [String] override (testing)
|
|
31
|
+
def record(workflow:, verdict:, path: ledger_path, at: Time.now.utc)
|
|
32
|
+
return unless VALID_VERDICTS.include?(verdict)
|
|
33
|
+
|
|
34
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
35
|
+
File.open(path, "a") do |f|
|
|
36
|
+
f.puts JSON.generate(
|
|
37
|
+
ts: at.iso8601,
|
|
38
|
+
workflow: workflow.to_s,
|
|
39
|
+
verdict: verdict.to_s
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Count the trailing streak of :clean verdicts for a workflow.
|
|
45
|
+
# A non-clean verdict resets the streak. Drift and fail both break it
|
|
46
|
+
# — the gate is intentionally strict; users can override with --force.
|
|
47
|
+
# @return [Integer]
|
|
48
|
+
def clean_streak(workflow:, path: ledger_path)
|
|
49
|
+
return 0 unless File.exist?(path)
|
|
50
|
+
|
|
51
|
+
streak = 0
|
|
52
|
+
File.foreach(path) do |line|
|
|
53
|
+
entry = parse(line) or next
|
|
54
|
+
next unless entry["workflow"] == workflow.to_s
|
|
55
|
+
|
|
56
|
+
if entry["verdict"] == "clean"
|
|
57
|
+
streak += 1
|
|
58
|
+
else
|
|
59
|
+
streak = 0
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
streak
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def parse(line)
|
|
66
|
+
JSON.parse(line)
|
|
67
|
+
rescue JSON::ParserError
|
|
68
|
+
nil
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|