textus 0.54.2 → 0.55.2

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 (242) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/README.md +17 -10
  4. data/SPEC.md +39 -11
  5. data/docs/architecture/README.md +23 -11
  6. data/docs/reference/conventions.md +5 -2
  7. data/exe/textus +1 -1
  8. data/lib/textus/boot.rb +64 -93
  9. data/lib/textus/dispatch/binder.rb +48 -0
  10. data/lib/textus/dispatch/contracts.rb +63 -0
  11. data/lib/textus/dispatch/handler_registry.rb +21 -0
  12. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  13. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  14. data/lib/textus/dispatch/middleware/base.rb +26 -0
  15. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  16. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  17. data/lib/textus/dispatch/pipeline.rb +35 -0
  18. data/lib/textus/doctor/check/audit_log.rb +2 -2
  19. data/lib/textus/doctor/check/generator_drift.rb +4 -4
  20. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  21. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  22. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  23. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  24. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  25. data/lib/textus/doctor/check/schemas.rb +1 -1
  26. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  27. data/lib/textus/doctor/check/sentinels.rb +4 -4
  28. data/lib/textus/doctor/check/templates.rb +1 -1
  29. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  30. data/lib/textus/doctor/check.rb +9 -10
  31. data/lib/textus/doctor.rb +2 -2
  32. data/lib/textus/errors.rb +8 -0
  33. data/lib/textus/format/base.rb +34 -10
  34. data/lib/textus/format/json.rb +5 -27
  35. data/lib/textus/format/markdown.rb +5 -27
  36. data/lib/textus/format/text.rb +5 -4
  37. data/lib/textus/format/yaml.rb +30 -27
  38. data/lib/textus/format.rb +22 -1
  39. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  40. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  41. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  42. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  43. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  44. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  45. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  46. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  47. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  48. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  49. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  50. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  51. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  52. data/lib/textus/handlers/read/get_entry.rb +68 -0
  53. data/lib/textus/handlers/read/list_keys.rb +36 -0
  54. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  55. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  56. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  57. data/lib/textus/handlers/read/where_entry.rb +18 -0
  58. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  59. data/lib/textus/handlers/write/data_mv.rb +55 -0
  60. data/lib/textus/handlers/write/delete_key.rb +17 -0
  61. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  62. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  63. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  64. data/lib/textus/handlers/write/move_key.rb +80 -0
  65. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  66. data/lib/textus/handlers/write/put_entry.rb +29 -0
  67. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  68. data/lib/textus/init.rb +5 -7
  69. data/lib/textus/jobs.rb +3 -9
  70. data/lib/textus/manifest/capabilities.rb +4 -4
  71. data/lib/textus/manifest/entry/base.rb +3 -3
  72. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  73. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -2
  74. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  75. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  76. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  77. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  78. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  79. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  80. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  81. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  82. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  83. data/lib/textus/manifest/policy/predicates.rb +54 -0
  84. data/lib/textus/manifest/policy/retention.rb +1 -1
  85. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  86. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  87. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  88. data/lib/textus/manifest/schema/semantics.rb +11 -216
  89. data/lib/textus/orchestration.rb +55 -0
  90. data/lib/textus/{ports → port}/audit_log.rb +46 -6
  91. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  92. data/lib/textus/{ports → port}/clock.rb +1 -1
  93. data/lib/textus/{ports → port}/publisher.rb +5 -5
  94. data/lib/textus/{core → port}/sentinel.rb +1 -6
  95. data/lib/textus/{ports → port}/sentinel_store.rb +4 -4
  96. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  97. data/lib/textus/port/storage/file_store.rb +49 -0
  98. data/lib/textus/port/storage/interface.rb +17 -0
  99. data/lib/textus/port/store.rb +149 -0
  100. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  101. data/lib/textus/produce/engine.rb +1 -11
  102. data/lib/textus/produce/publisher.rb +21 -0
  103. data/lib/textus/schema/registry.rb +42 -0
  104. data/lib/textus/schema/tools.rb +7 -10
  105. data/lib/textus/store/container.rb +173 -0
  106. data/lib/textus/store/cursor.rb +26 -0
  107. data/lib/textus/store/entry/reader.rb +47 -0
  108. data/lib/textus/store/entry/writer.rb +219 -0
  109. data/lib/textus/store/envelope/meta.rb +61 -0
  110. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  111. data/lib/textus/store/freshness/evaluator.rb +20 -0
  112. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  113. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  114. data/lib/textus/store/freshness.rb +8 -0
  115. data/lib/textus/store/index/builder.rb +76 -0
  116. data/lib/textus/store/index/lookup.rb +60 -0
  117. data/lib/textus/store/jobs/base.rb +13 -0
  118. data/lib/textus/store/jobs/index.rb +15 -0
  119. data/lib/textus/store/jobs/materialize.rb +15 -0
  120. data/lib/textus/store/jobs/plan.rb +11 -0
  121. data/lib/textus/store/jobs/planner.rb +124 -0
  122. data/lib/textus/store/jobs/queue.rb +162 -0
  123. data/lib/textus/store/jobs/registry.rb +19 -0
  124. data/lib/textus/store/jobs/retention/base.rb +52 -0
  125. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  126. data/lib/textus/store/jobs/retention.rb +8 -0
  127. data/lib/textus/store/jobs/sweep.rb +21 -0
  128. data/lib/textus/store/jobs/worker.rb +64 -0
  129. data/lib/textus/store/layout.rb +97 -0
  130. data/lib/textus/store.rb +63 -32
  131. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  132. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  133. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  135. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  136. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  137. data/lib/textus/{surfaces → surface}/cli/runner.rb +14 -68
  138. data/lib/textus/surface/cli/sources.rb +41 -0
  139. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +5 -6
  140. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +7 -5
  141. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  142. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  143. data/lib/textus/surface/cli/verb/put.rb +27 -0
  144. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  145. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  146. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  147. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  148. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  149. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  150. data/lib/textus/surface/mcp/catalog.rb +58 -0
  151. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -11
  152. data/lib/textus/surface/mcp/projector.rb +20 -0
  153. data/lib/textus/{surfaces → surface}/mcp/server.rb +24 -35
  154. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  155. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  156. data/lib/textus/value/call.rb +30 -0
  157. data/lib/textus/value/command.rb +16 -0
  158. data/lib/textus/{core → value}/duration.rb +1 -4
  159. data/lib/textus/value/envelope.rb +90 -0
  160. data/lib/textus/value/etag.rb +39 -0
  161. data/lib/textus/value/payload.rb +7 -0
  162. data/lib/textus/value/result.rb +46 -0
  163. data/lib/textus/value/role.rb +38 -0
  164. data/lib/textus/value/types.rb +13 -0
  165. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  166. data/lib/textus/verb_registry.rb +417 -0
  167. data/lib/textus/version.rb +1 -1
  168. data/lib/textus/workflow/loader.rb +4 -4
  169. data/lib/textus/workflow/runner.rb +11 -33
  170. data/lib/textus.rb +4 -69
  171. metadata +142 -105
  172. data/lib/textus/action/accept.rb +0 -44
  173. data/lib/textus/action/audit.rb +0 -131
  174. data/lib/textus/action/base.rb +0 -42
  175. data/lib/textus/action/blame.rb +0 -97
  176. data/lib/textus/action/boot.rb +0 -17
  177. data/lib/textus/action/data_mv.rb +0 -69
  178. data/lib/textus/action/deps.rb +0 -34
  179. data/lib/textus/action/doctor.rb +0 -24
  180. data/lib/textus/action/drain.rb +0 -39
  181. data/lib/textus/action/enqueue.rb +0 -53
  182. data/lib/textus/action/get.rb +0 -78
  183. data/lib/textus/action/ingest.rb +0 -203
  184. data/lib/textus/action/jobs.rb +0 -36
  185. data/lib/textus/action/key_delete.rb +0 -36
  186. data/lib/textus/action/key_delete_prefix.rb +0 -44
  187. data/lib/textus/action/key_mv.rb +0 -129
  188. data/lib/textus/action/key_mv_prefix.rb +0 -57
  189. data/lib/textus/action/list.rb +0 -42
  190. data/lib/textus/action/propose.rb +0 -52
  191. data/lib/textus/action/published.rb +0 -24
  192. data/lib/textus/action/pulse.rb +0 -56
  193. data/lib/textus/action/put.rb +0 -52
  194. data/lib/textus/action/rdeps.rb +0 -39
  195. data/lib/textus/action/reject.rb +0 -37
  196. data/lib/textus/action/rule_explain.rb +0 -91
  197. data/lib/textus/action/rule_lint.rb +0 -68
  198. data/lib/textus/action/rule_list.rb +0 -42
  199. data/lib/textus/action/schema_envelope.rb +0 -29
  200. data/lib/textus/action/uid.rb +0 -33
  201. data/lib/textus/action/where.rb +0 -36
  202. data/lib/textus/action/write_verb.rb +0 -44
  203. data/lib/textus/call.rb +0 -28
  204. data/lib/textus/command.rb +0 -41
  205. data/lib/textus/container.rb +0 -26
  206. data/lib/textus/contract/around.rb +0 -29
  207. data/lib/textus/contract/binder.rb +0 -88
  208. data/lib/textus/contract/resources/build_lock.rb +0 -17
  209. data/lib/textus/contract/resources/cursor.rb +0 -26
  210. data/lib/textus/contract/sources.rb +0 -39
  211. data/lib/textus/contract/view.rb +0 -15
  212. data/lib/textus/contract.rb +0 -174
  213. data/lib/textus/core/freshness/evaluator.rb +0 -150
  214. data/lib/textus/core/freshness.rb +0 -11
  215. data/lib/textus/core/retention/sweep.rb +0 -57
  216. data/lib/textus/core/retention.rb +0 -11
  217. data/lib/textus/cursor_store.rb +0 -24
  218. data/lib/textus/envelope/reader.rb +0 -46
  219. data/lib/textus/envelope/writer.rb +0 -209
  220. data/lib/textus/envelope.rb +0 -79
  221. data/lib/textus/etag.rb +0 -36
  222. data/lib/textus/gate/auth.rb +0 -227
  223. data/lib/textus/gate.rb +0 -116
  224. data/lib/textus/jobs/base.rb +0 -23
  225. data/lib/textus/jobs/materialize.rb +0 -20
  226. data/lib/textus/jobs/plan.rb +0 -9
  227. data/lib/textus/jobs/planner.rb +0 -101
  228. data/lib/textus/jobs/retention.rb +0 -48
  229. data/lib/textus/jobs/sweep.rb +0 -27
  230. data/lib/textus/jobs/worker.rb +0 -67
  231. data/lib/textus/layout.rb +0 -91
  232. data/lib/textus/ports/job_store/job.rb +0 -65
  233. data/lib/textus/ports/job_store.rb +0 -123
  234. data/lib/textus/ports/raw_index.rb +0 -61
  235. data/lib/textus/ports/storage/file_store.rb +0 -26
  236. data/lib/textus/role.rb +0 -36
  237. data/lib/textus/schemas.rb +0 -54
  238. data/lib/textus/session.rb +0 -35
  239. data/lib/textus/surfaces/cli/verb/put.rb +0 -30
  240. data/lib/textus/surfaces/mcp/catalog.rb +0 -111
  241. data/lib/textus/surfaces/role_scope.rb +0 -34
  242. data/lib/textus/types.rb +0 -15
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ module Handlers
3
+ module Maintenance
4
+ class JobsAction
5
+ def initialize(job_store:)
6
+ @job_store = job_store
7
+ end
8
+
9
+ def call(command, _call)
10
+ queue = Textus::Store::Jobs::Queue.new(store: @job_store)
11
+ case command.action
12
+ when "retry" then queue.retry_failed(command.job_id)
13
+ when "purge" then queue.purge(command.state)
14
+ end
15
+ Value::Result.success("protocol" => Textus::PROTOCOL, "ok" => true,
16
+ "state" => command.state, "jobs" => queue.list(command.state))
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Handlers
3
+ module Maintenance
4
+ class PublishedEntries
5
+ def initialize(manifest:)
6
+ @manifest = manifest
7
+ end
8
+
9
+ def call(_command, _call)
10
+ Value::Result.success(@manifest.data.entries.reject { |entry| entry.publish_to.empty? }.map do |entry|
11
+ { "key" => entry.key, "publish_to" => entry.publish_to }
12
+ end)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,77 @@
1
+ module Textus
2
+ module Handlers
3
+ module Maintenance
4
+ class RuleExplain
5
+ def initialize(manifest:)
6
+ @manifest = manifest
7
+ end
8
+
9
+ def call(command, _call)
10
+ key = command.key
11
+ result = if command.detail
12
+ explain(key)
13
+ else
14
+ effective(key)
15
+ end
16
+ Value::Result.success(result)
17
+ end
18
+
19
+ LEAN_FIELDS = Textus::Manifest::Schema::FIELD_REGISTRY
20
+ .select { |_, m| m[:in_rule_explain].include?(:lean) }.keys.freeze
21
+ DETAIL_FIELDS = Textus::Manifest::Schema::FIELD_REGISTRY
22
+ .select { |_, m| m[:in_rule_explain].include?(:detail) }.keys.freeze
23
+ EFFECTIVE_FIELDS = DETAIL_FIELDS.select { |f| Textus::Manifest::Schema::FIELD_REGISTRY[f][:policy_class] }.freeze
24
+
25
+ private
26
+
27
+ def effective(key)
28
+ set = @manifest.rules.for(key)
29
+ LEAN_FIELDS.each_with_object({}) do |field, out|
30
+ value = set.public_send(field)
31
+ out[field.to_s] = lean_value(field, value) unless value.nil?
32
+ end
33
+ end
34
+
35
+ def lean_value(field, value)
36
+ case field
37
+ when :retention then retention_hash(value, string_keys: true)
38
+ when :react then value.to_h
39
+ else value
40
+ end
41
+ end
42
+
43
+ def explain(key)
44
+ matching = @manifest.rules.explain(key)
45
+ winners = @manifest.rules.for(key)
46
+ {
47
+ key: key,
48
+ matched_blocks: matching.map do |block|
49
+ { match: block.match }.merge(DETAIL_FIELDS.to_h { |f| [f, !block.public_send(f).nil?] })
50
+ end,
51
+ effective: EFFECTIVE_FIELDS.to_h { |f| [f, effective_value(f, winners.public_send(f))] },
52
+ guards: Textus::Manifest::Policy::Predicates::FLOOR.keys.to_h do |action|
53
+ floor = Textus::Manifest::Policy::Predicates::FLOOR.fetch(action, [])
54
+ rule = Array(@manifest.rules.for(key).guard&.dig(action.to_s))
55
+ [action, { floor: floor, rule: rule }]
56
+ end,
57
+ }
58
+ end
59
+
60
+ def effective_value(field, value)
61
+ return nil if value.nil?
62
+
63
+ case field
64
+ when :retention then retention_hash(value, string_keys: false)
65
+ when :react then value.to_h
66
+ else value
67
+ end
68
+ end
69
+
70
+ def retention_hash(retention, string_keys:)
71
+ h = { ttl_seconds: retention.ttl_seconds, action: retention.action }
72
+ string_keys ? h.transform_keys(&:to_s) : h
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,54 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Handlers
5
+ module Maintenance
6
+ class RuleLint
7
+ def initialize(manifest:)
8
+ @manifest = manifest
9
+ end
10
+
11
+ def call(command, _call)
12
+ root = @manifest.data.root
13
+ live_rules = current_rules(root)
14
+ candidate_result = parse_candidate(command.candidate_yaml)
15
+ return candidate_result if candidate_result.is_a?(Value::Result) && candidate_result.failure?
16
+
17
+ candidate_rules = candidate_result
18
+ live_by_match = live_rules.to_h { |rule| [rule["match"], rule] }
19
+ candidate_by_match = candidate_rules.to_h { |rule| [rule["match"], rule] }
20
+
21
+ steps = (candidate_by_match.keys - live_by_match.keys).map do |match|
22
+ { "op" => "add_rule", "match" => match, "rule" => candidate_by_match[match] }
23
+ end
24
+ (live_by_match.keys - candidate_by_match.keys).each do |match|
25
+ steps << { "op" => "remove_rule", "match" => match }
26
+ end
27
+ (live_by_match.keys & candidate_by_match.keys).each do |match|
28
+ next if live_by_match[match] == candidate_by_match[match]
29
+
30
+ steps << { "op" => "change_rule", "match" => match, "from" => live_by_match[match], "to" => candidate_by_match[match] }
31
+ end
32
+
33
+ Value::Result.success(Textus::Store::Jobs::Plan.new(steps: steps, warnings: []))
34
+ end
35
+
36
+ private
37
+
38
+ def current_rules(root)
39
+ raw = YAML.safe_load_file(File.join(root, "manifest.yaml"), permitted_classes: [Symbol], aliases: false)
40
+ Array(raw["rules"])
41
+ end
42
+
43
+ def parse_candidate(yaml_text)
44
+ raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
45
+ return Value::Result.failure(:usage_error, "candidate is not a YAML mapping") unless raw.is_a?(Hash)
46
+
47
+ Array(raw["rules"])
48
+ rescue Psych::Exception => e
49
+ Value::Result.failure(:usage_error, "candidate YAML parse error: #{e.message}")
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,32 @@
1
+ module Textus
2
+ module Handlers
3
+ module Maintenance
4
+ class RuleList
5
+ def initialize(manifest:)
6
+ @manifest = manifest
7
+ end
8
+
9
+ def call(_command, _call)
10
+ Value::Result.success(@manifest.rules.blocks.map do |block|
11
+ row = { "match" => block.match }
12
+ LIST_FIELDS.each do |field|
13
+ value = block.public_send(field)
14
+ row[field.to_s] = serialize(field, value) unless value.nil?
15
+ end
16
+ row
17
+ end)
18
+ end
19
+
20
+ LIST_FIELDS = Textus::Manifest::Schema::FIELD_REGISTRY.select { |_, m| m[:in_rule_list] }.keys.freeze
21
+
22
+ def serialize(field, value)
23
+ case field
24
+ when :retention then { "ttl_seconds" => value.ttl_seconds, "action" => value.action.to_s }
25
+ when :react then value.to_h
26
+ else value
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ module Handlers
3
+ module Maintenance
4
+ class SchemaEnvelope
5
+ def initialize(manifest:, schemas:)
6
+ @manifest = manifest
7
+ @schemas = schemas
8
+ end
9
+
10
+ def call(command, _call)
11
+ mentry = @manifest.resolver.resolve(command.key).entry
12
+ schema = @schemas.fetch_or_nil(mentry.schema)
13
+ Value::Result.success("protocol" => Textus::PROTOCOL, "key" => command.key,
14
+ "schema_ref" => mentry.schema, "schema" => schema&.to_h)
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,48 @@
1
+ module Textus
2
+ module Handlers
3
+ module Read
4
+ class AuditEntries
5
+ def initialize(manifest:, audit_log:)
6
+ @manifest = manifest
7
+ @audit_log = audit_log
8
+ end
9
+
10
+ def call(command, _call)
11
+ cursor_check = check_cursor_expiry(command.seq_since)
12
+ return cursor_check if cursor_check
13
+
14
+ rows = @audit_log.scan(
15
+ seq_since: command.seq_since,
16
+ key: command.key, role: command.role, verb: command.verb,
17
+ correlation_id: command.correlation_id, limit: command.limit
18
+ ).select do |row|
19
+ next false if command.lane && !key_in_lane?(row["key"], command.lane)
20
+ next false if command.since && (row["ts"].nil? || Time.parse(row["ts"]) < command.since)
21
+
22
+ true
23
+ end
24
+ Value::Result.success(rows)
25
+ end
26
+
27
+ private
28
+
29
+ def check_cursor_expiry(seq_since)
30
+ return unless seq_since
31
+
32
+ min = @audit_log.min_available_seq
33
+ return unless min && seq_since < min - 1
34
+
35
+ Value::Result.failure(:cursor_expired, "requested seq #{seq_since} is below minimum available #{min}",
36
+ details: { requested: seq_since, min_available: min })
37
+ end
38
+
39
+ def key_in_lane?(key, lane)
40
+ mentry = @manifest.resolver.resolve(key).entry
41
+ mentry && mentry.lane == lane
42
+ rescue Textus::Error
43
+ false
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,71 @@
1
+ module Textus
2
+ module Handlers
3
+ module Read
4
+ class BlameEntry
5
+ def initialize(manifest:, orchestration:)
6
+ @manifest = manifest
7
+ @orchestration = orchestration
8
+ end
9
+
10
+ def call(command, call)
11
+ root = @manifest.data.root
12
+ audit = @orchestration.audit_entries(key: command.key, limit: command.limit, call: call)
13
+ return audit if audit.failure?
14
+
15
+ audit_rows = audit.value.fetch("rows")
16
+
17
+ path = resolve_path(command.key)
18
+ return Value::Result.success(audit_rows.map { |row| row.merge("git" => nil) }) unless git_tracked?(path, root: root)
19
+
20
+ Value::Result.success(audit_rows.map { |row| row.merge("git" => git_commit_at(path, timestamp: row["ts"], root: root)) })
21
+ end
22
+
23
+ private
24
+
25
+ def resolve_path(key)
26
+ res = @manifest.resolver.resolve(key)
27
+ path = res.path
28
+ path || Textus::Key::Path.resolve(@manifest.data, res.entry)
29
+ rescue Textus::Error
30
+ nil
31
+ end
32
+
33
+ def git_tracked?(path, root:)
34
+ return false if path.nil? || !File.exist?(path) || !git_repo?(root)
35
+
36
+ _out, _err, status = Open3.capture3("git", "ls-files", "--error-unmatch", path, chdir: root)
37
+ status.success?
38
+ rescue Errno::ENOENT
39
+ false
40
+ end
41
+
42
+ def git_repo?(root)
43
+ dir = root
44
+ loop do
45
+ return true if File.directory?(File.join(dir, ".git"))
46
+
47
+ parent = File.dirname(dir)
48
+ return false if parent == dir
49
+
50
+ dir = parent
51
+ end
52
+ end
53
+
54
+ def git_commit_at(path, timestamp:, root:)
55
+ args = ["git", "log", "-1"]
56
+ args << "--before=#{timestamp}" if timestamp
57
+ args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
58
+ out, _err, status = Open3.capture3(*args, chdir: root)
59
+ return nil unless status.success?
60
+
61
+ sha, author, date, subject = out.strip.split("\t", 4)
62
+ return nil if sha.nil? || sha.empty?
63
+
64
+ { "sha" => sha, "author" => author, "date" => date, "subject" => subject }
65
+ rescue Errno::ENOENT
66
+ nil
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Handlers
3
+ module Read
4
+ class DepsEntry
5
+ def initialize(manifest:)
6
+ @manifest = manifest
7
+ end
8
+
9
+ def call(command, _call)
10
+ entry = @manifest.data.entries.find { |e| e.key == command.key }
11
+ deps = entry&.external? ? Array(entry.source&.sources).compact : []
12
+ Value::Result.success("key" => command.key, "deps" => deps.uniq)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,68 @@
1
+ module Textus
2
+ module Handlers
3
+ module Read
4
+ class GetEntry
5
+ def initialize(container:, freshness_evaluator:)
6
+ @container = container
7
+ @freshness_evaluator = freshness_evaluator
8
+ end
9
+
10
+ def call(command, _call)
11
+ envelope = Store::Entry::Reader.from(container: @container).read(command.key)
12
+ return Value::Result.failure(:not_found, "no entry at #{command.key}") unless envelope
13
+
14
+ envelope = expand_sources(envelope, depth: 0)
15
+ Value::Result.success(envelope.with(freshness: @freshness_evaluator.verdict(resolve_entry(command.key))))
16
+ end
17
+
18
+ MAX_SOURCE_DEPTH = 5
19
+
20
+ private
21
+
22
+ def expand_sources(envelope, depth:)
23
+ return envelope if depth >= MAX_SOURCE_DEPTH
24
+
25
+ raw_sources = Array(envelope.meta["sources"])
26
+ return envelope if raw_sources.empty?
27
+
28
+ expanded = raw_sources.map { |src| expand_one_source(src, depth: depth) }
29
+ envelope.with(sources: expanded)
30
+ end
31
+
32
+ def expand_one_source(src, depth:)
33
+ src = { "key" => src } if src.is_a?(String)
34
+ return src unless src.is_a?(Hash) && src["key"].is_a?(String)
35
+
36
+ key = src["key"]
37
+ stored_etag = src["etag"]
38
+ current_etag = resolve_current_etag(key)
39
+ suspended = stored_etag && current_etag ? stored_etag != current_etag : false
40
+
41
+ result = src.merge("suspended" => suspended)
42
+
43
+ child_env = @container.reader.read(key)
44
+ if child_env
45
+ child_expanded = expand_sources(child_env, depth: depth + 1)
46
+ child_sources = Array(child_expanded.sources)
47
+ result = result.merge("sources" => child_sources) unless child_sources.empty?
48
+ end
49
+
50
+ result
51
+ end
52
+
53
+ def resolve_current_etag(key)
54
+ path = @container.manifest.resolver.resolve(key).path
55
+ return nil unless @container.file_store.exists?(path)
56
+
57
+ @container.file_store.etag(path)
58
+ rescue Textus::Error
59
+ nil
60
+ end
61
+
62
+ def resolve_entry(key)
63
+ @container.manifest.resolver.resolve(key).entry
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,36 @@
1
+ module Textus
2
+ module Handlers
3
+ module Read
4
+ class ListKeys
5
+ def initialize(manifest:, job_store: nil)
6
+ @manifest = manifest
7
+ @job_store = job_store
8
+ end
9
+
10
+ def call(command, _call)
11
+ q = command.respond_to?(:q) ? command.q : nil
12
+ schema = command.respond_to?(:schema) ? command.schema : nil
13
+
14
+ return sqlite_list(q: q, schema: schema, lane: command.lane, prefix: command.prefix) if @job_store && (q || schema)
15
+
16
+ manifest_list(prefix: command.prefix, lane: command.lane)
17
+ end
18
+
19
+ private
20
+
21
+ def sqlite_list(q:, schema:, lane:, prefix:) # rubocop:disable Naming/MethodParameterName
22
+ rows = @job_store.search_entries(q: q, schema: schema, lane: lane, prefix: prefix)
23
+ Value::Result.success((rows || []).map { |r| { "key" => r["key"], "lane" => r["lane"] } })
24
+ end
25
+
26
+ def manifest_list(prefix:, lane:)
27
+ rows = @manifest.resolver.enumerate(prefix: prefix)
28
+ rows = rows.select { |row| row[:manifest_entry].lane == lane } if lane
29
+ Value::Result.success(rows.map do |row|
30
+ { "key" => row[:key], "lane" => row[:manifest_entry].lane, "path" => row[:path] }
31
+ end)
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,66 @@
1
+ module Textus
2
+ module Handlers
3
+ module Read
4
+ class PulseEntries
5
+ def initialize(manifest:, audit_log:, file_store:, orchestration:, job_store: nil)
6
+ @manifest = manifest
7
+ @audit_log = audit_log
8
+ @file_store = file_store
9
+ @orchestration = orchestration
10
+ @job_store = job_store
11
+ end
12
+
13
+ def call(command, call)
14
+ root = @manifest.data.root
15
+ since = command.since || Textus::Store::Cursor.new(root: root, role: call.role).read
16
+
17
+ changed = changed_since(since, call)
18
+
19
+ result = {
20
+ "cursor" => @audit_log.latest_seq,
21
+ "changed" => changed,
22
+ "pending_review" => review_keys(call),
23
+ "contract_etag" => Textus::Value::Etag.for_contract(root),
24
+ "index_etag" => index_etag,
25
+ }
26
+
27
+ Textus::Store::Cursor.new(root: root, role: call.role).write(result["cursor"])
28
+ Value::Result.success(result)
29
+ end
30
+
31
+ private
32
+
33
+ def changed_since(since, call)
34
+ if @job_store
35
+ sqlite_rows = @job_store.audit_events_since(seq: since)
36
+ return sqlite_rows.map { |r| { "key" => r["key"], "verb" => r["verb"], "seq" => r["seq"] } } if sqlite_rows.any?
37
+ end
38
+
39
+ # Fall back to flat-log scan when SQLite index is empty for this window
40
+ # (writes that bypassed dispatch, fresh stores, or pre-migration entries).
41
+ audit = @orchestration.audit_entries(seq_since: since, call: call)
42
+ return [] if audit.failure?
43
+
44
+ audit.value.fetch("rows") || []
45
+ end
46
+
47
+ def review_keys(call)
48
+ queue = @manifest.policy.queue_lane
49
+ return [] unless queue
50
+
51
+ result = @orchestration.list_keys(prefix: nil, lane: queue, call: call)
52
+ return [] unless result.success?
53
+
54
+ result.value.fetch("rows").map { |r| r["key"] }
55
+ end
56
+
57
+ def index_etag
58
+ path = @manifest.resolver.resolve("artifacts.system.index").path
59
+ File.exist?(path) ? @file_store.etag(path) : nil
60
+ rescue Textus::Error
61
+ nil
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ module Handlers
3
+ module Read
4
+ class RdepsEntry
5
+ def initialize(manifest:)
6
+ @manifest = manifest
7
+ end
8
+
9
+ def call(command, _call)
10
+ rdeps = @manifest.data.entries.each_with_object([]) do |entry, acc|
11
+ next unless entry.external?
12
+
13
+ sources = Array(entry.source&.sources).compact
14
+ acc << entry.key if sources.any? { |source| source == command.key || command.key.start_with?("#{source}.") }
15
+ end
16
+ Value::Result.success("key" => command.key, "rdeps" => rdeps)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ module Textus
2
+ module Handlers
3
+ module Read
4
+ class UidEntry
5
+ def initialize(container:)
6
+ @container = container
7
+ end
8
+
9
+ def call(command, _call)
10
+ envelope = Store::Entry::Reader.from(container: @container).read(command.key)
11
+ return Value::Result.failure(:not_found, "no entry at #{command.key}") unless envelope
12
+
13
+ Value::Result.success(envelope.uid)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,18 @@
1
+ module Textus
2
+ module Handlers
3
+ module Read
4
+ class WhereEntry
5
+ def initialize(manifest:)
6
+ @manifest = manifest
7
+ end
8
+
9
+ def call(command, _call)
10
+ res = @manifest.resolver.resolve(command.key)
11
+ mentry = res.entry
12
+ Value::Result.success("protocol" => Textus::PROTOCOL, "key" => command.key,
13
+ "lane" => mentry.lane, "owner" => mentry.owner, "path" => res.path)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,39 @@
1
+ module Textus
2
+ module Handlers
3
+ module Write
4
+ class AcceptProposal
5
+ def initialize(container:)
6
+ @container = container
7
+ end
8
+
9
+ def call(command, call)
10
+ reader = Store::Entry::Reader.from(container: @container)
11
+ env = reader.read(command.pending_key)
12
+ proposal = env&.meta&.dig("proposal") or
13
+ return Value::Result.failure(:proposal_error, "entry has no proposal block: #{command.pending_key}")
14
+ target = proposal["target_key"] or
15
+ return Value::Result.failure(:proposal_error, "proposal missing target_key")
16
+ action = proposal["action"] || "put"
17
+
18
+ writer = Store::Entry::Writer.from(container: @container, call: call)
19
+ case action
20
+ when "put"
21
+ mentry = @container.manifest.resolver.resolve(target).entry
22
+ writer.put(
23
+ target, mentry: mentry,
24
+ payload: Textus::Value::Payload.new(meta: env.meta["_meta"] || {}, body: env.body, content: nil)
25
+ )
26
+ when "delete"
27
+ writer.delete(target)
28
+ else
29
+ return Value::Result.failure(:proposal_error, "unknown action: #{action}")
30
+ end
31
+
32
+ writer.delete(command.pending_key)
33
+ Value::Result.success("protocol" => Textus::PROTOCOL, "accepted" => command.pending_key,
34
+ "target_key" => target, "action" => action, "cascade_key" => target)
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end