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,7 +0,0 @@
1
- module Textus
2
- module MCP
3
- # The session value now lives in core (ADR 0036); retained here as an
4
- # alias so existing MCP references keep resolving.
5
- Session = Textus::Session
6
- end
7
- end
@@ -1,15 +0,0 @@
1
- module Textus
2
- module MCP
3
- # Kept for name stability (ADR 0039). The JSON schemas are DERIVED from
4
- # per-verb contracts; this delegates to MCP::Catalog. The hand-written
5
- # array is gone — a kwarg rename now updates the schema automatically (and
6
- # the signature guard fails if the contract lags the use-case).
7
- module ToolSchemas
8
- module_function
9
-
10
- def all
11
- Catalog.tool_schemas
12
- end
13
- end
14
- end
15
- end
data/lib/textus/mcp.rb DELETED
@@ -1,6 +0,0 @@
1
- module Textus
2
- # The agent gate. Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05.
3
- # Wraps Textus::RoleScope as auto-derived tools. See ADR 0015.
4
- module MCP
5
- end
6
- end
@@ -1,117 +0,0 @@
1
- module Textus
2
- module Mustache
3
- MAX_DEPTH = 8
4
- TAG = %r{\{\{(?<sigil>[#^/!&]?)\s*(?<name>[\w.-]+)\s*\}\}}
5
-
6
- def self.render(template, context, strict: false, depth: 0) # rubocop:disable Metrics/AbcSize
7
- raise TemplateError.new("template recursion depth #{depth} exceeded #{MAX_DEPTH}") if depth > MAX_DEPTH
8
-
9
- out = +""
10
- pos = 0
11
- while (m = template.match(TAG, pos))
12
- out << template[pos...m.begin(0)]
13
- case m[:sigil]
14
- when "!"
15
- # comment, skip
16
- when "#"
17
- section, new_pos = parse_section(template, m, m[:name])
18
- value = lookup(context, m[:name])
19
- out << render_section(section, value, context, strict, depth)
20
- pos = new_pos
21
- next
22
- when "^"
23
- section, new_pos = parse_section(template, m, m[:name])
24
- value = lookup(context, m[:name])
25
- if falsy?(value)
26
- raise TemplateError.new("template recursion depth #{depth + 1} exceeded #{MAX_DEPTH}") if depth + 1 > MAX_DEPTH
27
-
28
- out << render(section, context, strict: strict, depth: depth + 1)
29
- end
30
- pos = new_pos
31
- next
32
- when "/"
33
- raise TemplateError.new("unexpected closing tag #{m[:name]}")
34
- else
35
- val = lookup(context, m[:name])
36
- if val.nil?
37
- raise TemplateError.new("missing variable: #{m[:name]}") if strict
38
- else
39
- out << val.to_s
40
- end
41
- end
42
- pos = m.end(0)
43
- end
44
- out << template[pos..]
45
- out
46
- end
47
-
48
- def self.parse_section(template, open_match, name)
49
- open_re = /\{\{#\s*#{Regexp.escape(name)}\s*\}\}|\{\{\^\s*#{Regexp.escape(name)}\s*\}\}/
50
- close_re = %r{\{\{/\s*#{Regexp.escape(name)}\s*\}\}}
51
- both = Regexp.union(open_re, close_re)
52
- depth = 1
53
- cursor = open_match.end(0)
54
- while (m = template.match(both, cursor))
55
- if m[0].start_with?("{{/")
56
- depth -= 1
57
- return [template[open_match.end(0)...m.begin(0)], m.end(0)] if depth.zero?
58
- else
59
- depth += 1
60
- end
61
- cursor = m.end(0)
62
- end
63
- raise TemplateError.new("unclosed section: #{name}")
64
- end
65
-
66
- def self.render_section(section, value, context, strict, depth)
67
- raise TemplateError.new("template recursion depth #{depth + 1} exceeded #{MAX_DEPTH}") if depth + 1 > MAX_DEPTH
68
-
69
- case value
70
- when Array
71
- value.map { |v| render(section, scope_for(context, v), strict: strict, depth: depth + 1) }.join
72
- when Hash
73
- render(section, merge(context, value), strict: strict, depth: depth + 1)
74
- when true
75
- render(section, context, strict: strict, depth: depth + 1)
76
- when false, nil
77
- # falsy in regular section: render nothing.
78
- # render_section is only called for inverted sections when falsy? is true at the call site,
79
- # so this branch is only hit for normal sections with falsy values.
80
- ""
81
- else
82
- render(section, context, strict: strict, depth: depth + 1)
83
- end || ""
84
- end
85
-
86
- def self.lookup(context, name)
87
- # Implicit iterator: {{.}} refers to the current scope itself (used when
88
- # iterating arrays of primitive values).
89
- return context["."] if name == "." && context.is_a?(Hash) && context.key?(".")
90
- return context[name] if context.is_a?(Hash) && context.key?(name)
91
-
92
- name.split(".").reduce(context) do |acc, seg|
93
- return nil unless acc.is_a?(Hash)
94
-
95
- acc[seg]
96
- end
97
- end
98
-
99
- # Build the rendering scope for one iteration of a section. Hash items
100
- # merge into the outer context; primitive items (strings, numbers) bind
101
- # to the implicit iterator under key ".".
102
- def self.scope_for(context, item)
103
- return merge(context, item) if item.is_a?(Hash)
104
-
105
- base = context.is_a?(Hash) ? context : {}
106
- base.merge("." => item)
107
- end
108
-
109
- def self.merge(base, override)
110
- return base unless override.is_a?(Hash)
111
-
112
- base.merge(override)
113
- end
114
-
115
- def self.falsy?(v) = v.nil? || v == false || v == [] || v == ""
116
- end
117
- end
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Ports
5
- # Writes an "event_error" audit row when a user hook raises during
6
- # Hooks::EventBus publish. Attached at Store boot.
7
- #
8
- # Integration: uses Hooks::EventBus#on_error callback (chosen over a
9
- # synthetic :hook_error event because the bus already owns the
10
- # rescue and the failure is a bus-internal concern, not a domain
11
- # event subscribers should be able to filter by key glob).
12
- #
13
- # Lifecycle audit rows for verb: "put" / "key_delete" / "key_mv" are written
14
- # by Envelope::IO::Writer directly (it owns the
15
- # audit-append-as-final-step invariant); this subscriber covers the
16
- # hook-failure case the writer never sees.
17
- class AuditSubscriber
18
- def initialize(audit_log)
19
- @audit_log = audit_log
20
- end
21
-
22
- def attach(bus)
23
- bus.on_error do |event:, hook:, key:, kwargs:, error:|
24
- record_error(event: event, hook: hook, key: key, kwargs: kwargs, error: error)
25
- end
26
- self
27
- end
28
-
29
- private
30
-
31
- def record_error(event:, hook:, key:, kwargs:, error:)
32
- extras = { "event" => event.to_s, "hook" => hook.to_s, "error" => "#{error.class}: #{error.message}" }
33
- extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
34
- extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
35
- @audit_log.append(
36
- role: Textus::Role::AUTOMATION, verb: "event_error", key: key,
37
- etag_before: nil, etag_after: nil, extras: extras
38
- )
39
- end
40
- end
41
- end
42
- end
@@ -1,73 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Ports
5
- # ADR 0093 / job-queue model: on a canon write, enqueue a `materialize` job
6
- # for each derived entry that depends on the written key (rdeps ∩ producible).
7
- # Async-only — the write returns immediately; a worker (drain/serve) converges
8
- # the jobs. There is no inline `sync` path and no in-process thread: freshness
9
- # is re-homed to drain (at the commit/CI gate) and the daemon. A write INTO a
10
- # derived entry does not fan out (recursion guard). Produce self-elevates, so
11
- # the job is stamped automation. Attached at Store boot, alongside
12
- # AuditSubscriber.
13
- class ProduceOnWriteSubscriber
14
- def initialize(container)
15
- @container = container
16
- end
17
-
18
- def attach(bus)
19
- bus.on(:entry_written, :produce_on_write) do |key:, **|
20
- on_write(key: key)
21
- end
22
- # Closes the ADR 0087 gap: a delete/rename of a source must re-materialize
23
- # its orphaned dependents too, not just a write. These fire distinct
24
- # events (:entry_deleted / :entry_renamed), so subscribe to each.
25
- bus.on(:entry_deleted, :produce_on_delete) do |key:, **|
26
- on_write(key: key)
27
- end
28
- bus.on(:entry_renamed, :produce_on_rename) do |from_key:, to_key:, **|
29
- on_write(key: from_key)
30
- on_write(key: to_key)
31
- end
32
- self
33
- end
34
-
35
- def on_write(key:)
36
- return if derived_write?(key) # recursion guard: produce output is not a source change
37
-
38
- affected = Textus::Read::Rdeps.new(container: @container).call(key)["rdeps"]
39
- producible = affected.select { |k| producible?(k) }
40
- return if producible.empty?
41
-
42
- queue = Textus::Ports::Queue.new(root: @container.root)
43
- producible.each do |k|
44
- queue.enqueue(
45
- Textus::Domain::Jobs::Job.new(
46
- type: "materialize", args: { "key" => k }, enqueued_by: Textus::Role::AUTOMATION,
47
- ),
48
- )
49
- end
50
- end
51
-
52
- private
53
-
54
- def derived_write?(key)
55
- @container.manifest.resolver.resolve(key).entry.derived?
56
- rescue Textus::Error
57
- false
58
- end
59
-
60
- # The producible scope mirrors Produce::Engine#produce_one: derived
61
- # entries render+publish, and nested publish_tree entries mirror their
62
- # source subtree (ADR 0047). Including the latter restores reactive
63
- # re-mirroring on a write into a tree's source — dropped when the scope
64
- # narrowed to `derived?` only.
65
- def producible?(key)
66
- entry = @container.manifest.resolver.resolve(key).entry
67
- entry.derived? || !entry.publish_tree.nil?
68
- rescue Textus::Error
69
- false
70
- end
71
- end
72
- end
73
- end
@@ -1,29 +0,0 @@
1
- require "timeout"
2
-
3
- module Textus
4
- module Produce
5
- module Acquire
6
- # Invokes a :resolve_handler hook handler by name under a timeout — the single
7
- # home for "call the intake handler under a deadline" (ADR 0048 D1). Shared by
8
- # Produce::Acquire::Intake (the internal ingest mechanism — no public verb since ADR 0079)
9
- # as driven by the converge sweep (drain/serve) and `textus hook run` (ADR 0089 made
10
- # ingest system-pushed; there is no read or put trigger).
11
- # Always passes a Container as `caps:` so the hook contract (ADR 0027) is
12
- # uniform across every entry point. Maps Timeout::Error to a UsageError;
13
- # leaves any other error to the caller (call sites differ in how they wrap).
14
- module Handler
15
- FETCH_TIMEOUT_SECONDS = 30
16
-
17
- module_function
18
-
19
- def invoke(caps:, handler:, config:, args:, label:, timeout: FETCH_TIMEOUT_SECONDS)
20
- Timeout.timeout(timeout) do
21
- caps.rpc.invoke(:resolve_handler, handler, caps: caps, config: config, args: args)
22
- end
23
- rescue Timeout::Error
24
- raise Textus::UsageError.new("#{label} '#{handler}' exceeded #{timeout}s timeout")
25
- end
26
- end
27
- end
28
- end
29
- end
@@ -1,130 +0,0 @@
1
- require "timeout"
2
-
3
- module Textus
4
- module Produce
5
- module Acquire
6
- # Internal ingest executor for one machine-zone intake entry. No longer a
7
- # public verb (ADR 0079 collapsed the `fetch` surface): used by the
8
- # converge sweep (drain/serve) and `textus hook run` only — ingest is system-pushed
9
- # (ADR 0089 removed the read-through that once also drove it).
10
- class Intake
11
- FETCH_TIMEOUT_SECONDS = Textus::Produce::Acquire::Handler::FETCH_TIMEOUT_SECONDS
12
-
13
- def initialize(container:, call:)
14
- @container = container
15
- @call = call
16
- @manifest = container.manifest
17
- @schemas = container.schemas
18
- @rpc = container.rpc
19
- end
20
-
21
- # call(key) is the primary entry; run is kept as an alias for
22
- # Orchestrator and FetchAll which call worker.run(key).
23
- def call(key)
24
- run(key)
25
- end
26
-
27
- def run(key)
28
- res = @manifest.resolver.resolve(key)
29
- mentry = res.entry
30
- path = res.path
31
- remaining = res.remaining
32
- raise UsageError.new("no intake declared for '#{key}'") unless mentry.intake?
33
-
34
- before_etag = @container.file_store.exists?(path) ? @container.file_store.etag(path) : nil
35
- result = fetch_with_events(key, mentry, remaining)
36
- persist_and_notify(key, mentry, result, before_etag)
37
- end
38
-
39
- def self.normalize_action_result(res, format:)
40
- res = res.transform_keys(&:to_s) if res.is_a?(Hash)
41
- res ||= {}
42
- meta_val = res["_meta"]
43
- body = res["body"]
44
- content = res["content"]
45
-
46
- case format
47
- when "markdown" then { meta: meta_val || {}, body: body.to_s, content: nil }
48
- when "text" then { meta: {}, body: body.to_s, content: nil }
49
- when "json", "yaml"
50
- if !content.nil?
51
- { meta: meta_val || {}, body: nil, content: content }
52
- elsif !body.nil?
53
- { meta: {}, body: body.to_s, content: nil }
54
- else
55
- raise Textus::UsageError.new("intake for #{format} returned neither content nor body")
56
- end
57
- else
58
- raise Textus::UsageError.new("unknown format #{format.inspect}")
59
- end
60
- end
61
-
62
- private
63
-
64
- def fetch_events
65
- @fetch_events ||= Textus::Produce::Events.from(container: @container, call: @call)
66
- end
67
-
68
- # ADR 0079: a per-rule fetch_timeout_seconds override was an accepted loss
69
- # in the fetch:/retention: → lifecycle: collapse; the constant ceiling
70
- # applies to every intake.
71
- def fetch_timeout_for(_key)
72
- FETCH_TIMEOUT_SECONDS
73
- end
74
-
75
- def fetch_with_events(key, mentry, remaining)
76
- fetch_events.started(key)
77
- call_intake(key, mentry, remaining)
78
- end
79
-
80
- def call_intake(key, mentry, remaining)
81
- Textus::Produce::Acquire::Handler.invoke(
82
- caps: @container, handler: mentry.handler,
83
- config: mentry.config,
84
- args: { trigger_key: key, leaf_segments: remaining || [] },
85
- label: "intake", timeout: fetch_timeout_for(key)
86
- )
87
- rescue Textus::Error => e
88
- fetch_events.failed(key, e)
89
- raise
90
- rescue StandardError => e
91
- fetch_events.failed(key, e)
92
- raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
93
- end
94
-
95
- def persist_and_notify(key, mentry, result, before_etag)
96
- normalized = self.class.normalize_action_result(result, format: mentry.format)
97
- Textus::Domain::Policy::GuardFactory.new(
98
- manifest: @manifest, schemas: @schemas,
99
- ).for(:converge, key).check!(
100
- Textus::Domain::Policy::Evaluation.new(
101
- actor: @call.role, transition: :converge, origin: nil,
102
- target: key, envelope: nil, manifest: @manifest
103
- ),
104
- )
105
- envelope = writer.put(
106
- key,
107
- mentry: mentry,
108
- payload: Textus::Envelope::IO::Writer::Payload.new(
109
- meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
110
- ),
111
- )
112
- change = detect_change(before_etag, envelope)
113
- fetch_events.fetched(key, envelope, change)
114
- envelope
115
- end
116
-
117
- def detect_change(before_etag, envelope)
118
- if before_etag.nil? then :created
119
- elsif envelope.etag == before_etag then :unchanged
120
- else :updated
121
- end
122
- end
123
-
124
- def writer
125
- @writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
126
- end
127
- end
128
- end
129
- end
130
- end
@@ -1,127 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Produce
5
- module Acquire
6
- # Builds an entry's DATA artifact (ADR 0094) by running the projection
7
- # pipeline; rendering is a publish concern. External entries are NOT built
8
- # here — they are generated by an out-of-band runner; Derived#publish_via
9
- # filters them out before reaching this point.
10
- #
11
- # Merges the former Write::DataBuilder wrapper and Builder::Pipeline module
12
- # into one class (ADR 0100 produce/ topology refactor).
13
- class Projection
14
- # Injects provenance metadata as the first key in the serialized output.
15
- # Carries only deterministic provenance (`from`/`reduce`) — the volatile
16
- # `generated_at` is deliberately NOT stamped, so the built artifact is
17
- # content-addressed and a rebuild is a byte-for-byte no-op (ADR 0070).
18
- # Build time lives out of the tracked artifact.
19
- module InjectMeta
20
- def self.call(content_hash, mentry)
21
- meta = {}
22
- if mentry.derived?
23
- src = mentry.source
24
- if src.projection?
25
- from = Array(src.select).compact
26
- meta["from"] = from unless from.empty?
27
- meta["reduce"] = src.transform if src.transform
28
- end
29
- end
30
-
31
- out = { "_meta" => meta }
32
- content_hash.each { |k, v| out[k] = v unless k == "_meta" }
33
- out
34
- end
35
- end
36
-
37
- Deps = Data.define(:manifest, :reader, :lister, :rpc, :transform_context)
38
-
39
- def self.renderers
40
- @renderers ||= {
41
- "text" => Produce::Acquire::Serializer::Text,
42
- "json" => Produce::Acquire::Serializer::Json,
43
- "yaml" => Produce::Acquire::Serializer::Yaml,
44
- }
45
- end
46
-
47
- def initialize(container:, call:)
48
- @container = container
49
- @call = call
50
- @manifest = container.manifest
51
- @file_store = container.file_store
52
- @rpc = container.rpc
53
- @root = container.root
54
- end
55
-
56
- # Runs the projection pipeline for `mentry` and returns the on-disk
57
- # target_path string.
58
- def run(mentry)
59
- reader = Textus::Read::Get.new(container: @container, call: @call)
60
- # Projections must be able to read source data from any nested entry,
61
- # including keyless (publish_tree) ones like knowledge.decisions.
62
- # The `include_keyless: true` option makes the resolver walk those dirs
63
- # without exposing them on the public `list` / CLI surface (ADR 0047).
64
- resolver = @manifest.resolver
65
- lister = lambda do |prefix:|
66
- resolver.enumerate(prefix: prefix, include_keyless: true)
67
- .map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
68
- end
69
- self.class.pipeline_run(
70
- mentry: mentry,
71
- deps: Deps.new(
72
- manifest: @manifest,
73
- reader: reader.method(:call),
74
- lister: lister,
75
- rpc: @rpc,
76
- transform_context: @container,
77
- ),
78
- )
79
- end
80
-
81
- def self.pipeline_run(mentry:, deps:)
82
- # 1. Load sources + project + reduce. Only projection-derived entries are
83
- # buildable in-process; External entries are generated out-of-band and are
84
- # filtered out upstream (Derived#publish_via), so reaching here with a
85
- # non-projection source is a wiring bug — fail loudly rather than emit an
86
- # empty payload (and never re-stamp the volatile generated_at, ADR 0070).
87
- unless mentry.projection?
88
- raise UsageError.new(
89
- "builder: '#{mentry.key}' is not a projection-derived entry; only projections are buildable",
90
- )
91
- end
92
-
93
- data =
94
- Textus::Projection.new(
95
- reader: deps.reader,
96
- spec: mentry.source.projection_spec,
97
- lister: deps.lister,
98
- rpc: deps.rpc,
99
- transform_context: deps.transform_context,
100
- ).run
101
-
102
- # 2. Serialize as DATA. Rendering through a template is a publish concern
103
- # (ADR 0094) — the build never consults a template.
104
- klass = renderers[mentry.format] or
105
- raise UsageError.new("builder: unsupported data format #{mentry.format.inspect} for '#{mentry.key}'")
106
- bytes = klass.new.call(mentry: mentry, data: data)
107
-
108
- # 3. Write (idempotent: skip if only generated_at would differ)
109
- target_path = Key::Path.resolve(deps.manifest.data, mentry)
110
- FileUtils.mkdir_p(File.dirname(target_path))
111
- write_if_changed(target_path, bytes, mentry.format)
112
-
113
- target_path
114
- end
115
-
116
- # Built artifacts are content-addressed (no volatile timestamp, ADR 0070),
117
- # so identity is plain byte-equality: skip the write when nothing changed.
118
- # `format` is retained for signature stability across renderers.
119
- def self.write_if_changed(target_path, bytes, _format)
120
- return if File.exist?(target_path) && File.binread(target_path) == bytes
121
-
122
- File.binwrite(target_path, bytes)
123
- end
124
- end
125
- end
126
- end
127
- end
@@ -1,31 +0,0 @@
1
- require "json"
2
-
3
- module Textus
4
- module Produce
5
- module Acquire
6
- class Serializer
7
- class Json < Serializer
8
- def call(mentry:, data:)
9
- content = default_shape(mentry, data)
10
- final = Produce::Acquire::Projection::InjectMeta.call(content, mentry)
11
- Entry.for_format("json").serialize(meta: {}, body: "", content: final)
12
- end
13
-
14
- private
15
-
16
- def default_shape(mentry, data)
17
- has_transform = mentry.projection? &&
18
- mentry.source.transform
19
- if has_transform && data.is_a?(Hash) && !data.key?("entries")
20
- data
21
- elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
22
- { "entries" => data["entries"] }
23
- else
24
- data.is_a?(Hash) ? data : { "entries" => Array(data) }
25
- end
26
- end
27
- end
28
- end
29
- end
30
- end
31
- end
@@ -1,16 +0,0 @@
1
- module Textus
2
- module Produce
3
- module Acquire
4
- class Serializer
5
- class Text < Serializer
6
- def call(mentry:, data:) # rubocop:disable Lint/UnusedMethodArgument
7
- # Text format serializes data as plain-text. Rendering through a
8
- # template is a publish concern (ADR 0094) — build emits data only.
9
- body = data.is_a?(Hash) ? data.to_s : data.inspect
10
- Entry.for_format("text").serialize(meta: {}, body: body)
11
- end
12
- end
13
- end
14
- end
15
- end
16
- end
@@ -1,31 +0,0 @@
1
- require "yaml"
2
-
3
- module Textus
4
- module Produce
5
- module Acquire
6
- class Serializer
7
- class Yaml < Serializer
8
- def call(mentry:, data:)
9
- content = default_shape(mentry, data)
10
- final = Produce::Acquire::Projection::InjectMeta.call(content, mentry)
11
- Entry.for_format("yaml").serialize(meta: {}, body: "", content: final)
12
- end
13
-
14
- private
15
-
16
- def default_shape(mentry, data)
17
- has_transform = mentry.projection? &&
18
- mentry.source.transform
19
- if has_transform && data.is_a?(Hash) && !data.key?("entries")
20
- data
21
- elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
22
- { "entries" => data["entries"] }
23
- else
24
- data.is_a?(Hash) ? data : { "entries" => Array(data) }
25
- end
26
- end
27
- end
28
- end
29
- end
30
- end
31
- end
@@ -1,17 +0,0 @@
1
- module Textus
2
- module Produce
3
- module Acquire
4
- # Abstract base for output serializers. Each concrete serializer owns
5
- # producing the bytes for one manifest format (json/yaml/text).
6
- # Rendering through a template is a publish concern (ADR 0094) — serializers
7
- # here only serialize data; they take no arguments.
8
- class Serializer
9
- def call(mentry:, data:)
10
- _ = mentry
11
- _ = data
12
- raise NotImplementedError.new("#{self.class.name}#call not implemented")
13
- end
14
- end
15
- end
16
- end
17
- end