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
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Dispatch
5
+ module Middleware
6
+ # Shadows successful write operations into the SQLite audit_events table
7
+ # synchronously after dispatch, without the command knowing. Read verbs
8
+ # and failed writes pass through unchanged.
9
+ class AuditIndex < Base
10
+ middleware_name :audit_index
11
+
12
+ INDEXED_CONTRACTS = [
13
+ Contracts::PutEntry,
14
+ Contracts::DeleteKey,
15
+ Contracts::MoveKey,
16
+ ].freeze
17
+
18
+ def initialize(job_store:, audit_log:)
19
+ super()
20
+ @job_store = job_store
21
+ @audit_log = audit_log
22
+ end
23
+
24
+ def call(container:, command:, call:, next_handler:) # rubocop:disable Lint/UnusedMethodArgument
25
+ result = next_handler.call(command, call)
26
+ return result unless result.success? && INDEXED_CONTRACTS.include?(command.class)
27
+
28
+ key = command.respond_to?(:key) ? command.key : nil
29
+ return result unless key
30
+
31
+ seq = @audit_log.latest_seq
32
+ verb = command.class.name.split("::").last
33
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
34
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
35
+ .downcase
36
+
37
+ @job_store.insert_audit_event(
38
+ seq: seq,
39
+ ts: Time.now.utc.iso8601,
40
+ role: call.role,
41
+ verb: verb,
42
+ key: key,
43
+ etag_before: nil,
44
+ etag_after: result.value.is_a?(Hash) ? result.value["etag"] : nil,
45
+ )
46
+ result
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,40 @@
1
+ module Textus
2
+ module Dispatch
3
+ module Middleware
4
+ class Auth < Base
5
+ middleware_name :auth
6
+
7
+ def call(container:, command:, call:, next_handler:)
8
+ verb = VerbRegistry.contract_to_verb!(command.class).to_sym
9
+ key = key_for(command)
10
+
11
+ rule_preds = key ? rule_declared_predicates(verb, container.manifest, key) : []
12
+
13
+ Manifest::Policy::Predicates.evaluate(
14
+ manifest: container.manifest, schemas: container.schemas,
15
+ action: verb, actor: call.role, key: key,
16
+ rule_predicates: rule_preds
17
+ )
18
+
19
+ next_handler.call(command, call)
20
+ end
21
+
22
+ private
23
+
24
+ def rule_declared_predicates(verb, manifest, key)
25
+ guard_map = manifest.rules.for(key).guard
26
+ return [] if guard_map.nil?
27
+
28
+ Array(guard_map[verb.to_s])
29
+ end
30
+
31
+ def key_for(command)
32
+ if command.respond_to?(:key) then command.key
33
+ elsif command.respond_to?(:old_key) then command.old_key
34
+ elsif command.respond_to?(:pending_key) then command.pending_key
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,26 @@
1
+ module Textus
2
+ module Dispatch
3
+ module Middleware
4
+ class Base
5
+ def self.inherited(subclass)
6
+ super
7
+ subclass.instance_variable_set(:@middleware_name, nil)
8
+ end
9
+
10
+ class << self
11
+ def middleware_name(name = nil)
12
+ if name
13
+ @middleware_name = name.to_s
14
+ else
15
+ @middleware_name || name.split("::").last.downcase
16
+ end
17
+ end
18
+ end
19
+
20
+ def call(container:, command:, call:, next_handler:)
21
+ raise NotImplementedError
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,20 @@
1
+ module Textus
2
+ module Dispatch
3
+ module Middleware
4
+ class Binder < Base
5
+ middleware_name :binder
6
+
7
+ def call(container:, command:, call:, next_handler:) # rubocop:disable Lint/UnusedMethodArgument
8
+ return next_handler.call(command, call) unless command.is_a?(Dispatch::Binder::Pending)
9
+
10
+ spec = command.spec
11
+ contract_class = VerbRegistry.contract_class_for(spec.verb) or
12
+ raise Textus::UsageError.new("unknown command verb: #{spec.verb}")
13
+ resolved = Dispatch::Binder.bind(spec, command.inputs)
14
+ built = Dispatch::Pipeline.build_command(contract_class, resolved)
15
+ next_handler.call(built, call)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,53 @@
1
+ module Textus
2
+ module Dispatch
3
+ module Middleware
4
+ class Cascade < Base
5
+ middleware_name :cascade
6
+
7
+ CASCADE_VERBS = %i[put propose accept reject key_mv key_delete].freeze
8
+
9
+ TRIGGER_TYPE_MAP = {
10
+ Contracts::PutEntry => "entry.written",
11
+ Contracts::ProposeEntry => "entry.written",
12
+ Contracts::DeleteKey => "entry.deleted",
13
+ Contracts::MoveKey => "entry.moved",
14
+ Contracts::AcceptProposal => "proposal.accepted",
15
+ Contracts::RejectProposal => "proposal.rejected",
16
+ }.freeze
17
+
18
+ def call(container:, command:, call:, next_handler:)
19
+ result = next_handler.call(command, call)
20
+ return result unless result.success? && cascadable?(command)
21
+
22
+ key = cascade_key(command)
23
+ return result unless key
24
+
25
+ trigger_type = TRIGGER_TYPE_MAP[command.class]
26
+ jobs = Textus::Store::Jobs::Planner.new(container: container).plan(
27
+ trigger: { "type" => trigger_type, "target" => key },
28
+ role: call.role,
29
+ )
30
+ queue = Textus::Store::Jobs::Queue.new(store: container.job_store)
31
+ jobs.each { |j| queue.enqueue(j) }
32
+ result
33
+ end
34
+
35
+ private
36
+
37
+ def cascadable?(command)
38
+ CASCADE_VERBS.include?(VerbRegistry.contract_to_verb!(command.class).to_sym)
39
+ end
40
+
41
+ def cascade_key(command)
42
+ case command
43
+ when Contracts::PutEntry, Contracts::DeleteKey then command.key
44
+ when Contracts::MoveKey then command.new_key
45
+ when Contracts::AcceptProposal,
46
+ Contracts::RejectProposal then command.pending_key
47
+ when Contracts::ProposeEntry then command.key
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,35 @@
1
+ module Textus
2
+ module Dispatch
3
+ class Pipeline
4
+ attr_reader :container
5
+
6
+ def initialize(registry:, container:, middleware: [])
7
+ @registry = registry
8
+ @middleware = middleware
9
+ @container = container
10
+ end
11
+
12
+ def dispatch(command, call:)
13
+ stack = @middleware.reverse.reduce(->(cmd, c) { execute(cmd, c) }) do |next_mw, mw|
14
+ ->(cmd, c) { mw.call(container: @container, command: cmd, call: c, next_handler: next_mw) }
15
+ end
16
+ stack.call(command, call)
17
+ end
18
+
19
+ def self.build_command(contract_class, inputs)
20
+ members = contract_class.members
21
+ kwargs = members.to_h do |member|
22
+ [member, inputs[member]]
23
+ end
24
+ contract_class.new(**kwargs)
25
+ end
26
+
27
+ private
28
+
29
+ def execute(command, call)
30
+ handler = @registry.for(command.class)
31
+ handler.call(command, call)
32
+ end
33
+ end
34
+ end
35
+ end
@@ -3,7 +3,7 @@ module Textus
3
3
  class Check
4
4
  class AuditLog < Check
5
5
  def call
6
- path = Textus::Store::Geometry.new(root).audit_log_path
6
+ path = Textus::Store::Layout.new(root).audit_log_path
7
7
  Textus::Port::AuditLog.new(root).verify_integrity.map do |v|
8
8
  {
9
9
  "code" => "audit.parse_error",
@@ -8,12 +8,12 @@ module Textus
8
8
  # verb reported.
9
9
  class GeneratorDrift < Check
10
10
  def call
11
- gen = Textus::Core::Freshness::Evaluator.new(
11
+ detector = Textus::Store::Freshness::DriftDetector.new(
12
12
  manifest: manifest,
13
13
  file_stat: Textus::Port::Storage::FileStat.new,
14
14
  clock: Textus::Port::Clock.new,
15
15
  )
16
- manifest.data.entries.flat_map { |m| gen.drift_rows(m) }.map do |row|
16
+ manifest.data.entries.flat_map { |m| detector.drift_rows(m) }.map do |row|
17
17
  {
18
18
  "code" => "generator_drift",
19
19
  "level" => "warning",
@@ -8,7 +8,7 @@ module Textus
8
8
  # that drift without making `build` scan globally.
9
9
  class OrphanedPublishTargets < Check
10
10
  def call
11
- sdir = Textus::Store::Geometry.new(root).sentinels_root
11
+ sdir = Textus::Store::Layout.new(root).sentinels_root
12
12
  return [] unless File.directory?(sdir)
13
13
 
14
14
  repo_root = File.dirname(root)
@@ -4,7 +4,7 @@ module Textus
4
4
  class SchemaViolations < Check
5
5
  def call
6
6
  result = Textus::Doctor::Validator.new(
7
- reader: ->(key, ctnr, c) { Value::Result.unwrap(Textus::Action::Get.call(container: ctnr, call: c, key: key)) },
7
+ reader: ->(key, ctnr, _c) { Textus::Store::Entry::Reader.from(container: ctnr).read(key) },
8
8
  manifest: @container.manifest,
9
9
  audit_log: @container.audit_log,
10
10
  schema_for: ->(name) { @container.schemas.fetch_or_nil(name) },
@@ -1,11 +1,11 @@
1
1
  module Textus
2
2
  module Doctor
3
3
  class Check
4
- class NotebookSources < Check
4
+ class ScratchpadSources < Check
5
5
  def call
6
6
  issues = []
7
7
  manifest.resolver.enumerate.each do |row|
8
- next unless row[:key].start_with?("notebook.notes.")
8
+ next unless row[:key].start_with?("scratchpad.notes.")
9
9
  next unless row[:path] && File.exist?(row[:path])
10
10
 
11
11
  sources = parse_sources(row[:path])
@@ -13,10 +13,10 @@ module Textus
13
13
  next if raw_entry_exists?(raw_key)
14
14
 
15
15
  issues << {
16
- "code" => "notebook.source_missing",
16
+ "code" => "scratchpad.source_missing",
17
17
  "level" => "warning",
18
18
  "subject" => row[:key],
19
- "message" => "notebook entry '#{row[:key]}' references raw key '#{raw_key}' " \
19
+ "message" => "scratchpad entry '#{row[:key]}' references raw key '#{raw_key}' " \
20
20
  "which does not exist in the store",
21
21
  "fix" => "re-ingest the source: textus ingest ..., or remove the stale sources: entry",
22
22
  }
@@ -33,7 +33,12 @@ module Textus
33
33
  return [] unless match
34
34
 
35
35
  front = YAML.safe_load(match[1])
36
- Array(front&.dig("sources"))
36
+ Array(front&.dig("sources")).filter_map do |s|
37
+ case s
38
+ when String then s
39
+ when Hash then s["key"]
40
+ end
41
+ end
37
42
  rescue StandardError
38
43
  []
39
44
  end
@@ -5,7 +5,7 @@ module Textus
5
5
  def call
6
6
  store = Textus::Port::SentinelStore.new
7
7
  file_stat = Textus::Port::Storage::FileStat.new
8
- dir = Textus::Store::Geometry.new(root).sentinels_root
8
+ dir = Textus::Store::Layout.new(root).sentinels_root
9
9
  return [] unless file_stat.directory?(dir)
10
10
 
11
11
  repo_root = File.dirname(root)
@@ -26,15 +26,17 @@ module Textus
26
26
  protected
27
27
 
28
28
  def root = @container.root
29
- def geometry = @container.geometry
29
+ def geometry = @container.layout
30
30
  def manifest = @container.manifest
31
31
 
32
- # Dispatch a verb through Gate.
33
32
  def dispatch(verb, *args, **kwargs)
34
- klass = Textus::Action::VERBS[verb]
35
- spec = klass.contract if klass.respond_to?(:contract?) && klass.contract?
36
- inputs = spec ? Textus::Gate::Binder.inputs_from_ordered(spec, args, kwargs) : kwargs
37
- @container.gate.dispatch(spec:, inputs:, role: @role)
33
+ contract_class = Textus::VerbRegistry.contract_class_for(verb)
34
+ members = contract_class.members
35
+ command_kwargs = members.each_with_index.to_h { |m, i| [m, args[i] || kwargs[m]] }
36
+ command = contract_class.new(**command_kwargs)
37
+ call = Textus::Value::Call.build(role: @role)
38
+ result = @container.pipeline.dispatch(command, call: call)
39
+ Textus::Value::Result.extract(result)
38
40
  end
39
41
  end
40
42
  end
data/lib/textus/doctor.rb CHANGED
@@ -22,7 +22,7 @@ module Textus
22
22
  Check::ProposalTargets,
23
23
  Check::GeneratorDrift,
24
24
  Check::RawAssetPaths,
25
- Check::NotebookSources,
25
+ Check::ScratchpadSources,
26
26
  Check::StaleReviewedStamp,
27
27
  ].freeze
28
28
 
data/lib/textus/errors.rb CHANGED
@@ -232,6 +232,8 @@ module Textus
232
232
  end
233
233
 
234
234
  class CursorExpired < Error
235
+ JSONRPC_CODE = -32_002
236
+
235
237
  attr_reader :requested, :min_available
236
238
 
237
239
  def initialize(requested:, min_available:)
@@ -25,14 +25,46 @@ module Textus
25
25
  raise NotImplementedError.new("#{name}.nested_glob not implemented")
26
26
  end
27
27
 
28
- def self.validate_path_extension(_path, _nested)
29
- raise NotImplementedError.new("#{name}.validate_path_extension not implemented")
28
+ def self.validate_path_extension(path, nested)
29
+ ext = File.extname(path)
30
+ if nested
31
+ return if ext == ""
32
+
33
+ raise UsageError.new("#{format_name} nested path must not have an extension")
34
+ end
35
+
36
+ return if extensions.include?(ext)
37
+
38
+ raise UsageError.new("#{format_name} format requires '#{extensions.join("' or '")}' path (got #{ext.inspect})")
30
39
  end
31
40
 
32
- def self.enforce_name_match!(_path, _meta)
33
- raise NotImplementedError.new("#{name}.enforce_name_match! not implemented")
41
+ def self.enforce_name_match!(path, meta)
42
+ return unless meta.is_a?(Hash) && meta["name"]
43
+
44
+ ext = extensions.first
45
+ basename = File.basename(path, ext)
46
+ return if meta["name"] == basename
47
+
48
+ raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
34
49
  end
35
50
 
51
+ def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
52
+ raw = File.binread(path)
53
+ parsed = parse(raw, path: path)
54
+ meta = parsed["_meta"] || {}
55
+ return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
56
+
57
+ new_meta = meta.merge("name" => basename)
58
+ File.binwrite(path, serialize(meta: new_meta, body: parsed["body"] || "", content: parsed["content"]))
59
+ true
60
+ end
61
+
62
+ def self.format_name
63
+ name.split("::").last.downcase
64
+ end
65
+
66
+ def self.validate_raw_entry!(_parsed, _lane); end
67
+
36
68
  def self.serialize_for_put(meta:, body:, content:, path:)
37
69
  _ = meta
38
70
  _ = body
@@ -40,10 +72,6 @@ module Textus
40
72
  _ = path
41
73
  raise NotImplementedError.new("#{name}.serialize_for_put not implemented")
42
74
  end
43
-
44
- def self.rewrite_name(_path, _basename)
45
- raise NotImplementedError.new("#{name}.rewrite_name not implemented")
46
- end
47
75
  end
48
76
  end
49
77
  end
@@ -59,27 +59,6 @@ module Textus
59
59
  end
60
60
  end
61
61
 
62
- def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
63
- raw = File.binread(path)
64
- parsed = parse(raw, path: path)
65
- meta = parsed["_meta"]
66
- return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
67
-
68
- new_meta = meta.merge("name" => basename)
69
- File.binwrite(path, serialize(meta: new_meta, body: "", content: parsed["content"]))
70
- true
71
- end
72
-
73
- def self.enforce_name_match!(path, meta)
74
- return unless meta.is_a?(Hash) && meta["name"]
75
-
76
- ext = extensions.first
77
- basename = File.basename(path, ext)
78
- return if meta["name"] == basename
79
-
80
- raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
81
- end
82
-
83
62
  def self.validate_path_extension(path, nested)
84
63
  ext = File.extname(path)
85
64
  if nested
@@ -43,27 +43,6 @@ module Textus
43
43
  [bytes, meta, body.to_s, nil]
44
44
  end
45
45
 
46
- def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
47
- raw = File.binread(path)
48
- parsed = parse(raw, path: path)
49
- meta = parsed["_meta"] || {}
50
- return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
51
-
52
- new_meta = meta.merge("name" => basename)
53
- File.binwrite(path, serialize(meta: new_meta, body: parsed["body"]))
54
- true
55
- end
56
-
57
- def self.enforce_name_match!(path, meta)
58
- return unless meta.is_a?(Hash) && meta["name"]
59
-
60
- ext = extensions.first
61
- basename = File.basename(path, ext)
62
- return if meta["name"] == basename
63
-
64
- raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
65
- end
66
-
67
46
  def self.validate_path_extension(path, _nested)
68
47
  ext = File.extname(path)
69
48
  return if ["", ".md"].include?(ext)
@@ -82,27 +82,6 @@ module Textus
82
82
  end
83
83
  end
84
84
 
85
- def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
86
- raw = File.binread(path)
87
- parsed = parse(raw, path: path)
88
- meta = parsed["_meta"]
89
- return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
90
-
91
- new_meta = meta.merge("name" => basename)
92
- File.binwrite(path, serialize(meta: new_meta, body: "", content: parsed["content"]))
93
- true
94
- end
95
-
96
- def self.enforce_name_match!(path, meta)
97
- return unless meta.is_a?(Hash) && meta["name"]
98
-
99
- ext = extensions.first
100
- basename = File.basename(path, ext)
101
- return if meta["name"] == basename
102
-
103
- raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
104
- end
105
-
106
85
  def self.validate_path_extension(path, nested)
107
86
  ext = File.extname(path)
108
87
  if nested
data/lib/textus/format.rb CHANGED
@@ -9,6 +9,18 @@ module Textus
9
9
  "text" => -> { Format::Text },
10
10
  }.freeze
11
11
 
12
+ # Optional registry for injectable format strategies. Tests or app
13
+ # initializers can set Format.registry = { "custom" => -> { MyFormat }}
14
+ @registry = nil
15
+
16
+ def self.registry=(reg)
17
+ @registry = reg
18
+ end
19
+
20
+ def self.registry
21
+ @registry
22
+ end
23
+
12
24
  EXT_TO_FORMAT = {
13
25
  ".md" => "markdown",
14
26
  ".json" => "json",
@@ -18,7 +30,10 @@ module Textus
18
30
  }.freeze
19
31
 
20
32
  def self.for(format)
21
- STRATEGIES.fetch(format.to_s) { raise Textus::UsageError.new("unknown entry format: #{format.inspect}") }.call
33
+ key = format.to_s
34
+ return registry.fetch(key).call if registry&.key?(key)
35
+
36
+ STRATEGIES.fetch(key) { raise Textus::UsageError.new("unknown entry format: #{format.inspect}") }.call
22
37
  end
23
38
 
24
39
  def self.infer_from_extension(ext)
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Handlers
3
+ module Maintenance
4
+ class BootStore
5
+ def initialize(container:)
6
+ @container = container
7
+ end
8
+
9
+ def call(_command, _call)
10
+ Value::Result.success(Textus::Boot.build(container: @container))
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Handlers
3
+ module Maintenance
4
+ class DoctorStore
5
+ def initialize(container:)
6
+ @container = container
7
+ end
8
+
9
+ def call(command, call)
10
+ Value::Result.success(Textus::Doctor.build(container: @container, checks: command.checks, role: call.role))
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,21 @@
1
+ module Textus
2
+ module Handlers
3
+ module Maintenance
4
+ class DrainStore
5
+ def initialize(container:, job_store:)
6
+ @container = container
7
+ @job_store = job_store
8
+ end
9
+
10
+ def call(_command, call)
11
+ queue = Textus::Store::Jobs::Queue.new(store: @job_store)
12
+ Textus::Store::Jobs::Planner.seed(container: @container, queue: queue, role: call.role)
13
+ queue.reclaim(now: Textus::Port::Clock.new.now)
14
+ summary = Textus::Store::Jobs::Worker.for(container: @container, queue: queue).drain
15
+ Value::Result.success("protocol" => Textus::PROTOCOL, "ok" => summary.failed.zero?,
16
+ "completed" => summary.completed, "failed" => summary.failed)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end