textus 0.54.2 → 0.55.1

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 (176) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +8 -1
  4. data/SPEC.md +27 -0
  5. data/docs/architecture/README.md +20 -8
  6. data/docs/reference/conventions.md +1 -1
  7. data/exe/textus +1 -1
  8. data/lib/textus/action/accept.rb +23 -21
  9. data/lib/textus/action/audit.rb +24 -61
  10. data/lib/textus/action/base.rb +9 -9
  11. data/lib/textus/action/blame.rb +18 -36
  12. data/lib/textus/action/boot.rb +2 -4
  13. data/lib/textus/action/data_mv.rb +20 -31
  14. data/lib/textus/action/deps.rb +3 -18
  15. data/lib/textus/action/doctor.rb +2 -9
  16. data/lib/textus/action/drain.rb +11 -19
  17. data/lib/textus/action/enqueue.rb +14 -30
  18. data/lib/textus/action/get.rb +12 -56
  19. data/lib/textus/action/ingest.rb +74 -78
  20. data/lib/textus/action/jobs.rb +6 -15
  21. data/lib/textus/action/key_delete.rb +6 -16
  22. data/lib/textus/action/key_delete_prefix.rb +8 -17
  23. data/lib/textus/action/key_mv.rb +54 -61
  24. data/lib/textus/action/key_mv_prefix.rb +13 -22
  25. data/lib/textus/action/list.rb +7 -21
  26. data/lib/textus/action/propose.rb +16 -26
  27. data/lib/textus/action/published.rb +3 -5
  28. data/lib/textus/action/pulse.rb +19 -26
  29. data/lib/textus/action/put.rb +15 -29
  30. data/lib/textus/action/rdeps.rb +3 -18
  31. data/lib/textus/action/reject.rb +12 -21
  32. data/lib/textus/action/rule_explain.rb +12 -22
  33. data/lib/textus/action/rule_lint.rb +10 -16
  34. data/lib/textus/action/rule_list.rb +5 -9
  35. data/lib/textus/action/schema_envelope.rb +3 -10
  36. data/lib/textus/action/uid.rb +3 -17
  37. data/lib/textus/action/where.rb +3 -18
  38. data/lib/textus/boot.rb +7 -15
  39. data/lib/textus/contract/arg.rb +10 -0
  40. data/lib/textus/contract/dsl.rb +88 -0
  41. data/lib/textus/contract/spec.rb +25 -0
  42. data/lib/textus/contract.rb +0 -162
  43. data/lib/textus/doctor/check/audit_log.rb +2 -2
  44. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  45. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  46. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  47. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  48. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  49. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  50. data/lib/textus/doctor/check/schemas.rb +1 -1
  51. data/lib/textus/doctor/check/sentinels.rb +4 -4
  52. data/lib/textus/doctor/check/templates.rb +1 -1
  53. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  54. data/lib/textus/doctor/check.rb +4 -7
  55. data/lib/textus/doctor.rb +1 -1
  56. data/lib/textus/errors.rb +6 -0
  57. data/lib/textus/format/base.rb +0 -4
  58. data/lib/textus/format/json.rb +5 -6
  59. data/lib/textus/format/markdown.rb +5 -6
  60. data/lib/textus/format/shared.rb +17 -0
  61. data/lib/textus/format/text.rb +5 -4
  62. data/lib/textus/format/yaml.rb +30 -6
  63. data/lib/textus/format.rb +6 -0
  64. data/lib/textus/gate/auth.rb +2 -17
  65. data/lib/textus/gate/binder.rb +50 -0
  66. data/lib/textus/gate.rb +64 -88
  67. data/lib/textus/init.rb +2 -4
  68. data/lib/textus/jobs.rb +3 -9
  69. data/lib/textus/manifest/capabilities.rb +3 -3
  70. data/lib/textus/manifest/entry/base.rb +1 -1
  71. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  72. data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
  73. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  74. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  75. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  76. data/lib/textus/manifest/schema/semantics.rb +11 -216
  77. data/lib/textus/meta.rb +54 -0
  78. data/lib/textus/{ports → port}/audit_log.rb +44 -4
  79. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  80. data/lib/textus/{ports → port}/clock.rb +1 -1
  81. data/lib/textus/{ports → port}/publisher.rb +5 -5
  82. data/lib/textus/{ports → port}/sentinel_store.rb +3 -3
  83. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  84. data/lib/textus/{ports → port}/storage/file_store.rb +2 -2
  85. data/lib/textus/port/store.rb +93 -0
  86. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  87. data/lib/textus/produce/engine.rb +1 -1
  88. data/lib/textus/schema/tools.rb +11 -7
  89. data/lib/textus/store/compositor.rb +34 -0
  90. data/lib/textus/store/container.rb +43 -0
  91. data/lib/textus/store/cursor.rb +26 -0
  92. data/lib/textus/store/envelope/reader.rb +43 -0
  93. data/lib/textus/store/envelope/writer.rb +195 -0
  94. data/lib/textus/store/geometry.rb +81 -0
  95. data/lib/textus/store/index/builder.rb +74 -0
  96. data/lib/textus/store/index/lookup.rb +60 -0
  97. data/lib/textus/store/jobs/base.rb +13 -0
  98. data/lib/textus/store/jobs/index.rb +15 -0
  99. data/lib/textus/store/jobs/materialize.rb +15 -0
  100. data/lib/textus/store/jobs/plan.rb +11 -0
  101. data/lib/textus/store/jobs/planner.rb +104 -0
  102. data/lib/textus/store/jobs/queue.rb +154 -0
  103. data/lib/textus/store/jobs/registry.rb +19 -0
  104. data/lib/textus/store/jobs/retention.rb +50 -0
  105. data/lib/textus/store/jobs/sweep.rb +21 -0
  106. data/lib/textus/store/jobs/worker.rb +64 -0
  107. data/lib/textus/store/session.rb +37 -0
  108. data/lib/textus/store.rb +21 -13
  109. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  110. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  111. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  112. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  113. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  114. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  115. data/lib/textus/{surfaces → surface}/cli/runner.rb +10 -63
  116. data/lib/textus/surface/cli/sources.rb +41 -0
  117. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +4 -6
  118. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +4 -4
  119. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  120. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  121. data/lib/textus/{surfaces → surface}/cli/verb/put.rb +6 -11
  122. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  123. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  124. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  125. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  126. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  127. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  128. data/lib/textus/{surfaces → surface}/mcp/catalog.rb +9 -26
  129. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -1
  130. data/lib/textus/{surfaces → surface}/mcp/server.rb +5 -5
  131. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  132. data/lib/textus/surface/projector.rb +27 -0
  133. data/lib/textus/{surfaces → surface}/role_scope.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  135. data/lib/textus/value/call.rb +30 -0
  136. data/lib/textus/value/command.rb +16 -0
  137. data/lib/textus/value/envelope.rb +89 -0
  138. data/lib/textus/value/etag.rb +39 -0
  139. data/lib/textus/value/result.rb +26 -0
  140. data/lib/textus/value/role.rb +38 -0
  141. data/lib/textus/value/types.rb +13 -0
  142. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  143. data/lib/textus/version.rb +1 -1
  144. data/lib/textus/workflow/loader.rb +4 -4
  145. data/lib/textus/workflow/runner.rb +4 -18
  146. data/lib/textus.rb +9 -10
  147. metadata +100 -63
  148. data/lib/textus/action/write_verb.rb +0 -44
  149. data/lib/textus/call.rb +0 -28
  150. data/lib/textus/command.rb +0 -41
  151. data/lib/textus/container.rb +0 -26
  152. data/lib/textus/contract/around.rb +0 -29
  153. data/lib/textus/contract/binder.rb +0 -88
  154. data/lib/textus/contract/resources/build_lock.rb +0 -17
  155. data/lib/textus/contract/resources/cursor.rb +0 -26
  156. data/lib/textus/contract/sources.rb +0 -39
  157. data/lib/textus/contract/view.rb +0 -15
  158. data/lib/textus/cursor_store.rb +0 -24
  159. data/lib/textus/envelope/reader.rb +0 -46
  160. data/lib/textus/envelope/writer.rb +0 -209
  161. data/lib/textus/envelope.rb +0 -79
  162. data/lib/textus/etag.rb +0 -36
  163. data/lib/textus/jobs/base.rb +0 -23
  164. data/lib/textus/jobs/materialize.rb +0 -20
  165. data/lib/textus/jobs/plan.rb +0 -9
  166. data/lib/textus/jobs/planner.rb +0 -101
  167. data/lib/textus/jobs/retention.rb +0 -48
  168. data/lib/textus/jobs/sweep.rb +0 -27
  169. data/lib/textus/jobs/worker.rb +0 -67
  170. data/lib/textus/layout.rb +0 -91
  171. data/lib/textus/ports/job_store/job.rb +0 -65
  172. data/lib/textus/ports/job_store.rb +0 -123
  173. data/lib/textus/ports/raw_index.rb +0 -61
  174. data/lib/textus/role.rb +0 -36
  175. data/lib/textus/session.rb +0 -35
  176. data/lib/textus/types.rb +0 -15
@@ -8,12 +8,12 @@ module Textus
8
8
  # that drift without making `build` scan globally.
9
9
  class OrphanedPublishTargets < Check
10
10
  def call
11
- sdir = Textus::Layout.sentinels(root)
11
+ sdir = Textus::Store::Geometry.new(root).sentinels_root
12
12
  return [] unless File.directory?(sdir)
13
13
 
14
14
  repo_root = File.dirname(root)
15
- store = Textus::Ports::SentinelStore.new
16
- glob = File.join(sdir, "**", "*#{Textus::Ports::SentinelStore::SUFFIX}")
15
+ store = Textus::Port::SentinelStore.new
16
+ glob = File.join(sdir, "**", "*#{Textus::Port::SentinelStore::SUFFIX}")
17
17
  Dir.glob(glob).filter_map do |spath|
18
18
  sentinel = store.load(spath, repo_root)
19
19
  next nil if sentinel.nil? || sentinel.source.nil?
@@ -8,7 +8,7 @@ module Textus
8
8
  class ProtocolVersion < Check
9
9
  # Standalone interface: root is the project root (parent of .textus/).
10
10
  def self.run(root:)
11
- path = File.join(root, ".textus/manifest.yaml")
11
+ path = File.join(root, ".textus", "manifest.yaml")
12
12
  return [] unless File.exist?(path)
13
13
 
14
14
  doc = YAML.safe_load_file(path, aliases: false) || {}
@@ -26,7 +26,7 @@ module Textus
26
26
  # Doctor check interface: root is the .textus/ directory itself,
27
27
  # so manifest.yaml lives directly inside it.
28
28
  def call
29
- path = File.join(root, "manifest.yaml")
29
+ path = geometry.manifest_path
30
30
  return [] unless File.exist?(path)
31
31
 
32
32
  doc = YAML.safe_load_file(path, aliases: false) || {}
@@ -42,7 +42,7 @@ module Textus
42
42
  end
43
43
 
44
44
  def find_asset_path(asset_rel)
45
- File.join(root, "assets", asset_rel)
45
+ File.join(geometry.root, "assets", asset_rel)
46
46
  end
47
47
  end
48
48
  end
@@ -7,7 +7,7 @@ module Textus
7
7
  # leaving the operator with no signal that a schema is broken.
8
8
  class SchemaParseError < Check
9
9
  def call
10
- dir = File.join(root, "schemas")
10
+ dir = geometry.schemas_dir
11
11
  return [] unless File.directory?(dir)
12
12
 
13
13
  Dir.glob(File.join(dir, "*.yaml")).each_with_object([]) do |path, out|
@@ -4,11 +4,11 @@ module Textus
4
4
  class SchemaViolations < Check
5
5
  def call
6
6
  result = Textus::Doctor::Validator.new(
7
- reader: ->(key, ctnr, c) { Textus::Action::Get.new(key: key).call(container: ctnr, call: c) },
7
+ reader: ->(key, ctnr, c) { Value::Result.unwrap(Textus::Action::Get.call(container: ctnr, call: c, key: key)) },
8
8
  manifest: @container.manifest,
9
9
  audit_log: @container.audit_log,
10
10
  schema_for: ->(name) { @container.schemas.fetch_or_nil(name) },
11
- ).call(container: @container, call: Textus::Call.build(role: Textus::Role::DEFAULT))
11
+ ).call(container: @container, call: Textus::Value::Call.build(role: Textus::Value::Role::DEFAULT))
12
12
 
13
13
  result["violations"].map do |v|
14
14
  fix = v["expected"] &&
@@ -7,7 +7,7 @@ module Textus
7
7
  manifest.data.entries.each do |entry|
8
8
  next if entry.schema.nil?
9
9
 
10
- sp = File.join(root, "schemas", "#{entry.schema}.yaml")
10
+ sp = geometry.schema_path(entry.schema)
11
11
  next if File.exist?(sp)
12
12
 
13
13
  out << {
@@ -3,13 +3,13 @@ module Textus
3
3
  class Check
4
4
  class Sentinels < Check
5
5
  def call
6
- store = Textus::Ports::SentinelStore.new
7
- file_stat = Textus::Ports::Storage::FileStat.new
8
- dir = Textus::Layout.sentinels(root)
6
+ store = Textus::Port::SentinelStore.new
7
+ file_stat = Textus::Port::Storage::FileStat.new
8
+ dir = Textus::Store::Geometry.new(root).sentinels_root
9
9
  return [] unless file_stat.directory?(dir)
10
10
 
11
11
  repo_root = File.dirname(root)
12
- file_stat.glob(File.join(dir, "**", "*#{Textus::Ports::SentinelStore::SUFFIX}")).flat_map do |sentinel_path|
12
+ file_stat.glob(File.join(dir, "**", "*#{Textus::Port::SentinelStore::SUFFIX}")).flat_map do |sentinel_path|
13
13
  inspect_sentinel(sentinel_path, repo_root, store, file_stat)
14
14
  end
15
15
  end
@@ -9,7 +9,7 @@ module Textus
9
9
  template = target.template
10
10
  next if template.nil?
11
11
 
12
- tp = File.join(root, "templates", template)
12
+ tp = geometry.template_path(template)
13
13
  next if File.exist?(tp)
14
14
 
15
15
  out << {
@@ -3,7 +3,7 @@ module Textus
3
3
  class Check
4
4
  class UnownedSchemaFields < Check
5
5
  def call
6
- dir = File.join(root, "schemas")
6
+ dir = geometry.schemas_dir
7
7
  return [] unless File.directory?(dir)
8
8
 
9
9
  Dir.glob(File.join(dir, "*.yaml")).flat_map do |path|
@@ -14,7 +14,7 @@ module Textus
14
14
  .downcase
15
15
  end
16
16
 
17
- def initialize(container, role: Textus::Role::DEFAULT)
17
+ def initialize(container, role: Textus::Value::Role::DEFAULT)
18
18
  @container = container
19
19
  @role = role
20
20
  end
@@ -26,18 +26,15 @@ module Textus
26
26
  protected
27
27
 
28
28
  def root = @container.root
29
+ def geometry = @container.geometry
29
30
  def manifest = @container.manifest
30
31
 
31
32
  # Dispatch a verb through Gate.
32
33
  def dispatch(verb, *args, **kwargs)
33
34
  klass = Textus::Action::VERBS[verb]
34
35
  spec = klass.contract if klass.respond_to?(:contract?) && klass.contract?
35
- inputs = spec ? Textus::Contract::Binder.inputs_from_ordered(spec, args, kwargs) : kwargs
36
- cmd_class = Textus::Gate::VERB_COMMAND.fetch(verb)
37
- merged = inputs.merge(role: @role)
38
- filled = cmd_class.members.to_h { |m| [m, merged.key?(m) ? merged[m] : nil] }
39
- cmd = cmd_class.new(**filled)
40
- @container.gate.dispatch(cmd)
36
+ inputs = spec ? Textus::Gate::Binder.inputs_from_ordered(spec, args, kwargs) : kwargs
37
+ @container.gate.dispatch(spec:, inputs:, role: @role)
41
38
  end
42
39
  end
43
40
  end
data/lib/textus/doctor.rb CHANGED
@@ -30,7 +30,7 @@ module Textus
30
30
 
31
31
  module_function
32
32
 
33
- def build(container:, checks: nil, role: Textus::Role::DEFAULT)
33
+ def build(container:, checks: nil, role: Textus::Value::Role::DEFAULT)
34
34
  selected_keys = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
35
35
  unknown = selected_keys - ALL_CHECKS
36
36
  unless unknown.empty?
data/lib/textus/errors.rb CHANGED
@@ -225,6 +225,12 @@ module Textus
225
225
  end
226
226
  end
227
227
 
228
+ class ActionError < Error
229
+ def initialize(code, message, details: {})
230
+ super(code, message, details: details, exit_code: 1)
231
+ end
232
+ end
233
+
228
234
  class CursorExpired < Error
229
235
  attr_reader :requested, :min_available
230
236
 
@@ -29,10 +29,6 @@ module Textus
29
29
  raise NotImplementedError.new("#{name}.validate_path_extension not implemented")
30
30
  end
31
31
 
32
- def self.inject_uid(_meta, _content, _existing_uid)
33
- raise NotImplementedError.new("#{name}.inject_uid not implemented")
34
- end
35
-
36
32
  def self.enforce_name_match!(_path, _meta)
37
33
  raise NotImplementedError.new("#{name}.enforce_name_match! not implemented")
38
34
  end
@@ -80,12 +80,6 @@ module Textus
80
80
  raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
81
81
  end
82
82
 
83
- def self.inject_uid(meta, content, existing_uid)
84
- m = meta.is_a?(Hash) ? meta.dup : {}
85
- m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
86
- [m, content]
87
- end
88
-
89
83
  def self.validate_path_extension(path, nested)
90
84
  ext = File.extname(path)
91
85
  if nested
@@ -98,6 +92,11 @@ module Textus
98
92
 
99
93
  raise UsageError.new("json format requires '.json' path (got #{ext.inspect})")
100
94
  end
95
+
96
+ def self.data_to_payload(data)
97
+ data = data.transform_keys(&:to_s) if data.is_a?(Hash)
98
+ { meta: data["_meta"] || {}, body: nil, content: data["content"] || data }
99
+ end
101
100
  end
102
101
  end
103
102
  end
@@ -64,18 +64,17 @@ module Textus
64
64
  raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
65
65
  end
66
66
 
67
- def self.inject_uid(meta, content, existing_uid)
68
- m = meta.is_a?(Hash) ? meta.dup : {}
69
- m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
70
- [m, content]
71
- end
72
-
73
67
  def self.validate_path_extension(path, _nested)
74
68
  ext = File.extname(path)
75
69
  return if ["", ".md"].include?(ext)
76
70
 
77
71
  raise UsageError.new("markdown format requires '.md' path (got #{ext.inspect})")
78
72
  end
73
+
74
+ def self.data_to_payload(data)
75
+ data = data.transform_keys(&:to_s) if data.is_a?(Hash)
76
+ { meta: data["_meta"] || {}, body: (data["body"] || "").to_s, content: nil }
77
+ end
79
78
  end
80
79
  end
81
80
  end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Format
3
+ module Shared
4
+ ENFORCE_NAME_RE = /\.(md|json|yaml|yml|txt)\z/i
5
+
6
+ def self.enforce_name_match!(path, meta, extensions)
7
+ return unless meta.is_a?(Hash) && meta["name"]
8
+
9
+ ext = extensions.first
10
+ basename = File.basename(path, ext)
11
+ return if meta["name"] == basename
12
+
13
+ raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
14
+ end
15
+ end
16
+ end
17
+ end
@@ -20,10 +20,6 @@ module Textus
20
20
 
21
21
  def self.nested_glob = "**/*.txt"
22
22
 
23
- def self.inject_uid(meta, content, _existing_uid)
24
- [meta, content]
25
- end
26
-
27
23
  def self.enforce_name_match!(_path, _meta); end
28
24
 
29
25
  def self.serialize_for_put(meta:, body:, content:, path:)
@@ -49,6 +45,11 @@ module Textus
49
45
 
50
46
  raise UsageError.new("text format requires '.txt' or no extension (got #{ext.inspect})")
51
47
  end
48
+
49
+ def self.data_to_payload(data)
50
+ data = data.transform_keys(&:to_s) if data.is_a?(Hash)
51
+ { meta: data["_meta"] || {}, body: (data["body"] || "").to_s, content: nil }
52
+ end
52
53
  end
53
54
  end
54
55
  end
@@ -37,6 +37,31 @@ module Textus
37
37
  schema.validate!(parsed["content"] || {})
38
38
  end
39
39
 
40
+ RAW_REQUIRED = %w[ingested_at content_hash].freeze
41
+ RAW_SOURCE_KINDS = %w[url file asset].freeze
42
+
43
+ def self.validate_raw_entry!(parsed, lane)
44
+ return unless lane == "raw"
45
+
46
+ content = parsed["content"] || {}
47
+ return if content["superseded_by"]
48
+
49
+ missing = RAW_REQUIRED.reject { |f| content[f] }
50
+ raise Textus::BadContent.new(nil, "raw entry missing required field(s): #{missing.join(", ")}") if missing.any?
51
+
52
+ source = content["source"] || {}
53
+ kind = source["kind"]
54
+ unless RAW_SOURCE_KINDS.include?(kind)
55
+ raise Textus::BadContent.new(
56
+ nil, "raw entry source.kind must be #{RAW_SOURCE_KINDS.join("|")}, got #{kind.inspect}"
57
+ )
58
+ end
59
+
60
+ return unless kind == "url" && !source["url"]
61
+
62
+ raise Textus::BadContent.new(nil, "raw entry with source.kind=url must have source.url")
63
+ end
64
+
40
65
  def self.extensions = [".yaml", ".yml"]
41
66
 
42
67
  def self.nested_glob = "**/*.{yaml,yml}"
@@ -78,12 +103,6 @@ module Textus
78
103
  raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
79
104
  end
80
105
 
81
- def self.inject_uid(meta, content, existing_uid)
82
- m = meta.is_a?(Hash) ? meta.dup : {}
83
- m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
84
- [m, content]
85
- end
86
-
87
106
  def self.validate_path_extension(path, nested)
88
107
  ext = File.extname(path)
89
108
  if nested
@@ -96,6 +115,11 @@ module Textus
96
115
 
97
116
  raise UsageError.new("yaml format requires '.yaml' or '.yml' path (got #{ext.inspect})")
98
117
  end
118
+
119
+ def self.data_to_payload(data)
120
+ data = data.transform_keys(&:to_s) if data.is_a?(Hash)
121
+ { meta: data["_meta"] || {}, body: nil, content: data["content"] || data }
122
+ end
99
123
  end
100
124
  end
101
125
  end
data/lib/textus/format.rb CHANGED
@@ -36,5 +36,11 @@ module Textus
36
36
  def self.serialize(meta: {}, body: "", content: nil, format: "markdown")
37
37
  Format.for(format).serialize(meta: meta, body: body, content: content)
38
38
  end
39
+
40
+ def self.data_to_payload(data, format)
41
+ return { meta: {}, body: "", content: nil } if data.nil?
42
+
43
+ Format.for(format).data_to_payload(data)
44
+ end
39
45
  end
40
46
  end
@@ -27,13 +27,12 @@ module Textus
27
27
  @schemas = container.schemas
28
28
  end
29
29
 
30
- # Command-based check (new Gate path).
31
30
  def check!(cmd)
32
31
  key = extract_key(cmd)
33
32
  return unless key
34
33
 
35
34
  evaluate_predicates(
36
- action: command_to_action(cmd),
35
+ action: cmd.verb,
37
36
  actor: cmd.role.to_s,
38
37
  key: key,
39
38
  envelope: nil,
@@ -52,18 +51,8 @@ module Textus
52
51
  )
53
52
  end
54
53
 
55
- def self.command_to_verb
56
- @command_to_verb ||= Textus::Gate::VERB_COMMAND.invert.freeze
57
- end
58
-
59
54
  private
60
55
 
61
- def command_to_action(cmd)
62
- self.class.command_to_verb.fetch(cmd.class) do
63
- raise Textus::UsageError.new("unmapped command: #{cmd.class}")
64
- end
65
- end
66
-
67
56
  def evaluate_predicates(action:, actor:, key:, envelope:, extra:)
68
57
  mentry = @manifest.resolver.resolve(key).entry
69
58
  lane_verb = @manifest.policy.verb_for_lane(mentry.lane.to_s)
@@ -89,11 +78,7 @@ module Textus
89
78
  end
90
79
 
91
80
  def extract_key(cmd)
92
- if cmd.respond_to?(:pending_key)
93
- cmd.pending_key
94
- elsif cmd.respond_to?(:key)
95
- cmd.key
96
- end
81
+ cmd.params.key?(:pending_key) ? cmd.pending_key : cmd.key
97
82
  end
98
83
 
99
84
  def rule_declared_predicates(action, key)
@@ -0,0 +1,50 @@
1
+ module Textus
2
+ class Gate
3
+ # Raised when a required arg is absent from the bound input. Surface
4
+ # adapters translate this to their native error (MCP ToolError, CLI
5
+ # UsageError); a direct Ruby call lets it surface as-is.
6
+ class MissingArgs < Textus::Error
7
+ attr_reader :spec, :missing
8
+
9
+ def initialize(spec, missing)
10
+ @spec = spec
11
+ @missing = missing
12
+ super("missing_args", "#{spec.verb}: missing #{missing.map(&:wire).join(", ")}")
13
+ end
14
+ end
15
+
16
+ # Validates and resolves a by-name inputs hash against a contract spec.
17
+ # Returns a flat hash with defaults and session_defaults filled in.
18
+ # Every caller receives the same shape — no positional/kwarg split.
19
+ module Binder
20
+ module_function
21
+
22
+ def bind(spec, inputs, session: nil)
23
+ missing = spec.required_args.reject { |a| inputs.key?(a.name) }
24
+ raise MissingArgs.new(spec, missing) unless missing.empty?
25
+
26
+ spec.args.each_with_object({}) do |a, h|
27
+ if inputs.key?(a.name)
28
+ h[a.name] = inputs[a.name]
29
+ elsif a.session_default && session
30
+ h[a.name] = session.public_send(a.session_default)
31
+ elsif !a.default.nil?
32
+ h[a.name] = a.default
33
+ end
34
+ end
35
+ end
36
+
37
+ def inputs_from_ordered(spec, ordered_positionals, by_name_keywords)
38
+ names = spec.args.select(&:positional).map(&:name)
39
+ names.zip(ordered_positionals).to_h.compact.merge(by_name_keywords)
40
+ end
41
+
42
+ def inputs_from_wire(spec, raw)
43
+ raw ||= {}
44
+ spec.args.each_with_object({}) do |a, h|
45
+ h[a.name] = raw[a.wire.to_s] if raw.key?(a.wire.to_s)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
data/lib/textus/gate.rb CHANGED
@@ -2,115 +2,91 @@
2
2
 
3
3
  module Textus
4
4
  class Gate
5
- VERB_COMMAND = {
6
- get: Textus::Command::Get,
7
- put: Textus::Command::Put,
8
- propose: Textus::Command::Propose,
9
- key_delete: Textus::Command::KeyDelete,
10
- key_mv: Textus::Command::KeyMv,
11
- accept: Textus::Command::Accept,
12
- reject: Textus::Command::Reject,
13
- enqueue: Textus::Command::Enqueue,
14
- list: Textus::Command::List,
15
- where: Textus::Command::Where,
16
- uid: Textus::Command::Uid,
17
- blame: Textus::Command::Blame,
18
- audit: Textus::Command::Audit,
19
- deps: Textus::Command::Deps,
20
- rdeps: Textus::Command::Rdeps,
21
- pulse: Textus::Command::Pulse,
22
- rule_explain: Textus::Command::RuleExplain,
23
- rule_list: Textus::Command::RuleList,
24
- rule_lint: Textus::Command::RuleLint,
25
- published: Textus::Command::Published,
26
- schema_show: Textus::Command::SchemaShow,
27
- doctor: Textus::Command::Doctor,
28
- boot: Textus::Command::Boot,
29
- jobs: Textus::Command::Jobs,
30
- data_mv: Textus::Command::DataMv,
31
- key_mv_prefix: Textus::Command::KeyMvPrefix,
32
- key_delete_prefix: Textus::Command::KeyDeletePrefix,
33
- drain: Textus::Command::Drain,
34
- ingest: Textus::Command::Ingest,
35
- }.freeze
36
-
37
- ROUTES = {
38
- Command::Get => [Textus::Action::Get],
39
- Command::Put => [Textus::Action::Put],
40
- Command::Propose => [Textus::Action::Propose],
41
- Command::KeyDelete => [Textus::Action::KeyDelete],
42
- Command::KeyMv => [Textus::Action::KeyMv],
43
- Command::Accept => [Textus::Action::Accept],
44
- Command::Reject => [Textus::Action::Reject],
45
- Command::Enqueue => [Textus::Action::Enqueue],
46
- Command::List => [Textus::Action::List],
47
- Command::Where => [Textus::Action::Where],
48
- Command::Uid => [Textus::Action::Uid],
49
- Command::Blame => [Textus::Action::Blame],
50
- Command::Audit => [Textus::Action::Audit],
51
- Command::Deps => [Textus::Action::Deps],
52
- Command::Rdeps => [Textus::Action::Rdeps],
53
- Command::Pulse => [Textus::Action::Pulse],
54
- Command::RuleExplain => [Textus::Action::RuleExplain],
55
- Command::RuleList => [Textus::Action::RuleList],
56
- Command::RuleLint => [Textus::Action::RuleLint],
57
- Command::Published => [Textus::Action::Published],
58
- Command::SchemaShow => [Textus::Action::SchemaEnvelope],
59
- Command::Doctor => [Textus::Action::Doctor],
60
- Command::Boot => [Textus::Action::Boot],
61
- Command::Jobs => [Textus::Action::Jobs],
62
- Command::DataMv => [Textus::Action::DataMv],
63
- Command::KeyMvPrefix => [Textus::Action::KeyMvPrefix],
64
- Command::KeyDeletePrefix => [Textus::Action::KeyDeletePrefix],
65
- Command::Drain => [Textus::Action::Drain],
66
- Command::Ingest => [Textus::Action::Ingest],
67
- }.freeze
68
-
69
5
  def initialize(container)
70
6
  @container = container
71
7
  end
72
8
 
73
- def dispatch(cmd, correlation_id: nil)
74
- cmd = normalize_propose_key(cmd, @container) if cmd.is_a?(Command::Propose)
75
- action_classes = ROUTES.fetch(cmd.class) do
76
- raise Textus::UsageError.new("unknown command: #{cmd.class}")
9
+ def dispatch(spec:, inputs:, role:, correlation_id: nil, session: nil, surface: nil)
10
+ resolved = Binder.bind(spec, inputs, session: session)
11
+ cmd = Value::Command.new(verb: spec.verb, params: resolved.freeze, role: role)
12
+
13
+ cmd = normalize_propose_key(cmd) if cmd.verb == :propose
14
+ action_class = Textus::Action::VERBS.fetch(cmd.verb) do
15
+ raise Textus::UsageError.new("unknown command verb: #{cmd.verb}")
77
16
  end
78
17
 
79
- Gate::Auth.new(@container).check!(cmd)
18
+ auth = Gate::Auth.new(@container)
19
+ auth.check!(cmd)
20
+ check_dispatch_auth(cmd, resolved, auth)
80
21
  call_obj = build_call(cmd, correlation_id: correlation_id)
81
- results = action_classes.map { |klass| run_action(klass, cmd, @container, call_obj) }
82
- results.length == 1 ? results.first : results
22
+ result = run_action(action_class, resolved, call_obj)
23
+ cascade(cmd, result, call_obj) if CASCADE_VERBS.include?(cmd.verb) && !call_obj.dry_run
24
+ return result unless surface
25
+
26
+ spec.view(surface).call(result, resolved)
83
27
  end
84
28
 
29
+ CASCADE_VERBS = %i[put propose accept reject key_mv key_delete].freeze
30
+
31
+ AUTH_KEYS = {
32
+ key_mv: ->(params) { [params[:old_key], params[:new_key]] },
33
+ ingest: ->(params) { Textus::Action::Ingest.dispatch_key(**params) },
34
+ }.freeze
35
+
85
36
  private
86
37
 
87
- def normalize_propose_key(cmd, container)
38
+ def check_dispatch_auth(cmd, resolved, auth)
39
+ return unless (resolver = AUTH_KEYS[cmd.verb])
40
+
41
+ keys = Array(resolver.call(resolved))
42
+ keys.each { |k| auth.check_action!(action: cmd.verb, actor: cmd.role, key: k) }
43
+ end
44
+
45
+ def normalize_propose_key(cmd)
88
46
  return cmd if cmd.pending_key
89
47
 
90
- zone = container.manifest.policy.propose_lane_for(cmd.role.to_s)
91
- cmd.with(pending_key: zone ? "#{zone}.#{cmd.key}" : nil)
48
+ zone = @container.manifest.policy.propose_lane_for(cmd.role.to_s)
49
+ key = zone ? "#{zone}.#{cmd.key}" : nil
50
+ cmd.with(params: cmd.params.merge(pending_key: key))
92
51
  end
93
52
 
94
- def run_action(klass, cmd, container, call_obj)
95
- action = klass.new(**extract_kwargs(klass, cmd))
96
- action.call(container:, call: call_obj)
53
+ def run_action(klass, params, call_obj)
54
+ result = klass.call(container: @container, call: call_obj, **params)
55
+ Value::Result.unwrap(result)
56
+ end
57
+
58
+ def build_call(cmd, correlation_id: nil)
59
+ dry_run = cmd.params.key?(:dry_run) ? !cmd.params[:dry_run].nil? : false
60
+ Textus::Value::Call.build(role: cmd.role, dry_run:, correlation_id: correlation_id)
97
61
  end
98
62
 
99
- def extract_kwargs(klass, cmd)
100
- params = klass.instance_method(:initialize).parameters
101
- accepts_keyrest = params.any? { |t, _| t == :keyrest }
102
- param_set = params.to_set { |_t, n| n }
103
- cmd.members.each_with_object({}) do |m, h|
104
- next unless accepts_keyrest || param_set.include?(m)
63
+ def cascade(cmd, result, call)
64
+ key = result.is_a?(Hash) ? result["cascade_key"] : nil
65
+ key ||= cascade_key_from_params(cmd)
66
+ return unless key
105
67
 
106
- val = cmd.public_send(m)
107
- h[m] = val unless val.nil?
68
+ rdeps_result = Textus::Action::Rdeps.call(container: @container, call: call, key: key)
69
+ rdeps = Value::Result.unwrap(rdeps_result).fetch("rdeps", [])
70
+ producible = rdeps.select { |dep_key| producible?(dep_key) }
71
+ producible.each do |dep_key|
72
+ Textus::Store::Jobs::Materialize.call(container: @container, call: call, key: dep_key)
108
73
  end
109
74
  end
110
75
 
111
- def build_call(cmd, correlation_id: nil)
112
- dry_run = cmd.respond_to?(:dry_run) ? !cmd.dry_run.nil? : false
113
- Textus::Call.build(role: cmd.role, dry_run:, correlation_id: correlation_id)
76
+ def cascade_key_from_params(cmd)
77
+ case cmd.verb
78
+ when :put, :key_delete then cmd.params[:key]
79
+ when :key_mv then cmd.params[:new_key]
80
+ when :propose, :reject then cmd.params[:pending_key]
81
+ when :accept then nil
82
+ end
83
+ end
84
+
85
+ def producible?(key)
86
+ entry = @container.manifest.resolver.resolve(key).entry
87
+ !entry.publish_tree.nil?
88
+ rescue Textus::Error
89
+ false
114
90
  end
115
91
  end
116
92
  end
data/lib/textus/init.rb CHANGED
@@ -92,9 +92,7 @@ module Textus
92
92
  end
93
93
 
94
94
  def self.setup_state_dirs(target_root)
95
- FileUtils.mkdir_p(Textus::Layout.audit_dir(target_root))
96
- FileUtils.mkdir_p(Textus::Layout.cursors(target_root))
97
- FileUtils.mkdir_p(Textus::Layout.locks(target_root))
95
+ FileUtils.mkdir_p(Textus::Store::Geometry.new(target_root).audit_dir_path)
98
96
  end
99
97
 
100
98
  def self.write_gitignore(target_root)
@@ -153,7 +151,7 @@ module Textus
153
151
  Pathname.new(Textus::Key::Path.resolve(manifest.data, e)).relative_path_from(root).to_s
154
152
  end
155
153
  end
156
- Textus::Layout.gitignore_body(untracked_paths: untracked)
154
+ Textus::Store::Geometry.new(target_root).gitignore_body(untracked_entries: untracked)
157
155
  end
158
156
  end
159
157
  end
data/lib/textus/jobs.rb CHANGED
@@ -1,15 +1,9 @@
1
1
  module Textus
2
2
  module Jobs
3
- @registry = {}
4
-
5
- def self.registry = @registry
6
-
7
- def self.register(klass)
8
- @registry[klass::TYPE] = klass if klass.const_defined?(:TYPE, false)
9
- end
10
-
11
3
  def self.fetch(type)
12
- @registry.fetch(type) { raise Textus::UsageError.new("unknown job type: #{type}") }
4
+ Store::Jobs::Registry.fetch(type)
5
+ rescue Store::Jobs::Registry::UnknownJob
6
+ raise Textus::UsageError.new("unknown job type: #{type}")
13
7
  end
14
8
  end
15
9
  end