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
@@ -0,0 +1,101 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Jobs
5
+ class Planner
6
+ ACTIONS_BY_TRIGGER = {
7
+ "convergence" => %w[materialize sweep],
8
+ "entry.written" => %w[materialize],
9
+ "entry.deleted" => %w[materialize],
10
+ "entry.moved" => %w[materialize],
11
+ "proposal.accepted" => %w[materialize],
12
+ "proposal.rejected" => %w[materialize],
13
+ }.freeze
14
+
15
+ SCOPE_RESOLVERS = {
16
+ "materialize" => :producible_keys,
17
+ "sweep" => :lane_keys,
18
+ }.freeze
19
+
20
+ def self.seed(container:, queue:, role:)
21
+ jobs = new(container: container).plan(
22
+ trigger: { "type" => "convergence" },
23
+ role: role,
24
+ )
25
+ jobs.each { |j| queue.enqueue(j) }
26
+ end
27
+
28
+ def initialize(container:)
29
+ @container = container
30
+ @manifest = container.manifest
31
+ end
32
+
33
+ def plan(trigger:, role:)
34
+ type = trigger["type"] || trigger[:type]
35
+ trigger["target"] || trigger[:target]
36
+ return [] if type.nil?
37
+
38
+ blocks_with_react = @manifest.rules.blocks.select(&:react)
39
+ if blocks_with_react.any?
40
+ plan_from_rules(blocks_with_react, type, role)
41
+ else
42
+ plan_from_defaults(type, role)
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def plan_from_rules(blocks, type, role)
49
+ jobs = []
50
+ blocks
51
+ .select { |b| matches_trigger?(b.react, type) }
52
+ .each do |block|
53
+ do_action = block.react.raw["do"]
54
+ Array(do_action).each do |action|
55
+ if action == "sweep"
56
+ jobs << Textus::Ports::JobStore::Job.new(
57
+ type: "sweep", args: { "scope" => {} }, enqueued_by: role,
58
+ )
59
+ else
60
+ resolver = SCOPE_RESOLVERS.fetch(action, :producible_keys)
61
+ keys = send(resolver, nil)
62
+ keys.each { |key| jobs << job(action, key, role) }
63
+ end
64
+ end
65
+ end
66
+ jobs
67
+ end
68
+
69
+ def plan_from_defaults(type, role)
70
+ actions = ACTIONS_BY_TRIGGER.fetch(type, [])
71
+ jobs = []
72
+ producible_keys(nil).each { |k| jobs << job("materialize", k, role) } if actions.include?("materialize")
73
+ if actions.include?("sweep")
74
+ jobs << Textus::Ports::JobStore::Job.new(
75
+ type: "sweep", args: { "scope" => {} }, enqueued_by: role,
76
+ )
77
+ end
78
+ jobs
79
+ end
80
+
81
+ def matches_trigger?(react, type)
82
+ on = react.raw["on"]
83
+ Array(on).include?(type)
84
+ end
85
+
86
+ def job(type, key, enqueued_by)
87
+ Textus::Ports::JobStore::Job.new(type: type, args: { "key" => key }, enqueued_by: enqueued_by)
88
+ end
89
+
90
+ def producible_keys(_target)
91
+ @manifest.data.entries
92
+ .select { |e| !e.publish_tree.nil? || !e.publish_to.empty? }
93
+ .map(&:key)
94
+ end
95
+
96
+ def lane_keys(_target)
97
+ @manifest.data.entries.map(&:key)
98
+ end
99
+ end
100
+ end
101
+ end
@@ -0,0 +1,48 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Jobs
5
+ class Retention
6
+ def initialize(container:, call:)
7
+ @container = container
8
+ @call = call
9
+ end
10
+
11
+ def call(rows)
12
+ out = { dropped: [], archived: [], failed: [] }
13
+ rows.each do |row|
14
+ key = row["key"]
15
+ begin
16
+ case row["action"]
17
+ when "drop"
18
+ delete(key)
19
+ out[:dropped] << key
20
+ when "archive"
21
+ archive_leaf(row)
22
+ delete(key)
23
+ out[:archived] << key
24
+ end
25
+ rescue Textus::Error => e
26
+ out[:failed] << { "key" => key, "error" => e.message }
27
+ end
28
+ end
29
+ out
30
+ end
31
+
32
+ private
33
+
34
+ def archive_leaf(row)
35
+ src = row["path"]
36
+ root = @container.root.to_s
37
+ rel = src.delete_prefix("#{root}/")
38
+ dest = File.join(root, "archive", rel)
39
+ FileUtils.mkdir_p(File.dirname(dest))
40
+ FileUtils.cp(src, dest)
41
+ end
42
+
43
+ def delete(key)
44
+ Textus::Action::KeyDelete.new(key: key).call(container: @container, call: @call)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,27 @@
1
+ module Textus
2
+ module Jobs
3
+ class Sweep < Base
4
+ REQUIRED_ROLE = Textus::Role::AUTOMATION
5
+ TYPE = "sweep"
6
+
7
+ def initialize(scope: nil, key: nil)
8
+ super()
9
+ @scope = scope || {}
10
+ @key = key
11
+ end
12
+
13
+ def args = { scope: @scope, key: @key }.compact
14
+
15
+ def call(container:, call:)
16
+ prefix = @key || (@scope.is_a?(Hash) ? @scope["prefix"] : nil)
17
+ lane = @scope.is_a?(Hash) ? @scope["lane"] : nil
18
+ rows = Textus::Core::Retention::Sweep.new(
19
+ manifest: container.manifest,
20
+ file_stat: Textus::Ports::Storage::FileStat.new,
21
+ clock: Textus::Ports::Clock.new,
22
+ ).call(prefix: prefix, lane: lane)
23
+ Textus::Jobs::Retention.new(container: container, call: call).call(rows)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,67 @@
1
+ module Textus
2
+ module Jobs
3
+ class Worker
4
+ Summary = Struct.new(:completed, :failed, keyword_init: true)
5
+
6
+ def self.for(container:, queue:)
7
+ new(queue: queue, container: container,
8
+ lease_ttl: container.manifest.data.worker_config[:lease_ttl])
9
+ end
10
+
11
+ def initialize(queue:, container:, lease_ttl: 60)
12
+ @queue = queue
13
+ @container = container
14
+ @lease_ttl = lease_ttl
15
+ end
16
+
17
+ def drain(worker_id: "drain-#{Process.pid}")
18
+ completed = 0
19
+ failed = 0
20
+ loop do
21
+ leased = @queue.lease(worker_id: worker_id, lease_ttl: @lease_ttl)
22
+ break unless leased
23
+
24
+ case run_one(leased)
25
+ when :completed then completed += 1
26
+ when :dead_lettered then failed += 1
27
+ end
28
+ end
29
+ Summary.new(completed: completed, failed: failed)
30
+ end
31
+
32
+ def drain_pool(pool: 4)
33
+ summaries = []
34
+ mutex = Mutex.new
35
+ threads = Array.new(pool) do |i|
36
+ Thread.new do
37
+ s = drain(worker_id: "pool-#{Process.pid}-#{i}")
38
+ mutex.synchronize { summaries << s }
39
+ end
40
+ end
41
+ threads.each(&:join)
42
+ Summary.new(completed: summaries.sum(&:completed), failed: summaries.sum(&:failed))
43
+ end
44
+
45
+ private
46
+
47
+ def run_one(leased)
48
+ job = leased.job
49
+ klass = Textus::Jobs.fetch(job.type)
50
+ action = if klass.instance_method(:initialize).parameters.any?
51
+ klass.new(**job.args.transform_keys(&:to_sym))
52
+ else
53
+ klass.new
54
+ end
55
+ call = Textus::Call.build(
56
+ role: job.enqueued_by || Textus::Role::AUTOMATION,
57
+ correlation_id: SecureRandom.uuid,
58
+ )
59
+ action.call(container: @container, call: call)
60
+ @queue.ack(leased)
61
+ :completed
62
+ rescue StandardError => e
63
+ @queue.fail(leased, error: e.message)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Jobs
3
+ @registry = {}
4
+
5
+ def self.registry = @registry
6
+
7
+ def self.register(klass)
8
+ @registry[klass::TYPE] = klass if klass.const_defined?(:TYPE, false)
9
+ end
10
+
11
+ def self.fetch(type)
12
+ @registry.fetch(type) { raise Textus::UsageError.new("unknown job type: #{type}") }
13
+ end
14
+ end
15
+ end
@@ -9,13 +9,20 @@ module Textus
9
9
  # callers historically passed the whole Manifest but should now pass
10
10
  # `manifest.data`.
11
11
  def self.resolve(data, mentry)
12
- primary_ext = Entry.for_format(mentry.format).extensions.first
12
+ primary_ext = Format.for(mentry.format).extensions.first
13
+ rel_path = normalize_relative_path(mentry.path)
13
14
  if File.extname(mentry.path) == ""
14
- File.join(data.root, "zones", mentry.path + primary_ext)
15
+ File.join(data.root, rel_path + primary_ext)
15
16
  else
16
- File.join(data.root, "zones", mentry.path)
17
+ File.join(data.root, rel_path)
17
18
  end
18
19
  end
20
+
21
+ def self.normalize_relative_path(path)
22
+ return path if path.start_with?("data/")
23
+
24
+ File.join("data", path)
25
+ end
19
26
  end
20
27
  end
21
28
  end
data/lib/textus/layout.rb CHANGED
@@ -4,6 +4,15 @@ module Textus
4
4
  # tracked/disposable boundary is a directory boundary. ADR 0038.
5
5
  module Layout
6
6
  RUN = ".run"
7
+ DATA = "data"
8
+
9
+ def self.data(root)
10
+ File.join(root, DATA)
11
+ end
12
+
13
+ def self.data_lane(root, lane_name)
14
+ File.join(data(root), lane_name)
15
+ end
7
16
 
8
17
  def self.run(root)
9
18
  File.join(root, RUN)
@@ -25,6 +34,10 @@ module Textus
25
34
  File.join(run(root), "build.lock")
26
35
  end
27
36
 
37
+ def self.watcher_lock(root)
38
+ File.join(run(root), "watcher.lock")
39
+ end
40
+
28
41
  def self.queue(root)
29
42
  File.join(run(root), "queue")
30
43
  end
@@ -5,15 +5,15 @@ module Textus
5
5
  class Manifest
6
6
  # Immutable, parsed view of a manifest YAML document.
7
7
  #
8
- # Holds raw structural data (zones, entries, audit_config, role_caps)
9
- # but no behaviour beyond accessors. Behaviour (zone authority, key
8
+ # Holds raw structural data (lanes, entries, audit_config, role_caps)
9
+ # but no behaviour beyond accessors. Behaviour (lane authority, key
10
10
  # resolution, rules) lives on Manifest::Policy / Resolver / Rules.
11
11
  class Data
12
12
  AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
13
13
  WORKER_DEFAULTS = { pool: 4, poll: 5, lease_ttl: 60, max_attempts: 3 }.freeze
14
14
 
15
- attr_reader :raw, :root, :entries, :declared_zone_kinds,
16
- :zone_descs, :zone_owners,
15
+ attr_reader :raw, :root, :entries, :declared_lane_kinds,
16
+ :lane_descs, :lane_owners,
17
17
  :audit_config, :worker_config, :role_caps, :policy
18
18
 
19
19
  def self.validate_key!(key)
@@ -27,7 +27,7 @@ module Textus
27
27
  def validate_key!(key) = self.class.validate_key!(key)
28
28
 
29
29
  def self.parse(raw, root:)
30
- raise BadFrontmatter.new(File.join(root.to_s, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?
30
+ raise BadFrontmatter.new(File.join(root.to_s, "manifest.yaml"), "manifest must declare lanes:") if Array(raw["lanes"]).empty?
31
31
 
32
32
  Schema.validate!(raw)
33
33
  new(raw: raw, root: root)
@@ -36,17 +36,17 @@ module Textus
36
36
  def initialize(raw:, root:)
37
37
  @raw = raw
38
38
  @root = root
39
- # Write authority is derived from capabilities × zone-kind (ADR 0030),
40
- # not a per-zone writer list. "Which zones are declared" lives in the
41
- # one kind-keyed map below (declared_zone_kinds); membership checks by
42
- # read-side callers (boot, maintenance/zone_mv) use its keyset (ADR 0034).
43
- @declared_zone_kinds = Array(raw["zones"]).to_h do |z|
39
+ # Write authority is derived from capabilities × lane-kind (ADR 0030),
40
+ # not a per-lane writer list. "Which lanes are declared" lives in the
41
+ # one kind-keyed map below (declared_lane_kinds); membership checks by
42
+ # read-side callers (boot, maintenance/data_mv) use its keyset (ADR 0034).
43
+ @declared_lane_kinds = Array(raw["lanes"]).to_h do |z|
44
44
  [z["name"], z["kind"]&.to_sym]
45
45
  end
46
- @zone_descs = Array(raw["zones"]).to_h { |z| [z["name"], z["desc"]] }
47
- # Only zones that actually declare an owner — keep nil-tombstones out so a
48
- # future `zone_owners.key?(name)` means "owner declared", not "zone exists".
49
- @zone_owners = Array(raw["zones"]).to_h { |z| [z["name"], z["owner"]] }.compact
46
+ @lane_descs = Array(raw["lanes"]).to_h { |z| [z["name"], z["desc"]] }
47
+ # Only lanes that actually declare an owner — keep nil-tombstones out so a
48
+ # future `lane_owners.key?(name)` means "owner declared", not "lane exists".
49
+ @lane_owners = Array(raw["lanes"]).to_h { |z| [z["name"], z["owner"]] }.compact
50
50
  @audit_config = build_audit_config(raw)
51
51
  @worker_config = build_worker_config(raw)
52
52
  @role_caps = Capabilities.resolve(raw["roles"])
@@ -2,14 +2,14 @@ module Textus
2
2
  class Manifest
3
3
  class Entry
4
4
  class Base < Entry
5
- attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :publish_targets
5
+ attr_reader :raw, :key, :path, :lane, :schema, :owner, :format, :publish_targets
6
6
 
7
7
  # rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
8
- def initialize(raw:, key:, path:, zone:, schema:, owner:, format:, publish_targets: [])
8
+ def initialize(raw:, key:, path:, lane:, schema:, owner:, format:, publish_targets: [])
9
9
  @raw = raw
10
10
  @key = key
11
11
  @path = path
12
- @zone = zone
12
+ @lane = lane
13
13
  @schema = schema
14
14
  @owner = owner
15
15
  @format = format
@@ -17,13 +17,14 @@ module Textus
17
17
  end
18
18
  # rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
19
19
 
20
- def zone_writers(policy)
21
- policy.zone_writers(@zone)
20
+ def lane_writers(policy)
21
+ verb = policy.verb_for_lane(@lane)
22
+ policy.roles_with_capability(verb)
22
23
  rescue UsageError => e
23
24
  raise UsageError.new("entry '#{@key}': #{e.message}")
24
25
  end
25
26
 
26
- def in_proposal_zone?(policy) = policy.queue_zone?(@zone)
27
+ def in_proposal_lane?(policy) = policy.queue_lane?(@lane)
27
28
 
28
29
  def nested? = false
29
30
  def derived? = false
@@ -36,6 +37,9 @@ module Textus
36
37
  def external? = false
37
38
  def projection? = false
38
39
 
40
+ alias zone lane
41
+ alias in_proposal_zone? in_proposal_lane?
42
+
39
43
  # Whether git should track this entry's file. Default true; an entry
40
44
  # marked `tracked: false` in the manifest stays protocol-readable but is
41
45
  # listed in the generated `.gitignore` (ADR 0043). Cross-cutting, so it
@@ -58,22 +62,15 @@ module Textus
58
62
  def ignored?(_rel_path) = false
59
63
 
60
64
  # Minimal context object passed into entry `publish_via` hooks.
61
- # Everything beyond the three primitives is derived. Data.define
62
- # instances are frozen, so we recompute per-call rather than
63
- # memoizing — RoleScope/Hooks::Context construction is cheap.
65
+ # Everything beyond the three primitives is derived.
64
66
  PublishContext = ::Data.define(:container, :call, :reader) do
65
67
  def manifest = container.manifest
66
68
  def root = container.root
67
69
  def repo_root = File.dirname(container.root)
68
- def events = container.events
69
-
70
- def hook_context
71
- Textus::Hooks::Context.new(scope: scope_for_hooks)
72
- end
73
70
 
74
- def emit(event, **payload)
75
- events.publish(event, ctx: hook_context, **payload)
76
- end
71
+ # No-op: event bus removed in workflow redesign; callers that fire
72
+ # :entry_published / :entry_produced remain unchanged in the source.
73
+ def emit(_event, **_payload) = nil
77
74
 
78
75
  # Read a named template from the store's templates/ directory.
79
76
  # Raises TemplateError when the file doesn't exist.
@@ -87,14 +84,6 @@ module Textus
87
84
  end
88
85
  File.read(path)
89
86
  end
90
-
91
- private
92
-
93
- def scope_for_hooks
94
- Textus::RoleScope.new(
95
- container: container, role: call.role, dry_run: call.dry_run,
96
- )
97
- end
98
87
  end
99
88
 
100
89
  # ADR 0049: an entry resolves, once, to one Publish::* mode that owns its
@@ -4,8 +4,7 @@ module Textus
4
4
  module Parser
5
5
  def self.call(raw)
6
6
  key = raw["key"] or raise UsageError.new("manifest entry missing key")
7
- path = raw["path"] or raise UsageError.new("manifest entry '#{key}' missing path")
8
- zone = raw["zone"] or raise UsageError.new("manifest entry '#{key}' missing zone")
7
+ lane = raw["lane"] or raise UsageError.new("manifest entry '#{key}' missing lane")
9
8
 
10
9
  raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (#{Entry::REGISTRY.keys.join("|")})")
11
10
  kind = raw_kind.to_sym
@@ -15,11 +14,14 @@ module Textus
15
14
  "the produce method is `source.from` (#{kind == :intake ? "handler" : "project|command"})",
16
15
  )
17
16
  end
18
- format = resolve_format(raw, path)
17
+
18
+ explicit_path = raw["path"]
19
+ format = resolve_format(raw, explicit_path)
20
+ path = explicit_path || derive_path(key, kind, format)
19
21
 
20
22
  common = {
21
23
  raw: raw,
22
- key: key, path: path, zone: zone,
24
+ key: key, path: path, lane: lane,
23
25
  schema: raw["schema"], owner: raw["owner"],
24
26
  format: format,
25
27
  publish_targets: publish_targets(raw)
@@ -30,13 +32,13 @@ module Textus
30
32
  klass.from_raw(common, raw)
31
33
  end
32
34
 
33
- # ADR 0093: an entry's production block is the unified `source:`. Returns a
34
- # Domain::Policy::Source; kind (intake/derived) is read from source.from.
35
- def self.parse_source(raw, key)
36
- block = raw["source"] or
37
- raise BadManifest.new("entry '#{key}' requires a source: { from: project|handler|command, ... }")
35
+ # Parse the optional `source:` block. Returns nil when absent (workflow
36
+ # produced entries register their produce logic in .textus/workflows/).
37
+ def self.parse_source(raw, _key)
38
+ block = raw["source"]
39
+ return nil if block.nil?
38
40
 
39
- Textus::Domain::Policy::Source.new(block)
41
+ Textus::Manifest::Policy::Source.new(block)
40
42
  end
41
43
 
42
44
  # ADR 0094: `publish:` is a LIST of target objects — to-targets
@@ -52,13 +54,16 @@ module Textus
52
54
  "[{to:, template:?} | {tree:}] (ADR 0094); the `publish: { … }` map form was retired",
53
55
  )
54
56
  end
55
- block.map { |t| Textus::Domain::Policy::PublishTarget.new(t) }
57
+ block.map { |t| Textus::Manifest::Policy::PublishTarget.new(t) }
56
58
  end
57
59
 
58
60
  def self.resolve_format(raw, path)
59
61
  declared = raw["format"]
60
- ext = File.extname(path)
61
- inferred = Textus::Entry.infer_from_extension(ext)
62
+
63
+ return declared || "markdown" if path.nil? || path.empty?
64
+
65
+ ext = File.extname(path)
66
+ inferred = Textus::Format.infer_from_extension(ext)
62
67
 
63
68
  if declared.nil?
64
69
  return inferred if inferred
@@ -66,7 +71,7 @@ module Textus
66
71
  return "markdown"
67
72
  end
68
73
 
69
- raise UsageError.new("entry '#{raw["key"]}': unknown format #{declared.inspect}") unless Textus::Entry.formats.include?(declared)
74
+ raise UsageError.new("entry '#{raw["key"]}': unknown format #{declared.inspect}") unless Textus::Format.formats.include?(declared)
70
75
 
71
76
  if ext != "" && inferred && inferred != declared
72
77
  raise UsageError.new(
@@ -76,6 +81,18 @@ module Textus
76
81
 
77
82
  declared
78
83
  end
84
+
85
+ # Derives the manifest-relative path from key + kind + format.
86
+ # Key::Path.normalize_relative_path will prepend data/ at resolution time.
87
+ def self.derive_path(key, kind_sym, format)
88
+ dir_path = key.split(".").join("/")
89
+ return dir_path if kind_sym == :nested
90
+
91
+ ext = Textus::Format.for(format).extensions.first
92
+ "#{dir_path}#{ext}"
93
+ end
94
+
95
+ private_class_method :derive_path
79
96
  end
80
97
  end
81
98
  end
@@ -1,52 +1,32 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Entry
4
- # A produced entry (ADR 0095) anything with a `source:`. The produce
5
- # method (intake/derived/external) is read from source.from; there is no
6
- # separate kind for it. Merges the former Derived + Intake classes.
4
+ # A produced entry `kind: produced` with an optional `source:` block.
5
+ # When `source:` is present it must be `from: external` (out-of-band
6
+ # generator; textus detects drift but never runs it). When absent the
7
+ # entry is produced by a workflow file in .textus/workflows/.
7
8
  class Produced < Base
8
- attr_reader :source, :events
9
+ attr_reader :source
9
10
 
10
- def initialize(source:, events: {}, **rest)
11
+ def initialize(source:, **rest)
11
12
  super(**rest)
12
13
  @source = source
13
- @events = events || {}
14
14
  end
15
15
 
16
- def intake? = @source.kind == :intake
17
- def derived? = @source.kind == :derived
18
- def external? = @source.external?
19
- def projection? = @source.projection?
20
- def nested? = !!@raw["nested"]
21
- def handler = @source.handler
22
- def config = @source.config
16
+ def external? = @source&.external? || false
17
+ def nested? = !!@raw["nested"]
23
18
 
24
19
  KIND = :produced
25
20
 
26
- # ADR 0094/0095: projection (from: project) sources build their DATA
27
- # artifact here, then publish via the ONE shared mode (Publish::ToPaths).
28
- # Intake bytes come from Produce::Acquire::Intake and command (external) bytes from the
29
- # out-of-band runner — neither builds, but both still publish their
30
- # existing store bytes through the same mode. A projection entry with no
31
- # targets is a terminal data node: it produced data, so report :built
32
- # even though nothing was emitted.
21
+ # Publish existing store bytes via the shared publish mode (Publish::ToPaths
22
+ # or Publish::None). Workflow runners handle the produce step; this method
23
+ # only publishes whatever bytes are already on disk.
33
24
  def publish_via(pctx, prefix: nil)
34
- built = false
35
- if projection?
36
- Textus::Produce::Acquire::Projection.new(container: pctx.container, call: pctx.call).run(self)
37
- built = true
38
- pctx.emit(:entry_produced, key: @key, envelope: pctx.reader.call(@key), sources: Array(@source.select).compact)
39
- end
40
-
41
- emitted = publish_mode.publish(pctx, prefix: prefix)
42
- return emitted if emitted
43
- return nil unless built
44
-
45
- { kind: :built, value: { "key" => @key, "path" => Key::Path.resolve(pctx.manifest.data, self), "published_to" => [] } }
25
+ publish_mode.publish(pctx, prefix: prefix)
46
26
  end
47
27
 
48
28
  def self.from_raw(common, raw)
49
- new(source: Parser.parse_source(raw, common[:key]), events: raw["events"] || {}, **common)
29
+ new(source: Parser.parse_source(raw, common[:key]), **common)
50
30
  end
51
31
 
52
32
  Entry::REGISTRY[KIND] = self
@@ -36,7 +36,7 @@ module Textus
36
36
 
37
37
  # Store-side directory this entry's tree lives under.
38
38
  def store_base(pctx)
39
- File.join(pctx.root, "zones", entry.path)
39
+ File.join(pctx.root, Textus::Key::Path.normalize_relative_path(entry.path))
40
40
  end
41
41
  end
42
42
  end
@@ -16,6 +16,7 @@ module Textus
16
16
 
17
17
  def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument,Metrics/AbcSize
18
18
  targets = entry.publish_targets.select(&:to_target?)
19
+
19
20
  return nil if targets.empty?
20
21
 
21
22
  data_path = pctx.manifest.resolver.resolve(entry.key).path
@@ -25,11 +26,11 @@ module Textus
25
26
 
26
27
  targets.each do |t|
27
28
  if t.renders?
28
- content ||= Textus::Entry.for_format(entry.format).parse(File.read(data_path), path: data_path)["content"]
29
+ content ||= Textus::Format.for(entry.format).parse(File.read(data_path), path: data_path)["content"]
29
30
  publish_bytes(render_bytes(t, content, renderer, pctx), entry.key, t, pctx, data_path, envelope)
30
31
  elsif strip_meta?(entry)
31
- content ||= Textus::Entry.for_format(entry.format).parse(File.read(data_path), path: data_path)["content"]
32
- bytes = Textus::Entry.for_format(entry.format).serialize(meta: {}, body: "", content: content)
32
+ content ||= Textus::Format.for(entry.format).parse(File.read(data_path), path: data_path)["content"]
33
+ bytes = Textus::Format.for(entry.format).serialize(meta: {}, body: "", content: content)
33
34
  publish_bytes(bytes, entry.key, t, pctx, data_path, envelope)
34
35
  else
35
36
  # opaque / command / non-structured — publish the stored file as-is