textus 0.55.1 → 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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +9 -9
  4. data/SPEC.md +14 -13
  5. data/docs/architecture/README.md +3 -3
  6. data/docs/reference/conventions.md +5 -2
  7. data/lib/textus/boot.rb +64 -85
  8. data/lib/textus/{gate → dispatch}/binder.rb +8 -10
  9. data/lib/textus/dispatch/contracts.rb +63 -0
  10. data/lib/textus/dispatch/handler_registry.rb +21 -0
  11. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  12. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  13. data/lib/textus/dispatch/middleware/base.rb +26 -0
  14. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  15. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  16. data/lib/textus/dispatch/pipeline.rb +35 -0
  17. data/lib/textus/doctor/check/audit_log.rb +1 -1
  18. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  19. data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
  20. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  21. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  22. data/lib/textus/doctor/check/sentinels.rb +1 -1
  23. data/lib/textus/doctor/check.rb +8 -6
  24. data/lib/textus/doctor.rb +1 -1
  25. data/lib/textus/errors.rb +2 -0
  26. data/lib/textus/format/base.rb +36 -8
  27. data/lib/textus/format/json.rb +0 -21
  28. data/lib/textus/format/markdown.rb +0 -21
  29. data/lib/textus/format/yaml.rb +0 -21
  30. data/lib/textus/format.rb +16 -1
  31. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  32. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  33. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  34. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  35. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  36. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  37. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  38. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  39. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  40. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  41. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  42. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  43. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  44. data/lib/textus/handlers/read/get_entry.rb +68 -0
  45. data/lib/textus/handlers/read/list_keys.rb +36 -0
  46. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  47. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  48. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  49. data/lib/textus/handlers/read/where_entry.rb +18 -0
  50. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  51. data/lib/textus/handlers/write/data_mv.rb +55 -0
  52. data/lib/textus/handlers/write/delete_key.rb +17 -0
  53. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  54. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  55. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  56. data/lib/textus/handlers/write/move_key.rb +80 -0
  57. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  58. data/lib/textus/handlers/write/put_entry.rb +29 -0
  59. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  60. data/lib/textus/init.rb +5 -5
  61. data/lib/textus/manifest/capabilities.rb +1 -1
  62. data/lib/textus/manifest/entry/base.rb +3 -3
  63. data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
  64. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  65. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  66. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  67. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  68. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  69. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  70. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  71. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  72. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  73. data/lib/textus/manifest/policy/predicates.rb +54 -0
  74. data/lib/textus/manifest/policy/retention.rb +1 -1
  75. data/lib/textus/orchestration.rb +55 -0
  76. data/lib/textus/port/audit_log.rb +6 -6
  77. data/lib/textus/port/build_lock.rb +1 -1
  78. data/lib/textus/{core → port}/sentinel.rb +1 -6
  79. data/lib/textus/port/sentinel_store.rb +3 -3
  80. data/lib/textus/port/storage/file_store.rb +23 -0
  81. data/lib/textus/port/storage/interface.rb +17 -0
  82. data/lib/textus/port/store.rb +58 -2
  83. data/lib/textus/port/watcher_lock.rb +2 -2
  84. data/lib/textus/produce/engine.rb +1 -11
  85. data/lib/textus/produce/publisher.rb +21 -0
  86. data/lib/textus/schema/registry.rb +42 -0
  87. data/lib/textus/schema/tools.rb +3 -10
  88. data/lib/textus/store/container.rb +140 -10
  89. data/lib/textus/store/cursor.rb +1 -1
  90. data/lib/textus/store/{envelope → entry}/reader.rb +8 -4
  91. data/lib/textus/store/{envelope → entry}/writer.rb +53 -29
  92. data/lib/textus/store/envelope/meta.rb +61 -0
  93. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  94. data/lib/textus/store/freshness/evaluator.rb +20 -0
  95. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  96. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  97. data/lib/textus/store/freshness.rb +8 -0
  98. data/lib/textus/store/index/builder.rb +5 -3
  99. data/lib/textus/store/jobs/planner.rb +27 -7
  100. data/lib/textus/store/jobs/queue.rb +9 -1
  101. data/lib/textus/store/jobs/retention/base.rb +52 -0
  102. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  103. data/lib/textus/store/jobs/retention.rb +1 -43
  104. data/lib/textus/store/jobs/sweep.rb +2 -2
  105. data/lib/textus/store/{geometry.rb → layout.rb} +19 -3
  106. data/lib/textus/store.rb +53 -30
  107. data/lib/textus/surface/cli/runner.rb +8 -9
  108. data/lib/textus/surface/cli/verb/doctor.rb +3 -2
  109. data/lib/textus/surface/cli/verb/get.rb +5 -3
  110. data/lib/textus/surface/cli/verb/put.rb +5 -3
  111. data/lib/textus/surface/mcp/catalog.rb +26 -62
  112. data/lib/textus/surface/mcp/errors.rb +0 -10
  113. data/lib/textus/surface/mcp/projector.rb +20 -0
  114. data/lib/textus/surface/mcp/server.rb +20 -31
  115. data/lib/textus/{core → value}/duration.rb +1 -4
  116. data/lib/textus/value/envelope.rb +5 -4
  117. data/lib/textus/value/etag.rb +1 -1
  118. data/lib/textus/value/payload.rb +7 -0
  119. data/lib/textus/value/result.rb +36 -16
  120. data/lib/textus/verb_registry.rb +417 -0
  121. data/lib/textus/version.rb +1 -1
  122. data/lib/textus/workflow/loader.rb +1 -1
  123. data/lib/textus/workflow/runner.rb +10 -18
  124. data/lib/textus.rb +0 -64
  125. metadata +70 -70
  126. data/lib/textus/action/accept.rb +0 -46
  127. data/lib/textus/action/audit.rb +0 -94
  128. data/lib/textus/action/base.rb +0 -42
  129. data/lib/textus/action/blame.rb +0 -79
  130. data/lib/textus/action/boot.rb +0 -15
  131. data/lib/textus/action/data_mv.rb +0 -58
  132. data/lib/textus/action/deps.rb +0 -19
  133. data/lib/textus/action/doctor.rb +0 -17
  134. data/lib/textus/action/drain.rb +0 -31
  135. data/lib/textus/action/enqueue.rb +0 -37
  136. data/lib/textus/action/get.rb +0 -34
  137. data/lib/textus/action/ingest.rb +0 -199
  138. data/lib/textus/action/jobs.rb +0 -27
  139. data/lib/textus/action/key_delete.rb +0 -26
  140. data/lib/textus/action/key_delete_prefix.rb +0 -35
  141. data/lib/textus/action/key_mv.rb +0 -122
  142. data/lib/textus/action/key_mv_prefix.rb +0 -48
  143. data/lib/textus/action/list.rb +0 -28
  144. data/lib/textus/action/propose.rb +0 -42
  145. data/lib/textus/action/published.rb +0 -22
  146. data/lib/textus/action/pulse.rb +0 -49
  147. data/lib/textus/action/put.rb +0 -38
  148. data/lib/textus/action/rdeps.rb +0 -24
  149. data/lib/textus/action/reject.rb +0 -28
  150. data/lib/textus/action/rule_explain.rb +0 -81
  151. data/lib/textus/action/rule_lint.rb +0 -62
  152. data/lib/textus/action/rule_list.rb +0 -38
  153. data/lib/textus/action/schema_envelope.rb +0 -22
  154. data/lib/textus/action/uid.rb +0 -19
  155. data/lib/textus/action/where.rb +0 -21
  156. data/lib/textus/contract/arg.rb +0 -10
  157. data/lib/textus/contract/dsl.rb +0 -88
  158. data/lib/textus/contract/spec.rb +0 -25
  159. data/lib/textus/contract.rb +0 -12
  160. data/lib/textus/core/freshness/evaluator.rb +0 -150
  161. data/lib/textus/core/freshness.rb +0 -11
  162. data/lib/textus/core/retention/sweep.rb +0 -57
  163. data/lib/textus/core/retention.rb +0 -11
  164. data/lib/textus/format/shared.rb +0 -17
  165. data/lib/textus/gate/auth.rb +0 -212
  166. data/lib/textus/gate.rb +0 -92
  167. data/lib/textus/meta.rb +0 -54
  168. data/lib/textus/schemas.rb +0 -54
  169. data/lib/textus/store/compositor.rb +0 -34
  170. data/lib/textus/store/session.rb +0 -37
  171. data/lib/textus/surface/projector.rb +0 -27
  172. data/lib/textus/surface/role_scope.rb +0 -34
@@ -0,0 +1,159 @@
1
+ require "fileutils"
2
+ require "date"
3
+ require "digest"
4
+
5
+ module Textus
6
+ module Handlers
7
+ module Maintenance
8
+ class IngestEntry
9
+ SOURCE_KINDS = %w[url file asset].freeze
10
+ CONTENT_HASH_ALGO = "sha256"
11
+
12
+ def initialize(container:)
13
+ @container = container
14
+ end
15
+
16
+ def call(command, call)
17
+ unless SOURCE_KINDS.include?(command.kind)
18
+ return Value::Result.failure(:usage_error,
19
+ "ingest kind must be one of #{SOURCE_KINDS.join("|")}")
20
+ end
21
+
22
+ case command.kind
23
+ when "url" then return Value::Result.failure(:usage_error, "ingest url requires url") unless command.url
24
+ when "file" then return Value::Result.failure(:usage_error, "ingest file requires path") unless command.path
25
+ when "asset"
26
+ return Value::Result.failure(:usage_error, "ingest asset requires path") unless command.path
27
+ return Value::Result.failure(:usage_error, "ingest asset requires lane") unless command.lane
28
+ end
29
+
30
+ now = Time.now.utc
31
+ key = derive_key(now, command.kind, command.slug)
32
+ content_hash = compute_content_hash(command)
33
+ mentry = @container.manifest.resolver.resolve(key).entry
34
+ ts = now.iso8601
35
+
36
+ structured = build_structured(ts, now, content_hash, command)
37
+ store = @container.job_store
38
+ index = Textus::Store::Index::Lookup.new(store:)
39
+
40
+ duplicate_key = find_duplicate(index, content_hash, command)
41
+
42
+ env = if duplicate_key && duplicate_key != key
43
+ supersede_entry(duplicate_key, key, structured, call, store, command)
44
+ else
45
+ write_entry(key, structured, mentry, call)
46
+ end
47
+
48
+ rebuild_index(store)
49
+ Value::Result.success(env)
50
+ end
51
+
52
+ private
53
+
54
+ def derive_key(now, kind, slug)
55
+ date = now.strftime("%Y.%m.%d")
56
+ "raw.#{date}.#{kind}-#{slug}"
57
+ end
58
+
59
+ def compute_content_hash(command)
60
+ digest = Digest::SHA256.new
61
+ case command.kind
62
+ when "url" then digest.update(command.url)
63
+ when "file", "asset" then digest.file(command.path)
64
+ end
65
+ "#{CONTENT_HASH_ALGO}:#{digest.hexdigest}"
66
+ end
67
+
68
+ def build_structured(timestamp, now, content_hash, command)
69
+ base = { "ingested_at" => timestamp, "content_hash" => content_hash }
70
+ case command.kind
71
+ when "url"
72
+ base.merge("source" => { "kind" => "url", "url" => command.url,
73
+ "label" => command.label || command.url }, "body" => nil)
74
+ when "file"
75
+ base.merge("source" => { "kind" => "file", "path" => command.path,
76
+ "label" => command.label || File.basename(command.path) },
77
+ "body" => File.read(command.path))
78
+ when "asset"
79
+ asset_rel = copy_asset(now, command.path, command.lane)
80
+ base.merge("source" => { "kind" => "asset",
81
+ "label" => command.label || File.basename(command.path) },
82
+ "asset" => asset_rel, "body" => nil)
83
+ end
84
+ end
85
+
86
+ def copy_asset(now, path, lane)
87
+ date_path = now.strftime("%Y/%m/%d")
88
+ filename = File.basename(path)
89
+ assets_dir = @container.layout.asset_raw_dir(date_path, lane)
90
+ FileUtils.mkdir_p(assets_dir)
91
+ FileUtils.cp(path, File.join(assets_dir, filename))
92
+ sentinel = @container.layout.asset_sentinel_path
93
+ File.write(sentinel, "*\n") unless File.exist?(sentinel)
94
+ "raw/#{date_path}/#{lane}/#{filename}"
95
+ end
96
+
97
+ def write_entry(key, structured, mentry, call)
98
+ writer = Store::Entry::Writer.from(container: @container, call: call)
99
+ writer.put(key, mentry: mentry,
100
+ payload: Textus::Value::Payload.new(meta: nil, body: nil, content: structured))
101
+ end
102
+
103
+ def find_duplicate(index, content_hash, command)
104
+ dup = index.find_by_hash(content_hash)
105
+ return dup if dup
106
+ return unless command.kind == "url"
107
+
108
+ index.find_by_url(command.url)
109
+ end
110
+
111
+ def supersede_entry(old_key, new_key, structured, call, store, command)
112
+ old_mentry = @container.manifest.resolver.resolve(old_key).entry
113
+ reader = Store::Entry::Reader.from(container: @container)
114
+ old_env = reader.read(old_key)
115
+ old_content = old_env&.content || {}
116
+ tombstone = {}
117
+ %w[ingested_at].each { |k| tombstone[k] = old_content[k] if old_content.key?(k) }
118
+ source_kind = old_content.dig("source", "kind")
119
+ tombstone["source"] = { "kind" => source_kind } if source_kind
120
+ tombstone["superseded_by"] = new_key
121
+
122
+ writer = Store::Entry::Writer.from(container: @container, call: call)
123
+ writer.put(old_key, mentry: old_mentry,
124
+ payload: Textus::Value::Payload.new(meta: nil, body: nil, content: tombstone))
125
+
126
+ structured["supersedes"] = old_key
127
+ env = write_entry(new_key, structured,
128
+ @container.manifest.resolver.resolve(new_key).entry, call)
129
+
130
+ move_asset(old_content["asset"], command.lane) if command.kind == "asset" && old_content["asset"]
131
+
132
+ rebuild_index(store)
133
+ env
134
+ end
135
+
136
+ def move_asset(old_rel, lane)
137
+ old_path = @container.layout.asset_resolve(old_rel)
138
+ return unless File.exist?(old_path)
139
+
140
+ now = Time.now.utc
141
+ date_path = now.strftime("%Y/%m/%d")
142
+ filename = File.basename(old_path)
143
+ new_dir = @container.layout.asset_raw_dir(date_path, lane)
144
+ new_path = File.join(new_dir, filename)
145
+ return if old_path == new_path
146
+
147
+ FileUtils.mkdir_p(new_dir)
148
+ FileUtils.mv(old_path, new_path)
149
+ rescue Errno::ENOENT, Errno::EACCES => e
150
+ warn "[textus ingest] could not move asset #{old_rel}: #{e.message}"
151
+ end
152
+
153
+ def rebuild_index(store)
154
+ Textus::Store::Index::Builder.new(store:).rebuild!(resolver: @container.manifest.resolver)
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end
@@ -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