textus 0.51.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (250) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +74 -66
  4. data/SPEC.md +100 -223
  5. data/docs/architecture/README.md +91 -80
  6. data/docs/reference/conventions.md +8 -8
  7. data/exe/textus +1 -1
  8. data/lib/textus/action/accept.rb +53 -0
  9. data/lib/textus/action/audit.rb +133 -0
  10. data/lib/textus/action/base.rb +42 -0
  11. data/lib/textus/{read → action}/blame.rb +30 -22
  12. data/lib/textus/action/boot.rb +26 -0
  13. data/lib/textus/action/data_mv.rb +71 -0
  14. data/lib/textus/action/deps.rb +48 -0
  15. data/lib/textus/action/doctor.rb +26 -0
  16. data/lib/textus/action/drain.rb +41 -0
  17. data/lib/textus/action/enqueue.rb +55 -0
  18. data/lib/textus/action/get.rb +80 -0
  19. data/lib/textus/action/jobs.rb +38 -0
  20. data/lib/textus/action/key_delete.rb +46 -0
  21. data/lib/textus/action/key_delete_prefix.rb +46 -0
  22. data/lib/textus/action/key_mv.rb +143 -0
  23. data/lib/textus/action/key_mv_prefix.rb +59 -0
  24. data/lib/textus/action/list.rb +44 -0
  25. data/lib/textus/action/propose.rb +54 -0
  26. data/lib/textus/action/published.rb +26 -0
  27. data/lib/textus/action/pulse/scanner.rb +118 -0
  28. data/lib/textus/action/pulse.rb +87 -0
  29. data/lib/textus/action/put.rb +63 -0
  30. data/lib/textus/action/rdeps.rb +49 -0
  31. data/lib/textus/action/reject.rb +49 -0
  32. data/lib/textus/action/rule_explain.rb +95 -0
  33. data/lib/textus/action/rule_lint.rb +70 -0
  34. data/lib/textus/action/rule_list.rb +46 -0
  35. data/lib/textus/action/schema_envelope.rb +31 -0
  36. data/lib/textus/action/uid.rb +35 -0
  37. data/lib/textus/action/where.rb +38 -0
  38. data/lib/textus/action/write_verb.rb +58 -0
  39. data/lib/textus/background/job/base.rb +27 -0
  40. data/lib/textus/background/job/materialize.rb +31 -0
  41. data/lib/textus/background/job/refresh.rb +22 -0
  42. data/lib/textus/background/job/sweep.rb +31 -0
  43. data/lib/textus/background/job.rb +19 -0
  44. data/lib/textus/background/plan.rb +9 -0
  45. data/lib/textus/background/planner/plan.rb +113 -0
  46. data/lib/textus/background/retention/apply.rb +50 -0
  47. data/lib/textus/background/worker.rb +67 -0
  48. data/lib/textus/boot.rb +59 -49
  49. data/lib/textus/command.rb +36 -0
  50. data/lib/textus/container.rb +1 -1
  51. data/lib/textus/{domain → core}/duration.rb +1 -1
  52. data/lib/textus/{domain → core}/freshness/evaluator.rb +13 -13
  53. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  54. data/lib/textus/{domain → core}/freshness.rb +2 -2
  55. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  56. data/lib/textus/{domain → core}/retention.rb +2 -2
  57. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  58. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  59. data/lib/textus/doctor/check/handler_permit.rb +34 -0
  60. data/lib/textus/doctor/check/hooks.rb +11 -18
  61. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  62. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  63. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  64. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  65. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  66. data/lib/textus/doctor/check/sentinels.rb +2 -2
  67. data/lib/textus/doctor/check.rb +12 -9
  68. data/lib/textus/{read → doctor}/validator.rb +22 -13
  69. data/lib/textus/doctor.rb +6 -6
  70. data/lib/textus/envelope/io/writer.rb +65 -36
  71. data/lib/textus/envelope.rb +5 -3
  72. data/lib/textus/errors.rb +19 -11
  73. data/lib/textus/events.rb +21 -0
  74. data/lib/textus/gate/auth.rb +181 -0
  75. data/lib/textus/gate.rb +114 -0
  76. data/lib/textus/init/templates/machine_intake.rb +40 -36
  77. data/lib/textus/init/templates/orientation_reducer.rb +15 -11
  78. data/lib/textus/init.rb +93 -76
  79. data/lib/textus/key/path.rb +9 -2
  80. data/lib/textus/layout.rb +21 -0
  81. data/lib/textus/manifest/capabilities.rb +1 -1
  82. data/lib/textus/manifest/data.rb +30 -15
  83. data/lib/textus/manifest/entry/base.rb +15 -11
  84. data/lib/textus/manifest/entry/parser.rb +6 -6
  85. data/lib/textus/manifest/entry/produced.rb +3 -2
  86. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  87. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
  88. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  89. data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
  90. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  91. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  92. data/lib/textus/manifest/policy/react.rb +30 -0
  93. data/lib/textus/{domain → manifest}/policy/retention.rb +4 -4
  94. data/lib/textus/{domain → manifest}/policy/source.rb +27 -28
  95. data/lib/textus/manifest/policy.rb +36 -48
  96. data/lib/textus/manifest/resolver.rb +3 -2
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +18 -12
  99. data/lib/textus/manifest/schema/validator.rb +27 -25
  100. data/lib/textus/manifest/schema/vocabulary.rb +3 -3
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/{produce → pipeline}/acquire/handler.rb +3 -3
  104. data/lib/textus/{produce → pipeline}/acquire/intake.rb +23 -21
  105. data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
  106. data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
  107. data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
  108. data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
  109. data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
  110. data/lib/textus/{produce → pipeline}/engine.rb +17 -63
  111. data/lib/textus/{produce → pipeline}/render.rb +3 -1
  112. data/lib/textus/ports/audit_log.rb +31 -5
  113. data/lib/textus/ports/audit_subscriber.rb +4 -4
  114. data/lib/textus/ports/build_lock.rb +1 -1
  115. data/lib/textus/ports/queue/job.rb +65 -0
  116. data/lib/textus/ports/queue.rb +130 -0
  117. data/lib/textus/ports/sentinel_store.rb +2 -2
  118. data/lib/textus/ports/watcher_lock.rb +48 -0
  119. data/lib/textus/projection.rb +8 -8
  120. data/lib/textus/role.rb +1 -1
  121. data/lib/textus/schema/tools.rb +4 -3
  122. data/lib/textus/session.rb +6 -3
  123. data/lib/textus/step/base.rb +35 -0
  124. data/lib/textus/step/builtin/csv_fetch.rb +19 -0
  125. data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
  126. data/lib/textus/step/builtin/json_fetch.rb +18 -0
  127. data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
  128. data/lib/textus/step/builtin/rss_fetch.rb +26 -0
  129. data/lib/textus/step/builtin.rb +22 -0
  130. data/lib/textus/{hooks → step}/catalog.rb +3 -4
  131. data/lib/textus/{hooks → step}/context.rb +15 -13
  132. data/lib/textus/step/discovery.rb +24 -0
  133. data/lib/textus/{hooks → step}/error_log.rb +1 -1
  134. data/lib/textus/{hooks → step}/event_bus.rb +15 -16
  135. data/lib/textus/step/fetch.rb +13 -0
  136. data/lib/textus/{hooks → step}/fire_report.rb +1 -1
  137. data/lib/textus/step/loader.rb +108 -0
  138. data/lib/textus/step/observe.rb +31 -0
  139. data/lib/textus/step/registry_store.rb +66 -0
  140. data/lib/textus/{hooks → step}/signature.rb +1 -1
  141. data/lib/textus/step/transform.rb +12 -0
  142. data/lib/textus/step/validate.rb +11 -0
  143. data/lib/textus/step.rb +10 -0
  144. data/lib/textus/store.rb +17 -15
  145. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  146. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  147. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  148. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  149. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  150. data/lib/textus/surfaces/cli/group.rb +50 -0
  151. data/lib/textus/surfaces/cli/runner.rb +236 -0
  152. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  153. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  154. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  155. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  156. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  157. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  158. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  159. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  160. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  161. data/lib/textus/surfaces/cli/verb.rb +111 -0
  162. data/lib/textus/surfaces/cli.rb +148 -0
  163. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  164. data/lib/textus/surfaces/mcp/errors.rb +34 -0
  165. data/lib/textus/surfaces/mcp/server.rb +145 -0
  166. data/lib/textus/surfaces/mcp/session.rb +9 -0
  167. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  168. data/lib/textus/surfaces/mcp.rb +8 -0
  169. data/lib/textus/surfaces/role_scope.rb +38 -0
  170. data/lib/textus/surfaces/watcher.rb +38 -0
  171. data/lib/textus/version.rb +1 -1
  172. data/lib/textus.rb +64 -22
  173. metadata +133 -107
  174. data/lib/textus/cli/group/hook.rb +0 -9
  175. data/lib/textus/cli/group/key.rb +0 -9
  176. data/lib/textus/cli/group/mcp.rb +0 -9
  177. data/lib/textus/cli/group/rule.rb +0 -9
  178. data/lib/textus/cli/group/schema.rb +0 -9
  179. data/lib/textus/cli/group/zone.rb +0 -9
  180. data/lib/textus/cli/group.rb +0 -48
  181. data/lib/textus/cli/runner.rb +0 -193
  182. data/lib/textus/cli/verb/doctor.rb +0 -17
  183. data/lib/textus/cli/verb/get.rb +0 -18
  184. data/lib/textus/cli/verb/hook_run.rb +0 -48
  185. data/lib/textus/cli/verb/hooks.rb +0 -50
  186. data/lib/textus/cli/verb/init.rb +0 -18
  187. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  188. data/lib/textus/cli/verb/put.rb +0 -30
  189. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  190. data/lib/textus/cli/verb/schema_init.rb +0 -19
  191. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  192. data/lib/textus/cli/verb.rb +0 -116
  193. data/lib/textus/cli.rb +0 -138
  194. data/lib/textus/dispatcher.rb +0 -52
  195. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  196. data/lib/textus/domain/action.rb +0 -9
  197. data/lib/textus/domain/permission.rb +0 -7
  198. data/lib/textus/domain/policy/base_guards.rb +0 -25
  199. data/lib/textus/domain/policy/evaluation.rb +0 -15
  200. data/lib/textus/domain/policy/guard.rb +0 -35
  201. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  202. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  203. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  204. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  205. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  206. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  207. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  208. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  209. data/lib/textus/hooks/builtin.rb +0 -70
  210. data/lib/textus/hooks/loader.rb +0 -54
  211. data/lib/textus/hooks/rpc_registry.rb +0 -43
  212. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  213. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  214. data/lib/textus/maintenance/reconcile.rb +0 -160
  215. data/lib/textus/maintenance/rule_lint.rb +0 -66
  216. data/lib/textus/maintenance/zone_mv.rb +0 -64
  217. data/lib/textus/maintenance.rb +0 -15
  218. data/lib/textus/mcp/catalog.rb +0 -70
  219. data/lib/textus/mcp/errors.rb +0 -32
  220. data/lib/textus/mcp/server.rb +0 -138
  221. data/lib/textus/mcp/session.rb +0 -7
  222. data/lib/textus/mcp/tool_schemas.rb +0 -15
  223. data/lib/textus/mcp.rb +0 -6
  224. data/lib/textus/mustache.rb +0 -117
  225. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -69
  226. data/lib/textus/produce/events.rb +0 -36
  227. data/lib/textus/read/audit.rb +0 -130
  228. data/lib/textus/read/boot.rb +0 -26
  229. data/lib/textus/read/capabilities.rb +0 -70
  230. data/lib/textus/read/deps.rb +0 -38
  231. data/lib/textus/read/doctor.rb +0 -27
  232. data/lib/textus/read/freshness.rb +0 -152
  233. data/lib/textus/read/get.rb +0 -73
  234. data/lib/textus/read/list.rb +0 -24
  235. data/lib/textus/read/published.rb +0 -22
  236. data/lib/textus/read/pulse.rb +0 -98
  237. data/lib/textus/read/rdeps.rb +0 -39
  238. data/lib/textus/read/rule_explain.rb +0 -96
  239. data/lib/textus/read/rule_list.rb +0 -54
  240. data/lib/textus/read/schema_envelope.rb +0 -25
  241. data/lib/textus/read/uid.rb +0 -29
  242. data/lib/textus/read/validate_all.rb +0 -36
  243. data/lib/textus/read/where.rb +0 -24
  244. data/lib/textus/role_scope.rb +0 -78
  245. data/lib/textus/write/accept.rb +0 -58
  246. data/lib/textus/write/key_delete.rb +0 -65
  247. data/lib/textus/write/key_mv.rb +0 -141
  248. data/lib/textus/write/propose.rb +0 -54
  249. data/lib/textus/write/put.rb +0 -74
  250. data/lib/textus/write/reject.rb +0 -68
@@ -1,34 +1,46 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "open3"
2
4
 
3
5
  module Textus
4
- module Read
5
- # For one key, joins every audit-log row with the git commit (sha,
6
- # author, date, subject) that introduced the file state at that audit
7
- # row. Falls back to `git => nil` when not in a git repo or when the
8
- # file is untracked.
9
- class Blame
6
+ module Action
7
+ class Blame < Base
10
8
  extend Textus::Contract::DSL
11
9
 
12
- verb :blame
13
- summary "Annotate audit rows for a key with the git commit that introduced each file state."
10
+ verb :blame
11
+ summary "Annotate audit rows for a key with the git commit that introduced each file state."
14
12
  surfaces :cli
15
- cli "blame"
16
- arg :key, String, required: true, positional: true, description: "entry key to blame"
13
+ cli "blame"
14
+ arg :key, String, required: true, positional: true, description: "entry key to blame"
17
15
  arg :limit, Integer, required: false, description: "maximum number of audit rows to return"
18
16
  view(:cli) { |rows, inputs| { "verb" => "blame", "key" => inputs[:key], "rows" => rows } }
19
17
 
20
- def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
18
+ BURN = :sync
19
+
20
+ def initialize(key:, limit: nil)
21
+ super()
22
+ @key = key
23
+ @limit = limit
24
+ end
25
+
26
+ def call(container:, **)
21
27
  @container = container
22
- @manifest = container.manifest
23
- @root = container.root
28
+ @manifest = container.manifest
29
+ @root = container.root
30
+
31
+ audit_rows = Textus::Action::Audit.new(key: @key, limit: @limit).call(container: container)
32
+ path = resolve_path(@key)
33
+ return audit_rows.map { |row| row.merge("git" => nil) } unless git_tracked?(path)
34
+
35
+ audit_rows.map { |row| row.merge("git" => git_commit_at(path, timestamp: row["ts"])) }
24
36
  end
25
37
 
26
- def call(key, limit: nil)
27
- audit_rows = Textus::Read::Audit.new(container: @container).call(key: key, limit: limit)
28
- path = resolve_path(key)
29
- return audit_rows.map { |r| r.merge("git" => nil) } unless git_tracked?(path)
38
+ def self.new(*args, **kwargs)
39
+ return super(**kwargs) unless args.any?
30
40
 
31
- audit_rows.map { |r| r.merge("git" => git_commit_at(path, timestamp: r["ts"])) }
41
+ positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
42
+ mapped = positional.zip(args).to_h
43
+ super(**mapped.merge(kwargs))
32
44
  end
33
45
 
34
46
  private
@@ -37,9 +49,6 @@ module Textus
37
49
  res = @manifest.resolver.resolve(key)
38
50
  mentry = res.entry
39
51
  path = res.path
40
- # Nested entries resolve to a file under the entry path; leaf entries
41
- # already have a fully-resolved path. Either way `path` is what git
42
- # needs to know about.
43
52
  path || Textus::Key::Path.resolve(@manifest.data, mentry)
44
53
  rescue Textus::Error
45
54
  nil
@@ -60,7 +69,6 @@ module Textus
60
69
  end
61
70
 
62
71
  def git_repo?
63
- # Walk up from store root to find a .git directory.
64
72
  dir = @root
65
73
  loop do
66
74
  return true if File.directory?(File.join(dir, ".git"))
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Boot < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :boot
9
+ summary "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart."
10
+ surfaces :cli, :mcp
11
+ arg :lean, :boolean,
12
+ description: "return only orientation essentials (zones, agent_quickstart, contract_etag) for cheap session-start injection"
13
+
14
+ BURN = :sync
15
+
16
+ def initialize(lean: nil)
17
+ super()
18
+ @lean = lean
19
+ end
20
+
21
+ def call(container:, **)
22
+ Textus::Boot.build(container: container, lean: !@lean.nil?)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "yaml"
4
+
5
+ module Textus
6
+ module Action
7
+ class DataMv < Base
8
+ extend Textus::Contract::DSL
9
+
10
+ verb :data_mv
11
+ summary "Rename a data lane — manifest + files. Refuses if destination exists."
12
+ surfaces :cli, :mcp
13
+ cli "data mv"
14
+ arg :from, String, required: true, positional: true, description: "current data lane name"
15
+ arg :to, String, required: true, positional: true,
16
+ description: "new data lane name; refused if a lane 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
+ BURN = :sync
23
+
24
+ def initialize(from:, to:, dry_run: false)
25
+ super()
26
+ @from = from
27
+ @to = to
28
+ @dry_run = dry_run
29
+ end
30
+
31
+ def call(container:, **)
32
+ manifest = container.manifest
33
+ root = container.root
34
+
35
+ raise UsageError.new("from and to required") if @from.nil? || @to.nil? || @from.empty? || @to.empty?
36
+ raise UsageError.new("data lane '#{@from}' not declared") unless manifest.data.declared_lane_kinds.key?(@from)
37
+
38
+ dest_dir = File.join(root, "data", @to)
39
+ raise UsageError.new("destination 'data/#{@to}' already exists") if File.exist?(dest_dir)
40
+
41
+ affected_keys = manifest.data.entries.select { |entry| entry.lane == @from }.map(&:key)
42
+
43
+ steps = [{ "op" => "rename_zone", "from" => @from, "to" => @to }]
44
+ steps += affected_keys.map do |key|
45
+ { "op" => "mv", "from" => key, "to" => "#{@to}#{key[@from.length..]}" }
46
+ end
47
+
48
+ plan = Textus::Background::Plan.new(steps: steps, warnings: [])
49
+ return plan if @dry_run
50
+
51
+ rewrite_manifest!(root)
52
+ FileUtils.mv(File.join(root, "data", @from), dest_dir)
53
+ plan
54
+ end
55
+
56
+ private
57
+
58
+ def rewrite_manifest!(root)
59
+ path = File.join(root, "manifest.yaml")
60
+ raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
61
+ raw["lanes"].each { |lane| lane["name"] = @to if lane["name"] == @from }
62
+ raw["entries"].each do |entry|
63
+ entry["lane"] = @to if entry["lane"] == @from
64
+ entry["key"] = entry["key"].sub(/\A#{Regexp.escape(@from)}(\.|\z)/, "#{@to}\\1")
65
+ entry["path"] = entry["path"].sub(%r{\A(data/)?#{Regexp.escape(@from)}(/|\z)}, "\\1#{@to}\\2")
66
+ end
67
+ File.write(path, YAML.dump(raw))
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Deps < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :deps
9
+ summary "List the keys a derived entry depends on (its projection/external sources)."
10
+ surfaces :cli, :mcp
11
+ arg :key, String, required: true, positional: true,
12
+ description: "dotted key of the derived entry whose source keys you want"
13
+
14
+ BURN = :sync
15
+
16
+ def initialize(key:)
17
+ super()
18
+ @key = key
19
+ end
20
+
21
+ def call(container:, **)
22
+ entry = container.manifest.data.entries.find { |e| e.key == @key }
23
+ deps =
24
+ if entry&.derived?
25
+ src = entry.source
26
+ if src.projection?
27
+ Array(src.select).compact
28
+ elsif src.external?
29
+ Array(src.sources).compact
30
+ else
31
+ []
32
+ end
33
+ else
34
+ []
35
+ end
36
+ { "key" => @key, "deps" => deps.uniq }
37
+ end
38
+
39
+ def self.new(*args, **kwargs)
40
+ return super(**kwargs) unless args.any?
41
+
42
+ positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
43
+ mapped = positional.zip(args).to_h
44
+ super(**mapped.merge(kwargs))
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Doctor < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :doctor
9
+ summary "Run health checks on the textus store and report any issues."
10
+ surfaces :cli
11
+ cli "doctor"
12
+ arg :checks, Array, required: false, description: "subset of check names to run (default: all)"
13
+
14
+ BURN = :sync
15
+
16
+ def initialize(checks: nil)
17
+ super()
18
+ @checks = checks
19
+ end
20
+
21
+ def call(container:, call:, **)
22
+ Textus::Doctor.build(container: container, checks: @checks, role: call.role)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Drain < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :drain
9
+ summary "Seed refresh + sweep jobs then drain the queue to empty. " \
10
+ "Identical to one Watcher tick. Use when no watcher is running."
11
+ surfaces :cli, :mcp
12
+ arg :prefix, String, description: "restrict to keys under this dotted prefix"
13
+ arg :lane, String, description: "restrict to entries in this lane"
14
+
15
+ BURN = :sync
16
+
17
+ def initialize(prefix: nil, lane: nil)
18
+ super()
19
+ @prefix = prefix
20
+ @lane = lane
21
+ end
22
+
23
+ def call(container:, call:)
24
+ queue = Textus::Ports::Queue.new(root: container.root)
25
+ Textus::Background::Planner::Plan.seed(
26
+ container: container,
27
+ queue: queue,
28
+ role: call.role,
29
+ )
30
+ queue.reclaim(now: Textus::Ports::Clock.new.now)
31
+ summary = Textus::Background::Worker.for(container:, queue:).drain
32
+ {
33
+ "protocol" => Textus::PROTOCOL,
34
+ "ok" => summary.failed.zero?,
35
+ "completed" => summary.completed,
36
+ "failed" => summary.failed,
37
+ }
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Enqueue < WriteVerb
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :enqueue
9
+ summary "Push a registered job type onto the convergence queue, to be run by drain/serve."
10
+ surfaces :cli, :mcp
11
+ cli "enqueue"
12
+ arg :type, String, required: true, positional: true,
13
+ description: "registered job type (e.g. materialize, re-pull, sweep)"
14
+ arg :args, Hash, default: {},
15
+ description: "type-specific arguments (e.g. { key: ... } or { scope: ... })"
16
+
17
+ BURN = :sync
18
+
19
+ def initialize(type:, args: {})
20
+ super()
21
+ @type = type
22
+ @job_args = args
23
+ end
24
+
25
+ def args
26
+ { type: @type, args: @job_args }
27
+ end
28
+
29
+ def call(container:, call:)
30
+ action_class = begin
31
+ Textus::Background::Job.fetch(@type.to_s)
32
+ rescue Textus::UsageError
33
+ raise Textus::UsageError.new("unregistered job type '#{@type}'")
34
+ end
35
+ if action_class.const_defined?(:REQUIRED_ROLE) && call.role != action_class::REQUIRED_ROLE
36
+ raise Textus::Error.new(
37
+ "forbidden",
38
+ "role '#{call.role}' is not authorized to enqueue this job type (requires '#{action_class::REQUIRED_ROLE}')",
39
+ details: { "role" => call.role, "required_role" => action_class::REQUIRED_ROLE },
40
+ exit_code: 77,
41
+ )
42
+ end
43
+
44
+ job = Textus::Ports::Queue::Job.new(
45
+ type: @type,
46
+ args: @job_args,
47
+ enqueued_by: call.role,
48
+ max_attempts: 3,
49
+ )
50
+ Textus::Ports::Queue.new(root: container.root).enqueue(job)
51
+ { "protocol" => Textus::PROTOCOL, "ok" => true, "id" => job.id }
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Get < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :get
9
+ summary "Read one entry - a pure on-disk read annotated with a freshness " \
10
+ "verdict; never ingests (quarantine freshness is drain + hook " \
11
+ "only, ADR 0089). Returns the envelope (uid, etag, _meta, body, " \
12
+ "freshness)."
13
+ surfaces :cli, :mcp
14
+ arg :key, String, required: true, positional: true,
15
+ description: "dotted entry key to read, e.g. 'knowledge.project'"
16
+ view { |v, _i| v.to_h_for_wire }
17
+
18
+ BURN = :sync
19
+
20
+ def initialize(key:)
21
+ super()
22
+ @key = key
23
+ end
24
+
25
+ def call(container:, call:, file_stat: Textus::Ports::Storage::FileStat.new)
26
+ @container = container
27
+ @call = call
28
+ @manifest = container.manifest
29
+ @file_store = container.file_store
30
+ @file_stat = file_stat
31
+ annotated_envelope(@key)
32
+ end
33
+
34
+ def self.new(*args, **kwargs)
35
+ return super(**kwargs) unless args.any?
36
+
37
+ positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
38
+ mapped = positional.zip(args).to_h
39
+ super(**mapped.merge(kwargs))
40
+ end
41
+
42
+ private
43
+
44
+ def annotated_envelope(key)
45
+ envelope = read_raw_envelope(key)
46
+ return nil if envelope.nil?
47
+
48
+ entry = @manifest.resolver.resolve(key).entry
49
+ envelope.with(freshness: evaluator.verdict(entry))
50
+ end
51
+
52
+ def evaluator
53
+ @evaluator ||= Textus::Core::Freshness::Evaluator.new(
54
+ manifest: @manifest,
55
+ file_stat: @file_stat,
56
+ clock: @call,
57
+ )
58
+ end
59
+
60
+ def read_raw_envelope(key)
61
+ res = @manifest.resolver.resolve(key)
62
+ mentry = res.entry
63
+ path = res.path
64
+ return nil unless @file_store.exists?(path)
65
+
66
+ raw = @file_store.read(path)
67
+ parsed = Textus::Entry.for_format(mentry.format).parse(raw, path: path)
68
+ Textus::Envelope.build(
69
+ key: key,
70
+ mentry: mentry,
71
+ path: path,
72
+ meta: parsed["_meta"],
73
+ body: parsed["body"],
74
+ etag: Textus::Etag.for_bytes(raw),
75
+ content: parsed["content"],
76
+ )
77
+ end
78
+ end
79
+ end
80
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class Jobs < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :jobs
9
+ summary "List queued jobs by state; retry a dead-lettered job or purge a state."
10
+ surfaces :cli, :mcp
11
+ cli "jobs"
12
+ arg :state, String, default: "ready", description: "ready|leased|done|failed"
13
+ arg :action, String, default: nil, description: "retry|purge (optional)"
14
+ arg :job_id, String, default: nil, description: "job id (required for action=retry)"
15
+
16
+ BURN = :sync
17
+
18
+ def initialize(state: "ready", action: nil, job_id: nil)
19
+ super()
20
+ @state = state
21
+ @action = action
22
+ @job_id = job_id
23
+ end
24
+
25
+ def call(container:, **)
26
+ queue = Textus::Ports::Queue.new(root: container.root)
27
+ case @action
28
+ when "retry"
29
+ queue.retry_failed(@job_id)
30
+ when "purge"
31
+ queue.purge(@state)
32
+ end
33
+
34
+ { "protocol" => Textus::PROTOCOL, "ok" => true, "state" => @state, "jobs" => queue.list(@state) }
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class KeyDelete < WriteVerb
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :key_delete
9
+ summary "Delete one entry by key. Single-key, lower blast radius than key_delete_prefix; " \
10
+ "guarded by an optional optimistic-concurrency etag. Returns {ok, key, deleted}."
11
+ surfaces :cli, :mcp
12
+ cli "key delete"
13
+ arg :key, String, required: true, positional: true,
14
+ description: "dotted entry key to delete"
15
+ arg :if_etag, String,
16
+ description: "optimistic-concurrency guard: the etag you last read; the delete is rejected if the entry changed since"
17
+
18
+ BURN = :sync
19
+
20
+ def initialize(key:, if_etag: nil)
21
+ super()
22
+ @key = key
23
+ @if_etag = if_etag
24
+ end
25
+
26
+ def call(container:, call:)
27
+ run_with_cascade(@key, container:, call:) do
28
+ Textus::Manifest::Data.validate_key!(@key)
29
+ mentry = container.manifest.resolver.resolve(@key).entry
30
+
31
+ auth(container).check_action!(action: :key_delete, actor: call.role, key: @key, extra: { if_etag: @if_etag })
32
+
33
+ writer(container, call).delete(@key, mentry:, if_etag: @if_etag)
34
+
35
+ container.steps.publish(
36
+ :entry_deleted,
37
+ ctx: Textus::Step::Context.for(container: container, call: call),
38
+ key: @key,
39
+ )
40
+
41
+ { "protocol" => Textus::PROTOCOL, "ok" => true, "key" => @key, "deleted" => true }
42
+ end
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class KeyDeletePrefix < Base
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :key_delete_prefix
9
+ summary "Bulk-delete every leaf key under prefix."
10
+ surfaces :cli, :mcp
11
+ cli "key delete-prefix"
12
+ arg :prefix, String, required: true, positional: true,
13
+ description: "every leaf key under this dotted prefix is deleted"
14
+ arg :dry_run, :boolean, default: false,
15
+ description: "when true, returns the keys that would be deleted without deleting them; " \
16
+ "defaults to false, so omitting it deletes immediately"
17
+ view { |v, _i| v.to_h }
18
+
19
+ BURN = :sync
20
+
21
+ def initialize(prefix:, dry_run: false)
22
+ super()
23
+ @prefix = prefix
24
+ @dry_run = dry_run
25
+ end
26
+
27
+ def call(container:, call:)
28
+ raise UsageError.new("prefix required") if @prefix.nil? || @prefix.empty?
29
+
30
+ leaves = Textus::Action::List.new(prefix: @prefix).call(container: container)
31
+ .map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
32
+
33
+ warnings = leaves.empty? ? ["no keys under #{@prefix}"] : []
34
+ steps = leaves.map { |key| { "op" => "delete", "key" => key } }
35
+
36
+ plan = Textus::Background::Plan.new(steps: steps, warnings: warnings)
37
+ return plan if @dry_run
38
+
39
+ steps.each do |step|
40
+ Textus::Action::KeyDelete.new(key: step["key"]).call(container: container, call: call)
41
+ end
42
+ plan
43
+ end
44
+ end
45
+ end
46
+ end