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
@@ -2,12 +2,12 @@ require "digest"
2
2
  require "json"
3
3
 
4
4
  module Textus
5
- module Domain
6
- module Jobs
5
+ module Ports
6
+ class JobStore
7
7
  # A unit of deferred work. Pure data. The id is `<type>:<digest>` where the
8
8
  # digest is over the args with sorted keys, so logically-identical enqueues
9
- # collide on the same id — which is how the Queue dedups (the file already
10
- # exists). At-least-once delivery means handlers must be idempotent.
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
11
  class Job
12
12
  DIGEST_BYTES = 16
13
13
 
@@ -15,10 +15,10 @@ module Textus
15
15
  attr_accessor :attempts, :last_error
16
16
 
17
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)
18
+ @type = type.to_s
19
+ @args = stringify(args)
20
20
  @enqueued_by = enqueued_by
21
- @attempts = attempts
21
+ @attempts = attempts
22
22
  @max_attempts = max_attempts
23
23
  @last_error = last_error
24
24
  end
@@ -29,16 +29,23 @@ module Textus
29
29
 
30
30
  def to_h
31
31
  {
32
- "type" => @type, "args" => @args, "enqueued_by" => @enqueued_by,
33
- "attempts" => @attempts, "max_attempts" => @max_attempts, "last_error" => @last_error
32
+ "type" => @type,
33
+ "args" => @args,
34
+ "enqueued_by" => @enqueued_by,
35
+ "attempts" => @attempts,
36
+ "max_attempts" => @max_attempts,
37
+ "last_error" => @last_error,
34
38
  }
35
39
  end
36
40
 
37
41
  def self.from_h(hash)
38
42
  new(
39
- type: hash["type"], args: hash["args"] || {}, enqueued_by: hash["enqueued_by"],
40
- attempts: hash["attempts"] || 0, max_attempts: hash["max_attempts"] || 3,
41
- last_error: hash["last_error"]
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"],
42
49
  )
43
50
  end
44
51
 
@@ -10,7 +10,7 @@ module Textus
10
10
  # worker pool needs no central lock. Dedup falls out of the id-as-filename:
11
11
  # enqueueing an id that already exists is a no-op. ADR 0038 (runtime subtree),
12
12
  # ADR 0108 (instantiable port).
13
- class Queue
13
+ class JobStore
14
14
  STATES = %i[ready leased done failed].freeze
15
15
 
16
16
  def initialize(root:)
@@ -20,7 +20,7 @@ module Textus
20
20
 
21
21
  def enqueue(job)
22
22
  dest = path(:ready, job.id)
23
- return if File.exist?(dest) # dedup: identical work already queued
23
+ return if File.exist?(dest)
24
24
 
25
25
  write_atomic(dest, job.to_h)
26
26
  end
@@ -29,7 +29,6 @@ module Textus
29
29
  Dir.children(Textus::Layout.queue_state(@root, :ready)).map { |f| File.basename(f, ".json") }
30
30
  end
31
31
 
32
- # A claimed job plus the path it lives at, so ack/fail act on this copy.
33
32
  Leased = Struct.new(:job, :leased_path, keyword_init: true)
34
33
 
35
34
  def lease(worker_id:, lease_ttl:)
@@ -38,11 +37,11 @@ module Textus
38
37
  src = File.join(ready_dir, name)
39
38
  dst = File.join(Textus::Layout.queue_state(@root, :leased), name)
40
39
  begin
41
- File.rename(src, dst) # atomic claim; loser's rename raises ENOENT
40
+ File.rename(src, dst)
42
41
  rescue Errno::ENOENT
43
- next # another worker won this one
42
+ next
44
43
  end
45
- job = Textus::Domain::Jobs::Job.from_h(JSON.parse(File.read(dst)))
44
+ job = Job.from_h(JSON.parse(File.read(dst)))
46
45
  stamp_lease(dst, worker_id: worker_id, expires_at: Time.now.utc + lease_ttl)
47
46
  return Leased.new(job: job, leased_path: dst)
48
47
  end
@@ -54,9 +53,6 @@ module Textus
54
53
  File.rename(leased.leased_path, dest)
55
54
  end
56
55
 
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
56
  def fail(leased, error:)
61
57
  job = leased.job
62
58
  job.attempts += 1
@@ -67,9 +63,6 @@ module Textus
67
63
  dead ? :dead_lettered : :requeued
68
64
  end
69
65
 
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
66
  def reclaim(now:)
74
67
  leased_dir = Textus::Layout.queue_state(@root, :leased)
75
68
  count = 0
@@ -85,7 +78,7 @@ module Textus
85
78
  File.rename(src, dst)
86
79
  count += 1
87
80
  rescue Errno::ENOENT
88
- next # raced with another reclaimer / the worker's ack
81
+ next
89
82
  end
90
83
  count
91
84
  end
@@ -123,7 +116,7 @@ module Textus
123
116
  def write_atomic(dest, hash)
124
117
  tmp = "#{dest}.#{Process.pid}.tmp"
125
118
  File.write(tmp, JSON.pretty_generate(hash))
126
- File.rename(tmp, dest) # atomic on same filesystem
119
+ File.rename(tmp, dest)
127
120
  end
128
121
  end
129
122
  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"],
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "socket"
4
+
5
+ module Textus
6
+ module Ports
7
+ # Flock-based watcher presence lock. Held for the watcher's lifetime.
8
+ # Process death releases the flock automatically.
9
+ class WatcherLock
10
+ def initialize(root)
11
+ @path = Textus::Layout.watcher_lock(root)
12
+ @file = nil
13
+ FileUtils.mkdir_p(File.dirname(@path))
14
+ end
15
+
16
+ def self.running?(root)
17
+ path = Textus::Layout.watcher_lock(root)
18
+ return false unless File.exist?(path)
19
+
20
+ File.open(path, "r+") do |file|
21
+ got = file.flock(File::LOCK_EX | File::LOCK_NB)
22
+ file.flock(File::LOCK_UN) if got
23
+ return !got
24
+ end
25
+ rescue Errno::ENOENT
26
+ false
27
+ end
28
+
29
+ def acquire
30
+ @file = File.open(@path, File::RDWR | File::CREAT, 0o644)
31
+ raise "watcher already running" unless @file.flock(File::LOCK_EX | File::LOCK_NB)
32
+
33
+ @file.write("pid=#{Process.pid} host=#{Socket.gethostname}\n")
34
+ @file.flush
35
+ self
36
+ end
37
+
38
+ def release
39
+ return unless @file
40
+
41
+ @file.flock(File::LOCK_UN)
42
+ @file.close
43
+ FileUtils.rm_f(@path)
44
+ @file = nil
45
+ end
46
+ end
47
+ end
48
+ end
@@ -1,94 +1,49 @@
1
1
  module Textus
2
2
  module Produce
3
- # The single convergence engine (ADR 0093/0094). "Make these machine entries
4
- # current from upstream." Acquire is per-`from`; publish is one uniform
5
- # `publish_via` entry point for all kinds (ADR 0094):
6
- # intake (from: handler) -> re-pull (Produce::Acquire::Intake), then publish_via
7
- # derived (from: project) -> build data + publish_via (ToPaths or None)
8
- # derived (from: command) -> skip the build; publish_via publishes
9
- # existing store bytes via mode resolution
10
- # (None when no targets -> skipped)
11
- # Runs as the converge build actor (self-elevating); the passed `call`
12
- # supplies only correlation_id/dry_run. Callers choose the key set: the
13
- # write subscriber passes rdeps ∩ derived; the converge pass passes
14
- # all-derived + stale-intake.
15
3
  class Engine
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).
21
4
  def self.converge(container:, call:, keys:)
22
- Textus::Ports::BuildLock.with(root: container.root) do
23
- new(container: container, call: call).call(keys: keys)
24
- end
25
- rescue Textus::BuildInProgress
26
- nil
27
- rescue Textus::Error => e
28
- container.events.publish(
29
- :produce_failed,
30
- ctx: Textus::Hooks::Context.for(container: container, call: call),
31
- keys: keys, error: e.message
32
- )
5
+ new(container:, call:).run(keys)
33
6
  end
34
7
 
35
8
  def initialize(container:, call:)
36
9
  @container = container
37
10
  @call = call
38
- @manifest = container.manifest
39
11
  end
40
12
 
41
- # keys: the machine entry keys to converge. Returns
42
- # { produced: [k...], skipped: [k...], failed: [{ "key"=>, "error"=> }...] }
43
- def call(keys:)
44
- build_call = build_actor_call
45
- context = build_context(build_call)
46
- out = { produced: [], skipped: [], failed: [] }
47
-
48
- keys.each do |key|
49
- produce_one(key, build_call, context, out)
50
- rescue Textus::Error => e
51
- out[:failed] << { "key" => key, "error" => e.message }
52
- end
53
- out
13
+ def run(keys)
14
+ results = { completed: [], failed: [] }
15
+ Array(keys).each { |key| produce_one(key, results) }
16
+ results
54
17
  end
55
18
 
56
- private
19
+ alias call run
57
20
 
58
- # Acquire is per-`from`; publish is one uniform entry point (publish_via)
59
- # for every kind. The command emit-vs-skip falls out of publish-mode
60
- # resolution (Publish::None when no targets), so there is no command branch.
61
- def produce_one(key, build_call, context, out)
62
- entry = @manifest.resolver.resolve(key).entry
21
+ private
63
22
 
64
- if entry.intake?
65
- Textus::Produce::Acquire::Intake.new(container: @container, call: build_call).run(key) # acquire: re-pull
66
- entry.publish_via(context) # emit any targets
67
- out[:produced] << key # a fetch is production
23
+ def produce_one(key, results)
24
+ workflow = @container.workflows.for(key)
25
+ if workflow
26
+ Workflow::Runner.new(workflow, container: @container, call: @call).run(key)
68
27
  else
69
- result = entry.publish_via(context) # derived builds inside; command publishes-or-None
70
- result.nil? ? (out[:skipped] << key) : (out[:produced] << key)
28
+ publish_only(key)
71
29
  end
30
+ results[:completed] << key
31
+ rescue StandardError => e
32
+ results[:failed] << { key: key, error: e.message }
72
33
  end
73
34
 
74
- def build_actor_call
75
- build_role = @manifest.policy.actor_for("converge") or
76
- raise Textus::UsageError.new(
77
- "no role holds the 'converge' capability",
78
- hint: "declare a role with `can: [converge]` in .textus/manifest.yaml",
79
- )
80
- Textus::Call.build(
81
- role: build_role,
82
- correlation_id: @call.correlation_id,
83
- dry_run: @call.dry_run,
84
- )
85
- end
35
+ def publish_only(key)
36
+ entry = @container.manifest.resolver.resolve(key).entry
37
+ return unless entry.publish_tree || !Array(entry.publish_to).empty?
38
+
39
+ reader = Textus::Envelope::Reader.from(container: @container)
40
+ entry_path = @container.manifest.resolver.resolve(key).path
41
+ return unless entry.publish_tree || File.exist?(entry_path)
86
42
 
87
- def build_context(call)
88
- Textus::Manifest::Entry::Base::PublishContext.new(
89
- container: @container, call: call,
90
- reader: Textus::Read::Get.new(container: @container, call: call)
43
+ pctx = Textus::Manifest::Entry::Base::PublishContext.new(
44
+ container: @container, call: @call, reader: reader.method(:read),
91
45
  )
46
+ entry.publish_via(pctx)
92
47
  end
93
48
  end
94
49
  end
@@ -1,17 +1,12 @@
1
+ require "mustache"
2
+
1
3
  module Textus
2
4
  module Produce
3
- # Renders an entry's stored DATA into the bytes for one publish target
4
- # (ADR 0094). Relocates the Mustache logic that used to live in the
5
- # build-time Markdown renderer. Provenance is NOT added here — it lives in
6
- # the data's `_meta`; a template surfaces it if the output should show it.
7
- # A verbatim target (no template) is the caller's job to copy.
8
5
  class Render
9
6
  def initialize(template_loader:)
10
7
  @template_loader = template_loader
11
8
  end
12
9
 
13
- # target: a rendering Policy::PublishTarget. data: parsed entry data.
14
- # boot: boot context hash or nil. Returns the rendered String.
15
10
  def bytes_for(target:, data:, boot:)
16
11
  raise ArgumentError.new("Produce::Render called for a verbatim target #{target.to.inspect}") unless target.renders?
17
12
 
@@ -84,10 +84,11 @@ module Textus
84
84
  # Orchestrator-free read: schema tooling must never trigger a fetch
85
85
  # while inspecting/migrating entries (ADR 0062).
86
86
  def self.pure_get(store, role, key)
87
- Textus::Read::Get.new(
88
- container: store.as(role).container,
87
+ scope = store.as(role)
88
+ Textus::Action::Get.new(key: key).call(
89
+ container: scope.container,
89
90
  call: Textus::Call.build(role: role),
90
- ).call(key)
91
+ )
91
92
  end
92
93
 
93
94
  def self.load_schema(store, name)
@@ -1,15 +1,18 @@
1
1
  module Textus
2
2
  # The agent session: per-connection (MCP), per-process (CLI), or per-loop
3
3
  # (Ruby) orientation state — the audit cursor plus the contract etag and
4
- # propose_zone captured at boot. Immutable Data value; advance_cursor
4
+ # propose_lane captured at boot. Immutable Data value; advance_cursor
5
5
  # returns a new instance. ADR 0036; contract_etag widened in ADR 0074.
6
- Session = Data.define(:role, :cursor, :propose_zone, :contract_etag) do
6
+ Session = Data.define(:role, :cursor, :propose_lane, :contract_etag) do
7
+ # Back-compat reader while lane terminology migrates.
8
+ def propose_zone = propose_lane
9
+
7
10
  def advance_cursor(new_cursor) = with(cursor: new_cursor)
8
11
 
9
12
  def check_etag!(observed_etag)
10
13
  return if observed_etag == contract_etag
11
14
 
12
- raise Textus::MCP::ContractDrift.new(
15
+ raise Textus::ContractDrift.new(
13
16
  "contract changed (manifest/hooks/schemas were #{short_etag(contract_etag)}, " \
14
17
  "now #{short_etag(observed_etag)}); re-run boot",
15
18
  )
data/lib/textus/store.rb CHANGED
@@ -44,8 +44,6 @@ module Textus
44
44
 
45
45
  def initialize(root)
46
46
  @container = build_container(File.expand_path(root))
47
- bootstrap_hooks
48
- events.publish(:store_loaded, ctx: Hooks::Context.new(scope: as(Role::DEFAULT)))
49
47
  end
50
48
 
51
49
  # Build an agent Session oriented at the current cursor/manifest — the
@@ -54,26 +52,24 @@ module Textus
54
52
  Textus::Session.new(
55
53
  role: role,
56
54
  cursor: audit_log.latest_seq,
57
- propose_zone: manifest.policy.propose_zone_for(role),
55
+ propose_lane: manifest.policy.propose_lane_for(role),
58
56
  contract_etag: Textus::Etag.for_contract(root),
59
57
  )
60
58
  end
61
59
 
62
- def as(role, dry_run: false, correlation_id: nil)
63
- RoleScope.new(container: container, role: role, dry_run: dry_run, correlation_id: correlation_id)
60
+ def gate
61
+ @container.gate
64
62
  end
65
63
 
66
- Textus::Dispatcher::VERBS.each_key do |verb|
67
- define_method(verb) do |*args, role: Role::DEFAULT, **kwargs|
68
- as(role).public_send(verb, *args, **kwargs)
69
- end
64
+ def as(role, dry_run: false, correlation_id: nil)
65
+ Textus::Surfaces::RoleScope.new(container: container, role: role, dry_run: dry_run, correlation_id: correlation_id)
70
66
  end
71
67
 
72
68
  private
73
69
 
74
70
  def build_container(root)
75
71
  manifest = Manifest.load(root)
76
- Container.new(
72
+ container = Container.new(
77
73
  root: root,
78
74
  manifest: manifest,
79
75
  schemas: Schemas.new(File.join(root, "schemas")),
@@ -83,16 +79,13 @@ module Textus
83
79
  max_size: manifest.data.audit_config[:max_size],
84
80
  keep: manifest.data.audit_config[:keep],
85
81
  ),
86
- events: Hooks::EventBus.new,
87
- rpc: Hooks::RpcRegistry.new,
82
+ workflows: Workflow::Loader.load_all(root),
83
+ gate: nil,
88
84
  )
89
- end
90
-
91
- def bootstrap_hooks
92
- Ports::AuditSubscriber.new(audit_log).attach(events)
93
- Ports::ProduceOnWriteSubscriber.new(container).attach(events)
94
- Hooks::Builtin.register_all(events: events, rpc: rpc)
95
- Hooks::Loader.new(events: events, rpc: rpc).load_dir(File.join(root, "hooks"))
85
+ gate = Textus::Gate.new(container)
86
+ container = container.with(gate: gate)
87
+ gate.instance_variable_set(:@container, container)
88
+ container
96
89
  end
97
90
  end
98
91
  end
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Group
5
+ class Data < Group
6
+ command_name "data"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Group
5
+ class Key < Group
6
+ command_name "key"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Group
5
+ class MCP < Group
6
+ command_name "mcp"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Group
5
+ class Rule < Group
6
+ command_name "rule"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,11 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Group
5
+ class Schema < Group
6
+ command_name "schema"
7
+ end
8
+ end
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,50 @@
1
+ module Textus
2
+ module Surfaces
3
+ class CLI
4
+ class Group < Verb
5
+ class << self
6
+ # Subcommands are auto-derived: any Verb descendant whose
7
+ # `parent_group` is this group counts as a subcommand. Sorted
8
+ # alphabetically by command_name for stable help output.
9
+ def subcommands
10
+ Textus::Surfaces::CLI::Runner.install!
11
+ Verb.descendants
12
+ .select { |k| k.parent_group == self && k.command_name }
13
+ .sort_by(&:command_name)
14
+ .to_h { |k| [k.command_name, k] }
15
+ end
16
+
17
+ def needs_store?
18
+ # Delegate to the matched subcommand at parse time; default true.
19
+ true
20
+ end
21
+ end
22
+
23
+ def parse(argv)
24
+ subs = self.class.subcommands
25
+ subname = argv.shift
26
+ if subname.nil?
27
+ raise UsageError.new(
28
+ "#{self.class.command_name} requires a subcommand: #{subs.keys.join(", ")}",
29
+ )
30
+ end
31
+
32
+ @sub_klass = subs[subname]
33
+ unless @sub_klass
34
+ raise UsageError.new(
35
+ "unknown #{self.class.command_name} subcommand '#{subname}'. " \
36
+ "Valid: #{subs.keys.join(", ")}",
37
+ )
38
+ end
39
+
40
+ @sub = @sub_klass.new(stdin: @stdin, stdout: @stdout, stderr: @stderr, cwd: @cwd)
41
+ @sub.parse(argv)
42
+ end
43
+
44
+ def call(store)
45
+ @sub.call(@sub_klass.needs_store? ? store : nil)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end