textus 0.22.0 → 0.29.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 (186) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +195 -48
  3. data/CHANGELOG.md +178 -0
  4. data/README.md +55 -13
  5. data/SPEC.md +79 -42
  6. data/docs/conventions.md +10 -0
  7. data/lib/textus/boot.rb +31 -29
  8. data/lib/textus/builder/pipeline.rb +13 -12
  9. data/lib/textus/call.rb +28 -0
  10. data/lib/textus/cli/group/mcp.rb +9 -0
  11. data/lib/textus/cli/group/zone.rb +9 -0
  12. data/lib/textus/cli/verb/accept.rb +1 -1
  13. data/lib/textus/cli/verb/audit.rb +2 -2
  14. data/lib/textus/cli/verb/blame.rb +1 -1
  15. data/lib/textus/cli/verb/boot.rb +1 -1
  16. data/lib/textus/cli/verb/build.rb +3 -3
  17. data/lib/textus/cli/verb/delete.rb +1 -1
  18. data/lib/textus/cli/verb/deps.rb +1 -1
  19. data/lib/textus/cli/verb/doctor.rb +1 -1
  20. data/lib/textus/cli/verb/freshness.rb +1 -1
  21. data/lib/textus/cli/verb/get.rb +1 -1
  22. data/lib/textus/cli/verb/hook_run.rb +3 -4
  23. data/lib/textus/cli/verb/hooks.rb +11 -14
  24. data/lib/textus/cli/verb/key_delete.rb +24 -0
  25. data/lib/textus/cli/verb/list.rb +1 -1
  26. data/lib/textus/cli/verb/mcp_serve.rb +17 -0
  27. data/lib/textus/cli/verb/migrate.rb +18 -0
  28. data/lib/textus/cli/verb/mv.rb +11 -3
  29. data/lib/textus/cli/verb/published.rb +1 -1
  30. data/lib/textus/cli/verb/pulse.rb +1 -1
  31. data/lib/textus/cli/verb/put.rb +8 -6
  32. data/lib/textus/cli/verb/rdeps.rb +1 -1
  33. data/lib/textus/cli/verb/refresh.rb +1 -1
  34. data/lib/textus/cli/verb/refresh_stale.rb +1 -1
  35. data/lib/textus/cli/verb/reject.rb +1 -1
  36. data/lib/textus/cli/verb/rule_explain.rb +1 -1
  37. data/lib/textus/cli/verb/rule_lint.rb +18 -0
  38. data/lib/textus/cli/verb/schema.rb +1 -1
  39. data/lib/textus/cli/verb/uid.rb +1 -1
  40. data/lib/textus/cli/verb/where.rb +1 -1
  41. data/lib/textus/cli/verb/zone_mv.rb +19 -0
  42. data/lib/textus/cli/verb.rb +7 -7
  43. data/lib/textus/cli.rb +0 -7
  44. data/lib/textus/container.rb +23 -0
  45. data/lib/textus/dispatcher.rb +49 -0
  46. data/lib/textus/doctor/check/audit_log.rb +2 -2
  47. data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
  48. data/lib/textus/doctor/check/hooks.rb +4 -3
  49. data/lib/textus/doctor/check/illegal_keys.rb +2 -2
  50. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  51. data/lib/textus/doctor/check/manifest_files.rb +2 -2
  52. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  53. data/lib/textus/doctor/check/refresh_locks.rb +2 -2
  54. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  55. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  56. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  57. data/lib/textus/doctor/check/schemas.rb +2 -2
  58. data/lib/textus/doctor/check/sentinels.rb +11 -9
  59. data/lib/textus/doctor/check/templates.rb +2 -2
  60. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  61. data/lib/textus/doctor/check.rb +12 -3
  62. data/lib/textus/doctor.rb +24 -27
  63. data/lib/textus/domain/authorizer.rb +6 -6
  64. data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
  65. data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
  66. data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
  67. data/lib/textus/domain/sentinel.rb +9 -65
  68. data/lib/textus/domain/staleness/generator_check.rb +46 -26
  69. data/lib/textus/domain/staleness/intake_check.rb +20 -12
  70. data/lib/textus/domain/staleness.rb +4 -4
  71. data/lib/textus/envelope/io/reader.rb +44 -0
  72. data/lib/textus/{application/writes/envelope_io.rb → envelope/io/writer.rb} +32 -58
  73. data/lib/textus/hooks/builtin.rb +14 -14
  74. data/lib/textus/hooks/context.rb +30 -13
  75. data/lib/textus/hooks/error_log.rb +32 -0
  76. data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
  77. data/lib/textus/hooks/loader.rb +29 -3
  78. data/lib/textus/hooks/rpc_registry.rb +77 -0
  79. data/lib/textus/key/path.rb +7 -3
  80. data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
  81. data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
  82. data/lib/textus/maintenance/migrate.rb +51 -0
  83. data/lib/textus/maintenance/rule_lint.rb +56 -0
  84. data/lib/textus/maintenance/zone_mv.rb +51 -0
  85. data/lib/textus/maintenance.rb +15 -0
  86. data/lib/textus/manifest/data.rb +79 -0
  87. data/lib/textus/manifest/entry/base.rb +38 -18
  88. data/lib/textus/manifest/entry/derived.rb +8 -9
  89. data/lib/textus/manifest/entry/nested.rb +7 -9
  90. data/lib/textus/manifest/entry/parser.rb +2 -2
  91. data/lib/textus/manifest/entry/validators/events.rb +2 -2
  92. data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
  93. data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
  94. data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
  95. data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
  96. data/lib/textus/manifest/entry/validators.rb +2 -2
  97. data/lib/textus/manifest/entry.rb +0 -5
  98. data/lib/textus/manifest/policy.rb +48 -0
  99. data/lib/textus/manifest/resolver.rb +14 -14
  100. data/lib/textus/manifest/rules.rb +1 -1
  101. data/lib/textus/manifest.rb +47 -110
  102. data/lib/textus/mcp/errors.rb +32 -0
  103. data/lib/textus/mcp/server.rb +126 -0
  104. data/lib/textus/mcp/session.rb +40 -0
  105. data/lib/textus/mcp/tool_schemas.rb +71 -0
  106. data/lib/textus/mcp/tools.rb +129 -0
  107. data/lib/textus/mcp.rb +6 -0
  108. data/lib/textus/{infra → ports}/audit_log.rb +1 -1
  109. data/lib/textus/{infra → ports}/audit_subscriber.rb +7 -8
  110. data/lib/textus/{infra → ports}/build_lock.rb +1 -1
  111. data/lib/textus/{infra → ports}/clock.rb +1 -1
  112. data/lib/textus/{infra → ports}/publisher.rb +6 -6
  113. data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
  114. data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
  115. data/lib/textus/ports/sentinel_store.rb +67 -0
  116. data/lib/textus/ports/storage/file_stat.rb +19 -0
  117. data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
  118. data/lib/textus/projection.rb +91 -0
  119. data/lib/textus/read/audit.rb +111 -0
  120. data/lib/textus/read/blame.rb +81 -0
  121. data/lib/textus/read/boot.rb +18 -0
  122. data/lib/textus/read/deps.rb +24 -0
  123. data/lib/textus/read/doctor.rb +19 -0
  124. data/lib/textus/read/freshness.rb +101 -0
  125. data/lib/textus/read/get.rb +66 -0
  126. data/lib/textus/read/get_or_refresh.rb +69 -0
  127. data/lib/textus/read/list.rb +15 -0
  128. data/lib/textus/read/policy_explain.rb +37 -0
  129. data/lib/textus/read/published.rb +15 -0
  130. data/lib/textus/read/pulse.rb +89 -0
  131. data/lib/textus/read/rdeps.rb +25 -0
  132. data/lib/textus/read/schema_envelope.rb +16 -0
  133. data/lib/textus/read/stale.rb +17 -0
  134. data/lib/textus/read/uid.rb +20 -0
  135. data/lib/textus/read/validate_all.rb +22 -0
  136. data/lib/textus/read/validator.rb +84 -0
  137. data/lib/textus/read/where.rb +16 -0
  138. data/lib/textus/role_scope.rb +49 -0
  139. data/lib/textus/schema/tools.rb +14 -10
  140. data/lib/textus/store.rb +25 -11
  141. data/lib/textus/version.rb +1 -1
  142. data/lib/textus/write/accept.rb +86 -0
  143. data/lib/textus/write/authority_gate.rb +24 -0
  144. data/lib/textus/write/delete.rb +54 -0
  145. data/lib/textus/write/materializer.rb +48 -0
  146. data/lib/textus/write/mv.rb +123 -0
  147. data/lib/textus/write/publish.rb +66 -0
  148. data/lib/textus/write/put.rb +59 -0
  149. data/lib/textus/write/refresh_all.rb +44 -0
  150. data/lib/textus/write/refresh_orchestrator.rb +102 -0
  151. data/lib/textus/write/refresh_worker.rb +138 -0
  152. data/lib/textus/write/reject.rb +54 -0
  153. data/lib/textus.rb +7 -1
  154. metadata +75 -46
  155. data/lib/textus/application/context.rb +0 -34
  156. data/lib/textus/application/projection.rb +0 -91
  157. data/lib/textus/application/reads/audit.rb +0 -94
  158. data/lib/textus/application/reads/blame.rb +0 -82
  159. data/lib/textus/application/reads/deps.rb +0 -26
  160. data/lib/textus/application/reads/freshness.rb +0 -88
  161. data/lib/textus/application/reads/get.rb +0 -67
  162. data/lib/textus/application/reads/get_or_refresh.rb +0 -51
  163. data/lib/textus/application/reads/list.rb +0 -17
  164. data/lib/textus/application/reads/policy_explain.rb +0 -39
  165. data/lib/textus/application/reads/published.rb +0 -17
  166. data/lib/textus/application/reads/pulse.rb +0 -63
  167. data/lib/textus/application/reads/rdeps.rb +0 -27
  168. data/lib/textus/application/reads/schema_envelope.rb +0 -18
  169. data/lib/textus/application/reads/stale.rb +0 -15
  170. data/lib/textus/application/reads/uid.rb +0 -23
  171. data/lib/textus/application/reads/validate_all.rb +0 -24
  172. data/lib/textus/application/reads/validator.rb +0 -86
  173. data/lib/textus/application/reads/where.rb +0 -18
  174. data/lib/textus/application/refresh/all.rb +0 -52
  175. data/lib/textus/application/refresh/orchestrator.rb +0 -78
  176. data/lib/textus/application/refresh/worker.rb +0 -116
  177. data/lib/textus/application/writes/accept.rb +0 -89
  178. data/lib/textus/application/writes/authority_gate.rb +0 -26
  179. data/lib/textus/application/writes/delete.rb +0 -33
  180. data/lib/textus/application/writes/materializer.rb +0 -50
  181. data/lib/textus/application/writes/mv.rb +0 -105
  182. data/lib/textus/application/writes/publish.rb +0 -81
  183. data/lib/textus/application/writes/put.rb +0 -37
  184. data/lib/textus/application/writes/reject.rb +0 -50
  185. data/lib/textus/infra/event_bus.rb +0 -27
  186. data/lib/textus/operations.rb +0 -176
@@ -1,56 +1,38 @@
1
1
  require "fileutils"
2
2
 
3
3
  module Textus
4
- module Application
5
- module Writes
6
- # Owns the write pipeline (validate, serialize, etag-check, write, audit)
7
- # extracted from Store::Writer. Talks to ports (FileStore, Schemas,
8
- # AuditLog, Manifest) instead of File/FileUtils and Store directly.
4
+ class Envelope
5
+ module IO
6
+ # Owns the write pipeline (validate, serialize, etag-check, write, audit).
7
+ # Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
8
+ # Reader for the existing-uid lookup.
9
+ #
10
+ # Invariant: every public method's final action is @audit_log.append(...).
9
11
  #
10
12
  # No permission check, no event firing — those belong to the caller
11
- # (Application::Writes::Put / ::Delete / ::Mv).
12
- class EnvelopeIO
13
+ # (Write::Put / ::Delete / ::Mv).
14
+ class Writer
13
15
  Payload = Data.define(:meta, :body, :content)
14
16
 
15
- def initialize(file_store:, manifest:, schemas:, audit_log:, ctx:)
17
+ def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
16
18
  @file_store = file_store
17
19
  @manifest = manifest
18
20
  @schemas = schemas
19
21
  @audit_log = audit_log
20
- @ctx = ctx
22
+ @call = call
23
+ @reader = reader
21
24
  end
22
25
 
23
- def exists?(path) = @file_store.exists?(path)
24
-
25
- # Reads an envelope by key, returning nil when absent. Used by Mv
26
- # to inspect pre-move state (UID presence, content surfacing) so
27
- # the move pipeline can consolidate I/O in one place.
28
- def read_envelope(key)
29
- res = @manifest.resolver.resolve(key)
30
- path = res.path
31
- return nil unless @file_store.exists?(path)
32
-
33
- mentry = res.entry
34
- raw = @file_store.read(path)
35
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
36
- Envelope.build(
37
- key: key, mentry: mentry, path: path,
38
- meta: parsed["_meta"], body: parsed["body"],
39
- etag: Etag.for_bytes(raw), content: parsed["content"]
40
- )
41
- end
42
-
43
- def write(key, mentry:, payload:, if_etag: nil)
26
+ def put(key, mentry:, payload:, if_etag: nil)
44
27
  path = @manifest.resolver.resolve(key).path
45
28
 
46
29
  meta = payload.meta || {}
47
- strategy = Entry.for_format(mentry.format)
48
30
 
49
- existing_uid = existing_uid_for(mentry, path)
31
+ existing_uid = @reader.existing_uid(key)
50
32
  meta, content = ensure_uid(mentry.format, meta, payload.content, existing_uid)
51
33
 
52
34
  bytes, eff_meta, eff_body, eff_content = serialize_for_put(
53
- mentry: mentry, path: path, strategy: strategy,
35
+ mentry: mentry, path: path,
54
36
  meta: meta, body: payload.body, content: content
55
37
  )
56
38
 
@@ -69,19 +51,22 @@ module Textus
69
51
 
70
52
  @file_store.write(path, bytes)
71
53
  etag_after = Etag.for_bytes(bytes)
72
- @audit_log.append(
73
- role: @ctx.role, verb: "put", key: key,
74
- etag_before: etag_before, etag_after: etag_after,
75
- extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
76
- )
77
- Envelope.build(
54
+ envelope = Textus::Envelope.build(
78
55
  key: key, mentry: mentry, path: path,
79
56
  meta: eff_meta, body: eff_body, etag: etag_after, content: eff_content
80
57
  )
58
+ @audit_log.append(
59
+ role: @call.role, verb: "put", key: key,
60
+ etag_before: etag_before, etag_after: etag_after,
61
+ extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
62
+ )
63
+ envelope
81
64
  end
82
65
 
83
- def delete(key, mentry:, if_etag: nil)
84
- _ = mentry
66
+ def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
67
+ # `mentry:` is accepted for symmetry with `put` / `move` and to
68
+ # leave room for future format-specific delete hooks; no field
69
+ # on it is needed today.
85
70
  path = @manifest.resolver.resolve(key).path
86
71
  raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
87
72
 
@@ -90,9 +75,9 @@ module Textus
90
75
 
91
76
  @file_store.delete(path)
92
77
  @audit_log.append(
93
- role: @ctx.role, verb: "delete", key: key,
78
+ role: @call.role, verb: "delete", key: key,
94
79
  etag_before: etag_before, etag_after: nil,
95
- extras: @ctx.correlation_id ? { "correlation_id" => @ctx.correlation_id } : nil
80
+ extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
96
81
  )
97
82
  end
98
83
 
@@ -112,7 +97,7 @@ module Textus
112
97
 
113
98
  raw = @file_store.read(to_path)
114
99
  parsed = Entry.for_format(new_mentry.format).parse(raw, path: to_path)
115
- envelope = Envelope.build(
100
+ envelope = Textus::Envelope.build(
116
101
  key: to_key, mentry: new_mentry, path: to_path,
117
102
  meta: parsed["_meta"], body: parsed["body"],
118
103
  etag: etag_after, content: parsed["content"]
@@ -123,10 +108,10 @@ module Textus
123
108
  "from_path" => from_path, "to_path" => to_path,
124
109
  "uid" => envelope.uid
125
110
  }
126
- extras["correlation_id"] = @ctx.correlation_id if @ctx.correlation_id
111
+ extras["correlation_id"] = @call.correlation_id if @call.correlation_id
127
112
 
128
113
  @audit_log.append(
129
- role: @ctx.role, verb: "mv", key: to_key,
114
+ role: @call.role, verb: "mv", key: to_key,
130
115
  etag_before: etag_before, etag_after: etag_after,
131
116
  extras: extras
132
117
  )
@@ -136,16 +121,6 @@ module Textus
136
121
 
137
122
  private
138
123
 
139
- def existing_uid_for(mentry, path)
140
- return nil unless @file_store.exists?(path)
141
-
142
- raw = @file_store.read(path)
143
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
144
- Envelope.extract_uid(parsed["_meta"])
145
- rescue StandardError
146
- nil
147
- end
148
-
149
124
  def ensure_uid(format, meta, content, existing_uid)
150
125
  Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
151
126
  end
@@ -154,8 +129,7 @@ module Textus
154
129
  Textus::Entry.for_format(format).enforce_name_match!(path, meta)
155
130
  end
156
131
 
157
- def serialize_for_put(mentry:, path:, strategy:, meta:, body:, content:)
158
- _ = strategy
132
+ def serialize_for_put(mentry:, path:, meta:, body:, content:)
159
133
  Textus::Entry.for_format(mentry.format).serialize_for_put(
160
134
  meta: meta, body: body, content: content, path: path,
161
135
  )
@@ -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,48 @@
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 RoleScope 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 self.for(container:, call:)
12
+ scope = Textus::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
15
29
  end
16
30
 
17
31
  # 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)
32
+ def get(key) = @scope.get(key)
33
+ def list(**) = @scope.list(**)
34
+ def deps(key) = @scope.deps(key)
35
+ def freshness(key) = @scope.freshness(key)
22
36
 
23
37
  # 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, **)
38
+ def put(key, **) = @scope.put(key, **)
39
+ def delete(key, **) = @scope.delete(key, **)
40
+
41
+ def audit(verb, key:, **)
42
+ @scope.container.audit_log.append(role: @role, verb: verb, key: key, **)
43
+ end
27
44
 
28
45
  # fan-out
29
46
  def publish_followup(event, **)
30
- @ops.store.bus.publish(event, ctx: self, **)
47
+ @scope.container.events.publish(event, ctx: self, **)
31
48
  end
32
49
 
33
50
  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::Container)",
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
@@ -4,12 +4,16 @@ module Textus
4
4
  # Returns the absolute filesystem path for a manifest entry (the leaf file,
5
5
  # not a nested directory). Adds the format's primary extension when the
6
6
  # manifest entry's `path:` is extensionless.
7
- def self.resolve(manifest, mentry)
7
+ #
8
+ # The first argument is a Manifest::Data (or anything responding to .root);
9
+ # callers historically passed the whole Manifest but should now pass
10
+ # `manifest.data`.
11
+ def self.resolve(data, mentry)
8
12
  primary_ext = Entry.for_format(mentry.format).extensions.first
9
13
  if File.extname(mentry.path) == ""
10
- File.join(manifest.root, "zones", mentry.path + primary_ext)
14
+ File.join(data.root, "zones", mentry.path + primary_ext)
11
15
  else
12
- File.join(manifest.root, "zones", mentry.path)
16
+ File.join(data.root, "zones", mentry.path)
13
17
  end
14
18
  end
15
19
  end
@@ -0,0 +1,36 @@
1
+ module Textus
2
+ module Maintenance
3
+ # Bulk-delete every leaf key under `prefix`.
4
+ class KeyDeletePrefix
5
+ def initialize(container:, call:)
6
+ @container = container
7
+ @call = call
8
+ end
9
+
10
+ def call(prefix:, dry_run: false)
11
+ raise UsageError.new("prefix required") if prefix.nil? || prefix.empty?
12
+
13
+ leaves = Read::List.new(container: @container)
14
+ .call(prefix: prefix)
15
+ .map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
16
+
17
+ warnings = leaves.empty? ? ["no keys under #{prefix}"] : []
18
+ steps = leaves.map { |k| { "op" => "delete", "key" => k } }
19
+
20
+ plan = Plan.new(steps: steps, warnings: warnings)
21
+ return plan if dry_run
22
+
23
+ steps.each do |s|
24
+ delete.call(s["key"])
25
+ end
26
+ plan
27
+ end
28
+
29
+ private
30
+
31
+ def delete
32
+ Write::Delete.new(container: @container, call: @call)
33
+ end
34
+ end
35
+ end
36
+ end