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
@@ -2,9 +2,7 @@
2
2
 
3
3
  module Textus
4
4
  module Action
5
- class KeyMv < WriteVerb
6
- extend Textus::Contract::DSL
7
-
5
+ class KeyMv < Base
8
6
  verb :key_mv
9
7
  summary "Rename one entry (same zone + format). Refuses if the target exists. Single-key, lower blast radius than key_mv_prefix."
10
8
  surfaces :cli, :mcp
@@ -18,48 +16,40 @@ module Textus
18
16
  "defaults to false, so omitting it applies the move immediately " \
19
17
  "(unlike the bulk key_mv_prefix, which defaults to a dry-run plan)"
20
18
 
21
- def initialize(old_key:, new_key:, dry_run: false)
22
- super()
23
- @old_key = old_key
24
- @new_key = new_key
25
- @dry_run = dry_run
19
+ def self.call(container:, call:, old_key:, new_key:, dry_run: false)
20
+ execute_move(container: container, call: call, old_key: old_key, new_key: new_key, dry_run: dry_run)
26
21
  end
27
22
 
28
- def call(container:, call:)
29
- run_with_cascade(cascade_target_key, container:, call:) do
30
- execute_move(container, call)
31
- end
32
- end
23
+ def self.execute_move(container:, call:, old_key:, new_key:, dry_run:)
24
+ prepared = prepare(container: container, old_key: old_key, new_key: new_key)
25
+ return prepared if prepared.is_a?(Dry::Monads::Result::Failure)
33
26
 
34
- private
35
-
36
- def cascade_target_key
37
- @dry_run ? nil : @new_key
38
- end
39
-
40
- def execute_move(container, call)
41
- old_res, new_res = prepare(container, call)
42
- return dry_run_result(container, old_res, new_res) if @dry_run
27
+ old_res, new_res = prepared
28
+ if dry_run
29
+ return Success(dry_run_result(container: container, old_key: old_key, new_key: new_key, old_res: old_res,
30
+ new_res: new_res))
31
+ end
43
32
 
44
- envelope = apply_move(container, call, old_res, new_res)
45
- success_result(old_res, new_res, envelope)
33
+ envelope = apply_move(container: container, call: call, old_key: old_key, new_key: new_key, old_res: old_res, new_res: new_res)
34
+ Success(success_result(old_key: old_key, new_key: new_key, old_res: old_res, new_res: new_res, envelope: envelope))
46
35
  end
47
36
 
48
- def apply_move(container, call, old_res, new_res)
49
- ensure_uid!(container, call, old_res.entry)
50
- writer(container, call).move(
51
- from_key: @old_key,
52
- to_key: @new_key,
37
+ def self.apply_move(container:, call:, old_key:, new_key:, old_res:, new_res:)
38
+ ensure_uid!(container: container, call: call, old_key: old_key, old_mentry: old_res.entry)
39
+ container.compositor.move(
40
+ from_key: old_key,
41
+ to_key: new_key,
53
42
  new_mentry: new_res.entry,
43
+ call: call,
54
44
  )
55
45
  end
56
46
 
57
- def success_result(old_res, new_res, envelope)
47
+ def self.success_result(old_key:, new_key:, old_res:, new_res:, envelope:)
58
48
  {
59
- "protocol" => PROTOCOL,
49
+ "protocol" => Textus::PROTOCOL,
60
50
  "ok" => true,
61
- "from_key" => @old_key,
62
- "to_key" => @new_key,
51
+ "from_key" => old_key,
52
+ "to_key" => new_key,
63
53
  "from_path" => old_res.path,
64
54
  "to_path" => new_res.path,
65
55
  "uid" => envelope.uid,
@@ -67,58 +57,61 @@ module Textus
67
57
  }
68
58
  end
69
59
 
70
- def prepare(container, call)
71
- Textus::Manifest::Data.validate_key!(@old_key)
72
- Textus::Manifest::Data.validate_key!(@new_key)
73
- raise UsageError.new("mv: old and new keys are identical") if @old_key == @new_key
60
+ def self.prepare(container:, old_key:, new_key:)
61
+ Textus::Manifest::Data.validate_key!(old_key)
62
+ Textus::Manifest::Data.validate_key!(new_key)
63
+ return Failure(code: :usage_error, message: "mv: old and new keys are identical") if old_key == new_key
74
64
 
75
- old_res = container.manifest.resolver.resolve(@old_key)
76
- new_res = container.manifest.resolver.resolve(@new_key)
77
- raise UnknownKey.new(@old_key) unless reader(container).exists?(@old_key)
65
+ old_res = container.manifest.resolver.resolve(old_key)
66
+ new_res = container.manifest.resolver.resolve(new_key)
67
+ return Failure(code: :not_found, message: "source key '#{old_key}' not found") unless container.compositor.exists?(old_key)
78
68
 
79
- validate_zone_and_format!(old_res.entry, new_res.entry)
80
- auth(container).check_action!(action: :key_mv, actor: call.role, key: @old_key)
81
- auth(container).check_action!(action: :key_mv, actor: call.role, key: @new_key)
82
- raise UsageError.new("mv: target '#{@new_key}' already exists at #{new_res.path}") if reader(container).exists?(@new_key)
69
+ zone_check = validate_zone_and_format(old_mentry: old_res.entry, new_mentry: new_res.entry)
70
+ return zone_check if zone_check.is_a?(Dry::Monads::Result::Failure)
71
+
72
+ if container.compositor.exists?(new_key)
73
+ return Failure(code: :usage_error, message: "mv: target '#{new_key}' already exists at #{new_res.path}")
74
+ end
83
75
 
84
76
  [old_res, new_res]
85
77
  end
86
78
 
87
- def validate_zone_and_format!(old_mentry, new_mentry)
79
+ def self.validate_zone_and_format(old_mentry:, new_mentry:)
88
80
  if old_mentry.lane != new_mentry.lane
89
- raise UsageError.new(
90
- "mv: cross-zone move refused (#{old_mentry.lane} -> #{new_mentry.lane}). " \
91
- "Use put+delete for cross-zone moves.",
92
- )
81
+ return Failure(code: :usage_error,
82
+ message: "mv: cross-zone move refused (#{old_mentry.lane} -> #{new_mentry.lane}). " \
83
+ "Use put+delete for cross-zone moves.")
93
84
  end
94
- return if old_mentry.format == new_mentry.format
85
+ return unless old_mentry.format != new_mentry.format
95
86
 
96
- raise UsageError.new("mv: format mismatch (#{old_mentry.format} -> #{new_mentry.format}); refusing.")
87
+ Failure(code: :usage_error,
88
+ message: "mv: format mismatch (#{old_mentry.format} -> #{new_mentry.format}); refusing.")
97
89
  end
98
90
 
99
- def ensure_uid!(container, call, old_mentry)
100
- pre_env = reader(container).read(@old_key)
91
+ def self.ensure_uid!(container:, call:, old_key:, old_mentry:)
92
+ pre_env = container.compositor.read(old_key)
101
93
  return if pre_env.uid
102
94
 
103
- writer(container, call).put(
104
- @old_key,
95
+ container.compositor.write(
96
+ old_key,
105
97
  mentry: old_mentry,
106
- payload: Textus::Envelope::Writer::Payload.new(
98
+ payload: Textus::Store::Envelope::Writer::Payload.new(
107
99
  meta: pre_env.meta,
108
100
  body: pre_env.body,
109
101
  content: pre_env.content,
110
102
  ),
103
+ call: call,
111
104
  )
112
105
  end
113
106
 
114
- def dry_run_result(container, old_res, new_res)
115
- pre_env = reader(container).read(@old_key)
107
+ def self.dry_run_result(container:, old_key:, new_key:, old_res:, new_res:)
108
+ pre_env = container.compositor.read(old_key)
116
109
  {
117
- "protocol" => PROTOCOL,
110
+ "protocol" => Textus::PROTOCOL,
118
111
  "ok" => true,
119
112
  "dry_run" => true,
120
- "from_key" => @old_key,
121
- "to_key" => @new_key,
113
+ "from_key" => old_key,
114
+ "to_key" => new_key,
122
115
  "from_path" => old_res.path,
123
116
  "to_path" => new_res.path,
124
117
  "uid" => pre_env.uid,
@@ -3,8 +3,6 @@
3
3
  module Textus
4
4
  module Action
5
5
  class KeyMvPrefix < Base
6
- extend Textus::Contract::DSL
7
-
8
6
  verb :key_mv_prefix
9
7
  summary "Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false."
10
8
  surfaces :cli, :mcp
@@ -18,39 +16,32 @@ module Textus
18
16
  "to false, so omitting it applies the rename immediately"
19
17
  view { |v, _i| v.to_h }
20
18
 
21
- def initialize(from_prefix:, to_prefix:, dry_run: false)
22
- super()
23
- @from_prefix = from_prefix
24
- @to_prefix = to_prefix
25
- @dry_run = dry_run
26
- end
27
-
28
- def call(container:, call:)
29
- raise UsageError.new("from_prefix and to_prefix required") if @from_prefix.nil? || @to_prefix.nil?
19
+ def self.call(container:, call:, from_prefix:, to_prefix:, dry_run: false)
20
+ return Failure(code: :usage_error, message: "from_prefix and to_prefix required") if from_prefix.nil? || to_prefix.nil?
30
21
 
31
- leaves = Textus::Action::List.new(prefix: @from_prefix).call(container: container)
32
- .map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
22
+ leaves = Textus::Action::List.leaf_keys(container: container, prefix: from_prefix)
33
23
 
34
- if leaves.include?(@from_prefix)
35
- raise UsageError.new("from_prefix '#{@from_prefix}' is itself a leaf — use `mv` to rename a single key")
24
+ if leaves.include?(from_prefix)
25
+ return Failure(code: :usage_error,
26
+ message: "from_prefix '#{from_prefix}' is itself a leaf — use `mv` to rename a single key")
36
27
  end
37
28
 
38
29
  warnings = []
39
- warnings << "no keys under #{@from_prefix}" if leaves.empty?
30
+ warnings << "no keys under #{from_prefix}" if leaves.empty?
40
31
 
41
32
  steps = leaves.map do |old_key|
42
- tail = old_key.delete_prefix("#{@from_prefix}.")
43
- new_key = "#{@to_prefix}.#{tail}"
33
+ tail = old_key.delete_prefix("#{from_prefix}.")
34
+ new_key = "#{to_prefix}.#{tail}"
44
35
  { "op" => "mv", "from" => old_key, "to" => new_key }
45
36
  end
46
37
 
47
- plan = Textus::Jobs::Plan.new(steps: steps, warnings: warnings)
48
- return plan if @dry_run
38
+ plan = Textus::Store::Jobs::Plan.new(steps: steps, warnings: warnings)
39
+ return Success(plan) if dry_run
49
40
 
50
41
  steps.each do |step|
51
- Textus::Action::KeyMv.new(old_key: step["from"], new_key: step["to"]).call(container: container, call: call)
42
+ Value::Result.unwrap(Textus::Action::KeyMv.call(container: container, call: call, old_key: step["from"], new_key: step["to"]))
52
43
  end
53
- plan
44
+ Success(plan)
54
45
  end
55
46
  end
56
47
  end
@@ -3,8 +3,6 @@
3
3
  module Textus
4
4
  module Action
5
5
  class List < Base
6
- extend Textus::Contract::DSL
7
-
8
6
  verb :list
9
7
  summary "List keys filtered by lane and/or prefix."
10
8
  surfaces :cli, :mcp
@@ -14,28 +12,16 @@ module Textus
14
12
  description: "restrict to one lane by name (see `boot` lanes); combine with prefix to narrow further"
15
13
  view(:cli) { |rows| { "entries" => rows } }
16
14
 
17
- def initialize(prefix: nil, lane: nil)
18
- super()
19
- @prefix = prefix
20
- @lane = lane
21
- end
22
-
23
- def call(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
15
+ def self.call(container:, call: nil, prefix: nil, lane: nil) # rubocop:disable Lint/UnusedMethodArgument
24
16
  manifest = container.manifest
25
- rows = manifest.resolver.enumerate(prefix: @prefix)
26
- rows = rows.select { |row| row[:manifest_entry].lane == @lane } if @lane
27
- rows.map { |row| { "key" => row[:key], "lane" => row[:manifest_entry].lane, "path" => row[:path] } }
17
+ rows = manifest.resolver.enumerate(prefix: prefix)
18
+ rows = rows.select { |row| row[:manifest_entry].lane == lane } if lane
19
+ Success(rows.map { |row| { "key" => row[:key], "lane" => row[:manifest_entry].lane, "path" => row[:path] } })
28
20
  end
29
21
 
30
- def self.new(*args, **kwargs)
31
- return super(**kwargs) unless args.any?
32
-
33
- call_spec = instance_method(:initialize).parameters
34
- required = call_spec.slice(:keyreq).map(&:last)
35
- optional = call_spec.slice(:key).map(&:last)
36
- positional = required + optional
37
- mapped = positional.zip(args).to_h
38
- super(**mapped.merge(kwargs))
22
+ def self.leaf_keys(container:, prefix: nil, lane: nil)
23
+ rows = Value::Result.unwrap(call(container: container, prefix: prefix, lane: lane))
24
+ rows.map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
39
25
  end
40
26
  end
41
27
  end
@@ -2,9 +2,7 @@
2
2
 
3
3
  module Textus
4
4
  module Action
5
- class Propose < WriteVerb
6
- extend Textus::Contract::DSL
7
-
5
+ class Propose < Base
8
6
  verb :propose
9
7
  summary "Write a proposal to the role's propose_lane. Auto-prefixes the key."
10
8
  surfaces :cli, :mcp
@@ -19,33 +17,25 @@ module Textus
19
17
  description: "structured payload for json/yaml-format entries; omit (use `body`) for markdown entries. Do not send both"
20
18
  view { |env, _i| env.to_h_for_wire }
21
19
 
22
- def initialize(key:, meta: nil, body: nil, content: nil)
23
- super()
24
- @key = key
25
- @meta = meta
26
- @body = body
27
- @content = content
28
- end
29
-
30
- def call(container:, call:)
20
+ def self.call(container:, call:, key:, meta: nil, body: nil, content: nil)
31
21
  zone = container.manifest.policy.propose_lane_for(call.role)
32
22
  unless zone
33
- raise Textus::Error.new(
34
- "propose_forbidden",
35
- "role '#{call.role}' has no writable propose_lane",
36
- details: { "role" => call.role },
37
- hint: "the manifest must define a queue zone and '#{call.role}' must hold the 'propose' capability",
38
- )
23
+ return Failure(code: :propose_forbidden,
24
+ message: "role '#{call.role}' has no writable propose_lane",
25
+ details: { "role" => call.role })
39
26
  end
40
27
 
41
- run_with_cascade("#{zone}.#{@key}", container:, call:) do
42
- Textus::Action::Put.new(
43
- key: "#{zone}.#{@key}",
44
- meta: @meta || {},
45
- body: @body,
46
- content: @content,
47
- ).call(container: container, call: call)
48
- end
28
+ mentry = container.manifest.resolver.resolve("#{zone}.#{key}").entry
29
+ Success(container.compositor.write(
30
+ "#{zone}.#{key}",
31
+ mentry: mentry,
32
+ payload: Textus::Store::Envelope::Writer::Payload.new(
33
+ meta: meta || {},
34
+ body: body,
35
+ content: content,
36
+ ),
37
+ call: call,
38
+ ))
49
39
  end
50
40
  end
51
41
  end
@@ -3,8 +3,6 @@
3
3
  module Textus
4
4
  module Action
5
5
  class Published < Base
6
- extend Textus::Contract::DSL
7
-
8
6
  verb :published
9
7
  summary "List all entries that declare a publish_to target."
10
8
  surfaces :cli
@@ -14,10 +12,10 @@ module Textus
14
12
  {}
15
13
  end
16
14
 
17
- def call(container:, **)
18
- container.manifest.data.entries.reject { |entry| entry.publish_to.empty? }.map do |entry|
15
+ def self.call(container:, **)
16
+ Success(container.manifest.data.entries.reject { |entry| entry.publish_to.empty? }.map do |entry|
19
17
  { "key" => entry.key, "publish_to" => entry.publish_to }
20
- end
18
+ end)
21
19
  end
22
20
  end
23
21
  end
@@ -5,47 +5,40 @@ require "time"
5
5
  module Textus
6
6
  module Action
7
7
  class Pulse < Base
8
- extend Textus::Contract::DSL
9
-
10
8
  verb :pulse
11
9
  summary "Delta since cursor — changed entries, pending proposals, index freshness."
12
10
  surfaces :cli, :mcp
13
- around :cursor
14
11
  arg :since, Integer, session_default: :cursor,
15
12
  description: "audit seq to diff from; defaults to the session cursor"
16
13
 
17
- def initialize(since: nil)
18
- super()
19
- @since = since
20
- end
14
+ def self.call(container:, call:, since: nil, **)
15
+ manifest = container.manifest
16
+ audit_log = container.audit_log
17
+ root = container.root
18
+ since ||= Textus::Store::Cursor.new(root: root, role: call.role).read
19
+
20
+ changed = Value::Result.unwrap(Textus::Action::Audit.call(container: container, seq_since: since))
21
21
 
22
- def call(container:, call:)
23
- @container = container
24
- @call = call
25
- @manifest = container.manifest
26
- @audit_log = container.audit_log
27
- @root = container.root
28
-
29
- {
30
- "cursor" => @audit_log.latest_seq,
31
- "changed" => Textus::Action::Audit.new(seq_since: @since).call(container: container),
32
- "pending_review" => review_keys,
33
- "contract_etag" => Textus::Etag.for_contract(@root),
22
+ result = {
23
+ "cursor" => audit_log.latest_seq,
24
+ "changed" => changed,
25
+ "pending_review" => review_keys(manifest, container),
26
+ "contract_etag" => Textus::Value::Etag.for_contract(root),
34
27
  "index_etag" => index_etag(container),
35
28
  }
36
- end
37
29
 
38
- private
30
+ Textus::Store::Cursor.new(root: root, role: call.role).write(result["cursor"])
31
+ Success(result)
32
+ end
39
33
 
40
- def review_keys
41
- queue = @manifest.policy.queue_lane
34
+ def self.review_keys(manifest, container)
35
+ queue = manifest.policy.queue_lane
42
36
  return [] unless queue
43
37
 
44
- rows = Textus::Action::List.new(lane: queue).call(container: @container)
45
- rows.map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
38
+ Textus::Action::List.leaf_keys(container: container, lane: queue)
46
39
  end
47
40
 
48
- def index_etag(container)
41
+ def self.index_etag(container)
49
42
  path = container.manifest.resolver.resolve("artifacts.system.index").path
50
43
  File.exist?(path) ? container.file_store.etag(path) : nil
51
44
  rescue Textus::Error
@@ -2,9 +2,7 @@
2
2
 
3
3
  module Textus
4
4
  module Action
5
- class Put < WriteVerb
6
- extend Textus::Contract::DSL
7
-
5
+ class Put < Base
8
6
  verb :put
9
7
  summary "Create or update an entry. Schema-validated. Returns {uid, etag}."
10
8
  surfaces :cli, :mcp
@@ -20,32 +18,20 @@ module Textus
20
18
  description: "optimistic-concurrency guard: the etag you last read; the write is rejected if the entry changed since"
21
19
  view { |env| { "uid" => env.uid, "etag" => env.etag } }
22
20
 
23
- def initialize(key:, meta: nil, body: nil, content: nil, if_etag: nil)
24
- super()
25
- @key = key
26
- @meta = meta
27
- @body = body
28
- @content = content
29
- @if_etag = if_etag
30
- end
31
-
32
- def call(container:, call:)
33
- run_with_cascade(@key, container:, call:) do
34
- Textus::Manifest::Data.validate_key!(@key)
35
- mentry = container.manifest.resolver.resolve(@key).entry
36
- envelope = writer(container, call).put(
37
- @key,
38
- mentry: mentry,
39
- payload: Textus::Envelope::Writer::Payload.new(
40
- meta: @meta,
41
- body: @body,
42
- content: @content,
43
- ),
44
- if_etag: @if_etag,
45
- )
46
-
47
- envelope
48
- end
21
+ def self.call(container:, call:, key:, meta: nil, body: nil, content: nil, if_etag: nil) # rubocop:disable Metrics/ParameterLists
22
+ Textus::Manifest::Data.validate_key!(key)
23
+ mentry = container.manifest.resolver.resolve(key).entry
24
+ Success(container.compositor.write(
25
+ key,
26
+ mentry: mentry,
27
+ payload: Textus::Store::Envelope::Writer::Payload.new(
28
+ meta: meta,
29
+ body: body,
30
+ content: content,
31
+ ),
32
+ if_etag: if_etag,
33
+ call: call,
34
+ ))
49
35
  end
50
36
  end
51
37
  end
@@ -3,36 +3,21 @@
3
3
  module Textus
4
4
  module Action
5
5
  class Rdeps < Base
6
- extend Textus::Contract::DSL
7
-
8
6
  verb :rdeps
9
7
  summary "List the derived entries that depend on a key (reverse deps / impact set)."
10
8
  surfaces :cli, :mcp
11
9
  arg :key, String, required: true, positional: true,
12
10
  description: "dotted key whose dependents (what would be stranded if it moved) you want"
13
11
 
14
- def initialize(key:)
15
- super()
16
- @key = key
17
- end
18
-
19
- def call(container:, **)
12
+ def self.call(container:, key:, **)
20
13
  manifest = container.manifest
21
14
  rdeps = manifest.data.entries.each_with_object([]) do |entry, acc|
22
15
  next unless entry.external?
23
16
 
24
17
  sources = Array(entry.source&.sources).compact
25
- acc << entry.key if sources.any? { |source| source == @key || @key.start_with?("#{source}.") }
18
+ acc << entry.key if sources.any? { |source| source == key || key.start_with?("#{source}.") }
26
19
  end
27
- { "key" => @key, "rdeps" => rdeps }
28
- end
29
-
30
- def self.new(*args, **kwargs)
31
- return super(**kwargs) unless args.any?
32
-
33
- positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
34
- mapped = positional.zip(args).to_h
35
- super(**mapped.merge(kwargs))
20
+ Success({ "key" => key, "rdeps" => rdeps })
36
21
  end
37
22
  end
38
23
  end
@@ -2,35 +2,26 @@
2
2
 
3
3
  module Textus
4
4
  module Action
5
- class Reject < WriteVerb
6
- extend Textus::Contract::DSL
7
-
5
+ class Reject < Base
8
6
  verb :reject
9
7
  summary "discard a queued proposal without applying it"
10
8
  surfaces :cli, :mcp
11
9
  cli "reject"
12
10
  arg :pending_key, String, required: true, positional: true, description: "the queued proposal's key"
13
11
 
14
- def initialize(pending_key:)
15
- super()
16
- @pending_key = pending_key
17
- end
18
-
19
- def call(container:, call:)
20
- run_with_cascade(@pending_key, container:, call:) do
21
- mentry = container.manifest.resolver.resolve(@pending_key).entry
22
- unless mentry.in_proposal_lane?(container.manifest.policy)
23
- raise ProposalError.new("reject: '#{@pending_key}' is not in a proposal zone (zone=#{mentry.lane})")
24
- end
25
-
26
- env = Textus::Action::Get.new(key: @pending_key).call(container: container, call: call)
27
- proposal = env.meta&.dig("proposal") or raise ProposalError.new("entry has no proposal block: #{@pending_key}")
28
- target_key = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
12
+ def self.call(container:, call:, pending_key:)
13
+ mentry = container.manifest.resolver.resolve(pending_key).entry
14
+ unless mentry.in_proposal_lane?(container.manifest.policy)
15
+ return Failure(code: :proposal_error, message: "reject: '#{pending_key}' is not in a proposal zone (zone=#{mentry.lane})")
16
+ end
29
17
 
30
- writer(container, call).delete(@pending_key, mentry: mentry)
18
+ env = container.compositor.read(pending_key)
19
+ parsed = proposal_from(env, key: pending_key)
20
+ return parsed if parsed.is_a?(Dry::Monads::Result::Failure)
31
21
 
32
- { "protocol" => PROTOCOL, "rejected" => @pending_key, "target_key" => target_key }
33
- end
22
+ target_key = parsed[:target_key]
23
+ container.compositor.delete(pending_key, mentry: mentry, call: call)
24
+ Success("protocol" => Textus::PROTOCOL, "rejected" => pending_key, "target_key" => target_key)
34
25
  end
35
26
  end
36
27
  end