textus 0.52.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 (254) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +25 -0
  3. data/README.md +62 -54
  4. data/SPEC.md +62 -187
  5. data/docs/architecture/README.md +88 -77
  6. data/exe/textus +1 -1
  7. data/lib/textus/action/accept.rb +53 -0
  8. data/lib/textus/action/audit.rb +133 -0
  9. data/lib/textus/action/base.rb +42 -0
  10. data/lib/textus/{read → action}/blame.rb +30 -22
  11. data/lib/textus/action/boot.rb +26 -0
  12. data/lib/textus/action/data_mv.rb +71 -0
  13. data/lib/textus/action/deps.rb +48 -0
  14. data/lib/textus/action/doctor.rb +26 -0
  15. data/lib/textus/action/drain.rb +41 -0
  16. data/lib/textus/action/enqueue.rb +55 -0
  17. data/lib/textus/action/get.rb +80 -0
  18. data/lib/textus/action/jobs.rb +38 -0
  19. data/lib/textus/action/key_delete.rb +46 -0
  20. data/lib/textus/action/key_delete_prefix.rb +46 -0
  21. data/lib/textus/action/key_mv.rb +143 -0
  22. data/lib/textus/action/key_mv_prefix.rb +59 -0
  23. data/lib/textus/action/list.rb +44 -0
  24. data/lib/textus/action/propose.rb +54 -0
  25. data/lib/textus/action/published.rb +26 -0
  26. data/lib/textus/action/pulse/scanner.rb +118 -0
  27. data/lib/textus/action/pulse.rb +87 -0
  28. data/lib/textus/action/put.rb +63 -0
  29. data/lib/textus/action/rdeps.rb +49 -0
  30. data/lib/textus/action/reject.rb +49 -0
  31. data/lib/textus/action/rule_explain.rb +95 -0
  32. data/lib/textus/action/rule_lint.rb +70 -0
  33. data/lib/textus/action/rule_list.rb +46 -0
  34. data/lib/textus/action/schema_envelope.rb +31 -0
  35. data/lib/textus/action/uid.rb +35 -0
  36. data/lib/textus/action/where.rb +38 -0
  37. data/lib/textus/action/write_verb.rb +58 -0
  38. data/lib/textus/background/job/base.rb +27 -0
  39. data/lib/textus/background/job/materialize.rb +31 -0
  40. data/lib/textus/background/job/refresh.rb +22 -0
  41. data/lib/textus/background/job/sweep.rb +31 -0
  42. data/lib/textus/background/job.rb +19 -0
  43. data/lib/textus/background/plan.rb +9 -0
  44. data/lib/textus/background/planner/plan.rb +113 -0
  45. data/lib/textus/{maintenance → background}/retention/apply.rb +7 -9
  46. data/lib/textus/background/worker.rb +67 -0
  47. data/lib/textus/boot.rb +53 -45
  48. data/lib/textus/command.rb +36 -0
  49. data/lib/textus/container.rb +1 -1
  50. data/lib/textus/{domain → core}/duration.rb +1 -1
  51. data/lib/textus/{domain → core}/freshness/evaluator.rb +11 -11
  52. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  53. data/lib/textus/{domain → core}/freshness.rb +2 -2
  54. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  55. data/lib/textus/{domain → core}/retention.rb +2 -2
  56. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  57. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  58. data/lib/textus/doctor/check/handler_permit.rb +34 -0
  59. data/lib/textus/doctor/check/hooks.rb +11 -18
  60. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  61. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  62. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  63. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  64. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  65. data/lib/textus/doctor/check.rb +12 -9
  66. data/lib/textus/{read → doctor}/validator.rb +22 -13
  67. data/lib/textus/doctor.rb +6 -6
  68. data/lib/textus/envelope/io/writer.rb +65 -36
  69. data/lib/textus/envelope.rb +5 -3
  70. data/lib/textus/errors.rb +17 -9
  71. data/lib/textus/events.rb +21 -0
  72. data/lib/textus/gate/auth.rb +181 -0
  73. data/lib/textus/gate.rb +114 -0
  74. data/lib/textus/init/templates/machine_intake.rb +39 -35
  75. data/lib/textus/init/templates/orientation_reducer.rb +15 -11
  76. data/lib/textus/init.rb +90 -73
  77. data/lib/textus/key/path.rb +9 -2
  78. data/lib/textus/layout.rb +13 -0
  79. data/lib/textus/manifest/data.rb +14 -14
  80. data/lib/textus/manifest/entry/base.rb +15 -11
  81. data/lib/textus/manifest/entry/parser.rb +6 -6
  82. data/lib/textus/manifest/entry/produced.rb +3 -2
  83. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  84. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
  85. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  86. data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
  87. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  88. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  89. data/lib/textus/manifest/policy/react.rb +30 -0
  90. data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
  91. data/lib/textus/{domain → manifest}/policy/source.rb +24 -19
  92. data/lib/textus/manifest/policy.rb +36 -48
  93. data/lib/textus/manifest/resolver.rb +3 -2
  94. data/lib/textus/manifest/rules.rb +4 -4
  95. data/lib/textus/manifest/schema/keys.rb +17 -11
  96. data/lib/textus/manifest/schema/validator.rb +24 -22
  97. data/lib/textus/manifest/schema/vocabulary.rb +1 -1
  98. data/lib/textus/manifest/schema.rb +2 -2
  99. data/lib/textus/manifest.rb +2 -2
  100. data/lib/textus/{produce → pipeline}/acquire/handler.rb +2 -2
  101. data/lib/textus/{produce → pipeline}/acquire/intake.rb +22 -20
  102. data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
  103. data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
  104. data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
  105. data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
  106. data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
  107. data/lib/textus/{produce → pipeline}/engine.rb +7 -5
  108. data/lib/textus/{produce → pipeline}/render.rb +3 -1
  109. data/lib/textus/ports/audit_log.rb +31 -5
  110. data/lib/textus/ports/audit_subscriber.rb +4 -4
  111. data/lib/textus/{domain/jobs → ports/queue}/job.rb +19 -12
  112. data/lib/textus/ports/queue.rb +1 -1
  113. data/lib/textus/ports/sentinel_store.rb +2 -2
  114. data/lib/textus/ports/watcher_lock.rb +48 -0
  115. data/lib/textus/projection.rb +8 -8
  116. data/lib/textus/schema/tools.rb +4 -3
  117. data/lib/textus/session.rb +6 -3
  118. data/lib/textus/step/base.rb +35 -0
  119. data/lib/textus/step/builtin/csv_fetch.rb +19 -0
  120. data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
  121. data/lib/textus/step/builtin/json_fetch.rb +18 -0
  122. data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
  123. data/lib/textus/step/builtin/rss_fetch.rb +26 -0
  124. data/lib/textus/step/builtin.rb +22 -0
  125. data/lib/textus/{hooks → step}/catalog.rb +3 -3
  126. data/lib/textus/{hooks → step}/context.rb +15 -13
  127. data/lib/textus/step/discovery.rb +24 -0
  128. data/lib/textus/{hooks → step}/error_log.rb +1 -1
  129. data/lib/textus/{hooks → step}/event_bus.rb +15 -16
  130. data/lib/textus/step/fetch.rb +13 -0
  131. data/lib/textus/{hooks → step}/fire_report.rb +1 -1
  132. data/lib/textus/step/loader.rb +108 -0
  133. data/lib/textus/step/observe.rb +31 -0
  134. data/lib/textus/step/registry_store.rb +66 -0
  135. data/lib/textus/{hooks → step}/signature.rb +1 -1
  136. data/lib/textus/step/transform.rb +12 -0
  137. data/lib/textus/step/validate.rb +11 -0
  138. data/lib/textus/step.rb +10 -0
  139. data/lib/textus/store.rb +17 -15
  140. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  141. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  142. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  143. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  144. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  145. data/lib/textus/surfaces/cli/group.rb +50 -0
  146. data/lib/textus/surfaces/cli/runner.rb +236 -0
  147. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  148. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  149. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  150. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  151. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  152. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  153. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  154. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  155. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  156. data/lib/textus/surfaces/cli/verb.rb +111 -0
  157. data/lib/textus/surfaces/cli.rb +148 -0
  158. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  159. data/lib/textus/surfaces/mcp/errors.rb +34 -0
  160. data/lib/textus/surfaces/mcp/server.rb +145 -0
  161. data/lib/textus/surfaces/mcp/session.rb +9 -0
  162. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  163. data/lib/textus/surfaces/mcp.rb +8 -0
  164. data/lib/textus/surfaces/role_scope.rb +38 -0
  165. data/lib/textus/surfaces/watcher.rb +38 -0
  166. data/lib/textus/version.rb +1 -1
  167. data/lib/textus.rb +64 -22
  168. metadata +132 -118
  169. data/lib/textus/cli/group/hook.rb +0 -9
  170. data/lib/textus/cli/group/key.rb +0 -9
  171. data/lib/textus/cli/group/mcp.rb +0 -9
  172. data/lib/textus/cli/group/rule.rb +0 -9
  173. data/lib/textus/cli/group/schema.rb +0 -9
  174. data/lib/textus/cli/group/zone.rb +0 -9
  175. data/lib/textus/cli/group.rb +0 -48
  176. data/lib/textus/cli/runner.rb +0 -193
  177. data/lib/textus/cli/verb/doctor.rb +0 -17
  178. data/lib/textus/cli/verb/get.rb +0 -18
  179. data/lib/textus/cli/verb/hook_run.rb +0 -48
  180. data/lib/textus/cli/verb/hooks.rb +0 -50
  181. data/lib/textus/cli/verb/init.rb +0 -18
  182. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  183. data/lib/textus/cli/verb/put.rb +0 -30
  184. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  185. data/lib/textus/cli/verb/schema_init.rb +0 -19
  186. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  187. data/lib/textus/cli/verb/serve.rb +0 -19
  188. data/lib/textus/cli/verb.rb +0 -116
  189. data/lib/textus/cli.rb +0 -138
  190. data/lib/textus/dispatcher.rb +0 -54
  191. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  192. data/lib/textus/domain/action.rb +0 -9
  193. data/lib/textus/domain/jobs/registry.rb +0 -37
  194. data/lib/textus/domain/permission.rb +0 -7
  195. data/lib/textus/domain/policy/base_guards.rb +0 -25
  196. data/lib/textus/domain/policy/evaluation.rb +0 -15
  197. data/lib/textus/domain/policy/guard.rb +0 -35
  198. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  199. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  200. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  201. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  202. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  203. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  204. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  205. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  206. data/lib/textus/hooks/builtin.rb +0 -70
  207. data/lib/textus/hooks/loader.rb +0 -54
  208. data/lib/textus/hooks/rpc_registry.rb +0 -43
  209. data/lib/textus/jobs/handlers.rb +0 -62
  210. data/lib/textus/jobs/scheduler.rb +0 -36
  211. data/lib/textus/jobs/seeder.rb +0 -57
  212. data/lib/textus/maintenance/drain.rb +0 -42
  213. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  214. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  215. data/lib/textus/maintenance/rule_lint.rb +0 -66
  216. data/lib/textus/maintenance/serve.rb +0 -30
  217. data/lib/textus/maintenance/worker.rb +0 -74
  218. data/lib/textus/maintenance/zone_mv.rb +0 -64
  219. data/lib/textus/maintenance.rb +0 -15
  220. data/lib/textus/mcp/catalog.rb +0 -70
  221. data/lib/textus/mcp/errors.rb +0 -32
  222. data/lib/textus/mcp/server.rb +0 -138
  223. data/lib/textus/mcp/session.rb +0 -7
  224. data/lib/textus/mcp/tool_schemas.rb +0 -15
  225. data/lib/textus/mcp.rb +0 -6
  226. data/lib/textus/mustache.rb +0 -117
  227. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
  228. data/lib/textus/produce/events.rb +0 -36
  229. data/lib/textus/read/audit.rb +0 -130
  230. data/lib/textus/read/boot.rb +0 -26
  231. data/lib/textus/read/capabilities.rb +0 -70
  232. data/lib/textus/read/deps.rb +0 -38
  233. data/lib/textus/read/doctor.rb +0 -27
  234. data/lib/textus/read/freshness.rb +0 -152
  235. data/lib/textus/read/get.rb +0 -73
  236. data/lib/textus/read/jobs.rb +0 -31
  237. data/lib/textus/read/list.rb +0 -24
  238. data/lib/textus/read/published.rb +0 -22
  239. data/lib/textus/read/pulse.rb +0 -98
  240. data/lib/textus/read/rdeps.rb +0 -39
  241. data/lib/textus/read/rule_explain.rb +0 -96
  242. data/lib/textus/read/rule_list.rb +0 -54
  243. data/lib/textus/read/schema_envelope.rb +0 -25
  244. data/lib/textus/read/uid.rb +0 -29
  245. data/lib/textus/read/validate_all.rb +0 -36
  246. data/lib/textus/read/where.rb +0 -24
  247. data/lib/textus/role_scope.rb +0 -78
  248. data/lib/textus/write/accept.rb +0 -58
  249. data/lib/textus/write/enqueue.rb +0 -50
  250. data/lib/textus/write/key_delete.rb +0 -65
  251. data/lib/textus/write/key_mv.rb +0 -141
  252. data/lib/textus/write/propose.rb +0 -54
  253. data/lib/textus/write/put.rb +0 -74
  254. data/lib/textus/write/reject.rb +0 -68
@@ -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
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class KeyMv < WriteVerb
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :key_mv
9
+ summary "Rename one entry (same zone + format). Refuses if the target exists. Single-key, lower blast radius than key_mv_prefix."
10
+ surfaces :cli, :mcp
11
+ cli "key mv"
12
+ arg :old_key, String, required: true, positional: true,
13
+ description: "current dotted key"
14
+ arg :new_key, String, required: true, positional: true,
15
+ description: "new dotted key (must be the same zone and format as old_key)"
16
+ arg :dry_run, :boolean,
17
+ description: "when true, returns the planned move (from/to paths, uid) without applying it; " \
18
+ "defaults to false, so omitting it applies the move immediately " \
19
+ "(unlike the bulk key_mv_prefix, which defaults to a dry-run plan)"
20
+
21
+ BURN = :sync
22
+
23
+ def initialize(old_key:, new_key:, dry_run: false)
24
+ super()
25
+ @old_key = old_key
26
+ @new_key = new_key
27
+ @dry_run = dry_run
28
+ end
29
+
30
+ def call(container:, call:)
31
+ run_with_cascade(cascade_target_key, container:, call:) do
32
+ execute_move(container, call)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def cascade_target_key
39
+ @dry_run ? nil : @new_key
40
+ end
41
+
42
+ def execute_move(container, call)
43
+ old_res, new_res = prepare(container, call)
44
+ return dry_run_result(container, old_res, new_res) if @dry_run
45
+
46
+ envelope = apply_move(container, call, old_res, new_res)
47
+ publish_rename(container, call, envelope)
48
+ success_result(old_res, new_res, envelope)
49
+ end
50
+
51
+ def apply_move(container, call, old_res, new_res)
52
+ ensure_uid!(container, call, old_res.entry)
53
+ writer(container, call).move(
54
+ from_key: @old_key,
55
+ to_key: @new_key,
56
+ new_mentry: new_res.entry,
57
+ )
58
+ end
59
+
60
+ def publish_rename(container, call, envelope)
61
+ container.steps.publish(
62
+ :entry_renamed,
63
+ ctx: Textus::Step::Context.for(container: container, call: call),
64
+ key: @new_key,
65
+ from_key: @old_key,
66
+ to_key: @new_key,
67
+ envelope: envelope,
68
+ )
69
+ end
70
+
71
+ def success_result(old_res, new_res, envelope)
72
+ {
73
+ "protocol" => PROTOCOL,
74
+ "ok" => true,
75
+ "from_key" => @old_key,
76
+ "to_key" => @new_key,
77
+ "from_path" => old_res.path,
78
+ "to_path" => new_res.path,
79
+ "uid" => envelope.uid,
80
+ "envelope" => envelope.to_h_for_wire,
81
+ }
82
+ end
83
+
84
+ def prepare(container, call)
85
+ Textus::Manifest::Data.validate_key!(@old_key)
86
+ Textus::Manifest::Data.validate_key!(@new_key)
87
+ raise UsageError.new("mv: old and new keys are identical") if @old_key == @new_key
88
+
89
+ old_res = container.manifest.resolver.resolve(@old_key)
90
+ new_res = container.manifest.resolver.resolve(@new_key)
91
+ raise UnknownKey.new(@old_key) unless reader(container).exists?(@old_key)
92
+
93
+ validate_zone_and_format!(old_res.entry, new_res.entry)
94
+ auth(container).check_action!(action: :key_mv, actor: call.role, key: @old_key)
95
+ auth(container).check_action!(action: :key_mv, actor: call.role, key: @new_key)
96
+ raise UsageError.new("mv: target '#{@new_key}' already exists at #{new_res.path}") if reader(container).exists?(@new_key)
97
+
98
+ [old_res, new_res]
99
+ end
100
+
101
+ def validate_zone_and_format!(old_mentry, new_mentry)
102
+ if old_mentry.lane != new_mentry.lane
103
+ raise UsageError.new(
104
+ "mv: cross-zone move refused (#{old_mentry.lane} -> #{new_mentry.lane}). " \
105
+ "Use put+delete for cross-zone moves.",
106
+ )
107
+ end
108
+ return if old_mentry.format == new_mentry.format
109
+
110
+ raise UsageError.new("mv: format mismatch (#{old_mentry.format} -> #{new_mentry.format}); refusing.")
111
+ end
112
+
113
+ def ensure_uid!(container, call, old_mentry)
114
+ pre_env = reader(container).read(@old_key)
115
+ return if pre_env.uid
116
+
117
+ writer(container, call).put(
118
+ @old_key,
119
+ mentry: old_mentry,
120
+ payload: Textus::Envelope::IO::Writer::Payload.new(
121
+ meta: pre_env.meta,
122
+ body: pre_env.body,
123
+ content: pre_env.content,
124
+ ),
125
+ )
126
+ end
127
+
128
+ def dry_run_result(container, old_res, new_res)
129
+ pre_env = reader(container).read(@old_key)
130
+ {
131
+ "protocol" => PROTOCOL,
132
+ "ok" => true,
133
+ "dry_run" => true,
134
+ "from_key" => @old_key,
135
+ "to_key" => @new_key,
136
+ "from_path" => old_res.path,
137
+ "to_path" => new_res.path,
138
+ "uid" => pre_env.uid,
139
+ }
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ class KeyMvPrefix < Base
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,
13
+ description: "dotted prefix whose leaf keys are renamed"
14
+ arg :to_prefix, String, required: true, positional: true,
15
+ description: "dotted prefix the keys are renamed to"
16
+ arg :dry_run, :boolean, default: false,
17
+ description: "when true, returns the planned moves without applying them; defaults " \
18
+ "to false, so omitting it applies the rename immediately"
19
+ view { |v, _i| v.to_h }
20
+
21
+ BURN = :sync
22
+
23
+ def initialize(from_prefix:, to_prefix:, dry_run: false)
24
+ super()
25
+ @from_prefix = from_prefix
26
+ @to_prefix = to_prefix
27
+ @dry_run = dry_run
28
+ end
29
+
30
+ def call(container:, call:)
31
+ raise UsageError.new("from_prefix and to_prefix required") if @from_prefix.nil? || @to_prefix.nil?
32
+
33
+ leaves = Textus::Action::List.new(prefix: @from_prefix).call(container: container)
34
+ .map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
35
+
36
+ if leaves.include?(@from_prefix)
37
+ raise UsageError.new("from_prefix '#{@from_prefix}' is itself a leaf — use `mv` to rename a single key")
38
+ end
39
+
40
+ warnings = []
41
+ warnings << "no keys under #{@from_prefix}" if leaves.empty?
42
+
43
+ steps = leaves.map do |old_key|
44
+ tail = old_key.delete_prefix("#{@from_prefix}.")
45
+ new_key = "#{@to_prefix}.#{tail}"
46
+ { "op" => "mv", "from" => old_key, "to" => new_key }
47
+ end
48
+
49
+ plan = Textus::Background::Plan.new(steps: steps, warnings: warnings)
50
+ return plan if @dry_run
51
+
52
+ steps.each do |step|
53
+ Textus::Action::KeyMv.new(old_key: step["from"], new_key: step["to"]).call(container: container, call: call)
54
+ end
55
+ plan
56
+ end
57
+ end
58
+ end
59
+ end