textus 0.54.2 → 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 (242) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/README.md +17 -10
  4. data/SPEC.md +39 -11
  5. data/docs/architecture/README.md +23 -11
  6. data/docs/reference/conventions.md +5 -2
  7. data/exe/textus +1 -1
  8. data/lib/textus/boot.rb +64 -93
  9. data/lib/textus/dispatch/binder.rb +48 -0
  10. data/lib/textus/dispatch/contracts.rb +63 -0
  11. data/lib/textus/dispatch/handler_registry.rb +21 -0
  12. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  13. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  14. data/lib/textus/dispatch/middleware/base.rb +26 -0
  15. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  16. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  17. data/lib/textus/dispatch/pipeline.rb +35 -0
  18. data/lib/textus/doctor/check/audit_log.rb +2 -2
  19. data/lib/textus/doctor/check/generator_drift.rb +4 -4
  20. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  21. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  22. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  23. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  24. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  25. data/lib/textus/doctor/check/schemas.rb +1 -1
  26. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  27. data/lib/textus/doctor/check/sentinels.rb +4 -4
  28. data/lib/textus/doctor/check/templates.rb +1 -1
  29. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  30. data/lib/textus/doctor/check.rb +9 -10
  31. data/lib/textus/doctor.rb +2 -2
  32. data/lib/textus/errors.rb +8 -0
  33. data/lib/textus/format/base.rb +34 -10
  34. data/lib/textus/format/json.rb +5 -27
  35. data/lib/textus/format/markdown.rb +5 -27
  36. data/lib/textus/format/text.rb +5 -4
  37. data/lib/textus/format/yaml.rb +30 -27
  38. data/lib/textus/format.rb +22 -1
  39. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  40. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  41. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  42. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  43. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  44. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  45. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  46. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  47. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  48. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  49. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  50. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  51. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  52. data/lib/textus/handlers/read/get_entry.rb +68 -0
  53. data/lib/textus/handlers/read/list_keys.rb +36 -0
  54. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  55. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  56. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  57. data/lib/textus/handlers/read/where_entry.rb +18 -0
  58. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  59. data/lib/textus/handlers/write/data_mv.rb +55 -0
  60. data/lib/textus/handlers/write/delete_key.rb +17 -0
  61. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  62. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  63. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  64. data/lib/textus/handlers/write/move_key.rb +80 -0
  65. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  66. data/lib/textus/handlers/write/put_entry.rb +29 -0
  67. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  68. data/lib/textus/init.rb +5 -7
  69. data/lib/textus/jobs.rb +3 -9
  70. data/lib/textus/manifest/capabilities.rb +4 -4
  71. data/lib/textus/manifest/entry/base.rb +3 -3
  72. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  73. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -2
  74. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  75. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  76. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  77. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  78. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  79. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  80. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  81. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  82. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  83. data/lib/textus/manifest/policy/predicates.rb +54 -0
  84. data/lib/textus/manifest/policy/retention.rb +1 -1
  85. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  86. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  87. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  88. data/lib/textus/manifest/schema/semantics.rb +11 -216
  89. data/lib/textus/orchestration.rb +55 -0
  90. data/lib/textus/{ports → port}/audit_log.rb +46 -6
  91. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  92. data/lib/textus/{ports → port}/clock.rb +1 -1
  93. data/lib/textus/{ports → port}/publisher.rb +5 -5
  94. data/lib/textus/{core → port}/sentinel.rb +1 -6
  95. data/lib/textus/{ports → port}/sentinel_store.rb +4 -4
  96. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  97. data/lib/textus/port/storage/file_store.rb +49 -0
  98. data/lib/textus/port/storage/interface.rb +17 -0
  99. data/lib/textus/port/store.rb +149 -0
  100. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  101. data/lib/textus/produce/engine.rb +1 -11
  102. data/lib/textus/produce/publisher.rb +21 -0
  103. data/lib/textus/schema/registry.rb +42 -0
  104. data/lib/textus/schema/tools.rb +7 -10
  105. data/lib/textus/store/container.rb +173 -0
  106. data/lib/textus/store/cursor.rb +26 -0
  107. data/lib/textus/store/entry/reader.rb +47 -0
  108. data/lib/textus/store/entry/writer.rb +219 -0
  109. data/lib/textus/store/envelope/meta.rb +61 -0
  110. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  111. data/lib/textus/store/freshness/evaluator.rb +20 -0
  112. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  113. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  114. data/lib/textus/store/freshness.rb +8 -0
  115. data/lib/textus/store/index/builder.rb +76 -0
  116. data/lib/textus/store/index/lookup.rb +60 -0
  117. data/lib/textus/store/jobs/base.rb +13 -0
  118. data/lib/textus/store/jobs/index.rb +15 -0
  119. data/lib/textus/store/jobs/materialize.rb +15 -0
  120. data/lib/textus/store/jobs/plan.rb +11 -0
  121. data/lib/textus/store/jobs/planner.rb +124 -0
  122. data/lib/textus/store/jobs/queue.rb +162 -0
  123. data/lib/textus/store/jobs/registry.rb +19 -0
  124. data/lib/textus/store/jobs/retention/base.rb +52 -0
  125. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  126. data/lib/textus/store/jobs/retention.rb +8 -0
  127. data/lib/textus/store/jobs/sweep.rb +21 -0
  128. data/lib/textus/store/jobs/worker.rb +64 -0
  129. data/lib/textus/store/layout.rb +97 -0
  130. data/lib/textus/store.rb +63 -32
  131. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  132. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  133. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  135. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  136. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  137. data/lib/textus/{surfaces → surface}/cli/runner.rb +14 -68
  138. data/lib/textus/surface/cli/sources.rb +41 -0
  139. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +5 -6
  140. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +7 -5
  141. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  142. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  143. data/lib/textus/surface/cli/verb/put.rb +27 -0
  144. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  145. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  146. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  147. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  148. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  149. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  150. data/lib/textus/surface/mcp/catalog.rb +58 -0
  151. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -11
  152. data/lib/textus/surface/mcp/projector.rb +20 -0
  153. data/lib/textus/{surfaces → surface}/mcp/server.rb +24 -35
  154. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  155. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  156. data/lib/textus/value/call.rb +30 -0
  157. data/lib/textus/value/command.rb +16 -0
  158. data/lib/textus/{core → value}/duration.rb +1 -4
  159. data/lib/textus/value/envelope.rb +90 -0
  160. data/lib/textus/value/etag.rb +39 -0
  161. data/lib/textus/value/payload.rb +7 -0
  162. data/lib/textus/value/result.rb +46 -0
  163. data/lib/textus/value/role.rb +38 -0
  164. data/lib/textus/value/types.rb +13 -0
  165. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  166. data/lib/textus/verb_registry.rb +417 -0
  167. data/lib/textus/version.rb +1 -1
  168. data/lib/textus/workflow/loader.rb +4 -4
  169. data/lib/textus/workflow/runner.rb +11 -33
  170. data/lib/textus.rb +4 -69
  171. metadata +142 -105
  172. data/lib/textus/action/accept.rb +0 -44
  173. data/lib/textus/action/audit.rb +0 -131
  174. data/lib/textus/action/base.rb +0 -42
  175. data/lib/textus/action/blame.rb +0 -97
  176. data/lib/textus/action/boot.rb +0 -17
  177. data/lib/textus/action/data_mv.rb +0 -69
  178. data/lib/textus/action/deps.rb +0 -34
  179. data/lib/textus/action/doctor.rb +0 -24
  180. data/lib/textus/action/drain.rb +0 -39
  181. data/lib/textus/action/enqueue.rb +0 -53
  182. data/lib/textus/action/get.rb +0 -78
  183. data/lib/textus/action/ingest.rb +0 -203
  184. data/lib/textus/action/jobs.rb +0 -36
  185. data/lib/textus/action/key_delete.rb +0 -36
  186. data/lib/textus/action/key_delete_prefix.rb +0 -44
  187. data/lib/textus/action/key_mv.rb +0 -129
  188. data/lib/textus/action/key_mv_prefix.rb +0 -57
  189. data/lib/textus/action/list.rb +0 -42
  190. data/lib/textus/action/propose.rb +0 -52
  191. data/lib/textus/action/published.rb +0 -24
  192. data/lib/textus/action/pulse.rb +0 -56
  193. data/lib/textus/action/put.rb +0 -52
  194. data/lib/textus/action/rdeps.rb +0 -39
  195. data/lib/textus/action/reject.rb +0 -37
  196. data/lib/textus/action/rule_explain.rb +0 -91
  197. data/lib/textus/action/rule_lint.rb +0 -68
  198. data/lib/textus/action/rule_list.rb +0 -42
  199. data/lib/textus/action/schema_envelope.rb +0 -29
  200. data/lib/textus/action/uid.rb +0 -33
  201. data/lib/textus/action/where.rb +0 -36
  202. data/lib/textus/action/write_verb.rb +0 -44
  203. data/lib/textus/call.rb +0 -28
  204. data/lib/textus/command.rb +0 -41
  205. data/lib/textus/container.rb +0 -26
  206. data/lib/textus/contract/around.rb +0 -29
  207. data/lib/textus/contract/binder.rb +0 -88
  208. data/lib/textus/contract/resources/build_lock.rb +0 -17
  209. data/lib/textus/contract/resources/cursor.rb +0 -26
  210. data/lib/textus/contract/sources.rb +0 -39
  211. data/lib/textus/contract/view.rb +0 -15
  212. data/lib/textus/contract.rb +0 -174
  213. data/lib/textus/core/freshness/evaluator.rb +0 -150
  214. data/lib/textus/core/freshness.rb +0 -11
  215. data/lib/textus/core/retention/sweep.rb +0 -57
  216. data/lib/textus/core/retention.rb +0 -11
  217. data/lib/textus/cursor_store.rb +0 -24
  218. data/lib/textus/envelope/reader.rb +0 -46
  219. data/lib/textus/envelope/writer.rb +0 -209
  220. data/lib/textus/envelope.rb +0 -79
  221. data/lib/textus/etag.rb +0 -36
  222. data/lib/textus/gate/auth.rb +0 -227
  223. data/lib/textus/gate.rb +0 -116
  224. data/lib/textus/jobs/base.rb +0 -23
  225. data/lib/textus/jobs/materialize.rb +0 -20
  226. data/lib/textus/jobs/plan.rb +0 -9
  227. data/lib/textus/jobs/planner.rb +0 -101
  228. data/lib/textus/jobs/retention.rb +0 -48
  229. data/lib/textus/jobs/sweep.rb +0 -27
  230. data/lib/textus/jobs/worker.rb +0 -67
  231. data/lib/textus/layout.rb +0 -91
  232. data/lib/textus/ports/job_store/job.rb +0 -65
  233. data/lib/textus/ports/job_store.rb +0 -123
  234. data/lib/textus/ports/raw_index.rb +0 -61
  235. data/lib/textus/ports/storage/file_store.rb +0 -26
  236. data/lib/textus/role.rb +0 -36
  237. data/lib/textus/schemas.rb +0 -54
  238. data/lib/textus/session.rb +0 -35
  239. data/lib/textus/surfaces/cli/verb/put.rb +0 -30
  240. data/lib/textus/surfaces/mcp/catalog.rb +0 -111
  241. data/lib/textus/surfaces/role_scope.rb +0 -34
  242. data/lib/textus/types.rb +0 -15
@@ -1,69 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
-
5
- module Textus
6
- module Action
7
- class DataMv < Base
8
- extend Textus::Contract::DSL
9
-
10
- verb :data_mv
11
- summary "Rename a data lane — manifest + files. Refuses if destination exists."
12
- surfaces :cli, :mcp
13
- cli "data mv"
14
- arg :from, String, required: true, positional: true, description: "current data lane name"
15
- arg :to, String, required: true, positional: true,
16
- description: "new data lane name; refused if a lane by this name already exists"
17
- arg :dry_run, :boolean, default: false,
18
- description: "when true, returns the planned zone move without applying it; " \
19
- "defaults to false, so omitting it applies the move immediately"
20
- view { |v, _i| v.to_h }
21
-
22
- def initialize(from:, to:, dry_run: false)
23
- super()
24
- @from = from
25
- @to = to
26
- @dry_run = dry_run
27
- end
28
-
29
- def call(container:, **)
30
- manifest = container.manifest
31
- root = container.root
32
-
33
- raise UsageError.new("from and to required") if @from.nil? || @to.nil? || @from.empty? || @to.empty?
34
- raise UsageError.new("data lane '#{@from}' not declared") unless manifest.data.declared_lane_kinds.key?(@from)
35
-
36
- dest_dir = File.join(root, "data", @to)
37
- raise UsageError.new("destination 'data/#{@to}' already exists") if File.exist?(dest_dir)
38
-
39
- affected_keys = manifest.data.entries.select { |entry| entry.lane == @from }.map(&:key)
40
-
41
- steps = [{ "op" => "rename_zone", "from" => @from, "to" => @to }]
42
- steps += affected_keys.map do |key|
43
- { "op" => "mv", "from" => key, "to" => "#{@to}#{key[@from.length..]}" }
44
- end
45
-
46
- plan = Textus::Jobs::Plan.new(steps: steps, warnings: [])
47
- return plan if @dry_run
48
-
49
- rewrite_manifest!(root)
50
- FileUtils.mv(File.join(root, "data", @from), dest_dir)
51
- plan
52
- end
53
-
54
- private
55
-
56
- def rewrite_manifest!(root)
57
- path = File.join(root, "manifest.yaml")
58
- raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
59
- raw["lanes"].each { |lane| lane["name"] = @to if lane["name"] == @from }
60
- raw["entries"].each do |entry|
61
- entry["lane"] = @to if entry["lane"] == @from
62
- entry["key"] = entry["key"].sub(/\A#{Regexp.escape(@from)}(\.|\z)/, "#{@to}\\1")
63
- entry["path"] = entry["path"].sub(%r{\A(data/)?#{Regexp.escape(@from)}(/|\z)}, "\\1#{@to}\\2")
64
- end
65
- File.write(path, YAML.dump(raw))
66
- end
67
- end
68
- end
69
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Deps < Base
6
- extend Textus::Contract::DSL
7
-
8
- verb :deps
9
- summary "List the keys a derived entry depends on (its projection/external sources)."
10
- surfaces :cli, :mcp
11
- arg :key, String, required: true, positional: true,
12
- description: "dotted key of the derived entry whose source keys you want"
13
-
14
- def initialize(key:)
15
- super()
16
- @key = key
17
- end
18
-
19
- def call(container:, **)
20
- entry = container.manifest.data.entries.find { |e| e.key == @key }
21
- deps = entry&.external? ? Array(entry.source&.sources).compact : []
22
- { "key" => @key, "deps" => deps.uniq }
23
- end
24
-
25
- def self.new(*args, **kwargs)
26
- return super(**kwargs) unless args.any?
27
-
28
- positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
29
- mapped = positional.zip(args).to_h
30
- super(**mapped.merge(kwargs))
31
- end
32
- end
33
- end
34
- end
@@ -1,24 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Doctor < Base
6
- extend Textus::Contract::DSL
7
-
8
- verb :doctor
9
- summary "Run health checks on the textus store and report any issues."
10
- surfaces :cli
11
- cli "doctor"
12
- arg :checks, Array, required: false, description: "subset of check names to run (default: all)"
13
-
14
- def initialize(checks: nil)
15
- super()
16
- @checks = checks
17
- end
18
-
19
- def call(container:, call:, **)
20
- Textus::Doctor.build(container: container, checks: @checks, role: call.role)
21
- end
22
- end
23
- end
24
- end
@@ -1,39 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Drain < Base
6
- extend Textus::Contract::DSL
7
-
8
- verb :drain
9
- summary "Seed materialize + sweep jobs then drain the queue to empty. " \
10
- "Identical to one Watcher tick. Use when no watcher is running."
11
- surfaces :cli, :mcp
12
- arg :prefix, String, description: "restrict to keys under this dotted prefix"
13
- arg :lane, String, description: "restrict to entries in this lane"
14
-
15
- def initialize(prefix: nil, lane: nil)
16
- super()
17
- @prefix = prefix
18
- @lane = lane
19
- end
20
-
21
- def call(container:, call:)
22
- queue = Textus::Ports::JobStore.new(root: container.root)
23
- Textus::Jobs::Planner.seed(
24
- container: container,
25
- queue: queue,
26
- role: call.role,
27
- )
28
- queue.reclaim(now: Textus::Ports::Clock.new.now)
29
- summary = Textus::Jobs::Worker.for(container:, queue:).drain
30
- {
31
- "protocol" => Textus::PROTOCOL,
32
- "ok" => summary.failed.zero?,
33
- "completed" => summary.completed,
34
- "failed" => summary.failed,
35
- }
36
- end
37
- end
38
- end
39
- end
@@ -1,53 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Enqueue < WriteVerb
6
- extend Textus::Contract::DSL
7
-
8
- verb :enqueue
9
- summary "Push a registered job type onto the convergence queue, to be run by drain/serve."
10
- surfaces :cli, :mcp
11
- cli "enqueue"
12
- arg :type, String, required: true, positional: true,
13
- description: "registered job type (e.g. materialize, re-pull, sweep)"
14
- arg :args, Hash, default: {},
15
- description: "type-specific arguments (e.g. { key: ... } or { scope: ... })"
16
-
17
- def initialize(type:, args: {})
18
- super()
19
- @type = type
20
- @job_args = args
21
- end
22
-
23
- def args
24
- { type: @type, args: @job_args }
25
- end
26
-
27
- def call(container:, call:)
28
- action_class = begin
29
- Textus::Jobs.fetch(@type.to_s)
30
- rescue Textus::UsageError
31
- raise Textus::UsageError.new("unregistered job type '#{@type}'")
32
- end
33
- if action_class.const_defined?(:REQUIRED_ROLE) && call.role != action_class::REQUIRED_ROLE
34
- raise Textus::Error.new(
35
- "forbidden",
36
- "role '#{call.role}' is not authorized to enqueue this job type (requires '#{action_class::REQUIRED_ROLE}')",
37
- details: { "role" => call.role, "required_role" => action_class::REQUIRED_ROLE },
38
- exit_code: 77,
39
- )
40
- end
41
-
42
- job = Textus::Ports::JobStore::Job.new(
43
- type: @type,
44
- args: @job_args,
45
- enqueued_by: call.role,
46
- max_attempts: 3,
47
- )
48
- Textus::Ports::JobStore.new(root: container.root).enqueue(job)
49
- { "protocol" => Textus::PROTOCOL, "ok" => true, "id" => job.id }
50
- end
51
- end
52
- end
53
- end
@@ -1,78 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Get < Base
6
- extend Textus::Contract::DSL
7
-
8
- verb :get
9
- summary "Read one entry - a pure on-disk read annotated with a freshness " \
10
- "verdict; never ingests (quarantine freshness is drain + hook " \
11
- "only, ADR 0089). Returns the envelope (uid, etag, _meta, body, " \
12
- "freshness)."
13
- surfaces :cli, :mcp
14
- arg :key, String, required: true, positional: true,
15
- description: "dotted entry key to read, e.g. 'knowledge.project'"
16
- view { |v, _i| v.to_h_for_wire }
17
-
18
- def initialize(key:)
19
- super()
20
- @key = key
21
- end
22
-
23
- def call(container:, call:, file_stat: Textus::Ports::Storage::FileStat.new)
24
- @container = container
25
- @call = call
26
- @manifest = container.manifest
27
- @file_store = container.file_store
28
- @file_stat = file_stat
29
- annotated_envelope(@key)
30
- end
31
-
32
- def self.new(*args, **kwargs)
33
- return super(**kwargs) unless args.any?
34
-
35
- positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
36
- mapped = positional.zip(args).to_h
37
- super(**mapped.merge(kwargs))
38
- end
39
-
40
- private
41
-
42
- def annotated_envelope(key)
43
- envelope = read_raw_envelope(key)
44
- return nil if envelope.nil?
45
-
46
- entry = @manifest.resolver.resolve(key).entry
47
- envelope.with(freshness: evaluator.verdict(entry))
48
- end
49
-
50
- def evaluator
51
- @evaluator ||= Textus::Core::Freshness::Evaluator.new(
52
- manifest: @manifest,
53
- file_stat: @file_stat,
54
- clock: @call,
55
- )
56
- end
57
-
58
- def read_raw_envelope(key)
59
- res = @manifest.resolver.resolve(key)
60
- mentry = res.entry
61
- path = res.path
62
- return nil unless @file_store.exists?(path)
63
-
64
- raw = @file_store.read(path)
65
- parsed = Textus::Format.for(mentry.format).parse(raw, path: path)
66
- Textus::Envelope.build(
67
- key: key,
68
- mentry: mentry,
69
- path: path,
70
- meta: parsed["_meta"],
71
- body: parsed["body"],
72
- etag: Textus::Etag.for_bytes(raw),
73
- content: parsed["content"],
74
- )
75
- end
76
- end
77
- end
78
- end
@@ -1,203 +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
- extend Textus::Contract::DSL
11
-
12
- verb :ingest
13
- summary "Capture external source material into the raw lane. Write-once, agent-owned."
14
- surfaces :cli, :mcp
15
- arg :kind, String, required: true, positional: true,
16
- description: "source kind: url | file | asset"
17
- arg :slug, String, required: true,
18
- description: "human slug for the key suffix (kebab-case)"
19
- arg :url, String, description: "remote URL (required when kind=url)"
20
- arg :path, String, description: "local file path (required when kind=file or kind=asset)"
21
- arg :zone, String, description: "asset group subdirectory (required when kind=asset)"
22
- arg :label, String, description: "human label stored in source.label"
23
- view { |env| { "key" => env.key, "uid" => env.uid, "etag" => env.etag } }
24
-
25
- SOURCE_KINDS = %w[url file asset].freeze
26
- CONTENT_HASH_ALGO = "sha256"
27
- TOMBSTONE_RETAIN = %w[ingested_at].freeze
28
-
29
- def initialize(kind:, slug:, url: nil, path: nil, zone: nil, label: nil)
30
- super()
31
- @kind = kind
32
- @slug = slug
33
- @url = url
34
- @path = path
35
- @zone = zone
36
- @label = label
37
- end
38
-
39
- def call(container:, call:)
40
- validate_inputs!
41
-
42
- now = Time.now.utc
43
- key = derive_key(now)
44
-
45
- Textus::Gate::Auth.new(container).check_action!(
46
- action: :ingest, actor: call.role, key: key,
47
- )
48
-
49
- content_hash = compute_content_hash
50
- writer = Textus::Envelope::Writer.from(container: container, call: call)
51
- mentry = container.manifest.resolver.resolve(key).entry
52
- ts = now.iso8601
53
- structured = build_structured(ts, container, now, content_hash)
54
-
55
- index = Textus::Ports::RawIndex.new(root: container.root)
56
- duplicate_key = find_duplicate(index, content_hash)
57
-
58
- if duplicate_key && duplicate_key != key
59
- supersede_entry(duplicate_key, key, structured, container, call, index)
60
- else
61
- env = write_raw_entry(key, structured, mentry, writer)
62
- index.upsert(content_hash: content_hash, url: @url, key: key)
63
- env
64
- end
65
- end
66
-
67
- private
68
-
69
- def validate_inputs!
70
- unless SOURCE_KINDS.include?(@kind)
71
- raise Textus::UsageError.new(
72
- "ingest kind must be one of #{SOURCE_KINDS.join("|")}, got #{@kind.inspect}",
73
- )
74
- end
75
- case @kind
76
- when "url"
77
- raise Textus::UsageError.new("ingest url requires --url") unless @url
78
- when "file"
79
- raise Textus::UsageError.new("ingest file requires --path") unless @path
80
- when "asset"
81
- raise Textus::UsageError.new("ingest asset requires --path") unless @path
82
- raise Textus::UsageError.new("ingest asset requires --zone") unless @zone
83
- end
84
- end
85
-
86
- def derive_key(now)
87
- date = now.strftime("%Y.%m.%d")
88
- "raw.#{date}.#{@kind}-#{@slug}"
89
- end
90
-
91
- def compute_content_hash
92
- digest = Digest::SHA256.new
93
- case @kind
94
- when "url"
95
- digest.update(@url)
96
- when "file", "asset"
97
- digest.file(@path)
98
- end
99
- "#{CONTENT_HASH_ALGO}:#{digest.hexdigest}"
100
- end
101
-
102
- def build_structured(timestamp, container, now, content_hash)
103
- base = { "ingested_at" => timestamp, "content_hash" => content_hash }
104
- case @kind
105
- when "url"
106
- base.merge("source" => { "kind" => "url", "url" => @url, "label" => @label || @url },
107
- "body" => nil)
108
- when "file"
109
- body_content = File.read(@path)
110
- base.merge("source" => { "kind" => "file", "path" => @path,
111
- "label" => @label || File.basename(@path) },
112
- "body" => body_content)
113
- when "asset"
114
- asset_rel = copy_asset_file(container, now)
115
- base.merge("source" => { "kind" => "asset",
116
- "label" => @label || File.basename(@path) },
117
- "asset" => asset_rel,
118
- "body" => nil)
119
- end
120
- end
121
-
122
- def write_raw_entry(key, structured, mentry, writer)
123
- writer.put(key, mentry: mentry,
124
- payload: Textus::Envelope::Writer::Payload.new(
125
- meta: nil, body: nil, content: structured,
126
- ))
127
- end
128
-
129
- def find_duplicate(index, content_hash)
130
- dup = index.find_by_hash(content_hash)
131
- return dup if dup
132
-
133
- return unless @kind == "url"
134
-
135
- index.find_by_url(@url)
136
- end
137
-
138
- def supersede_entry(old_key, new_key, structured, container, call, index)
139
- old_mentry = container.manifest.resolver.resolve(old_key).entry
140
- writer = Textus::Envelope::Writer.from(container: container, call: call)
141
-
142
- reader = Textus::Envelope::Reader.from(container: container)
143
- old_env = reader.read(old_key)
144
- old_content = old_env&.content || {}
145
- tombstone = {}
146
- TOMBSTONE_RETAIN.each do |k|
147
- tombstone[k] = old_content[k] if old_content.key?(k)
148
- end
149
- source_kind = old_content.dig("source", "kind")
150
- tombstone["source"] = { "kind" => source_kind } if source_kind
151
- tombstone["superseded_by"] = new_key
152
-
153
- writer.put(old_key, mentry: old_mentry,
154
- payload: Textus::Envelope::Writer::Payload.new(
155
- meta: nil, body: nil, content: tombstone,
156
- ))
157
-
158
- structured["supersedes"] = old_key
159
- env = write_raw_entry(new_key, structured, container.manifest.resolver.resolve(new_key).entry, writer)
160
-
161
- move_asset_file(container, old_content["asset"]) if @kind == "asset" && old_content["asset"]
162
-
163
- index.upsert(content_hash: structured["content_hash"], url: @url, key: new_key)
164
- env
165
- end
166
-
167
- def move_asset_file(container, old_asset_rel)
168
- old_path = File.join(container.root, "assets", old_asset_rel)
169
- return unless File.exist?(old_path)
170
-
171
- now = Time.now.utc
172
- date_path = now.strftime("%Y/%m/%d")
173
- filename = File.basename(old_path)
174
- new_dir = File.join(container.root, "assets", "raw", date_path, @zone)
175
- new_path = File.join(new_dir, filename)
176
-
177
- return if old_path == new_path
178
-
179
- FileUtils.mkdir_p(new_dir)
180
- FileUtils.mv(old_path, new_path)
181
- rescue Errno::ENOENT, Errno::EACCES => e
182
- warn "[textus ingest] could not move asset #{old_asset_rel}: #{e.message}"
183
- end
184
-
185
- def copy_asset_file(container, now)
186
- date_path = now.strftime("%Y/%m/%d")
187
- filename = File.basename(@path)
188
- assets_dir = File.join(container.root, "assets", "raw", date_path, @zone)
189
- FileUtils.mkdir_p(assets_dir)
190
- FileUtils.cp(@path, File.join(assets_dir, filename))
191
- create_gitignore_sentinel(container)
192
- "raw/#{date_path}/#{@zone}/#{filename}"
193
- end
194
-
195
- def create_gitignore_sentinel(container)
196
- assets_root = File.join(container.root, "assets")
197
- FileUtils.mkdir_p(assets_root)
198
- sentinel = File.join(assets_root, ".gitignore")
199
- File.write(sentinel, "*\n") unless File.exist?(sentinel)
200
- end
201
- end
202
- end
203
- end
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class Jobs < Base
6
- extend Textus::Contract::DSL
7
-
8
- verb :jobs
9
- summary "List queued jobs by state; retry a dead-lettered job or purge a state."
10
- surfaces :cli, :mcp
11
- cli "jobs"
12
- arg :state, String, default: "ready", description: "ready|leased|done|failed"
13
- arg :action, String, default: nil, description: "retry|purge (optional)"
14
- arg :job_id, String, default: nil, description: "job id (required for action=retry)"
15
-
16
- def initialize(state: "ready", action: nil, job_id: nil)
17
- super()
18
- @state = state
19
- @action = action
20
- @job_id = job_id
21
- end
22
-
23
- def call(container:, **)
24
- queue = Textus::Ports::JobStore.new(root: container.root)
25
- case @action
26
- when "retry"
27
- queue.retry_failed(@job_id)
28
- when "purge"
29
- queue.purge(@state)
30
- end
31
-
32
- { "protocol" => Textus::PROTOCOL, "ok" => true, "state" => @state, "jobs" => queue.list(@state) }
33
- end
34
- end
35
- end
36
- end
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class KeyDelete < WriteVerb
6
- extend Textus::Contract::DSL
7
-
8
- verb :key_delete
9
- summary "Delete one entry by key. Single-key, lower blast radius than key_delete_prefix; " \
10
- "guarded by an optional optimistic-concurrency etag. Returns {ok, key, deleted}."
11
- surfaces :cli, :mcp
12
- cli "key delete"
13
- arg :key, String, required: true, positional: true,
14
- description: "dotted entry key to delete"
15
- arg :if_etag, String,
16
- description: "optimistic-concurrency guard: the etag you last read; the delete is rejected if the entry changed since"
17
-
18
- def initialize(key:, if_etag: nil)
19
- super()
20
- @key = key
21
- @if_etag = if_etag
22
- end
23
-
24
- def call(container:, call:)
25
- run_with_cascade(@key, container:, call:) do
26
- Textus::Manifest::Data.validate_key!(@key)
27
- mentry = container.manifest.resolver.resolve(@key).entry
28
-
29
- writer(container, call).delete(@key, mentry:, if_etag: @if_etag)
30
-
31
- { "protocol" => Textus::PROTOCOL, "ok" => true, "key" => @key, "deleted" => true }
32
- end
33
- end
34
- end
35
- end
36
- end
@@ -1,44 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Action
5
- class KeyDeletePrefix < Base
6
- extend Textus::Contract::DSL
7
-
8
- verb :key_delete_prefix
9
- summary "Bulk-delete every leaf key under prefix."
10
- surfaces :cli, :mcp
11
- cli "key delete-prefix"
12
- arg :prefix, String, required: true, positional: true,
13
- description: "every leaf key under this dotted prefix is deleted"
14
- arg :dry_run, :boolean, default: false,
15
- description: "when true, returns the keys that would be deleted without deleting them; " \
16
- "defaults to false, so omitting it deletes immediately"
17
- view { |v, _i| v.to_h }
18
-
19
- def initialize(prefix:, dry_run: false)
20
- super()
21
- @prefix = prefix
22
- @dry_run = dry_run
23
- end
24
-
25
- def call(container:, call:)
26
- raise UsageError.new("prefix required") if @prefix.nil? || @prefix.empty?
27
-
28
- leaves = Textus::Action::List.new(prefix: @prefix).call(container: container)
29
- .map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
30
-
31
- warnings = leaves.empty? ? ["no keys under #{@prefix}"] : []
32
- steps = leaves.map { |key| { "op" => "delete", "key" => key } }
33
-
34
- plan = Textus::Jobs::Plan.new(steps: steps, warnings: warnings)
35
- return plan if @dry_run
36
-
37
- steps.each do |step|
38
- Textus::Action::KeyDelete.new(key: step["key"]).call(container: container, call: call)
39
- end
40
- plan
41
- end
42
- end
43
- end
44
- end