textus 0.53.0 → 0.54.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 (149) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/SPEC.md +2 -5
  4. data/docs/architecture/README.md +149 -159
  5. data/lib/textus/action/accept.rb +0 -9
  6. data/lib/textus/action/audit.rb +0 -2
  7. data/lib/textus/action/blame.rb +0 -2
  8. data/lib/textus/action/boot.rb +2 -11
  9. data/lib/textus/action/data_mv.rb +1 -3
  10. data/lib/textus/action/deps.rb +1 -15
  11. data/lib/textus/action/doctor.rb +0 -2
  12. data/lib/textus/action/drain.rb +4 -6
  13. data/lib/textus/action/enqueue.rb +3 -5
  14. data/lib/textus/action/get.rb +1 -3
  15. data/lib/textus/action/ingest.rb +135 -0
  16. data/lib/textus/action/jobs.rb +1 -3
  17. data/lib/textus/action/key_delete.rb +0 -10
  18. data/lib/textus/action/key_delete_prefix.rb +1 -3
  19. data/lib/textus/action/key_mv.rb +1 -15
  20. data/lib/textus/action/key_mv_prefix.rb +1 -3
  21. data/lib/textus/action/list.rb +0 -2
  22. data/lib/textus/action/propose.rb +0 -2
  23. data/lib/textus/action/published.rb +0 -2
  24. data/lib/textus/action/pulse.rb +7 -38
  25. data/lib/textus/action/put.rb +1 -12
  26. data/lib/textus/action/rdeps.rb +2 -12
  27. data/lib/textus/action/reject.rb +0 -12
  28. data/lib/textus/action/rule_explain.rb +0 -4
  29. data/lib/textus/action/rule_lint.rb +1 -3
  30. data/lib/textus/action/rule_list.rb +0 -4
  31. data/lib/textus/action/schema_envelope.rb +0 -2
  32. data/lib/textus/action/uid.rb +0 -2
  33. data/lib/textus/action/where.rb +0 -2
  34. data/lib/textus/action/write_verb.rb +4 -18
  35. data/lib/textus/boot.rb +33 -109
  36. data/lib/textus/command.rb +6 -1
  37. data/lib/textus/container.rb +1 -1
  38. data/lib/textus/core/freshness/evaluator.rb +18 -34
  39. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  40. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  41. data/lib/textus/doctor/check.rb +0 -1
  42. data/lib/textus/doctor.rb +2 -42
  43. data/lib/textus/envelope/reader.rb +46 -0
  44. data/lib/textus/envelope/writer.rb +209 -0
  45. data/lib/textus/errors.rb +8 -4
  46. data/lib/textus/{entry → format}/base.rb +1 -6
  47. data/lib/textus/{entry → format}/json.rb +1 -4
  48. data/lib/textus/{entry → format}/markdown.rb +2 -6
  49. data/lib/textus/{entry → format}/text.rb +2 -6
  50. data/lib/textus/{entry → format}/yaml.rb +1 -4
  51. data/lib/textus/{entry.rb → format.rb} +9 -10
  52. data/lib/textus/gate/auth.rb +58 -12
  53. data/lib/textus/gate.rb +2 -0
  54. data/lib/textus/init.rb +12 -75
  55. data/lib/textus/jobs/base.rb +23 -0
  56. data/lib/textus/jobs/materialize.rb +20 -0
  57. data/lib/textus/{background → jobs}/plan.rb +1 -1
  58. data/lib/textus/jobs/planner.rb +101 -0
  59. data/lib/textus/jobs/retention.rb +48 -0
  60. data/lib/textus/jobs/sweep.rb +27 -0
  61. data/lib/textus/{background → jobs}/worker.rb +2 -2
  62. data/lib/textus/jobs.rb +15 -0
  63. data/lib/textus/key/path.rb +1 -1
  64. data/lib/textus/manifest/entry/base.rb +4 -19
  65. data/lib/textus/manifest/entry/parser.rb +27 -10
  66. data/lib/textus/manifest/entry/produced.rb +13 -34
  67. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
  68. data/lib/textus/manifest/entry/publish.rb +0 -12
  69. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  70. data/lib/textus/manifest/entry/validators.rb +0 -1
  71. data/lib/textus/manifest/policy/source.rb +14 -62
  72. data/lib/textus/manifest/policy.rb +2 -5
  73. data/lib/textus/manifest/resolver.rb +2 -2
  74. data/lib/textus/manifest/schema/keys.rb +6 -20
  75. data/lib/textus/manifest/schema/validator.rb +3 -11
  76. data/lib/textus/manifest/schema/vocabulary.rb +1 -0
  77. data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
  78. data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
  79. data/lib/textus/produce/engine.rb +50 -0
  80. data/lib/textus/produce/render.rb +18 -0
  81. data/lib/textus/session.rb +1 -1
  82. data/lib/textus/store.rb +1 -10
  83. data/lib/textus/surfaces/mcp/catalog.rb +1 -1
  84. data/lib/textus/surfaces/mcp/errors.rb +0 -9
  85. data/lib/textus/surfaces/mcp/routing.rb +51 -0
  86. data/lib/textus/surfaces/mcp/server.rb +101 -62
  87. data/lib/textus/surfaces/role_scope.rb +0 -4
  88. data/lib/textus/surfaces/watcher.rb +3 -3
  89. data/lib/textus/version.rb +1 -1
  90. data/lib/textus/workflow/collector.rb +27 -0
  91. data/lib/textus/workflow/context.rb +5 -0
  92. data/lib/textus/workflow/dsl.rb +44 -0
  93. data/lib/textus/workflow/loader.rb +17 -0
  94. data/lib/textus/workflow/pattern.rb +18 -0
  95. data/lib/textus/workflow/registry.rb +21 -0
  96. data/lib/textus/workflow/runner.rb +84 -0
  97. data/lib/textus/workflow.rb +28 -0
  98. data/lib/textus.rb +12 -1
  99. metadata +33 -61
  100. data/lib/textus/action/pulse/scanner.rb +0 -118
  101. data/lib/textus/background/job/base.rb +0 -27
  102. data/lib/textus/background/job/materialize.rb +0 -31
  103. data/lib/textus/background/job/refresh.rb +0 -22
  104. data/lib/textus/background/job/sweep.rb +0 -31
  105. data/lib/textus/background/job.rb +0 -19
  106. data/lib/textus/background/planner/plan.rb +0 -113
  107. data/lib/textus/background/retention/apply.rb +0 -50
  108. data/lib/textus/doctor/check/handler_permit.rb +0 -34
  109. data/lib/textus/doctor/check/hooks.rb +0 -25
  110. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  111. data/lib/textus/envelope/io/reader.rb +0 -48
  112. data/lib/textus/envelope/io/writer.rb +0 -211
  113. data/lib/textus/events.rb +0 -21
  114. data/lib/textus/init/templates/machine_intake.rb +0 -49
  115. data/lib/textus/init/templates/orientation_reducer.rb +0 -21
  116. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  117. data/lib/textus/manifest/policy/handler_permit.rb +0 -17
  118. data/lib/textus/pipeline/acquire/handler.rb +0 -29
  119. data/lib/textus/pipeline/acquire/intake.rb +0 -132
  120. data/lib/textus/pipeline/acquire/projection.rb +0 -129
  121. data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
  122. data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
  123. data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
  124. data/lib/textus/pipeline/acquire/serializer.rb +0 -17
  125. data/lib/textus/pipeline/engine.rb +0 -97
  126. data/lib/textus/pipeline/render.rb +0 -25
  127. data/lib/textus/ports/audit_subscriber.rb +0 -42
  128. data/lib/textus/projection.rb +0 -103
  129. data/lib/textus/step/base.rb +0 -35
  130. data/lib/textus/step/builtin/csv_fetch.rb +0 -19
  131. data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
  132. data/lib/textus/step/builtin/json_fetch.rb +0 -18
  133. data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
  134. data/lib/textus/step/builtin/rss_fetch.rb +0 -26
  135. data/lib/textus/step/builtin.rb +0 -22
  136. data/lib/textus/step/catalog.rb +0 -37
  137. data/lib/textus/step/context.rb +0 -74
  138. data/lib/textus/step/discovery.rb +0 -24
  139. data/lib/textus/step/error_log.rb +0 -32
  140. data/lib/textus/step/event_bus.rb +0 -113
  141. data/lib/textus/step/fetch.rb +0 -13
  142. data/lib/textus/step/fire_report.rb +0 -23
  143. data/lib/textus/step/loader.rb +0 -108
  144. data/lib/textus/step/observe.rb +0 -31
  145. data/lib/textus/step/registry_store.rb +0 -66
  146. data/lib/textus/step/signature.rb +0 -31
  147. data/lib/textus/step/transform.rb +0 -12
  148. data/lib/textus/step/validate.rb +0 -11
  149. data/lib/textus/step.rb +0 -10
@@ -1,35 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Step
5
- class Base
6
- class << self
7
- # The step kind is derived from class hierarchy.
8
- def kind
9
- return :fetch if defined?(Step::Fetch) && self <= Step::Fetch
10
- return :transform if defined?(Step::Transform) && self <= Step::Transform
11
- return :validate if defined?(Step::Validate) && self <= Step::Validate
12
- return :observe if defined?(Step::Observe) && self <= Step::Observe
13
-
14
- raise NotImplementedError.new("#{self} is not a known step kind")
15
- end
16
-
17
- # Required #call kwargs the loader validates against the subclass.
18
- def required_kwargs = []
19
-
20
- # Built-ins (and only built-ins) override the registered name when the
21
- # Ruby class name can't carry it (e.g. "markdown-links").
22
- def step_name(value = :__read__)
23
- if value == :__read__
24
- @step_name
25
- else
26
- @step_name = value.to_s
27
- end
28
- end
29
- end
30
-
31
- # Assigned by the loader/registry at registration time.
32
- attr_accessor :name
33
- end
34
- end
35
- end
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "csv"
4
- require "yaml"
5
-
6
- module Textus
7
- module Step
8
- module Builtin
9
- class CsvFetch < Step::Fetch
10
- step_name "csv"
11
- def call(config:, args:, **)
12
- _ = args
13
- rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
14
- { _meta: {}, body: YAML.dump(rows) }
15
- end
16
- end
17
- end
18
- end
19
- end
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
-
5
- module Textus
6
- module Step
7
- module Builtin
8
- class IcalEventsFetch < Step::Fetch
9
- step_name "ical-events"
10
- def call(config:, args:, **)
11
- _ = args
12
- events_list = []
13
- current = nil
14
- config["bytes"].to_s.each_line do |line|
15
- line = line.strip
16
- case line
17
- when "BEGIN:VEVENT" then current = {}
18
- when "END:VEVENT"
19
- events_list << current if current
20
- current = nil
21
- when /\A(SUMMARY|DTSTART|DTEND|UID|LOCATION|DESCRIPTION):(.*)\z/
22
- current[Regexp.last_match(1).downcase] = Regexp.last_match(2) if current
23
- end
24
- end
25
- { _meta: {}, body: YAML.dump(events_list) }
26
- end
27
- end
28
- end
29
- end
30
- end
@@ -1,18 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "yaml"
5
-
6
- module Textus
7
- module Step
8
- module Builtin
9
- class JsonFetch < Step::Fetch
10
- step_name "json"
11
- def call(config:, args:, **)
12
- _ = args
13
- { _meta: {}, body: YAML.dump(JSON.parse(config["bytes"].to_s)) }
14
- end
15
- end
16
- end
17
- end
18
- end
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
-
5
- module Textus
6
- module Step
7
- module Builtin
8
- class MarkdownLinksFetch < Step::Fetch
9
- step_name "markdown-links"
10
- def call(config:, args:, **)
11
- _ = args
12
- links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
13
- { "text" => text, "href" => href }
14
- end
15
- { _meta: {}, body: YAML.dump(links) }
16
- end
17
- end
18
- end
19
- end
20
- end
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "rexml/document"
4
- require "yaml"
5
-
6
- module Textus
7
- module Step
8
- module Builtin
9
- class RssFetch < Step::Fetch
10
- step_name "rss"
11
- def call(config:, args:, **)
12
- _ = args
13
- doc = REXML::Document.new(config["bytes"].to_s)
14
- items = doc.elements.to_a("//item").map do |item|
15
- {
16
- "title" => item.elements["title"]&.text,
17
- "link" => item.elements["link"]&.text,
18
- "pubDate" => item.elements["pubDate"]&.text,
19
- }
20
- end
21
- { _meta: {}, body: YAML.dump(items) }
22
- end
23
- end
24
- end
25
- end
26
- end
@@ -1,22 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Step
5
- # Registers the framework-provided fetch steps (json/csv/markdown-links/
6
- # ical-events/rss) into a registry at Store construction. The successor to
7
- # Step::Builtin.register_all.
8
- module Builtin
9
- STEPS = [
10
- JsonFetch, CsvFetch, MarkdownLinksFetch, IcalEventsFetch, RssFetch
11
- ].freeze
12
-
13
- def self.register_all(registry)
14
- STEPS.each do |klass|
15
- step = klass.new
16
- step.name = klass.step_name
17
- registry.register(step)
18
- end
19
- end
20
- end
21
- end
22
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Step
5
- # The single source of truth for step event names and their required
6
- # kwargs. EventBus, RegistryStore, and the Loader all read these
7
- # tables directly — the registries do not keep their own copies. Catalog
8
- # references no other constant, so it has no load-order cycle, which is
9
- # what removed the previous drift hazard (EventBus held a hard-coded
10
- # `RPC_EVENTS` list that could fall out of sync with RpcRegistry's table).
11
- module Catalog
12
- # Pub-sub events: 0..N handlers, fire-and-forget, receive `ctx:`.
13
- PUBSUB = {
14
- entry_written: %i[ctx key envelope],
15
- entry_deleted: %i[ctx key],
16
- entry_fetched: %i[ctx key envelope change],
17
- entry_renamed: %i[ctx key from_key to_key envelope],
18
- entry_produced: %i[ctx key envelope sources],
19
- produce_failed: %i[ctx keys error],
20
- proposal_accepted: %i[ctx key target_key],
21
- proposal_rejected: %i[ctx key target_key],
22
- entry_published: %i[ctx key envelope source target],
23
- store_loaded: %i[ctx],
24
- session_opened: %i[ctx role cursor],
25
- entry_fetch_started: %i[ctx key mode],
26
- entry_fetch_failed: %i[ctx key error_class error_message],
27
- }.freeze
28
-
29
- # RPC events: single handler, return value matters, receive `caps:`.
30
- RPC = {
31
- resolve_handler: %i[caps config args],
32
- transform_rows: %i[caps rows config],
33
- validate: %i[caps],
34
- }.freeze
35
- end
36
- end
37
- end
@@ -1,74 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Step
5
- # A narrow handle passed to user steps in place of the raw Store.
6
- # All writes route back through the RoleScope so authorization, audit
7
- # logging, and schema validation always fire.
8
- class Context
9
- attr_reader :role, :correlation_id
10
-
11
- def self.for(container:, call:)
12
- scope = Textus::Surfaces::RoleScope.new(
13
- container: container,
14
- role: call.role,
15
- correlation_id: call.correlation_id,
16
- dry_run: call.dry_run,
17
- )
18
- new(scope: scope)
19
- end
20
-
21
- def initialize(scope:)
22
- @scope = scope
23
- @role = scope.role
24
- @correlation_id = scope.correlation_id
25
- end
26
-
27
- def backend
28
- @scope
29
- end
30
-
31
- # read — a pure-observation surface: nothing here ingests. Since ADR 0089
32
- # `get` itself is a pure read (the read-through that once forced this
33
- # surface to opt out is gone, so the old re-entrancy/deadlock guard is no
34
- # longer needed); `list`/`deps`/`freshness` are reads too. A hook observes
35
- # current state and never triggers an I/O cascade.
36
- def get(key) = pure_reader.call(key)
37
- def list(**) = @scope.list(**)
38
- def deps(key) = @scope.deps(key)
39
- def freshness(key) = @scope.freshness(key)
40
-
41
- # write (authorized + audited)
42
- def put(key, **) = @scope.put(key, **)
43
- def delete(key, **) = @scope.key_delete(key, **)
44
-
45
- def audit(verb, key:, **)
46
- @scope.container.audit_log.append(role: @role, verb: verb, key: key, **)
47
- end
48
-
49
- # fan-out
50
- def publish_followup(event, **)
51
- @scope.container.steps.publish(event, ctx: self, **)
52
- end
53
-
54
- def inspect
55
- "#<Textus::Step::Context role=#{@role} correlation_id=#{@correlation_id}>"
56
- end
57
-
58
- private
59
-
60
- def pure_reader
61
- @pure_reader ||= lambda do |key|
62
- Textus::Action::Get.new(key: key).call(
63
- container: @scope.container,
64
- call: Textus::Call.build(
65
- role: @scope.role,
66
- correlation_id: @scope.correlation_id,
67
- dry_run: @scope.dry_run?,
68
- ),
69
- )
70
- end
71
- end
72
- end
73
- end
74
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Step
5
- # Maps a discovered file path under .textus/steps to its (kind, name).
6
- # kind = the directory segment directly under steps/; name = the basename
7
- # without .rb (hyphens preserved). The single source of truth for valid
8
- # kinds is the set of Base subclasses.
9
- KINDS = %i[fetch transform validate observe].freeze
10
-
11
- Discovery = Data.define(:kind, :name) do
12
- def self.parse(path, base:)
13
- rel = path.delete_prefix(base.to_s).delete_prefix("/")
14
- parts = rel.split("/")
15
- raise UsageError.new("step #{rel} must live under steps/<kind>/<name>.rb") unless parts.length == 2
16
-
17
- kind = parts[0].to_sym
18
- raise UsageError.new("unknown step kind '#{parts[0]}' (expected one of: #{KINDS.join(", ")})") unless KINDS.include?(kind)
19
-
20
- new(kind: kind, name: File.basename(parts[1], ".rb").to_sym)
21
- end
22
- end
23
- end
24
- end
@@ -1,32 +0,0 @@
1
- module Textus
2
- module Step
3
- # Bounded in-memory ring buffer of recent hook failures (errored and
4
- # timed_out). Each row carries the audit `seq` observed at the time of
5
- # failure so pulse can filter "errors since cursor".
6
- class ErrorLog
7
- DEFAULT_CAPACITY = 256
8
-
9
- def initialize(capacity: DEFAULT_CAPACITY)
10
- @capacity = capacity
11
- @rows = []
12
- @mutex = Mutex.new
13
- end
14
-
15
- def record(seq:, event:, hook:, key:, error_class:, error_message:)
16
- row = {
17
- seq: seq, event: event, hook: hook, key: key,
18
- error_class: error_class, error_message: error_message,
19
- at: Time.now.utc.iso8601
20
- }
21
- @mutex.synchronize do
22
- @rows << row
23
- @rows.shift while @rows.size > @capacity
24
- end
25
- end
26
-
27
- def since(seq)
28
- @mutex.synchronize { @rows.select { |r| r[:seq] > seq }.dup }
29
- end
30
- end
31
- end
32
- end
@@ -1,113 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "timeout"
4
-
5
- module Textus
6
- module Step
7
- class EventBus
8
- HOOK_TIMEOUT_SECONDS = 2
9
-
10
- class HookTimeout < StandardError; end
11
-
12
- def initialize(error_log: ErrorLog.new)
13
- @pubsub = Hash.new { |h, k| h[k] = [] }
14
- @error_handlers = []
15
- @error_log = error_log
16
- end
17
-
18
- attr_reader :error_log
19
-
20
- def on(event, name, keys: nil, &) = register(event, name, keys: keys, &)
21
-
22
- def register(event, name, keys: nil, &blk)
23
- event_sym = event.to_sym
24
- raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if Catalog::RPC.key?(event_sym)
25
-
26
- required = Catalog::PUBSUB[event_sym] or raise UsageError.new("unknown event: #{event}")
27
- sig = Signature.new(blk)
28
- missing = sig.missing(required)
29
- if missing.any?
30
- raise UsageError.new("#{event_sym} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
31
- end
32
-
33
- name = name.to_sym
34
- raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
35
-
36
- @pubsub[event_sym] << { name: name, callable: blk, keys: keys }
37
- end
38
-
39
- def on_error(&block) = @error_handlers << block
40
-
41
- def listeners(event, key:) = @pubsub[event.to_sym].select { |h| match?(h[:keys], key) }
42
-
43
- def pubsub_handlers(event) = @pubsub[event.to_sym]
44
-
45
- def pubsub_handlers_names = @pubsub.values.flatten.map { |h| h[:name] }
46
-
47
- def publish(event, strict: false, **kwargs)
48
- key = kwargs[:key] || "-"
49
- fired = []
50
- errored = []
51
- timed_out = []
52
- raised = nil
53
-
54
- @pubsub[event.to_sym].each do |sub|
55
- next unless match?(sub[:keys], key)
56
-
57
- outcome, err = invoke(event, sub, key, kwargs)
58
- case outcome
59
- when :ok then fired << sub[:name]
60
- when :errored then errored << sub[:name]
61
- when :timed_out then timed_out << sub[:name]
62
- end
63
- raised ||= err if strict && err
64
- end
65
-
66
- raise raised if strict && raised
67
-
68
- FireReport.new(fired: fired, errored: errored, timed_out: timed_out)
69
- end
70
-
71
- private
72
-
73
- def invoke(event, sub, key, kwargs)
74
- accepted = Signature.new(sub[:callable]).filter(kwargs)
75
- begin
76
- Timeout.timeout(HOOK_TIMEOUT_SECONDS) do
77
- sub[:callable].call(**accepted)
78
- end
79
- [:ok, nil]
80
- rescue Timeout::Error
81
- err = HookTimeout.new("hook #{sub[:name]} exceeded #{HOOK_TIMEOUT_SECONDS}s on event #{event}")
82
- notify_error(event, sub, key, kwargs, err)
83
- [:timed_out, err]
84
- rescue StandardError => e
85
- notify_error(event, sub, key, kwargs, e)
86
- [:errored, e]
87
- end
88
- end
89
-
90
- def notify_error(event, sub, key, kwargs, error)
91
- @error_log.record(
92
- seq: kwargs[:_audit_seq] || -1,
93
- event: event,
94
- hook: sub[:name],
95
- key: key,
96
- error_class: error.class.name,
97
- error_message: error.message,
98
- )
99
- @error_handlers.each do |handler|
100
- handler.call(event: event, hook: sub[:name], key: key, kwargs: kwargs, error: error)
101
- rescue StandardError => e
102
- warn "[textus] error handler failed: #{e.class}: #{e.message}"
103
- end
104
- end
105
-
106
- def match?(globs, key)
107
- return true if globs.nil?
108
-
109
- Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
110
- end
111
- end
112
- end
113
- end
@@ -1,13 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Step
5
- # Acquires data from outside the store (the `fetch:` build input). Returns
6
- # either { "content" => Hash } (structured) or { _meta:, body: } (rendered
7
- # text). Replaces the :resolve_handler RPC. `caps:` is injected by the
8
- # registry only if #call declares it.
9
- class Fetch < Base
10
- def self.required_kwargs = %i[config args]
11
- end
12
- end
13
- end
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Step
5
- # Outcome of a single Dispatcher#publish call.
6
- #
7
- # fired — hook names that ran to completion
8
- # errored — hook names that raised
9
- # timed_out — hook names whose worker thread exceeded the deadline
10
- #
11
- # Callers that care about hook health (tests, strict embedders) can
12
- # check #ok? or inspect #failures. The dispatcher itself never raises
13
- # on a hook failure unless strict: true was passed to #publish.
14
- FireReport = Data.define(:fired, :errored, :timed_out) do
15
- def initialize(fired:, errored:, timed_out:)
16
- super(fired: fired.dup.freeze, errored: errored.dup.freeze, timed_out: timed_out.dup.freeze)
17
- end
18
-
19
- def ok? = errored.empty? && timed_out.empty?
20
- def failures = errored + timed_out
21
- end
22
- end
23
- end
@@ -1,108 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Step
5
- # Convention discovery: glob .textus/steps/<kind>/<name>.rb, load each file,
6
- # validate the class it defines against the discovered kind, assign the
7
- # discovered name, and register it. No global queue, no Textus.hook.
8
- class Loader
9
- BASE_FOR = {
10
- fetch: Step::Fetch, transform: Step::Transform,
11
- validate: Step::Validate, observe: Step::Observe
12
- }.freeze
13
-
14
- def initialize(registry:)
15
- @registry = registry
16
- end
17
-
18
- def load_dir(dir)
19
- return unless File.directory?(dir)
20
-
21
- loaded_paths = Set.new
22
- Dir.glob(File.join(dir, "**/*.rb")).sort.each do |path| # rubocop:disable Lint/RedundantDirGlobSort
23
- real_path = File.realpath(path)
24
- next if loaded_paths.include?(real_path)
25
-
26
- loaded_paths << real_path
27
- load_one(dir, path)
28
- end
29
- end
30
-
31
- private
32
-
33
- def load_one(dir, path)
34
- disc = Discovery.parse(path, base: dir)
35
- klass = capture_defined_class(path, disc)
36
- validate!(disc, klass, path, dir)
37
-
38
- step = klass.new
39
- step.name = disc.name
40
- @registry.register(step)
41
- rescue StandardError, ScriptError => e
42
- raise UsageError.new("failed loading step #{rel(dir, path)}: #{e.class}: #{e.message}") unless e.is_a?(UsageError)
43
-
44
- raise
45
- end
46
-
47
- # Load the file and return the Step::Base subclass it newly defined.
48
- def capture_defined_class(path, disc)
49
- before = descendants
50
- load(path)
51
- defined = descendants - before
52
- return defined.first if defined.length == 1
53
- raise UsageError.new("step #{path} defined more than one Textus::Step subclass") if defined.length > 1
54
-
55
- fallback = find_existing_class_for(disc)
56
- return fallback if fallback
57
-
58
- raise UsageError.new("step #{path} defined no Textus::Step subclass")
59
- end
60
-
61
- def find_existing_class_for(disc)
62
- expected = inferred_class_name_for(disc)
63
- candidates = descendants.select do |klass|
64
- klass < BASE_FOR.fetch(disc.kind) && klass.name&.split("::")&.last == expected
65
- end
66
- return nil if candidates.empty?
67
- return candidates.first if candidates.length == 1
68
-
69
- namespaced = candidates.find { |klass| klass.name == "Textus::Step::#{expected}" }
70
- namespaced || candidates.first
71
- end
72
-
73
- def inferred_class_name_for(disc)
74
- stem = disc.name.to_s.split(/[-_]/).map(&:capitalize).join
75
- suffix = { fetch: "Fetch", transform: "Transform", validate: "Validate", observe: "Observe" }.fetch(disc.kind)
76
- "#{stem}#{suffix}"
77
- end
78
-
79
- def validate!(disc, klass, path, dir)
80
- expected = BASE_FOR.fetch(disc.kind)
81
- actual_kind = klass.respond_to?(:kind) ? safe_kind(klass) : nil
82
- unless klass < expected
83
- raise UsageError.new("#{rel(dir, path)} defines a #{actual_kind || "non-step"} step but lives under #{disc.kind}/")
84
- end
85
-
86
- sig = Step::Signature.new(klass.instance_method(:call))
87
- missing = sig.missing(klass.required_kwargs)
88
- return if missing.empty?
89
-
90
- msg = "#{disc.kind} step '#{disc.name}' #call must accept kwargs: " \
91
- "#{klass.required_kwargs.join(", ")} (missing: #{missing.join(", ")})"
92
- raise UsageError.new(msg)
93
- end
94
-
95
- def safe_kind(klass)
96
- klass.kind
97
- rescue StandardError
98
- nil
99
- end
100
-
101
- def descendants
102
- ObjectSpace.each_object(Class).select { |c| c < Step::Base }
103
- end
104
-
105
- def rel(dir, path) = path.delete_prefix(dir.to_s).delete_prefix("/")
106
- end
107
- end
108
- end
@@ -1,31 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Step
5
- # Reacts to a lifecycle event (Catalog::PUBSUB). 0..N per event,
6
- # fire-and-forget, no meaningful return, timeout-isolated by the EventBus.
7
- # Declares its event with `on :event_name` and an optional key filter with
8
- # `match "glob.**"`. Replaces user pub/sub subscribers.
9
- class Observe < Base
10
- def self.on(event = :__read__)
11
- if event == :__read__
12
- @event
13
- else
14
- @event = event.to_sym
15
- end
16
- end
17
-
18
- def self.match(glob = :__read__)
19
- if glob == :__read__
20
- @match
21
- else
22
- @match = glob
23
- end
24
- end
25
-
26
- class << self
27
- attr_reader :event
28
- end
29
- end
30
- end
31
- end