textus 0.52.0 → 0.53.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 (254) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +62 -54
  4. data/SPEC.md +62 -187
  5. data/docs/architecture/README.md +88 -77
  6. data/exe/textus +1 -1
  7. data/lib/textus/action/accept.rb +53 -0
  8. data/lib/textus/action/audit.rb +133 -0
  9. data/lib/textus/action/base.rb +42 -0
  10. data/lib/textus/{read → action}/blame.rb +30 -22
  11. data/lib/textus/action/boot.rb +26 -0
  12. data/lib/textus/action/data_mv.rb +71 -0
  13. data/lib/textus/action/deps.rb +48 -0
  14. data/lib/textus/action/doctor.rb +26 -0
  15. data/lib/textus/action/drain.rb +41 -0
  16. data/lib/textus/action/enqueue.rb +55 -0
  17. data/lib/textus/action/get.rb +80 -0
  18. data/lib/textus/action/jobs.rb +38 -0
  19. data/lib/textus/action/key_delete.rb +46 -0
  20. data/lib/textus/action/key_delete_prefix.rb +46 -0
  21. data/lib/textus/action/key_mv.rb +143 -0
  22. data/lib/textus/action/key_mv_prefix.rb +59 -0
  23. data/lib/textus/action/list.rb +44 -0
  24. data/lib/textus/action/propose.rb +54 -0
  25. data/lib/textus/action/published.rb +26 -0
  26. data/lib/textus/action/pulse/scanner.rb +118 -0
  27. data/lib/textus/action/pulse.rb +87 -0
  28. data/lib/textus/action/put.rb +63 -0
  29. data/lib/textus/action/rdeps.rb +49 -0
  30. data/lib/textus/action/reject.rb +49 -0
  31. data/lib/textus/action/rule_explain.rb +95 -0
  32. data/lib/textus/action/rule_lint.rb +70 -0
  33. data/lib/textus/action/rule_list.rb +46 -0
  34. data/lib/textus/action/schema_envelope.rb +31 -0
  35. data/lib/textus/action/uid.rb +35 -0
  36. data/lib/textus/action/where.rb +38 -0
  37. data/lib/textus/action/write_verb.rb +58 -0
  38. data/lib/textus/background/job/base.rb +27 -0
  39. data/lib/textus/background/job/materialize.rb +31 -0
  40. data/lib/textus/background/job/refresh.rb +22 -0
  41. data/lib/textus/background/job/sweep.rb +31 -0
  42. data/lib/textus/background/job.rb +19 -0
  43. data/lib/textus/background/plan.rb +9 -0
  44. data/lib/textus/background/planner/plan.rb +113 -0
  45. data/lib/textus/{maintenance → background}/retention/apply.rb +7 -9
  46. data/lib/textus/background/worker.rb +67 -0
  47. data/lib/textus/boot.rb +53 -45
  48. data/lib/textus/command.rb +36 -0
  49. data/lib/textus/container.rb +1 -1
  50. data/lib/textus/{domain → core}/duration.rb +1 -1
  51. data/lib/textus/{domain → core}/freshness/evaluator.rb +11 -11
  52. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  53. data/lib/textus/{domain → core}/freshness.rb +2 -2
  54. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  55. data/lib/textus/{domain → core}/retention.rb +2 -2
  56. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  57. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  58. data/lib/textus/doctor/check/handler_permit.rb +34 -0
  59. data/lib/textus/doctor/check/hooks.rb +11 -18
  60. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  61. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  62. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  63. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  64. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  65. data/lib/textus/doctor/check.rb +12 -9
  66. data/lib/textus/{read → doctor}/validator.rb +22 -13
  67. data/lib/textus/doctor.rb +6 -6
  68. data/lib/textus/envelope/io/writer.rb +65 -36
  69. data/lib/textus/envelope.rb +5 -3
  70. data/lib/textus/errors.rb +17 -9
  71. data/lib/textus/events.rb +21 -0
  72. data/lib/textus/gate/auth.rb +181 -0
  73. data/lib/textus/gate.rb +114 -0
  74. data/lib/textus/init/templates/machine_intake.rb +39 -35
  75. data/lib/textus/init/templates/orientation_reducer.rb +15 -11
  76. data/lib/textus/init.rb +90 -73
  77. data/lib/textus/key/path.rb +9 -2
  78. data/lib/textus/layout.rb +13 -0
  79. data/lib/textus/manifest/data.rb +14 -14
  80. data/lib/textus/manifest/entry/base.rb +15 -11
  81. data/lib/textus/manifest/entry/parser.rb +6 -6
  82. data/lib/textus/manifest/entry/produced.rb +3 -2
  83. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  84. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
  85. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  86. data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
  87. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  88. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  89. data/lib/textus/manifest/policy/react.rb +30 -0
  90. data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
  91. data/lib/textus/{domain → manifest}/policy/source.rb +24 -19
  92. data/lib/textus/manifest/policy.rb +36 -48
  93. data/lib/textus/manifest/resolver.rb +3 -2
  94. data/lib/textus/manifest/rules.rb +4 -4
  95. data/lib/textus/manifest/schema/keys.rb +17 -11
  96. data/lib/textus/manifest/schema/validator.rb +24 -22
  97. data/lib/textus/manifest/schema/vocabulary.rb +1 -1
  98. data/lib/textus/manifest/schema.rb +2 -2
  99. data/lib/textus/manifest.rb +2 -2
  100. data/lib/textus/{produce → pipeline}/acquire/handler.rb +2 -2
  101. data/lib/textus/{produce → pipeline}/acquire/intake.rb +22 -20
  102. data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
  103. data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
  104. data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
  105. data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
  106. data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
  107. data/lib/textus/{produce → pipeline}/engine.rb +7 -5
  108. data/lib/textus/{produce → pipeline}/render.rb +3 -1
  109. data/lib/textus/ports/audit_log.rb +31 -5
  110. data/lib/textus/ports/audit_subscriber.rb +4 -4
  111. data/lib/textus/{domain/jobs → ports/queue}/job.rb +19 -12
  112. data/lib/textus/ports/queue.rb +1 -1
  113. data/lib/textus/ports/sentinel_store.rb +2 -2
  114. data/lib/textus/ports/watcher_lock.rb +48 -0
  115. data/lib/textus/projection.rb +8 -8
  116. data/lib/textus/schema/tools.rb +4 -3
  117. data/lib/textus/session.rb +6 -3
  118. data/lib/textus/step/base.rb +35 -0
  119. data/lib/textus/step/builtin/csv_fetch.rb +19 -0
  120. data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
  121. data/lib/textus/step/builtin/json_fetch.rb +18 -0
  122. data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
  123. data/lib/textus/step/builtin/rss_fetch.rb +26 -0
  124. data/lib/textus/step/builtin.rb +22 -0
  125. data/lib/textus/{hooks → step}/catalog.rb +3 -3
  126. data/lib/textus/{hooks → step}/context.rb +15 -13
  127. data/lib/textus/step/discovery.rb +24 -0
  128. data/lib/textus/{hooks → step}/error_log.rb +1 -1
  129. data/lib/textus/{hooks → step}/event_bus.rb +15 -16
  130. data/lib/textus/step/fetch.rb +13 -0
  131. data/lib/textus/{hooks → step}/fire_report.rb +1 -1
  132. data/lib/textus/step/loader.rb +108 -0
  133. data/lib/textus/step/observe.rb +31 -0
  134. data/lib/textus/step/registry_store.rb +66 -0
  135. data/lib/textus/{hooks → step}/signature.rb +1 -1
  136. data/lib/textus/step/transform.rb +12 -0
  137. data/lib/textus/step/validate.rb +11 -0
  138. data/lib/textus/step.rb +10 -0
  139. data/lib/textus/store.rb +17 -15
  140. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  141. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  142. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  143. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  144. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  145. data/lib/textus/surfaces/cli/group.rb +50 -0
  146. data/lib/textus/surfaces/cli/runner.rb +236 -0
  147. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  148. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  149. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  150. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  151. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  152. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  153. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  154. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  155. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  156. data/lib/textus/surfaces/cli/verb.rb +111 -0
  157. data/lib/textus/surfaces/cli.rb +148 -0
  158. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  159. data/lib/textus/surfaces/mcp/errors.rb +34 -0
  160. data/lib/textus/surfaces/mcp/server.rb +145 -0
  161. data/lib/textus/surfaces/mcp/session.rb +9 -0
  162. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  163. data/lib/textus/surfaces/mcp.rb +8 -0
  164. data/lib/textus/surfaces/role_scope.rb +38 -0
  165. data/lib/textus/surfaces/watcher.rb +38 -0
  166. data/lib/textus/version.rb +1 -1
  167. data/lib/textus.rb +64 -22
  168. metadata +132 -118
  169. data/lib/textus/cli/group/hook.rb +0 -9
  170. data/lib/textus/cli/group/key.rb +0 -9
  171. data/lib/textus/cli/group/mcp.rb +0 -9
  172. data/lib/textus/cli/group/rule.rb +0 -9
  173. data/lib/textus/cli/group/schema.rb +0 -9
  174. data/lib/textus/cli/group/zone.rb +0 -9
  175. data/lib/textus/cli/group.rb +0 -48
  176. data/lib/textus/cli/runner.rb +0 -193
  177. data/lib/textus/cli/verb/doctor.rb +0 -17
  178. data/lib/textus/cli/verb/get.rb +0 -18
  179. data/lib/textus/cli/verb/hook_run.rb +0 -48
  180. data/lib/textus/cli/verb/hooks.rb +0 -50
  181. data/lib/textus/cli/verb/init.rb +0 -18
  182. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  183. data/lib/textus/cli/verb/put.rb +0 -30
  184. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  185. data/lib/textus/cli/verb/schema_init.rb +0 -19
  186. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  187. data/lib/textus/cli/verb/serve.rb +0 -19
  188. data/lib/textus/cli/verb.rb +0 -116
  189. data/lib/textus/cli.rb +0 -138
  190. data/lib/textus/dispatcher.rb +0 -54
  191. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  192. data/lib/textus/domain/action.rb +0 -9
  193. data/lib/textus/domain/jobs/registry.rb +0 -37
  194. data/lib/textus/domain/permission.rb +0 -7
  195. data/lib/textus/domain/policy/base_guards.rb +0 -25
  196. data/lib/textus/domain/policy/evaluation.rb +0 -15
  197. data/lib/textus/domain/policy/guard.rb +0 -35
  198. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  199. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  200. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  201. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  202. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  203. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  204. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  205. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  206. data/lib/textus/hooks/builtin.rb +0 -70
  207. data/lib/textus/hooks/loader.rb +0 -54
  208. data/lib/textus/hooks/rpc_registry.rb +0 -43
  209. data/lib/textus/jobs/handlers.rb +0 -62
  210. data/lib/textus/jobs/scheduler.rb +0 -36
  211. data/lib/textus/jobs/seeder.rb +0 -57
  212. data/lib/textus/maintenance/drain.rb +0 -42
  213. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  214. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  215. data/lib/textus/maintenance/rule_lint.rb +0 -66
  216. data/lib/textus/maintenance/serve.rb +0 -30
  217. data/lib/textus/maintenance/worker.rb +0 -74
  218. data/lib/textus/maintenance/zone_mv.rb +0 -64
  219. data/lib/textus/maintenance.rb +0 -15
  220. data/lib/textus/mcp/catalog.rb +0 -70
  221. data/lib/textus/mcp/errors.rb +0 -32
  222. data/lib/textus/mcp/server.rb +0 -138
  223. data/lib/textus/mcp/session.rb +0 -7
  224. data/lib/textus/mcp/tool_schemas.rb +0 -15
  225. data/lib/textus/mcp.rb +0 -6
  226. data/lib/textus/mustache.rb +0 -117
  227. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
  228. data/lib/textus/produce/events.rb +0 -36
  229. data/lib/textus/read/audit.rb +0 -130
  230. data/lib/textus/read/boot.rb +0 -26
  231. data/lib/textus/read/capabilities.rb +0 -70
  232. data/lib/textus/read/deps.rb +0 -38
  233. data/lib/textus/read/doctor.rb +0 -27
  234. data/lib/textus/read/freshness.rb +0 -152
  235. data/lib/textus/read/get.rb +0 -73
  236. data/lib/textus/read/jobs.rb +0 -31
  237. data/lib/textus/read/list.rb +0 -24
  238. data/lib/textus/read/published.rb +0 -22
  239. data/lib/textus/read/pulse.rb +0 -98
  240. data/lib/textus/read/rdeps.rb +0 -39
  241. data/lib/textus/read/rule_explain.rb +0 -96
  242. data/lib/textus/read/rule_list.rb +0 -54
  243. data/lib/textus/read/schema_envelope.rb +0 -25
  244. data/lib/textus/read/uid.rb +0 -29
  245. data/lib/textus/read/validate_all.rb +0 -36
  246. data/lib/textus/read/where.rb +0 -24
  247. data/lib/textus/role_scope.rb +0 -78
  248. data/lib/textus/write/accept.rb +0 -58
  249. data/lib/textus/write/enqueue.rb +0 -50
  250. data/lib/textus/write/key_delete.rb +0 -65
  251. data/lib/textus/write/key_mv.rb +0 -141
  252. data/lib/textus/write/propose.rb +0 -54
  253. data/lib/textus/write/put.rb +0 -74
  254. data/lib/textus/write/reject.rb +0 -68
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Domain
5
- module Policy
6
- module Predicates
7
- # Predicate #0 of every write guard. Wraps the post-0.31.0 capability
8
- # topology gate (role.can ⊇ verb_for(zone.kind)). On failure, #error
9
- # raises the capability-shaped WriteForbidden so the topology refusal
10
- # — textus's signature product feature — is unchanged.
11
- class ZoneWritableBy
12
- attr_reader :reason
13
-
14
- def name = "zone_writable_by"
15
-
16
- def call(eval)
17
- manifest = eval.manifest
18
- @mentry = manifest.resolver.resolve(eval.target).entry
19
- return true if manifest.policy.permission_for(@mentry.zone.to_s).allows_write?(eval.actor)
20
-
21
- @verb = manifest.policy.verb_for_zone(@mentry.zone) # capability the kind requires
22
- @holders = manifest.policy.roles_with_capability(@verb)
23
- @reason = "zone '#{@mentry.zone}' needs capability '#{@verb}'; '#{eval.actor}' lacks it"
24
- false
25
- end
26
-
27
- # Matches the capability-shaped WriteForbidden landed by ADR 0030
28
- # Task 3:
29
- # WriteForbidden.new(key, zone, verb:, holders:)
30
- # → "writing '<k>' (zone '<z>') needs capability '<verb>'",
31
- # hint: "held by: <holders>; pass --as=<role>".
32
- def error(_eval)
33
- Textus::WriteForbidden.new(@mentry.key, @mentry.zone, verb: @verb, holders: @holders)
34
- end
35
- end
36
- end
37
- end
38
- end
39
- end
@@ -1,70 +0,0 @@
1
- require "json"
2
- require "csv"
3
- require "yaml"
4
- require "rexml/document"
5
-
6
- module Textus
7
- module Hooks
8
- module Builtin
9
- # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
10
- def self.register_all(events:, rpc:) # rubocop:disable Lint/UnusedMethodArgument
11
- rpc.register(:resolve_handler, :json) do |caps:, config:, args:|
12
- _ = caps
13
- _ = args
14
- data = JSON.parse(config["bytes"].to_s)
15
- { _meta: {}, body: YAML.dump(data) }
16
- end
17
-
18
- rpc.register(:resolve_handler, :csv) do |caps:, config:, args:|
19
- _ = caps
20
- _ = args
21
- rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
22
- { _meta: {}, body: YAML.dump(rows) }
23
- end
24
-
25
- rpc.register(:resolve_handler, :"markdown-links") do |caps:, config:, args:|
26
- _ = caps
27
- _ = args
28
- links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
29
- { "text" => text, "href" => href }
30
- end
31
- { _meta: {}, body: YAML.dump(links) }
32
- end
33
-
34
- rpc.register(:resolve_handler, :"ical-events") do |caps:, config:, args:|
35
- _ = caps
36
- _ = args
37
- events_list = []
38
- current = nil
39
- config["bytes"].to_s.each_line do |line|
40
- line = line.strip
41
- case line
42
- when "BEGIN:VEVENT" then current = {}
43
- when "END:VEVENT"
44
- events_list << current if current
45
- current = nil
46
- when /\A(SUMMARY|DTSTART|DTEND|UID|LOCATION|DESCRIPTION):(.*)\z/
47
- current[Regexp.last_match(1).downcase] = Regexp.last_match(2) if current
48
- end
49
- end
50
- { _meta: {}, body: YAML.dump(events_list) }
51
- end
52
-
53
- rpc.register(:resolve_handler, :rss) do |caps:, config:, args:|
54
- _ = caps
55
- _ = args
56
- doc = REXML::Document.new(config["bytes"].to_s)
57
- items = doc.elements.to_a("//item").map do |item|
58
- {
59
- "title" => item.elements["title"]&.text,
60
- "link" => item.elements["link"]&.text,
61
- "pubDate" => item.elements["pubDate"]&.text,
62
- }
63
- end
64
- { _meta: {}, body: YAML.dump(items) }
65
- end
66
- end
67
- # rubocop:enable Metrics/AbcSize, Metrics/MethodLength
68
- end
69
- end
70
- end
@@ -1,54 +0,0 @@
1
- module Textus
2
- module Hooks
3
- class Loader
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::Catalog::RPC.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)
32
- end
33
-
34
- def load_dir(dir)
35
- return unless File.directory?(dir)
36
-
37
- # Discard any leftover blocks from a prior partial load.
38
- Textus.drain_hook_blocks
39
-
40
- Dir.glob(File.join(dir, "**/*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
41
- load(f)
42
- rescue StandardError, ScriptError => e
43
- raise UsageError.new("failed loading hook #{File.basename(f)}: #{e.class}: #{e.message}")
44
- end
45
-
46
- Textus.drain_hook_blocks.each do |blk|
47
- blk.call(@dsl)
48
- rescue StandardError, ScriptError => e
49
- raise UsageError.new("failed registering hook: #{e.class}: #{e.message}")
50
- end
51
- end
52
- end
53
- end
54
- end
@@ -1,43 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Hooks
5
- class RpcRegistry
6
- def initialize
7
- @table = Hash.new { |h, k| h[k] = {} }
8
- end
9
-
10
- def register(event, name, &blk)
11
- event_sym = event.to_sym
12
- raise UsageError.new("#{event_sym} is a pubsub event; register on EventBus") if Catalog::PUBSUB.key?(event_sym)
13
-
14
- required = Catalog::RPC[event_sym] or raise UsageError.new("unknown RPC event: #{event}")
15
- sig = Signature.new(blk)
16
- missing = sig.missing(required)
17
- raise UsageError.new("#{event_sym} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})") if missing.any?
18
-
19
- name = name.to_sym
20
- raise UsageError.new("#{event_sym} '#{name}' already registered") if @table[event_sym].key?(name)
21
-
22
- @table[event_sym][name] = blk
23
- end
24
-
25
- def names(event) = @table[event.to_sym].keys
26
-
27
- def callable(event, name)
28
- @table[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
29
- end
30
-
31
- # Invoke a registered callable, injecting `caps:` only if the callable
32
- # declares it (or accepts keyrest). Mis-named kwargs (e.g. the legacy
33
- # `store:`) are rejected at registration time, not here.
34
- def invoke(event, name, caps:, **other)
35
- blk = callable(event, name)
36
- sig = Signature.new(blk)
37
- kwargs = other.dup
38
- kwargs[:caps] = caps if sig.accepts_keyrest? || sig.declared_keys.include?(:caps)
39
- blk.call(**kwargs)
40
- end
41
- end
42
- end
43
- end
@@ -1,62 +0,0 @@
1
- module Textus
2
- module Jobs
3
- # Wires the closed allow-list of convergence job types to the existing
4
- # convergence code. Authority is read from the job's frozen `enqueued_by`
5
- # and turned into the Call the handler runs under: produce self-elevates
6
- # inside Produce::Engine regardless; destructive sweep runs AS the caller.
7
- module Handlers
8
- module_function
9
-
10
- def registry
11
- reg = Textus::Domain::Jobs::Registry.new
12
- # produce is pure (self-elevates) — any caller may request a rematerialize.
13
- reg.register("materialize", handler: method(:produce))
14
- reg.register("re-pull", handler: method(:produce))
15
- # sweep is destructive — gate ad-hoc enqueue to the automation authority.
16
- reg.register("sweep", handler: method(:sweep), required_role: Textus::Role::AUTOMATION)
17
- reg
18
- end
19
-
20
- # produce: render derived / re-pull intake for a single key. Engine
21
- # self-elevates to the build actor internally; the passed call carries
22
- # only correlation/dry_run plus the stamped role for audit. Engine#call
23
- # isolates per-key produce errors into its result hash rather than raising,
24
- # so surface them as :produce_failed events (the converge result hash used
25
- # to carry them; the worker drops the return, so re-publish here).
26
- def produce(job:, container:)
27
- call = call_for(job)
28
- result = Textus::Produce::Engine.converge(container: container, call: call, keys: [job.args["key"]])
29
- return unless result.is_a?(Hash)
30
-
31
- Array(result[:failed]).each do |failure|
32
- container.events.publish(
33
- :produce_failed,
34
- ctx: Textus::Hooks::Context.for(container: container, call: call),
35
- keys: [failure["key"]], error: failure["error"]
36
- )
37
- end
38
- end
39
-
40
- # sweep: compute retention rows for the scope, then apply destructively AS
41
- # the job's role (no self-elevation).
42
- def sweep(job:, container:)
43
- call = call_for(job)
44
- scope = job.args["scope"]
45
- rows = Textus::Domain::Retention::Sweep.new(
46
- manifest: container.manifest,
47
- file_stat: Textus::Ports::Storage::FileStat.new,
48
- clock: Textus::Ports::Clock.new,
49
- ).call(prefix: scope_prefix(scope), zone: scope_zone(scope))
50
- Textus::Maintenance::Retention::Apply.new(container: container, call: call).call(rows)
51
- end
52
-
53
- def call_for(job)
54
- Textus::Call.build(role: job.enqueued_by || Textus::Role::AUTOMATION)
55
- end
56
-
57
- # A scope is `{ "prefix" => ..., "zone" => ... }` or nil (whole store).
58
- def scope_prefix(scope) = scope.is_a?(Hash) ? scope["prefix"] : nil
59
- def scope_zone(scope) = scope.is_a?(Hash) ? scope["zone"] : nil
60
- end
61
- end
62
- end
@@ -1,36 +0,0 @@
1
- module Textus
2
- module Jobs
3
- # Time-based seeding for the daemon: at each tick, enqueue a re-pull job for
4
- # every intake key past its source.ttl and a sweep job to GC entries past
5
- # retention.ttl. Dedup means a job already queued from a prior tick is a
6
- # no-op. Both are stamped automation (the daemon's own authority); the sweep
7
- # handler runs retention as that role.
8
- class Scheduler
9
- def initialize(container:, queue:)
10
- @container = container
11
- @queue = queue
12
- end
13
-
14
- def run_once
15
- stale_intake.each do |key|
16
- @queue.enqueue(job("re-pull", { "key" => key }))
17
- end
18
- @queue.enqueue(job("sweep", { "scope" => { "prefix" => nil, "zone" => nil } }))
19
- end
20
-
21
- private
22
-
23
- def stale_intake
24
- Textus::Domain::Freshness::Evaluator.new(
25
- manifest: @container.manifest,
26
- file_stat: Textus::Ports::Storage::FileStat.new,
27
- clock: Textus::Ports::Clock.new,
28
- ).stale_intake_keys(prefix: nil, zone: nil)
29
- end
30
-
31
- def job(type, args)
32
- Textus::Domain::Jobs::Job.new(type: type, args: args, enqueued_by: Textus::Role::AUTOMATION)
33
- end
34
- end
35
- end
36
- end
@@ -1,57 +0,0 @@
1
- module Textus
2
- module Jobs
3
- # Enqueues the full convergence set for a scope: a produce job per derived /
4
- # publish_tree / publish.to entry, a re-pull job per stale intake key, and a
5
- # single sweep job for the scope. The scope logic mirrors
6
- # the converge scope (Produce::Engine) so `drain` and `serve` converge identically.
7
- # Produce jobs self-elevate (stamped automation); the sweep job carries the
8
- # caller's role (destructive runs as caller).
9
- class Seeder
10
- def initialize(container:, queue:, call:)
11
- @container = container
12
- @queue = queue
13
- @call = call
14
- @manifest = container.manifest
15
- end
16
-
17
- def seed(prefix:, zone:)
18
- file_stat = Textus::Ports::Storage::FileStat.new
19
-
20
- producible_keys(prefix, zone).each do |key|
21
- @queue.enqueue(job("materialize", { "key" => key }, Textus::Role::AUTOMATION))
22
- end
23
- stale_intake_keys(prefix, zone, file_stat).each do |key|
24
- @queue.enqueue(job("re-pull", { "key" => key }, Textus::Role::AUTOMATION))
25
- end
26
- @queue.enqueue(job("sweep", { "scope" => { "prefix" => prefix, "zone" => zone } }, @call.role))
27
- end
28
-
29
- private
30
-
31
- def job(type, args, role)
32
- Textus::Domain::Jobs::Job.new(type: type, args: args, enqueued_by: role)
33
- end
34
-
35
- # Mirrors the converge scope (the publishable arm).
36
- def producible_keys(prefix, zone)
37
- @manifest.data.entries
38
- .select { |e| e.derived? || !e.publish_tree.nil? || !e.publish_to.empty? }
39
- .select { |e| in_scope?(e, prefix, zone) }
40
- .map(&:key)
41
- end
42
-
43
- def stale_intake_keys(prefix, zone, file_stat)
44
- Textus::Domain::Freshness::Evaluator.new(
45
- manifest: @manifest, file_stat: file_stat, clock: Textus::Ports::Clock.new,
46
- ).stale_intake_keys(prefix: prefix, zone: zone)
47
- end
48
-
49
- def in_scope?(entry, prefix, zone)
50
- return false if zone && entry.zone != zone
51
- return false if prefix && !entry.key.start_with?(prefix)
52
-
53
- true
54
- end
55
- end
56
- end
57
- end
@@ -1,42 +0,0 @@
1
- module Textus
2
- module Maintenance
3
- # Converge-and-exit: seed the full convergence set for the scope, run the
4
- # worker until the queue is empty, return a health summary. Exits not-ok if
5
- # any job dead-lettered. This is the converge entry point and what CI
6
- # runs. Single-pass (serial) on purpose: each produce job self-locks via
7
- # Produce::Engine.converge, so running them in turn keeps the build lock
8
- # uncontended; a concurrent pool would make all-but-one produce job hit
9
- # BuildInProgress and skip.
10
- class Drain
11
- extend Textus::Contract::DSL
12
-
13
- verb :drain
14
- summary "Converge everything now: seed produce + retention jobs and drain the queue to empty."
15
- surfaces :cli, :mcp
16
- cli "drain"
17
- arg :prefix, String, description: "restrict convergence to keys under this dotted prefix"
18
- arg :zone, String, description: "restrict convergence to entries in this zone"
19
-
20
- def initialize(container:, call:)
21
- @container = container
22
- @call = call
23
- end
24
-
25
- def call(prefix: nil, zone: nil)
26
- queue = Textus::Ports::Queue.new(root: @container.root)
27
- Textus::Jobs::Seeder.new(container: @container, queue: queue, call: @call).seed(prefix: prefix, zone: zone)
28
-
29
- summary = Worker.for(container: @container, queue: queue).drain
30
- health = Read::Doctor.new(container: @container, call: @call).call
31
-
32
- {
33
- "protocol" => Textus::PROTOCOL,
34
- "ok" => summary.failed.zero?,
35
- "completed" => summary.completed,
36
- "failed" => summary.failed,
37
- "health" => health,
38
- }
39
- end
40
- end
41
- end
42
- end
@@ -1,48 +0,0 @@
1
- module Textus
2
- module Maintenance
3
- # Bulk-delete every leaf key under `prefix`.
4
- class KeyDeletePrefix
5
- extend Textus::Contract::DSL
6
-
7
- verb :key_delete_prefix
8
- summary "Bulk-delete every leaf key under prefix."
9
- surfaces :cli, :mcp
10
- cli "key delete-prefix"
11
- arg :prefix, String, required: true, positional: true, description: "every leaf key under this dotted prefix is deleted"
12
- arg :dry_run, :boolean, default: false,
13
- description: "when true, returns the keys that would be deleted without deleting them; " \
14
- "defaults to false, so omitting it deletes immediately"
15
- view { |v, _i| v.to_h }
16
-
17
- def initialize(container:, call:)
18
- @container = container
19
- @call = call
20
- end
21
-
22
- def call(prefix, dry_run: false)
23
- raise UsageError.new("prefix required") if prefix.nil? || prefix.empty?
24
-
25
- leaves = Read::List.new(container: @container)
26
- .call(prefix: prefix)
27
- .map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
28
-
29
- warnings = leaves.empty? ? ["no keys under #{prefix}"] : []
30
- steps = leaves.map { |k| { "op" => "delete", "key" => k } }
31
-
32
- plan = Plan.new(steps: steps, warnings: warnings)
33
- return plan if dry_run
34
-
35
- steps.each do |s|
36
- delete.call(s["key"])
37
- end
38
- plan
39
- end
40
-
41
- private
42
-
43
- def delete
44
- Write::KeyDelete.new(container: @container, call: @call)
45
- end
46
- end
47
- end
48
- end
@@ -1,68 +0,0 @@
1
- module Textus
2
- module Maintenance
3
- # Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
4
- # Calls Write::KeyMv directly for each entry — emits one audit row per file moved.
5
- class KeyMvPrefix
6
- extend Textus::Contract::DSL
7
-
8
- verb :key_mv_prefix
9
- summary "Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false."
10
- surfaces :cli, :mcp
11
- cli "key mv-prefix"
12
- arg :from_prefix, String, required: true, positional: true, description: "dotted prefix whose leaf keys are renamed"
13
- arg :to_prefix, String, required: true, positional: true, description: "dotted prefix the keys are renamed to"
14
- arg :dry_run, :boolean, default: false,
15
- description: "when true, returns the planned moves without applying them; " \
16
- "defaults to false, so omitting it applies the rename immediately"
17
- view { |v, _i| v.to_h }
18
-
19
- def initialize(container:, call:)
20
- @container = container
21
- @call = call
22
- end
23
-
24
- def call(from_prefix, to_prefix, dry_run: false)
25
- raise UsageError.new("from_prefix and to_prefix required") if from_prefix.nil? || to_prefix.nil?
26
-
27
- leaves = list_leaves_under(from_prefix)
28
-
29
- # When from_prefix is itself a leaf, `delete_prefix("#{from_prefix}.")`
30
- # finds no trailing dot to strip, so the tail keeps the whole key and the
31
- # move silently targets "to_prefix.<full-from_prefix>". Refuse it — a
32
- # single-key rename is `mv`'s job, not the bulk prefix verb's.
33
- if leaves.include?(from_prefix)
34
- raise UsageError.new("from_prefix '#{from_prefix}' is itself a leaf — use `mv` to rename a single key")
35
- end
36
-
37
- warnings = []
38
- warnings << "no keys under #{from_prefix}" if leaves.empty?
39
-
40
- steps = leaves.map do |old_key|
41
- tail = old_key.delete_prefix("#{from_prefix}.")
42
- new_key = "#{to_prefix}.#{tail}"
43
- { "op" => "mv", "from" => old_key, "to" => new_key }
44
- end
45
-
46
- plan = Plan.new(steps: steps, warnings: warnings)
47
- return plan if dry_run
48
-
49
- steps.each do |s|
50
- mv.call(s["from"], s["to"], dry_run: false)
51
- end
52
- plan
53
- end
54
-
55
- private
56
-
57
- def list_leaves_under(prefix)
58
- Read::List.new(container: @container)
59
- .call(prefix: prefix)
60
- .map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
61
- end
62
-
63
- def mv
64
- Write::KeyMv.new(container: @container, call: @call)
65
- end
66
- end
67
- end
68
- end
@@ -1,66 +0,0 @@
1
- require "yaml"
2
-
3
- module Textus
4
- module Maintenance
5
- # Compare the live manifest's `rules:` block against a candidate
6
- # YAML string. Returns a Plan describing rule additions/removals/
7
- # changes. Does NOT write anything.
8
- class RuleLint
9
- extend Textus::Contract::DSL
10
-
11
- verb :rule_lint
12
- summary "Diff candidate manifest YAML's rules against the live manifest. No writes."
13
- surfaces :cli, :mcp
14
- cli "rule lint"
15
- arg :candidate_yaml, String, required: true, wire_name: :against, source: :file,
16
- description: "path to candidate manifest YAML; its `rules:` block is diffed against the live manifest"
17
- view { |v, _i| v.to_h }
18
-
19
- def initialize(container:, call:)
20
- @container = container
21
- @call = call
22
- @root = container.root
23
- end
24
-
25
- def call(candidate_yaml:)
26
- live_rules = current_rules
27
- candidate_rules = parse_candidate(candidate_yaml)
28
-
29
- live_by_match = live_rules.to_h { |r| [r["match"], r] }
30
- candidate_by_match = candidate_rules.to_h { |r| [r["match"], r] }
31
-
32
- steps = (candidate_by_match.keys - live_by_match.keys).map do |m|
33
- { "op" => "add_rule", "match" => m, "rule" => candidate_by_match[m] }
34
- end
35
- (live_by_match.keys - candidate_by_match.keys).each do |m|
36
- steps << { "op" => "remove_rule", "match" => m }
37
- end
38
- (live_by_match.keys & candidate_by_match.keys).each do |m|
39
- next if live_by_match[m] == candidate_by_match[m]
40
-
41
- steps << { "op" => "change_rule", "match" => m,
42
- "from" => live_by_match[m], "to" => candidate_by_match[m] }
43
- end
44
-
45
- Plan.new(steps: steps, warnings: [])
46
- end
47
-
48
- private
49
-
50
- def current_rules
51
- raw = YAML.safe_load_file(File.join(@root, "manifest.yaml"),
52
- permitted_classes: [Symbol], aliases: false)
53
- Array(raw["rules"])
54
- end
55
-
56
- def parse_candidate(yaml_text)
57
- raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
58
- raise UsageError.new("candidate is not a YAML mapping") unless raw.is_a?(Hash)
59
-
60
- Array(raw["rules"])
61
- rescue Psych::Exception => e
62
- raise UsageError.new("candidate YAML parse error: #{e.message}")
63
- end
64
- end
65
- end
66
- end
@@ -1,30 +0,0 @@
1
- module Textus
2
- module Maintenance
3
- # The convergence daemon loop: seed scheduled work (TTL re-pull + sweep),
4
- # reclaim crashed leases, drain the queue, sleep, repeat. `tick` is one
5
- # iteration (unit-testable); `run` loops forever. Drains serially for the
6
- # same reason as Drain — each produce job self-locks, so running them in turn
7
- # keeps the build lock uncontended.
8
- class Serve
9
- def initialize(container:, call:)
10
- @container = container
11
- @call = call
12
- @queue = Textus::Ports::Queue.new(root: container.root)
13
- end
14
-
15
- def tick
16
- Textus::Jobs::Scheduler.new(container: @container, queue: @queue).run_once
17
- @queue.reclaim(now: Textus::Ports::Clock.new.now)
18
- Worker.for(container: @container, queue: @queue).drain
19
- end
20
-
21
- def run(poll: nil)
22
- interval = poll || @container.manifest.data.worker_config[:poll]
23
- loop do
24
- tick
25
- sleep(interval)
26
- end
27
- end
28
- end
29
- end
30
- end