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
@@ -1,21 +1,21 @@
1
1
  require "timeout"
2
2
 
3
3
  module Textus
4
- module Produce
4
+ module Pipeline
5
5
  module Acquire
6
6
  # Internal ingest executor for one machine-zone intake entry. No longer a
7
7
  # public verb (ADR 0079 collapsed the `fetch` surface): used by the
8
- # `reconcile` sweep and `textus hook run` only — ingest is system-pushed
8
+ # converge sweep (drain/serve) and `textus hook run` only — ingest is system-pushed
9
9
  # (ADR 0089 removed the read-through that once also drove it).
10
10
  class Intake
11
- FETCH_TIMEOUT_SECONDS = Textus::Produce::Acquire::Handler::FETCH_TIMEOUT_SECONDS
11
+ FETCH_TIMEOUT_SECONDS = Textus::Pipeline::Acquire::Handler::FETCH_TIMEOUT_SECONDS
12
12
 
13
13
  def initialize(container:, call:)
14
14
  @container = container
15
15
  @call = call
16
16
  @manifest = container.manifest
17
17
  @schemas = container.schemas
18
- @rpc = container.rpc
18
+ @steps = container.steps
19
19
  end
20
20
 
21
21
  # call(key) is the primary entry; run is kept as an alias for
@@ -61,10 +61,6 @@ module Textus
61
61
 
62
62
  private
63
63
 
64
- def fetch_events
65
- @fetch_events ||= Textus::Produce::Events.from(container: @container, call: @call)
66
- end
67
-
68
64
  # ADR 0079: a per-rule fetch_timeout_seconds override was an accepted loss
69
65
  # in the fetch:/retention: → lifecycle: collapse; the constant ceiling
70
66
  # applies to every intake.
@@ -72,36 +68,35 @@ module Textus
72
68
  FETCH_TIMEOUT_SECONDS
73
69
  end
74
70
 
71
+ def steps_ctx
72
+ @steps_ctx ||= Textus::Step::Context.for(container: @container, call: @call)
73
+ end
74
+
75
75
  def fetch_with_events(key, mentry, remaining)
76
- fetch_events.started(key)
76
+ @steps.publish(:entry_fetch_started, ctx: steps_ctx, key: key, mode: :sync)
77
77
  call_intake(key, mentry, remaining)
78
78
  end
79
79
 
80
80
  def call_intake(key, mentry, remaining)
81
- Textus::Produce::Acquire::Handler.invoke(
81
+ Textus::Pipeline::Acquire::Handler.invoke(
82
82
  caps: @container, handler: mentry.handler,
83
83
  config: mentry.config,
84
84
  args: { trigger_key: key, leaf_segments: remaining || [] },
85
85
  label: "intake", timeout: fetch_timeout_for(key)
86
86
  )
87
87
  rescue Textus::Error => e
88
- fetch_events.failed(key, e)
88
+ @steps.publish(:entry_fetch_failed, ctx: steps_ctx, key: key,
89
+ error_class: e.class.name, error_message: e.message)
89
90
  raise
90
91
  rescue StandardError => e
91
- fetch_events.failed(key, e)
92
+ @steps.publish(:entry_fetch_failed, ctx: steps_ctx, key: key,
93
+ error_class: e.class.name, error_message: e.message)
92
94
  raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
93
95
  end
94
96
 
95
97
  def persist_and_notify(key, mentry, result, before_etag)
96
98
  normalized = self.class.normalize_action_result(result, format: mentry.format)
97
- Textus::Domain::Policy::GuardFactory.new(
98
- manifest: @manifest, schemas: @schemas,
99
- ).for(:reconcile, key).check!(
100
- Textus::Domain::Policy::Evaluation.new(
101
- actor: @call.role, transition: :reconcile, origin: nil,
102
- target: key, envelope: nil, manifest: @manifest
103
- ),
104
- )
99
+ auth.check_action!(action: :converge, actor: @call.role, key: key)
105
100
  envelope = writer.put(
106
101
  key,
107
102
  mentry: mentry,
@@ -110,7 +105,10 @@ module Textus
110
105
  ),
111
106
  )
112
107
  change = detect_change(before_etag, envelope)
113
- fetch_events.fetched(key, envelope, change)
108
+ unless change == :unchanged
109
+ @steps.publish(:entry_fetched, ctx: steps_ctx, key: key,
110
+ envelope: envelope, change: change)
111
+ end
114
112
  envelope
115
113
  end
116
114
 
@@ -124,6 +122,10 @@ module Textus
124
122
  def writer
125
123
  @writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
126
124
  end
125
+
126
+ def auth
127
+ @auth ||= Textus::Gate::Auth.new(@container)
128
+ end
127
129
  end
128
130
  end
129
131
  end
@@ -1,7 +1,7 @@
1
1
  require "fileutils"
2
2
 
3
3
  module Textus
4
- module Produce
4
+ module Pipeline
5
5
  module Acquire
6
6
  # Builds an entry's DATA artifact (ADR 0094) by running the projection
7
7
  # pipeline; rendering is a publish concern. External entries are NOT built
@@ -34,13 +34,13 @@ module Textus
34
34
  end
35
35
  end
36
36
 
37
- Deps = Data.define(:manifest, :reader, :lister, :rpc, :transform_context)
37
+ Deps = Data.define(:manifest, :reader, :lister, :steps, :transform_context)
38
38
 
39
39
  def self.renderers
40
40
  @renderers ||= {
41
- "text" => Produce::Acquire::Serializer::Text,
42
- "json" => Produce::Acquire::Serializer::Json,
43
- "yaml" => Produce::Acquire::Serializer::Yaml,
41
+ "text" => Textus::Pipeline::Acquire::Serializer::Text,
42
+ "json" => Textus::Pipeline::Acquire::Serializer::Json,
43
+ "yaml" => Textus::Pipeline::Acquire::Serializer::Yaml,
44
44
  }
45
45
  end
46
46
 
@@ -49,14 +49,16 @@ module Textus
49
49
  @call = call
50
50
  @manifest = container.manifest
51
51
  @file_store = container.file_store
52
- @rpc = container.rpc
52
+ @steps = container.steps
53
53
  @root = container.root
54
54
  end
55
55
 
56
56
  # Runs the projection pipeline for `mentry` and returns the on-disk
57
57
  # target_path string.
58
58
  def run(mentry)
59
- reader = Textus::Read::Get.new(container: @container, call: @call)
59
+ reader = lambda do |key|
60
+ Textus::Action::Get.new(key: key).call(container: @container, call: @call)
61
+ end
60
62
  # Projections must be able to read source data from any nested entry,
61
63
  # including keyless (publish_tree) ones like knowledge.decisions.
62
64
  # The `include_keyless: true` option makes the resolver walk those dirs
@@ -64,15 +66,15 @@ module Textus
64
66
  resolver = @manifest.resolver
65
67
  lister = lambda do |prefix:|
66
68
  resolver.enumerate(prefix: prefix, include_keyless: true)
67
- .map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
69
+ .map { |row| { "key" => row[:key], "lane" => row[:manifest_entry].lane, "path" => row[:path] } }
68
70
  end
69
71
  self.class.pipeline_run(
70
72
  mentry: mentry,
71
73
  deps: Deps.new(
72
74
  manifest: @manifest,
73
- reader: reader.method(:call),
75
+ reader: reader,
74
76
  lister: lister,
75
- rpc: @rpc,
77
+ steps: @steps,
76
78
  transform_context: @container,
77
79
  ),
78
80
  )
@@ -95,7 +97,7 @@ module Textus
95
97
  reader: deps.reader,
96
98
  spec: mentry.source.projection_spec,
97
99
  lister: deps.lister,
98
- rpc: deps.rpc,
100
+ steps: deps.steps,
99
101
  transform_context: deps.transform_context,
100
102
  ).run
101
103
 
@@ -1,13 +1,13 @@
1
1
  require "json"
2
2
 
3
3
  module Textus
4
- module Produce
4
+ module Pipeline
5
5
  module Acquire
6
6
  class Serializer
7
7
  class Json < Serializer
8
8
  def call(mentry:, data:)
9
9
  content = default_shape(mentry, data)
10
- final = Produce::Acquire::Projection::InjectMeta.call(content, mentry)
10
+ final = Textus::Pipeline::Acquire::Projection::InjectMeta.call(content, mentry)
11
11
  Entry.for_format("json").serialize(meta: {}, body: "", content: final)
12
12
  end
13
13
 
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Produce
2
+ module Pipeline
3
3
  module Acquire
4
4
  class Serializer
5
5
  class Text < Serializer
@@ -1,13 +1,13 @@
1
1
  require "yaml"
2
2
 
3
3
  module Textus
4
- module Produce
4
+ module Pipeline
5
5
  module Acquire
6
6
  class Serializer
7
7
  class Yaml < Serializer
8
8
  def call(mentry:, data:)
9
9
  content = default_shape(mentry, data)
10
- final = Produce::Acquire::Projection::InjectMeta.call(content, mentry)
10
+ final = Textus::Pipeline::Acquire::Projection::InjectMeta.call(content, mentry)
11
11
  Entry.for_format("yaml").serialize(meta: {}, body: "", content: final)
12
12
  end
13
13
 
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Produce
2
+ module Pipeline
3
3
  module Acquire
4
4
  # Abstract base for output serializers. Each concrete serializer owns
5
5
  # producing the bytes for one manifest format (json/yaml/text).
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Produce
2
+ module Pipeline
3
3
  # The single convergence engine (ADR 0093/0094). "Make these machine entries
4
4
  # current from upstream." Acquire is per-`from`; publish is one uniform
5
5
  # `publish_via` entry point for all kinds (ADR 0094):
@@ -8,17 +8,16 @@ module Textus
8
8
  # derived (from: command) -> skip the build; publish_via publishes
9
9
  # existing store bytes via mode resolution
10
10
  # (None when no targets -> skipped)
11
- # Runs as the reconcile build actor (self-elevating); the passed `call`
11
+ # Runs as the converge build actor (self-elevating); the passed `call`
12
12
  # supplies only correlation_id/dry_run. Callers choose the key set: the
13
- # write subscriber passes rdeps ∩ derived; reconcile passes
13
+ # write subscriber passes rdeps ∩ derived; the converge pass passes
14
14
  # all-derived + stale-intake.
15
15
  class Engine
16
- # Locked + failure-isolated convergence — the shared entry point for the
17
- # write trigger (ADR 0093). Both the sync path (inline, in the subscriber)
18
- # and the async path (AsyncRunner) call this. A held lock is a soft miss
19
- # (an in-flight build/reconcile already produces fresh output); any other
20
- # error is republished as :produce_failed and never raised at the
21
- # writer (ADR 0087 §5 failure isolation, preserved).
16
+ # Locked + failure-isolated convergence — the entry point worker handlers
17
+ # call to materialize a key set (ADR 0093 / job-queue model). A held lock
18
+ # is a soft miss (an in-flight build/converge already produces fresh
19
+ # output); any other error is republished as :produce_failed and never
20
+ # raised at the caller (ADR 0087 §5 failure isolation, preserved).
22
21
  def self.converge(container:, call:, keys:)
23
22
  Textus::Ports::BuildLock.with(root: container.root) do
24
23
  new(container: container, call: call).call(keys: keys)
@@ -26,9 +25,9 @@ module Textus
26
25
  rescue Textus::BuildInProgress
27
26
  nil
28
27
  rescue Textus::Error => e
29
- container.events.publish(
28
+ container.steps.publish(
30
29
  :produce_failed,
31
- ctx: Textus::Hooks::Context.for(container: container, call: call),
30
+ ctx: Textus::Step::Context.for(container: container, call: call),
32
31
  keys: keys, error: e.message
33
32
  )
34
33
  end
@@ -63,7 +62,7 @@ module Textus
63
62
  entry = @manifest.resolver.resolve(key).entry
64
63
 
65
64
  if entry.intake?
66
- Textus::Produce::Acquire::Intake.new(container: @container, call: build_call).run(key) # acquire: re-pull
65
+ Textus::Pipeline::Acquire::Intake.new(container: @container, call: build_call).run(key) # acquire: re-pull
67
66
  entry.publish_via(context) # emit any targets
68
67
  out[:produced] << key # a fetch is production
69
68
  else
@@ -73,10 +72,10 @@ module Textus
73
72
  end
74
73
 
75
74
  def build_actor_call
76
- build_role = @manifest.policy.actor_for("reconcile") or
75
+ build_role = @manifest.policy.actor_for("converge") or
77
76
  raise Textus::UsageError.new(
78
- "no role holds the 'reconcile' capability",
79
- hint: "declare a role with `can: [reconcile]` in .textus/manifest.yaml",
77
+ "no role holds the 'converge' capability",
78
+ hint: "declare a role with `can: [converge]` in .textus/manifest.yaml",
80
79
  )
81
80
  Textus::Call.build(
82
81
  role: build_role,
@@ -88,56 +87,11 @@ module Textus
88
87
  def build_context(call)
89
88
  Textus::Manifest::Entry::Base::PublishContext.new(
90
89
  container: @container, call: call,
91
- reader: Textus::Read::Get.new(container: @container, call: call)
90
+ reader: lambda { |key|
91
+ Textus::Action::Get.new(key: key).call(container: @container, call: call)
92
+ }
92
93
  )
93
94
  end
94
-
95
- # In-process deferral for the async write trigger (ADR 0087/0093).
96
- # Spawns a tracked thread that runs Produce.converge after the write
97
- # returns; a one-time at_exit joins
98
- # all pending threads so a short-lived CLI process cannot exit before an
99
- # async rebuild completes. The write itself never blocks.
100
- module AsyncRunner
101
- @mutex = Mutex.new
102
- @threads = []
103
- @hooked = false
104
-
105
- class << self
106
- def enqueue(container:, call:, keys:)
107
- thread = Thread.new { Textus::Produce::Engine.converge(container: container, call: call, keys: keys) }
108
- track(thread)
109
- thread
110
- end
111
-
112
- # Block until every spawned async rebuild has finished. Idempotent;
113
- # safe to call from at_exit and directly from tests.
114
- def drain
115
- pending = @mutex.synchronize { @threads.dup }
116
- pending.each(&:join)
117
- @mutex.synchronize { @threads.delete_if { |t| !t.alive? } }
118
- nil
119
- end
120
-
121
- private
122
-
123
- def track(thread)
124
- @mutex.synchronize do
125
- @threads.delete_if { |t| !t.alive? }
126
- @threads << thread
127
- install_drain_hook
128
- end
129
- end
130
-
131
- # Register the join-before-exit hook exactly once. Guarded by the
132
- # caller holding @mutex.
133
- def install_drain_hook
134
- return if @hooked
135
-
136
- @hooked = true
137
- at_exit { drain }
138
- end
139
- end
140
- end
141
95
  end
142
96
  end
143
97
  end
@@ -1,5 +1,7 @@
1
+ require "mustache"
2
+
1
3
  module Textus
2
- module Produce
4
+ module Pipeline
3
5
  # Renders an entry's stored DATA into the bytes for one publish target
4
6
  # (ADR 0094). Relocates the Mustache logic that used to live in the
5
7
  # build-time Markdown renderer. Provenance is NOT added here — it lives in
@@ -78,16 +78,42 @@ module Textus
78
78
  def verify_integrity
79
79
  return [] unless File.exist?(@path)
80
80
 
81
- out = []
82
- File.foreach(@path).with_index(1) do |line, lineno|
83
- violation = check_line(line.chomp, lineno)
84
- out << violation if violation
81
+ [].tap do |out|
82
+ iterate_with_prev_seq do |line, lineno, prev_seq|
83
+ check_line_integrity(line, lineno, prev_seq, out)
84
+ end
85
85
  end
86
- out
87
86
  end
88
87
 
89
88
  private
90
89
 
90
+ def iterate_with_prev_seq
91
+ prev_seq = nil
92
+ File.foreach(@path).with_index(1) do |line, lineno|
93
+ yield line.chomp, lineno, prev_seq
94
+ parsed = parse_row(line.chomp)
95
+ prev_seq = parsed["seq"] if parsed&.dig("seq").is_a?(Integer)
96
+ end
97
+ end
98
+
99
+ def check_line_integrity(line, lineno, prev_seq, out)
100
+ violation = check_line(line, lineno)
101
+ if violation
102
+ out << violation
103
+ return
104
+ end
105
+
106
+ parsed = parse_row(line)
107
+ return unless parsed && (seq = parsed["seq"]).is_a?(Integer)
108
+ return unless prev_seq && seq != prev_seq + 1
109
+
110
+ out << {
111
+ "lineno" => lineno,
112
+ "reason" => "seq_gap",
113
+ "detail" => "expected #{prev_seq + 1}, got #{seq}",
114
+ }
115
+ end
116
+
91
117
  def rotated(n)
92
118
  File.join(Textus::Layout.audit_dir(@root), "audit.log.#{n}")
93
119
  end
@@ -3,9 +3,9 @@
3
3
  module Textus
4
4
  module Ports
5
5
  # Writes an "event_error" audit row when a user hook raises during
6
- # Hooks::EventBus publish. Attached at Store boot.
6
+ # Step::EventBus publish. Attached at Store boot.
7
7
  #
8
- # Integration: uses Hooks::EventBus#on_error callback (chosen over a
8
+ # Integration: uses Step::EventBus#on_error callback (chosen over a
9
9
  # synthetic :hook_error event because the bus already owns the
10
10
  # rescue and the failure is a bus-internal concern, not a domain
11
11
  # event subscribers should be able to filter by key glob).
@@ -19,8 +19,8 @@ module Textus
19
19
  @audit_log = audit_log
20
20
  end
21
21
 
22
- def attach(bus)
23
- bus.on_error do |event:, hook:, key:, kwargs:, error:|
22
+ def attach(registry)
23
+ registry.on_error do |event:, hook:, key:, kwargs:, error:|
24
24
  record_error(event: event, hook: hook, key: key, kwargs: kwargs, error: error)
25
25
  end
26
26
  self
@@ -5,7 +5,7 @@ require "time"
5
5
  module Textus
6
6
  module Ports
7
7
  # Cross-process build lock: a pid/host-stamped lockfile under the store root
8
- # that serializes reconcile's produce/sweep. An instantiable class — it holds
8
+ # that serializes converge's produce/sweep. An instantiable class — it holds
9
9
  # the root and lock state; `self.with(root:)` is a convenience that constructs
10
10
  # one and runs the block under the held lock. It already satisfied ADR 0109's
11
11
  # single-shape rule (every port is an instantiable class) before that ADR's
@@ -0,0 +1,65 @@
1
+ require "digest"
2
+ require "json"
3
+
4
+ module Textus
5
+ module Ports
6
+ class Queue
7
+ # A unit of deferred work. Pure data. The id is `<type>:<digest>` where the
8
+ # digest is over the args with sorted keys, so logically-identical enqueues
9
+ # collide on the same id — which is how Queue dedups (file already exists).
10
+ # At-least-once delivery means handlers must be idempotent.
11
+ class Job
12
+ DIGEST_BYTES = 16
13
+
14
+ attr_reader :type, :args, :enqueued_by, :max_attempts
15
+ attr_accessor :attempts, :last_error
16
+
17
+ def initialize(type:, args:, enqueued_by: nil, attempts: 0, max_attempts: 3, last_error: nil)
18
+ @type = type.to_s
19
+ @args = stringify(args)
20
+ @enqueued_by = enqueued_by
21
+ @attempts = attempts
22
+ @max_attempts = max_attempts
23
+ @last_error = last_error
24
+ end
25
+
26
+ def id
27
+ "#{@type}:#{digest}"
28
+ end
29
+
30
+ def to_h
31
+ {
32
+ "type" => @type,
33
+ "args" => @args,
34
+ "enqueued_by" => @enqueued_by,
35
+ "attempts" => @attempts,
36
+ "max_attempts" => @max_attempts,
37
+ "last_error" => @last_error,
38
+ }
39
+ end
40
+
41
+ def self.from_h(hash)
42
+ new(
43
+ type: hash["type"],
44
+ args: hash["args"] || {},
45
+ enqueued_by: hash["enqueued_by"],
46
+ attempts: hash["attempts"] || 0,
47
+ max_attempts: hash["max_attempts"] || 3,
48
+ last_error: hash["last_error"],
49
+ )
50
+ end
51
+
52
+ private
53
+
54
+ def digest
55
+ canonical = JSON.dump(@args.sort.to_h)
56
+ Digest::SHA256.hexdigest(canonical)[0, DIGEST_BYTES]
57
+ end
58
+
59
+ def stringify(hash)
60
+ hash.transform_keys(&:to_s)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,130 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require "time"
4
+
5
+ module Textus
6
+ module Ports
7
+ # File-backed durable job queue under `<root>/.run/queue/`. Each job state
8
+ # is a directory; a job is one `<id>.json` file. Claiming is an atomic
9
+ # `rename(2)` from ready/ to leased/ — the rename winner owns the job, so a
10
+ # worker pool needs no central lock. Dedup falls out of the id-as-filename:
11
+ # enqueueing an id that already exists is a no-op. ADR 0038 (runtime subtree),
12
+ # ADR 0108 (instantiable port).
13
+ class Queue
14
+ STATES = %i[ready leased done failed].freeze
15
+
16
+ def initialize(root:)
17
+ @root = root
18
+ STATES.each { |s| FileUtils.mkdir_p(Textus::Layout.queue_state(root, s)) }
19
+ end
20
+
21
+ def enqueue(job)
22
+ dest = path(:ready, job.id)
23
+ return if File.exist?(dest) # dedup: identical work already queued
24
+
25
+ write_atomic(dest, job.to_h)
26
+ end
27
+
28
+ def ready_ids
29
+ Dir.children(Textus::Layout.queue_state(@root, :ready)).map { |f| File.basename(f, ".json") }
30
+ end
31
+
32
+ # A claimed job plus the path it lives at, so ack/fail act on this copy.
33
+ Leased = Struct.new(:job, :leased_path, keyword_init: true)
34
+
35
+ def lease(worker_id:, lease_ttl:)
36
+ ready_dir = Textus::Layout.queue_state(@root, :ready)
37
+ Dir.children(ready_dir).each do |name|
38
+ src = File.join(ready_dir, name)
39
+ dst = File.join(Textus::Layout.queue_state(@root, :leased), name)
40
+ begin
41
+ File.rename(src, dst) # atomic claim; loser's rename raises ENOENT
42
+ rescue Errno::ENOENT
43
+ next # another worker won this one
44
+ end
45
+ job = Job.from_h(JSON.parse(File.read(dst)))
46
+ stamp_lease(dst, worker_id: worker_id, expires_at: Time.now.utc + lease_ttl)
47
+ return Leased.new(job: job, leased_path: dst)
48
+ end
49
+ nil
50
+ end
51
+
52
+ def ack(leased)
53
+ dest = File.join(Textus::Layout.queue_state(@root, :done), File.basename(leased.leased_path))
54
+ File.rename(leased.leased_path, dest)
55
+ end
56
+
57
+ # Increment attempts and either requeue (transient) or dead-letter (attempts
58
+ # exhausted). Returns :requeued or :dead_lettered so the worker can count
59
+ # terminal failures distinctly from retries.
60
+ def fail(leased, error:)
61
+ job = leased.job
62
+ job.attempts += 1
63
+ job.last_error = error
64
+ dead = job.attempts >= job.max_attempts
65
+ write_atomic(path(dead ? :failed : :ready, job.id), job.to_h)
66
+ File.delete(leased.leased_path)
67
+ dead ? :dead_lettered : :requeued
68
+ end
69
+
70
+ # Return expired leases to ready/ (the holding worker crashed). Returns the
71
+ # count reclaimed. At-least-once delivery: a job whose handler actually
72
+ # finished but whose ack was lost will re-run — handlers must be idempotent.
73
+ def reclaim(now:)
74
+ leased_dir = Textus::Layout.queue_state(@root, :leased)
75
+ count = 0
76
+ Dir.children(leased_dir).each do |name|
77
+ src = File.join(leased_dir, name)
78
+ data = JSON.parse(File.read(src))
79
+ expires = data.dig("lease", "expires_at")
80
+ next if expires && Time.parse(expires) > now
81
+
82
+ dst = File.join(Textus::Layout.queue_state(@root, :ready), name)
83
+ data.delete("lease")
84
+ File.write(src, JSON.pretty_generate(data))
85
+ File.rename(src, dst)
86
+ count += 1
87
+ rescue Errno::ENOENT
88
+ next # raced with another reclaimer / the worker's ack
89
+ end
90
+ count
91
+ end
92
+
93
+ def list(state)
94
+ Dir.children(Textus::Layout.queue_state(@root, state.to_sym)).map { |f| File.basename(f, ".json") }
95
+ end
96
+
97
+ def retry_failed(job_id)
98
+ src = path(:failed, job_id)
99
+ data = JSON.parse(File.read(src))
100
+ data["attempts"] = 0
101
+ data["last_error"] = nil
102
+ write_atomic(path(:ready, job_id), data)
103
+ File.delete(src)
104
+ end
105
+
106
+ def purge(state)
107
+ dir = Textus::Layout.queue_state(@root, state.to_sym)
108
+ Dir.children(dir).each { |f| File.delete(File.join(dir, f)) }
109
+ end
110
+
111
+ private
112
+
113
+ def stamp_lease(leased_path, worker_id:, expires_at:)
114
+ data = JSON.parse(File.read(leased_path))
115
+ data["lease"] = { "worker_id" => worker_id, "expires_at" => expires_at.iso8601 }
116
+ File.write(leased_path, JSON.pretty_generate(data))
117
+ end
118
+
119
+ def path(state, job_id)
120
+ File.join(Textus::Layout.queue_state(@root, state), "#{job_id}.json")
121
+ end
122
+
123
+ def write_atomic(dest, hash)
124
+ tmp = "#{dest}.#{Process.pid}.tmp"
125
+ File.write(tmp, JSON.pretty_generate(hash))
126
+ File.rename(tmp, dest) # atomic on same filesystem
127
+ end
128
+ end
129
+ end
130
+ end
@@ -7,7 +7,7 @@ module Textus
7
7
  # Persistence adapter for sentinel files. Owns the on-disk JSON shape, the
8
8
  # path layout (<store_root>/.run/sentinels/<target-rel-to-repo>.textus-managed.json
9
9
  # — runtime, git-ignored, ADR 0070), and all File/FileUtils I/O.
10
- # Domain::Sentinel is a pure value object that depends on this port for
10
+ # Core::Sentinel is a pure value object that depends on this port for
11
11
  # reads and writes.
12
12
  class SentinelStore
13
13
  SUFFIX = ".textus-managed.json".freeze
@@ -26,7 +26,7 @@ module Textus
26
26
 
27
27
  def load(path, repo_root)
28
28
  raw = JSON.parse(File.read(path))
29
- Textus::Domain::Sentinel.new(
29
+ Textus::Core::Sentinel.new(
30
30
  target: absolutize(raw["target"], repo_root),
31
31
  source: absolutize(raw["source"], repo_root),
32
32
  sha256: raw["sha256"],