textus 0.18.0 → 0.20.2

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 (102) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +43 -48
  3. data/CHANGELOG.md +238 -0
  4. data/SPEC.md +35 -2
  5. data/lib/textus/application/context.rb +20 -58
  6. data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
  7. data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
  8. data/lib/textus/{domain → application}/policy/promotion.rb +18 -6
  9. data/lib/textus/application/projection.rb +91 -0
  10. data/lib/textus/application/reads/audit.rb +4 -4
  11. data/lib/textus/application/reads/blame.rb +9 -8
  12. data/lib/textus/application/reads/deps.rb +14 -3
  13. data/lib/textus/application/reads/freshness.rb +10 -8
  14. data/lib/textus/application/reads/get.rb +10 -8
  15. data/lib/textus/application/reads/get_or_refresh.rb +3 -3
  16. data/lib/textus/application/reads/list.rb +3 -3
  17. data/lib/textus/application/reads/policy_explain.rb +3 -3
  18. data/lib/textus/application/reads/published.rb +5 -3
  19. data/lib/textus/application/reads/rdeps.rb +15 -3
  20. data/lib/textus/application/reads/schema_envelope.rb +5 -4
  21. data/lib/textus/application/reads/stale.rb +3 -3
  22. data/lib/textus/application/reads/uid.rb +11 -3
  23. data/lib/textus/application/reads/validate_all.rb +10 -6
  24. data/lib/textus/application/reads/validator.rb +5 -3
  25. data/lib/textus/application/reads/where.rb +3 -3
  26. data/lib/textus/application/refresh/all.rb +15 -11
  27. data/lib/textus/application/refresh/orchestrator.rb +9 -8
  28. data/lib/textus/application/refresh/worker.rb +56 -32
  29. data/lib/textus/application/writes/accept.rb +43 -16
  30. data/lib/textus/application/writes/authority_gate.rb +26 -0
  31. data/lib/textus/application/writes/delete.rb +13 -10
  32. data/lib/textus/application/writes/envelope_io.rb +64 -4
  33. data/lib/textus/application/writes/materializer.rb +50 -0
  34. data/lib/textus/application/writes/mv.rb +57 -94
  35. data/lib/textus/application/writes/publish.rb +132 -26
  36. data/lib/textus/application/writes/put.rb +15 -14
  37. data/lib/textus/application/writes/reject.rb +25 -12
  38. data/lib/textus/builder/pipeline.rb +21 -15
  39. data/lib/textus/builder/renderer/json.rb +4 -1
  40. data/lib/textus/builder/renderer/markdown.rb +7 -1
  41. data/lib/textus/builder/renderer/yaml.rb +4 -1
  42. data/lib/textus/cli/verb/build.rb +4 -6
  43. data/lib/textus/cli/verb/get.rb +1 -1
  44. data/lib/textus/cli/verb/hook_run.rb +3 -4
  45. data/lib/textus/cli/verb/hooks.rb +5 -5
  46. data/lib/textus/cli/verb/put.rb +2 -3
  47. data/lib/textus/cli/verb/refresh_stale.rb +1 -2
  48. data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
  49. data/lib/textus/doctor/check/hooks.rb +2 -2
  50. data/lib/textus/doctor/check/illegal_keys.rb +7 -7
  51. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  52. data/lib/textus/doctor/check/manifest_files.rb +1 -1
  53. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  54. data/lib/textus/doctor/check/templates.rb +4 -3
  55. data/lib/textus/doctor.rb +3 -4
  56. data/lib/textus/domain/authorizer.rb +37 -0
  57. data/lib/textus/domain/policy/promote.rb +4 -2
  58. data/lib/textus/domain/policy/refresh.rb +2 -0
  59. data/lib/textus/domain/staleness/generator_check.rb +8 -7
  60. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  61. data/lib/textus/hooks/builtin.rb +6 -6
  62. data/lib/textus/hooks/bus.rb +155 -0
  63. data/lib/textus/hooks/context.rb +38 -0
  64. data/lib/textus/hooks/fire_report.rb +23 -0
  65. data/lib/textus/hooks/loader.rb +3 -3
  66. data/lib/textus/infra/audit_subscriber.rb +4 -4
  67. data/lib/textus/infra/event_bus.rb +3 -3
  68. data/lib/textus/infra/refresh/detached.rb +1 -1
  69. data/lib/textus/init.rb +3 -2
  70. data/lib/textus/intro.rb +51 -27
  71. data/lib/textus/manifest/entry/base.rb +38 -0
  72. data/lib/textus/manifest/entry/derived.rb +25 -0
  73. data/lib/textus/manifest/entry/intake.rb +19 -0
  74. data/lib/textus/manifest/entry/leaf.rb +16 -0
  75. data/lib/textus/manifest/entry/nested.rb +39 -0
  76. data/lib/textus/manifest/entry/parser.rb +58 -31
  77. data/lib/textus/manifest/entry/validators/events.rb +3 -2
  78. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
  79. data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
  80. data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
  81. data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
  82. data/lib/textus/manifest/entry.rb +0 -72
  83. data/lib/textus/manifest/resolver.rb +112 -0
  84. data/lib/textus/manifest/role_kinds.rb +21 -0
  85. data/lib/textus/manifest/schema.rb +46 -2
  86. data/lib/textus/manifest.rb +24 -101
  87. data/lib/textus/operations.rb +131 -74
  88. data/lib/textus/schema/tools.rb +10 -3
  89. data/lib/textus/store.rb +6 -6
  90. data/lib/textus/version.rb +1 -1
  91. metadata +18 -14
  92. data/lib/textus/application/writes/build.rb +0 -78
  93. data/lib/textus/cli/verb/key_normalize.rb +0 -19
  94. data/lib/textus/dependencies.rb +0 -23
  95. data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
  96. data/lib/textus/domain/policy.rb +0 -7
  97. data/lib/textus/hooks/dispatcher.rb +0 -71
  98. data/lib/textus/hooks/registry.rb +0 -85
  99. data/lib/textus/manifest/resolution.rb +0 -5
  100. data/lib/textus/migrate_keys.rb +0 -187
  101. data/lib/textus/projection.rb +0 -89
  102. data/lib/textus/refresh.rb +0 -39
@@ -5,16 +5,17 @@ module Textus
5
5
  def call
6
6
  out = []
7
7
  store.manifest.entries.each do |entry|
8
- next if entry.template.nil?
8
+ template = entry.respond_to?(:template) ? entry.template : nil
9
+ next if template.nil?
9
10
 
10
- tp = File.join(store.root, "templates", entry.template)
11
+ tp = File.join(store.root, "templates", template)
11
12
  next if File.exist?(tp)
12
13
 
13
14
  out << {
14
15
  "code" => "template.missing",
15
16
  "level" => "error",
16
17
  "subject" => entry.key,
17
- "message" => "template '#{entry.template}' not found at #{tp}",
18
+ "message" => "template '#{template}' not found at #{tp}",
18
19
  "fix" => "create the file at #{tp} or update the entry's template: field",
19
20
  }
20
21
  end
data/lib/textus/doctor.rb CHANGED
@@ -54,11 +54,10 @@ module Textus
54
54
 
55
55
  def run_registered_checks(store)
56
56
  out = []
57
- view = Application::Context.system(store)
58
- store.registry.rpc_names(:validate).each do |name|
59
- callable = store.registry.rpc_callable(:validate, name)
57
+ store.bus.rpc_names(:validate).each do |name|
58
+ callable = store.bus.rpc_callable(:validate, name)
60
59
  begin
61
- result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: view) }
60
+ result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: store) }
62
61
  if result.is_a?(Array)
63
62
  out.concat(result.map { |h| h.transform_keys(&:to_s) })
64
63
  else
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Domain
5
+ # Authorization service. Single source of truth for "given a manifest
6
+ # entry and a role, may this caller read/write?". Extracted from
7
+ # Application::Context so the rule lives in Domain alongside Permission.
8
+ class Authorizer
9
+ def initialize(manifest:)
10
+ @manifest = manifest
11
+ end
12
+
13
+ def can_write?(zone, role:)
14
+ @manifest.permission_for(zone.to_s).allows_write?(role)
15
+ end
16
+
17
+ def can_read?(zone, role:)
18
+ @manifest.permission_for(zone.to_s).allows_read?(role)
19
+ end
20
+
21
+ def authorize_write!(mentry, role:)
22
+ return if can_write?(mentry.zone, role: role)
23
+
24
+ writers = @manifest.zone_writers(mentry.zone)
25
+ raise WriteForbidden.new(mentry.key, mentry.zone, writers: writers)
26
+ end
27
+
28
+ def authorize_read!(mentry, role:)
29
+ return if can_read?(mentry.zone, role: role)
30
+
31
+ readers = @manifest.zone_readers[mentry.zone]
32
+ readers = nil if readers == :all
33
+ raise ReadForbidden.new(mentry.key, mentry.zone, readers: readers)
34
+ end
35
+ end
36
+ end
37
+ end
@@ -2,14 +2,16 @@ module Textus
2
2
  module Domain
3
3
  module Policy
4
4
  class Promote
5
- KNOWN = %i[schema_valid human_accept].freeze
5
+ KNOWN = %i[schema_valid accept_authority_signed].freeze
6
6
  attr_reader :requires
7
7
 
8
8
  def initialize(requires:)
9
9
  syms = Array(requires).map { |r| r.to_s.to_sym }
10
10
  unknown = syms - KNOWN
11
11
  unless unknown.empty?
12
- raise Textus::UsageError.new("unknown promote requirement: #{unknown.first.inspect} (known: #{KNOWN.join(", ")})")
12
+ raise Textus::UsageError.new(
13
+ "unknown promote requirement: #{unknown.first.inspect} (known: #{KNOWN.join(", ")})",
14
+ )
13
15
  end
14
16
 
15
17
  @requires = syms
@@ -2,6 +2,8 @@ module Textus
2
2
  module Domain
3
3
  module Policy
4
4
  class Refresh
5
+ ALLOWED_ON_STALE = %i[warn sync timed_sync].freeze
6
+
5
7
  attr_reader :ttl, :on_stale, :sync_budget_ms, :fetch_timeout_seconds
6
8
 
7
9
  def initialize(ttl:, on_stale:, sync_budget_ms:, fetch_timeout_seconds: nil)
@@ -14,9 +14,10 @@ module Textus
14
14
 
15
15
  def rows_for(mentry)
16
16
  return [] unless mentry.in_generator_zone?
17
+ return [] unless mentry.is_a?(Textus::Manifest::Entry::Derived)
17
18
 
18
- gen = mentry.generator
19
- return [] unless gen
19
+ src = mentry.source
20
+ return [] unless src.is_a?(Textus::Manifest::Entry::Derived::External)
20
21
 
21
22
  path = Textus::Key::Path.resolve(@manifest, mentry)
22
23
  return [stale_row(mentry, path, "derived entry has never been generated")] unless File.exist?(path)
@@ -28,7 +29,7 @@ module Textus
28
29
  gen_time = parse_time(generated_at)
29
30
  return [stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")] unless gen_time
30
31
 
31
- offender = newest_source_after(gen, gen_time)
32
+ offender = newest_source_after(src, gen_time)
32
33
  return [stale_row(mentry, path, "source '#{offender}' modified after generated.at")] if offender
33
34
 
34
35
  []
@@ -42,8 +43,8 @@ module Textus
42
43
  nil
43
44
  end
44
45
 
45
- def newest_source_after(gen, gen_time)
46
- Array(gen["sources"]).each do |src|
46
+ def newest_source_after(external_src, gen_time)
47
+ Array(external_src.sources).each do |src|
47
48
  offender = check_source(src, gen_time)
48
49
  return offender if offender
49
50
  end
@@ -52,7 +53,7 @@ module Textus
52
53
 
53
54
  def check_source(src, gen_time)
54
55
  if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
55
- @manifest.enumerate(prefix: src).each do |row|
56
+ @manifest.resolver.enumerate(prefix: src).each do |row|
56
57
  return src if File.mtime(row[:path]) > gen_time
57
58
  end
58
59
  nil
@@ -78,7 +79,7 @@ module Textus
78
79
  {
79
80
  "key" => mentry.key,
80
81
  "path" => path,
81
- "generator" => mentry.generator,
82
+ "generator" => mentry.raw["compute"],
82
83
  "reason" => reason,
83
84
  }
84
85
  end
@@ -11,7 +11,7 @@ module Textus
11
11
  end
12
12
 
13
13
  def rows_for(mentry)
14
- return [] unless mentry.intake_handler
14
+ return [] unless mentry.is_a?(Textus::Manifest::Entry::Intake)
15
15
 
16
16
  ttl = @manifest.rules_for(mentry.key).refresh&.ttl_seconds
17
17
  return [] unless ttl
@@ -38,7 +38,7 @@ module Textus
38
38
  end
39
39
 
40
40
  def row(mentry, path, reason)
41
- { "key" => mentry.key, "path" => path, "handler" => mentry.intake_handler, "reason" => reason }
41
+ { "key" => mentry.key, "path" => path, "handler" => mentry.handler, "reason" => reason }
42
42
  end
43
43
  end
44
44
  end
@@ -7,22 +7,22 @@ module Textus
7
7
  module Hooks
8
8
  module Builtin
9
9
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
10
- def self.register_all(registry)
11
- registry.on(:resolve_intake, :json) do |store:, config:, args:|
10
+ def self.register_all(bus)
11
+ bus.on(:resolve_intake, :json) do |store:, config:, args:|
12
12
  _ = store
13
13
  _ = args
14
14
  data = JSON.parse(config["bytes"].to_s)
15
15
  { _meta: {}, body: YAML.dump(data) }
16
16
  end
17
17
 
18
- registry.on(:resolve_intake, :csv) do |store:, config:, args:|
18
+ bus.on(:resolve_intake, :csv) do |store:, config:, args:|
19
19
  _ = store
20
20
  _ = args
21
21
  rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
22
22
  { _meta: {}, body: YAML.dump(rows) }
23
23
  end
24
24
 
25
- registry.on(:resolve_intake, :"markdown-links") do |store:, config:, args:|
25
+ bus.on(:resolve_intake, :"markdown-links") do |store:, config:, args:|
26
26
  _ = store
27
27
  _ = args
28
28
  links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
@@ -31,7 +31,7 @@ module Textus
31
31
  { _meta: {}, body: YAML.dump(links) }
32
32
  end
33
33
 
34
- registry.on(:resolve_intake, :"ical-events") do |store:, config:, args:|
34
+ bus.on(:resolve_intake, :"ical-events") do |store:, config:, args:|
35
35
  _ = store
36
36
  _ = args
37
37
  events = []
@@ -50,7 +50,7 @@ module Textus
50
50
  { _meta: {}, body: YAML.dump(events) }
51
51
  end
52
52
 
53
- registry.on(:resolve_intake, :rss) do |store:, config:, args:|
53
+ bus.on(:resolve_intake, :rss) do |store:, config:, args:|
54
54
  _ = store
55
55
  _ = args
56
56
  doc = REXML::Document.new(config["bytes"].to_s)
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Hooks
5
+ class Bus
6
+ HOOK_TIMEOUT_SECONDS = 2
7
+
8
+ class HookTimeout < StandardError; end
9
+
10
+ EVENTS = {
11
+ # RPC events — gem-internal, keep :store
12
+ resolve_intake: { mode: :rpc, args: %i[store config args] },
13
+ transform_rows: { mode: :rpc, args: %i[store rows config] },
14
+ validate: { mode: :rpc, args: %i[store] },
15
+
16
+ # Pubsub events — ship :ctx (Hooks::Context) instead of raw store
17
+ entry_put: { mode: :pubsub, args: %i[ctx key envelope] },
18
+ entry_deleted: { mode: :pubsub, args: %i[ctx key] },
19
+ entry_refreshed: { mode: :pubsub, args: %i[ctx key envelope change] },
20
+ entry_renamed: { mode: :pubsub, args: %i[ctx key from_key to_key envelope] },
21
+ build_completed: { mode: :pubsub, args: %i[ctx key envelope sources] },
22
+ proposal_accepted: { mode: :pubsub, args: %i[ctx key target_key] },
23
+ proposal_rejected: { mode: :pubsub, args: %i[ctx key target_key] },
24
+ file_published: { mode: :pubsub, args: %i[ctx key envelope source target] },
25
+ store_loaded: { mode: :pubsub, args: %i[ctx] },
26
+ refresh_started: { mode: :pubsub, args: %i[ctx key mode] },
27
+ refresh_failed: { mode: :pubsub, args: %i[ctx key error_class error_message] },
28
+ refresh_backgrounded: { mode: :pubsub, args: %i[ctx key started_at budget_ms] },
29
+ }.freeze
30
+
31
+ def initialize
32
+ @rpc = Hash.new { |h, k| h[k] = {} }
33
+ @pubsub = Hash.new { |h, k| h[k] = [] }
34
+ @error_handlers = []
35
+ end
36
+
37
+ def on(event, name, keys: nil, &) = register(event, name, keys: keys, &)
38
+
39
+ def register(event, name, keys: nil, &blk)
40
+ event_sym = event.to_sym
41
+ spec = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
42
+ shape_check!(event_sym, spec, blk)
43
+ name = name.to_sym
44
+
45
+ case spec[:mode]
46
+ when :rpc
47
+ raise UsageError.new("#{event_sym} '#{name}' already registered") if @rpc[event_sym].key?(name)
48
+
49
+ @rpc[event_sym][name] = blk
50
+ when :pubsub
51
+ raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
52
+
53
+ @pubsub[event_sym] << { name: name, callable: blk, keys: keys }
54
+ end
55
+ end
56
+
57
+ def on_error(&block) = @error_handlers << block
58
+
59
+ def rpc_callable(event, name)
60
+ @rpc[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
61
+ end
62
+
63
+ def rpc_names(event) = @rpc[event.to_sym].keys
64
+ def pubsub_handlers(event) = @pubsub[event.to_sym]
65
+ def listeners(event, key:) = @pubsub[event.to_sym].select { |h| h[:keys].nil? || matches_any?(h[:keys], key) }
66
+
67
+ def publish(event, strict: false, **kwargs)
68
+ key = kwargs[:key] || "-"
69
+ fired = []
70
+ errored = []
71
+ timed_out = []
72
+ raised = nil
73
+
74
+ @pubsub[event.to_sym].each do |sub|
75
+ next unless match?(sub[:keys], key)
76
+
77
+ outcome, err = invoke(event, sub, key, kwargs)
78
+ case outcome
79
+ when :ok then fired << sub[:name]
80
+ when :errored then errored << sub[:name]
81
+ when :timed_out then timed_out << sub[:name]
82
+ end
83
+ raised ||= err if strict && err
84
+ end
85
+
86
+ raise raised if strict && raised
87
+
88
+ FireReport.new(fired: fired, errored: errored, timed_out: timed_out)
89
+ end
90
+
91
+ private
92
+
93
+ def invoke(event, sub, key, kwargs)
94
+ accepted = filter_kwargs(sub[:callable], kwargs)
95
+ error = nil
96
+
97
+ thread = Thread.new do
98
+ sub[:callable].call(**accepted)
99
+ rescue StandardError => e
100
+ error = e
101
+ end
102
+
103
+ if thread.join(HOOK_TIMEOUT_SECONDS).nil?
104
+ thread.kill
105
+ err = HookTimeout.new("hook #{sub[:name]} exceeded #{HOOK_TIMEOUT_SECONDS}s on event #{event}")
106
+ notify_error(event, sub, key, kwargs, err)
107
+ return [:timed_out, err]
108
+ end
109
+
110
+ if error
111
+ notify_error(event, sub, key, kwargs, error)
112
+ return [:errored, error]
113
+ end
114
+
115
+ [:ok, nil]
116
+ end
117
+
118
+ def notify_error(event, sub, key, kwargs, error)
119
+ @error_handlers.each do |handler|
120
+ handler.call(event: event, hook: sub[:name], key: key, kwargs: kwargs, error: error)
121
+ rescue StandardError => e
122
+ warn "[textus] error handler failed: #{e.class}: #{e.message}"
123
+ end
124
+ end
125
+
126
+ def filter_kwargs(callable, kwargs)
127
+ params = callable.parameters
128
+ return kwargs if params.any? { |type, _| type == :keyrest }
129
+
130
+ accepted = params.each_with_object([]) do |(type, name), acc|
131
+ acc << name if %i[key keyreq].include?(type)
132
+ end
133
+ kwargs.slice(*accepted)
134
+ end
135
+
136
+ def shape_check!(event, spec, blk)
137
+ required = spec[:args]
138
+ provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
139
+ keyrest = provided.any? { |t, _| t == :keyrest }
140
+ missing = required - provided.map { |_, n| n }
141
+ return if keyrest || missing.empty?
142
+
143
+ raise UsageError.new("#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
144
+ end
145
+
146
+ def match?(globs, key)
147
+ return true if globs.nil?
148
+
149
+ Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
150
+ end
151
+
152
+ def matches_any?(globs, key) = match?(globs, key)
153
+ end
154
+ end
155
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Hooks
5
+ # A narrow handle passed to user hooks in place of the raw Store.
6
+ # All writes route back through Operations so authorization, audit
7
+ # logging, and schema validation always fire.
8
+ class Context
9
+ attr_reader :role, :correlation_id
10
+
11
+ def initialize(ops:)
12
+ @ops = ops
13
+ @role = ops.ctx.role
14
+ @correlation_id = ops.ctx.correlation_id
15
+ end
16
+
17
+ # read
18
+ def get(key) = @ops.get(key)
19
+ def list(**) = @ops.list(**)
20
+ def deps(key) = @ops.deps(key)
21
+ def freshness(key) = @ops.freshness(key)
22
+
23
+ # write (authorized + audited)
24
+ def put(key, **) = @ops.put(key, **)
25
+ def delete(key, **) = @ops.delete(key, **)
26
+ def audit(verb, key:, **) = @ops.store.audit_log.append(role: @role, verb: verb, key: key, **)
27
+
28
+ # fan-out
29
+ def publish_followup(event, **)
30
+ @ops.store.bus.publish(event, ctx: self, **)
31
+ end
32
+
33
+ def inspect
34
+ "#<Textus::Hooks::Context role=#{@role} correlation_id=#{@correlation_id}>"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Hooks
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,8 +1,8 @@
1
1
  module Textus
2
2
  module Hooks
3
3
  class Loader
4
- def initialize(registry:)
5
- @registry = registry
4
+ def initialize(bus:)
5
+ @bus = bus
6
6
  end
7
7
 
8
8
  def load_dir(dir)
@@ -18,7 +18,7 @@ module Textus
18
18
  end
19
19
 
20
20
  Textus.drain_hook_blocks.each do |blk|
21
- blk.call(@registry)
21
+ blk.call(@bus)
22
22
  rescue StandardError, ScriptError => e
23
23
  raise UsageError.new("failed registering hook: #{e.class}: #{e.message}")
24
24
  end
@@ -3,11 +3,11 @@
3
3
  module Textus
4
4
  module Infra
5
5
  # Writes an "event_error" audit row when a user hook raises during
6
- # Hooks::Dispatcher publish. Attached at Store boot.
6
+ # Hooks::Bus publish. Attached at Store boot.
7
7
  #
8
- # Integration: uses Hooks::Dispatcher#on_error callback (chosen over a
9
- # synthetic :hook_error event because the dispatcher already owns the
10
- # rescue and the failure is a dispatcher-internal concern, not a domain
8
+ # Integration: uses Hooks::Bus#on_error callback (chosen over a
9
+ # synthetic :hook_error event because the bus already owns the
10
+ # rescue and the failure is a bus-internal concern, not a domain
11
11
  # event subscribers should be able to filter by key glob).
12
12
  #
13
13
  # NOTE (0.16 scope): lifecycle audit rows for verb: "put" / "delete" /
@@ -1,12 +1,12 @@
1
1
  module Textus
2
2
  module Infra
3
3
  class EventBus
4
- def initialize(registry:)
5
- @registry = registry
4
+ def initialize(bus:)
5
+ @bus = bus
6
6
  end
7
7
 
8
8
  def publish(event, **payload)
9
- @registry.pubsub_handlers(event).each do |entry|
9
+ @bus.pubsub_handlers(event).each do |entry|
10
10
  next unless entry[:keys].nil? || matches?(entry[:keys], payload[:key])
11
11
 
12
12
  entry[:callable].call(**payload)
@@ -21,7 +21,7 @@ module Textus
21
21
 
22
22
  begin
23
23
  store = Textus::Store.new(store_root)
24
- Textus::Refresh.call(store, key, as: "runner")
24
+ Textus::Operations.for(store, role: "runner").refresh(key)
25
25
  rescue StandardError
26
26
  # Already logged via :refresh_failed; exit cleanly.
27
27
  ensure
data/lib/textus/init.rb CHANGED
@@ -13,8 +13,8 @@ module Textus
13
13
  - { name: review, write_policy: [agent, human], read_policy: [all] }
14
14
  - { name: output, write_policy: [builder], read_policy: [all] }
15
15
  entries:
16
- - { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self }
17
- - { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true }
16
+ - { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self, kind: leaf }
17
+ - { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true, kind: nested }
18
18
  YAML
19
19
 
20
20
  HOOKS_README = <<~MD
@@ -51,6 +51,7 @@ module Textus
51
51
  ```yaml
52
52
  entries:
53
53
  - key: intake.foo
54
+ kind: intake
54
55
  path: intake/foo.md
55
56
  zone: intake
56
57
  intake:
data/lib/textus/intro.rb CHANGED
@@ -18,19 +18,37 @@ module Textus
18
18
  "output" => "build-computed outputs; never hand-edited",
19
19
  }.freeze
20
20
 
21
- WRITE_FLOWS = {
22
- "human" => "edit files in identity/working zones, then 'textus put KEY --as=human'",
23
- "agent" => "propose changes by writing 'review.*' entries with --as=agent and a 'proposal:' frontmatter block; " \
24
- "a human runs 'textus accept' to apply",
25
- "runner" => "refresh intake entries with 'textus refresh KEY --as=runner' (uses the entry's declared action)",
26
- "builder" => "'textus build' computes output entries from projections; output files are never hand-edited",
21
+ # Per-kind write-flow templates. Each lambda receives the user-facing role
22
+ # name and returns a guidance string for that role. Roles whose kind has
23
+ # no template (e.g. unknown future kinds) are omitted from write_flows.
24
+ WRITE_FLOW_TEMPLATES = {
25
+ accept_authority: lambda do |name, _manifest|
26
+ "edit files in identity/working zones, then 'textus put KEY --as=#{name}'"
27
+ end,
28
+ proposer: lambda do |name, manifest|
29
+ authority = manifest.roles_with_kind(:accept_authority).first || "accept_authority"
30
+ "propose changes by writing review.* entries with --as=#{name} and a 'proposal:' frontmatter block; " \
31
+ "the #{authority} role runs 'textus accept' to apply"
32
+ end,
33
+ runner: lambda do |name, _manifest|
34
+ "refresh intake entries with 'textus refresh KEY --as=#{name}' (uses the entry's declared action)"
35
+ end,
36
+ generator: lambda do |_name, _manifest|
37
+ "'textus build' computes output entries from projections; output files are never hand-edited"
38
+ end,
27
39
  }.freeze
28
40
 
29
- # Static, store-independent guide to the agent-facing protocol. Surfaced
30
- # under the new top-level `agent_protocol` key in Intro.run. Recipes
31
- # describe CLI verbs (not Ruby Operations) because the audience is an
32
- # agent driving textus from the command line.
33
- AGENT_PROTOCOL = {
41
+ def self.write_flows_for(manifest)
42
+ manifest.role_mapping.each_with_object({}) do |(name, kind), acc|
43
+ tmpl = WRITE_FLOW_TEMPLATES[kind]
44
+ acc[name] = tmpl.call(name, manifest) if tmpl
45
+ end
46
+ end
47
+
48
+ # Static, store-independent parts of the agent-facing protocol. The
49
+ # `role_resolution` block is derived per-manifest in agent_protocol(...)
50
+ # because role names are user-configurable.
51
+ AGENT_PROTOCOL_TEMPLATE = {
34
52
  "envelope_shape" => {
35
53
  "summary" => "every read/write payload is a JSON envelope with _meta, body, uid, and etag",
36
54
  "fields" => {
@@ -41,11 +59,6 @@ module Textus
41
59
  },
42
60
  "ref" => "SPEC.md §8",
43
61
  },
44
- "role_resolution" => {
45
- "summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, default human",
46
- "roles" => %w[human agent runner builder],
47
- "ref" => "SPEC.md §5",
48
- },
49
62
  "recipes" => {
50
63
  "read" => {
51
64
  "purpose" => "find and read an entry",
@@ -92,7 +105,7 @@ module Textus
92
105
  { "name" => "schema", "summary" => "field shape for a key family" },
93
106
  { "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
94
107
  { "name" => "accept", "summary" => "apply a review.* proposal; --as=human only" },
95
- { "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key normalize'" },
108
+ { "name" => "key", "summary" => "key operations: 'key mv', 'key uid'" },
96
109
  { "name" => "delete", "summary" => "delete an entry; --as=<role>" },
97
110
  { "name" => "build", "summary" => "materialize output entries; publish_to and publish_each fan out copies" },
98
111
  { "name" => "refresh", "summary" => "run an action for an intake entry" },
@@ -105,6 +118,17 @@ module Textus
105
118
  "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
106
119
  ].freeze
107
120
 
121
+ def self.agent_protocol(manifest)
122
+ AGENT_PROTOCOL_TEMPLATE.merge(
123
+ "role_resolution" => {
124
+ "summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, " \
125
+ "default 'human'",
126
+ "roles" => manifest.role_mapping.keys,
127
+ "ref" => "SPEC.md §5",
128
+ },
129
+ )
130
+ end
131
+
108
132
  def self.run(store)
109
133
  {
110
134
  "protocol" => PROTOCOL_ID,
@@ -112,9 +136,9 @@ module Textus
112
136
  "zones" => zones_for(store),
113
137
  "entries" => entries_for(store),
114
138
  "hooks" => hooks_for(store),
115
- "write_flows" => WRITE_FLOWS.dup,
139
+ "write_flows" => write_flows_for(store.manifest),
116
140
  "cli_verbs" => CLI_VERBS.map(&:dup),
117
- "agent_protocol" => AGENT_PROTOCOL,
141
+ "agent_protocol" => agent_protocol(store.manifest),
118
142
  "docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
119
143
  }
120
144
  end
@@ -130,31 +154,31 @@ module Textus
130
154
 
131
155
  def self.entries_for(store)
132
156
  store.manifest.entries.map do |e|
133
- derived = store.manifest.zone_writers(e.zone).include?("builder")
157
+ derived = store.manifest.zone_kinds(e.zone).include?(:generator)
134
158
  {
135
159
  "key" => e.key,
136
160
  "zone" => e.zone,
137
161
  "schema" => e.schema,
138
- "nested" => e.nested ? true : false,
162
+ "nested" => e.is_a?(Textus::Manifest::Entry::Nested),
139
163
  "owner" => e.owner,
140
164
  "format" => e.format,
141
165
  "derived" => derived,
142
- "intake" => !e.intake_handler.nil?,
166
+ "intake" => e.is_a?(Textus::Manifest::Entry::Intake),
143
167
  "publish_to" => Array(e.publish_to),
144
- "publish_each" => e.publish_each,
168
+ "publish_each" => e.respond_to?(:publish_each) ? e.publish_each : nil,
145
169
  }
146
170
  end
147
171
  end
148
172
 
149
173
  def self.hooks_for(store)
150
- reg = store.registry
174
+ bus = store.bus
151
175
  sections = {}
152
- Hooks::Registry::EVENTS.each do |event, spec|
176
+ Hooks::Bus::EVENTS.each do |event, spec|
153
177
  case spec[:mode]
154
178
  when :rpc
155
- sections[event.to_s] = reg.rpc_names(event).map(&:to_s).sort
179
+ sections[event.to_s] = bus.rpc_names(event).map(&:to_s).sort
156
180
  when :pubsub
157
- sections[event.to_s] = reg.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
181
+ sections[event.to_s] = bus.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
158
182
  end
159
183
  end
160
184
  sections