textus 0.52.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (261) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +59 -0
  3. data/README.md +62 -54
  4. data/SPEC.md +64 -192
  5. data/docs/architecture/README.md +157 -156
  6. data/exe/textus +1 -1
  7. data/lib/textus/action/accept.rb +44 -0
  8. data/lib/textus/action/audit.rb +131 -0
  9. data/lib/textus/action/base.rb +42 -0
  10. data/lib/textus/{read → action}/blame.rb +28 -22
  11. data/lib/textus/action/boot.rb +17 -0
  12. data/lib/textus/action/data_mv.rb +69 -0
  13. data/lib/textus/action/deps.rb +34 -0
  14. data/lib/textus/action/doctor.rb +24 -0
  15. data/lib/textus/action/drain.rb +39 -0
  16. data/lib/textus/action/enqueue.rb +53 -0
  17. data/lib/textus/action/get.rb +78 -0
  18. data/lib/textus/action/ingest.rb +135 -0
  19. data/lib/textus/action/jobs.rb +36 -0
  20. data/lib/textus/action/key_delete.rb +36 -0
  21. data/lib/textus/action/key_delete_prefix.rb +44 -0
  22. data/lib/textus/action/key_mv.rb +129 -0
  23. data/lib/textus/action/key_mv_prefix.rb +57 -0
  24. data/lib/textus/action/list.rb +42 -0
  25. data/lib/textus/action/propose.rb +52 -0
  26. data/lib/textus/action/published.rb +24 -0
  27. data/lib/textus/action/pulse.rb +56 -0
  28. data/lib/textus/action/put.rb +52 -0
  29. data/lib/textus/action/rdeps.rb +39 -0
  30. data/lib/textus/action/reject.rb +37 -0
  31. data/lib/textus/action/rule_explain.rb +91 -0
  32. data/lib/textus/action/rule_lint.rb +68 -0
  33. data/lib/textus/action/rule_list.rb +42 -0
  34. data/lib/textus/action/schema_envelope.rb +29 -0
  35. data/lib/textus/action/uid.rb +33 -0
  36. data/lib/textus/action/where.rb +36 -0
  37. data/lib/textus/action/write_verb.rb +44 -0
  38. data/lib/textus/boot.rb +64 -132
  39. data/lib/textus/command.rb +41 -0
  40. data/lib/textus/container.rb +1 -1
  41. data/lib/textus/{domain → core}/duration.rb +1 -1
  42. data/lib/textus/{domain → core}/freshness/evaluator.rb +25 -41
  43. data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
  44. data/lib/textus/{domain → core}/freshness.rb +2 -2
  45. data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
  46. data/lib/textus/{domain → core}/retention.rb +2 -2
  47. data/lib/textus/{domain → core}/sentinel.rb +1 -1
  48. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  49. data/lib/textus/doctor/check/illegal_keys.rb +1 -1
  50. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  51. data/lib/textus/doctor/check/proposal_targets.rb +3 -3
  52. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  53. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  54. data/lib/textus/doctor/check/schema_violations.rb +8 -2
  55. data/lib/textus/doctor/check.rb +11 -9
  56. data/lib/textus/{read → doctor}/validator.rb +22 -13
  57. data/lib/textus/doctor.rb +4 -44
  58. data/lib/textus/envelope/reader.rb +46 -0
  59. data/lib/textus/envelope/writer.rb +209 -0
  60. data/lib/textus/envelope.rb +5 -3
  61. data/lib/textus/errors.rb +25 -13
  62. data/lib/textus/{entry → format}/base.rb +1 -6
  63. data/lib/textus/{entry → format}/json.rb +1 -4
  64. data/lib/textus/{entry → format}/markdown.rb +2 -6
  65. data/lib/textus/{entry → format}/text.rb +2 -6
  66. data/lib/textus/{entry → format}/yaml.rb +1 -4
  67. data/lib/textus/{entry.rb → format.rb} +9 -10
  68. data/lib/textus/gate/auth.rb +227 -0
  69. data/lib/textus/gate.rb +116 -0
  70. data/lib/textus/init.rb +59 -105
  71. data/lib/textus/jobs/base.rb +23 -0
  72. data/lib/textus/jobs/materialize.rb +20 -0
  73. data/lib/textus/jobs/plan.rb +9 -0
  74. data/lib/textus/jobs/planner.rb +101 -0
  75. data/lib/textus/jobs/retention.rb +48 -0
  76. data/lib/textus/jobs/sweep.rb +27 -0
  77. data/lib/textus/jobs/worker.rb +67 -0
  78. data/lib/textus/jobs.rb +15 -0
  79. data/lib/textus/key/path.rb +10 -3
  80. data/lib/textus/layout.rb +13 -0
  81. data/lib/textus/manifest/data.rb +14 -14
  82. data/lib/textus/manifest/entry/base.rb +14 -25
  83. data/lib/textus/manifest/entry/parser.rb +31 -14
  84. data/lib/textus/manifest/entry/produced.rb +13 -33
  85. data/lib/textus/manifest/entry/publish/mode.rb +1 -1
  86. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -3
  87. data/lib/textus/manifest/entry/publish.rb +0 -12
  88. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  89. data/lib/textus/manifest/entry/validators.rb +0 -1
  90. data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
  91. data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
  92. data/lib/textus/manifest/policy/react.rb +30 -0
  93. data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
  94. data/lib/textus/manifest/policy/source.rb +30 -0
  95. data/lib/textus/manifest/policy.rb +38 -53
  96. data/lib/textus/manifest/resolver.rb +5 -4
  97. data/lib/textus/manifest/rules.rb +4 -4
  98. data/lib/textus/manifest/schema/keys.rb +20 -28
  99. data/lib/textus/manifest/schema/validator.rb +27 -33
  100. data/lib/textus/manifest/schema/vocabulary.rb +2 -1
  101. data/lib/textus/manifest/schema.rb +2 -2
  102. data/lib/textus/manifest.rb +2 -2
  103. data/lib/textus/ports/audit_log.rb +31 -5
  104. data/lib/textus/{domain/jobs → ports/job_store}/job.rb +19 -12
  105. data/lib/textus/ports/{queue.rb → job_store.rb} +7 -14
  106. data/lib/textus/ports/sentinel_store.rb +2 -2
  107. data/lib/textus/ports/watcher_lock.rb +48 -0
  108. data/lib/textus/produce/engine.rb +25 -70
  109. data/lib/textus/produce/render.rb +2 -7
  110. data/lib/textus/schema/tools.rb +4 -3
  111. data/lib/textus/session.rb +6 -3
  112. data/lib/textus/store.rb +12 -19
  113. data/lib/textus/surfaces/cli/group/data.rb +11 -0
  114. data/lib/textus/surfaces/cli/group/key.rb +11 -0
  115. data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
  116. data/lib/textus/surfaces/cli/group/rule.rb +11 -0
  117. data/lib/textus/surfaces/cli/group/schema.rb +11 -0
  118. data/lib/textus/surfaces/cli/group.rb +50 -0
  119. data/lib/textus/surfaces/cli/runner.rb +236 -0
  120. data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
  121. data/lib/textus/surfaces/cli/verb/get.rb +21 -0
  122. data/lib/textus/surfaces/cli/verb/init.rb +20 -0
  123. data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
  124. data/lib/textus/surfaces/cli/verb/put.rb +30 -0
  125. data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
  126. data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
  127. data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
  128. data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
  129. data/lib/textus/surfaces/cli/verb.rb +111 -0
  130. data/lib/textus/surfaces/cli.rb +148 -0
  131. data/lib/textus/surfaces/mcp/catalog.rb +99 -0
  132. data/lib/textus/surfaces/mcp/errors.rb +25 -0
  133. data/lib/textus/surfaces/mcp/routing.rb +51 -0
  134. data/lib/textus/surfaces/mcp/server.rb +184 -0
  135. data/lib/textus/surfaces/mcp/session.rb +9 -0
  136. data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
  137. data/lib/textus/surfaces/mcp.rb +8 -0
  138. data/lib/textus/surfaces/role_scope.rb +34 -0
  139. data/lib/textus/surfaces/watcher.rb +38 -0
  140. data/lib/textus/version.rb +1 -1
  141. data/lib/textus/workflow/collector.rb +27 -0
  142. data/lib/textus/workflow/context.rb +5 -0
  143. data/lib/textus/workflow/dsl.rb +44 -0
  144. data/lib/textus/workflow/loader.rb +17 -0
  145. data/lib/textus/workflow/pattern.rb +18 -0
  146. data/lib/textus/workflow/registry.rb +21 -0
  147. data/lib/textus/workflow/runner.rb +84 -0
  148. data/lib/textus/workflow.rb +28 -0
  149. data/lib/textus.rb +75 -22
  150. metadata +118 -132
  151. data/lib/textus/cli/group/hook.rb +0 -9
  152. data/lib/textus/cli/group/key.rb +0 -9
  153. data/lib/textus/cli/group/mcp.rb +0 -9
  154. data/lib/textus/cli/group/rule.rb +0 -9
  155. data/lib/textus/cli/group/schema.rb +0 -9
  156. data/lib/textus/cli/group/zone.rb +0 -9
  157. data/lib/textus/cli/group.rb +0 -48
  158. data/lib/textus/cli/runner.rb +0 -193
  159. data/lib/textus/cli/verb/doctor.rb +0 -17
  160. data/lib/textus/cli/verb/get.rb +0 -18
  161. data/lib/textus/cli/verb/hook_run.rb +0 -48
  162. data/lib/textus/cli/verb/hooks.rb +0 -50
  163. data/lib/textus/cli/verb/init.rb +0 -18
  164. data/lib/textus/cli/verb/mcp_serve.rb +0 -22
  165. data/lib/textus/cli/verb/put.rb +0 -30
  166. data/lib/textus/cli/verb/schema_diff.rb +0 -15
  167. data/lib/textus/cli/verb/schema_init.rb +0 -19
  168. data/lib/textus/cli/verb/schema_migrate.rb +0 -19
  169. data/lib/textus/cli/verb/serve.rb +0 -19
  170. data/lib/textus/cli/verb.rb +0 -116
  171. data/lib/textus/cli.rb +0 -138
  172. data/lib/textus/dispatcher.rb +0 -54
  173. data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
  174. data/lib/textus/doctor/check/hooks.rb +0 -32
  175. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  176. data/lib/textus/domain/action.rb +0 -9
  177. data/lib/textus/domain/jobs/registry.rb +0 -37
  178. data/lib/textus/domain/permission.rb +0 -7
  179. data/lib/textus/domain/policy/base_guards.rb +0 -25
  180. data/lib/textus/domain/policy/evaluation.rb +0 -15
  181. data/lib/textus/domain/policy/guard.rb +0 -35
  182. data/lib/textus/domain/policy/guard_factory.rb +0 -40
  183. data/lib/textus/domain/policy/handler_allowlist.rb +0 -17
  184. data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
  185. data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
  186. data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
  187. data/lib/textus/domain/policy/predicates/registry.rb +0 -39
  188. data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
  189. data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
  190. data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
  191. data/lib/textus/domain/policy/source.rb +0 -73
  192. data/lib/textus/envelope/io/reader.rb +0 -48
  193. data/lib/textus/envelope/io/writer.rb +0 -182
  194. data/lib/textus/hooks/builtin.rb +0 -70
  195. data/lib/textus/hooks/catalog.rb +0 -37
  196. data/lib/textus/hooks/context.rb +0 -72
  197. data/lib/textus/hooks/error_log.rb +0 -32
  198. data/lib/textus/hooks/event_bus.rb +0 -114
  199. data/lib/textus/hooks/fire_report.rb +0 -23
  200. data/lib/textus/hooks/loader.rb +0 -54
  201. data/lib/textus/hooks/rpc_registry.rb +0 -43
  202. data/lib/textus/hooks/signature.rb +0 -31
  203. data/lib/textus/init/templates/machine_intake.rb +0 -45
  204. data/lib/textus/init/templates/orientation_reducer.rb +0 -17
  205. data/lib/textus/jobs/handlers.rb +0 -62
  206. data/lib/textus/jobs/scheduler.rb +0 -36
  207. data/lib/textus/jobs/seeder.rb +0 -57
  208. data/lib/textus/maintenance/drain.rb +0 -42
  209. data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
  210. data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
  211. data/lib/textus/maintenance/retention/apply.rb +0 -52
  212. data/lib/textus/maintenance/rule_lint.rb +0 -66
  213. data/lib/textus/maintenance/serve.rb +0 -30
  214. data/lib/textus/maintenance/worker.rb +0 -74
  215. data/lib/textus/maintenance/zone_mv.rb +0 -64
  216. data/lib/textus/maintenance.rb +0 -15
  217. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  218. data/lib/textus/mcp/catalog.rb +0 -70
  219. data/lib/textus/mcp/errors.rb +0 -32
  220. data/lib/textus/mcp/server.rb +0 -138
  221. data/lib/textus/mcp/session.rb +0 -7
  222. data/lib/textus/mcp/tool_schemas.rb +0 -15
  223. data/lib/textus/mcp.rb +0 -6
  224. data/lib/textus/mustache.rb +0 -117
  225. data/lib/textus/ports/audit_subscriber.rb +0 -42
  226. data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
  227. data/lib/textus/produce/acquire/handler.rb +0 -29
  228. data/lib/textus/produce/acquire/intake.rb +0 -130
  229. data/lib/textus/produce/acquire/projection.rb +0 -127
  230. data/lib/textus/produce/acquire/serializer/json.rb +0 -31
  231. data/lib/textus/produce/acquire/serializer/text.rb +0 -16
  232. data/lib/textus/produce/acquire/serializer/yaml.rb +0 -31
  233. data/lib/textus/produce/acquire/serializer.rb +0 -17
  234. data/lib/textus/produce/events.rb +0 -36
  235. data/lib/textus/projection.rb +0 -103
  236. data/lib/textus/read/audit.rb +0 -130
  237. data/lib/textus/read/boot.rb +0 -26
  238. data/lib/textus/read/capabilities.rb +0 -70
  239. data/lib/textus/read/deps.rb +0 -38
  240. data/lib/textus/read/doctor.rb +0 -27
  241. data/lib/textus/read/freshness.rb +0 -152
  242. data/lib/textus/read/get.rb +0 -73
  243. data/lib/textus/read/jobs.rb +0 -31
  244. data/lib/textus/read/list.rb +0 -24
  245. data/lib/textus/read/published.rb +0 -22
  246. data/lib/textus/read/pulse.rb +0 -98
  247. data/lib/textus/read/rdeps.rb +0 -39
  248. data/lib/textus/read/rule_explain.rb +0 -96
  249. data/lib/textus/read/rule_list.rb +0 -54
  250. data/lib/textus/read/schema_envelope.rb +0 -25
  251. data/lib/textus/read/uid.rb +0 -29
  252. data/lib/textus/read/validate_all.rb +0 -36
  253. data/lib/textus/read/where.rb +0 -24
  254. data/lib/textus/role_scope.rb +0 -78
  255. data/lib/textus/write/accept.rb +0 -58
  256. data/lib/textus/write/enqueue.rb +0 -50
  257. data/lib/textus/write/key_delete.rb +0 -65
  258. data/lib/textus/write/key_mv.rb +0 -141
  259. data/lib/textus/write/propose.rb +0 -54
  260. data/lib/textus/write/put.rb +0 -74
  261. data/lib/textus/write/reject.rb +0 -68
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Action
5
+ @registry = {}
6
+
7
+ def self.registry = @registry
8
+
9
+ def self.register(klass)
10
+ @registry[klass.name.gsub("::", "/").downcase] = klass
11
+ end
12
+
13
+ def self.fetch(type)
14
+ return @registry[type] if @registry[type]
15
+
16
+ match = @registry.values.find { |k| k.const_defined?(:TYPE, false) && type == k::TYPE }
17
+ raise Textus::UsageError.new("unknown action type: #{type}") unless match
18
+
19
+ @registry[type] = match
20
+ end
21
+
22
+ class Base
23
+ def self.inherited(subclass)
24
+ super
25
+ Textus::Action.register(subclass) if subclass.name
26
+ end
27
+
28
+ def call(**)
29
+ raise NotImplementedError.new("#{self.class}#call")
30
+ end
31
+
32
+ def args
33
+ params = self.class.instance_method(:initialize).parameters
34
+ names = params.select { |t,| %i[key keyreq].include?(t) }.map(&:last)
35
+ names.each_with_object({}) do |name, h|
36
+ val = instance_variable_get(:"@#{name}")
37
+ h[name] = val unless val.nil?
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -1,34 +1,44 @@
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
+ def initialize(key:, limit: nil)
19
+ super()
20
+ @key = key
21
+ @limit = limit
22
+ end
23
+
24
+ def call(container:, **)
21
25
  @container = container
22
- @manifest = container.manifest
23
- @root = container.root
26
+ @manifest = container.manifest
27
+ @root = container.root
28
+
29
+ audit_rows = Textus::Action::Audit.new(key: @key, limit: @limit).call(container: container)
30
+ path = resolve_path(@key)
31
+ return audit_rows.map { |row| row.merge("git" => nil) } unless git_tracked?(path)
32
+
33
+ audit_rows.map { |row| row.merge("git" => git_commit_at(path, timestamp: row["ts"])) }
24
34
  end
25
35
 
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)
36
+ def self.new(*args, **kwargs)
37
+ return super(**kwargs) unless args.any?
30
38
 
31
- audit_rows.map { |r| r.merge("git" => git_commit_at(path, timestamp: r["ts"])) }
39
+ positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
40
+ mapped = positional.zip(args).to_h
41
+ super(**mapped.merge(kwargs))
32
42
  end
33
43
 
34
44
  private
@@ -37,9 +47,6 @@ module Textus
37
47
  res = @manifest.resolver.resolve(key)
38
48
  mentry = res.entry
39
49
  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
50
  path || Textus::Key::Path.resolve(@manifest.data, mentry)
44
51
  rescue Textus::Error
45
52
  nil
@@ -60,7 +67,6 @@ module Textus
60
67
  end
61
68
 
62
69
  def git_repo?
63
- # Walk up from store root to find a .git directory.
64
70
  dir = @root
65
71
  loop do
66
72
  return true if File.directory?(File.join(dir, ".git"))
@@ -0,0 +1,17 @@
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: lanes, agent_quickstart, agent_protocol, and pre-computed artifacts."
10
+ surfaces :cli, :mcp
11
+
12
+ def call(container:, **)
13
+ Textus::Boot.build(container: container)
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,69 @@
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
+ def initialize(from:, to:, dry_run: false)
23
+ super()
24
+ @from = from
25
+ @to = to
26
+ @dry_run = dry_run
27
+ end
28
+
29
+ def call(container:, **)
30
+ manifest = container.manifest
31
+ root = container.root
32
+
33
+ raise UsageError.new("from and to required") if @from.nil? || @to.nil? || @from.empty? || @to.empty?
34
+ raise UsageError.new("data lane '#{@from}' not declared") unless manifest.data.declared_lane_kinds.key?(@from)
35
+
36
+ dest_dir = File.join(root, "data", @to)
37
+ raise UsageError.new("destination 'data/#{@to}' already exists") if File.exist?(dest_dir)
38
+
39
+ affected_keys = manifest.data.entries.select { |entry| entry.lane == @from }.map(&:key)
40
+
41
+ steps = [{ "op" => "rename_zone", "from" => @from, "to" => @to }]
42
+ steps += affected_keys.map do |key|
43
+ { "op" => "mv", "from" => key, "to" => "#{@to}#{key[@from.length..]}" }
44
+ end
45
+
46
+ plan = Textus::Jobs::Plan.new(steps: steps, warnings: [])
47
+ return plan if @dry_run
48
+
49
+ rewrite_manifest!(root)
50
+ FileUtils.mv(File.join(root, "data", @from), dest_dir)
51
+ plan
52
+ end
53
+
54
+ private
55
+
56
+ def rewrite_manifest!(root)
57
+ path = File.join(root, "manifest.yaml")
58
+ raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
59
+ raw["lanes"].each { |lane| lane["name"] = @to if lane["name"] == @from }
60
+ raw["entries"].each do |entry|
61
+ entry["lane"] = @to if entry["lane"] == @from
62
+ entry["key"] = entry["key"].sub(/\A#{Regexp.escape(@from)}(\.|\z)/, "#{@to}\\1")
63
+ entry["path"] = entry["path"].sub(%r{\A(data/)?#{Regexp.escape(@from)}(/|\z)}, "\\1#{@to}\\2")
64
+ end
65
+ File.write(path, YAML.dump(raw))
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,34 @@
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
+ def initialize(key:)
15
+ super()
16
+ @key = key
17
+ end
18
+
19
+ def call(container:, **)
20
+ entry = container.manifest.data.entries.find { |e| e.key == @key }
21
+ deps = entry&.external? ? Array(entry.source&.sources).compact : []
22
+ { "key" => @key, "deps" => deps.uniq }
23
+ end
24
+
25
+ def self.new(*args, **kwargs)
26
+ return super(**kwargs) unless args.any?
27
+
28
+ positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
29
+ mapped = positional.zip(args).to_h
30
+ super(**mapped.merge(kwargs))
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,24 @@
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
+ def initialize(checks: nil)
15
+ super()
16
+ @checks = checks
17
+ end
18
+
19
+ def call(container:, call:, **)
20
+ Textus::Doctor.build(container: container, checks: @checks, role: call.role)
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,39 @@
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 materialize + 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
+ def initialize(prefix: nil, lane: nil)
16
+ super()
17
+ @prefix = prefix
18
+ @lane = lane
19
+ end
20
+
21
+ def call(container:, call:)
22
+ queue = Textus::Ports::JobStore.new(root: container.root)
23
+ Textus::Jobs::Planner.seed(
24
+ container: container,
25
+ queue: queue,
26
+ role: call.role,
27
+ )
28
+ queue.reclaim(now: Textus::Ports::Clock.new.now)
29
+ summary = Textus::Jobs::Worker.for(container:, queue:).drain
30
+ {
31
+ "protocol" => Textus::PROTOCOL,
32
+ "ok" => summary.failed.zero?,
33
+ "completed" => summary.completed,
34
+ "failed" => summary.failed,
35
+ }
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,53 @@
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
+ def initialize(type:, args: {})
18
+ super()
19
+ @type = type
20
+ @job_args = args
21
+ end
22
+
23
+ def args
24
+ { type: @type, args: @job_args }
25
+ end
26
+
27
+ def call(container:, call:)
28
+ action_class = begin
29
+ Textus::Jobs.fetch(@type.to_s)
30
+ rescue Textus::UsageError
31
+ raise Textus::UsageError.new("unregistered job type '#{@type}'")
32
+ end
33
+ if action_class.const_defined?(:REQUIRED_ROLE) && call.role != action_class::REQUIRED_ROLE
34
+ raise Textus::Error.new(
35
+ "forbidden",
36
+ "role '#{call.role}' is not authorized to enqueue this job type (requires '#{action_class::REQUIRED_ROLE}')",
37
+ details: { "role" => call.role, "required_role" => action_class::REQUIRED_ROLE },
38
+ exit_code: 77,
39
+ )
40
+ end
41
+
42
+ job = Textus::Ports::JobStore::Job.new(
43
+ type: @type,
44
+ args: @job_args,
45
+ enqueued_by: call.role,
46
+ max_attempts: 3,
47
+ )
48
+ Textus::Ports::JobStore.new(root: container.root).enqueue(job)
49
+ { "protocol" => Textus::PROTOCOL, "ok" => true, "id" => job.id }
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,78 @@
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
+ def initialize(key:)
19
+ super()
20
+ @key = key
21
+ end
22
+
23
+ def call(container:, call:, file_stat: Textus::Ports::Storage::FileStat.new)
24
+ @container = container
25
+ @call = call
26
+ @manifest = container.manifest
27
+ @file_store = container.file_store
28
+ @file_stat = file_stat
29
+ annotated_envelope(@key)
30
+ end
31
+
32
+ def self.new(*args, **kwargs)
33
+ return super(**kwargs) unless args.any?
34
+
35
+ positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
36
+ mapped = positional.zip(args).to_h
37
+ super(**mapped.merge(kwargs))
38
+ end
39
+
40
+ private
41
+
42
+ def annotated_envelope(key)
43
+ envelope = read_raw_envelope(key)
44
+ return nil if envelope.nil?
45
+
46
+ entry = @manifest.resolver.resolve(key).entry
47
+ envelope.with(freshness: evaluator.verdict(entry))
48
+ end
49
+
50
+ def evaluator
51
+ @evaluator ||= Textus::Core::Freshness::Evaluator.new(
52
+ manifest: @manifest,
53
+ file_stat: @file_stat,
54
+ clock: @call,
55
+ )
56
+ end
57
+
58
+ def read_raw_envelope(key)
59
+ res = @manifest.resolver.resolve(key)
60
+ mentry = res.entry
61
+ path = res.path
62
+ return nil unless @file_store.exists?(path)
63
+
64
+ raw = @file_store.read(path)
65
+ parsed = Textus::Format.for(mentry.format).parse(raw, path: path)
66
+ Textus::Envelope.build(
67
+ key: key,
68
+ mentry: mentry,
69
+ path: path,
70
+ meta: parsed["_meta"],
71
+ body: parsed["body"],
72
+ etag: Textus::Etag.for_bytes(raw),
73
+ content: parsed["content"],
74
+ )
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,135 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "date"
5
+
6
+ module Textus
7
+ module Action
8
+ class Ingest < Base
9
+ extend Textus::Contract::DSL
10
+
11
+ verb :ingest
12
+ summary "Capture external source material into the raw lane. Write-once, agent-owned."
13
+ surfaces :cli, :mcp
14
+ arg :kind, String, required: true, positional: true,
15
+ description: "source kind: url | file | asset"
16
+ arg :slug, String, required: true,
17
+ description: "human slug for the key suffix (kebab-case)"
18
+ arg :url, String, description: "remote URL (required when kind=url)"
19
+ arg :path, String, description: "local file path (required when kind=file or kind=asset)"
20
+ arg :zone, String, description: "asset group subdirectory (required when kind=asset)"
21
+ arg :label, String, description: "human label stored in source.label"
22
+ view { |env| { "key" => env.key, "uid" => env.uid, "etag" => env.etag } }
23
+
24
+ SOURCE_KINDS = %w[url file asset].freeze
25
+
26
+ def initialize(kind:, slug:, url: nil, path: nil, zone: nil, label: nil)
27
+ super()
28
+ @kind = kind
29
+ @slug = slug
30
+ @url = url
31
+ @path = path
32
+ @zone = zone
33
+ @label = label
34
+ end
35
+
36
+ def call(container:, call:)
37
+ validate_inputs!
38
+
39
+ now = Time.now.utc
40
+ key = derive_key(now)
41
+
42
+ Textus::Gate::Auth.new(container).check_action!(
43
+ action: :ingest, actor: call.role, key: key,
44
+ )
45
+
46
+ write_raw_entry(key, now, container, call)
47
+ end
48
+
49
+ private
50
+
51
+ def validate_inputs!
52
+ unless SOURCE_KINDS.include?(@kind)
53
+ raise Textus::UsageError.new(
54
+ "ingest kind must be one of #{SOURCE_KINDS.join("|")}, got #{@kind.inspect}",
55
+ )
56
+ end
57
+ case @kind
58
+ when "url"
59
+ raise Textus::UsageError.new("ingest url requires --url") unless @url
60
+ when "file"
61
+ raise Textus::UsageError.new("ingest file requires --path") unless @path
62
+ when "asset"
63
+ raise Textus::UsageError.new("ingest asset requires --path") unless @path
64
+ raise Textus::UsageError.new("ingest asset requires --zone") unless @zone
65
+ end
66
+ end
67
+
68
+ def derive_key(now)
69
+ date = now.strftime("%Y.%m.%d")
70
+ "raw.#{date}.#{@kind}-#{@slug}"
71
+ end
72
+
73
+ def write_raw_entry(key, now, container, call)
74
+ ts = now.iso8601
75
+ mentry = container.manifest.resolver.resolve(key).entry
76
+ writer = Textus::Envelope::Writer.from(container: container, call: call)
77
+
78
+ case @kind
79
+ when "url"
80
+ structured = {
81
+ "ingested_at" => ts,
82
+ "source" => { "kind" => "url", "url" => @url, "label" => @label || @url },
83
+ "body" => nil,
84
+ }
85
+ writer.put(key, mentry: mentry,
86
+ payload: Textus::Envelope::Writer::Payload.new(
87
+ meta: nil, body: nil, content: structured,
88
+ ))
89
+ when "file"
90
+ body_content = File.read(@path)
91
+ structured = {
92
+ "ingested_at" => ts,
93
+ "source" => { "kind" => "file", "path" => @path,
94
+ "label" => @label || File.basename(@path) },
95
+ "body" => body_content,
96
+ }
97
+ writer.put(key, mentry: mentry,
98
+ payload: Textus::Envelope::Writer::Payload.new(
99
+ meta: nil, body: nil, content: structured,
100
+ ))
101
+ when "asset"
102
+ asset_rel = copy_asset_file(container, now)
103
+ structured = {
104
+ "ingested_at" => ts,
105
+ "source" => { "kind" => "asset",
106
+ "label" => @label || File.basename(@path) },
107
+ "asset" => asset_rel,
108
+ "body" => nil,
109
+ }
110
+ writer.put(key, mentry: mentry,
111
+ payload: Textus::Envelope::Writer::Payload.new(
112
+ meta: nil, body: nil, content: structured,
113
+ ))
114
+ end
115
+ end
116
+
117
+ def copy_asset_file(container, now)
118
+ date_path = now.strftime("%Y/%m/%d")
119
+ filename = File.basename(@path)
120
+ assets_dir = File.join(container.root, "assets", "raw", date_path, @zone)
121
+ FileUtils.mkdir_p(assets_dir)
122
+ FileUtils.cp(@path, File.join(assets_dir, filename))
123
+ create_gitignore_sentinel(container)
124
+ "raw/#{date_path}/#{@zone}/#{filename}"
125
+ end
126
+
127
+ def create_gitignore_sentinel(container)
128
+ assets_root = File.join(container.root, "assets")
129
+ FileUtils.mkdir_p(assets_root)
130
+ sentinel = File.join(assets_root, ".gitignore")
131
+ File.write(sentinel, "*\n") unless File.exist?(sentinel)
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,36 @@
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
+ def initialize(state: "ready", action: nil, job_id: nil)
17
+ super()
18
+ @state = state
19
+ @action = action
20
+ @job_id = job_id
21
+ end
22
+
23
+ def call(container:, **)
24
+ queue = Textus::Ports::JobStore.new(root: container.root)
25
+ case @action
26
+ when "retry"
27
+ queue.retry_failed(@job_id)
28
+ when "purge"
29
+ queue.purge(@state)
30
+ end
31
+
32
+ { "protocol" => Textus::PROTOCOL, "ok" => true, "state" => @state, "jobs" => queue.list(@state) }
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,36 @@
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
+ def initialize(key:, if_etag: nil)
19
+ super()
20
+ @key = key
21
+ @if_etag = if_etag
22
+ end
23
+
24
+ def call(container:, call:)
25
+ run_with_cascade(@key, container:, call:) do
26
+ Textus::Manifest::Data.validate_key!(@key)
27
+ mentry = container.manifest.resolver.resolve(@key).entry
28
+
29
+ writer(container, call).delete(@key, mentry:, if_etag: @if_etag)
30
+
31
+ { "protocol" => Textus::PROTOCOL, "ok" => true, "key" => @key, "deleted" => true }
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end