textus 0.55.1 → 0.55.2

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 (172) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +1 -1
  3. data/README.md +9 -9
  4. data/SPEC.md +14 -13
  5. data/docs/architecture/README.md +3 -3
  6. data/docs/reference/conventions.md +5 -2
  7. data/lib/textus/boot.rb +64 -85
  8. data/lib/textus/{gate → dispatch}/binder.rb +8 -10
  9. data/lib/textus/dispatch/contracts.rb +63 -0
  10. data/lib/textus/dispatch/handler_registry.rb +21 -0
  11. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  12. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  13. data/lib/textus/dispatch/middleware/base.rb +26 -0
  14. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  15. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  16. data/lib/textus/dispatch/pipeline.rb +35 -0
  17. data/lib/textus/doctor/check/audit_log.rb +1 -1
  18. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  19. data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
  20. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  21. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  22. data/lib/textus/doctor/check/sentinels.rb +1 -1
  23. data/lib/textus/doctor/check.rb +8 -6
  24. data/lib/textus/doctor.rb +1 -1
  25. data/lib/textus/errors.rb +2 -0
  26. data/lib/textus/format/base.rb +36 -8
  27. data/lib/textus/format/json.rb +0 -21
  28. data/lib/textus/format/markdown.rb +0 -21
  29. data/lib/textus/format/yaml.rb +0 -21
  30. data/lib/textus/format.rb +16 -1
  31. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  32. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  33. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  34. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  35. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  36. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  37. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  38. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  39. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  40. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  41. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  42. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  43. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  44. data/lib/textus/handlers/read/get_entry.rb +68 -0
  45. data/lib/textus/handlers/read/list_keys.rb +36 -0
  46. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  47. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  48. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  49. data/lib/textus/handlers/read/where_entry.rb +18 -0
  50. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  51. data/lib/textus/handlers/write/data_mv.rb +55 -0
  52. data/lib/textus/handlers/write/delete_key.rb +17 -0
  53. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  54. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  55. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  56. data/lib/textus/handlers/write/move_key.rb +80 -0
  57. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  58. data/lib/textus/handlers/write/put_entry.rb +29 -0
  59. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  60. data/lib/textus/init.rb +5 -5
  61. data/lib/textus/manifest/capabilities.rb +1 -1
  62. data/lib/textus/manifest/entry/base.rb +3 -3
  63. data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
  64. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  65. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  66. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  67. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  68. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  69. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  70. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  71. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  72. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  73. data/lib/textus/manifest/policy/predicates.rb +54 -0
  74. data/lib/textus/manifest/policy/retention.rb +1 -1
  75. data/lib/textus/orchestration.rb +55 -0
  76. data/lib/textus/port/audit_log.rb +6 -6
  77. data/lib/textus/port/build_lock.rb +1 -1
  78. data/lib/textus/{core → port}/sentinel.rb +1 -6
  79. data/lib/textus/port/sentinel_store.rb +3 -3
  80. data/lib/textus/port/storage/file_store.rb +23 -0
  81. data/lib/textus/port/storage/interface.rb +17 -0
  82. data/lib/textus/port/store.rb +58 -2
  83. data/lib/textus/port/watcher_lock.rb +2 -2
  84. data/lib/textus/produce/engine.rb +1 -11
  85. data/lib/textus/produce/publisher.rb +21 -0
  86. data/lib/textus/schema/registry.rb +42 -0
  87. data/lib/textus/schema/tools.rb +3 -10
  88. data/lib/textus/store/container.rb +140 -10
  89. data/lib/textus/store/cursor.rb +1 -1
  90. data/lib/textus/store/{envelope → entry}/reader.rb +8 -4
  91. data/lib/textus/store/{envelope → entry}/writer.rb +53 -29
  92. data/lib/textus/store/envelope/meta.rb +61 -0
  93. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  94. data/lib/textus/store/freshness/evaluator.rb +20 -0
  95. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  96. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  97. data/lib/textus/store/freshness.rb +8 -0
  98. data/lib/textus/store/index/builder.rb +5 -3
  99. data/lib/textus/store/jobs/planner.rb +27 -7
  100. data/lib/textus/store/jobs/queue.rb +9 -1
  101. data/lib/textus/store/jobs/retention/base.rb +52 -0
  102. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  103. data/lib/textus/store/jobs/retention.rb +1 -43
  104. data/lib/textus/store/jobs/sweep.rb +2 -2
  105. data/lib/textus/store/{geometry.rb → layout.rb} +19 -3
  106. data/lib/textus/store.rb +53 -30
  107. data/lib/textus/surface/cli/runner.rb +8 -9
  108. data/lib/textus/surface/cli/verb/doctor.rb +3 -2
  109. data/lib/textus/surface/cli/verb/get.rb +5 -3
  110. data/lib/textus/surface/cli/verb/put.rb +5 -3
  111. data/lib/textus/surface/mcp/catalog.rb +26 -62
  112. data/lib/textus/surface/mcp/errors.rb +0 -10
  113. data/lib/textus/surface/mcp/projector.rb +20 -0
  114. data/lib/textus/surface/mcp/server.rb +20 -31
  115. data/lib/textus/{core → value}/duration.rb +1 -4
  116. data/lib/textus/value/envelope.rb +5 -4
  117. data/lib/textus/value/etag.rb +1 -1
  118. data/lib/textus/value/payload.rb +7 -0
  119. data/lib/textus/value/result.rb +36 -16
  120. data/lib/textus/verb_registry.rb +417 -0
  121. data/lib/textus/version.rb +1 -1
  122. data/lib/textus/workflow/loader.rb +1 -1
  123. data/lib/textus/workflow/runner.rb +10 -18
  124. data/lib/textus.rb +0 -64
  125. metadata +70 -70
  126. data/lib/textus/action/accept.rb +0 -46
  127. data/lib/textus/action/audit.rb +0 -94
  128. data/lib/textus/action/base.rb +0 -42
  129. data/lib/textus/action/blame.rb +0 -79
  130. data/lib/textus/action/boot.rb +0 -15
  131. data/lib/textus/action/data_mv.rb +0 -58
  132. data/lib/textus/action/deps.rb +0 -19
  133. data/lib/textus/action/doctor.rb +0 -17
  134. data/lib/textus/action/drain.rb +0 -31
  135. data/lib/textus/action/enqueue.rb +0 -37
  136. data/lib/textus/action/get.rb +0 -34
  137. data/lib/textus/action/ingest.rb +0 -199
  138. data/lib/textus/action/jobs.rb +0 -27
  139. data/lib/textus/action/key_delete.rb +0 -26
  140. data/lib/textus/action/key_delete_prefix.rb +0 -35
  141. data/lib/textus/action/key_mv.rb +0 -122
  142. data/lib/textus/action/key_mv_prefix.rb +0 -48
  143. data/lib/textus/action/list.rb +0 -28
  144. data/lib/textus/action/propose.rb +0 -42
  145. data/lib/textus/action/published.rb +0 -22
  146. data/lib/textus/action/pulse.rb +0 -49
  147. data/lib/textus/action/put.rb +0 -38
  148. data/lib/textus/action/rdeps.rb +0 -24
  149. data/lib/textus/action/reject.rb +0 -28
  150. data/lib/textus/action/rule_explain.rb +0 -81
  151. data/lib/textus/action/rule_lint.rb +0 -62
  152. data/lib/textus/action/rule_list.rb +0 -38
  153. data/lib/textus/action/schema_envelope.rb +0 -22
  154. data/lib/textus/action/uid.rb +0 -19
  155. data/lib/textus/action/where.rb +0 -21
  156. data/lib/textus/contract/arg.rb +0 -10
  157. data/lib/textus/contract/dsl.rb +0 -88
  158. data/lib/textus/contract/spec.rb +0 -25
  159. data/lib/textus/contract.rb +0 -12
  160. data/lib/textus/core/freshness/evaluator.rb +0 -150
  161. data/lib/textus/core/freshness.rb +0 -11
  162. data/lib/textus/core/retention/sweep.rb +0 -57
  163. data/lib/textus/core/retention.rb +0 -11
  164. data/lib/textus/format/shared.rb +0 -17
  165. data/lib/textus/gate/auth.rb +0 -212
  166. data/lib/textus/gate.rb +0 -92
  167. data/lib/textus/meta.rb +0 -54
  168. data/lib/textus/schemas.rb +0 -54
  169. data/lib/textus/store/compositor.rb +0 -34
  170. data/lib/textus/store/session.rb +0 -37
  171. data/lib/textus/surface/projector.rb +0 -27
  172. data/lib/textus/surface/role_scope.rb +0 -34
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Enqueue < Base
6
- verb :enqueue
7
- summary "Push a registered job type onto the convergence queue, to be run by drain/serve."
8
- surfaces :cli, :mcp
9
- cli "enqueue"
10
- arg :type, String, required: true, positional: true,
11
- description: "registered job type (e.g. materialize, re-pull, sweep)"
12
- arg :args, Hash, default: {},
13
- description: "type-specific arguments (e.g. { key: ... } or { scope: ... })"
14
-
15
- def self.call(container:, call:, type:, args: {})
16
- action_class = Textus::Jobs.fetch(type.to_s)
17
-
18
- if action_class.const_defined?(:REQUIRED_ROLE) && call.role != action_class::REQUIRED_ROLE
19
- return Failure(code: :forbidden,
20
- message: "role '#{call.role}' is not authorized to enqueue this job type",
21
- details: { "role" => call.role, "required_role" => action_class::REQUIRED_ROLE })
22
- end
23
-
24
- job = Textus::Store::Jobs::Queue::Job.new(
25
- type: type,
26
- args: args,
27
- role: call.role,
28
- max_attempts: 3,
29
- )
30
- Textus::Store::Jobs::Queue.new(store: container.job_store).enqueue(job)
31
- Success({ "protocol" => Textus::PROTOCOL, "ok" => true, "id" => job.id })
32
- rescue Textus::UsageError
33
- Failure(code: :usage_error, message: "unregistered job type '#{type}'")
34
- end
35
- end
36
- end
37
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Get < Base
6
- verb :get
7
- summary "Read one entry - a pure on-disk read annotated with a freshness " \
8
- "verdict; never ingests (quarantine freshness is drain + hook " \
9
- "only, ADR 0089). Returns the envelope (uid, etag, _meta, body, " \
10
- "freshness)."
11
- surfaces :cli, :mcp
12
- arg :key, String, required: true, positional: true,
13
- description: "dotted entry key to read, e.g. 'knowledge.project'"
14
- view(:default) { |v, _i| v&.to_h_for_wire }
15
-
16
- def self.call(container:, call:, key:)
17
- envelope = container.compositor.read(key)
18
- return Failure(code: :not_found, message: "no entry at #{key}") unless envelope
19
-
20
- entry = container.manifest.resolver.resolve(key).entry
21
- file_stat = Textus::Port::Storage::FileStat.new
22
- Success(envelope.with(freshness: freshness_evaluator(container, call, file_stat).verdict(entry)))
23
- end
24
-
25
- def self.freshness_evaluator(container, call, file_stat)
26
- Textus::Core::Freshness::Evaluator.new(
27
- manifest: container.manifest,
28
- file_stat: file_stat,
29
- clock: call,
30
- )
31
- end
32
- end
33
- end
34
- end
@@ -1,199 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "fileutils"
4
- require "date"
5
- require "digest"
6
-
7
- module Textus
8
- module Action
9
- class Ingest < Base
10
- verb :ingest
11
- summary "Capture external source material into the raw lane. Write-once, agent-owned."
12
- surfaces :cli, :mcp
13
- arg :kind, String, required: true, positional: true,
14
- description: "source kind: url | file | asset"
15
- arg :slug, String, required: true,
16
- description: "human slug for the key suffix (kebab-case)"
17
- arg :url, String, description: "remote URL (required when kind=url)"
18
- arg :path, String, description: "local file path (required when kind=file or kind=asset)"
19
- arg :zone, String, description: "asset group subdirectory (required when kind=asset)"
20
- arg :label, String, description: "human label stored in source.label"
21
- view { |env| { "key" => env.key, "uid" => env.uid, "etag" => env.etag } }
22
-
23
- SOURCE_KINDS = %w[url file asset].freeze
24
- CONTENT_HASH_ALGO = "sha256"
25
- TOMBSTONE_RETAIN = %w[ingested_at].freeze
26
-
27
- def self.call(container:, call:, kind:, slug:, url: nil, path: nil, zone: nil, label: nil, **) # rubocop:disable Metrics/ParameterLists
28
- validation = validate_inputs(kind:, url:, path:, zone:)
29
- return validation if validation.is_a?(Dry::Monads::Result::Failure)
30
-
31
- now = Time.now.utc
32
- key = derive_key(now, kind:, slug:)
33
-
34
- content_hash = compute_content_hash(kind:, url:, path:)
35
- writer = Textus::Store::Envelope::Writer.from(container: container, call: call)
36
- mentry = container.manifest.resolver.resolve(key).entry
37
- ts = now.iso8601
38
- structured = build_structured(ts, container, now, content_hash, kind:, url:, path:, label:, zone:)
39
-
40
- store = container.job_store
41
- index = Textus::Store::Index::Lookup.new(store: store)
42
- duplicate_key = find_duplicate(index, content_hash, kind:, url:)
43
-
44
- result = if duplicate_key && duplicate_key != key
45
- supersede_entry(duplicate_key, key, structured, container, call, store: store, kind:, zone:)
46
- else
47
- env = write_raw_entry(key, structured, mentry, writer)
48
- rebuild_index(container, store)
49
- env
50
- end
51
- Success(result)
52
- end
53
-
54
- def self.validate_inputs(kind:, url:, path:, zone:)
55
- unless SOURCE_KINDS.include?(kind)
56
- return Failure(code: :usage_error,
57
- message: "ingest kind must be one of #{SOURCE_KINDS.join("|")}, got #{kind.inspect}")
58
- end
59
- case kind
60
- when "url"
61
- return Failure(code: :usage_error, message: "ingest url requires --url") unless url
62
- when "file"
63
- return Failure(code: :usage_error, message: "ingest file requires --path") unless path
64
- when "asset"
65
- return Failure(code: :usage_error, message: "ingest asset requires --path") unless path
66
- return Failure(code: :usage_error, message: "ingest asset requires --zone") unless zone
67
- end
68
- nil
69
- end
70
-
71
- # Key derivation for Gate pre-dispatch auth. Must match the runtime
72
- # derivation in #call so the same key is checked by auth and used by
73
- # the action body.
74
- def self.dispatch_key(kind:, slug:, **)
75
- derive_key(Time.now.utc, kind:, slug:)
76
- end
77
-
78
- def self.derive_key(now, kind:, slug:)
79
- date = now.strftime("%Y.%m.%d")
80
- "raw.#{date}.#{kind}-#{slug}"
81
- end
82
-
83
- def self.compute_content_hash(kind:, url:, path:)
84
- digest = Digest::SHA256.new
85
- case kind
86
- when "url"
87
- digest.update(url)
88
- when "file", "asset"
89
- digest.file(path)
90
- end
91
- "#{CONTENT_HASH_ALGO}:#{digest.hexdigest}"
92
- end
93
-
94
- def self.build_structured(timestamp, container, now, content_hash, kind:, url:, path:, label:, zone:) # rubocop:disable Metrics/ParameterLists
95
- base = { "ingested_at" => timestamp, "content_hash" => content_hash }
96
- case kind
97
- when "url"
98
- base.merge("source" => { "kind" => "url", "url" => url, "label" => label || url },
99
- "body" => nil)
100
- when "file"
101
- body_content = File.read(path)
102
- base.merge("source" => { "kind" => "file", "path" => path,
103
- "label" => label || File.basename(path) },
104
- "body" => body_content)
105
- when "asset"
106
- asset_rel = copy_asset_file(container, now, path:, zone:)
107
- base.merge("source" => { "kind" => "asset",
108
- "label" => label || File.basename(path) },
109
- "asset" => asset_rel,
110
- "body" => nil)
111
- end
112
- end
113
-
114
- def self.write_raw_entry(key, structured, mentry, writer)
115
- writer.put(key, mentry: mentry,
116
- payload: Textus::Store::Envelope::Writer::Payload.new(
117
- meta: nil, body: nil, content: structured,
118
- ))
119
- end
120
-
121
- def self.find_duplicate(index, content_hash, kind:, url:)
122
- dup = index.find_by_hash(content_hash)
123
- return dup if dup
124
-
125
- return unless kind == "url"
126
-
127
- index.find_by_url(url)
128
- end
129
-
130
- def self.rebuild_index(container, store)
131
- Textus::Store::Index::Builder.new(store: store).rebuild!(resolver: container.manifest.resolver)
132
- end
133
-
134
- def self.supersede_entry(old_key, new_key, structured, container, call, store:, kind:, zone:) # rubocop:disable Metrics/ParameterLists
135
- old_mentry = container.manifest.resolver.resolve(old_key).entry
136
- writer = Textus::Store::Envelope::Writer.from(container: container, call: call)
137
-
138
- reader = Textus::Store::Envelope::Reader.from(container: container)
139
- old_env = reader.read(old_key)
140
- old_content = old_env&.content || {}
141
- tombstone = {}
142
- TOMBSTONE_RETAIN.each do |k|
143
- tombstone[k] = old_content[k] if old_content.key?(k)
144
- end
145
- source_kind = old_content.dig("source", "kind")
146
- tombstone["source"] = { "kind" => source_kind } if source_kind
147
- tombstone["superseded_by"] = new_key
148
-
149
- writer.put(old_key, mentry: old_mentry,
150
- payload: Textus::Store::Envelope::Writer::Payload.new(
151
- meta: nil, body: nil, content: tombstone,
152
- ))
153
-
154
- structured["supersedes"] = old_key
155
- env = write_raw_entry(new_key, structured, container.manifest.resolver.resolve(new_key).entry, writer)
156
-
157
- move_asset_file(container, old_content["asset"], zone:) if kind == "asset" && old_content["asset"]
158
-
159
- rebuild_index(container, store)
160
- env
161
- end
162
-
163
- def self.move_asset_file(container, old_asset_rel, zone:)
164
- old_path = File.join(container.root, "assets", old_asset_rel)
165
- return unless File.exist?(old_path)
166
-
167
- now = Time.now.utc
168
- date_path = now.strftime("%Y/%m/%d")
169
- filename = File.basename(old_path)
170
- new_dir = File.join(container.root, "assets", "raw", date_path, zone)
171
- new_path = File.join(new_dir, filename)
172
-
173
- return if old_path == new_path
174
-
175
- FileUtils.mkdir_p(new_dir)
176
- FileUtils.mv(old_path, new_path)
177
- rescue Errno::ENOENT, Errno::EACCES => e
178
- warn "[textus ingest] could not move asset #{old_asset_rel}: #{e.message}"
179
- end
180
-
181
- def self.copy_asset_file(container, now, path:, zone:)
182
- date_path = now.strftime("%Y/%m/%d")
183
- filename = File.basename(path)
184
- assets_dir = File.join(container.root, "assets", "raw", date_path, zone)
185
- FileUtils.mkdir_p(assets_dir)
186
- FileUtils.cp(path, File.join(assets_dir, filename))
187
- create_gitignore_sentinel(container)
188
- "raw/#{date_path}/#{zone}/#{filename}"
189
- end
190
-
191
- def self.create_gitignore_sentinel(container)
192
- assets_root = File.join(container.root, "assets")
193
- FileUtils.mkdir_p(assets_root)
194
- sentinel = File.join(assets_root, ".gitignore")
195
- File.write(sentinel, "*\n") unless File.exist?(sentinel)
196
- end
197
- end
198
- end
199
- end
@@ -1,27 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Jobs < Base
6
- verb :jobs
7
- summary "List queued jobs by state; retry a dead-lettered job or purge a state."
8
- surfaces :cli, :mcp
9
- cli "jobs"
10
- arg :state, String, default: "ready", description: "ready|leased|done|failed"
11
- arg :action, String, default: nil, description: "retry|purge (optional)"
12
- arg :job_id, String, default: nil, description: "job id (required for action=retry)"
13
-
14
- def self.call(container:, call:, state: "ready", action: nil, job_id: nil) # rubocop:disable Lint/UnusedMethodArgument
15
- queue = Textus::Store::Jobs::Queue.new(store: container.job_store)
16
- case action
17
- when "retry"
18
- queue.retry_failed(job_id)
19
- when "purge"
20
- queue.purge(state)
21
- end
22
-
23
- Success({ "protocol" => Textus::PROTOCOL, "ok" => true, "state" => state, "jobs" => queue.list(state) })
24
- end
25
- end
26
- end
27
- end
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class KeyDelete < Base
6
- verb :key_delete
7
- summary "Delete one entry by key. Single-key, lower blast radius than key_delete_prefix; " \
8
- "guarded by an optional optimistic-concurrency etag. Returns {ok, key, deleted}."
9
- surfaces :cli, :mcp
10
- cli "key delete"
11
- arg :key, String, required: true, positional: true,
12
- description: "dotted entry key to delete"
13
- arg :if_etag, String,
14
- description: "optimistic-concurrency guard: the etag you last read; the delete is rejected if the entry changed since"
15
-
16
- def self.call(container:, call:, key:, if_etag: nil)
17
- Textus::Manifest::Data.validate_key!(key)
18
- mentry = container.manifest.resolver.resolve(key).entry
19
-
20
- container.compositor.delete(key, mentry: mentry, if_etag: if_etag, call: call)
21
-
22
- Success({ "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true })
23
- end
24
- end
25
- end
26
- end
@@ -1,35 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class KeyDeletePrefix < Base
6
- verb :key_delete_prefix
7
- summary "Bulk-delete every leaf key under prefix."
8
- surfaces :cli, :mcp
9
- cli "key delete-prefix"
10
- arg :prefix, String, required: true, positional: true,
11
- description: "every leaf key under this dotted prefix is deleted"
12
- arg :dry_run, :boolean, default: false,
13
- description: "when true, returns the keys that would be deleted without deleting them; " \
14
- "defaults to false, so omitting it deletes immediately"
15
- view { |v, _i| v.to_h }
16
-
17
- def self.call(container:, call:, prefix:, dry_run: false)
18
- return Failure(code: :usage_error, message: "prefix required") if prefix.nil? || prefix.empty?
19
-
20
- leaves = Textus::Action::List.leaf_keys(container: container, prefix: prefix)
21
-
22
- warnings = leaves.empty? ? ["no keys under #{prefix}"] : []
23
- steps = leaves.map { |key| { "op" => "delete", "key" => key } }
24
-
25
- plan = Textus::Store::Jobs::Plan.new(steps: steps, warnings: warnings)
26
- return Success(plan) if dry_run
27
-
28
- steps.each do |step|
29
- Value::Result.unwrap(Textus::Action::KeyDelete.call(container: container, call: call, key: step["key"]))
30
- end
31
- Success(plan)
32
- end
33
- end
34
- end
35
- end
@@ -1,122 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class KeyMv < Base
6
- verb :key_mv
7
- summary "Rename one entry (same zone + format). Refuses if the target exists. Single-key, lower blast radius than key_mv_prefix."
8
- surfaces :cli, :mcp
9
- cli "key mv"
10
- arg :old_key, String, required: true, positional: true,
11
- description: "current dotted key"
12
- arg :new_key, String, required: true, positional: true,
13
- description: "new dotted key (must be the same zone and format as old_key)"
14
- arg :dry_run, :boolean,
15
- description: "when true, returns the planned move (from/to paths, uid) without applying it; " \
16
- "defaults to false, so omitting it applies the move immediately " \
17
- "(unlike the bulk key_mv_prefix, which defaults to a dry-run plan)"
18
-
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)
21
- end
22
-
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)
26
-
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
32
-
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))
35
- end
36
-
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,
42
- new_mentry: new_res.entry,
43
- call: call,
44
- )
45
- end
46
-
47
- def self.success_result(old_key:, new_key:, old_res:, new_res:, envelope:)
48
- {
49
- "protocol" => Textus::PROTOCOL,
50
- "ok" => true,
51
- "from_key" => old_key,
52
- "to_key" => new_key,
53
- "from_path" => old_res.path,
54
- "to_path" => new_res.path,
55
- "uid" => envelope.uid,
56
- "envelope" => envelope.to_h_for_wire,
57
- }
58
- end
59
-
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
64
-
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)
68
-
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
75
-
76
- [old_res, new_res]
77
- end
78
-
79
- def self.validate_zone_and_format(old_mentry:, new_mentry:)
80
- if old_mentry.lane != new_mentry.lane
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.")
84
- end
85
- return unless old_mentry.format != new_mentry.format
86
-
87
- Failure(code: :usage_error,
88
- message: "mv: format mismatch (#{old_mentry.format} -> #{new_mentry.format}); refusing.")
89
- end
90
-
91
- def self.ensure_uid!(container:, call:, old_key:, old_mentry:)
92
- pre_env = container.compositor.read(old_key)
93
- return if pre_env.uid
94
-
95
- container.compositor.write(
96
- old_key,
97
- mentry: old_mentry,
98
- payload: Textus::Store::Envelope::Writer::Payload.new(
99
- meta: pre_env.meta,
100
- body: pre_env.body,
101
- content: pre_env.content,
102
- ),
103
- call: call,
104
- )
105
- end
106
-
107
- def self.dry_run_result(container:, old_key:, new_key:, old_res:, new_res:)
108
- pre_env = container.compositor.read(old_key)
109
- {
110
- "protocol" => Textus::PROTOCOL,
111
- "ok" => true,
112
- "dry_run" => true,
113
- "from_key" => old_key,
114
- "to_key" => new_key,
115
- "from_path" => old_res.path,
116
- "to_path" => new_res.path,
117
- "uid" => pre_env.uid,
118
- }
119
- end
120
- end
121
- end
122
- end
@@ -1,48 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class KeyMvPrefix < Base
6
- verb :key_mv_prefix
7
- summary "Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false."
8
- surfaces :cli, :mcp
9
- cli "key mv-prefix"
10
- arg :from_prefix, String, required: true, positional: true,
11
- description: "dotted prefix whose leaf keys are renamed"
12
- arg :to_prefix, String, required: true, positional: true,
13
- description: "dotted prefix the keys are renamed to"
14
- arg :dry_run, :boolean, default: false,
15
- description: "when true, returns the planned moves without applying them; defaults " \
16
- "to false, so omitting it applies the rename immediately"
17
- view { |v, _i| v.to_h }
18
-
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?
21
-
22
- leaves = Textus::Action::List.leaf_keys(container: container, prefix: from_prefix)
23
-
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")
27
- end
28
-
29
- warnings = []
30
- warnings << "no keys under #{from_prefix}" if leaves.empty?
31
-
32
- steps = leaves.map do |old_key|
33
- tail = old_key.delete_prefix("#{from_prefix}.")
34
- new_key = "#{to_prefix}.#{tail}"
35
- { "op" => "mv", "from" => old_key, "to" => new_key }
36
- end
37
-
38
- plan = Textus::Store::Jobs::Plan.new(steps: steps, warnings: warnings)
39
- return Success(plan) if dry_run
40
-
41
- steps.each do |step|
42
- Value::Result.unwrap(Textus::Action::KeyMv.call(container: container, call: call, old_key: step["from"], new_key: step["to"]))
43
- end
44
- Success(plan)
45
- end
46
- end
47
- end
48
- end
@@ -1,28 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class List < Base
6
- verb :list
7
- summary "List keys filtered by lane and/or prefix."
8
- surfaces :cli, :mcp
9
- arg :prefix, String,
10
- description: "restrict to keys starting with this dotted prefix, e.g. 'knowledge.runbooks'"
11
- arg :lane, String,
12
- description: "restrict to one lane by name (see `boot` lanes); combine with prefix to narrow further"
13
- view(:cli) { |rows| { "entries" => rows } }
14
-
15
- def self.call(container:, call: nil, prefix: nil, lane: nil) # rubocop:disable Lint/UnusedMethodArgument
16
- manifest = container.manifest
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] } })
20
- end
21
-
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 }
25
- end
26
- end
27
- end
28
- end
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Propose < Base
6
- verb :propose
7
- summary "Write a proposal to the role's propose_lane. Auto-prefixes the key."
8
- surfaces :cli, :mcp
9
- cli_stdin :json
10
- arg :key, String, required: true, positional: true,
11
- description: "key relative to propose_lane, e.g. 'decisions.feature-x'"
12
- arg :meta, Hash, required: false, wire_name: :_meta,
13
- description: "frontmatter; reads back as `_meta` from `get`. Include a 'proposal:' block naming the target_key"
14
- arg :body, String,
15
- description: "markdown/text payload for markdown-format entries; omit (use `content`) for json/yaml entries. Do not send both"
16
- arg :content, Hash,
17
- description: "structured payload for json/yaml-format entries; omit (use `body`) for markdown entries. Do not send both"
18
- view { |env, _i| env.to_h_for_wire }
19
-
20
- def self.call(container:, call:, key:, meta: nil, body: nil, content: nil)
21
- zone = container.manifest.policy.propose_lane_for(call.role)
22
- unless zone
23
- return Failure(code: :propose_forbidden,
24
- message: "role '#{call.role}' has no writable propose_lane",
25
- details: { "role" => call.role })
26
- end
27
-
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
- ))
39
- end
40
- end
41
- end
42
- end
@@ -1,22 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Published < Base
6
- verb :published
7
- summary "List all entries that declare a publish_to target."
8
- surfaces :cli
9
- cli "published"
10
-
11
- def args
12
- {}
13
- end
14
-
15
- def self.call(container:, **)
16
- Success(container.manifest.data.entries.reject { |entry| entry.publish_to.empty? }.map do |entry|
17
- { "key" => entry.key, "publish_to" => entry.publish_to }
18
- end)
19
- end
20
- end
21
- end
22
- end
@@ -1,49 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "time"
4
-
5
- module Textus
6
- module Action
7
- class Pulse < Base
8
- verb :pulse
9
- summary "Delta since cursor — changed entries, pending proposals, index freshness."
10
- surfaces :cli, :mcp
11
- arg :since, Integer, session_default: :cursor,
12
- description: "audit seq to diff from; defaults to the session cursor"
13
-
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
-
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),
27
- "index_etag" => index_etag(container),
28
- }
29
-
30
- Textus::Store::Cursor.new(root: root, role: call.role).write(result["cursor"])
31
- Success(result)
32
- end
33
-
34
- def self.review_keys(manifest, container)
35
- queue = manifest.policy.queue_lane
36
- return [] unless queue
37
-
38
- Textus::Action::List.leaf_keys(container: container, lane: queue)
39
- end
40
-
41
- def self.index_etag(container)
42
- path = container.manifest.resolver.resolve("artifacts.system.index").path
43
- File.exist?(path) ? container.file_store.etag(path) : nil
44
- rescue Textus::Error
45
- nil
46
- end
47
- end
48
- end
49
- end