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
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class RuleExplain < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :rule_explain
9
+ summary "Effective rules for a key. Lean {lifecycle, guard} by default; detail: true adds matched blocks + guard predicates."
10
+ surfaces :cli, :mcp
11
+ cli "rule explain"
12
+ arg :key, String, required: true, positional: true,
13
+ description: "dotted key whose effective rules you want (lifecycle ttl/action, write guard, ...)"
14
+ arg :detail, :boolean,
15
+ description: "defaults false: lean {lifecycle, guard}. detail: true adds matched blocks + guard predicates per transition."
16
+ view(:cli) { |r| { "verb" => "rule_explain" }.merge(r.transform_keys(&:to_s)) }
17
+
18
+ BURN = :sync
19
+
20
+ def initialize(key:, detail: nil)
21
+ super()
22
+ @key = key
23
+ @detail = detail
24
+ end
25
+
26
+ def call(container:, **)
27
+ @manifest = container.manifest
28
+ @detail ? explain(@key) : effective(@key)
29
+ end
30
+
31
+ REGISTRY = Textus::Manifest::Schema::FIELD_REGISTRY
32
+ LEAN_FIELDS = REGISTRY.select { |_, m| m[:in_rule_explain].include?(:lean) }.keys.freeze
33
+ DETAIL_FIELDS = REGISTRY.select { |_, m| m[:in_rule_explain].include?(:detail) }.keys.freeze
34
+ EFFECTIVE_FIELDS = DETAIL_FIELDS.select { |f| REGISTRY[f][:policy_class] }.freeze
35
+
36
+ private
37
+
38
+ def effective(key)
39
+ set = @manifest.rules.for(key)
40
+ LEAN_FIELDS.each_with_object({}) do |field, out|
41
+ value = set.public_send(field)
42
+ out[field.to_s] = lean_value(field, value) unless value.nil?
43
+ end
44
+ end
45
+
46
+ def lean_value(field, value)
47
+ case field
48
+ when :retention
49
+ retention_hash(value, string_keys: true)
50
+ when :react
51
+ value.to_h
52
+ else
53
+ value
54
+ end
55
+ end
56
+
57
+ def explain(key)
58
+ matching = @manifest.rules.explain(key)
59
+ winners = @manifest.rules.for(key)
60
+ {
61
+ key: key,
62
+ matched_blocks: matching.map do |block|
63
+ { match: block.match }.merge(DETAIL_FIELDS.to_h { |f| [f, !block.public_send(f).nil?] })
64
+ end,
65
+ effective: EFFECTIVE_FIELDS.to_h { |f| [f, effective_value(f, winners.public_send(f))] },
66
+ guards: Textus::Gate::Auth::FLOOR.keys.to_h do |action|
67
+ floor = Textus::Gate::Auth::FLOOR.fetch(action, [])
68
+ rule = Array(@manifest.rules.for(key).guard&.dig(action.to_s))
69
+ [action, { floor: floor, rule: rule }]
70
+ end,
71
+ }
72
+ end
73
+
74
+ def effective_value(field, value)
75
+ return nil if value.nil?
76
+
77
+ case field
78
+ when :retention
79
+ retention_hash(value, string_keys: false)
80
+ when :react
81
+ value.to_h
82
+ when :handler_permit
83
+ value.handlers
84
+ else
85
+ value
86
+ end
87
+ end
88
+
89
+ def retention_hash(retention, string_keys:)
90
+ h = { ttl_seconds: retention.ttl_seconds, action: retention.action }
91
+ string_keys ? h.transform_keys(&:to_s) : h
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Textus
6
+ module Action
7
+ class RuleLint < Base
8
+ extend Textus::Contract::DSL
9
+
10
+ verb :rule_lint
11
+ summary "Diff candidate manifest YAML's rules against the live manifest. No writes."
12
+ surfaces :cli, :mcp
13
+ cli "rule lint"
14
+ arg :candidate_yaml, String, required: true, wire_name: :against, source: :file,
15
+ description: "path to candidate manifest YAML; its `rules:` block is diffed against the live manifest"
16
+ view { |v, _i| v.to_h }
17
+
18
+ BURN = :sync
19
+
20
+ def initialize(candidate_yaml:)
21
+ super()
22
+ @candidate_yaml = candidate_yaml
23
+ end
24
+
25
+ def call(container:, **)
26
+ root = container.root
27
+ live_rules = current_rules(root)
28
+ candidate_rules = parse_candidate(@candidate_yaml)
29
+
30
+ live_by_match = live_rules.to_h { |rule| [rule["match"], rule] }
31
+ candidate_by_match = candidate_rules.to_h { |rule| [rule["match"], rule] }
32
+
33
+ steps = (candidate_by_match.keys - live_by_match.keys).map do |match|
34
+ { "op" => "add_rule", "match" => match, "rule" => candidate_by_match[match] }
35
+ end
36
+ (live_by_match.keys - candidate_by_match.keys).each do |match|
37
+ steps << { "op" => "remove_rule", "match" => match }
38
+ end
39
+ (live_by_match.keys & candidate_by_match.keys).each do |match|
40
+ next if live_by_match[match] == candidate_by_match[match]
41
+
42
+ steps << {
43
+ "op" => "change_rule",
44
+ "match" => match,
45
+ "from" => live_by_match[match],
46
+ "to" => candidate_by_match[match],
47
+ }
48
+ end
49
+
50
+ Textus::Background::Plan.new(steps: steps, warnings: [])
51
+ end
52
+
53
+ private
54
+
55
+ def current_rules(root)
56
+ raw = YAML.safe_load_file(File.join(root, "manifest.yaml"), permitted_classes: [Symbol], aliases: false)
57
+ Array(raw["rules"])
58
+ end
59
+
60
+ def parse_candidate(yaml_text)
61
+ raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
62
+ raise UsageError.new("candidate is not a YAML mapping") unless raw.is_a?(Hash)
63
+
64
+ Array(raw["rules"])
65
+ rescue Psych::Exception => e
66
+ raise UsageError.new("candidate YAML parse error: #{e.message}")
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class RuleList < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :rule_list
9
+ summary "List every rule block in the manifest."
10
+ surfaces :cli
11
+ cli "rule list"
12
+ view(:cli) { |policies| { "verb" => "rule_list", "policies" => policies } }
13
+
14
+ BURN = :sync
15
+
16
+ def call(container:, **)
17
+ manifest = container.manifest
18
+ manifest.rules.blocks.map do |block|
19
+ row = { "match" => block.match }
20
+ self.class::LIST_FIELDS.each do |field|
21
+ value = block.public_send(field)
22
+ row[field.to_s] = serialize(field, value) unless value.nil?
23
+ end
24
+ row
25
+ end
26
+ end
27
+
28
+ LIST_FIELDS = Textus::Manifest::Schema::FIELD_REGISTRY.select { |_, m| m[:in_rule_list] }.keys.freeze
29
+
30
+ private
31
+
32
+ def serialize(field, value)
33
+ case field
34
+ when :retention
35
+ { "ttl_seconds" => value.ttl_seconds, "action" => value.action.to_s }
36
+ when :react
37
+ value.to_h
38
+ when :handler_permit
39
+ value.handlers
40
+ else
41
+ value
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class SchemaEnvelope < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :schema_show
9
+ summary "Return the schema (field shape) for an entry's family, by key."
10
+ surfaces :cli, :mcp
11
+ cli "schema show"
12
+ arg :key, String, required: true, positional: true,
13
+ description: "any key in the family whose schema you want; returns required/optional fields and their types"
14
+
15
+ BURN = :sync
16
+
17
+ def initialize(key:)
18
+ super()
19
+ @key = key
20
+ end
21
+
22
+ def call(container:, **)
23
+ manifest = container.manifest
24
+ schemas = container.schemas
25
+ mentry = manifest.resolver.resolve(@key).entry
26
+ schema = schemas.fetch_or_nil(mentry.schema)
27
+ { "protocol" => PROTOCOL, "key" => @key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Uid < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :uid
9
+ summary "Return the stable UID of an entry without reading its body."
10
+ surfaces :cli
11
+ cli "key uid"
12
+ arg :key, String, required: true, positional: true, description: "entry key"
13
+ view(:cli) { |uid, inputs| { "key" => inputs[:key], "uid" => uid } }
14
+
15
+ BURN = :sync
16
+
17
+ def initialize(key:)
18
+ super()
19
+ @key = key
20
+ end
21
+
22
+ def call(container:, call:)
23
+ Textus::Action::Get.new(key: @key).call(container: container, call: call).uid
24
+ end
25
+
26
+ def self.new(*args, **kwargs)
27
+ return super(**kwargs) unless args.any?
28
+
29
+ positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
30
+ mapped = positional.zip(args).to_h
31
+ super(**mapped.merge(kwargs))
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Where < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :where
9
+ summary "Resolve a key to its zone, owner, and path without reading the body."
10
+ surfaces :cli, :mcp
11
+ arg :key, String, required: true, positional: true,
12
+ description: "dotted key to locate (returns zone, owner, path; does not read content)"
13
+
14
+ BURN = :sync
15
+
16
+ def initialize(key:)
17
+ super()
18
+ @key = key
19
+ end
20
+
21
+ def call(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
22
+ manifest = container.manifest
23
+ res = manifest.resolver.resolve(@key)
24
+ mentry = res.entry
25
+ path = res.path
26
+ { "protocol" => PROTOCOL, "key" => @key, "lane" => mentry.lane, "owner" => mentry.owner, "path" => path }
27
+ end
28
+
29
+ def self.new(*args, **kwargs)
30
+ return super(**kwargs) unless args.any?
31
+
32
+ positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
33
+ mapped = positional.zip(args).to_h
34
+ super(**mapped.merge(kwargs))
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class WriteVerb < Base
6
+ private
7
+
8
+ def auth(container)
9
+ Textus::Gate::Auth.new(container)
10
+ end
11
+
12
+ def writer(container, call)
13
+ Textus::Envelope::IO::Writer.from(container: container, call: call)
14
+ end
15
+
16
+ def reader(container)
17
+ Textus::Envelope::IO::Reader.from(container: container)
18
+ end
19
+
20
+ def run_with_cascade(target_key, container:, call:)
21
+ result = yield
22
+ cascade_to_rdeps(target_key, container, call) if target_key
23
+ result
24
+ end
25
+
26
+ def cascade_to_rdeps(key, container, call)
27
+ return if derived_write?(key, container)
28
+
29
+ rdeps = Textus::Action::Rdeps.new(key: key).call(container: container, call: call).fetch("rdeps", [])
30
+ producible = rdeps.select { |dep_key| producible?(dep_key, container) }
31
+ return if producible.empty?
32
+
33
+ producible.each do |dep_key|
34
+ Textus::Background::Job::Materialize.new(key: dep_key).call(container:, call:)
35
+ end
36
+ container.steps.publish(
37
+ :entry_written,
38
+ ctx: Textus::Step::Context.for(container: container, call: call),
39
+ key: key,
40
+ envelope: nil,
41
+ )
42
+ end
43
+
44
+ def derived_write?(key, container)
45
+ container.manifest.resolver.resolve(key).entry.derived?
46
+ rescue Textus::Error
47
+ false
48
+ end
49
+
50
+ def producible?(key, container)
51
+ entry = container.manifest.resolver.resolve(key).entry
52
+ entry.derived? || !entry.publish_tree.nil?
53
+ rescue Textus::Error
54
+ false
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Background
5
+ module Job
6
+ class Base
7
+ def self.inherited(subclass)
8
+ super
9
+ return unless subclass.name
10
+
11
+ TracePoint.new(:end) do |tp|
12
+ if tp.self == subclass
13
+ Textus::Background::Job.register(subclass)
14
+ tp.disable
15
+ end
16
+ end.enable
17
+ end
18
+
19
+ def call(**)
20
+ raise NotImplementedError.new("#{self.class}#call")
21
+ end
22
+
23
+ def args = {}
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Background
5
+ module Job
6
+ class Materialize < Base
7
+ TYPE = "materialize"
8
+
9
+ def initialize(key:)
10
+ super()
11
+ @key = key
12
+ end
13
+
14
+ def args = { key: @key }
15
+
16
+ def call(container:, call:)
17
+ result = Textus::Pipeline::Engine.converge(container: container, call: call, keys: [@key])
18
+ return unless result.is_a?(Hash)
19
+
20
+ Array(result[:failed]).each do |failure|
21
+ container.steps.publish(
22
+ :produce_failed,
23
+ ctx: Textus::Step::Context.for(container: container, call: call),
24
+ keys: [failure["key"]], error: failure["error"]
25
+ )
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Background
5
+ module Job
6
+ class Refresh < Base
7
+ TYPE = "refresh"
8
+
9
+ def initialize(key:)
10
+ super()
11
+ @key = key
12
+ end
13
+
14
+ def args = { key: @key }
15
+
16
+ def call(container:, call:)
17
+ Textus::Pipeline::Engine.converge(container: container, call: call, keys: [@key])
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Background
5
+ module Job
6
+ class Sweep < Base
7
+ REQUIRED_ROLE = Textus::Role::AUTOMATION
8
+ TYPE = "sweep"
9
+
10
+ def initialize(scope: nil, key: nil)
11
+ super()
12
+ @scope = scope || {}
13
+ @key = key
14
+ end
15
+
16
+ def args = { scope: @scope, key: @key }.compact
17
+
18
+ def call(container:, call:)
19
+ prefix = @key || (@scope.is_a?(Hash) ? @scope["prefix"] : nil)
20
+ lane = @scope.is_a?(Hash) ? @scope["lane"] : nil
21
+ rows = Textus::Core::Retention::Sweep.new(
22
+ manifest: container.manifest,
23
+ file_stat: Textus::Ports::Storage::FileStat.new,
24
+ clock: Textus::Ports::Clock.new,
25
+ ).call(prefix: prefix, lane: lane)
26
+ Textus::Background::Retention::Apply.new(container: container, call: call).call(rows)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Background
5
+ module Job
6
+ @registry = {}
7
+
8
+ def self.registry = @registry
9
+
10
+ def self.register(klass)
11
+ @registry[klass::TYPE] = klass if klass.const_defined?(:TYPE, false)
12
+ end
13
+
14
+ def self.fetch(type)
15
+ @registry.fetch(type) { raise Textus::UsageError.new("unknown job type: #{type}") }
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,9 @@
1
+ module Textus
2
+ module Background
3
+ Plan = Data.define(:steps, :warnings) do
4
+ def to_h
5
+ { "steps" => steps, "warnings" => warnings }
6
+ end
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Background
5
+ module Planner
6
+ class Plan
7
+ ACTIONS_BY_TRIGGER = {
8
+ "convergence" => %w[materialize refresh sweep],
9
+ "entry.written" => %w[materialize],
10
+ "entry.deleted" => %w[materialize],
11
+ "entry.moved" => %w[materialize],
12
+ "proposal.accepted" => %w[materialize],
13
+ "proposal.rejected" => %w[materialize],
14
+ }.freeze
15
+
16
+ SCOPE_RESOLVERS = {
17
+ "materialize" => :producible_keys,
18
+ "refresh" => :stale_intake_keys,
19
+ "sweep" => :lane_keys,
20
+ }.freeze
21
+
22
+ def self.seed(container:, queue:, role:)
23
+ jobs = new(container: container).plan(
24
+ trigger: { "type" => "convergence" },
25
+ role: role,
26
+ )
27
+ jobs.each { |j| queue.enqueue(j) }
28
+ end
29
+
30
+ def initialize(container:)
31
+ @container = container
32
+ @manifest = container.manifest
33
+ end
34
+
35
+ def plan(trigger:, role:)
36
+ type = trigger["type"] || trigger[:type]
37
+ trigger["target"] || trigger[:target]
38
+ return [] if type.nil?
39
+
40
+ blocks_with_react = @manifest.rules.blocks.select(&:react)
41
+ if blocks_with_react.any?
42
+ plan_from_rules(blocks_with_react, type, role)
43
+ else
44
+ plan_from_defaults(type, role)
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def plan_from_rules(blocks, type, role)
51
+ jobs = []
52
+ blocks
53
+ .select { |b| matches_trigger?(b.react, type) }
54
+ .each do |block|
55
+ do_action = block.react.raw["do"]
56
+ Array(do_action).each do |action|
57
+ if action == "sweep"
58
+ jobs << Textus::Ports::Queue::Job.new(
59
+ type: "sweep", args: { "scope" => {} }, enqueued_by: role,
60
+ )
61
+ else
62
+ resolver = SCOPE_RESOLVERS.fetch(action, :producible_keys)
63
+ keys = send(resolver, nil)
64
+ keys.each { |key| jobs << job(action, key, role) }
65
+ end
66
+ end
67
+ end
68
+ jobs
69
+ end
70
+
71
+ def plan_from_defaults(type, role)
72
+ actions = ACTIONS_BY_TRIGGER.fetch(type, [])
73
+ jobs = []
74
+ producible_keys(nil).each { |k| jobs << job("materialize", k, role) } if actions.include?("materialize")
75
+ stale_intake_keys(nil).each { |k| jobs << job("refresh", k, role) } if actions.include?("refresh")
76
+ if actions.include?("sweep")
77
+ jobs << Textus::Ports::Queue::Job.new(
78
+ type: "sweep", args: { "scope" => {} }, enqueued_by: role,
79
+ )
80
+ end
81
+ jobs
82
+ end
83
+
84
+ def matches_trigger?(react, type)
85
+ on = react.raw["on"]
86
+ Array(on).include?(type)
87
+ end
88
+
89
+ def job(type, key, enqueued_by)
90
+ Textus::Ports::Queue::Job.new(type: type, args: { "key" => key }, enqueued_by: enqueued_by)
91
+ end
92
+
93
+ def producible_keys(_target)
94
+ @manifest.data.entries
95
+ .select { |e| e.derived? || !e.publish_tree.nil? || !e.publish_to.empty? }
96
+ .map(&:key)
97
+ end
98
+
99
+ def stale_intake_keys(_target)
100
+ Textus::Core::Freshness::Evaluator.new(
101
+ manifest: @manifest,
102
+ file_stat: Textus::Ports::Storage::FileStat.new,
103
+ clock: Textus::Ports::Clock.new,
104
+ ).stale_intake_keys(prefix: nil, lane: nil)
105
+ end
106
+
107
+ def lane_keys(_target)
108
+ @manifest.data.entries.map(&:key)
109
+ end
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,50 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Background
5
+ module Retention
6
+ class Apply
7
+ def initialize(container:, call:)
8
+ @container = container
9
+ @call = call
10
+ end
11
+
12
+ def call(rows)
13
+ out = { dropped: [], archived: [], failed: [] }
14
+ rows.each do |row|
15
+ key = row["key"]
16
+ begin
17
+ case row["action"]
18
+ when "drop"
19
+ delete(key)
20
+ out[:dropped] << key
21
+ when "archive"
22
+ archive_leaf(row)
23
+ delete(key)
24
+ out[:archived] << key
25
+ end
26
+ rescue Textus::Error => e
27
+ out[:failed] << { "key" => key, "error" => e.message }
28
+ end
29
+ end
30
+ out
31
+ end
32
+
33
+ private
34
+
35
+ def archive_leaf(row)
36
+ src = row["path"]
37
+ root = @container.root.to_s
38
+ rel = src.delete_prefix("#{root}/")
39
+ dest = File.join(root, "archive", rel)
40
+ FileUtils.mkdir_p(File.dirname(dest))
41
+ FileUtils.cp(src, dest)
42
+ end
43
+
44
+ def delete(key)
45
+ Textus::Action::KeyDelete.new(key: key).call(container: @container, call: @call)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end