Module: RoundhouseUi::Tags

Defined in:
lib/roundhouse_ui/tags.rb

Overview

Resolves host-defined tags for a job at read time (no middleware, no storage — works on every backend and applies retroactively to jobs already in the sets). The host supplies the resolver; see ADR 0002.

RoundhouseUi.job_tags = RoundhouseUi::Tags.from_constant(:OWNER, as: :squad)
# or any callable:
RoundhouseUi.job_tags = ->(klass:, item:) { { squad: :growth } }

Contract: resolver output is normalized to string keys/values, masked via Redaction (redact_args patterns apply to tag keys), and a raising resolver yields no tags — tagging must never break a page.

Constant Summary collapse

EMPTY =
{}.freeze

Class Method Summary collapse

Class Method Details

.effective_klass(klass, item) ⇒ Object

ActiveJob-on-Sidekiq stores the adapter's JobWrapper in item and the real job class in item; Solid Queue and raw Sidekiq workers put the real class in klass. Resolvers always get the real one.



91
92
93
94
# File 'lib/roundhouse_ui/tags.rb', line 91

def effective_klass(klass, item)
  wrapped = item["wrapped"] if item.is_a?(Hash)
  (wrapped || klass)&.to_s
end

.filtersObject

The declared filter vocabulary, normalized to { "key" => ["value", ...] }, or nil when the host declared none (the filter UI then discovers values from the entries it scans). Both the whole setting and individual values may be callables, so vocabularies can be dynamic.



65
66
67
68
69
70
71
72
73
74
75
76
77
# File 'lib/roundhouse_ui/tags.rb', line 65

def filters
  declared = RoundhouseUi.tag_filters
  declared = declared.call if declared.respond_to?(:call)
  return nil unless declared.is_a?(Hash)

  declared.each_with_object({}) do |(key, values), out|
    values = values.call if values.respond_to?(:call)
    out[key.to_s] = Array(values).map(&:to_s)
  end
rescue => e
  warn_once("tag_filters failed: #{e.message}")
  nil
end

.for(klass:, item:, cache: nil) ⇒ Object

Tags for one job entry, as a { "key" => "value" } Hash (EMPTY when no resolver is configured, the resolver declines, or it raises).

klass/item come from the backend entry (entry.klass / entry.item); the ActiveJob adapter wrapper is unwrapped here, so resolvers always see the real job class. Pass a Hash as cache to memoize per class across a request — unused (and unneeded) in per-job mode.



25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# File 'lib/roundhouse_ui/tags.rb', line 25

def for(klass:, item:, cache: nil)
  resolver = RoundhouseUi.job_tags
  return EMPTY unless resolver

  effective = effective_klass(klass, item)
  return EMPTY unless effective

  if RoundhouseUi.job_tags_per_job
    # Per-job mode still memoizes, keyed by jid rather than class: a job's
    # tags cannot change within one request, and the same entry is resolved
    # twice otherwise — once while scanning, once when its badge renders.
    jid = item["jid"] if item.is_a?(Hash)
    return resolve(resolver, effective, item) unless cache && jid

    cache.key?(jid) ? cache[jid] : cache[jid] = resolve(resolver, effective, item)
  elsif cache
    # Class-cached mode: item is withheld (deliberately — see resolve).
    cache.key?(effective) ? cache[effective] : cache[effective] = resolve(resolver, effective, nil)
  else
    resolve(resolver, effective, nil)
  end
end

.from_constant(const_name, as: const_name.to_s.downcase) ⇒ Object

The class-constant convention (Trainual's OWNER pattern) as a resolver:

RoundhouseUi.job_tags = RoundhouseUi::Tags.from_constant(:OWNER, as: :squad)

Tags every job whose class (or ancestor — inherited constants count, so a base-class OWNER covers subclasses) defines the constant.



54
55
56
57
58
59
# File 'lib/roundhouse_ui/tags.rb', line 54

def from_constant(const_name, as: const_name.to_s.downcase)
  lambda do |klass:, item:|
    k = klass.to_s.safe_constantize
    { as => k.const_get(const_name) } if k&.const_defined?(const_name)
  end
end

.match?(tags, key, value) ⇒ Boolean

Does a resolved tag Hash match a key/value filter? Exact match on the normalized (post-redaction) value — so a redacted tag matches only its mask, and the filter can't be used to probe redacted values.

Returns:

  • (Boolean)


82
83
84
# File 'lib/roundhouse_ui/tags.rb', line 82

def match?(tags, key, value)
  tags[key.to_s] == value.to_s
end

.resolve(resolver, klass, item) ⇒ Object

item is nil in class-cached mode: an args-reading resolver cached by class would poison the cache with first-job-wins values — withholding the payload makes it fail deterministically (rescued → no tags) instead. Hosts that need the payload set RoundhouseUi.job_tags_per_job = true.



100
101
102
103
104
105
106
107
108
109
# File 'lib/roundhouse_ui/tags.rb', line 100

def resolve(resolver, klass, item)
  tags = resolver.call(klass: klass, item: item)
  return EMPTY unless tags.is_a?(Hash)

  normalized = tags.each_with_object({}) { |(k, v), out| out[k.to_s] = v.to_s }
  Redaction.apply(normalized)
rescue => e
  warn_once("job_tags resolver failed for #{klass}: #{e.message}")
  EMPTY
end

.warn_once(message) ⇒ Object



111
112
113
# File 'lib/roundhouse_ui/tags.rb', line 111

def warn_once(message)
  Rails.logger&.warn("[roundhouse] #{message}") if defined?(Rails)
end