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
@@ -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
@@ -1,12 +1,8 @@
1
1
  require "fileutils"
2
2
 
3
3
  module Textus
4
- module Maintenance
4
+ module Background
5
5
  module Retention
6
- # The destructive half of convergence: apply retention rows (drop/archive).
7
- # Lifted verbatim from the legacy reconcile apply/archive_leaf so drain/serve and
8
- # the `sweep` job handler share one path. Runs as the caller's role — never
9
- # self-elevates (ADR 0079/0093: destructiveness decides authority).
10
6
  class Apply
11
7
  def initialize(container:, call:)
12
8
  @container = container
@@ -15,17 +11,16 @@ module Textus
15
11
 
16
12
  def call(rows)
17
13
  out = { dropped: [], archived: [], failed: [] }
18
- delete = Write::KeyDelete.new(container: @container, call: @call)
19
14
  rows.each do |row|
20
15
  key = row["key"]
21
16
  begin
22
17
  case row["action"]
23
18
  when "drop"
24
- delete.call(key)
19
+ delete(key)
25
20
  out[:dropped] << key
26
21
  when "archive"
27
22
  archive_leaf(row)
28
- delete.call(key)
23
+ delete(key)
29
24
  out[:archived] << key
30
25
  end
31
26
  rescue Textus::Error => e
@@ -37,7 +32,6 @@ module Textus
37
32
 
38
33
  private
39
34
 
40
- # Copy the leaf into <store>/archive/<relative-path> before deletion.
41
35
  def archive_leaf(row)
42
36
  src = row["path"]
43
37
  root = @container.root.to_s
@@ -46,6 +40,10 @@ module Textus
46
40
  FileUtils.mkdir_p(File.dirname(dest))
47
41
  FileUtils.cp(src, dest)
48
42
  end
43
+
44
+ def delete(key)
45
+ Textus::Action::KeyDelete.new(key: key).call(container: @container, call: @call)
46
+ end
49
47
  end
50
48
  end
51
49
  end
@@ -0,0 +1,67 @@
1
+ module Textus
2
+ module Background
3
+ class Worker
4
+ Summary = Struct.new(:completed, :failed, keyword_init: true)
5
+
6
+ def self.for(container:, queue:)
7
+ new(queue: queue, container: container,
8
+ lease_ttl: container.manifest.data.worker_config[:lease_ttl])
9
+ end
10
+
11
+ def initialize(queue:, container:, lease_ttl: 60)
12
+ @queue = queue
13
+ @container = container
14
+ @lease_ttl = lease_ttl
15
+ end
16
+
17
+ def drain(worker_id: "drain-#{Process.pid}")
18
+ completed = 0
19
+ failed = 0
20
+ loop do
21
+ leased = @queue.lease(worker_id: worker_id, lease_ttl: @lease_ttl)
22
+ break unless leased
23
+
24
+ case run_one(leased)
25
+ when :completed then completed += 1
26
+ when :dead_lettered then failed += 1
27
+ end
28
+ end
29
+ Summary.new(completed: completed, failed: failed)
30
+ end
31
+
32
+ def drain_pool(pool: 4)
33
+ summaries = []
34
+ mutex = Mutex.new
35
+ threads = Array.new(pool) do |i|
36
+ Thread.new do
37
+ s = drain(worker_id: "pool-#{Process.pid}-#{i}")
38
+ mutex.synchronize { summaries << s }
39
+ end
40
+ end
41
+ threads.each(&:join)
42
+ Summary.new(completed: summaries.sum(&:completed), failed: summaries.sum(&:failed))
43
+ end
44
+
45
+ private
46
+
47
+ def run_one(leased)
48
+ job = leased.job
49
+ klass = Textus::Background::Job.fetch(job.type)
50
+ action = if klass.instance_method(:initialize).parameters.any?
51
+ klass.new(**job.args.transform_keys(&:to_sym))
52
+ else
53
+ klass.new
54
+ end
55
+ call = Textus::Call.build(
56
+ role: job.enqueued_by || Textus::Role::AUTOMATION,
57
+ correlation_id: SecureRandom.uuid,
58
+ )
59
+ action.call(container: @container, call: call)
60
+ @queue.ack(leased)
61
+ :completed
62
+ rescue StandardError => e
63
+ @queue.fail(leased, error: e.message)
64
+ end
65
+ end
66
+ end
67
+ end