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
@@ -0,0 +1,154 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+ require "securerandom"
6
+ require "time"
7
+
8
+ module Textus
9
+ class Store
10
+ module Jobs
11
+ class Queue
12
+ VALID_STATES = %w[ready leased done failed].freeze
13
+
14
+ Leased = Data.define(:job)
15
+
16
+ class Job
17
+ DIGEST_BYTES = 16
18
+
19
+ attr_reader :type, :args, :role, :attempts, :max_attempts, :errors
20
+
21
+ def initialize(type:, args:, role:, attempts: 0, max_attempts: 3, errors: [])
22
+ @type = type.to_s
23
+ @args = stringify(args)
24
+ @role = role.to_s
25
+ @attempts = attempts.to_i
26
+ @max_attempts = max_attempts.to_i
27
+ @errors = Array(errors)
28
+ end
29
+
30
+ def id
31
+ "#{type}:#{Digest::SHA256.hexdigest(JSON.dump(args.sort.to_h))[0, DIGEST_BYTES]}"
32
+ end
33
+
34
+ private
35
+
36
+ def stringify(hash)
37
+ hash.to_h.transform_keys(&:to_s)
38
+ end
39
+ end
40
+
41
+ def initialize(store:)
42
+ @store = store
43
+ end
44
+
45
+ def enqueue(job)
46
+ now = iso_now
47
+ @store.execute(
48
+ "INSERT OR IGNORE INTO jobs (id, type, args, state, role, attempts, max_attempts, errors, lease, created_at, updated_at)
49
+ VALUES (?, ?, ?, 'ready', ?, ?, ?, ?, NULL, ?, ?)",
50
+ [job.id, job.type, JSON.dump(job.args), job.role, job.attempts, job.max_attempts, JSON.dump(job.errors), now, now],
51
+ )
52
+ end
53
+
54
+ def ready_ids
55
+ list(:ready)
56
+ end
57
+
58
+ def lease(worker_id:, lease_ttl:)
59
+ now = Time.now.utc
60
+ expires_at = now + lease_ttl
61
+ token = SecureRandom.hex(8)
62
+ marked_lease = JSON.dump({ "worker_id" => worker_id, "expires_at" => expires_at.iso8601, "token" => token })
63
+
64
+ @store.execute(
65
+ "UPDATE jobs
66
+ SET state = 'leased', lease = ?, updated_at = ?
67
+ WHERE id = (
68
+ SELECT id FROM jobs WHERE state = 'ready' ORDER BY created_at, id LIMIT 1
69
+ )",
70
+ [marked_lease, now.iso8601],
71
+ )
72
+ row = @store.execute("SELECT * FROM jobs WHERE state = 'leased' AND lease = ? LIMIT 1", [marked_lease]).first
73
+ return nil unless row
74
+
75
+ Leased.new(job_from_row(row))
76
+ end
77
+
78
+ def ack(leased)
79
+ @store.execute(
80
+ "UPDATE jobs SET state = 'done', lease = NULL, updated_at = ? WHERE id = ? AND state = 'leased'",
81
+ [iso_now, leased.job.id],
82
+ )
83
+ end
84
+
85
+ def fail(leased, error:)
86
+ job = leased.job
87
+ attempts = job.attempts + 1
88
+ errors = job.errors + [{ "attempt" => attempts, "error" => error, "at" => iso_now }]
89
+ dead = attempts >= job.max_attempts
90
+ state = dead ? "failed" : "ready"
91
+ @store.execute(
92
+ "UPDATE jobs SET state = ?, attempts = ?, errors = ?, lease = NULL, updated_at = ? WHERE id = ?",
93
+ [state, attempts, JSON.dump(errors), iso_now, job.id],
94
+ )
95
+ dead ? :dead_lettered : :requeued
96
+ end
97
+
98
+ def reclaim(now:)
99
+ rows = @store.execute("SELECT id, lease FROM jobs WHERE state = 'leased'")
100
+ expired = rows.select do |row|
101
+ lease = JSON.parse(row["lease"] || "{}")
102
+ expires_at = lease["expires_at"]
103
+ expires_at.nil? || Time.parse(expires_at) <= now
104
+ end
105
+ expired.each do |row|
106
+ @store.execute(
107
+ "UPDATE jobs SET state = 'ready', lease = NULL, updated_at = ? WHERE id = ?",
108
+ [now.utc.iso8601, row["id"]],
109
+ )
110
+ end
111
+ expired.size
112
+ end
113
+
114
+ def list(state)
115
+ state = state.to_s
116
+ raise Textus::UsageError.new("unknown job state: #{state}") unless VALID_STATES.include?(state)
117
+
118
+ @store.execute("SELECT id FROM jobs WHERE state = ? ORDER BY created_at, id", [state]).map { |row| row["id"] }
119
+ end
120
+
121
+ def retry_failed(job_id)
122
+ @store.execute(
123
+ "UPDATE jobs SET state = 'ready', attempts = 0, errors = ?, lease = NULL, updated_at = ? WHERE id = ? AND state = 'failed'",
124
+ [JSON.dump([]), iso_now, job_id],
125
+ )
126
+ end
127
+
128
+ def purge(state)
129
+ state = state.to_s
130
+ raise Textus::UsageError.new("unknown job state: #{state}") unless VALID_STATES.include?(state)
131
+
132
+ @store.execute("DELETE FROM jobs WHERE state = ?", [state])
133
+ end
134
+
135
+ private
136
+
137
+ def job_from_row(row)
138
+ Job.new(
139
+ type: row["type"],
140
+ args: JSON.parse(row["args"] || "{}"),
141
+ role: row["role"],
142
+ attempts: row["attempts"],
143
+ max_attempts: row["max_attempts"],
144
+ errors: JSON.parse(row["errors"] || "[]"),
145
+ )
146
+ end
147
+
148
+ def iso_now
149
+ Time.now.utc.iso8601
150
+ end
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ class Store
3
+ module Jobs
4
+ module Registry
5
+ class UnknownJob < KeyError; end
6
+
7
+ JOBS = {
8
+ "index" => Store::Jobs::Index,
9
+ "materialize" => Store::Jobs::Materialize,
10
+ "sweep" => Store::Jobs::Sweep,
11
+ }.freeze
12
+
13
+ def self.fetch(type)
14
+ JOBS.fetch(type.to_s) { raise UnknownJob.new("Unknown job type: #{type}") }
15
+ end
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,50 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ class Store
5
+ module Jobs
6
+ class Retention
7
+ def initialize(container:, call:)
8
+ @container = container
9
+ @call = call
10
+ end
11
+
12
+ def call(rows)
13
+ out = { dropped: [], archived: [], failed: [] }
14
+ rows.each do |row|
15
+ key = row["key"]
16
+ begin
17
+ case row["action"]
18
+ when "drop"
19
+ delete(key)
20
+ out[:dropped] << key
21
+ when "archive"
22
+ archive_leaf(row)
23
+ delete(key)
24
+ out[:archived] << key
25
+ end
26
+ rescue Textus::Error => e
27
+ out[:failed] << { "key" => key, "error" => e.message }
28
+ end
29
+ end
30
+ out
31
+ end
32
+
33
+ private
34
+
35
+ def archive_leaf(row)
36
+ src = row["path"]
37
+ root = @container.root.to_s
38
+ rel = src.delete_prefix("#{root}/")
39
+ dest = File.join(root, "archive", rel)
40
+ FileUtils.mkdir_p(File.dirname(dest))
41
+ FileUtils.cp(src, dest)
42
+ end
43
+
44
+ def delete(key)
45
+ Textus::Action::KeyDelete.call(container: @container, call: @call, key: key)
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ class Store
3
+ module Jobs
4
+ class Sweep < Base
5
+ REQUIRED_ROLE = Textus::Value::Role::AUTOMATION
6
+ TYPE = "sweep"
7
+
8
+ def self.call(container:, call:, scope: {}, key: nil)
9
+ prefix = key || (scope.is_a?(Hash) ? scope["prefix"] : nil)
10
+ lane = scope.is_a?(Hash) ? scope["lane"] : nil
11
+ rows = Textus::Core::Retention::Sweep.new(
12
+ manifest: container.manifest,
13
+ file_stat: Textus::Port::Storage::FileStat.new,
14
+ clock: Textus::Port::Clock.new,
15
+ ).call(prefix: prefix, lane: lane)
16
+ Textus::Store::Jobs::Retention.new(container: container, call: call).call(rows)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,64 @@
1
+ module Textus
2
+ class Store
3
+ module Jobs
4
+ class Worker
5
+ Summary = Struct.new(:completed, :failed, keyword_init: true)
6
+
7
+ def self.for(container:, queue:)
8
+ new(queue: queue, container: container,
9
+ lease_ttl: container.manifest.data.worker_config[:lease_ttl])
10
+ end
11
+
12
+ def initialize(queue:, container:, lease_ttl: 60)
13
+ @queue = queue
14
+ @container = container
15
+ @lease_ttl = lease_ttl
16
+ end
17
+
18
+ def drain(worker_id: "drain-#{Process.pid}")
19
+ completed = 0
20
+ failed = 0
21
+ loop do
22
+ leased = @queue.lease(worker_id: worker_id, lease_ttl: @lease_ttl)
23
+ break unless leased
24
+
25
+ case run_one(leased)
26
+ when :completed then completed += 1
27
+ when :dead_lettered then failed += 1
28
+ end
29
+ end
30
+ Summary.new(completed: completed, failed: failed)
31
+ end
32
+
33
+ def drain_pool(pool: 4)
34
+ summaries = []
35
+ mutex = Mutex.new
36
+ threads = Array.new(pool) do |i|
37
+ Thread.new do
38
+ s = drain(worker_id: "pool-#{Process.pid}-#{i}")
39
+ mutex.synchronize { summaries << s }
40
+ end
41
+ end
42
+ threads.each(&:join)
43
+ Summary.new(completed: summaries.sum(&:completed), failed: summaries.sum(&:failed))
44
+ end
45
+
46
+ private
47
+
48
+ def run_one(leased)
49
+ job = leased.job
50
+ klass = Textus::Jobs.fetch(job.type)
51
+ call = Textus::Value::Call.build(
52
+ role: job.role || Textus::Value::Role::AUTOMATION,
53
+ correlation_id: SecureRandom.uuid,
54
+ )
55
+ klass.call(container: @container, call: call, **job.args.transform_keys(&:to_sym))
56
+ @queue.ack(leased)
57
+ :completed
58
+ rescue StandardError => e
59
+ @queue.fail(leased, error: e.message)
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry-struct"
4
+
5
+ module Textus
6
+ # The agent session: per-connection (MCP), per-process (CLI), or per-loop
7
+ # (Ruby) orientation state — the audit cursor plus the contract etag and
8
+ # propose_lane captured at boot. Immutable Dry::Struct::Value; advance_cursor
9
+ # and with return new instances. ADR 0036; contract_etag widened in ADR 0074.
10
+ class Store
11
+ class Session < Dry::Struct
12
+ attribute :role, Value::Types::RoleName
13
+ attribute :cursor, Value::Types::Cursor
14
+ attribute :propose_lane, Value::Types::String.optional
15
+ attribute :contract_etag, Value::Types::String
16
+
17
+ def with(**attrs) = self.class.new(to_h.merge(attrs))
18
+
19
+ def advance_cursor(new_cursor) = with(cursor: new_cursor)
20
+
21
+ def check_etag!(observed_etag)
22
+ return if observed_etag == contract_etag
23
+
24
+ raise Textus::ContractDrift.new(
25
+ "contract changed (manifest/hooks/schemas were #{short_etag(contract_etag)}, " \
26
+ "now #{short_etag(observed_etag)}); re-run boot",
27
+ )
28
+ end
29
+
30
+ private
31
+
32
+ # First 8 hex chars after the "sha256:" prefix — a stable short id for
33
+ # the drift diagnostic.
34
+ def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
35
+ end
36
+ end
37
+ end
data/lib/textus/store.rb CHANGED
@@ -7,7 +7,7 @@ module Textus
7
7
  # Readers are derived from the Container's schema, so the field set lives
8
8
  # in exactly one place (Container). A new capability added there is
9
9
  # automatically exposed on the Store.
10
- Textus::Container.attribute_names.each do |field|
10
+ Textus::Store::Container.attribute_names.each do |field|
11
11
  define_method(field) { @container.public_send(field) }
12
12
  end
13
13
 
@@ -49,11 +49,11 @@ module Textus
49
49
  # Build an agent Session oriented at the current cursor/manifest — the
50
50
  # Ruby equivalent of an MCP `initialize`. ADR 0036.
51
51
  def session(role:)
52
- Textus::Session.new(
52
+ Textus::Store::Session.new(
53
53
  role: role.to_s,
54
54
  cursor: audit_log.latest_seq,
55
55
  propose_lane: manifest.policy.propose_lane_for(role),
56
- contract_etag: Textus::Etag.for_contract(root),
56
+ contract_etag: Textus::Value::Etag.for_contract(root),
57
57
  )
58
58
  end
59
59
 
@@ -62,30 +62,38 @@ module Textus
62
62
  end
63
63
 
64
64
  def as(role, dry_run: false, correlation_id: nil)
65
- Textus::Surfaces::RoleScope.new(container: container, role: role, dry_run: dry_run, correlation_id: correlation_id)
65
+ Textus::Surface::RoleScope.new(container: container, role: role, dry_run: dry_run, correlation_id: correlation_id)
66
66
  end
67
67
 
68
68
  private
69
69
 
70
70
  def build_container(root)
71
71
  manifest = Manifest.load(root)
72
- container = Container.new(
73
- root: root,
74
- manifest: manifest,
75
- schemas: Schemas.new(File.join(root, "schemas")),
76
- file_store: Ports::Storage::FileStore.new,
77
- audit_log: Ports::AuditLog.new(
72
+ job_store = Port::Store.new(root: root).setup!
73
+ geometry = Store::Geometry.new(root)
74
+ infra = Container::Infrastructure.new(
75
+ file_store: Port::Storage::FileStore.new,
76
+ schemas: Schemas.new(geometry.schemas_dir),
77
+ audit_log: Port::AuditLog.new(
78
78
  root,
79
79
  max_size: manifest.data.audit_config[:max_size],
80
80
  keep: manifest.data.audit_config[:keep],
81
81
  ),
82
+ job_store:,
83
+ geometry:,
84
+ )
85
+
86
+ coord = Container::Coordination.new(
87
+ manifest:,
82
88
  workflows: Workflow::Loader.load_all(root),
83
89
  gate: nil,
90
+ compositor: nil,
84
91
  )
92
+
93
+ container = Container.new(infra, coord)
94
+ compositor = Store::Compositor.new(container)
85
95
  gate = Textus::Gate.new(container)
86
- container = container.with(gate: gate)
87
- gate.instance_variable_set(:@container, container)
88
- container
96
+ container.wire_gate!(gate, compositor)
89
97
  end
90
98
  end
91
99
  end
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Group
5
5
  class Data < Group
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Group
5
5
  class Key < Group
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Group
5
5
  class MCP < Group
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Group
5
5
  class Rule < Group
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Group
5
5
  class Schema < Group
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Group < Verb
5
5
  class << self
@@ -7,7 +7,7 @@ module Textus
7
7
  # `parent_group` is this group counts as a subcommand. Sorted
8
8
  # alphabetically by command_name for stable help output.
9
9
  def subcommands
10
- Textus::Surfaces::CLI::Runner.install!
10
+ Textus::Surface::CLI::Runner.install!
11
11
  Verb.descendants
12
12
  .select { |k| k.parent_group == self && k.command_name }
13
13
  .sort_by(&:command_name)
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  # Generates CLI::Verb (and CLI::Group) subclasses from per-verb contracts,
5
5
  # so the CLI surface is a projection of the contract — the operator-facing
@@ -49,63 +49,21 @@ module Textus
49
49
 
50
50
  module_function
51
51
 
52
- # Build a Command from the spec + parsed inputs, dispatch through Gate.
53
52
  def dispatch(verb_instance, store, spec)
54
- inputs = Textus::Contract::Binder.inputs_from_ordered(
53
+ inputs = Textus::Gate::Binder.inputs_from_ordered(
55
54
  spec, verb_instance.positional, verb_instance.flag_values(spec)
56
55
  )
57
- inputs = inputs.merge(Textus::Contract::Sources.from_stdin(spec, verb_instance.stdin)) if spec.cli_stdin
58
- inputs = Textus::Contract::Sources.acquire(spec, inputs)
56
+ inputs = inputs.merge(Surface::CLI::Sources.from_stdin(spec, verb_instance.stdin)) if spec.cli_stdin
57
+ inputs = Surface::CLI::Sources.acquire(spec, inputs)
59
58
  inputs = apply_cli_defaults(spec, inputs)
60
59
  role = verb_instance.resolved_role(store)
61
60
 
62
- invoke = lambda do |effective_inputs|
63
- cmd = build_command(spec, effective_inputs, role)
64
- store.gate.dispatch(cmd)
65
- end
66
-
67
- result = if spec.around
68
- scope = store.as(role)
69
- Textus::Contract::Around.with(spec.around, scope: scope, inputs: inputs, session: nil, &invoke)
70
- else
71
- invoke.call(inputs)
72
- end
73
- verb_instance.emit(shape(spec, result, inputs))
74
- rescue Textus::Contract::MissingArgs => e
61
+ result = store.gate.dispatch(spec:, inputs:, role:, surface: :cli)
62
+ verb_instance.emit(result)
63
+ rescue Textus::Gate::MissingArgs => e
75
64
  raise UsageError.new("#{spec.cli_path} requires #{e.missing.first.wire}")
76
65
  end
77
66
 
78
- def build_command(spec, inputs, role)
79
- cmd_class = Textus::Gate::VERB_COMMAND.fetch(spec.verb) do
80
- raise Textus::UsageError.new("no Command for verb: #{spec.verb}")
81
- end
82
- defaults = {}
83
- spec.args.each do |a|
84
- next if a.default == :__unset || inputs.key?(a.name)
85
- next if a.default.nil? && a.required
86
-
87
- defaults[a.name] = a.default
88
- end
89
- kwargs = defaults.merge(inputs)
90
- kwargs[:role] = role if cmd_class.members.include?(:role) && !inputs.key?(:role) && spec.verb != :audit
91
- check_missing_args!(spec, cmd_class, kwargs)
92
-
93
- cmd_class.new(**kwargs.slice(*cmd_class.members))
94
- end
95
-
96
- def check_missing_args!(spec, cmd_class, kwargs)
97
- params = cmd_class.instance_method(:initialize).parameters
98
- required = if params == [[:rest]]
99
- cmd_class.members
100
- else
101
- params.select { |t,| t == :keyreq }.map(&:last)
102
- end
103
- missing = required - kwargs.keys
104
- return if missing.empty?
105
-
106
- raise Textus::Contract::MissingArgs.new(spec, missing.map { |m| Struct.new(:wire, :name).new(m.to_s, m) })
107
- end
108
-
109
67
  # Fill CLI-specific defaults (cli_default:) for args the operator did not
110
68
  # pass, where the CLI default diverges from the contract default the agent
111
69
  # surfaces use — e.g. migrate/data_mv apply by default on the CLI but plan
@@ -119,14 +77,6 @@ module Textus
119
77
  end
120
78
  end
121
79
 
122
- # Shape the use-case result for the CLI wire via the verb's :cli view
123
- # (falling back to the default view). The view is called uniformly as
124
- # (result, inputs); an inputs-aware view echoes an input such as the key
125
- # (ADR 0067).
126
- def shape(spec, result, inputs)
127
- Textus::Contract::View.render(spec, :cli, result, inputs)
128
- end
129
-
130
80
  # The default the CLI flag is generated against — `cli_default:` when the
131
81
  # operator-facing default diverges from the contract default the agent
132
82
  # surfaces use, else the contract `default`. This drives boolean flag
@@ -151,6 +101,7 @@ module Textus
151
101
  def coerce(arg, raw)
152
102
  return effective_default(arg) != true if arg.type == :boolean
153
103
  return Integer(raw) if arg.type == Integer
104
+ return JSON.parse(raw) if arg.type == Hash
154
105
 
155
106
  raw
156
107
  end
@@ -193,11 +144,7 @@ module Textus
193
144
 
194
145
  def install!
195
146
  @installed ||= {}
196
- Textus::Gate::ROUTES.each_key do |cmd_class|
197
- verb = Textus::Gate::VERB_COMMAND.key(cmd_class)
198
- next unless verb
199
-
200
- action_class = Textus::Gate::ROUTES[cmd_class].first
147
+ Textus::Action::VERBS.each_value do |action_class|
201
148
  next unless action_class.respond_to?(:contract?) && action_class.contract?
202
149
 
203
150
  spec = action_class.contract
@@ -224,7 +171,7 @@ module Textus
224
171
  non_positional.each { |a| klass.option a.name, Runner.flagspec_for(a) }
225
172
 
226
173
  # Anchor the anonymous class to a constant so descendants discovery is
227
- # stable. Name it after the verb under a Generated namespace.
174
+ # stable. Name it under a Generated namespace.
228
175
  const_name = spec.verb.to_s.split("_").map(&:capitalize).join
229
176
  gen = "Gen#{const_name}"
230
177
  Verb.const_set(gen, klass) unless Verb.const_defined?(gen, false)
@@ -0,0 +1,41 @@
1
+ require "json"
2
+
3
+ module Textus
4
+ module Surface
5
+ class CLI
6
+ # CLI-only input acquisition. Transforms entries of the uniform `inputs`
7
+ # hash that declare a `source:`/`coerce:`, and builds `inputs` from a
8
+ # `cli_stdin` envelope — so put/propose/migrate/rule_lint/audit need no
9
+ # hand-authored CLI class (ADR 0068). MCP receives typed JSON, so these
10
+ # never run there.
11
+ module Sources
12
+ module_function
13
+
14
+ # Apply per-arg :file sources (value is a path -> file contents) and
15
+ # :coerce callables to a by-name inputs hash. Returns a new hash.
16
+ def acquire(spec, inputs)
17
+ spec.args.each_with_object(inputs.dup) do |a, h|
18
+ next unless h.key?(a.name)
19
+
20
+ h[a.name] = File.read(h[a.name]) if a.source == :file
21
+ h[a.name] = a.coerce.call(h[a.name]) if a.coerce
22
+ end
23
+ end
24
+
25
+ # Parse a cli_stdin :json envelope into a by-name inputs hash, mapping
26
+ # envelope keys (wire-names) to arg names.
27
+ def from_stdin(spec, stream)
28
+ return {} unless spec.cli_stdin == :json
29
+
30
+ raw = stream.read.to_s
31
+ return {} if raw.strip.empty? # no envelope piped -> required args surface as missing
32
+
33
+ envelope = JSON.parse(raw)
34
+ spec.args.each_with_object({}) do |a, h|
35
+ h[a.name] = envelope[a.wire.to_s] if envelope.key?(a.wire.to_s)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Surfaces
2
+ module Surface
3
3
  class CLI
4
4
  class Verb
5
5
  class Doctor < Verb
@@ -7,11 +7,9 @@ module Textus
7
7
  option :checks, "--check=NAME"
8
8
 
9
9
  def call(store)
10
- cmd = Textus::Command::Doctor.new(
11
- checks: checks&.split(",")&.map(&:strip),
12
- role: resolved_role(store),
13
- )
14
- res = store.gate.dispatch(cmd)
10
+ spec = Textus::Action::Doctor.contract
11
+ inputs = { checks: checks&.split(",")&.map(&:strip) }
12
+ res = store.gate.dispatch(spec: spec, inputs: inputs, role: resolved_role(store))
15
13
  emit(res, exit_code: res["ok"] ? 0 : 1)
16
14
  end
17
15
  end