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
@@ -10,13 +10,13 @@ module Textus
10
10
  # read on every path (ADR 0089): it annotates freshness but never ingests,
11
11
  # so materialization and any other reader share the same side-effect-free read.
12
12
  # `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
13
- # `rpc` — a `Hooks::RpcRegistry` used to dispatch `transform_rows` callables.
13
+ # `rpc` — a `Step::RpcRegistry` used to dispatch `transform_rows` callables.
14
14
  # `transform_context` — capability object handed to transform reducers as `caps:`.
15
- def initialize(reader:, spec:, lister:, rpc:, transform_context:)
15
+ def initialize(reader:, spec:, lister:, steps:, transform_context:)
16
16
  @reader = reader
17
17
  @spec = spec || {}
18
18
  @lister = lister
19
- @rpc = rpc
19
+ @steps = steps
20
20
  @transform_context = transform_context
21
21
  @limit = (@spec["limit"] || MAX_LIMIT).to_i
22
22
  raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
@@ -55,13 +55,13 @@ module Textus
55
55
  def apply_reducer(rows)
56
56
  name = @spec["transform"] or return rows
57
57
  Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
58
- @rpc.invoke(:transform_rows, name,
59
- caps: @transform_context,
60
- rows: rows,
61
- config: @spec["transform_config"] || {})
58
+ @steps.invoke(:transform, name,
59
+ caps: @transform_context,
60
+ rows: rows,
61
+ config: @spec["transform_config"] || {})
62
62
  end
63
63
  rescue Timeout::Error
64
- raise UsageError.new("transform_rows '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
64
+ raise UsageError.new("transform '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
65
65
  end
66
66
 
67
67
  def collect_keys
@@ -84,10 +84,11 @@ module Textus
84
84
  # Orchestrator-free read: schema tooling must never trigger a fetch
85
85
  # while inspecting/migrating entries (ADR 0062).
86
86
  def self.pure_get(store, role, key)
87
- Textus::Read::Get.new(
88
- container: store.as(role).container,
87
+ scope = store.as(role)
88
+ Textus::Action::Get.new(key: key).call(
89
+ container: scope.container,
89
90
  call: Textus::Call.build(role: role),
90
- ).call(key)
91
+ )
91
92
  end
92
93
 
93
94
  def self.load_schema(store, name)
@@ -1,15 +1,18 @@
1
1
  module Textus
2
2
  # The agent session: per-connection (MCP), per-process (CLI), or per-loop
3
3
  # (Ruby) orientation state — the audit cursor plus the contract etag and
4
- # propose_zone captured at boot. Immutable Data value; advance_cursor
4
+ # propose_lane captured at boot. Immutable Data value; advance_cursor
5
5
  # returns a new instance. ADR 0036; contract_etag widened in ADR 0074.
6
- Session = Data.define(:role, :cursor, :propose_zone, :contract_etag) do
6
+ Session = Data.define(:role, :cursor, :propose_lane, :contract_etag) do
7
+ # Back-compat reader while lane terminology migrates.
8
+ def propose_zone = propose_lane
9
+
7
10
  def advance_cursor(new_cursor) = with(cursor: new_cursor)
8
11
 
9
12
  def check_etag!(observed_etag)
10
13
  return if observed_etag == contract_etag
11
14
 
12
- raise Textus::MCP::ContractDrift.new(
15
+ raise Textus::Surfaces::MCP::ContractDrift.new(
13
16
  "contract changed (manifest/hooks/schemas were #{short_etag(contract_etag)}, " \
14
17
  "now #{short_etag(observed_etag)}); re-run boot",
15
18
  )
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Step
5
+ class Base
6
+ class << self
7
+ # The step kind is derived from class hierarchy.
8
+ def kind
9
+ return :fetch if defined?(Step::Fetch) && self <= Step::Fetch
10
+ return :transform if defined?(Step::Transform) && self <= Step::Transform
11
+ return :validate if defined?(Step::Validate) && self <= Step::Validate
12
+ return :observe if defined?(Step::Observe) && self <= Step::Observe
13
+
14
+ raise NotImplementedError.new("#{self} is not a known step kind")
15
+ end
16
+
17
+ # Required #call kwargs the loader validates against the subclass.
18
+ def required_kwargs = []
19
+
20
+ # Built-ins (and only built-ins) override the registered name when the
21
+ # Ruby class name can't carry it (e.g. "markdown-links").
22
+ def step_name(value = :__read__)
23
+ if value == :__read__
24
+ @step_name
25
+ else
26
+ @step_name = value.to_s
27
+ end
28
+ end
29
+ end
30
+
31
+ # Assigned by the loader/registry at registration time.
32
+ attr_accessor :name
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "csv"
4
+ require "yaml"
5
+
6
+ module Textus
7
+ module Step
8
+ module Builtin
9
+ class CsvFetch < Step::Fetch
10
+ step_name "csv"
11
+ def call(config:, args:, **)
12
+ _ = args
13
+ rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
14
+ { _meta: {}, body: YAML.dump(rows) }
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Textus
6
+ module Step
7
+ module Builtin
8
+ class IcalEventsFetch < Step::Fetch
9
+ step_name "ical-events"
10
+ def call(config:, args:, **)
11
+ _ = args
12
+ events_list = []
13
+ current = nil
14
+ config["bytes"].to_s.each_line do |line|
15
+ line = line.strip
16
+ case line
17
+ when "BEGIN:VEVENT" then current = {}
18
+ when "END:VEVENT"
19
+ events_list << current if current
20
+ current = nil
21
+ when /\A(SUMMARY|DTSTART|DTEND|UID|LOCATION|DESCRIPTION):(.*)\z/
22
+ current[Regexp.last_match(1).downcase] = Regexp.last_match(2) if current
23
+ end
24
+ end
25
+ { _meta: {}, body: YAML.dump(events_list) }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module Textus
7
+ module Step
8
+ module Builtin
9
+ class JsonFetch < Step::Fetch
10
+ step_name "json"
11
+ def call(config:, args:, **)
12
+ _ = args
13
+ { _meta: {}, body: YAML.dump(JSON.parse(config["bytes"].to_s)) }
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Textus
6
+ module Step
7
+ module Builtin
8
+ class MarkdownLinksFetch < Step::Fetch
9
+ step_name "markdown-links"
10
+ def call(config:, args:, **)
11
+ _ = args
12
+ links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
13
+ { "text" => text, "href" => href }
14
+ end
15
+ { _meta: {}, body: YAML.dump(links) }
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rexml/document"
4
+ require "yaml"
5
+
6
+ module Textus
7
+ module Step
8
+ module Builtin
9
+ class RssFetch < Step::Fetch
10
+ step_name "rss"
11
+ def call(config:, args:, **)
12
+ _ = args
13
+ doc = REXML::Document.new(config["bytes"].to_s)
14
+ items = doc.elements.to_a("//item").map do |item|
15
+ {
16
+ "title" => item.elements["title"]&.text,
17
+ "link" => item.elements["link"]&.text,
18
+ "pubDate" => item.elements["pubDate"]&.text,
19
+ }
20
+ end
21
+ { _meta: {}, body: YAML.dump(items) }
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Step
5
+ # Registers the framework-provided fetch steps (json/csv/markdown-links/
6
+ # ical-events/rss) into a registry at Store construction. The successor to
7
+ # Step::Builtin.register_all.
8
+ module Builtin
9
+ STEPS = [
10
+ JsonFetch, CsvFetch, MarkdownLinksFetch, IcalEventsFetch, RssFetch
11
+ ].freeze
12
+
13
+ def self.register_all(registry)
14
+ STEPS.each do |klass|
15
+ step = klass.new
16
+ step.name = klass.step_name
17
+ registry.register(step)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -1,9 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Textus
4
- module Hooks
5
- # The single source of truth for hook event names and their required
6
- # kwargs. EventBus, RpcRegistry, and the Loader DSL router all read these
4
+ module Step
5
+ # The single source of truth for step event names and their required
6
+ # kwargs. EventBus, RegistryStore, and the Loader all read these
7
7
  # tables directly — the registries do not keep their own copies. Catalog
8
8
  # references no other constant, so it has no load-order cycle, which is
9
9
  # what removed the previous drift hazard (EventBus held a hard-coded
@@ -1,15 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Textus
4
- module Hooks
5
- # A narrow handle passed to user hooks in place of the raw Store.
4
+ module Step
5
+ # A narrow handle passed to user steps in place of the raw Store.
6
6
  # All writes route back through the RoleScope so authorization, audit
7
7
  # logging, and schema validation always fire.
8
8
  class Context
9
9
  attr_reader :role, :correlation_id
10
10
 
11
11
  def self.for(container:, call:)
12
- scope = Textus::RoleScope.new(
12
+ scope = Textus::Surfaces::RoleScope.new(
13
13
  container: container,
14
14
  role: call.role,
15
15
  correlation_id: call.correlation_id,
@@ -48,24 +48,26 @@ module Textus
48
48
 
49
49
  # fan-out
50
50
  def publish_followup(event, **)
51
- @scope.container.events.publish(event, ctx: self, **)
51
+ @scope.container.steps.publish(event, ctx: self, **)
52
52
  end
53
53
 
54
54
  def inspect
55
- "#<Textus::Hooks::Context role=#{@role} correlation_id=#{@correlation_id}>"
55
+ "#<Textus::Step::Context role=#{@role} correlation_id=#{@correlation_id}>"
56
56
  end
57
57
 
58
58
  private
59
59
 
60
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
- )
61
+ @pure_reader ||= lambda do |key|
62
+ Textus::Action::Get.new(key: key).call(
63
+ container: @scope.container,
64
+ call: Textus::Call.build(
65
+ role: @scope.role,
66
+ correlation_id: @scope.correlation_id,
67
+ dry_run: @scope.dry_run?,
68
+ ),
69
+ )
70
+ end
69
71
  end
70
72
  end
71
73
  end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Step
5
+ # Maps a discovered file path under .textus/steps to its (kind, name).
6
+ # kind = the directory segment directly under steps/; name = the basename
7
+ # without .rb (hyphens preserved). The single source of truth for valid
8
+ # kinds is the set of Base subclasses.
9
+ KINDS = %i[fetch transform validate observe].freeze
10
+
11
+ Discovery = Data.define(:kind, :name) do
12
+ def self.parse(path, base:)
13
+ rel = path.delete_prefix(base.to_s).delete_prefix("/")
14
+ parts = rel.split("/")
15
+ raise UsageError.new("step #{rel} must live under steps/<kind>/<name>.rb") unless parts.length == 2
16
+
17
+ kind = parts[0].to_sym
18
+ raise UsageError.new("unknown step kind '#{parts[0]}' (expected one of: #{KINDS.join(", ")})") unless KINDS.include?(kind)
19
+
20
+ new(kind: kind, name: File.basename(parts[1], ".rb").to_sym)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Hooks
2
+ module Step
3
3
  # Bounded in-memory ring buffer of recent hook failures (errored and
4
4
  # timed_out). Each row carries the audit `seq` observed at the time of
5
5
  # failure so pulse can filter "errors since cursor".
@@ -1,7 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "timeout"
4
+
3
5
  module Textus
4
- module Hooks
6
+ module Step
5
7
  class EventBus
6
8
  HOOK_TIMEOUT_SECONDS = 2
7
9
 
@@ -40,6 +42,8 @@ module Textus
40
42
 
41
43
  def pubsub_handlers(event) = @pubsub[event.to_sym]
42
44
 
45
+ def pubsub_handlers_names = @pubsub.values.flatten.map { |h| h[:name] }
46
+
43
47
  def publish(event, strict: false, **kwargs)
44
48
  key = kwargs[:key] || "-"
45
49
  fired = []
@@ -68,24 +72,19 @@ module Textus
68
72
 
69
73
  def invoke(event, sub, key, kwargs)
70
74
  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
75
+ begin
76
+ Timeout.timeout(HOOK_TIMEOUT_SECONDS) do
77
+ sub[:callable].call(**accepted)
78
+ end
79
+ [:ok, nil]
80
+ rescue Timeout::Error
80
81
  err = HookTimeout.new("hook #{sub[:name]} exceeded #{HOOK_TIMEOUT_SECONDS}s on event #{event}")
81
82
  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]
83
+ [:timed_out, err]
84
+ rescue StandardError => e
85
+ notify_error(event, sub, key, kwargs, e)
86
+ [:errored, e]
87
87
  end
88
- [:ok, nil]
89
88
  end
90
89
 
91
90
  def notify_error(event, sub, key, kwargs, error)
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Step
5
+ # Acquires data from outside the store (the `fetch:` build input). Returns
6
+ # either { "content" => Hash } (structured) or { _meta:, body: } (rendered
7
+ # text). Replaces the :resolve_handler RPC. `caps:` is injected by the
8
+ # registry only if #call declares it.
9
+ class Fetch < Base
10
+ def self.required_kwargs = %i[config args]
11
+ end
12
+ end
13
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Textus
4
- module Hooks
4
+ module Step
5
5
  # Outcome of a single Dispatcher#publish call.
6
6
  #
7
7
  # fired — hook names that ran to completion
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Step
5
+ # Convention discovery: glob .textus/steps/<kind>/<name>.rb, load each file,
6
+ # validate the class it defines against the discovered kind, assign the
7
+ # discovered name, and register it. No global queue, no Textus.hook.
8
+ class Loader
9
+ BASE_FOR = {
10
+ fetch: Step::Fetch, transform: Step::Transform,
11
+ validate: Step::Validate, observe: Step::Observe
12
+ }.freeze
13
+
14
+ def initialize(registry:)
15
+ @registry = registry
16
+ end
17
+
18
+ def load_dir(dir)
19
+ return unless File.directory?(dir)
20
+
21
+ loaded_paths = Set.new
22
+ Dir.glob(File.join(dir, "**/*.rb")).sort.each do |path| # rubocop:disable Lint/RedundantDirGlobSort
23
+ real_path = File.realpath(path)
24
+ next if loaded_paths.include?(real_path)
25
+
26
+ loaded_paths << real_path
27
+ load_one(dir, path)
28
+ end
29
+ end
30
+
31
+ private
32
+
33
+ def load_one(dir, path)
34
+ disc = Discovery.parse(path, base: dir)
35
+ klass = capture_defined_class(path, disc)
36
+ validate!(disc, klass, path, dir)
37
+
38
+ step = klass.new
39
+ step.name = disc.name
40
+ @registry.register(step)
41
+ rescue StandardError, ScriptError => e
42
+ raise UsageError.new("failed loading step #{rel(dir, path)}: #{e.class}: #{e.message}") unless e.is_a?(UsageError)
43
+
44
+ raise
45
+ end
46
+
47
+ # Load the file and return the Step::Base subclass it newly defined.
48
+ def capture_defined_class(path, disc)
49
+ before = descendants
50
+ load(path)
51
+ defined = descendants - before
52
+ return defined.first if defined.length == 1
53
+ raise UsageError.new("step #{path} defined more than one Textus::Step subclass") if defined.length > 1
54
+
55
+ fallback = find_existing_class_for(disc)
56
+ return fallback if fallback
57
+
58
+ raise UsageError.new("step #{path} defined no Textus::Step subclass")
59
+ end
60
+
61
+ def find_existing_class_for(disc)
62
+ expected = inferred_class_name_for(disc)
63
+ candidates = descendants.select do |klass|
64
+ klass < BASE_FOR.fetch(disc.kind) && klass.name&.split("::")&.last == expected
65
+ end
66
+ return nil if candidates.empty?
67
+ return candidates.first if candidates.length == 1
68
+
69
+ namespaced = candidates.find { |klass| klass.name == "Textus::Step::#{expected}" }
70
+ namespaced || candidates.first
71
+ end
72
+
73
+ def inferred_class_name_for(disc)
74
+ stem = disc.name.to_s.split(/[-_]/).map(&:capitalize).join
75
+ suffix = { fetch: "Fetch", transform: "Transform", validate: "Validate", observe: "Observe" }.fetch(disc.kind)
76
+ "#{stem}#{suffix}"
77
+ end
78
+
79
+ def validate!(disc, klass, path, dir)
80
+ expected = BASE_FOR.fetch(disc.kind)
81
+ actual_kind = klass.respond_to?(:kind) ? safe_kind(klass) : nil
82
+ unless klass < expected
83
+ raise UsageError.new("#{rel(dir, path)} defines a #{actual_kind || "non-step"} step but lives under #{disc.kind}/")
84
+ end
85
+
86
+ sig = Step::Signature.new(klass.instance_method(:call))
87
+ missing = sig.missing(klass.required_kwargs)
88
+ return if missing.empty?
89
+
90
+ msg = "#{disc.kind} step '#{disc.name}' #call must accept kwargs: " \
91
+ "#{klass.required_kwargs.join(", ")} (missing: #{missing.join(", ")})"
92
+ raise UsageError.new(msg)
93
+ end
94
+
95
+ def safe_kind(klass)
96
+ klass.kind
97
+ rescue StandardError
98
+ nil
99
+ end
100
+
101
+ def descendants
102
+ ObjectSpace.each_object(Class).select { |c| c < Step::Base }
103
+ end
104
+
105
+ def rel(dir, path) = path.delete_prefix(dir.to_s).delete_prefix("/")
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Step
5
+ # Reacts to a lifecycle event (Catalog::PUBSUB). 0..N per event,
6
+ # fire-and-forget, no meaningful return, timeout-isolated by the EventBus.
7
+ # Declares its event with `on :event_name` and an optional key filter with
8
+ # `match "glob.**"`. Replaces user pub/sub subscribers.
9
+ class Observe < Base
10
+ def self.on(event = :__read__)
11
+ if event == :__read__
12
+ @event
13
+ else
14
+ @event = event.to_sym
15
+ end
16
+ end
17
+
18
+ def self.match(glob = :__read__)
19
+ if glob == :__read__
20
+ @match
21
+ else
22
+ @match = glob
23
+ end
24
+ end
25
+
26
+ class << self
27
+ attr_reader :event
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Step
5
+ # The single home for registered steps. Reuses Step::EventBus for observe
6
+ # (pub/sub) dispatch — including its timeout isolation and error ring buffer
7
+ # — and holds a kind->{name->instance} table for the invocable kinds
8
+ # (fetch/transform/validate). Replaces the EventBus+RpcRegistryStore pair.
9
+ class RegistryStore
10
+ def initialize(error_log: Step::ErrorLog.new)
11
+ @bus = Step::EventBus.new(error_log: error_log)
12
+ @table = Hash.new { |h, k| h[k] = {} }
13
+ end
14
+
15
+ # Register either a Step instance (invocable/observe table) or a pub/sub
16
+ # subscriber (`register(event, name, keys:, &block)`) for internal/spec
17
+ # probes.
18
+ def register(step, *, keys: nil, &)
19
+ return @bus.register(step, *, keys: keys, &) unless step.is_a?(Step::Base)
20
+
21
+ kind = step.class.kind
22
+ name = step.name.to_sym
23
+ return register_observe(step) if kind == :observe
24
+
25
+ raise UsageError.new("#{kind} '#{name}' already registered") if @table[kind].key?(name)
26
+
27
+ @table[kind][name] = step
28
+ end
29
+
30
+ # Invoke an invocable step. Mirrors the old RpcRegistryStore#invoke: inject
31
+ # caps only when #call declares :caps or accepts keyrest.
32
+ def invoke(kind, name, caps:, **other)
33
+ step = @table[kind][name.to_sym] or raise UsageError.new("unknown #{kind}: #{name}")
34
+ sig = Step::Signature.new(step.method(:call))
35
+ kwargs = other.dup
36
+ kwargs[:caps] = caps if sig.accepts_keyrest? || sig.declared_keys.include?(:caps)
37
+ step.call(**kwargs)
38
+ end
39
+
40
+ def names(kind)
41
+ return @bus.pubsub_handlers_names if kind.to_sym == :observe
42
+
43
+ @table[kind.to_sym].keys
44
+ end
45
+
46
+ def pubsub_handlers(event)
47
+ @bus.pubsub_handlers(event)
48
+ end
49
+
50
+ # Pub/sub passthrough (observe + internal built-in subscribers).
51
+ def publish(event, **) = @bus.publish(event, **)
52
+ def on(event, name, keys: nil, &) = @bus.register(event, name, keys: keys, &)
53
+ def on_error(&) = @bus.on_error(&)
54
+ def error_log = @bus.error_log
55
+
56
+ private
57
+
58
+ def register_observe(step)
59
+ sig = Step::Signature.new(step.method(:call))
60
+ @bus.register(step.class.event, step.name, keys: step.class.match) do |**kw|
61
+ step.call(**sig.filter(kw))
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Textus
4
- module Hooks
4
+ module Step
5
5
  class Signature
6
6
  def initialize(callable)
7
7
  @params = callable.parameters
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Step
5
+ # Combines/reshapes projected rows into an artifact shape. Returns a Hash
6
+ # (the structured payload base) or an Array of rows. Replaces the
7
+ # :transform_rows RPC. (Phase 2 will widen `rows:` to a named `inputs:` map.)
8
+ class Transform < Base
9
+ def self.required_kwargs = %i[rows config]
10
+ end
11
+ end
12
+ end