textus 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +62 -54
  4. data/SPEC.md +64 -192
  5. data/docs/architecture/README.md +157 -156
  6. data/exe/textus +1 -1
  7. data/lib/textus/action/accept.rb +44 -0
  8. data/lib/textus/action/audit.rb +131 -0
  9. data/lib/textus/action/base.rb +42 -0
  10. data/lib/textus/{read → action}/blame.rb +28 -22
  11. data/lib/textus/action/boot.rb +17 -0
  12. data/lib/textus/action/data_mv.rb +69 -0
  13. data/lib/textus/action/deps.rb +34 -0
  14. data/lib/textus/action/doctor.rb +24 -0
  15. data/lib/textus/action/drain.rb +39 -0
  16. data/lib/textus/action/enqueue.rb +53 -0
  17. data/lib/textus/action/get.rb +78 -0
  18. data/lib/textus/action/ingest.rb +135 -0
  19. data/lib/textus/action/jobs.rb +36 -0
  20. data/lib/textus/action/key_delete.rb +36 -0
  21. data/lib/textus/action/key_delete_prefix.rb +44 -0
  22. data/lib/textus/action/key_mv.rb +129 -0
  23. data/lib/textus/action/key_mv_prefix.rb +57 -0
  24. data/lib/textus/action/list.rb +42 -0
  25. data/lib/textus/action/propose.rb +52 -0
  26. data/lib/textus/action/published.rb +24 -0
  27. data/lib/textus/action/pulse.rb +56 -0
  28. data/lib/textus/action/put.rb +52 -0
  29. data/lib/textus/action/rdeps.rb +39 -0
  30. data/lib/textus/action/reject.rb +37 -0
  31. data/lib/textus/action/rule_explain.rb +91 -0
  32. data/lib/textus/action/rule_lint.rb +68 -0
  33. data/lib/textus/action/rule_list.rb +42 -0
  34. data/lib/textus/action/schema_envelope.rb +29 -0
  35. data/lib/textus/action/uid.rb +33 -0
  36. data/lib/textus/action/where.rb +36 -0
  37. data/lib/textus/action/write_verb.rb +44 -0
  38. data/lib/textus/boot.rb +64 -132
  39. data/lib/textus/command.rb +41 -0
  40. data/lib/textus/container.rb +1 -1
  41. data/lib/textus/{domain → core}/duration.rb +1 -1
  42. data/lib/textus/{domain → core}/freshness/evaluator.rb +25 -41
  43. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  44. data/lib/textus/{domain → core}/freshness.rb +2 -2
  45. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  46. data/lib/textus/{domain → core}/retention.rb +2 -2
  47. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  48. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  49. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  50. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  51. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  52. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  53. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  54. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  55. data/lib/textus/doctor/check.rb +11 -9
  56. data/lib/textus/{read → doctor}/validator.rb +22 -13
  57. data/lib/textus/doctor.rb +4 -44
  58. data/lib/textus/envelope/reader.rb +46 -0
  59. data/lib/textus/envelope/writer.rb +209 -0
  60. data/lib/textus/envelope.rb +5 -3
  61. data/lib/textus/errors.rb +25 -13
  62. data/lib/textus/{entry → format}/base.rb +1 -6
  63. data/lib/textus/{entry → format}/json.rb +1 -4
  64. data/lib/textus/{entry → format}/markdown.rb +2 -6
  65. data/lib/textus/{entry → format}/text.rb +2 -6
  66. data/lib/textus/{entry → format}/yaml.rb +1 -4
  67. data/lib/textus/{entry.rb → format.rb} +9 -10
  68. data/lib/textus/gate/auth.rb +227 -0
  69. data/lib/textus/gate.rb +116 -0
  70. data/lib/textus/init.rb +59 -105
  71. data/lib/textus/jobs/base.rb +23 -0
  72. data/lib/textus/jobs/materialize.rb +20 -0
  73. data/lib/textus/jobs/plan.rb +9 -0
  74. data/lib/textus/jobs/planner.rb +101 -0
  75. data/lib/textus/jobs/retention.rb +48 -0
  76. data/lib/textus/jobs/sweep.rb +27 -0
  77. data/lib/textus/jobs/worker.rb +67 -0
  78. data/lib/textus/jobs.rb +15 -0
  79. data/lib/textus/key/path.rb +10 -3
  80. data/lib/textus/layout.rb +13 -0
  81. data/lib/textus/manifest/data.rb +14 -14
  82. data/lib/textus/manifest/entry/base.rb +14 -25
  83. data/lib/textus/manifest/entry/parser.rb +31 -14
  84. data/lib/textus/manifest/entry/produced.rb +13 -33
  85. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  86. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -3
  87. data/lib/textus/manifest/entry/publish.rb +0 -12
  88. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  89. data/lib/textus/manifest/entry/validators.rb +0 -1
  90. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  91. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  92. data/lib/textus/manifest/policy/react.rb +30 -0
  93. data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
  94. data/lib/textus/manifest/policy/source.rb +30 -0
  95. data/lib/textus/manifest/policy.rb +38 -53
  96. data/lib/textus/manifest/resolver.rb +5 -4
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +20 -28
  99. data/lib/textus/manifest/schema/validator.rb +27 -33
  100. data/lib/textus/manifest/schema/vocabulary.rb +2 -1
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/ports/audit_log.rb +31 -5
  104. data/lib/textus/{domain/jobs → ports/job_store}/job.rb +19 -12
  105. data/lib/textus/ports/{queue.rb → job_store.rb} +7 -14
  106. data/lib/textus/ports/sentinel_store.rb +2 -2
  107. data/lib/textus/ports/watcher_lock.rb +48 -0
  108. data/lib/textus/produce/engine.rb +25 -70
  109. data/lib/textus/produce/render.rb +2 -7
  110. data/lib/textus/schema/tools.rb +4 -3
  111. data/lib/textus/session.rb +6 -3
  112. data/lib/textus/store.rb +12 -19
  113. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  114. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  115. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  116. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  117. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  118. data/lib/textus/surfaces/cli/group.rb +50 -0
  119. data/lib/textus/surfaces/cli/runner.rb +236 -0
  120. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  121. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  122. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  123. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  124. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  125. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  126. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  127. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  128. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  129. data/lib/textus/surfaces/cli/verb.rb +111 -0
  130. data/lib/textus/surfaces/cli.rb +148 -0
  131. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  132. data/lib/textus/surfaces/mcp/errors.rb +25 -0
  133. data/lib/textus/surfaces/mcp/routing.rb +51 -0
  134. data/lib/textus/surfaces/mcp/server.rb +184 -0
  135. data/lib/textus/surfaces/mcp/session.rb +9 -0
  136. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  137. data/lib/textus/surfaces/mcp.rb +8 -0
  138. data/lib/textus/surfaces/role_scope.rb +34 -0
  139. data/lib/textus/surfaces/watcher.rb +38 -0
  140. data/lib/textus/version.rb +1 -1
  141. data/lib/textus/workflow/collector.rb +27 -0
  142. data/lib/textus/workflow/context.rb +5 -0
  143. data/lib/textus/workflow/dsl.rb +44 -0
  144. data/lib/textus/workflow/loader.rb +17 -0
  145. data/lib/textus/workflow/pattern.rb +18 -0
  146. data/lib/textus/workflow/registry.rb +21 -0
  147. data/lib/textus/workflow/runner.rb +84 -0
  148. data/lib/textus/workflow.rb +28 -0
  149. data/lib/textus.rb +75 -22
  150. metadata +118 -132
  151. data/lib/textus/cli/group/hook.rb +0 -9
  152. data/lib/textus/cli/group/key.rb +0 -9
  153. data/lib/textus/cli/group/mcp.rb +0 -9
  154. data/lib/textus/cli/group/rule.rb +0 -9
  155. data/lib/textus/cli/group/schema.rb +0 -9
  156. data/lib/textus/cli/group/zone.rb +0 -9
  157. data/lib/textus/cli/group.rb +0 -48
  158. data/lib/textus/cli/runner.rb +0 -193
  159. data/lib/textus/cli/verb/doctor.rb +0 -17
  160. data/lib/textus/cli/verb/get.rb +0 -18
  161. data/lib/textus/cli/verb/hook_run.rb +0 -48
  162. data/lib/textus/cli/verb/hooks.rb +0 -50
  163. data/lib/textus/cli/verb/init.rb +0 -18
  164. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  165. data/lib/textus/cli/verb/put.rb +0 -30
  166. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  167. data/lib/textus/cli/verb/schema_init.rb +0 -19
  168. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  169. data/lib/textus/cli/verb/serve.rb +0 -19
  170. data/lib/textus/cli/verb.rb +0 -116
  171. data/lib/textus/cli.rb +0 -138
  172. data/lib/textus/dispatcher.rb +0 -54
  173. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  174. data/lib/textus/doctor/check/hooks.rb +0 -32
  175. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  176. data/lib/textus/domain/action.rb +0 -9
  177. data/lib/textus/domain/jobs/registry.rb +0 -37
  178. data/lib/textus/domain/permission.rb +0 -7
  179. data/lib/textus/domain/policy/base_guards.rb +0 -25
  180. data/lib/textus/domain/policy/evaluation.rb +0 -15
  181. data/lib/textus/domain/policy/guard.rb +0 -35
  182. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  183. data/lib/textus/domain/policy/handler_allowlist.rb +0 -17
  184. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  185. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  186. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  187. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  188. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  189. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  190. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  191. data/lib/textus/domain/policy/source.rb +0 -73
  192. data/lib/textus/envelope/io/reader.rb +0 -48
  193. data/lib/textus/envelope/io/writer.rb +0 -182
  194. data/lib/textus/hooks/builtin.rb +0 -70
  195. data/lib/textus/hooks/catalog.rb +0 -37
  196. data/lib/textus/hooks/context.rb +0 -72
  197. data/lib/textus/hooks/error_log.rb +0 -32
  198. data/lib/textus/hooks/event_bus.rb +0 -114
  199. data/lib/textus/hooks/fire_report.rb +0 -23
  200. data/lib/textus/hooks/loader.rb +0 -54
  201. data/lib/textus/hooks/rpc_registry.rb +0 -43
  202. data/lib/textus/hooks/signature.rb +0 -31
  203. data/lib/textus/init/templates/machine_intake.rb +0 -45
  204. data/lib/textus/init/templates/orientation_reducer.rb +0 -17
  205. data/lib/textus/jobs/handlers.rb +0 -62
  206. data/lib/textus/jobs/scheduler.rb +0 -36
  207. data/lib/textus/jobs/seeder.rb +0 -57
  208. data/lib/textus/maintenance/drain.rb +0 -42
  209. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  210. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  211. data/lib/textus/maintenance/retention/apply.rb +0 -52
  212. data/lib/textus/maintenance/rule_lint.rb +0 -66
  213. data/lib/textus/maintenance/serve.rb +0 -30
  214. data/lib/textus/maintenance/worker.rb +0 -74
  215. data/lib/textus/maintenance/zone_mv.rb +0 -64
  216. data/lib/textus/maintenance.rb +0 -15
  217. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  218. data/lib/textus/mcp/catalog.rb +0 -70
  219. data/lib/textus/mcp/errors.rb +0 -32
  220. data/lib/textus/mcp/server.rb +0 -138
  221. data/lib/textus/mcp/session.rb +0 -7
  222. data/lib/textus/mcp/tool_schemas.rb +0 -15
  223. data/lib/textus/mcp.rb +0 -6
  224. data/lib/textus/mustache.rb +0 -117
  225. data/lib/textus/ports/audit_subscriber.rb +0 -42
  226. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
  227. data/lib/textus/produce/acquire/handler.rb +0 -29
  228. data/lib/textus/produce/acquire/intake.rb +0 -130
  229. data/lib/textus/produce/acquire/projection.rb +0 -127
  230. data/lib/textus/produce/acquire/serializer/json.rb +0 -31
  231. data/lib/textus/produce/acquire/serializer/text.rb +0 -16
  232. data/lib/textus/produce/acquire/serializer/yaml.rb +0 -31
  233. data/lib/textus/produce/acquire/serializer.rb +0 -17
  234. data/lib/textus/produce/events.rb +0 -36
  235. data/lib/textus/projection.rb +0 -103
  236. data/lib/textus/read/audit.rb +0 -130
  237. data/lib/textus/read/boot.rb +0 -26
  238. data/lib/textus/read/capabilities.rb +0 -70
  239. data/lib/textus/read/deps.rb +0 -38
  240. data/lib/textus/read/doctor.rb +0 -27
  241. data/lib/textus/read/freshness.rb +0 -152
  242. data/lib/textus/read/get.rb +0 -73
  243. data/lib/textus/read/jobs.rb +0 -31
  244. data/lib/textus/read/list.rb +0 -24
  245. data/lib/textus/read/published.rb +0 -22
  246. data/lib/textus/read/pulse.rb +0 -98
  247. data/lib/textus/read/rdeps.rb +0 -39
  248. data/lib/textus/read/rule_explain.rb +0 -96
  249. data/lib/textus/read/rule_list.rb +0 -54
  250. data/lib/textus/read/schema_envelope.rb +0 -25
  251. data/lib/textus/read/uid.rb +0 -29
  252. data/lib/textus/read/validate_all.rb +0 -36
  253. data/lib/textus/read/where.rb +0 -24
  254. data/lib/textus/role_scope.rb +0 -78
  255. data/lib/textus/write/accept.rb +0 -58
  256. data/lib/textus/write/enqueue.rb +0 -50
  257. data/lib/textus/write/key_delete.rb +0 -65
  258. data/lib/textus/write/key_mv.rb +0 -141
  259. data/lib/textus/write/propose.rb +0 -54
  260. data/lib/textus/write/put.rb +0 -74
  261. data/lib/textus/write/reject.rb +0 -68
@@ -1,48 +0,0 @@
1
- module Textus
2
- module Maintenance
3
- # Bulk-delete every leaf key under `prefix`.
4
- class KeyDeletePrefix
5
- extend Textus::Contract::DSL
6
-
7
- verb :key_delete_prefix
8
- summary "Bulk-delete every leaf key under prefix."
9
- surfaces :cli, :mcp
10
- cli "key delete-prefix"
11
- arg :prefix, String, required: true, positional: true, description: "every leaf key under this dotted prefix is deleted"
12
- arg :dry_run, :boolean, default: false,
13
- description: "when true, returns the keys that would be deleted without deleting them; " \
14
- "defaults to false, so omitting it deletes immediately"
15
- view { |v, _i| v.to_h }
16
-
17
- def initialize(container:, call:)
18
- @container = container
19
- @call = call
20
- end
21
-
22
- def call(prefix, dry_run: false)
23
- raise UsageError.new("prefix required") if prefix.nil? || prefix.empty?
24
-
25
- leaves = Read::List.new(container: @container)
26
- .call(prefix: prefix)
27
- .map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
28
-
29
- warnings = leaves.empty? ? ["no keys under #{prefix}"] : []
30
- steps = leaves.map { |k| { "op" => "delete", "key" => k } }
31
-
32
- plan = Plan.new(steps: steps, warnings: warnings)
33
- return plan if dry_run
34
-
35
- steps.each do |s|
36
- delete.call(s["key"])
37
- end
38
- plan
39
- end
40
-
41
- private
42
-
43
- def delete
44
- Write::KeyDelete.new(container: @container, call: @call)
45
- end
46
- end
47
- end
48
- end
@@ -1,68 +0,0 @@
1
- module Textus
2
- module Maintenance
3
- # Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
4
- # Calls Write::KeyMv directly for each entry — emits one audit row per file moved.
5
- class KeyMvPrefix
6
- extend Textus::Contract::DSL
7
-
8
- verb :key_mv_prefix
9
- summary "Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false."
10
- surfaces :cli, :mcp
11
- cli "key mv-prefix"
12
- arg :from_prefix, String, required: true, positional: true, description: "dotted prefix whose leaf keys are renamed"
13
- arg :to_prefix, String, required: true, positional: true, description: "dotted prefix the keys are renamed to"
14
- arg :dry_run, :boolean, default: false,
15
- description: "when true, returns the planned moves without applying them; " \
16
- "defaults to false, so omitting it applies the rename immediately"
17
- view { |v, _i| v.to_h }
18
-
19
- def initialize(container:, call:)
20
- @container = container
21
- @call = call
22
- end
23
-
24
- def call(from_prefix, to_prefix, dry_run: false)
25
- raise UsageError.new("from_prefix and to_prefix required") if from_prefix.nil? || to_prefix.nil?
26
-
27
- leaves = list_leaves_under(from_prefix)
28
-
29
- # When from_prefix is itself a leaf, `delete_prefix("#{from_prefix}.")`
30
- # finds no trailing dot to strip, so the tail keeps the whole key and the
31
- # move silently targets "to_prefix.<full-from_prefix>". Refuse it — a
32
- # single-key rename is `mv`'s job, not the bulk prefix verb's.
33
- if leaves.include?(from_prefix)
34
- raise UsageError.new("from_prefix '#{from_prefix}' is itself a leaf — use `mv` to rename a single key")
35
- end
36
-
37
- warnings = []
38
- warnings << "no keys under #{from_prefix}" if leaves.empty?
39
-
40
- steps = leaves.map do |old_key|
41
- tail = old_key.delete_prefix("#{from_prefix}.")
42
- new_key = "#{to_prefix}.#{tail}"
43
- { "op" => "mv", "from" => old_key, "to" => new_key }
44
- end
45
-
46
- plan = Plan.new(steps: steps, warnings: warnings)
47
- return plan if dry_run
48
-
49
- steps.each do |s|
50
- mv.call(s["from"], s["to"], dry_run: false)
51
- end
52
- plan
53
- end
54
-
55
- private
56
-
57
- def list_leaves_under(prefix)
58
- Read::List.new(container: @container)
59
- .call(prefix: prefix)
60
- .map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
61
- end
62
-
63
- def mv
64
- Write::KeyMv.new(container: @container, call: @call)
65
- end
66
- end
67
- end
68
- end
@@ -1,52 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Maintenance
5
- module Retention
6
- # The destructive half of convergence: apply retention rows (drop/archive).
7
- # Lifted verbatim from the legacy reconcile apply/archive_leaf so drain/serve and
8
- # the `sweep` job handler share one path. Runs as the caller's role — never
9
- # self-elevates (ADR 0079/0093: destructiveness decides authority).
10
- class Apply
11
- def initialize(container:, call:)
12
- @container = container
13
- @call = call
14
- end
15
-
16
- def call(rows)
17
- out = { dropped: [], archived: [], failed: [] }
18
- delete = Write::KeyDelete.new(container: @container, call: @call)
19
- rows.each do |row|
20
- key = row["key"]
21
- begin
22
- case row["action"]
23
- when "drop"
24
- delete.call(key)
25
- out[:dropped] << key
26
- when "archive"
27
- archive_leaf(row)
28
- delete.call(key)
29
- out[:archived] << key
30
- end
31
- rescue Textus::Error => e
32
- out[:failed] << { "key" => key, "error" => e.message }
33
- end
34
- end
35
- out
36
- end
37
-
38
- private
39
-
40
- # Copy the leaf into <store>/archive/<relative-path> before deletion.
41
- def archive_leaf(row)
42
- src = row["path"]
43
- root = @container.root.to_s
44
- rel = src.delete_prefix("#{root}/")
45
- dest = File.join(root, "archive", rel)
46
- FileUtils.mkdir_p(File.dirname(dest))
47
- FileUtils.cp(src, dest)
48
- end
49
- end
50
- end
51
- end
52
- end
@@ -1,66 +0,0 @@
1
- require "yaml"
2
-
3
- module Textus
4
- module Maintenance
5
- # Compare the live manifest's `rules:` block against a candidate
6
- # YAML string. Returns a Plan describing rule additions/removals/
7
- # changes. Does NOT write anything.
8
- class RuleLint
9
- extend Textus::Contract::DSL
10
-
11
- verb :rule_lint
12
- summary "Diff candidate manifest YAML's rules against the live manifest. No writes."
13
- surfaces :cli, :mcp
14
- cli "rule lint"
15
- arg :candidate_yaml, String, required: true, wire_name: :against, source: :file,
16
- description: "path to candidate manifest YAML; its `rules:` block is diffed against the live manifest"
17
- view { |v, _i| v.to_h }
18
-
19
- def initialize(container:, call:)
20
- @container = container
21
- @call = call
22
- @root = container.root
23
- end
24
-
25
- def call(candidate_yaml:)
26
- live_rules = current_rules
27
- candidate_rules = parse_candidate(candidate_yaml)
28
-
29
- live_by_match = live_rules.to_h { |r| [r["match"], r] }
30
- candidate_by_match = candidate_rules.to_h { |r| [r["match"], r] }
31
-
32
- steps = (candidate_by_match.keys - live_by_match.keys).map do |m|
33
- { "op" => "add_rule", "match" => m, "rule" => candidate_by_match[m] }
34
- end
35
- (live_by_match.keys - candidate_by_match.keys).each do |m|
36
- steps << { "op" => "remove_rule", "match" => m }
37
- end
38
- (live_by_match.keys & candidate_by_match.keys).each do |m|
39
- next if live_by_match[m] == candidate_by_match[m]
40
-
41
- steps << { "op" => "change_rule", "match" => m,
42
- "from" => live_by_match[m], "to" => candidate_by_match[m] }
43
- end
44
-
45
- Plan.new(steps: steps, warnings: [])
46
- end
47
-
48
- private
49
-
50
- def current_rules
51
- raw = YAML.safe_load_file(File.join(@root, "manifest.yaml"),
52
- permitted_classes: [Symbol], aliases: false)
53
- Array(raw["rules"])
54
- end
55
-
56
- def parse_candidate(yaml_text)
57
- raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
58
- raise UsageError.new("candidate is not a YAML mapping") unless raw.is_a?(Hash)
59
-
60
- Array(raw["rules"])
61
- rescue Psych::Exception => e
62
- raise UsageError.new("candidate YAML parse error: #{e.message}")
63
- end
64
- end
65
- end
66
- end
@@ -1,30 +0,0 @@
1
- module Textus
2
- module Maintenance
3
- # The convergence daemon loop: seed scheduled work (TTL re-pull + sweep),
4
- # reclaim crashed leases, drain the queue, sleep, repeat. `tick` is one
5
- # iteration (unit-testable); `run` loops forever. Drains serially for the
6
- # same reason as Drain — each produce job self-locks, so running them in turn
7
- # keeps the build lock uncontended.
8
- class Serve
9
- def initialize(container:, call:)
10
- @container = container
11
- @call = call
12
- @queue = Textus::Ports::Queue.new(root: container.root)
13
- end
14
-
15
- def tick
16
- Textus::Jobs::Scheduler.new(container: @container, queue: @queue).run_once
17
- @queue.reclaim(now: Textus::Ports::Clock.new.now)
18
- Worker.for(container: @container, queue: @queue).drain
19
- end
20
-
21
- def run(poll: nil)
22
- interval = poll || @container.manifest.data.worker_config[:poll]
23
- loop do
24
- tick
25
- sleep(interval)
26
- end
27
- end
28
- end
29
- end
30
- end
@@ -1,74 +0,0 @@
1
- module Textus
2
- module Maintenance
3
- # Drains the job queue: lease a job, look up its handler in the registry, run
4
- # it (as the job's stamped authority — wired in a later phase), then ack on
5
- # success or fail (requeue/dead-letter) on a raise. `drain` runs until the
6
- # queue is empty and returns a summary. Delivery is at-least-once.
7
- class Worker
8
- Summary = Struct.new(:completed, :failed, keyword_init: true)
9
-
10
- # The standard convergence worker: the closed handler allow-list plus the
11
- # lease TTL from worker_config. Both `drain` and `serve` build it this way.
12
- def self.for(container:, queue:)
13
- new(
14
- queue: queue, registry: Textus::Jobs::Handlers.registry,
15
- container: container, lease_ttl: container.manifest.data.worker_config[:lease_ttl]
16
- )
17
- end
18
-
19
- def initialize(queue:, registry:, container:, lease_ttl: 60)
20
- @queue = queue
21
- @registry = registry
22
- @container = container
23
- @lease_ttl = lease_ttl
24
- end
25
-
26
- def drain(worker_id: "drain-#{Process.pid}")
27
- completed = 0
28
- failed = 0
29
- loop do
30
- leased = @queue.lease(worker_id: worker_id, lease_ttl: @lease_ttl)
31
- break unless leased
32
-
33
- case run_one(leased)
34
- when :completed then completed += 1
35
- when :dead_lettered then failed += 1
36
- # :requeued -> a transient failure; it re-leases on a later iteration
37
- end
38
- end
39
- Summary.new(completed: completed, failed: failed)
40
- end
41
-
42
- def drain_pool(pool: 4)
43
- summaries = []
44
- mutex = Mutex.new
45
- threads = Array.new(pool) do |i|
46
- Thread.new do
47
- s = drain(worker_id: "pool-#{Process.pid}-#{i}")
48
- mutex.synchronize { summaries << s }
49
- end
50
- end
51
- threads.each(&:join)
52
- Summary.new(
53
- completed: summaries.sum(&:completed),
54
- failed: summaries.sum(&:failed),
55
- )
56
- end
57
-
58
- private
59
-
60
- # Returns :completed on ack, or the queue's failure verdict (:requeued |
61
- # :dead_lettered) on a raise. A requeued job re-leases on the next loop
62
- # iteration, so a transient failure still drains; only a dead-letter is a
63
- # terminal failure that counts toward the summary.
64
- def run_one(leased)
65
- entry = @registry.lookup(leased.job.type)
66
- entry.handler.call(job: leased.job, container: @container)
67
- @queue.ack(leased)
68
- :completed
69
- rescue StandardError => e
70
- @queue.fail(leased, error: e.message)
71
- end
72
- end
73
- end
74
- end
@@ -1,64 +0,0 @@
1
- require "yaml"
2
-
3
- module Textus
4
- module Maintenance
5
- # Rename a zone — rewrites the manifest's zones[] entry, rewrites
6
- # the `zone:` field on every entry under the old zone, and moves
7
- # every file from zones/<old>/ to zones/<new>/.
8
- class ZoneMv
9
- extend Textus::Contract::DSL
10
-
11
- verb :zone_mv
12
- summary "Rename a zone — manifest + files. Refuses if destination exists."
13
- surfaces :cli, :mcp
14
- cli "zone mv"
15
- arg :from, String, required: true, positional: true, description: "current zone name"
16
- arg :to, String, required: true, positional: true, description: "new zone name; refused if a zone by this name already exists"
17
- arg :dry_run, :boolean, default: false,
18
- description: "when true, returns the planned zone move without applying it; " \
19
- "defaults to false, so omitting it applies the move immediately"
20
- view { |v, _i| v.to_h }
21
-
22
- def initialize(container:, call:)
23
- @container = container
24
- @call = call
25
- @manifest = container.manifest
26
- @root = container.root
27
- end
28
-
29
- def call(from, to, dry_run: false)
30
- raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
31
- raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.declared_zone_kinds.key?(from)
32
-
33
- dest_dir = File.join(@root, "zones", to)
34
- raise UsageError.new("destination 'zones/#{to}' already exists") if File.exist?(dest_dir)
35
-
36
- affected_keys = @manifest.data.entries.select { |e| e.zone == from }.map(&:key)
37
-
38
- steps = [{ "op" => "rename_zone", "from" => from, "to" => to }]
39
- steps += affected_keys.map { |k| { "op" => "mv", "from" => k, "to" => "#{to}#{k[from.length..]}" } }
40
-
41
- plan = Plan.new(steps: steps, warnings: [])
42
- return plan if dry_run
43
-
44
- rewrite_manifest!(from, to)
45
- FileUtils.mv(File.join(@root, "zones", from), dest_dir)
46
- plan
47
- end
48
-
49
- private
50
-
51
- def rewrite_manifest!(from, to)
52
- path = File.join(@root, "manifest.yaml")
53
- raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
54
- raw["zones"].each { |z| z["name"] = to if z["name"] == from }
55
- raw["entries"].each do |e|
56
- e["zone"] = to if e["zone"] == from
57
- e["key"] = e["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
58
- e["path"] = e["path"].sub(%r{\A#{Regexp.escape(from)}(/|\z)}, "#{to}\\1")
59
- end
60
- File.write(path, YAML.dump(raw))
61
- end
62
- end
63
- end
64
- end
@@ -1,15 +0,0 @@
1
- module Textus
2
- # Bulk and structural changes to a textus store. Each use case returns
3
- # a Plan when called with dry_run: true, and applies the plan when
4
- # called with dry_run: false.
5
- module Maintenance
6
- # A Plan is a JSON-shaped preview. Steps are op-tagged hashes the
7
- # use case knows how to apply. Warnings are strings surfaced to
8
- # the operator (skipped keys, ambiguities).
9
- Plan = Data.define(:steps, :warnings) do
10
- def to_h
11
- { "steps" => steps, "warnings" => warnings }
12
- end
13
- end
14
- end
15
- end
@@ -1,22 +0,0 @@
1
- module Textus
2
- class Manifest
3
- class Entry
4
- module Validators
5
- module Events
6
- def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
7
- pubsub_events = Textus::Hooks::Catalog::PUBSUB.keys
8
- events = entry.events
9
- events.each_key do |evt|
10
- next if pubsub_events.include?(evt.to_sym)
11
-
12
- raise UsageError.new(
13
- "entry '#{entry.key}': unknown event '#{evt}' in events: block. " \
14
- "Known events: #{pubsub_events.join(", ")}.",
15
- )
16
- end
17
- end
18
- end
19
- end
20
- end
21
- end
22
- end
@@ -1,70 +0,0 @@
1
- module Textus
2
- module MCP
3
- # Derives the entire MCP tool surface from the per-verb contracts
4
- # (ADR 0039). `tool_schemas` feeds tools/list; `call` is the generic
5
- # tools/call dispatch: map JSON args -> (positional, keyword) per the
6
- # contract, invoke the verb through the role scope, then shape the
7
- # return value with the contract's default view. No per-tool code.
8
- module Catalog
9
- module_function
10
-
11
- # Contracts of every MCP-surfaced verb, in Dispatcher order.
12
- def specs
13
- Textus::Dispatcher::VERBS.values
14
- .select { |k| mcp_surfaced?(k) }
15
- .map(&:contract)
16
- end
17
-
18
- def tool_schemas
19
- specs.map do |s|
20
- { name: s.verb.to_s, description: s.summary, inputSchema: s.input_schema }
21
- end.freeze
22
- end
23
-
24
- def names
25
- specs.map { |s| s.verb.to_s }
26
- end
27
-
28
- # MCP-surfaced read verbs, by Dispatcher class namespace — the agent's
29
- # real read/discovery surface. `boot.agent_quickstart.read_verbs` derives
30
- # from this so it can never advertise a verb the agent cannot call, nor
31
- # omit one it can (ADR 0056). Excludes Write/Maintenance.
32
- def read_verbs
33
- Textus::Dispatcher::VERBS
34
- .select { |_verb, klass| mcp_surfaced?(klass) && klass.name.start_with?("Textus::Read::") }
35
- .keys.map(&:to_s)
36
- end
37
-
38
- # MCP-surfaced write verbs, by Dispatcher class namespace — the mirror of
39
- # read_verbs for the write side. `boot.agent_quickstart.write_verbs` derives
40
- # from this so it advertises bare verb names the agent can call (no `--as`/
41
- # `--stdin` CLI framing), finishing the de-CLI-ing of the agent surface
42
- # (ADR 0056, ADR 0057).
43
- def write_verbs
44
- Textus::Dispatcher::VERBS
45
- .select { |_verb, klass| mcp_surfaced?(klass) && klass.name.start_with?("Textus::Write::") }
46
- .keys.map(&:to_s)
47
- end
48
-
49
- def mcp_surfaced?(klass)
50
- klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
51
- end
52
-
53
- def call(name, session:, store:, args:)
54
- klass = Textus::Dispatcher::VERBS[name.to_sym]
55
- raise ToolError.new("unknown tool: #{name}") unless klass && mcp_surfaced?(klass)
56
-
57
- spec = klass.contract
58
- inputs = Textus::Contract::Binder.inputs_from_wire(spec, args)
59
- result = store.as(session.role).dispatch_bound(spec.verb, inputs, session: session)
60
- Textus::Contract::View.render(spec, :default, result, inputs)
61
- rescue Textus::Contract::MissingArgs => e
62
- raise ToolError.new("#{spec.verb}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
63
- rescue ContractDrift, CursorExpired
64
- raise
65
- rescue Textus::Error => e
66
- raise ToolError.new("#{name}: #{e.message}")
67
- end
68
- end
69
- end
70
- end
@@ -1,32 +0,0 @@
1
- module Textus
2
- module MCP
3
- # Manifest fingerprint changed mid-session. Client should re-boot.
4
- class ContractDrift < Textus::Error
5
- JSONRPC_CODE = -32_001
6
-
7
- def initialize(message, details: {})
8
- super("contract_drift", message, details: details)
9
- end
10
- end
11
-
12
- # Audit cursor fell off the keep window. Client should re-boot and
13
- # resume from the new latest_seq.
14
- class CursorExpired < Textus::Error
15
- JSONRPC_CODE = -32_002
16
-
17
- def initialize(message, details: {})
18
- super("cursor_expired", message, details: details)
19
- end
20
- end
21
-
22
- # Tool execution failed (validation, authorization, IO). Wraps an
23
- # underlying Textus::Error or generic StandardError.
24
- class ToolError < Textus::Error
25
- JSONRPC_CODE = -32_000
26
-
27
- def initialize(message, details: {})
28
- super("tool_error", message, details: details)
29
- end
30
- end
31
- end
32
- end
@@ -1,138 +0,0 @@
1
- require "json"
2
-
3
- module Textus
4
- module MCP
5
- # Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. One line per
6
- # message (NDJSON). Holds a single Session for the lifetime of stdin.
7
- class Server
8
- PROTOCOL_VERSION = "2024-11-05"
9
- SERVER_INFO = { "name" => "textus", "version" => Textus::VERSION }.freeze
10
-
11
- def initialize(store:, stdin: $stdin, stdout: $stdout, role: Textus::Role::DEFAULT)
12
- @store = store
13
- @stdin = stdin
14
- @stdout = stdout
15
- @role = role
16
- @session = nil
17
- end
18
-
19
- def run
20
- @stdin.each_line do |line|
21
- line = line.strip
22
- next if line.empty?
23
-
24
- handle_line(line)
25
- end
26
- end
27
-
28
- private
29
-
30
- def handle_line(line)
31
- msg = JSON.parse(line)
32
- rescue JSON::ParserError => e
33
- emit_error(nil, -32_700, "parse error: #{e.message}")
34
- else
35
- dispatch(msg)
36
- end
37
-
38
- def dispatch(msg)
39
- rid = msg["id"]
40
- case msg["method"]
41
- when "initialize" then handle_initialize(rid, msg["params"] || {})
42
- when "tools/list" then handle_tools_list(rid)
43
- when "tools/call" then handle_tools_call(rid, msg["params"] || {})
44
- when "ping" then emit_result(rid, {})
45
- when "shutdown" then emit_result(rid, nil)
46
- when "notifications/initialized" then nil
47
- else emit_error(rid, -32_601, "method not found: #{msg["method"]}")
48
- end
49
- end
50
-
51
- def handle_initialize(rid, _params)
52
- # The acting role IS the resolved connection role (ADR 0040): the MCP
53
- # transport defaults to `agent`, which can write the queue, so its
54
- # propose_zone resolves directly. If a connection's role cannot propose,
55
- # propose_zone is nil and the `propose` tool reports that honestly.
56
- propose_zone = @store.manifest.policy.propose_zone_for(@role)
57
-
58
- @session = Session.new(
59
- role: @role,
60
- cursor: @store.audit_log.latest_seq,
61
- propose_zone: propose_zone,
62
- contract_etag: contract_etag,
63
- )
64
-
65
- # ADR 0075: announce the connection to connect-time hooks with the
66
- # resolved role. Distinct from :store_loaded (fired at Store.new under
67
- # the default role, before any connection's role is known).
68
- @store.events.publish(
69
- :session_opened,
70
- ctx: Hooks::Context.new(scope: @store.as(@role)),
71
- role: @role,
72
- cursor: @session.cursor,
73
- )
74
-
75
- emit_result(rid, {
76
- "protocolVersion" => PROTOCOL_VERSION,
77
- "serverInfo" => SERVER_INFO,
78
- "capabilities" => { "tools" => {} },
79
- })
80
- end
81
-
82
- def handle_tools_list(rid)
83
- emit_result(rid, { "tools" => Catalog.tool_schemas })
84
- end
85
-
86
- def handle_tools_call(rid, params)
87
- unless @session
88
- emit_error(rid, -32_002, "session not initialized; call 'initialize' first")
89
- return
90
- end
91
-
92
- name = params["name"]
93
- args = params["arguments"] || {}
94
-
95
- # ADR 0083: the contract-drift guard gates mutating verbs — every MCP
96
- # verb that is NOT a pure read (Write:: + the destructive Maintenance::
97
- # verbs drain/zone_mv/key_*_prefix). Reads and boot bypass it (a stale
98
- # read returns on-disk truth; boot re-orients). Keying on read_verbs
99
- # (not write_verbs) keeps the destructive Maintenance:: verbs gated.
100
- @session.check_etag!(contract_etag) unless Catalog.read_verbs.include?(name)
101
-
102
- result = Catalog.call(name, session: @session, store: @store, args: args)
103
- @session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "pulse"
104
- @session = @session.with(contract_etag: contract_etag) if name == "boot"
105
-
106
- emit_result(rid, {
107
- "content" => [{ "type" => "text", "text" => JSON.dump(result) }],
108
- "isError" => false,
109
- })
110
- rescue ContractDrift => e
111
- emit_error(rid, ContractDrift::JSONRPC_CODE, e.message)
112
- rescue CursorExpired => e
113
- emit_error(rid, CursorExpired::JSONRPC_CODE, e.message)
114
- rescue ToolError => e
115
- emit_error(rid, ToolError::JSONRPC_CODE, e.message)
116
- rescue StandardError => e
117
- emit_error(rid, -32_603, "internal: #{e.class}: #{e.message}")
118
- end
119
-
120
- def contract_etag
121
- Textus::Etag.for_contract(@store.root)
122
- end
123
-
124
- def emit_result(rid, result)
125
- write({ "jsonrpc" => "2.0", "id" => rid, "result" => result })
126
- end
127
-
128
- def emit_error(rid, code, message)
129
- write({ "jsonrpc" => "2.0", "id" => rid, "error" => { "code" => code, "message" => message } })
130
- end
131
-
132
- def write(obj)
133
- @stdout.puts(JSON.dump(obj))
134
- @stdout.flush
135
- end
136
- end
137
- end
138
- end