textus 0.52.0 → 0.54.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 (261) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +62 -54
  4. data/SPEC.md +64 -192
  5. data/docs/architecture/README.md +157 -156
  6. data/exe/textus +1 -1
  7. data/lib/textus/action/accept.rb +44 -0
  8. data/lib/textus/action/audit.rb +131 -0
  9. data/lib/textus/action/base.rb +42 -0
  10. data/lib/textus/{read → action}/blame.rb +28 -22
  11. data/lib/textus/action/boot.rb +17 -0
  12. data/lib/textus/action/data_mv.rb +69 -0
  13. data/lib/textus/action/deps.rb +34 -0
  14. data/lib/textus/action/doctor.rb +24 -0
  15. data/lib/textus/action/drain.rb +39 -0
  16. data/lib/textus/action/enqueue.rb +53 -0
  17. data/lib/textus/action/get.rb +78 -0
  18. data/lib/textus/action/ingest.rb +135 -0
  19. data/lib/textus/action/jobs.rb +36 -0
  20. data/lib/textus/action/key_delete.rb +36 -0
  21. data/lib/textus/action/key_delete_prefix.rb +44 -0
  22. data/lib/textus/action/key_mv.rb +129 -0
  23. data/lib/textus/action/key_mv_prefix.rb +57 -0
  24. data/lib/textus/action/list.rb +42 -0
  25. data/lib/textus/action/propose.rb +52 -0
  26. data/lib/textus/action/published.rb +24 -0
  27. data/lib/textus/action/pulse.rb +56 -0
  28. data/lib/textus/action/put.rb +52 -0
  29. data/lib/textus/action/rdeps.rb +39 -0
  30. data/lib/textus/action/reject.rb +37 -0
  31. data/lib/textus/action/rule_explain.rb +91 -0
  32. data/lib/textus/action/rule_lint.rb +68 -0
  33. data/lib/textus/action/rule_list.rb +42 -0
  34. data/lib/textus/action/schema_envelope.rb +29 -0
  35. data/lib/textus/action/uid.rb +33 -0
  36. data/lib/textus/action/where.rb +36 -0
  37. data/lib/textus/action/write_verb.rb +44 -0
  38. data/lib/textus/boot.rb +64 -132
  39. data/lib/textus/command.rb +41 -0
  40. data/lib/textus/container.rb +1 -1
  41. data/lib/textus/{domain → core}/duration.rb +1 -1
  42. data/lib/textus/{domain → core}/freshness/evaluator.rb +25 -41
  43. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  44. data/lib/textus/{domain → core}/freshness.rb +2 -2
  45. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  46. data/lib/textus/{domain → core}/retention.rb +2 -2
  47. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  48. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  49. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  50. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  51. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  52. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  53. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  54. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  55. data/lib/textus/doctor/check.rb +11 -9
  56. data/lib/textus/{read → doctor}/validator.rb +22 -13
  57. data/lib/textus/doctor.rb +4 -44
  58. data/lib/textus/envelope/reader.rb +46 -0
  59. data/lib/textus/envelope/writer.rb +209 -0
  60. data/lib/textus/envelope.rb +5 -3
  61. data/lib/textus/errors.rb +25 -13
  62. data/lib/textus/{entry → format}/base.rb +1 -6
  63. data/lib/textus/{entry → format}/json.rb +1 -4
  64. data/lib/textus/{entry → format}/markdown.rb +2 -6
  65. data/lib/textus/{entry → format}/text.rb +2 -6
  66. data/lib/textus/{entry → format}/yaml.rb +1 -4
  67. data/lib/textus/{entry.rb → format.rb} +9 -10
  68. data/lib/textus/gate/auth.rb +227 -0
  69. data/lib/textus/gate.rb +116 -0
  70. data/lib/textus/init.rb +59 -105
  71. data/lib/textus/jobs/base.rb +23 -0
  72. data/lib/textus/jobs/materialize.rb +20 -0
  73. data/lib/textus/jobs/plan.rb +9 -0
  74. data/lib/textus/jobs/planner.rb +101 -0
  75. data/lib/textus/jobs/retention.rb +48 -0
  76. data/lib/textus/jobs/sweep.rb +27 -0
  77. data/lib/textus/jobs/worker.rb +67 -0
  78. data/lib/textus/jobs.rb +15 -0
  79. data/lib/textus/key/path.rb +10 -3
  80. data/lib/textus/layout.rb +13 -0
  81. data/lib/textus/manifest/data.rb +14 -14
  82. data/lib/textus/manifest/entry/base.rb +14 -25
  83. data/lib/textus/manifest/entry/parser.rb +31 -14
  84. data/lib/textus/manifest/entry/produced.rb +13 -33
  85. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  86. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -3
  87. data/lib/textus/manifest/entry/publish.rb +0 -12
  88. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  89. data/lib/textus/manifest/entry/validators.rb +0 -1
  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 +3 -3
  94. data/lib/textus/manifest/policy/source.rb +30 -0
  95. data/lib/textus/manifest/policy.rb +38 -53
  96. data/lib/textus/manifest/resolver.rb +5 -4
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +20 -28
  99. data/lib/textus/manifest/schema/validator.rb +27 -33
  100. data/lib/textus/manifest/schema/vocabulary.rb +2 -1
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/ports/audit_log.rb +31 -5
  104. data/lib/textus/{domain/jobs → ports/job_store}/job.rb +19 -12
  105. data/lib/textus/ports/{queue.rb → job_store.rb} +7 -14
  106. data/lib/textus/ports/sentinel_store.rb +2 -2
  107. data/lib/textus/ports/watcher_lock.rb +48 -0
  108. data/lib/textus/produce/engine.rb +25 -70
  109. data/lib/textus/produce/render.rb +2 -7
  110. data/lib/textus/schema/tools.rb +4 -3
  111. data/lib/textus/session.rb +6 -3
  112. data/lib/textus/store.rb +12 -19
  113. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  114. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  115. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  116. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  117. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  118. data/lib/textus/surfaces/cli/group.rb +50 -0
  119. data/lib/textus/surfaces/cli/runner.rb +236 -0
  120. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  121. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  122. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  123. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  124. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  125. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  126. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  127. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  128. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  129. data/lib/textus/surfaces/cli/verb.rb +111 -0
  130. data/lib/textus/surfaces/cli.rb +148 -0
  131. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  132. data/lib/textus/surfaces/mcp/errors.rb +25 -0
  133. data/lib/textus/surfaces/mcp/routing.rb +51 -0
  134. data/lib/textus/surfaces/mcp/server.rb +184 -0
  135. data/lib/textus/surfaces/mcp/session.rb +9 -0
  136. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  137. data/lib/textus/surfaces/mcp.rb +8 -0
  138. data/lib/textus/surfaces/role_scope.rb +34 -0
  139. data/lib/textus/surfaces/watcher.rb +38 -0
  140. data/lib/textus/version.rb +1 -1
  141. data/lib/textus/workflow/collector.rb +27 -0
  142. data/lib/textus/workflow/context.rb +5 -0
  143. data/lib/textus/workflow/dsl.rb +44 -0
  144. data/lib/textus/workflow/loader.rb +17 -0
  145. data/lib/textus/workflow/pattern.rb +18 -0
  146. data/lib/textus/workflow/registry.rb +21 -0
  147. data/lib/textus/workflow/runner.rb +84 -0
  148. data/lib/textus/workflow.rb +28 -0
  149. data/lib/textus.rb +75 -22
  150. metadata +118 -132
  151. data/lib/textus/cli/group/hook.rb +0 -9
  152. data/lib/textus/cli/group/key.rb +0 -9
  153. data/lib/textus/cli/group/mcp.rb +0 -9
  154. data/lib/textus/cli/group/rule.rb +0 -9
  155. data/lib/textus/cli/group/schema.rb +0 -9
  156. data/lib/textus/cli/group/zone.rb +0 -9
  157. data/lib/textus/cli/group.rb +0 -48
  158. data/lib/textus/cli/runner.rb +0 -193
  159. data/lib/textus/cli/verb/doctor.rb +0 -17
  160. data/lib/textus/cli/verb/get.rb +0 -18
  161. data/lib/textus/cli/verb/hook_run.rb +0 -48
  162. data/lib/textus/cli/verb/hooks.rb +0 -50
  163. data/lib/textus/cli/verb/init.rb +0 -18
  164. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  165. data/lib/textus/cli/verb/put.rb +0 -30
  166. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  167. data/lib/textus/cli/verb/schema_init.rb +0 -19
  168. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  169. data/lib/textus/cli/verb/serve.rb +0 -19
  170. data/lib/textus/cli/verb.rb +0 -116
  171. data/lib/textus/cli.rb +0 -138
  172. data/lib/textus/dispatcher.rb +0 -54
  173. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  174. data/lib/textus/doctor/check/hooks.rb +0 -32
  175. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  176. data/lib/textus/domain/action.rb +0 -9
  177. data/lib/textus/domain/jobs/registry.rb +0 -37
  178. data/lib/textus/domain/permission.rb +0 -7
  179. data/lib/textus/domain/policy/base_guards.rb +0 -25
  180. data/lib/textus/domain/policy/evaluation.rb +0 -15
  181. data/lib/textus/domain/policy/guard.rb +0 -35
  182. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  183. data/lib/textus/domain/policy/handler_allowlist.rb +0 -17
  184. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  185. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  186. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  187. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  188. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  189. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  190. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  191. data/lib/textus/domain/policy/source.rb +0 -73
  192. data/lib/textus/envelope/io/reader.rb +0 -48
  193. data/lib/textus/envelope/io/writer.rb +0 -182
  194. data/lib/textus/hooks/builtin.rb +0 -70
  195. data/lib/textus/hooks/catalog.rb +0 -37
  196. data/lib/textus/hooks/context.rb +0 -72
  197. data/lib/textus/hooks/error_log.rb +0 -32
  198. data/lib/textus/hooks/event_bus.rb +0 -114
  199. data/lib/textus/hooks/fire_report.rb +0 -23
  200. data/lib/textus/hooks/loader.rb +0 -54
  201. data/lib/textus/hooks/rpc_registry.rb +0 -43
  202. data/lib/textus/hooks/signature.rb +0 -31
  203. data/lib/textus/init/templates/machine_intake.rb +0 -45
  204. data/lib/textus/init/templates/orientation_reducer.rb +0 -17
  205. data/lib/textus/jobs/handlers.rb +0 -62
  206. data/lib/textus/jobs/scheduler.rb +0 -36
  207. data/lib/textus/jobs/seeder.rb +0 -57
  208. data/lib/textus/maintenance/drain.rb +0 -42
  209. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  210. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  211. data/lib/textus/maintenance/retention/apply.rb +0 -52
  212. data/lib/textus/maintenance/rule_lint.rb +0 -66
  213. data/lib/textus/maintenance/serve.rb +0 -30
  214. data/lib/textus/maintenance/worker.rb +0 -74
  215. data/lib/textus/maintenance/zone_mv.rb +0 -64
  216. data/lib/textus/maintenance.rb +0 -15
  217. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  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/audit_subscriber.rb +0 -42
  226. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
  227. data/lib/textus/produce/acquire/handler.rb +0 -29
  228. data/lib/textus/produce/acquire/intake.rb +0 -130
  229. data/lib/textus/produce/acquire/projection.rb +0 -127
  230. data/lib/textus/produce/acquire/serializer/json.rb +0 -31
  231. data/lib/textus/produce/acquire/serializer/text.rb +0 -16
  232. data/lib/textus/produce/acquire/serializer/yaml.rb +0 -31
  233. data/lib/textus/produce/acquire/serializer.rb +0 -17
  234. data/lib/textus/produce/events.rb +0 -36
  235. data/lib/textus/projection.rb +0 -103
  236. data/lib/textus/read/audit.rb +0 -130
  237. data/lib/textus/read/boot.rb +0 -26
  238. data/lib/textus/read/capabilities.rb +0 -70
  239. data/lib/textus/read/deps.rb +0 -38
  240. data/lib/textus/read/doctor.rb +0 -27
  241. data/lib/textus/read/freshness.rb +0 -152
  242. data/lib/textus/read/get.rb +0 -73
  243. data/lib/textus/read/jobs.rb +0 -31
  244. data/lib/textus/read/list.rb +0 -24
  245. data/lib/textus/read/published.rb +0 -22
  246. data/lib/textus/read/pulse.rb +0 -98
  247. data/lib/textus/read/rdeps.rb +0 -39
  248. data/lib/textus/read/rule_explain.rb +0 -96
  249. data/lib/textus/read/rule_list.rb +0 -54
  250. data/lib/textus/read/schema_envelope.rb +0 -25
  251. data/lib/textus/read/uid.rb +0 -29
  252. data/lib/textus/read/validate_all.rb +0 -36
  253. data/lib/textus/read/where.rb +0 -24
  254. data/lib/textus/role_scope.rb +0 -78
  255. data/lib/textus/write/accept.rb +0 -58
  256. data/lib/textus/write/enqueue.rb +0 -50
  257. data/lib/textus/write/key_delete.rb +0 -65
  258. data/lib/textus/write/key_mv.rb +0 -141
  259. data/lib/textus/write/propose.rb +0 -54
  260. data/lib/textus/write/put.rb +0 -74
  261. data/lib/textus/write/reject.rb +0 -68
@@ -1,72 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Hooks
5
- # A narrow handle passed to user hooks in place of the raw Store.
6
- # All writes route back through the RoleScope so authorization, audit
7
- # logging, and schema validation always fire.
8
- class Context
9
- attr_reader :role, :correlation_id
10
-
11
- def self.for(container:, call:)
12
- scope = Textus::RoleScope.new(
13
- container: container,
14
- role: call.role,
15
- correlation_id: call.correlation_id,
16
- dry_run: call.dry_run,
17
- )
18
- new(scope: scope)
19
- end
20
-
21
- def initialize(scope:)
22
- @scope = scope
23
- @role = scope.role
24
- @correlation_id = scope.correlation_id
25
- end
26
-
27
- def backend
28
- @scope
29
- end
30
-
31
- # read — a pure-observation surface: nothing here ingests. Since ADR 0089
32
- # `get` itself is a pure read (the read-through that once forced this
33
- # surface to opt out is gone, so the old re-entrancy/deadlock guard is no
34
- # longer needed); `list`/`deps`/`freshness` are reads too. A hook observes
35
- # current state and never triggers an I/O cascade.
36
- def get(key) = pure_reader.call(key)
37
- def list(**) = @scope.list(**)
38
- def deps(key) = @scope.deps(key)
39
- def freshness(key) = @scope.freshness(key)
40
-
41
- # write (authorized + audited)
42
- def put(key, **) = @scope.put(key, **)
43
- def delete(key, **) = @scope.key_delete(key, **)
44
-
45
- def audit(verb, key:, **)
46
- @scope.container.audit_log.append(role: @role, verb: verb, key: key, **)
47
- end
48
-
49
- # fan-out
50
- def publish_followup(event, **)
51
- @scope.container.events.publish(event, ctx: self, **)
52
- end
53
-
54
- def inspect
55
- "#<Textus::Hooks::Context role=#{@role} correlation_id=#{@correlation_id}>"
56
- end
57
-
58
- private
59
-
60
- def pure_reader
61
- @pure_reader ||= Textus::Read::Get.new(
62
- container: @scope.container,
63
- call: Textus::Call.build(
64
- role: @scope.role,
65
- correlation_id: @scope.correlation_id,
66
- dry_run: @scope.dry_run?,
67
- ),
68
- )
69
- end
70
- end
71
- end
72
- end
@@ -1,32 +0,0 @@
1
- module Textus
2
- module Hooks
3
- # Bounded in-memory ring buffer of recent hook failures (errored and
4
- # timed_out). Each row carries the audit `seq` observed at the time of
5
- # failure so pulse can filter "errors since cursor".
6
- class ErrorLog
7
- DEFAULT_CAPACITY = 256
8
-
9
- def initialize(capacity: DEFAULT_CAPACITY)
10
- @capacity = capacity
11
- @rows = []
12
- @mutex = Mutex.new
13
- end
14
-
15
- def record(seq:, event:, hook:, key:, error_class:, error_message:)
16
- row = {
17
- seq: seq, event: event, hook: hook, key: key,
18
- error_class: error_class, error_message: error_message,
19
- at: Time.now.utc.iso8601
20
- }
21
- @mutex.synchronize do
22
- @rows << row
23
- @rows.shift while @rows.size > @capacity
24
- end
25
- end
26
-
27
- def since(seq)
28
- @mutex.synchronize { @rows.select { |r| r[:seq] > seq }.dup }
29
- end
30
- end
31
- end
32
- end
@@ -1,114 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Hooks
5
- class EventBus
6
- HOOK_TIMEOUT_SECONDS = 2
7
-
8
- class HookTimeout < StandardError; end
9
-
10
- def initialize(error_log: ErrorLog.new)
11
- @pubsub = Hash.new { |h, k| h[k] = [] }
12
- @error_handlers = []
13
- @error_log = error_log
14
- end
15
-
16
- attr_reader :error_log
17
-
18
- def on(event, name, keys: nil, &) = register(event, name, keys: keys, &)
19
-
20
- def register(event, name, keys: nil, &blk)
21
- event_sym = event.to_sym
22
- raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if Catalog::RPC.key?(event_sym)
23
-
24
- required = Catalog::PUBSUB[event_sym] or raise UsageError.new("unknown event: #{event}")
25
- sig = Signature.new(blk)
26
- missing = sig.missing(required)
27
- if missing.any?
28
- raise UsageError.new("#{event_sym} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
29
- end
30
-
31
- name = name.to_sym
32
- raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
33
-
34
- @pubsub[event_sym] << { name: name, callable: blk, keys: keys }
35
- end
36
-
37
- def on_error(&block) = @error_handlers << block
38
-
39
- def listeners(event, key:) = @pubsub[event.to_sym].select { |h| match?(h[:keys], key) }
40
-
41
- def pubsub_handlers(event) = @pubsub[event.to_sym]
42
-
43
- def publish(event, strict: false, **kwargs)
44
- key = kwargs[:key] || "-"
45
- fired = []
46
- errored = []
47
- timed_out = []
48
- raised = nil
49
-
50
- @pubsub[event.to_sym].each do |sub|
51
- next unless match?(sub[:keys], key)
52
-
53
- outcome, err = invoke(event, sub, key, kwargs)
54
- case outcome
55
- when :ok then fired << sub[:name]
56
- when :errored then errored << sub[:name]
57
- when :timed_out then timed_out << sub[:name]
58
- end
59
- raised ||= err if strict && err
60
- end
61
-
62
- raise raised if strict && raised
63
-
64
- FireReport.new(fired: fired, errored: errored, timed_out: timed_out)
65
- end
66
-
67
- private
68
-
69
- def invoke(event, sub, key, kwargs)
70
- accepted = Signature.new(sub[:callable]).filter(kwargs)
71
- error = nil
72
- # Thread#kill is unsafe in general but bounded here: post-commit, isolated, only a runaway user hook is affected.
73
- thread = Thread.new do
74
- sub[:callable].call(**accepted)
75
- rescue StandardError => e
76
- error = e
77
- end
78
- if thread.join(HOOK_TIMEOUT_SECONDS).nil?
79
- thread.kill
80
- err = HookTimeout.new("hook #{sub[:name]} exceeded #{HOOK_TIMEOUT_SECONDS}s on event #{event}")
81
- notify_error(event, sub, key, kwargs, err)
82
- return [:timed_out, err]
83
- end
84
- if error
85
- notify_error(event, sub, key, kwargs, error)
86
- return [:errored, error]
87
- end
88
- [:ok, nil]
89
- end
90
-
91
- def notify_error(event, sub, key, kwargs, error)
92
- @error_log.record(
93
- seq: kwargs[:_audit_seq] || -1,
94
- event: event,
95
- hook: sub[:name],
96
- key: key,
97
- error_class: error.class.name,
98
- error_message: error.message,
99
- )
100
- @error_handlers.each do |handler|
101
- handler.call(event: event, hook: sub[:name], key: key, kwargs: kwargs, error: error)
102
- rescue StandardError => e
103
- warn "[textus] error handler failed: #{e.class}: #{e.message}"
104
- end
105
- end
106
-
107
- def match?(globs, key)
108
- return true if globs.nil?
109
-
110
- Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
111
- end
112
- end
113
- end
114
- end
@@ -1,23 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Hooks
5
- # Outcome of a single Dispatcher#publish call.
6
- #
7
- # fired — hook names that ran to completion
8
- # errored — hook names that raised
9
- # timed_out — hook names whose worker thread exceeded the deadline
10
- #
11
- # Callers that care about hook health (tests, strict embedders) can
12
- # check #ok? or inspect #failures. The dispatcher itself never raises
13
- # on a hook failure unless strict: true was passed to #publish.
14
- FireReport = Data.define(:fired, :errored, :timed_out) do
15
- def initialize(fired:, errored:, timed_out:)
16
- super(fired: fired.dup.freeze, errored: errored.dup.freeze, timed_out: timed_out.dup.freeze)
17
- end
18
-
19
- def ok? = errored.empty? && timed_out.empty?
20
- def failures = errored + timed_out
21
- end
22
- end
23
- 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,31 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Hooks
5
- class Signature
6
- def initialize(callable)
7
- @params = callable.parameters
8
- end
9
-
10
- def accepts_keyrest?
11
- @params.any? { |type, _| type == :keyrest }
12
- end
13
-
14
- def declared_keys
15
- @params.each_with_object([]) { |(t, n), acc| acc << n if %i[keyreq key].include?(t) }
16
- end
17
-
18
- def missing(required)
19
- return [] if accepts_keyrest?
20
-
21
- required - declared_keys
22
- end
23
-
24
- def filter(kwargs)
25
- return kwargs if accepts_keyrest?
26
-
27
- kwargs.slice(*declared_keys)
28
- end
29
- end
30
- end
31
- end
@@ -1,45 +0,0 @@
1
- # .textus/hooks/machine_intake.rb
2
- # Scaffolded by `textus init` — CUSTOMIZE FREELY, or delete the feeds.machines
3
- # entry from manifest.yaml if you don't want it.
4
- # Feeds a per-host SNAPSHOT into feeds.machines.<host> on `textus drain`
5
- # (never on the per-turn boot/pulse path). It is NESTED so it grows to a fleet: the
6
- # `local` leaf scans THIS host; add ssh hosts with the cookbook recipe
7
- # (docs/cookbook/environment-scan.md). tracked:false → gitignored. Keep this an
8
- # ALLOWLIST of versions and counts — NEVER secrets, raw `env`, or package lists.
9
- Textus.hook do |reg|
10
- reg.on(:resolve_handler, :machines) do |config:, args:, **|
11
- machine = args[:leaf_segments].first or
12
- raise "machines intake needs a host leaf, e.g. the 'local' in feeds.machines.local"
13
- spec = (config["machines"] || {}).fetch(machine) { raise "unknown machine: #{machine}" }
14
- unless (spec["via"] || "local").to_s == "local"
15
- raise "machine #{machine}: only `via: local` is scaffolded — see " \
16
- "docs/cookbook/environment-scan.md for the SSH (remote) fan-out"
17
- end
18
-
19
- sh = ->(cmd) { `#{cmd}`.strip } # local shell-out, no network
20
- ver = ->(cmd) { o = `#{cmd} 2>/dev/null`.strip; o.empty? ? nil : o } # nil if tool absent
21
- count = ->(cmd) { n = `#{cmd} 2>/dev/null`.strip.lines.size; n.zero? ? nil : n }
22
- { content: {
23
- # git_* describe THIS repo on the control host — only meaningful for `local`.
24
- "git_head" => sh.call("git rev-parse --short HEAD 2>/dev/null"),
25
- "git_branch" => sh.call("git rev-parse --abbrev-ref HEAD 2>/dev/null"),
26
- "git_dirty" => !sh.call("git status --porcelain 2>/dev/null").empty?,
27
- "repo_root" => sh.call("git rev-parse --show-toplevel 2>/dev/null"),
28
- "captured_at" => Time.now.utc.iso8601,
29
- "os" => RbConfig::CONFIG["host_os"],
30
- "arch" => RbConfig::CONFIG["host_cpu"],
31
- "ruby_version" => RUBY_VERSION,
32
- "runtimes" => { # versions only; nil when not installed
33
- "node" => ver.call("node --version"),
34
- "python" => ver.call("python3 --version"),
35
- "go" => ver.call("go version"),
36
- },
37
- "packages" => { # COUNTS only — never the list (size/secrets)
38
- "brew" => count.call("brew list --formula"), # ~1-3s on macOS; runs only on fetch, amortized by the ttl rule
39
- "apt" => count.call("dpkg-query -f '.\n' -W"),
40
- },
41
- "textus_version" => Textus::VERSION,
42
- "protocol" => Textus::PROTOCOL,
43
- } }
44
- end
45
- end
@@ -1,17 +0,0 @@
1
- # Reducer that reshapes the raw projection rows into the keys the
2
- # orientation.mustache template references. Without this, the template
3
- # would only have access to the flat rows list.
4
- Textus.hook do |reg|
5
- reg.on(:transform_rows, :orientation_reducer) do |rows:, **|
6
- project_row = rows.find { |r| r["_key"] == "knowledge.project" } || {}
7
- runbook_rows = rows.select { |r| r["_key"]&.start_with?("knowledge.runbooks.") }
8
-
9
- {
10
- "project" => {
11
- "name" => project_row["name"],
12
- "description" => project_row["description"]
13
- },
14
- "runbooks" => runbook_rows.map { |r| { "name" => r["name"], "description" => r["description"] } }
15
- }
16
- end
17
- end
@@ -1,62 +0,0 @@
1
- module Textus
2
- module Jobs
3
- # Wires the closed allow-list of convergence job types to the existing
4
- # convergence code. Authority is read from the job's frozen `enqueued_by`
5
- # and turned into the Call the handler runs under: produce self-elevates
6
- # inside Produce::Engine regardless; destructive sweep runs AS the caller.
7
- module Handlers
8
- module_function
9
-
10
- def registry
11
- reg = Textus::Domain::Jobs::Registry.new
12
- # produce is pure (self-elevates) — any caller may request a rematerialize.
13
- reg.register("materialize", handler: method(:produce))
14
- reg.register("re-pull", handler: method(:produce))
15
- # sweep is destructive — gate ad-hoc enqueue to the automation authority.
16
- reg.register("sweep", handler: method(:sweep), required_role: Textus::Role::AUTOMATION)
17
- reg
18
- end
19
-
20
- # produce: render derived / re-pull intake for a single key. Engine
21
- # self-elevates to the build actor internally; the passed call carries
22
- # only correlation/dry_run plus the stamped role for audit. Engine#call
23
- # isolates per-key produce errors into its result hash rather than raising,
24
- # so surface them as :produce_failed events (the converge result hash used
25
- # to carry them; the worker drops the return, so re-publish here).
26
- def produce(job:, container:)
27
- call = call_for(job)
28
- result = Textus::Produce::Engine.converge(container: container, call: call, keys: [job.args["key"]])
29
- return unless result.is_a?(Hash)
30
-
31
- Array(result[:failed]).each do |failure|
32
- container.events.publish(
33
- :produce_failed,
34
- ctx: Textus::Hooks::Context.for(container: container, call: call),
35
- keys: [failure["key"]], error: failure["error"]
36
- )
37
- end
38
- end
39
-
40
- # sweep: compute retention rows for the scope, then apply destructively AS
41
- # the job's role (no self-elevation).
42
- def sweep(job:, container:)
43
- call = call_for(job)
44
- scope = job.args["scope"]
45
- rows = Textus::Domain::Retention::Sweep.new(
46
- manifest: container.manifest,
47
- file_stat: Textus::Ports::Storage::FileStat.new,
48
- clock: Textus::Ports::Clock.new,
49
- ).call(prefix: scope_prefix(scope), zone: scope_zone(scope))
50
- Textus::Maintenance::Retention::Apply.new(container: container, call: call).call(rows)
51
- end
52
-
53
- def call_for(job)
54
- Textus::Call.build(role: job.enqueued_by || Textus::Role::AUTOMATION)
55
- end
56
-
57
- # A scope is `{ "prefix" => ..., "zone" => ... }` or nil (whole store).
58
- def scope_prefix(scope) = scope.is_a?(Hash) ? scope["prefix"] : nil
59
- def scope_zone(scope) = scope.is_a?(Hash) ? scope["zone"] : nil
60
- end
61
- end
62
- end
@@ -1,36 +0,0 @@
1
- module Textus
2
- module Jobs
3
- # Time-based seeding for the daemon: at each tick, enqueue a re-pull job for
4
- # every intake key past its source.ttl and a sweep job to GC entries past
5
- # retention.ttl. Dedup means a job already queued from a prior tick is a
6
- # no-op. Both are stamped automation (the daemon's own authority); the sweep
7
- # handler runs retention as that role.
8
- class Scheduler
9
- def initialize(container:, queue:)
10
- @container = container
11
- @queue = queue
12
- end
13
-
14
- def run_once
15
- stale_intake.each do |key|
16
- @queue.enqueue(job("re-pull", { "key" => key }))
17
- end
18
- @queue.enqueue(job("sweep", { "scope" => { "prefix" => nil, "zone" => nil } }))
19
- end
20
-
21
- private
22
-
23
- def stale_intake
24
- Textus::Domain::Freshness::Evaluator.new(
25
- manifest: @container.manifest,
26
- file_stat: Textus::Ports::Storage::FileStat.new,
27
- clock: Textus::Ports::Clock.new,
28
- ).stale_intake_keys(prefix: nil, zone: nil)
29
- end
30
-
31
- def job(type, args)
32
- Textus::Domain::Jobs::Job.new(type: type, args: args, enqueued_by: Textus::Role::AUTOMATION)
33
- end
34
- end
35
- end
36
- end
@@ -1,57 +0,0 @@
1
- module Textus
2
- module Jobs
3
- # Enqueues the full convergence set for a scope: a produce job per derived /
4
- # publish_tree / publish.to entry, a re-pull job per stale intake key, and a
5
- # single sweep job for the scope. The scope logic mirrors
6
- # the converge scope (Produce::Engine) so `drain` and `serve` converge identically.
7
- # Produce jobs self-elevate (stamped automation); the sweep job carries the
8
- # caller's role (destructive runs as caller).
9
- class Seeder
10
- def initialize(container:, queue:, call:)
11
- @container = container
12
- @queue = queue
13
- @call = call
14
- @manifest = container.manifest
15
- end
16
-
17
- def seed(prefix:, zone:)
18
- file_stat = Textus::Ports::Storage::FileStat.new
19
-
20
- producible_keys(prefix, zone).each do |key|
21
- @queue.enqueue(job("materialize", { "key" => key }, Textus::Role::AUTOMATION))
22
- end
23
- stale_intake_keys(prefix, zone, file_stat).each do |key|
24
- @queue.enqueue(job("re-pull", { "key" => key }, Textus::Role::AUTOMATION))
25
- end
26
- @queue.enqueue(job("sweep", { "scope" => { "prefix" => prefix, "zone" => zone } }, @call.role))
27
- end
28
-
29
- private
30
-
31
- def job(type, args, role)
32
- Textus::Domain::Jobs::Job.new(type: type, args: args, enqueued_by: role)
33
- end
34
-
35
- # Mirrors the converge scope (the publishable arm).
36
- def producible_keys(prefix, zone)
37
- @manifest.data.entries
38
- .select { |e| e.derived? || !e.publish_tree.nil? || !e.publish_to.empty? }
39
- .select { |e| in_scope?(e, prefix, zone) }
40
- .map(&:key)
41
- end
42
-
43
- def stale_intake_keys(prefix, zone, file_stat)
44
- Textus::Domain::Freshness::Evaluator.new(
45
- manifest: @manifest, file_stat: file_stat, clock: Textus::Ports::Clock.new,
46
- ).stale_intake_keys(prefix: prefix, zone: zone)
47
- end
48
-
49
- def in_scope?(entry, prefix, zone)
50
- return false if zone && entry.zone != zone
51
- return false if prefix && !entry.key.start_with?(prefix)
52
-
53
- true
54
- end
55
- end
56
- end
57
- end
@@ -1,42 +0,0 @@
1
- module Textus
2
- module Maintenance
3
- # Converge-and-exit: seed the full convergence set for the scope, run the
4
- # worker until the queue is empty, return a health summary. Exits not-ok if
5
- # any job dead-lettered. This is the converge entry point and what CI
6
- # runs. Single-pass (serial) on purpose: each produce job self-locks via
7
- # Produce::Engine.converge, so running them in turn keeps the build lock
8
- # uncontended; a concurrent pool would make all-but-one produce job hit
9
- # BuildInProgress and skip.
10
- class Drain
11
- extend Textus::Contract::DSL
12
-
13
- verb :drain
14
- summary "Converge everything now: seed produce + retention jobs and drain the queue to empty."
15
- surfaces :cli, :mcp
16
- cli "drain"
17
- arg :prefix, String, description: "restrict convergence to keys under this dotted prefix"
18
- arg :zone, String, description: "restrict convergence to entries in this zone"
19
-
20
- def initialize(container:, call:)
21
- @container = container
22
- @call = call
23
- end
24
-
25
- def call(prefix: nil, zone: nil)
26
- queue = Textus::Ports::Queue.new(root: @container.root)
27
- Textus::Jobs::Seeder.new(container: @container, queue: queue, call: @call).seed(prefix: prefix, zone: zone)
28
-
29
- summary = Worker.for(container: @container, queue: queue).drain
30
- health = Read::Doctor.new(container: @container, call: @call).call
31
-
32
- {
33
- "protocol" => Textus::PROTOCOL,
34
- "ok" => summary.failed.zero?,
35
- "completed" => summary.completed,
36
- "failed" => summary.failed,
37
- "health" => health,
38
- }
39
- end
40
- end
41
- end
42
- end