deployangel 0.1.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.
@@ -0,0 +1,205 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module DeployAngel
6
+ module MCP
7
+ # A Model Context Protocol server over stdio (JSON-RPC 2.0, one message
8
+ # per line) so coding agents can ask whether their release passed
9
+ # production. stdout carries only protocol messages.
10
+ #
11
+ # The tools are read-only with respect to production: nothing here rolls
12
+ # back, restarts, or changes customer infrastructure.
13
+ class Server
14
+ PROTOCOL_VERSIONS = %w[2025-06-18 2025-03-26 2024-11-05].freeze
15
+ MAX_WAIT_SECONDS = 300
16
+
17
+ INSTRUCTIONS = <<~TEXT.freeze
18
+ DeployAngel verifies deployments in production. After deploying, call wait_for_verification
19
+ with until "initial" (about 15 minutes) or "verdict". Verdicts: verified means cleared, so you may
20
+ report the release as successful. inconclusive means NOT verified: never report it as success.
21
+ failed means production regressed: read the findings and exceptions and investigate.
22
+ Findings are deterministic evidence; any "investigation" field is AI inference. These tools cannot
23
+ change production. Do not roll back or change production without explicit approval.
24
+ TEXT
25
+
26
+ TARGET_PROPERTIES = {
27
+ "commit" => { "type" => "string", "description" => "Commit SHA (prefix of 7+ characters is fine)." },
28
+ "version" => { "type" => "string", "description" => "Release version, e.g. v184." },
29
+ "deployment_id" => { "type" => "string", "description" => "DeployAngel deployment ID." },
30
+ "latest" => { "type" => "boolean", "description" => "Use the most recent deployment." }
31
+ }.freeze
32
+
33
+ ERROR_CODES = { parse: -32_700, invalid: -32_600, method: -32_601, params: -32_602, internal: -32_603 }.freeze
34
+
35
+ def initialize(client:, input: $stdin, output: $stdout, error_output: $stderr,
36
+ sleeper: ->(seconds) { sleep(seconds) }, clock: -> { Time.now }, git_head: -> {})
37
+ @client = client
38
+ @input = input
39
+ @output = output
40
+ @error_output = error_output
41
+ @sleeper = sleeper
42
+ @clock = clock
43
+ @git_head = git_head
44
+ end
45
+
46
+ def run
47
+ @input.each_line do |line|
48
+ next if line.strip.empty?
49
+
50
+ response = handle_line(line)
51
+ next unless response
52
+
53
+ @output.puts(JSON.generate(response))
54
+ @output.flush
55
+ end
56
+ end
57
+
58
+ def handle_line(line)
59
+ message = JSON.parse(line)
60
+ return error_response(nil, :invalid, "expected a JSON object") unless message.is_a?(Hash)
61
+
62
+ handle(message)
63
+ rescue JSON::ParserError
64
+ error_response(nil, :parse, "invalid JSON")
65
+ end
66
+
67
+ def handle(message)
68
+ id = message["id"]
69
+ method = message["method"]
70
+ return nil if id.nil? # notifications, including notifications/initialized
71
+
72
+ case method
73
+ when "initialize" then result(id, initialize_result(message.dig("params", "protocolVersion")))
74
+ when "ping" then result(id, {})
75
+ when "tools/list" then result(id, { "tools" => tools })
76
+ when "tools/call" then result(id, call_tool(message.dig("params", "name"), message.dig("params", "arguments") || {}))
77
+ else error_response(id, :method, "method not found: #{method}")
78
+ end
79
+ rescue ArgumentError => e
80
+ error_response(id, :params, e.message)
81
+ rescue StandardError => e
82
+ @error_output.puts("deployangel mcp: #{e.class}: #{e.message}")
83
+ error_response(id, :internal, "internal error")
84
+ end
85
+
86
+ def tools
87
+ list = [
88
+ tool("get_verification", "Current verdict document for a deployment: state, verdict, confidence, coverage, " \
89
+ "findings, new exceptions, clearance report, and what is still watched. Defaults to the current git HEAD.",
90
+ TARGET_PROPERTIES),
91
+ tool("wait_for_verification", "Wait for a deployment's verification. until=initial returns at the 15-minute " \
92
+ "initial check (\"no problems so far\" is NOT clearance); until=verdict waits for verified, failed, or " \
93
+ "inconclusive. Returns the in-progress document if timeout_seconds (max 300) is reached; call again " \
94
+ "while it is still in progress. Inconclusive means not verified.",
95
+ TARGET_PROPERTIES.merge(
96
+ "until" => { "type" => "string", "enum" => %w[initial verdict], "default" => "initial" },
97
+ "timeout_seconds" => { "type" => "integer", "minimum" => 1, "maximum" => MAX_WAIT_SECONDS, "default" => MAX_WAIT_SECONDS }
98
+ )),
99
+ tool("list_deployments", "Recent deployments with their verification state and verdict.",
100
+ { "limit" => { "type" => "integer", "minimum" => 1, "maximum" => 50, "default" => 10 } }),
101
+ tool("get_exception", "Sanitized details and the application stack trace for an exception fingerprint.",
102
+ { "fingerprint" => { "type" => "string" } }, required: %w[fingerprint]),
103
+ tool("list_late_regressions", "Failures found after a release was cleared, on paths the clearance did not cover.",
104
+ { "since" => { "type" => "string", "description" => "ISO 8601 time" },
105
+ "limit" => { "type" => "integer", "minimum" => 1, "maximum" => 50, "default" => 10 } })
106
+ ]
107
+ if scopes.include?("deployments")
108
+ list << tool("register_deployment", "Register a deployment so it is verified (manual and CI deploys).",
109
+ { "commit" => { "type" => "string" }, "version" => { "type" => "string" },
110
+ "kind" => { "type" => "string", "enum" => %w[code config rollback promotion] } })
111
+ end
112
+ list
113
+ end
114
+
115
+ private
116
+ def initialize_result(requested)
117
+ {
118
+ "protocolVersion" => PROTOCOL_VERSIONS.include?(requested) ? requested : PROTOCOL_VERSIONS.first,
119
+ "capabilities" => { "tools" => { "listChanged" => false } },
120
+ "serverInfo" => { "name" => "deployangel", "version" => DeployAngel::VERSION },
121
+ "instructions" => INSTRUCTIONS
122
+ }
123
+ end
124
+
125
+ def call_tool(name, arguments)
126
+ content =
127
+ case name
128
+ when "get_verification" then waiter.wait(target(arguments), wait: false).then { |o| verification_content(o) }
129
+ when "wait_for_verification" then wait_content(arguments)
130
+ when "list_deployments" then @client.deployments(limit: arguments.fetch("limit", 10))
131
+ when "get_exception" then @client.exception(arguments.fetch("fingerprint") { raise ArgumentError, "fingerprint is required" })
132
+ when "list_late_regressions" then @client.late_regressions(since: arguments["since"], limit: arguments.fetch("limit", 10))
133
+ when "register_deployment"
134
+ raise ArgumentError, "register_deployment is not available for this token" unless scopes.include?("deployments")
135
+
136
+ @client.register_deployment(commit: arguments["commit"], version: arguments["version"], kind: arguments["kind"])
137
+ else raise ArgumentError, "unknown tool: #{name}"
138
+ end
139
+ { "content" => [ { "type" => "text", "text" => JSON.pretty_generate(content) } ],
140
+ "structuredContent" => content, "isError" => false }
141
+ rescue Client::Error => e
142
+ { "content" => [ { "type" => "text", "text" => "DeployAngel API error: #{e.message}" } ], "isError" => true }
143
+ end
144
+
145
+ def wait_content(arguments)
146
+ until_mode = arguments.fetch("until", "initial")
147
+ raise ArgumentError, "until must be initial or verdict" unless %w[initial verdict].include?(until_mode)
148
+
149
+ timeout = arguments.fetch("timeout_seconds", MAX_WAIT_SECONDS).to_i.clamp(1, MAX_WAIT_SECONDS)
150
+ verification_content(waiter.wait(target(arguments), until_mode: until_mode, timeout: timeout))
151
+ end
152
+
153
+ def verification_content(outcome)
154
+ return { "found" => false, "note" => "No matching deployment is registered yet." } if outcome.not_found
155
+
156
+ { "exit_code" => outcome.exit_code, "meaning" => meaning(outcome.exit_code),
157
+ "in_progress" => outcome.exit_code == VerificationWaiter::TIMED_OUT, "verification" => outcome.document }
158
+ end
159
+
160
+ def meaning(code)
161
+ {
162
+ 0 => "verified: the release is cleared",
163
+ 1 => "failed: production regressed",
164
+ 2 => "inconclusive: NOT verified; do not report success",
165
+ 3 => "still in progress; call wait_for_verification again",
166
+ 6 => "initial check: no problems so far, NOT cleared yet",
167
+ 7 => "initial check: warnings present, NOT cleared"
168
+ }.fetch(code, "unknown")
169
+ end
170
+
171
+ def target(arguments)
172
+ return { deployment_id: arguments["deployment_id"].to_s } if arguments["deployment_id"]
173
+ return { version: arguments["version"].to_s } if arguments["version"]
174
+ return { commit: arguments["commit"].to_s.downcase } if arguments["commit"]
175
+ return { latest: true } if arguments["latest"]
176
+
177
+ head = @git_head.call
178
+ head ? { commit: head.downcase } : { latest: true }
179
+ end
180
+
181
+ def waiter
182
+ VerificationWaiter.new(client: @client, sleeper: @sleeper, clock: @clock)
183
+ end
184
+
185
+ def scopes
186
+ @scopes ||= Array(@client.token_info["scopes"])
187
+ rescue Client::Error
188
+ []
189
+ end
190
+
191
+ def tool(name, description, properties, required: [])
192
+ { "name" => name, "description" => description,
193
+ "inputSchema" => { "type" => "object", "properties" => properties, "required" => required } }
194
+ end
195
+
196
+ def result(id, payload)
197
+ { "jsonrpc" => "2.0", "id" => id, "result" => payload }
198
+ end
199
+
200
+ def error_response(id, kind, message)
201
+ { "jsonrpc" => "2.0", "id" => id, "error" => { "code" => ERROR_CODES.fetch(kind), "message" => message } }
202
+ end
203
+ end
204
+ end
205
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeployAngel
4
+ module Rails
5
+ # Instruments every ActiveJob adapter (Solid Queue, Sidekiq, GoodJob,
6
+ # ...) through ActiveSupport::Notifications.
7
+ #
8
+ # Failures handled by retry_on or discard_on never surface on the
9
+ # perform event, so enqueue_retry, discard, and retry_stopped mark the
10
+ # job while it performs. Everything is then counted exactly once when
11
+ # perform finishes.
12
+ module ActiveJob
13
+ STATE = :@__deployangel_job_state
14
+
15
+ module_function
16
+
17
+ def install
18
+ return if @installed || !defined?(::ActiveSupport::Notifications)
19
+
20
+ @installed = true
21
+ DeployAngel.add_capability("jobs")
22
+ subscribe("perform_start.active_job") { |payload| state(payload)[:queue_latency_ms] = queue_latency_ms(payload[:job]) }
23
+ subscribe("enqueue_retry.active_job") { |payload| state(payload).merge!(failed: true, error: payload[:error]) }
24
+ subscribe("discard.active_job") { |payload| state(payload).merge!(failed: true, discarded: true, error: payload[:error]) }
25
+ subscribe("retry_stopped.active_job") { |payload| state(payload).merge!(failed: true, discarded: true, error: payload[:error]) }
26
+ ::ActiveSupport::Notifications.subscribe("perform.active_job") { |event| record(event) }
27
+ end
28
+
29
+ def record(event)
30
+ return unless DeployAngel.recording?
31
+
32
+ job = event.payload[:job]
33
+ state = job&.instance_variable_get(STATE) || {}
34
+ error = event.payload[:exception_object] || state[:error]
35
+ DeployAngel.record_exception(error, source: "job_class:#{job.class.name}") if error
36
+ DeployAngel.record_job(
37
+ job_class: job.class.name,
38
+ duration_ms: event.duration,
39
+ failed: state[:failed] || !event.payload[:exception_object].nil?,
40
+ discarded: state[:discarded] || false,
41
+ queue_latency_ms: state[:queue_latency_ms]
42
+ )
43
+ rescue StandardError
44
+ nil
45
+ end
46
+
47
+ def subscribe(name)
48
+ ::ActiveSupport::Notifications.subscribe(name) do |*, payload|
49
+ yield payload if DeployAngel.recording? && payload[:job]
50
+ rescue StandardError
51
+ nil
52
+ end
53
+ end
54
+
55
+ def state(payload)
56
+ job = payload[:job]
57
+ job.instance_variable_get(STATE) || job.instance_variable_set(STATE, {})
58
+ end
59
+
60
+ # From when the job became runnable: its scheduled time, else when it
61
+ # was enqueued.
62
+ def queue_latency_ms(job)
63
+ runnable_at = job.scheduled_at || job.enqueued_at
64
+ return unless runnable_at
65
+
66
+ [ (Time.now - runnable_at) * 1000.0, 0 ].max
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeployAngel
4
+ module Rails
5
+ # Receives Rails.error reports, for handled errors only (Rails.error.handle
6
+ # and Rails.error.report(handled: true)), shown for context.
7
+ #
8
+ # Rails also reports unhandled request and job exceptions here, before the
9
+ # middleware and job instrumentation see them. Those paths record them
10
+ # with their route or job class, so unhandled reports are skipped here.
11
+ class ErrorSubscriber
12
+ def report(error, handled:, **)
13
+ DeployAngel.record_exception(error, handled: true) if handled
14
+ rescue StandardError
15
+ nil
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,65 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeployAngel
4
+ module Rails
5
+ # Rack middleware at the top of the stack, so it sees the final status
6
+ # after Rails renders exceptions. Records the matched route pattern,
7
+ # never the raw path, which keeps IDs out of route keys.
8
+ class Http
9
+ FORMAT_SUFFIX = "(.:format)"
10
+
11
+ def initialize(app)
12
+ @app = app
13
+ end
14
+
15
+ def call(env)
16
+ return @app.call(env) unless DeployAngel.recording?
17
+
18
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
19
+ begin
20
+ status, headers, body = @app.call(env)
21
+ rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
22
+ record(env, 500, started, unhandled: true, exception: e)
23
+ raise
24
+ end
25
+ # Rails renders some exceptions as 4xx (routing errors, RecordNotFound,
26
+ # ParameterMissing); only those that end in a 5xx count as unhandled.
27
+ exception = env["action_dispatch.exception"] if status.to_i >= 500
28
+ record(env, status, started, unhandled: !exception.nil?, exception: exception)
29
+ [ status, headers, body ]
30
+ end
31
+
32
+ private
33
+ def record(env, status, started, unhandled:, exception: nil)
34
+ route = route_key(env)
35
+ # Unrouted successes are static files and similar; unrouted
36
+ # errors (such as routing 404s) still count.
37
+ return if route.nil? && status.to_i < 400
38
+
39
+ key = route || "#{env["REQUEST_METHOD"]} unmatched"
40
+ DeployAngel.record_exception(exception, source: "route:#{key}") if exception
41
+ DeployAngel.record_request(
42
+ route_key: key,
43
+ status: status.to_i,
44
+ duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0,
45
+ unhandled: unhandled
46
+ )
47
+ rescue StandardError
48
+ nil
49
+ end
50
+
51
+ def route_key(env)
52
+ method = env["REQUEST_METHOD"]
53
+ if (pattern = route_pattern(env))
54
+ "#{method} #{pattern.delete_suffix(FORMAT_SUFFIX)}"
55
+ elsif (params = env["action_dispatch.request.path_parameters"]) && params[:controller]
56
+ "#{method} #{params[:controller]}##{params[:action]}"
57
+ end
58
+ end
59
+
60
+ def route_pattern(env)
61
+ env["action_dispatch.route_uri_pattern"] || env["action_dispatch.route"]&.path&.spec&.to_s
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "erb"
5
+ require "yaml"
6
+
7
+ module DeployAngel
8
+ module Rails
9
+ # What the application contains, sent once per process: the route table,
10
+ # job classes, declared recurring schedules, critical flows, and file
11
+ # digests. Paths and hashes only; source code never leaves the
12
+ # application.
13
+ class Metadata
14
+ DIGEST_GLOBS = %w[app/**/* config/**/* lib/**/* db/migrate/**/* Gemfile.lock].freeze
15
+ MAX_FILES = 20_000
16
+ DIGEST_LENGTH = 16
17
+ FORMAT_SUFFIX = "(.:format)"
18
+
19
+ def initialize(app:, config:, root:)
20
+ @app = app
21
+ @config = config
22
+ @root = root
23
+ end
24
+
25
+ def to_protocol
26
+ {
27
+ "routes" => routes,
28
+ "job_classes" => job_classes,
29
+ "schedules" => schedules,
30
+ "critical_flows" => @config.critical_flows.transform_keys(&:to_s).transform_values { |items| Array(items).map(&:to_s) },
31
+ "file_manifest" => file_manifest.slice("hash", "count", "truncated")
32
+ }
33
+ end
34
+
35
+ def files
36
+ file_manifest["files"]
37
+ end
38
+
39
+ def routes
40
+ @app.routes.routes.flat_map do |route|
41
+ controller = route.defaults[:controller]
42
+ next [] if controller.nil? || controller.start_with?("rails/", "active_storage/", "action_mailbox/")
43
+
44
+ path = route.path.spec.to_s.delete_suffix(FORMAT_SUFFIX)
45
+ route.verb.to_s.split("|").reject(&:empty?).map do |verb|
46
+ { "key" => "#{verb} #{path}", "controller" => controller, "action" => route.defaults[:action].to_s }
47
+ end
48
+ end.uniq { |route| route["key"] }
49
+ rescue StandardError
50
+ []
51
+ end
52
+
53
+ def job_classes
54
+ return [] unless defined?(::ActiveJob::Base)
55
+
56
+ ::ActiveJob::Base.descendants.filter_map(&:name).reject { |name| name == "ApplicationJob" }.sort
57
+ rescue StandardError
58
+ []
59
+ end
60
+
61
+ # Solid Queue recurring tasks for the current environment.
62
+ def schedules
63
+ path = File.join(@root, "config", "recurring.yml")
64
+ return [] unless File.file?(path)
65
+
66
+ config = YAML.safe_load(ERB.new(File.read(path)).result, aliases: true) || {}
67
+ tasks = config.key?(::Rails.env) ? config[::Rails.env] : config
68
+ Array(tasks).filter_map do |key, task|
69
+ next unless task.is_a?(Hash) && task["schedule"]
70
+
71
+ { "key" => key.to_s, "class" => task["class"]&.to_s, "schedule" => task["schedule"].to_s, "source" => "solid_queue" }
72
+ end
73
+ rescue StandardError
74
+ []
75
+ end
76
+
77
+ def file_manifest
78
+ @file_manifest ||= begin
79
+ if @config.file_digests
80
+ paths = DIGEST_GLOBS.flat_map { |glob| Dir.glob(File.join(@root, glob)) }.select { |path| File.file?(path) }.sort
81
+ truncated = paths.size > MAX_FILES
82
+ files = paths.first(MAX_FILES).to_h do |path|
83
+ [ path.delete_prefix(@root).delete_prefix("/"), Digest::SHA256.file(path).hexdigest[0, DIGEST_LENGTH] ]
84
+ end
85
+ hash = Digest::SHA256.hexdigest(files.map { |path, digest| "#{path}:#{digest}" }.join("\n"))
86
+ { "hash" => hash, "count" => files.size, "truncated" => truncated, "files" => files }
87
+ else
88
+ { "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
89
+ end
90
+ end
91
+ rescue StandardError
92
+ { "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/railtie"
4
+
5
+ module DeployAngel
6
+ module Rails
7
+ class Railtie < ::Rails::Railtie
8
+ initializer "deployangel.middleware" do |app|
9
+ app.config.middleware.insert(0, DeployAngel::Rails::Http)
10
+ end
11
+
12
+ initializer "deployangel.jobs" do
13
+ ActiveSupport.on_load(:active_job) { DeployAngel::Rails::ActiveJob.install }
14
+ DeployAngel::Sidekiq.install
15
+ end
16
+
17
+ # Handled reports (Rails.error.handle / Rails.error.report) are shown for
18
+ # context; only unhandled exceptions count toward verdicts.
19
+ initializer "deployangel.error_reporter" do |app|
20
+ app.executor.error_reporter&.subscribe(DeployAngel::Rails::ErrorSubscriber.new) if app.executor.respond_to?(:error_reporter)
21
+ end
22
+
23
+ config.after_initialize do |app|
24
+ agent = DeployAngel.start(
25
+ environment: ::Rails.env,
26
+ root: ::Rails.root.to_s,
27
+ framework: "rails",
28
+ framework_version: ::Rails.version,
29
+ logger: ::Rails.logger
30
+ )
31
+ agent&.metadata = DeployAngel::Rails::Metadata.new(app: app, config: DeployAngel.configuration, root: ::Rails.root.to_s)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeployAngel
4
+ # Server middleware for native Sidekiq jobs (Sidekiq::Job without
5
+ # ActiveJob). ActiveJob jobs on Sidekiq are recorded by the ActiveJob
6
+ # instrumentation instead, so they are skipped here to avoid double counts.
7
+ module Sidekiq
8
+ ACTIVE_JOB_WRAPPERS = %w[
9
+ Sidekiq::ActiveJob::Wrapper
10
+ ActiveJob::QueueAdapters::SidekiqAdapter::JobWrapper
11
+ ].freeze
12
+
13
+ class ServerMiddleware
14
+ def call(_job_instance, job, _queue)
15
+ return yield if ACTIVE_JOB_WRAPPERS.include?(job["class"]) || !DeployAngel.recording?
16
+
17
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
18
+ latency = DeployAngel::Sidekiq.queue_latency_ms(job)
19
+ begin
20
+ yield
21
+ rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
22
+ DeployAngel.record_exception(e, source: "job_class:#{job["class"]}")
23
+ record(job, started, latency, failed: true)
24
+ raise
25
+ end
26
+ record(job, started, latency, failed: false)
27
+ end
28
+
29
+ private
30
+ def record(job, started, latency, failed:)
31
+ DeployAngel.record_job(job_class: job["class"], failed: failed, queue_latency_ms: latency,
32
+ duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0)
33
+ rescue StandardError
34
+ nil
35
+ end
36
+ end
37
+
38
+ module_function
39
+
40
+ def install
41
+ return if @installed || !defined?(::Sidekiq) || !::Sidekiq.respond_to?(:configure_server)
42
+
43
+ @installed = true
44
+ DeployAngel.add_capability("jobs")
45
+ ::Sidekiq.configure_server do |config|
46
+ config.server_middleware { |chain| chain.add(ServerMiddleware) }
47
+ # Jobs that exhausted their retries are discarded.
48
+ config.death_handlers << ->(job, _error) { record_death(job) } if config.respond_to?(:death_handlers)
49
+ end
50
+ end
51
+
52
+ # A dead job was already counted as a failed attempt by the middleware;
53
+ # this only records that it will not run again.
54
+ def record_death(job)
55
+ return if ACTIVE_JOB_WRAPPERS.include?(job["class"]) || !DeployAngel.agent
56
+
57
+ DeployAngel.agent.record_discard(job_class: job["class"])
58
+ rescue StandardError
59
+ nil
60
+ end
61
+
62
+ # Sidekiq 8 stores epoch milliseconds; earlier versions store seconds.
63
+ def queue_latency_ms(job)
64
+ enqueued = job["enqueued_at"]
65
+ return unless enqueued.is_a?(Numeric)
66
+
67
+ enqueued_ms = enqueued > 100_000_000_000 ? enqueued : enqueued * 1000.0
68
+ [ (Time.now.to_f * 1000.0) - enqueued_ms, 0 ].max
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeployAngel
4
+ # Finds a deployment and polls its verdict document until the requested
5
+ # point: the 15-minute initial check, a verdict, or the end of watching.
6
+ # Shared by the CLI and the MCP server.
7
+ class VerificationWaiter
8
+ Outcome = Struct.new(:document, :exit_code, :timed_out, :not_found, keyword_init: true)
9
+
10
+ # Exit codes are the primary signal for agents and CI.
11
+ EXIT_CODES = { "verified" => 0, "failed" => 1, "inconclusive" => 2 }.freeze
12
+ TIMED_OUT = 3
13
+ NOT_FOUND = 4
14
+ INITIAL_OK = 6
15
+ INITIAL_WARNINGS = 7
16
+ UNTIL_MODES = %w[initial verdict closed].freeze
17
+ PENDING_POLL = 15
18
+
19
+ def initialize(client:, sleeper: ->(seconds) { sleep(seconds) }, clock: -> { Time.now }, on_progress: nil)
20
+ @client = client
21
+ @sleeper = sleeper
22
+ @clock = clock
23
+ @on_progress = on_progress
24
+ end
25
+
26
+ # target: { deployment_id: } | { version: } | { commit: } | { latest: true }
27
+ def wait(target, until_mode: "verdict", timeout: 1800, wait: true)
28
+ deadline = @clock.call + timeout
29
+ last_state = nil
30
+ loop do
31
+ deployment = find(target)
32
+ if deployment.nil?
33
+ return Outcome.new(not_found: true, exit_code: NOT_FOUND) if !wait || @clock.call >= deadline
34
+
35
+ progress("Waiting for deployment #{describe(target)} to be registered…") unless last_state == :pending_registration
36
+ last_state = :pending_registration
37
+ @sleeper.call([ PENDING_POLL, deadline - @clock.call ].min.clamp(1, PENDING_POLL))
38
+ next
39
+ end
40
+
41
+ document = @client.verification(deployment["id"])
42
+ state = document.dig("verification", "state")
43
+ progress("#{document.dig("deployment", "version") || document.dig("deployment", "commit")}: #{state}") if state != last_state
44
+ last_state = state
45
+
46
+ code = self.class.exit_code(document, until_mode)
47
+ return Outcome.new(document: document, exit_code: code) if code
48
+ return Outcome.new(document: document, exit_code: TIMED_OUT) unless wait
49
+ return Outcome.new(document: document, exit_code: TIMED_OUT, timed_out: true) if @clock.call >= deadline
50
+
51
+ poll = (document["poll_after_seconds"] || 30).to_i.clamp(5, 60)
52
+ @sleeper.call([ poll, deadline - @clock.call ].min.clamp(1, 60))
53
+ end
54
+ end
55
+
56
+ # nil means "keep waiting". Failed always returns immediately.
57
+ def self.exit_code(document, until_mode)
58
+ verification = document["verification"] || {}
59
+ verdict = verification["verdict"]
60
+ return EXIT_CODES["failed"] if verdict == "failed"
61
+
62
+ case until_mode
63
+ when "closed"
64
+ EXIT_CODES[verdict] if verdict && verification["state"] == "closed"
65
+ when "initial"
66
+ if verdict
67
+ EXIT_CODES[verdict]
68
+ elsif (check = verification["initial_check"])
69
+ check["result"] == "warnings" ? INITIAL_WARNINGS : INITIAL_OK
70
+ end
71
+ else
72
+ EXIT_CODES[verdict] if verdict
73
+ end
74
+ end
75
+
76
+ private
77
+ def find(target)
78
+ if target[:deployment_id]
79
+ { "id" => target[:deployment_id] }
80
+ elsif target[:latest]
81
+ @client.latest_deployment
82
+ else
83
+ @client.deployments(commit: target[:commit], version: target[:version], limit: 1).first
84
+ end
85
+ rescue Client::NotFound
86
+ nil
87
+ end
88
+
89
+ def describe(target)
90
+ target[:version] || target[:commit]&.slice(0, 12) || target[:deployment_id] || "latest"
91
+ end
92
+
93
+ def progress(message)
94
+ @on_progress&.call(message)
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeployAngel
4
+ VERSION = "0.1.0"
5
+ end