textus 0.54.1 → 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 (175) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +51 -1
  3. data/README.md +61 -27
  4. data/SPEC.md +167 -296
  5. data/docs/architecture/README.md +20 -8
  6. data/docs/reference/conventions.md +40 -56
  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 +141 -77
  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 +20 -27
  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 +8 -16
  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 +2 -5
  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 -62
  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 -83
  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/role.rb +0 -36
  174. data/lib/textus/session.rb +0 -35
  175. data/lib/textus/types.rb +0 -15
@@ -3,31 +3,16 @@
3
3
  module Textus
4
4
  module Action
5
5
  class Deps < Base
6
- extend Textus::Contract::DSL
7
-
8
6
  verb :deps
9
7
  summary "List the keys a derived entry depends on (its projection/external sources)."
10
8
  surfaces :cli, :mcp
11
9
  arg :key, String, required: true, positional: true,
12
10
  description: "dotted key of the derived entry whose source keys you want"
13
11
 
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 }
12
+ def self.call(container:, key:, **)
13
+ entry = container.manifest.data.entries.find { |e| e.key == key }
21
14
  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))
15
+ Success({ "key" => key, "deps" => deps.uniq })
31
16
  end
32
17
  end
33
18
  end
@@ -3,21 +3,14 @@
3
3
  module Textus
4
4
  module Action
5
5
  class Doctor < Base
6
- extend Textus::Contract::DSL
7
-
8
6
  verb :doctor
9
7
  summary "Run health checks on the textus store and report any issues."
10
8
  surfaces :cli
11
9
  cli "doctor"
12
10
  arg :checks, Array, required: false, description: "subset of check names to run (default: all)"
13
11
 
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)
12
+ def self.call(container:, call:, checks: nil, **)
13
+ Success(Textus::Doctor.build(container: container, checks: checks, role: call.role))
21
14
  end
22
15
  end
23
16
  end
@@ -3,8 +3,6 @@
3
3
  module Textus
4
4
  module Action
5
5
  class Drain < Base
6
- extend Textus::Contract::DSL
7
-
8
6
  verb :drain
9
7
  summary "Seed materialize + sweep jobs then drain the queue to empty. " \
10
8
  "Identical to one Watcher tick. Use when no watcher is running."
@@ -12,27 +10,21 @@ module Textus
12
10
  arg :prefix, String, description: "restrict to keys under this dotted prefix"
13
11
  arg :lane, String, description: "restrict to entries in this lane"
14
12
 
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(
13
+ def self.call(container:, call:, prefix: nil, lane: nil) # rubocop:disable Lint/UnusedMethodArgument
14
+ queue = Textus::Store::Jobs::Queue.new(store: container.job_store)
15
+ Textus::Store::Jobs::Planner.seed(
24
16
  container: container,
25
17
  queue: queue,
26
18
  role: call.role,
27
19
  )
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
- }
20
+ queue.reclaim(now: Textus::Port::Clock.new.now)
21
+ summary = Textus::Store::Jobs::Worker.for(container:, queue:).drain
22
+ Success({
23
+ "protocol" => Textus::PROTOCOL,
24
+ "ok" => summary.failed.zero?,
25
+ "completed" => summary.completed,
26
+ "failed" => summary.failed,
27
+ })
36
28
  end
37
29
  end
38
30
  end
@@ -2,9 +2,7 @@
2
2
 
3
3
  module Textus
4
4
  module Action
5
- class Enqueue < WriteVerb
6
- extend Textus::Contract::DSL
7
-
5
+ class Enqueue < Base
8
6
  verb :enqueue
9
7
  summary "Push a registered job type onto the convergence queue, to be run by drain/serve."
10
8
  surfaces :cli, :mcp
@@ -14,39 +12,25 @@ module Textus
14
12
  arg :args, Hash, default: {},
15
13
  description: "type-specific arguments (e.g. { key: ... } or { scope: ... })"
16
14
 
17
- def initialize(type:, args: {})
18
- super()
19
- @type = type
20
- @job_args = args
21
- end
15
+ def self.call(container:, call:, type:, args: {})
16
+ action_class = Textus::Jobs.fetch(type.to_s)
22
17
 
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
18
  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
- )
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 })
40
22
  end
41
23
 
42
- job = Textus::Ports::JobStore::Job.new(
43
- type: @type,
44
- args: @job_args,
45
- enqueued_by: call.role,
24
+ job = Textus::Store::Jobs::Queue::Job.new(
25
+ type: type,
26
+ args: args,
27
+ role: call.role,
46
28
  max_attempts: 3,
47
29
  )
48
- Textus::Ports::JobStore.new(root: container.root).enqueue(job)
49
- { "protocol" => Textus::PROTOCOL, "ok" => true, "id" => job.id }
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}'")
50
34
  end
51
35
  end
52
36
  end
@@ -3,8 +3,6 @@
3
3
  module Textus
4
4
  module Action
5
5
  class Get < Base
6
- extend Textus::Contract::DSL
7
-
8
6
  verb :get
9
7
  summary "Read one entry - a pure on-disk read annotated with a freshness " \
10
8
  "verdict; never ingests (quarantine freshness is drain + hook " \
@@ -13,64 +11,22 @@ module Textus
13
11
  surfaces :cli, :mcp
14
12
  arg :key, String, required: true, positional: true,
15
13
  description: "dotted entry key to read, e.g. 'knowledge.project'"
16
- view { |v, _i| v.to_h_for_wire }
14
+ view(:default) { |v, _i| v&.to_h_for_wire }
17
15
 
18
- def initialize(key:)
19
- super()
20
- @key = key
21
- end
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
22
19
 
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)
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)))
30
23
  end
31
24
 
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"],
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,
74
30
  )
75
31
  end
76
32
  end
@@ -2,12 +2,11 @@
2
2
 
3
3
  require "fileutils"
4
4
  require "date"
5
+ require "digest"
5
6
 
6
7
  module Textus
7
8
  module Action
8
9
  class Ingest < Base
9
- extend Textus::Contract::DSL
10
-
11
10
  verb :ingest
12
11
  summary "Capture external source material into the raw lane. Write-once, agent-owned."
13
12
  surfaces :cli, :mcp
@@ -22,109 +21,174 @@ module Textus
22
21
  view { |env| { "key" => env.key, "uid" => env.uid, "etag" => env.etag } }
23
22
 
24
23
  SOURCE_KINDS = %w[url file asset].freeze
24
+ CONTENT_HASH_ALGO = "sha256"
25
+ TOMBSTONE_RETAIN = %w[ingested_at].freeze
25
26
 
26
- def initialize(kind:, slug:, url: nil, path: nil, zone: nil, label: nil)
27
- super()
28
- @kind = kind
29
- @slug = slug
30
- @url = url
31
- @path = path
32
- @zone = zone
33
- @label = label
34
- end
35
-
36
- def call(container:, call:)
37
- validate_inputs!
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)
38
30
 
39
31
  now = Time.now.utc
40
- key = derive_key(now)
32
+ key = derive_key(now, kind:, slug:)
41
33
 
42
- Textus::Gate::Auth.new(container).check_action!(
43
- action: :ingest, actor: call.role, key: key,
44
- )
45
-
46
- write_raw_entry(key, now, container, call)
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)
47
52
  end
48
53
 
49
- private
50
-
51
- def validate_inputs!
52
- unless SOURCE_KINDS.include?(@kind)
53
- raise Textus::UsageError.new(
54
- "ingest kind must be one of #{SOURCE_KINDS.join("|")}, got #{@kind.inspect}",
55
- )
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}")
56
58
  end
57
- case @kind
59
+ case kind
58
60
  when "url"
59
- raise Textus::UsageError.new("ingest url requires --url") unless @url
61
+ return Failure(code: :usage_error, message: "ingest url requires --url") unless url
60
62
  when "file"
61
- raise Textus::UsageError.new("ingest file requires --path") unless @path
63
+ return Failure(code: :usage_error, message: "ingest file requires --path") unless path
62
64
  when "asset"
63
- raise Textus::UsageError.new("ingest asset requires --path") unless @path
64
- raise Textus::UsageError.new("ingest asset requires --zone") unless @zone
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
65
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:)
66
76
  end
67
77
 
68
- def derive_key(now)
78
+ def self.derive_key(now, kind:, slug:)
69
79
  date = now.strftime("%Y.%m.%d")
70
- "raw.#{date}.#{@kind}-#{@slug}"
80
+ "raw.#{date}.#{kind}-#{slug}"
71
81
  end
72
82
 
73
- def write_raw_entry(key, now, container, call)
74
- ts = now.iso8601
75
- mentry = container.manifest.resolver.resolve(key).entry
76
- writer = Textus::Envelope::Writer.from(container: container, call: call)
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
77
93
 
78
- case @kind
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
79
97
  when "url"
80
- structured = {
81
- "ingested_at" => ts,
82
- "source" => { "kind" => "url", "url" => @url, "label" => @label || @url },
83
- "body" => nil,
84
- }
85
- writer.put(key, mentry: mentry,
86
- payload: Textus::Envelope::Writer::Payload.new(
87
- meta: nil, body: nil, content: structured,
88
- ))
98
+ base.merge("source" => { "kind" => "url", "url" => url, "label" => label || url },
99
+ "body" => nil)
89
100
  when "file"
90
- body_content = File.read(@path)
91
- structured = {
92
- "ingested_at" => ts,
93
- "source" => { "kind" => "file", "path" => @path,
94
- "label" => @label || File.basename(@path) },
95
- "body" => body_content,
96
- }
97
- writer.put(key, mentry: mentry,
98
- payload: Textus::Envelope::Writer::Payload.new(
99
- meta: nil, body: nil, content: structured,
100
- ))
101
+ body_content = File.read(path)
102
+ base.merge("source" => { "kind" => "file", "path" => path,
103
+ "label" => label || File.basename(path) },
104
+ "body" => body_content)
101
105
  when "asset"
102
- asset_rel = copy_asset_file(container, now)
103
- structured = {
104
- "ingested_at" => ts,
105
- "source" => { "kind" => "asset",
106
- "label" => @label || File.basename(@path) },
107
- "asset" => asset_rel,
108
- "body" => nil,
109
- }
110
- writer.put(key, mentry: mentry,
111
- payload: Textus::Envelope::Writer::Payload.new(
112
- meta: nil, body: nil, content: structured,
113
- ))
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)
114
111
  end
115
112
  end
116
113
 
117
- def copy_asset_file(container, now)
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:)
118
182
  date_path = now.strftime("%Y/%m/%d")
119
- filename = File.basename(@path)
120
- assets_dir = File.join(container.root, "assets", "raw", date_path, @zone)
183
+ filename = File.basename(path)
184
+ assets_dir = File.join(container.root, "assets", "raw", date_path, zone)
121
185
  FileUtils.mkdir_p(assets_dir)
122
- FileUtils.cp(@path, File.join(assets_dir, filename))
186
+ FileUtils.cp(path, File.join(assets_dir, filename))
123
187
  create_gitignore_sentinel(container)
124
- "raw/#{date_path}/#{@zone}/#{filename}"
188
+ "raw/#{date_path}/#{zone}/#{filename}"
125
189
  end
126
190
 
127
- def create_gitignore_sentinel(container)
191
+ def self.create_gitignore_sentinel(container)
128
192
  assets_root = File.join(container.root, "assets")
129
193
  FileUtils.mkdir_p(assets_root)
130
194
  sentinel = File.join(assets_root, ".gitignore")
@@ -3,8 +3,6 @@
3
3
  module Textus
4
4
  module Action
5
5
  class Jobs < Base
6
- extend Textus::Contract::DSL
7
-
8
6
  verb :jobs
9
7
  summary "List queued jobs by state; retry a dead-lettered job or purge a state."
10
8
  surfaces :cli, :mcp
@@ -13,23 +11,16 @@ module Textus
13
11
  arg :action, String, default: nil, description: "retry|purge (optional)"
14
12
  arg :job_id, String, default: nil, description: "job id (required for action=retry)"
15
13
 
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
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
26
17
  when "retry"
27
- queue.retry_failed(@job_id)
18
+ queue.retry_failed(job_id)
28
19
  when "purge"
29
- queue.purge(@state)
20
+ queue.purge(state)
30
21
  end
31
22
 
32
- { "protocol" => Textus::PROTOCOL, "ok" => true, "state" => @state, "jobs" => queue.list(@state) }
23
+ Success({ "protocol" => Textus::PROTOCOL, "ok" => true, "state" => state, "jobs" => queue.list(state) })
33
24
  end
34
25
  end
35
26
  end
@@ -2,9 +2,7 @@
2
2
 
3
3
  module Textus
4
4
  module Action
5
- class KeyDelete < WriteVerb
6
- extend Textus::Contract::DSL
7
-
5
+ class KeyDelete < Base
8
6
  verb :key_delete
9
7
  summary "Delete one entry by key. Single-key, lower blast radius than key_delete_prefix; " \
10
8
  "guarded by an optional optimistic-concurrency etag. Returns {ok, key, deleted}."
@@ -15,21 +13,13 @@ module Textus
15
13
  arg :if_etag, String,
16
14
  description: "optimistic-concurrency guard: the etag you last read; the delete is rejected if the entry changed since"
17
15
 
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
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
28
19
 
29
- writer(container, call).delete(@key, mentry:, if_etag: @if_etag)
20
+ container.compositor.delete(key, mentry: mentry, if_etag: if_etag, call: call)
30
21
 
31
- { "protocol" => Textus::PROTOCOL, "ok" => true, "key" => @key, "deleted" => true }
32
- end
22
+ Success({ "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true })
33
23
  end
34
24
  end
35
25
  end
@@ -3,8 +3,6 @@
3
3
  module Textus
4
4
  module Action
5
5
  class KeyDeletePrefix < Base
6
- extend Textus::Contract::DSL
7
-
8
6
  verb :key_delete_prefix
9
7
  summary "Bulk-delete every leaf key under prefix."
10
8
  surfaces :cli, :mcp
@@ -16,28 +14,21 @@ module Textus
16
14
  "defaults to false, so omitting it deletes immediately"
17
15
  view { |v, _i| v.to_h }
18
16
 
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?
17
+ def self.call(container:, call:, prefix:, dry_run: false)
18
+ return Failure(code: :usage_error, message: "prefix required") if prefix.nil? || prefix.empty?
27
19
 
28
- leaves = Textus::Action::List.new(prefix: @prefix).call(container: container)
29
- .map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
20
+ leaves = Textus::Action::List.leaf_keys(container: container, prefix: prefix)
30
21
 
31
- warnings = leaves.empty? ? ["no keys under #{@prefix}"] : []
22
+ warnings = leaves.empty? ? ["no keys under #{prefix}"] : []
32
23
  steps = leaves.map { |key| { "op" => "delete", "key" => key } }
33
24
 
34
- plan = Textus::Jobs::Plan.new(steps: steps, warnings: warnings)
35
- return plan if @dry_run
25
+ plan = Textus::Store::Jobs::Plan.new(steps: steps, warnings: warnings)
26
+ return Success(plan) if dry_run
36
27
 
37
28
  steps.each do |step|
38
- Textus::Action::KeyDelete.new(key: step["key"]).call(container: container, call: call)
29
+ Value::Result.unwrap(Textus::Action::KeyDelete.call(container: container, call: call, key: step["key"]))
39
30
  end
40
- plan
31
+ Success(plan)
41
32
  end
42
33
  end
43
34
  end