textus 0.51.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +74 -66
  4. data/SPEC.md +100 -223
  5. data/docs/architecture/README.md +91 -80
  6. data/docs/reference/conventions.md +8 -8
  7. data/exe/textus +1 -1
  8. data/lib/textus/action/accept.rb +53 -0
  9. data/lib/textus/action/audit.rb +133 -0
  10. data/lib/textus/action/base.rb +42 -0
  11. data/lib/textus/{read → action}/blame.rb +30 -22
  12. data/lib/textus/action/boot.rb +26 -0
  13. data/lib/textus/action/data_mv.rb +71 -0
  14. data/lib/textus/action/deps.rb +48 -0
  15. data/lib/textus/action/doctor.rb +26 -0
  16. data/lib/textus/action/drain.rb +41 -0
  17. data/lib/textus/action/enqueue.rb +55 -0
  18. data/lib/textus/action/get.rb +80 -0
  19. data/lib/textus/action/jobs.rb +38 -0
  20. data/lib/textus/action/key_delete.rb +46 -0
  21. data/lib/textus/action/key_delete_prefix.rb +46 -0
  22. data/lib/textus/action/key_mv.rb +143 -0
  23. data/lib/textus/action/key_mv_prefix.rb +59 -0
  24. data/lib/textus/action/list.rb +44 -0
  25. data/lib/textus/action/propose.rb +54 -0
  26. data/lib/textus/action/published.rb +26 -0
  27. data/lib/textus/action/pulse/scanner.rb +118 -0
  28. data/lib/textus/action/pulse.rb +87 -0
  29. data/lib/textus/action/put.rb +63 -0
  30. data/lib/textus/action/rdeps.rb +49 -0
  31. data/lib/textus/action/reject.rb +49 -0
  32. data/lib/textus/action/rule_explain.rb +95 -0
  33. data/lib/textus/action/rule_lint.rb +70 -0
  34. data/lib/textus/action/rule_list.rb +46 -0
  35. data/lib/textus/action/schema_envelope.rb +31 -0
  36. data/lib/textus/action/uid.rb +35 -0
  37. data/lib/textus/action/where.rb +38 -0
  38. data/lib/textus/action/write_verb.rb +58 -0
  39. data/lib/textus/background/job/base.rb +27 -0
  40. data/lib/textus/background/job/materialize.rb +31 -0
  41. data/lib/textus/background/job/refresh.rb +22 -0
  42. data/lib/textus/background/job/sweep.rb +31 -0
  43. data/lib/textus/background/job.rb +19 -0
  44. data/lib/textus/background/plan.rb +9 -0
  45. data/lib/textus/background/planner/plan.rb +113 -0
  46. data/lib/textus/background/retention/apply.rb +50 -0
  47. data/lib/textus/background/worker.rb +67 -0
  48. data/lib/textus/boot.rb +59 -49
  49. data/lib/textus/command.rb +36 -0
  50. data/lib/textus/container.rb +1 -1
  51. data/lib/textus/{domain → core}/duration.rb +1 -1
  52. data/lib/textus/{domain → core}/freshness/evaluator.rb +13 -13
  53. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  54. data/lib/textus/{domain → core}/freshness.rb +2 -2
  55. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  56. data/lib/textus/{domain → core}/retention.rb +2 -2
  57. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  58. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  59. data/lib/textus/doctor/check/handler_permit.rb +34 -0
  60. data/lib/textus/doctor/check/hooks.rb +11 -18
  61. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  62. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  63. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  64. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  65. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  66. data/lib/textus/doctor/check/sentinels.rb +2 -2
  67. data/lib/textus/doctor/check.rb +12 -9
  68. data/lib/textus/{read → doctor}/validator.rb +22 -13
  69. data/lib/textus/doctor.rb +6 -6
  70. data/lib/textus/envelope/io/writer.rb +65 -36
  71. data/lib/textus/envelope.rb +5 -3
  72. data/lib/textus/errors.rb +19 -11
  73. data/lib/textus/events.rb +21 -0
  74. data/lib/textus/gate/auth.rb +181 -0
  75. data/lib/textus/gate.rb +114 -0
  76. data/lib/textus/init/templates/machine_intake.rb +40 -36
  77. data/lib/textus/init/templates/orientation_reducer.rb +15 -11
  78. data/lib/textus/init.rb +93 -76
  79. data/lib/textus/key/path.rb +9 -2
  80. data/lib/textus/layout.rb +21 -0
  81. data/lib/textus/manifest/capabilities.rb +1 -1
  82. data/lib/textus/manifest/data.rb +30 -15
  83. data/lib/textus/manifest/entry/base.rb +15 -11
  84. data/lib/textus/manifest/entry/parser.rb +6 -6
  85. data/lib/textus/manifest/entry/produced.rb +3 -2
  86. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  87. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
  88. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  89. data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
  90. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  91. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  92. data/lib/textus/manifest/policy/react.rb +30 -0
  93. data/lib/textus/{domain → manifest}/policy/retention.rb +4 -4
  94. data/lib/textus/{domain → manifest}/policy/source.rb +27 -28
  95. data/lib/textus/manifest/policy.rb +36 -48
  96. data/lib/textus/manifest/resolver.rb +3 -2
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +18 -12
  99. data/lib/textus/manifest/schema/validator.rb +27 -25
  100. data/lib/textus/manifest/schema/vocabulary.rb +3 -3
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/{produce → pipeline}/acquire/handler.rb +3 -3
  104. data/lib/textus/{produce → pipeline}/acquire/intake.rb +23 -21
  105. data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
  106. data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
  107. data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
  108. data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
  109. data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
  110. data/lib/textus/{produce → pipeline}/engine.rb +17 -63
  111. data/lib/textus/{produce → pipeline}/render.rb +3 -1
  112. data/lib/textus/ports/audit_log.rb +31 -5
  113. data/lib/textus/ports/audit_subscriber.rb +4 -4
  114. data/lib/textus/ports/build_lock.rb +1 -1
  115. data/lib/textus/ports/queue/job.rb +65 -0
  116. data/lib/textus/ports/queue.rb +130 -0
  117. data/lib/textus/ports/sentinel_store.rb +2 -2
  118. data/lib/textus/ports/watcher_lock.rb +48 -0
  119. data/lib/textus/projection.rb +8 -8
  120. data/lib/textus/role.rb +1 -1
  121. data/lib/textus/schema/tools.rb +4 -3
  122. data/lib/textus/session.rb +6 -3
  123. data/lib/textus/step/base.rb +35 -0
  124. data/lib/textus/step/builtin/csv_fetch.rb +19 -0
  125. data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
  126. data/lib/textus/step/builtin/json_fetch.rb +18 -0
  127. data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
  128. data/lib/textus/step/builtin/rss_fetch.rb +26 -0
  129. data/lib/textus/step/builtin.rb +22 -0
  130. data/lib/textus/{hooks → step}/catalog.rb +3 -4
  131. data/lib/textus/{hooks → step}/context.rb +15 -13
  132. data/lib/textus/step/discovery.rb +24 -0
  133. data/lib/textus/{hooks → step}/error_log.rb +1 -1
  134. data/lib/textus/{hooks → step}/event_bus.rb +15 -16
  135. data/lib/textus/step/fetch.rb +13 -0
  136. data/lib/textus/{hooks → step}/fire_report.rb +1 -1
  137. data/lib/textus/step/loader.rb +108 -0
  138. data/lib/textus/step/observe.rb +31 -0
  139. data/lib/textus/step/registry_store.rb +66 -0
  140. data/lib/textus/{hooks → step}/signature.rb +1 -1
  141. data/lib/textus/step/transform.rb +12 -0
  142. data/lib/textus/step/validate.rb +11 -0
  143. data/lib/textus/step.rb +10 -0
  144. data/lib/textus/store.rb +17 -15
  145. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  146. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  147. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  148. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  149. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  150. data/lib/textus/surfaces/cli/group.rb +50 -0
  151. data/lib/textus/surfaces/cli/runner.rb +236 -0
  152. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  153. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  154. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  155. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  156. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  157. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  158. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  159. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  160. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  161. data/lib/textus/surfaces/cli/verb.rb +111 -0
  162. data/lib/textus/surfaces/cli.rb +148 -0
  163. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  164. data/lib/textus/surfaces/mcp/errors.rb +34 -0
  165. data/lib/textus/surfaces/mcp/server.rb +145 -0
  166. data/lib/textus/surfaces/mcp/session.rb +9 -0
  167. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  168. data/lib/textus/surfaces/mcp.rb +8 -0
  169. data/lib/textus/surfaces/role_scope.rb +38 -0
  170. data/lib/textus/surfaces/watcher.rb +38 -0
  171. data/lib/textus/version.rb +1 -1
  172. data/lib/textus.rb +64 -22
  173. metadata +133 -107
  174. data/lib/textus/cli/group/hook.rb +0 -9
  175. data/lib/textus/cli/group/key.rb +0 -9
  176. data/lib/textus/cli/group/mcp.rb +0 -9
  177. data/lib/textus/cli/group/rule.rb +0 -9
  178. data/lib/textus/cli/group/schema.rb +0 -9
  179. data/lib/textus/cli/group/zone.rb +0 -9
  180. data/lib/textus/cli/group.rb +0 -48
  181. data/lib/textus/cli/runner.rb +0 -193
  182. data/lib/textus/cli/verb/doctor.rb +0 -17
  183. data/lib/textus/cli/verb/get.rb +0 -18
  184. data/lib/textus/cli/verb/hook_run.rb +0 -48
  185. data/lib/textus/cli/verb/hooks.rb +0 -50
  186. data/lib/textus/cli/verb/init.rb +0 -18
  187. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  188. data/lib/textus/cli/verb/put.rb +0 -30
  189. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  190. data/lib/textus/cli/verb/schema_init.rb +0 -19
  191. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  192. data/lib/textus/cli/verb.rb +0 -116
  193. data/lib/textus/cli.rb +0 -138
  194. data/lib/textus/dispatcher.rb +0 -52
  195. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  196. data/lib/textus/domain/action.rb +0 -9
  197. data/lib/textus/domain/permission.rb +0 -7
  198. data/lib/textus/domain/policy/base_guards.rb +0 -25
  199. data/lib/textus/domain/policy/evaluation.rb +0 -15
  200. data/lib/textus/domain/policy/guard.rb +0 -35
  201. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  202. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  203. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  204. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  205. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  206. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  207. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  208. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  209. data/lib/textus/hooks/builtin.rb +0 -70
  210. data/lib/textus/hooks/loader.rb +0 -54
  211. data/lib/textus/hooks/rpc_registry.rb +0 -43
  212. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  213. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  214. data/lib/textus/maintenance/reconcile.rb +0 -160
  215. data/lib/textus/maintenance/rule_lint.rb +0 -66
  216. data/lib/textus/maintenance/zone_mv.rb +0 -64
  217. data/lib/textus/maintenance.rb +0 -15
  218. data/lib/textus/mcp/catalog.rb +0 -70
  219. data/lib/textus/mcp/errors.rb +0 -32
  220. data/lib/textus/mcp/server.rb +0 -138
  221. data/lib/textus/mcp/session.rb +0 -7
  222. data/lib/textus/mcp/tool_schemas.rb +0 -15
  223. data/lib/textus/mcp.rb +0 -6
  224. data/lib/textus/mustache.rb +0 -117
  225. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -69
  226. data/lib/textus/produce/events.rb +0 -36
  227. data/lib/textus/read/audit.rb +0 -130
  228. data/lib/textus/read/boot.rb +0 -26
  229. data/lib/textus/read/capabilities.rb +0 -70
  230. data/lib/textus/read/deps.rb +0 -38
  231. data/lib/textus/read/doctor.rb +0 -27
  232. data/lib/textus/read/freshness.rb +0 -152
  233. data/lib/textus/read/get.rb +0 -73
  234. data/lib/textus/read/list.rb +0 -24
  235. data/lib/textus/read/published.rb +0 -22
  236. data/lib/textus/read/pulse.rb +0 -98
  237. data/lib/textus/read/rdeps.rb +0 -39
  238. data/lib/textus/read/rule_explain.rb +0 -96
  239. data/lib/textus/read/rule_list.rb +0 -54
  240. data/lib/textus/read/schema_envelope.rb +0 -25
  241. data/lib/textus/read/uid.rb +0 -29
  242. data/lib/textus/read/validate_all.rb +0 -36
  243. data/lib/textus/read/where.rb +0 -24
  244. data/lib/textus/role_scope.rb +0 -78
  245. data/lib/textus/write/accept.rb +0 -58
  246. data/lib/textus/write/key_delete.rb +0 -65
  247. data/lib/textus/write/key_mv.rb +0 -141
  248. data/lib/textus/write/propose.rb +0 -54
  249. data/lib/textus/write/put.rb +0 -74
  250. data/lib/textus/write/reject.rb +0 -68
@@ -0,0 +1,181 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ class Gate
5
+ class Auth
6
+ FLOOR = {
7
+ put: %w[lane_writable_by],
8
+ key_delete: %w[lane_writable_by],
9
+ key_mv: %w[lane_writable_by],
10
+ accept: %w[author_held],
11
+ reject: %w[author_held],
12
+ propose: %w[lane_writable_by],
13
+ key_mv_prefix: %w[lane_writable_by],
14
+ key_delete_prefix: %w[lane_writable_by],
15
+ }.freeze
16
+
17
+ AuthContext = Struct.new(
18
+ :actor, :actor_caps, :lane_verb,
19
+ :action, :target, :envelope,
20
+ :mentry, :manifest,
21
+ keyword_init: true
22
+ )
23
+
24
+ def initialize(container)
25
+ @manifest = container.manifest
26
+ @schemas = container.schemas
27
+ end
28
+
29
+ # Command-based check (new Gate path).
30
+ def check!(cmd)
31
+ key = extract_key(cmd)
32
+ return unless key
33
+
34
+ evaluate_predicates(
35
+ action: command_to_action(cmd),
36
+ actor: cmd.role.to_s,
37
+ key: key,
38
+ envelope: nil,
39
+ extra: {},
40
+ )
41
+ end
42
+
43
+ # Backward-compatible check for inline action auth (accept, put, etc.).
44
+ def check_action!(action:, actor:, key:, envelope: nil, extra: {})
45
+ evaluate_predicates(
46
+ action: action.to_sym,
47
+ actor: actor,
48
+ key: key,
49
+ envelope: envelope,
50
+ extra: extra,
51
+ )
52
+ end
53
+
54
+ def self.command_to_verb
55
+ @command_to_verb ||= Textus::Gate::VERB_COMMAND.invert.freeze
56
+ end
57
+
58
+ private
59
+
60
+ def command_to_action(cmd)
61
+ self.class.command_to_verb.fetch(cmd.class) do
62
+ raise Textus::UsageError.new("unmapped command: #{cmd.class}")
63
+ end
64
+ end
65
+
66
+ def evaluate_predicates(action:, actor:, key:, envelope:, extra:)
67
+ mentry = @manifest.resolver.resolve(key).entry
68
+ lane_verb = @manifest.policy.verb_for_lane(mentry.lane.to_s)
69
+ actor_caps = Set.new(@manifest.data.role_caps.fetch(actor, []))
70
+
71
+ ctx = AuthContext.new(
72
+ actor:, actor_caps:, lane_verb:,
73
+ action:, target: key, envelope:,
74
+ mentry:, manifest: @manifest
75
+ )
76
+
77
+ failures = []
78
+ floor_preds = FLOOR.fetch(action, [])
79
+ rule_preds = rule_declared_predicates(action, key)
80
+ (floor_preds + rule_preds).uniq.each do |pred|
81
+ result = evaluate(pred, ctx, extra)
82
+ next if result[:pass]
83
+ raise result[:error] if result[:error]
84
+
85
+ failures << [pred, result[:reason]]
86
+ end
87
+ raise Textus::GuardFailed.new(failures) unless failures.empty?
88
+ end
89
+
90
+ def extract_key(cmd)
91
+ if cmd.respond_to?(:pending_key)
92
+ cmd.pending_key
93
+ elsif cmd.respond_to?(:key)
94
+ cmd.key
95
+ end
96
+ end
97
+
98
+ def rule_declared_predicates(action, key)
99
+ guard_map = @manifest.rules.for(key).guard
100
+ return [] if guard_map.nil?
101
+
102
+ Array(guard_map[action.to_s])
103
+ end
104
+
105
+ def evaluate(pred_name, ctx, extra)
106
+ case pred_name
107
+ when "lane_writable_by" then evaluate_lane_writable_by(ctx)
108
+ when "author_held" then evaluate_author_held(ctx)
109
+ when "target_is_canon" then evaluate_target_is_canon(ctx)
110
+ when "etag_match" then evaluate_etag_match(ctx, extra)
111
+ when "schema_valid" then evaluate_schema_valid(ctx)
112
+ when "fresh_within" then { pass: true }
113
+ else raise Textus::UsageError.new("unknown predicate '#{pred_name}'")
114
+ end
115
+ end
116
+
117
+ def evaluate_lane_writable_by(ctx)
118
+ pass = ctx.actor_caps.include?(ctx.lane_verb.to_s)
119
+ return { pass: true } if pass
120
+
121
+ holders = @manifest.policy.roles_with_capability(ctx.lane_verb.to_s)
122
+ { pass: false, error: Textus::WriteForbidden.new(ctx.mentry.key, ctx.mentry.lane, verb: ctx.lane_verb, holders:) }
123
+ end
124
+
125
+ def evaluate_author_held(ctx)
126
+ holders = @manifest.policy.roles_with_capability("author")
127
+ pass = holders.include?(ctx.actor.to_s)
128
+ reason = if pass
129
+ nil
130
+ elsif holders.empty?
131
+ "no role holds the 'author' capability; #{ctx.action} is disabled"
132
+ else
133
+ "role '#{ctx.actor}' lacks the 'author' capability (held by: #{holders.join(", ")})"
134
+ end
135
+ { pass:, reason: }
136
+ end
137
+
138
+ def evaluate_target_is_canon(ctx)
139
+ kind = @manifest.policy.declared_kind(ctx.mentry.lane.to_s)
140
+ pass = kind == :canon
141
+ { pass:, reason: pass ? nil : "target lane '#{ctx.mentry.lane}' is not canon (kind: #{kind})" }
142
+ end
143
+
144
+ def evaluate_etag_match(ctx, extra)
145
+ if_etag = extra[:if_etag]
146
+ return { pass: true } if if_etag.nil?
147
+
148
+ current = ctx.envelope&.etag
149
+ pass = current.nil? || current == if_etag
150
+ { pass:, error: pass ? nil : Textus::EtagMismatch.new(ctx.target, if_etag, current) }
151
+ end
152
+
153
+ def evaluate_schema_valid(ctx)
154
+ return { pass: true } unless ctx.envelope
155
+
156
+ schema_ref = ctx.mentry.schema
157
+ return { pass: true } unless schema_ref
158
+
159
+ schema = @schemas.fetch_or_nil(schema_ref)
160
+ return { pass: true } unless schema
161
+
162
+ frontmatter = ctx.envelope.meta&.dig("_meta") || ctx.envelope.meta || {}
163
+ begin
164
+ schema.validate!(frontmatter)
165
+ { pass: true }
166
+ rescue Textus::SchemaViolation => e
167
+ { pass: false, reason: schema_reason(e) }
168
+ end
169
+ end
170
+
171
+ def schema_reason(err)
172
+ d = err.details
173
+ return err.message.dup unless d.is_a?(Hash)
174
+ return "missing required fields: #{Array(d["missing"]).join(", ")}" if d["missing"]
175
+ return "field '#{d["field"]}': #{d["reason"]}" if d["field"]
176
+
177
+ err.message.dup
178
+ end
179
+ end
180
+ end
181
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ class Gate
5
+ VERB_COMMAND = {
6
+ get: Textus::Command::Get,
7
+ put: Textus::Command::Put,
8
+ propose: Textus::Command::Propose,
9
+ key_delete: Textus::Command::KeyDelete,
10
+ key_mv: Textus::Command::KeyMv,
11
+ accept: Textus::Command::Accept,
12
+ reject: Textus::Command::Reject,
13
+ enqueue: Textus::Command::Enqueue,
14
+ list: Textus::Command::List,
15
+ where: Textus::Command::Where,
16
+ uid: Textus::Command::Uid,
17
+ blame: Textus::Command::Blame,
18
+ audit: Textus::Command::Audit,
19
+ deps: Textus::Command::Deps,
20
+ rdeps: Textus::Command::Rdeps,
21
+ pulse: Textus::Command::Pulse,
22
+ rule_explain: Textus::Command::RuleExplain,
23
+ rule_list: Textus::Command::RuleList,
24
+ rule_lint: Textus::Command::RuleLint,
25
+ published: Textus::Command::Published,
26
+ schema_show: Textus::Command::SchemaShow,
27
+ doctor: Textus::Command::Doctor,
28
+ boot: Textus::Command::Boot,
29
+ jobs: Textus::Command::Jobs,
30
+ data_mv: Textus::Command::DataMv,
31
+ key_mv_prefix: Textus::Command::KeyMvPrefix,
32
+ key_delete_prefix: Textus::Command::KeyDeletePrefix,
33
+ drain: Textus::Command::Drain,
34
+ }.freeze
35
+
36
+ ROUTES = {
37
+ Command::Get => [Textus::Action::Get],
38
+ Command::Put => [Textus::Action::Put],
39
+ Command::Propose => [Textus::Action::Propose],
40
+ Command::KeyDelete => [Textus::Action::KeyDelete],
41
+ Command::KeyMv => [Textus::Action::KeyMv],
42
+ Command::Accept => [Textus::Action::Accept],
43
+ Command::Reject => [Textus::Action::Reject],
44
+ Command::Enqueue => [Textus::Action::Enqueue],
45
+ Command::List => [Textus::Action::List],
46
+ Command::Where => [Textus::Action::Where],
47
+ Command::Uid => [Textus::Action::Uid],
48
+ Command::Blame => [Textus::Action::Blame],
49
+ Command::Audit => [Textus::Action::Audit],
50
+ Command::Deps => [Textus::Action::Deps],
51
+ Command::Rdeps => [Textus::Action::Rdeps],
52
+ Command::Pulse => [Textus::Action::Pulse],
53
+ Command::RuleExplain => [Textus::Action::RuleExplain],
54
+ Command::RuleList => [Textus::Action::RuleList],
55
+ Command::RuleLint => [Textus::Action::RuleLint],
56
+ Command::Published => [Textus::Action::Published],
57
+ Command::SchemaShow => [Textus::Action::SchemaEnvelope],
58
+ Command::Doctor => [Textus::Action::Doctor],
59
+ Command::Boot => [Textus::Action::Boot],
60
+ Command::Jobs => [Textus::Action::Jobs],
61
+ Command::DataMv => [Textus::Action::DataMv],
62
+ Command::KeyMvPrefix => [Textus::Action::KeyMvPrefix],
63
+ Command::KeyDeletePrefix => [Textus::Action::KeyDeletePrefix],
64
+ Command::Drain => [Textus::Action::Drain],
65
+ }.freeze
66
+
67
+ def initialize(container)
68
+ @container = container
69
+ end
70
+
71
+ def dispatch(cmd, correlation_id: nil)
72
+ cmd = normalize_propose_key(cmd, @container) if cmd.is_a?(Command::Propose)
73
+ action_classes = ROUTES.fetch(cmd.class) do
74
+ raise Textus::UsageError.new("unknown command: #{cmd.class}")
75
+ end
76
+
77
+ Gate::Auth.new(@container).check!(cmd)
78
+ call_obj = build_call(cmd, correlation_id: correlation_id)
79
+ results = action_classes.map { |klass| run_action(klass, cmd, @container, call_obj) }
80
+ results.length == 1 ? results.first : results
81
+ end
82
+
83
+ private
84
+
85
+ def normalize_propose_key(cmd, container)
86
+ return cmd if cmd.pending_key
87
+
88
+ zone = container.manifest.policy.propose_lane_for(cmd.role.to_s)
89
+ cmd.with(pending_key: zone ? "#{zone}.#{cmd.key}" : nil)
90
+ end
91
+
92
+ def run_action(klass, cmd, container, call_obj)
93
+ action = klass.new(**extract_kwargs(klass, cmd))
94
+ action.call(container:, call: call_obj)
95
+ end
96
+
97
+ def extract_kwargs(klass, cmd)
98
+ params = klass.instance_method(:initialize).parameters
99
+ accepts_keyrest = params.any? { |t, _| t == :keyrest }
100
+ param_set = params.to_set { |_t, n| n }
101
+ cmd.members.each_with_object({}) do |m, h|
102
+ next unless accepts_keyrest || param_set.include?(m)
103
+
104
+ val = cmd.public_send(m)
105
+ h[m] = val unless val.nil?
106
+ end
107
+ end
108
+
109
+ def build_call(cmd, correlation_id: nil)
110
+ dry_run = cmd.respond_to?(:dry_run) ? !cmd.dry_run.nil? : false
111
+ Textus::Call.build(role: cmd.role, dry_run:, correlation_id: correlation_id)
112
+ end
113
+ end
114
+ end
@@ -1,45 +1,49 @@
1
- # .textus/hooks/machine_intake.rb
1
+ # .textus/steps/fetch/machine_intake.rb
2
2
  # Scaffolded by `textus init` — CUSTOMIZE FREELY, or delete the feeds.machines
3
3
  # entry from manifest.yaml if you don't want it.
4
- # Feeds a per-host SNAPSHOT into feeds.machines.<host> on `textus reconcile`
4
+ # Feeds a per-host SNAPSHOT into feeds.machines.<host> on `textus drain`
5
5
  # (never on the per-turn boot/pulse path). It is NESTED so it grows to a fleet: the
6
6
  # `local` leaf scans THIS host; add ssh hosts with the cookbook recipe
7
7
  # (docs/cookbook/environment-scan.md). tracked:false → gitignored. Keep this an
8
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
9
+ module Textus
10
+ module Step
11
+ class MachineIntakeFetch < Fetch
12
+ def call(config:, args:, caps:, **)
13
+ machine = args[:leaf_segments].first or
14
+ raise "machines intake needs a host leaf, e.g. the 'local' in feeds.machines.local"
15
+ spec = (config["machines"] || {}).fetch(machine) { raise "unknown machine: #{machine}" }
16
+ unless (spec["via"] || "local").to_s == "local"
17
+ raise "machine #{machine}: only `via: local` is scaffolded — see " \
18
+ "docs/cookbook/environment-scan.md for the SSH (remote) fan-out"
19
+ end
18
20
 
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
- } }
21
+ sh = ->(cmd) { `#{cmd}`.strip } # local shell-out, no network
22
+ ver = ->(cmd) { o = `#{cmd} 2>/dev/null`.strip; o.empty? ? nil : o } # nil if tool absent
23
+ count = ->(cmd) { n = `#{cmd} 2>/dev/null`.strip.lines.size; n.zero? ? nil : n }
24
+ { content: {
25
+ # git_* describe THIS repo on the control host — only meaningful for `local`.
26
+ "git_head" => sh.call("git rev-parse --short HEAD 2>/dev/null"),
27
+ "git_branch" => sh.call("git rev-parse --abbrev-ref HEAD 2>/dev/null"),
28
+ "git_dirty" => !sh.call("git status --porcelain 2>/dev/null").empty?,
29
+ "repo_root" => sh.call("git rev-parse --show-toplevel 2>/dev/null"),
30
+ "captured_at" => Time.now.utc.iso8601,
31
+ "os" => RbConfig::CONFIG["host_os"],
32
+ "arch" => RbConfig::CONFIG["host_cpu"],
33
+ "ruby_version" => RUBY_VERSION,
34
+ "runtimes" => { # versions only; nil when not installed
35
+ "node" => ver.call("node --version"),
36
+ "python" => ver.call("python3 --version"),
37
+ "go" => ver.call("go version"),
38
+ },
39
+ "packages" => { # COUNTS only — never the list (size/secrets)
40
+ "brew" => count.call("brew list --formula"), # ~1-3s on macOS; runs only on fetch, amortized by the ttl rule
41
+ "apt" => count.call("dpkg-query -f '.\n' -W"),
42
+ },
43
+ "textus_version" => Textus::VERSION,
44
+ "protocol" => Textus::PROTOCOL,
45
+ } }
46
+ end
47
+ end
44
48
  end
45
49
  end
@@ -1,17 +1,21 @@
1
1
  # Reducer that reshapes the raw projection rows into the keys the
2
2
  # orientation.mustache template references. Without this, the template
3
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.") }
4
+ module Textus
5
+ module Step
6
+ class OrientationTransform < Transform
7
+ def call(rows:, config:, **)
8
+ project_row = rows.find { |r| r["_key"] == "knowledge.project" } || {}
9
+ runbook_rows = rows.select { |r| r["_key"]&.start_with?("knowledge.runbooks.") }
8
10
 
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
- }
11
+ {
12
+ "project" => {
13
+ "name" => project_row["name"],
14
+ "description" => project_row["description"]
15
+ },
16
+ "runbooks" => runbook_rows.map { |r| { "name" => r["name"], "description" => r["description"] } }
17
+ }
18
+ end
19
+ end
16
20
  end
17
21
  end