textus 0.51.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 (250) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +74 -66
  4. data/SPEC.md +100 -223
  5. data/docs/architecture/README.md +91 -80
  6. data/docs/reference/conventions.md +8 -8
  7. data/exe/textus +1 -1
  8. data/lib/textus/action/accept.rb +53 -0
  9. data/lib/textus/action/audit.rb +133 -0
  10. data/lib/textus/action/base.rb +42 -0
  11. data/lib/textus/{read → action}/blame.rb +30 -22
  12. data/lib/textus/action/boot.rb +26 -0
  13. data/lib/textus/action/data_mv.rb +71 -0
  14. data/lib/textus/action/deps.rb +48 -0
  15. data/lib/textus/action/doctor.rb +26 -0
  16. data/lib/textus/action/drain.rb +41 -0
  17. data/lib/textus/action/enqueue.rb +55 -0
  18. data/lib/textus/action/get.rb +80 -0
  19. data/lib/textus/action/jobs.rb +38 -0
  20. data/lib/textus/action/key_delete.rb +46 -0
  21. data/lib/textus/action/key_delete_prefix.rb +46 -0
  22. data/lib/textus/action/key_mv.rb +143 -0
  23. data/lib/textus/action/key_mv_prefix.rb +59 -0
  24. data/lib/textus/action/list.rb +44 -0
  25. data/lib/textus/action/propose.rb +54 -0
  26. data/lib/textus/action/published.rb +26 -0
  27. data/lib/textus/action/pulse/scanner.rb +118 -0
  28. data/lib/textus/action/pulse.rb +87 -0
  29. data/lib/textus/action/put.rb +63 -0
  30. data/lib/textus/action/rdeps.rb +49 -0
  31. data/lib/textus/action/reject.rb +49 -0
  32. data/lib/textus/action/rule_explain.rb +95 -0
  33. data/lib/textus/action/rule_lint.rb +70 -0
  34. data/lib/textus/action/rule_list.rb +46 -0
  35. data/lib/textus/action/schema_envelope.rb +31 -0
  36. data/lib/textus/action/uid.rb +35 -0
  37. data/lib/textus/action/where.rb +38 -0
  38. data/lib/textus/action/write_verb.rb +58 -0
  39. data/lib/textus/background/job/base.rb +27 -0
  40. data/lib/textus/background/job/materialize.rb +31 -0
  41. data/lib/textus/background/job/refresh.rb +22 -0
  42. data/lib/textus/background/job/sweep.rb +31 -0
  43. data/lib/textus/background/job.rb +19 -0
  44. data/lib/textus/background/plan.rb +9 -0
  45. data/lib/textus/background/planner/plan.rb +113 -0
  46. data/lib/textus/background/retention/apply.rb +50 -0
  47. data/lib/textus/background/worker.rb +67 -0
  48. data/lib/textus/boot.rb +59 -49
  49. data/lib/textus/command.rb +36 -0
  50. data/lib/textus/container.rb +1 -1
  51. data/lib/textus/{domain → core}/duration.rb +1 -1
  52. data/lib/textus/{domain → core}/freshness/evaluator.rb +13 -13
  53. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  54. data/lib/textus/{domain → core}/freshness.rb +2 -2
  55. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  56. data/lib/textus/{domain → core}/retention.rb +2 -2
  57. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  58. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  59. data/lib/textus/doctor/check/handler_permit.rb +34 -0
  60. data/lib/textus/doctor/check/hooks.rb +11 -18
  61. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  62. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  63. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  64. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  65. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  66. data/lib/textus/doctor/check/sentinels.rb +2 -2
  67. data/lib/textus/doctor/check.rb +12 -9
  68. data/lib/textus/{read → doctor}/validator.rb +22 -13
  69. data/lib/textus/doctor.rb +6 -6
  70. data/lib/textus/envelope/io/writer.rb +65 -36
  71. data/lib/textus/envelope.rb +5 -3
  72. data/lib/textus/errors.rb +19 -11
  73. data/lib/textus/events.rb +21 -0
  74. data/lib/textus/gate/auth.rb +181 -0
  75. data/lib/textus/gate.rb +114 -0
  76. data/lib/textus/init/templates/machine_intake.rb +40 -36
  77. data/lib/textus/init/templates/orientation_reducer.rb +15 -11
  78. data/lib/textus/init.rb +93 -76
  79. data/lib/textus/key/path.rb +9 -2
  80. data/lib/textus/layout.rb +21 -0
  81. data/lib/textus/manifest/capabilities.rb +1 -1
  82. data/lib/textus/manifest/data.rb +30 -15
  83. data/lib/textus/manifest/entry/base.rb +15 -11
  84. data/lib/textus/manifest/entry/parser.rb +6 -6
  85. data/lib/textus/manifest/entry/produced.rb +3 -2
  86. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  87. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
  88. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  89. data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
  90. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  91. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  92. data/lib/textus/manifest/policy/react.rb +30 -0
  93. data/lib/textus/{domain → manifest}/policy/retention.rb +4 -4
  94. data/lib/textus/{domain → manifest}/policy/source.rb +27 -28
  95. data/lib/textus/manifest/policy.rb +36 -48
  96. data/lib/textus/manifest/resolver.rb +3 -2
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +18 -12
  99. data/lib/textus/manifest/schema/validator.rb +27 -25
  100. data/lib/textus/manifest/schema/vocabulary.rb +3 -3
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/{produce → pipeline}/acquire/handler.rb +3 -3
  104. data/lib/textus/{produce → pipeline}/acquire/intake.rb +23 -21
  105. data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
  106. data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
  107. data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
  108. data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
  109. data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
  110. data/lib/textus/{produce → pipeline}/engine.rb +17 -63
  111. data/lib/textus/{produce → pipeline}/render.rb +3 -1
  112. data/lib/textus/ports/audit_log.rb +31 -5
  113. data/lib/textus/ports/audit_subscriber.rb +4 -4
  114. data/lib/textus/ports/build_lock.rb +1 -1
  115. data/lib/textus/ports/queue/job.rb +65 -0
  116. data/lib/textus/ports/queue.rb +130 -0
  117. data/lib/textus/ports/sentinel_store.rb +2 -2
  118. data/lib/textus/ports/watcher_lock.rb +48 -0
  119. data/lib/textus/projection.rb +8 -8
  120. data/lib/textus/role.rb +1 -1
  121. data/lib/textus/schema/tools.rb +4 -3
  122. data/lib/textus/session.rb +6 -3
  123. data/lib/textus/step/base.rb +35 -0
  124. data/lib/textus/step/builtin/csv_fetch.rb +19 -0
  125. data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
  126. data/lib/textus/step/builtin/json_fetch.rb +18 -0
  127. data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
  128. data/lib/textus/step/builtin/rss_fetch.rb +26 -0
  129. data/lib/textus/step/builtin.rb +22 -0
  130. data/lib/textus/{hooks → step}/catalog.rb +3 -4
  131. data/lib/textus/{hooks → step}/context.rb +15 -13
  132. data/lib/textus/step/discovery.rb +24 -0
  133. data/lib/textus/{hooks → step}/error_log.rb +1 -1
  134. data/lib/textus/{hooks → step}/event_bus.rb +15 -16
  135. data/lib/textus/step/fetch.rb +13 -0
  136. data/lib/textus/{hooks → step}/fire_report.rb +1 -1
  137. data/lib/textus/step/loader.rb +108 -0
  138. data/lib/textus/step/observe.rb +31 -0
  139. data/lib/textus/step/registry_store.rb +66 -0
  140. data/lib/textus/{hooks → step}/signature.rb +1 -1
  141. data/lib/textus/step/transform.rb +12 -0
  142. data/lib/textus/step/validate.rb +11 -0
  143. data/lib/textus/step.rb +10 -0
  144. data/lib/textus/store.rb +17 -15
  145. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  146. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  147. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  148. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  149. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  150. data/lib/textus/surfaces/cli/group.rb +50 -0
  151. data/lib/textus/surfaces/cli/runner.rb +236 -0
  152. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  153. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  154. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  155. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  156. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  157. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  158. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  159. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  160. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  161. data/lib/textus/surfaces/cli/verb.rb +111 -0
  162. data/lib/textus/surfaces/cli.rb +148 -0
  163. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  164. data/lib/textus/surfaces/mcp/errors.rb +34 -0
  165. data/lib/textus/surfaces/mcp/server.rb +145 -0
  166. data/lib/textus/surfaces/mcp/session.rb +9 -0
  167. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  168. data/lib/textus/surfaces/mcp.rb +8 -0
  169. data/lib/textus/surfaces/role_scope.rb +38 -0
  170. data/lib/textus/surfaces/watcher.rb +38 -0
  171. data/lib/textus/version.rb +1 -1
  172. data/lib/textus.rb +64 -22
  173. metadata +133 -107
  174. data/lib/textus/cli/group/hook.rb +0 -9
  175. data/lib/textus/cli/group/key.rb +0 -9
  176. data/lib/textus/cli/group/mcp.rb +0 -9
  177. data/lib/textus/cli/group/rule.rb +0 -9
  178. data/lib/textus/cli/group/schema.rb +0 -9
  179. data/lib/textus/cli/group/zone.rb +0 -9
  180. data/lib/textus/cli/group.rb +0 -48
  181. data/lib/textus/cli/runner.rb +0 -193
  182. data/lib/textus/cli/verb/doctor.rb +0 -17
  183. data/lib/textus/cli/verb/get.rb +0 -18
  184. data/lib/textus/cli/verb/hook_run.rb +0 -48
  185. data/lib/textus/cli/verb/hooks.rb +0 -50
  186. data/lib/textus/cli/verb/init.rb +0 -18
  187. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  188. data/lib/textus/cli/verb/put.rb +0 -30
  189. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  190. data/lib/textus/cli/verb/schema_init.rb +0 -19
  191. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  192. data/lib/textus/cli/verb.rb +0 -116
  193. data/lib/textus/cli.rb +0 -138
  194. data/lib/textus/dispatcher.rb +0 -52
  195. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  196. data/lib/textus/domain/action.rb +0 -9
  197. data/lib/textus/domain/permission.rb +0 -7
  198. data/lib/textus/domain/policy/base_guards.rb +0 -25
  199. data/lib/textus/domain/policy/evaluation.rb +0 -15
  200. data/lib/textus/domain/policy/guard.rb +0 -35
  201. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  202. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  203. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  204. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  205. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  206. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  207. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  208. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  209. data/lib/textus/hooks/builtin.rb +0 -70
  210. data/lib/textus/hooks/loader.rb +0 -54
  211. data/lib/textus/hooks/rpc_registry.rb +0 -43
  212. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  213. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  214. data/lib/textus/maintenance/reconcile.rb +0 -160
  215. data/lib/textus/maintenance/rule_lint.rb +0 -66
  216. data/lib/textus/maintenance/zone_mv.rb +0 -64
  217. data/lib/textus/maintenance.rb +0 -15
  218. data/lib/textus/mcp/catalog.rb +0 -70
  219. data/lib/textus/mcp/errors.rb +0 -32
  220. data/lib/textus/mcp/server.rb +0 -138
  221. data/lib/textus/mcp/session.rb +0 -7
  222. data/lib/textus/mcp/tool_schemas.rb +0 -15
  223. data/lib/textus/mcp.rb +0 -6
  224. data/lib/textus/mustache.rb +0 -117
  225. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -69
  226. data/lib/textus/produce/events.rb +0 -36
  227. data/lib/textus/read/audit.rb +0 -130
  228. data/lib/textus/read/boot.rb +0 -26
  229. data/lib/textus/read/capabilities.rb +0 -70
  230. data/lib/textus/read/deps.rb +0 -38
  231. data/lib/textus/read/doctor.rb +0 -27
  232. data/lib/textus/read/freshness.rb +0 -152
  233. data/lib/textus/read/get.rb +0 -73
  234. data/lib/textus/read/list.rb +0 -24
  235. data/lib/textus/read/published.rb +0 -22
  236. data/lib/textus/read/pulse.rb +0 -98
  237. data/lib/textus/read/rdeps.rb +0 -39
  238. data/lib/textus/read/rule_explain.rb +0 -96
  239. data/lib/textus/read/rule_list.rb +0 -54
  240. data/lib/textus/read/schema_envelope.rb +0 -25
  241. data/lib/textus/read/uid.rb +0 -29
  242. data/lib/textus/read/validate_all.rb +0 -36
  243. data/lib/textus/read/where.rb +0 -24
  244. data/lib/textus/role_scope.rb +0 -78
  245. data/lib/textus/write/accept.rb +0 -58
  246. data/lib/textus/write/key_delete.rb +0 -65
  247. data/lib/textus/write/key_mv.rb +0 -141
  248. data/lib/textus/write/propose.rb +0 -54
  249. data/lib/textus/write/put.rb +0 -74
  250. data/lib/textus/write/reject.rb +0 -68
@@ -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,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,160 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Maintenance
5
- # Two-phase convergence pass (ADR 0093). Replaces the old Lifecycle-reporter
6
- # sweep.
7
- #
8
- # Phase 1 — Produce (non-destructive): re-render ALL derived entries (cheap,
9
- # idempotent) plus every intake entry past its source.ttl (stale-only, so
10
- # external sources are not hammered). Driven by Produce::Engine.
11
- #
12
- # Phase 2 — Retention sweep (destructive): drop or archive entries past their
13
- # retention ttl. Driven by Domain::Retention::Sweep. The old refresh/warn
14
- # actions are gone — intake re-pull is now Produce's responsibility.
15
- class Reconcile
16
- extend Textus::Contract::DSL
17
-
18
- verb :reconcile
19
- summary "Run the convergence pass: produce derived + stale intake, then drop/archive aged entries; report health."
20
- surfaces :cli, :mcp
21
- cli "reconcile"
22
- arg :prefix, String, description: "restrict the sweep to keys under this dotted prefix"
23
- arg :zone, String, description: "restrict the sweep to entries in this zone"
24
- arg :dry_run, :boolean, default: false,
25
- description: "when true, report what the pass WOULD do without applying; " \
26
- "defaults to false, so omitting it produces + drops/archives immediately"
27
-
28
- def initialize(container:, call:)
29
- @container = container
30
- @call = call
31
- end
32
-
33
- def call(prefix: nil, zone: nil, dry_run: false)
34
- file_stat = Textus::Ports::Storage::FileStat.new
35
- retention_rows = Textus::Domain::Retention::Sweep.new(
36
- manifest: @container.manifest, file_stat: file_stat, clock: Textus::Ports::Clock.new,
37
- ).call(prefix: prefix, zone: zone)
38
-
39
- produce_keys = produce_scope(prefix, zone, file_stat)
40
- health = Read::Doctor.new(container: @container, call: @call).call
41
- return dry_run_result(produce_keys, retention_rows, health) if dry_run
42
-
43
- # reconcile is the authoritative "make everything current now" pass, so
44
- # it subsumes any in-flight reactive produce: drain pending async
45
- # produce-on-write threads first, both to fold their work in and to free
46
- # the shared maintenance lock (BuildLock is non-blocking — a thread still
47
- # holding it would make the acquire below raise BuildInProgress). ADR 0093.
48
- Textus::Produce::Engine::AsyncRunner.drain
49
-
50
- Textus::Ports::BuildLock.with(root: @container.root) do
51
- produced = Textus::Produce::Engine.new(container: @container, call: @call).call(keys: produce_keys)
52
- swept = apply(retention_rows)
53
- publish_failed(swept[:failed]) unless swept[:failed].empty?
54
- apply_result(produced, swept, health)
55
- end
56
- end
57
-
58
- private
59
-
60
- # The full produce scope (ADR 0093): every derived entry (always
61
- # re-render — cheap, idempotent), every entry that mirrors a publish_tree
62
- # (the nested-subtree publishers, ADR 0047 — mirrored each pass so a
63
- # removed source leaf is swept from the published tree), every authored
64
- # leaf with a `publish.to` target (the single-file canon publishers —
65
- # docs/README.md, the architecture index, the root README; ADR 0103 —
66
- # converged each pass so a stale published copy is rewritten and the
67
- # `reconcile`-is-a-no-op check guards them), plus every intake entry past
68
- # its source.ttl (re-pull only when due, so external sources aren't
69
- # hammered). Ttl-less intake entries (:no_policy) are skipped — they have
70
- # no freshness contract and are never auto-re-pulled (ADR 0099). All are
71
- # idempotent: publish writes only when the target's content changed.
72
- def produce_scope(prefix, zone, file_stat)
73
- publishable = @container.manifest.data.entries
74
- .select { |e| e.derived? || !e.publish_tree.nil? || !e.publish_to.empty? }
75
- .select { |e| in_scope?(e, prefix, zone) }.map(&:key)
76
- stale_intake = Textus::Domain::Freshness::Evaluator.new(
77
- manifest: @container.manifest, file_stat: file_stat, clock: Textus::Ports::Clock.new,
78
- ).stale_intake_keys(prefix: prefix, zone: zone)
79
- (publishable + stale_intake).uniq
80
- end
81
-
82
- def in_scope?(entry, prefix, zone)
83
- return false if zone && entry.zone != zone
84
- return false if prefix && !entry.key.start_with?(prefix)
85
-
86
- true
87
- end
88
-
89
- def dry_run_result(produce_keys, rows, health)
90
- {
91
- "protocol" => Textus::PROTOCOL, "ok" => true, "dry_run" => true,
92
- "would_produce" => produce_keys,
93
- "would_drop" => action_keys(rows, "drop"),
94
- "would_archive" => action_keys(rows, "archive"),
95
- "health" => health
96
- }
97
- end
98
-
99
- def apply_result(produced, swept, health)
100
- {
101
- "protocol" => Textus::PROTOCOL,
102
- "ok" => produced[:failed].empty? && swept[:failed].empty?,
103
- "dry_run" => false,
104
- "produced" => produced[:produced],
105
- "produce_failed" => produced[:failed],
106
- "dropped" => swept[:dropped], "archived" => swept[:archived],
107
- "failed" => swept[:failed],
108
- "health" => health
109
- }
110
- end
111
-
112
- def action_keys(rows, action)
113
- rows.select { |r| r["action"] == action }.map { |r| r["key"] }
114
- end
115
-
116
- def publish_failed(failed)
117
- @container.events.publish(
118
- :reconcile_failed,
119
- ctx: Textus::Hooks::Context.for(container: @container, call: @call),
120
- failed: failed,
121
- )
122
- end
123
-
124
- # Phase 2: destructive retention only (drop/archive). No refresh — intake
125
- # re-pull is Produce's job (Phase 1). ADR 0093.
126
- def apply(rows)
127
- out = { dropped: [], archived: [], failed: [] }
128
- delete = Write::KeyDelete.new(container: @container, call: @call)
129
- rows.each do |row|
130
- key = row["key"]
131
- begin
132
- case row["action"]
133
- when "drop"
134
- delete.call(key)
135
- out[:dropped] << key
136
- when "archive"
137
- archive_leaf(row)
138
- delete.call(key)
139
- out[:archived] << key
140
- end
141
- rescue Textus::Error => e
142
- out[:failed] << { "key" => key, "error" => e.message }
143
- end
144
- end
145
- out
146
- end
147
-
148
- # Copy the leaf into <store>/archive/<relative-path> before deletion.
149
- # (Lifted from the retired RetentionSweep#archive_leaf.)
150
- def archive_leaf(row)
151
- src = row["path"]
152
- root = @container.root.to_s
153
- rel = src.delete_prefix("#{root}/")
154
- dest = File.join(root, "archive", rel)
155
- FileUtils.mkdir_p(File.dirname(dest))
156
- FileUtils.cp(src, dest)
157
- end
158
- end
159
- end
160
- 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,64 +0,0 @@
1
- require "yaml"
2
-
3
- module Textus
4
- module Maintenance
5
- # Rename a zone — rewrites the manifest's zones[] entry, rewrites
6
- # the `zone:` field on every entry under the old zone, and moves
7
- # every file from zones/<old>/ to zones/<new>/.
8
- class ZoneMv
9
- extend Textus::Contract::DSL
10
-
11
- verb :zone_mv
12
- summary "Rename a zone — manifest + files. Refuses if destination exists."
13
- surfaces :cli, :mcp
14
- cli "zone mv"
15
- arg :from, String, required: true, positional: true, description: "current zone name"
16
- arg :to, String, required: true, positional: true, description: "new zone name; refused if a zone by this name already exists"
17
- arg :dry_run, :boolean, default: false,
18
- description: "when true, returns the planned zone move without applying it; " \
19
- "defaults to false, so omitting it applies the move immediately"
20
- view { |v, _i| v.to_h }
21
-
22
- def initialize(container:, call:)
23
- @container = container
24
- @call = call
25
- @manifest = container.manifest
26
- @root = container.root
27
- end
28
-
29
- def call(from, to, dry_run: false)
30
- raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
31
- raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.declared_zone_kinds.key?(from)
32
-
33
- dest_dir = File.join(@root, "zones", to)
34
- raise UsageError.new("destination 'zones/#{to}' already exists") if File.exist?(dest_dir)
35
-
36
- affected_keys = @manifest.data.entries.select { |e| e.zone == from }.map(&:key)
37
-
38
- steps = [{ "op" => "rename_zone", "from" => from, "to" => to }]
39
- steps += affected_keys.map { |k| { "op" => "mv", "from" => k, "to" => "#{to}#{k[from.length..]}" } }
40
-
41
- plan = Plan.new(steps: steps, warnings: [])
42
- return plan if dry_run
43
-
44
- rewrite_manifest!(from, to)
45
- FileUtils.mv(File.join(@root, "zones", from), dest_dir)
46
- plan
47
- end
48
-
49
- private
50
-
51
- def rewrite_manifest!(from, to)
52
- path = File.join(@root, "manifest.yaml")
53
- raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
54
- raw["zones"].each { |z| z["name"] = to if z["name"] == from }
55
- raw["entries"].each do |e|
56
- e["zone"] = to if e["zone"] == from
57
- e["key"] = e["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
58
- e["path"] = e["path"].sub(%r{\A#{Regexp.escape(from)}(/|\z)}, "#{to}\\1")
59
- end
60
- File.write(path, YAML.dump(raw))
61
- end
62
- end
63
- end
64
- end
@@ -1,15 +0,0 @@
1
- module Textus
2
- # Bulk and structural changes to a textus store. Each use case returns
3
- # a Plan when called with dry_run: true, and applies the plan when
4
- # called with dry_run: false.
5
- module Maintenance
6
- # A Plan is a JSON-shaped preview. Steps are op-tagged hashes the
7
- # use case knows how to apply. Warnings are strings surfaced to
8
- # the operator (skipped keys, ambiguities).
9
- Plan = Data.define(:steps, :warnings) do
10
- def to_h
11
- { "steps" => steps, "warnings" => warnings }
12
- end
13
- end
14
- end
15
- end
@@ -1,70 +0,0 @@
1
- module Textus
2
- module MCP
3
- # Derives the entire MCP tool surface from the per-verb contracts
4
- # (ADR 0039). `tool_schemas` feeds tools/list; `call` is the generic
5
- # tools/call dispatch: map JSON args -> (positional, keyword) per the
6
- # contract, invoke the verb through the role scope, then shape the
7
- # return value with the contract's default view. No per-tool code.
8
- module Catalog
9
- module_function
10
-
11
- # Contracts of every MCP-surfaced verb, in Dispatcher order.
12
- def specs
13
- Textus::Dispatcher::VERBS.values
14
- .select { |k| mcp_surfaced?(k) }
15
- .map(&:contract)
16
- end
17
-
18
- def tool_schemas
19
- specs.map do |s|
20
- { name: s.verb.to_s, description: s.summary, inputSchema: s.input_schema }
21
- end.freeze
22
- end
23
-
24
- def names
25
- specs.map { |s| s.verb.to_s }
26
- end
27
-
28
- # MCP-surfaced read verbs, by Dispatcher class namespace — the agent's
29
- # real read/discovery surface. `boot.agent_quickstart.read_verbs` derives
30
- # from this so it can never advertise a verb the agent cannot call, nor
31
- # omit one it can (ADR 0056). Excludes Write/Maintenance.
32
- def read_verbs
33
- Textus::Dispatcher::VERBS
34
- .select { |_verb, klass| mcp_surfaced?(klass) && klass.name.start_with?("Textus::Read::") }
35
- .keys.map(&:to_s)
36
- end
37
-
38
- # MCP-surfaced write verbs, by Dispatcher class namespace — the mirror of
39
- # read_verbs for the write side. `boot.agent_quickstart.write_verbs` derives
40
- # from this so it advertises bare verb names the agent can call (no `--as`/
41
- # `--stdin` CLI framing), finishing the de-CLI-ing of the agent surface
42
- # (ADR 0056, ADR 0057).
43
- def write_verbs
44
- Textus::Dispatcher::VERBS
45
- .select { |_verb, klass| mcp_surfaced?(klass) && klass.name.start_with?("Textus::Write::") }
46
- .keys.map(&:to_s)
47
- end
48
-
49
- def mcp_surfaced?(klass)
50
- klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
51
- end
52
-
53
- def call(name, session:, store:, args:)
54
- klass = Textus::Dispatcher::VERBS[name.to_sym]
55
- raise ToolError.new("unknown tool: #{name}") unless klass && mcp_surfaced?(klass)
56
-
57
- spec = klass.contract
58
- inputs = Textus::Contract::Binder.inputs_from_wire(spec, args)
59
- result = store.as(session.role).dispatch_bound(spec.verb, inputs, session: session)
60
- Textus::Contract::View.render(spec, :default, result, inputs)
61
- rescue Textus::Contract::MissingArgs => e
62
- raise ToolError.new("#{spec.verb}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
63
- rescue ContractDrift, CursorExpired
64
- raise
65
- rescue Textus::Error => e
66
- raise ToolError.new("#{name}: #{e.message}")
67
- end
68
- end
69
- end
70
- end