textus 0.20.2 → 0.26.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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +148 -45
  3. data/CHANGELOG.md +194 -0
  4. data/README.md +8 -5
  5. data/SPEC.md +54 -15
  6. data/docs/conventions.md +10 -0
  7. data/lib/textus/application/caps.rb +49 -0
  8. data/lib/textus/application/context.rb +2 -2
  9. data/lib/textus/application/envelope/reader.rb +44 -0
  10. data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
  11. data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
  12. data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
  13. data/lib/textus/application/maintenance/migrate.rb +59 -0
  14. data/lib/textus/application/maintenance/rule_lint.rb +65 -0
  15. data/lib/textus/application/maintenance/zone_mv.rb +60 -0
  16. data/lib/textus/application/maintenance.rb +17 -0
  17. data/lib/textus/application/projection.rb +12 -10
  18. data/lib/textus/application/read/audit.rb +106 -0
  19. data/lib/textus/application/read/blame.rb +91 -0
  20. data/lib/textus/application/read/deps.rb +34 -0
  21. data/lib/textus/application/read/freshness.rb +110 -0
  22. data/lib/textus/application/read/get.rb +75 -0
  23. data/lib/textus/application/read/get_or_refresh.rb +63 -0
  24. data/lib/textus/application/read/list.rb +25 -0
  25. data/lib/textus/application/read/policy_explain.rb +47 -0
  26. data/lib/textus/application/read/published.rb +25 -0
  27. data/lib/textus/application/read/pulse.rb +101 -0
  28. data/lib/textus/application/read/rdeps.rb +35 -0
  29. data/lib/textus/application/read/schema_envelope.rb +26 -0
  30. data/lib/textus/application/read/stale.rb +23 -0
  31. data/lib/textus/application/read/uid.rb +30 -0
  32. data/lib/textus/application/read/validate_all.rb +32 -0
  33. data/lib/textus/application/{reads → read}/validator.rb +2 -2
  34. data/lib/textus/application/read/where.rb +26 -0
  35. data/lib/textus/application/use_case.rb +22 -0
  36. data/lib/textus/application/write/accept.rb +102 -0
  37. data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
  38. data/lib/textus/application/write/delete.rb +45 -0
  39. data/lib/textus/application/{writes → write}/materializer.rb +14 -15
  40. data/lib/textus/application/write/mv.rb +118 -0
  41. data/lib/textus/application/write/publish.rb +96 -0
  42. data/lib/textus/application/write/put.rb +49 -0
  43. data/lib/textus/application/write/refresh_all.rb +63 -0
  44. data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
  45. data/lib/textus/application/write/refresh_worker.rb +134 -0
  46. data/lib/textus/application/write/reject.rb +62 -0
  47. data/lib/textus/{intro.rb → boot.rb} +49 -29
  48. data/lib/textus/builder/pipeline.rb +5 -5
  49. data/lib/textus/cli/group/mcp.rb +9 -0
  50. data/lib/textus/cli/group/zone.rb +9 -0
  51. data/lib/textus/cli/verb/accept.rb +1 -1
  52. data/lib/textus/cli/verb/audit.rb +4 -2
  53. data/lib/textus/cli/verb/blame.rb +1 -1
  54. data/lib/textus/cli/verb/boot.rb +13 -0
  55. data/lib/textus/cli/verb/build.rb +2 -2
  56. data/lib/textus/cli/verb/delete.rb +1 -1
  57. data/lib/textus/cli/verb/deps.rb +1 -1
  58. data/lib/textus/cli/verb/doctor.rb +1 -1
  59. data/lib/textus/cli/verb/freshness.rb +1 -1
  60. data/lib/textus/cli/verb/get.rb +1 -1
  61. data/lib/textus/cli/verb/hook_run.rb +3 -4
  62. data/lib/textus/cli/verb/hooks.rb +11 -14
  63. data/lib/textus/cli/verb/key_delete.rb +24 -0
  64. data/lib/textus/cli/verb/list.rb +1 -1
  65. data/lib/textus/cli/verb/mcp_serve.rb +17 -0
  66. data/lib/textus/cli/verb/migrate.rb +18 -0
  67. data/lib/textus/cli/verb/mv.rb +11 -3
  68. data/lib/textus/cli/verb/published.rb +1 -1
  69. data/lib/textus/cli/verb/pulse.rb +17 -0
  70. data/lib/textus/cli/verb/put.rb +8 -6
  71. data/lib/textus/cli/verb/rdeps.rb +1 -1
  72. data/lib/textus/cli/verb/refresh.rb +1 -1
  73. data/lib/textus/cli/verb/refresh_stale.rb +1 -1
  74. data/lib/textus/cli/verb/reject.rb +1 -1
  75. data/lib/textus/cli/verb/rule_explain.rb +1 -1
  76. data/lib/textus/cli/verb/rule_lint.rb +18 -0
  77. data/lib/textus/cli/verb/schema.rb +1 -1
  78. data/lib/textus/cli/verb/uid.rb +1 -1
  79. data/lib/textus/cli/verb/where.rb +1 -1
  80. data/lib/textus/cli/verb/zone_mv.rb +19 -0
  81. data/lib/textus/cli/verb.rb +4 -4
  82. data/lib/textus/cli.rb +1 -1
  83. data/lib/textus/doctor/check/audit_log.rb +2 -2
  84. data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
  85. data/lib/textus/doctor/check/hooks.rb +4 -3
  86. data/lib/textus/doctor/check/illegal_keys.rb +2 -2
  87. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  88. data/lib/textus/doctor/check/manifest_files.rb +2 -2
  89. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  90. data/lib/textus/doctor/check/refresh_locks.rb +2 -2
  91. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  92. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  93. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  94. data/lib/textus/doctor/check/schemas.rb +2 -2
  95. data/lib/textus/doctor/check/sentinels.rb +2 -2
  96. data/lib/textus/doctor/check/templates.rb +2 -2
  97. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  98. data/lib/textus/doctor/check.rb +5 -3
  99. data/lib/textus/doctor.rb +24 -27
  100. data/lib/textus/domain/authorizer.rb +4 -4
  101. data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
  102. data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
  103. data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
  104. data/lib/textus/domain/staleness/generator_check.rb +2 -2
  105. data/lib/textus/domain/staleness/intake_check.rb +2 -2
  106. data/lib/textus/domain/staleness.rb +1 -1
  107. data/lib/textus/errors.rb +16 -0
  108. data/lib/textus/hooks/builtin.rb +14 -14
  109. data/lib/textus/hooks/context.rb +13 -13
  110. data/lib/textus/hooks/error_log.rb +32 -0
  111. data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
  112. data/lib/textus/hooks/loader.rb +29 -3
  113. data/lib/textus/hooks/rpc_registry.rb +77 -0
  114. data/lib/textus/infra/audit_log.rb +126 -16
  115. data/lib/textus/infra/audit_subscriber.rb +6 -7
  116. data/lib/textus/infra/refresh/detached.rb +1 -1
  117. data/lib/textus/key/path.rb +7 -3
  118. data/lib/textus/manifest/data.rb +78 -0
  119. data/lib/textus/manifest/entry/base.rb +44 -7
  120. data/lib/textus/manifest/entry/derived.rb +41 -6
  121. data/lib/textus/manifest/entry/intake.rb +15 -3
  122. data/lib/textus/manifest/entry/leaf.rb +6 -5
  123. data/lib/textus/manifest/entry/nested.rb +42 -3
  124. data/lib/textus/manifest/entry/parser.rb +8 -44
  125. data/lib/textus/manifest/entry/validators/events.rb +2 -2
  126. data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
  127. data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
  128. data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
  129. data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
  130. data/lib/textus/manifest/entry/validators.rb +1 -1
  131. data/lib/textus/manifest/entry.rb +3 -0
  132. data/lib/textus/manifest/policy.rb +48 -0
  133. data/lib/textus/manifest/resolver.rb +18 -18
  134. data/lib/textus/manifest/rules.rb +1 -1
  135. data/lib/textus/manifest/schema.rb +20 -6
  136. data/lib/textus/manifest.rb +53 -101
  137. data/lib/textus/mcp/errors.rb +32 -0
  138. data/lib/textus/mcp/server.rb +127 -0
  139. data/lib/textus/mcp/session.rb +31 -0
  140. data/lib/textus/mcp/tool_schemas.rb +71 -0
  141. data/lib/textus/mcp/tools.rb +129 -0
  142. data/lib/textus/mcp.rb +6 -0
  143. data/lib/textus/schema/tools.rb +14 -10
  144. data/lib/textus/session.rb +84 -0
  145. data/lib/textus/store.rb +17 -8
  146. data/lib/textus/version.rb +1 -1
  147. data/lib/textus.rb +8 -1
  148. metadata +65 -38
  149. data/lib/textus/application/reads/audit.rb +0 -69
  150. data/lib/textus/application/reads/blame.rb +0 -82
  151. data/lib/textus/application/reads/deps.rb +0 -26
  152. data/lib/textus/application/reads/freshness.rb +0 -88
  153. data/lib/textus/application/reads/get.rb +0 -67
  154. data/lib/textus/application/reads/get_or_refresh.rb +0 -51
  155. data/lib/textus/application/reads/list.rb +0 -17
  156. data/lib/textus/application/reads/policy_explain.rb +0 -39
  157. data/lib/textus/application/reads/published.rb +0 -17
  158. data/lib/textus/application/reads/rdeps.rb +0 -27
  159. data/lib/textus/application/reads/schema_envelope.rb +0 -18
  160. data/lib/textus/application/reads/stale.rb +0 -15
  161. data/lib/textus/application/reads/uid.rb +0 -23
  162. data/lib/textus/application/reads/validate_all.rb +0 -24
  163. data/lib/textus/application/reads/where.rb +0 -18
  164. data/lib/textus/application/refresh/all.rb +0 -52
  165. data/lib/textus/application/refresh/worker.rb +0 -116
  166. data/lib/textus/application/writes/accept.rb +0 -89
  167. data/lib/textus/application/writes/delete.rb +0 -33
  168. data/lib/textus/application/writes/mv.rb +0 -105
  169. data/lib/textus/application/writes/publish.rb +0 -162
  170. data/lib/textus/application/writes/put.rb +0 -37
  171. data/lib/textus/application/writes/reject.rb +0 -50
  172. data/lib/textus/cli/verb/intro.rb +0 -13
  173. data/lib/textus/infra/event_bus.rb +0 -27
  174. data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
  175. data/lib/textus/operations.rb +0 -169
data/lib/textus/doctor.rb CHANGED
@@ -30,7 +30,7 @@ module Textus
30
30
 
31
31
  module_function
32
32
 
33
- def run(store, checks: nil)
33
+ def run(session, checks: nil)
34
34
  selected_keys = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
35
35
  unknown = selected_keys - ALL_CHECKS
36
36
  unless unknown.empty?
@@ -40,8 +40,8 @@ module Textus
40
40
  end
41
41
 
42
42
  selected = CHECKS.select { |c| selected_keys.include?(c.name_key) }
43
- issues = selected.flat_map { |c| c.new(store).call }
44
- issues.concat(run_registered_checks(store))
43
+ issues = selected.flat_map { |c| c.new(session).call }
44
+ issues.concat(run_registered_checks(session))
45
45
 
46
46
  summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
47
47
  {
@@ -52,30 +52,27 @@ module Textus
52
52
  }
53
53
  end
54
54
 
55
- def run_registered_checks(store)
56
- out = []
57
- store.bus.rpc_names(:validate).each do |name|
58
- callable = store.bus.rpc_callable(:validate, name)
59
- begin
60
- result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: store) }
61
- if result.is_a?(Array)
62
- out.concat(result.map { |h| h.transform_keys(&:to_s) })
63
- else
64
- out << fail_issue(name, code: "doctor_check.bad_return",
65
- message: "doctor_check '#{name}' returned #{result.class} (expected Array)",
66
- fix: "return an array of issue hashes from the doctor_check block")
67
- end
68
- rescue Timeout::Error
69
- out << fail_issue(name, code: "doctor_check.timeout",
70
- message: "doctor_check '#{name}' exceeded #{DOCTOR_CHECK_TIMEOUT_SECONDS}s",
71
- fix: "shorten the check or split it into smaller checks")
72
- rescue StandardError => e
73
- out << fail_issue(name, code: "doctor_check.failed",
74
- message: "#{e.class}: #{e.message}",
75
- fix: "fix the :validate hook in .textus/hooks/")
76
- end
55
+ def run_registered_checks(session)
56
+ session.rpc.names(:validate).flat_map { |name| invoke_registered_check(session, name) }
57
+ end
58
+
59
+ def invoke_registered_check(session, name)
60
+ result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) do
61
+ session.rpc.invoke(:validate, name, caps: session.write_caps)
77
62
  end
78
- out
63
+ return result.map { |h| h.transform_keys(&:to_s) } if result.is_a?(Array)
64
+
65
+ [fail_issue(name, code: "doctor_check.bad_return",
66
+ message: "doctor_check '#{name}' returned #{result.class} (expected Array)",
67
+ fix: "return an array of issue hashes from the doctor_check block")]
68
+ rescue Timeout::Error
69
+ [fail_issue(name, code: "doctor_check.timeout",
70
+ message: "doctor_check '#{name}' exceeded #{DOCTOR_CHECK_TIMEOUT_SECONDS}s",
71
+ fix: "shorten the check or split it into smaller checks")]
72
+ rescue StandardError => e
73
+ [fail_issue(name, code: "doctor_check.failed",
74
+ message: "#{e.class}: #{e.message}",
75
+ fix: "fix the :validate hook in .textus/hooks/")]
79
76
  end
80
77
 
81
78
  def fail_issue(name, code:, message:, fix:)
@@ -88,6 +85,6 @@ module Textus
88
85
  }
89
86
  end
90
87
 
91
- private_class_method :run_registered_checks, :fail_issue
88
+ private_class_method :run_registered_checks, :invoke_registered_check, :fail_issue
92
89
  end
93
90
  end
@@ -11,24 +11,24 @@ module Textus
11
11
  end
12
12
 
13
13
  def can_write?(zone, role:)
14
- @manifest.permission_for(zone.to_s).allows_write?(role)
14
+ @manifest.policy.permission_for(zone.to_s).allows_write?(role)
15
15
  end
16
16
 
17
17
  def can_read?(zone, role:)
18
- @manifest.permission_for(zone.to_s).allows_read?(role)
18
+ @manifest.policy.permission_for(zone.to_s).allows_read?(role)
19
19
  end
20
20
 
21
21
  def authorize_write!(mentry, role:)
22
22
  return if can_write?(mentry.zone, role: role)
23
23
 
24
- writers = @manifest.zone_writers(mentry.zone)
24
+ writers = @manifest.policy.zone_writers(mentry.zone)
25
25
  raise WriteForbidden.new(mentry.key, mentry.zone, writers: writers)
26
26
  end
27
27
 
28
28
  def authorize_read!(mentry, role:)
29
29
  return if can_read?(mentry.zone, role: role)
30
30
 
31
- readers = @manifest.zone_readers[mentry.zone]
31
+ readers = @manifest.policy.zone_readers[mentry.zone]
32
32
  readers = nil if readers == :all
33
33
  raise ReadForbidden.new(mentry.key, mentry.zone, readers: readers)
34
34
  end
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Application
2
+ module Domain
3
3
  module Policy
4
4
  module Predicates
5
5
  # Promotion predicate: the role driving the promotion must have
@@ -20,7 +20,7 @@ module Textus
20
20
  role_str = role&.to_s
21
21
  return true if role_str.nil? || role_str.empty?
22
22
 
23
- kind = manifest.role_kind(role_str)
23
+ kind = manifest.policy.role_kind(role_str)
24
24
  return true if kind == :accept_authority
25
25
 
26
26
  @reason = "role '#{role_str}' has kind '#{kind.inspect}', expected ':accept_authority'"
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Application
2
+ module Domain
3
3
  module Policy
4
4
  module Predicates
5
5
  class SchemaValid
@@ -2,7 +2,7 @@ require_relative "predicates/schema_valid"
2
2
  require_relative "predicates/accept_authority_signed"
3
3
 
4
4
  module Textus
5
- module Application
5
+ module Domain
6
6
  module Policy
7
7
  class Promotion
8
8
  Result = Struct.new(:ok?, :reasons, keyword_init: true)
@@ -19,7 +19,7 @@ module Textus
19
19
  src = mentry.source
20
20
  return [] unless src.is_a?(Textus::Manifest::Entry::Derived::External)
21
21
 
22
- path = Textus::Key::Path.resolve(@manifest, mentry)
22
+ path = Textus::Key::Path.resolve(@manifest.data, mentry)
23
23
  return [stale_row(mentry, path, "derived entry has never been generated")] unless File.exist?(path)
24
24
 
25
25
  parsed = Entry.for_format(mentry.format).parse(File.binread(path), path: path)
@@ -63,7 +63,7 @@ module Textus
63
63
  end
64
64
 
65
65
  def check_filesystem_source(src, gen_time)
66
- abs = File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.root), src)
66
+ abs = File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.data.root), src)
67
67
  if File.directory?(abs)
68
68
  Dir.glob(File.join(abs, "**", "*")).each do |fp|
69
69
  next unless File.file?(fp)
@@ -13,10 +13,10 @@ module Textus
13
13
  def rows_for(mentry)
14
14
  return [] unless mentry.is_a?(Textus::Manifest::Entry::Intake)
15
15
 
16
- ttl = @manifest.rules_for(mentry.key).refresh&.ttl_seconds
16
+ ttl = @manifest.rules.for(mentry.key).refresh&.ttl_seconds
17
17
  return [] unless ttl
18
18
 
19
- path = Textus::Key::Path.resolve(@manifest, mentry)
19
+ path = Textus::Key::Path.resolve(@manifest.data, mentry)
20
20
  return [row(mentry, path, "never refreshed")] unless File.exist?(path)
21
21
 
22
22
  meta = Entry.for_format(mentry.format).parse(File.binread(path), path: path)["_meta"]
@@ -8,7 +8,7 @@ module Textus
8
8
  end
9
9
 
10
10
  def call(prefix: nil, zone: nil)
11
- @manifest.entries
11
+ @manifest.data.entries
12
12
  .select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
13
13
  .flat_map { |m| @generator_check.rows_for(m) + @intake_check.rows_for(m) }
14
14
  end
data/lib/textus/errors.rb CHANGED
@@ -215,4 +215,20 @@ module Textus
215
215
  )
216
216
  end
217
217
  end
218
+
219
+ class CursorExpired < Error
220
+ attr_reader :requested, :min_available
221
+
222
+ def initialize(requested:, min_available:)
223
+ @requested = requested
224
+ @min_available = min_available
225
+ super(
226
+ "cursor_expired",
227
+ "audit cursor expired: requested seq=#{requested} but oldest available is #{min_available}; " \
228
+ "call `textus boot` to re-orient and resume from latest_seq",
229
+ details: { "requested" => requested, "min_available" => min_available },
230
+ hint: "call `textus boot` to get the current latest_seq and resume from there",
231
+ )
232
+ end
233
+ end
218
234
  end
@@ -7,23 +7,23 @@ module Textus
7
7
  module Hooks
8
8
  module Builtin
9
9
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
10
- def self.register_all(bus)
11
- bus.on(:resolve_intake, :json) do |store:, config:, args:|
12
- _ = store
10
+ def self.register_all(events:, rpc:) # rubocop:disable Lint/UnusedMethodArgument
11
+ rpc.register(:resolve_intake, :json) do |caps:, config:, args:|
12
+ _ = caps
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
- bus.on(:resolve_intake, :csv) do |store:, config:, args:|
19
- _ = store
18
+ rpc.register(:resolve_intake, :csv) do |caps:, config:, args:|
19
+ _ = caps
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
- bus.on(:resolve_intake, :"markdown-links") do |store:, config:, args:|
26
- _ = store
25
+ rpc.register(:resolve_intake, :"markdown-links") do |caps:, config:, args:|
26
+ _ = caps
27
27
  _ = args
28
28
  links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
29
29
  { "text" => text, "href" => href }
@@ -31,27 +31,27 @@ module Textus
31
31
  { _meta: {}, body: YAML.dump(links) }
32
32
  end
33
33
 
34
- bus.on(:resolve_intake, :"ical-events") do |store:, config:, args:|
35
- _ = store
34
+ rpc.register(:resolve_intake, :"ical-events") do |caps:, config:, args:|
35
+ _ = caps
36
36
  _ = args
37
- events = []
37
+ events_list = []
38
38
  current = nil
39
39
  config["bytes"].to_s.each_line do |line|
40
40
  line = line.strip
41
41
  case line
42
42
  when "BEGIN:VEVENT" then current = {}
43
43
  when "END:VEVENT"
44
- events << current if current
44
+ events_list << current if current
45
45
  current = nil
46
46
  when /\A(SUMMARY|DTSTART|DTEND|UID|LOCATION|DESCRIPTION):(.*)\z/
47
47
  current[Regexp.last_match(1).downcase] = Regexp.last_match(2) if current
48
48
  end
49
49
  end
50
- { _meta: {}, body: YAML.dump(events) }
50
+ { _meta: {}, body: YAML.dump(events_list) }
51
51
  end
52
52
 
53
- bus.on(:resolve_intake, :rss) do |store:, config:, args:|
54
- _ = store
53
+ rpc.register(:resolve_intake, :rss) do |caps:, config:, args:|
54
+ _ = caps
55
55
  _ = args
56
56
  doc = REXML::Document.new(config["bytes"].to_s)
57
57
  items = doc.elements.to_a("//item").map do |item|
@@ -3,31 +3,31 @@
3
3
  module Textus
4
4
  module Hooks
5
5
  # A narrow handle passed to user hooks in place of the raw Store.
6
- # All writes route back through Operations so authorization, audit
6
+ # All writes route back through the Session so authorization, audit
7
7
  # logging, and schema validation always fire.
8
8
  class Context
9
9
  attr_reader :role, :correlation_id
10
10
 
11
- def initialize(ops:)
12
- @ops = ops
13
- @role = ops.ctx.role
14
- @correlation_id = ops.ctx.correlation_id
11
+ def initialize(session:)
12
+ @session = session
13
+ @role = session.ctx.role
14
+ @correlation_id = session.ctx.correlation_id
15
15
  end
16
16
 
17
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)
18
+ def get(key) = @session.get(key)
19
+ def list(**) = @session.list(**)
20
+ def deps(key) = @session.deps(key)
21
+ def freshness(key) = @session.freshness(key)
22
22
 
23
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, **)
24
+ def put(key, **) = @session.put(key, **)
25
+ def delete(key, **) = @session.delete(key, **)
26
+ def audit(verb, key:, **) = @session.write_caps.audit_log.append(role: @role, verb: verb, key: key, **)
27
27
 
28
28
  # fan-out
29
29
  def publish_followup(event, **)
30
- @ops.store.bus.publish(event, ctx: self, **)
30
+ @session.write_caps.events.publish(event, ctx: self, **)
31
31
  end
32
32
 
33
33
  def inspect
@@ -0,0 +1,32 @@
1
+ module Textus
2
+ module Hooks
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
@@ -2,67 +2,55 @@
2
2
 
3
3
  module Textus
4
4
  module Hooks
5
- class Bus
5
+ class EventBus
6
6
  HOOK_TIMEOUT_SECONDS = 2
7
7
 
8
8
  class HookTimeout < StandardError; end
9
9
 
10
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] },
11
+ entry_put: %i[ctx key envelope],
12
+ entry_deleted: %i[ctx key],
13
+ entry_refreshed: %i[ctx key envelope change],
14
+ entry_renamed: %i[ctx key from_key to_key envelope],
15
+ build_completed: %i[ctx key envelope sources],
16
+ proposal_accepted: %i[ctx key target_key],
17
+ proposal_rejected: %i[ctx key target_key],
18
+ file_published: %i[ctx key envelope source target],
19
+ store_loaded: %i[ctx],
20
+ refresh_started: %i[ctx key mode],
21
+ refresh_failed: %i[ctx key error_class error_message],
22
+ refresh_backgrounded: %i[ctx key started_at budget_ms],
29
23
  }.freeze
30
24
 
31
- def initialize
32
- @rpc = Hash.new { |h, k| h[k] = {} }
25
+ RPC_EVENTS = %i[resolve_intake transform_rows validate].freeze
26
+
27
+ def initialize(error_log: ErrorLog.new)
33
28
  @pubsub = Hash.new { |h, k| h[k] = [] }
34
29
  @error_handlers = []
30
+ @error_log = error_log
35
31
  end
36
32
 
33
+ attr_reader :error_log
34
+
37
35
  def on(event, name, keys: nil, &) = register(event, name, keys: keys, &)
38
36
 
39
37
  def register(event, name, keys: nil, &blk)
40
38
  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)
39
+ raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if RPC_EVENTS.include?(event_sym)
48
40
 
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 }
41
+ required = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
42
+ shape_check!(event_sym, required, blk)
43
+ name = name.to_sym
44
+ raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
52
45
 
53
- @pubsub[event_sym] << { name: name, callable: blk, keys: keys }
54
- end
46
+ @pubsub[event_sym] << { name: name, callable: blk, keys: keys }
55
47
  end
56
48
 
57
49
  def on_error(&block) = @error_handlers << block
58
50
 
59
- def rpc_callable(event, name)
60
- @rpc[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
61
- end
51
+ def listeners(event, key:) = @pubsub[event.to_sym].select { |h| match?(h[:keys], key) }
62
52
 
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) }
53
+ def pubsub_handlers(event) = @pubsub[event.to_sym]
66
54
 
67
55
  def publish(event, strict: false, **kwargs)
68
56
  key = kwargs[:key] || "-"
@@ -93,29 +81,33 @@ module Textus
93
81
  def invoke(event, sub, key, kwargs)
94
82
  accepted = filter_kwargs(sub[:callable], kwargs)
95
83
  error = nil
96
-
97
84
  thread = Thread.new do
98
85
  sub[:callable].call(**accepted)
99
86
  rescue StandardError => e
100
87
  error = e
101
88
  end
102
-
103
89
  if thread.join(HOOK_TIMEOUT_SECONDS).nil?
104
90
  thread.kill
105
91
  err = HookTimeout.new("hook #{sub[:name]} exceeded #{HOOK_TIMEOUT_SECONDS}s on event #{event}")
106
92
  notify_error(event, sub, key, kwargs, err)
107
93
  return [:timed_out, err]
108
94
  end
109
-
110
95
  if error
111
96
  notify_error(event, sub, key, kwargs, error)
112
97
  return [:errored, error]
113
98
  end
114
-
115
99
  [:ok, nil]
116
100
  end
117
101
 
118
102
  def notify_error(event, sub, key, kwargs, error)
103
+ @error_log.record(
104
+ seq: kwargs[:_audit_seq] || -1,
105
+ event: event,
106
+ hook: sub[:name],
107
+ key: key,
108
+ error_class: error.class.name,
109
+ error_message: error.message,
110
+ )
119
111
  @error_handlers.each do |handler|
120
112
  handler.call(event: event, hook: sub[:name], key: key, kwargs: kwargs, error: error)
121
113
  rescue StandardError => e
@@ -127,18 +119,16 @@ module Textus
127
119
  params = callable.parameters
128
120
  return kwargs if params.any? { |type, _| type == :keyrest }
129
121
 
130
- accepted = params.each_with_object([]) do |(type, name), acc|
131
- acc << name if %i[key keyreq].include?(type)
132
- end
122
+ accepted = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
133
123
  kwargs.slice(*accepted)
134
124
  end
135
125
 
136
- def shape_check!(event, spec, blk)
137
- required = spec[:args]
126
+ def shape_check!(event, required, blk)
138
127
  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?
128
+ return if provided.any? { |t, _| t == :keyrest }
129
+
130
+ missing = required - provided.map { |_, n| n }
131
+ return if missing.empty?
142
132
 
143
133
  raise UsageError.new("#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
144
134
  end
@@ -148,8 +138,6 @@ module Textus
148
138
 
149
139
  Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
150
140
  end
151
-
152
- def matches_any?(globs, key) = match?(globs, key)
153
141
  end
154
142
  end
155
143
  end
@@ -1,8 +1,34 @@
1
1
  module Textus
2
2
  module Hooks
3
3
  class Loader
4
- def initialize(bus:)
5
- @bus = bus
4
+ # A small DSL object passed to user hook blocks. Routes `.on(...)` to the
5
+ # EventBus and `.rpc(...)` / `.register(...)` to the RpcRegistry.
6
+ class Dsl
7
+ def initialize(events:, rpc:)
8
+ @events = events
9
+ @rpc = rpc
10
+ end
11
+
12
+ # Pubsub registration — delegates to EventBus.
13
+ # Also handles RPC event names by delegating to RpcRegistry.
14
+ def on(event, name, keys: nil, &)
15
+ if Hooks::RpcRegistry::EVENTS.key?(event.to_sym)
16
+ @rpc.register(event, name, &)
17
+ else
18
+ @events.register(event, name, keys: keys, &)
19
+ end
20
+ end
21
+
22
+ # Explicit RPC registration.
23
+ def register(event, name, &)
24
+ @rpc.register(event, name, &)
25
+ end
26
+ end
27
+
28
+ def initialize(events:, rpc:)
29
+ @events = events
30
+ @rpc = rpc
31
+ @dsl = Dsl.new(events: @events, rpc: @rpc)
6
32
  end
7
33
 
8
34
  def load_dir(dir)
@@ -18,7 +44,7 @@ module Textus
18
44
  end
19
45
 
20
46
  Textus.drain_hook_blocks.each do |blk|
21
- blk.call(@bus)
47
+ blk.call(@dsl)
22
48
  rescue StandardError, ScriptError => e
23
49
  raise UsageError.new("failed registering hook: #{e.class}: #{e.message}")
24
50
  end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Hooks
5
+ class RpcRegistry
6
+ EVENTS = {
7
+ resolve_intake: %i[caps config args],
8
+ transform_rows: %i[caps rows config],
9
+ validate: %i[caps],
10
+ }.freeze
11
+
12
+ PUBSUB_EVENTS = EventBus::EVENTS.keys.freeze
13
+
14
+ def initialize
15
+ @table = Hash.new { |h, k| h[k] = {} }
16
+ end
17
+
18
+ def register(event, name, &blk)
19
+ event_sym = event.to_sym
20
+ raise UsageError.new("#{event_sym} is a pubsub event; register on EventBus") if PUBSUB_EVENTS.include?(event_sym)
21
+
22
+ required = EVENTS[event_sym] or raise UsageError.new("unknown RPC event: #{event}")
23
+ shape_check!(event_sym, required, blk)
24
+ name = name.to_sym
25
+ raise UsageError.new("#{event_sym} '#{name}' already registered") if @table[event_sym].key?(name)
26
+
27
+ @table[event_sym][name] = blk
28
+ end
29
+
30
+ def names(event) = @table[event.to_sym].keys
31
+
32
+ def callable(event, name)
33
+ @table[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
34
+ end
35
+
36
+ # Invoke a registered callable, injecting `caps:` under the kwarg name
37
+ # the callable declares. Legacy `store:` is rejected (no shim).
38
+ def invoke(event, name, caps:, **other)
39
+ blk = callable(event, name)
40
+ params = blk.parameters
41
+ accepts_keyrest = params.any? { |t, _| t == :keyrest }
42
+ declared = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
43
+
44
+ if declared.include?(:store)
45
+ raise UsageError.new(
46
+ "RPC callable for #{event} '#{name}' declares legacy `store:`; rename to `caps:` " \
47
+ "(Textus::Application::ReadCaps / WriteCaps)",
48
+ )
49
+ end
50
+
51
+ kwargs = other.dup
52
+ kwargs[:caps] = caps if accepts_keyrest || declared.include?(:caps)
53
+ blk.call(**kwargs)
54
+ end
55
+
56
+ private
57
+
58
+ def shape_check!(event, required, blk)
59
+ provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
60
+ return if provided.any? { |t, _| t == :keyrest }
61
+
62
+ param_names = provided.map { |_, n| n }
63
+ # Allow `store:` as a stand-in for `caps:` so registration succeeds;
64
+ # invoke will raise UsageError when the callable is actually called.
65
+ effective_required = if param_names.include?(:store)
66
+ required.map { |r| r == :caps ? :store : r }
67
+ else
68
+ required
69
+ end
70
+ missing = effective_required - param_names
71
+ return if missing.empty?
72
+
73
+ raise UsageError.new("#{event} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
74
+ end
75
+ end
76
+ end
77
+ end