textus 0.8.0 → 0.9.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +245 -0
  3. data/README.md +54 -26
  4. data/SPEC.md +194 -63
  5. data/docs/architecture.md +22 -4
  6. data/docs/conventions.md +24 -17
  7. data/lib/textus/application/context.rb +68 -0
  8. data/lib/textus/application/reads/audit.rb +69 -0
  9. data/lib/textus/application/reads/blame.rb +79 -0
  10. data/lib/textus/application/reads/freshness.rb +77 -0
  11. data/lib/textus/application/reads/get.rb +62 -0
  12. data/lib/textus/application/reads/policy_explain.rb +39 -0
  13. data/lib/textus/application/refresh/all.rb +41 -0
  14. data/lib/textus/application/refresh/orchestrator.rb +68 -0
  15. data/lib/textus/application/refresh/worker.rb +79 -0
  16. data/lib/textus/application/writes/accept.rb +43 -0
  17. data/lib/textus/application/writes/build.rb +24 -0
  18. data/lib/textus/application/writes/delete.rb +37 -0
  19. data/lib/textus/application/writes/publish.rb +25 -0
  20. data/lib/textus/application/writes/put.rb +44 -0
  21. data/lib/textus/builder.rb +27 -14
  22. data/lib/textus/cli/group/policy.rb +11 -0
  23. data/lib/textus/cli/verb/accept.rb +2 -1
  24. data/lib/textus/cli/verb/audit.rb +31 -0
  25. data/lib/textus/cli/verb/blame.rb +17 -0
  26. data/lib/textus/cli/verb/build.rb +2 -1
  27. data/lib/textus/cli/verb/delete.rb +2 -1
  28. data/lib/textus/cli/verb/freshness.rb +17 -0
  29. data/lib/textus/cli/verb/get.rb +8 -1
  30. data/lib/textus/cli/verb/hook_run.rb +3 -3
  31. data/lib/textus/cli/verb/policy_explain.rb +15 -0
  32. data/lib/textus/cli/verb/policy_list.rb +25 -0
  33. data/lib/textus/cli/verb/put.rb +5 -4
  34. data/lib/textus/cli/verb/refresh.rb +2 -1
  35. data/lib/textus/cli/verb/refresh_stale.rb +19 -0
  36. data/lib/textus/cli/verb/reject.rb +15 -0
  37. data/lib/textus/cli.rb +16 -2
  38. data/lib/textus/composition.rb +71 -0
  39. data/lib/textus/doctor/check/handler_allowlist.rb +33 -0
  40. data/lib/textus/doctor/check/intake_registration.rb +46 -0
  41. data/lib/textus/doctor/check/legacy_intake_fields.rb +57 -0
  42. data/lib/textus/doctor/check/policy_ambiguity.rb +49 -0
  43. data/lib/textus/doctor.rb +5 -1
  44. data/lib/textus/domain/action.rb +9 -0
  45. data/lib/textus/domain/freshness/evaluator.rb +30 -0
  46. data/lib/textus/domain/freshness/policy.rb +18 -0
  47. data/lib/textus/domain/freshness/verdict.rb +12 -0
  48. data/lib/textus/domain/outcome.rb +10 -0
  49. data/lib/textus/domain/permission.rb +15 -0
  50. data/lib/textus/domain/policy/handler_allowlist.rb +17 -0
  51. data/lib/textus/domain/policy/matcher.rb +51 -0
  52. data/lib/textus/domain/policy/promote.rb +24 -0
  53. data/lib/textus/domain/policy/refresh.rb +48 -0
  54. data/lib/textus/domain/policy.rb +7 -0
  55. data/lib/textus/hooks/builtin.rb +5 -5
  56. data/lib/textus/hooks/dispatcher.rb +15 -1
  57. data/lib/textus/hooks/dsl.rb +18 -0
  58. data/lib/textus/hooks/registry.rb +12 -5
  59. data/lib/textus/infra/clock.rb +9 -0
  60. data/lib/textus/infra/event_bus.rb +27 -0
  61. data/lib/textus/infra/publisher.rb +73 -0
  62. data/lib/textus/infra/refresh/detached.rb +38 -0
  63. data/lib/textus/infra/refresh/lock.rb +44 -0
  64. data/lib/textus/init.rb +71 -28
  65. data/lib/textus/intro.rb +22 -14
  66. data/lib/textus/manifest/entry.rb +18 -9
  67. data/lib/textus/manifest/policies.rb +83 -0
  68. data/lib/textus/manifest.rb +30 -0
  69. data/lib/textus/proposal.rb +4 -21
  70. data/lib/textus/publisher.rb +4 -69
  71. data/lib/textus/refresh.rb +9 -44
  72. data/lib/textus/store/mover.rb +14 -9
  73. data/lib/textus/store/reader.rb +10 -8
  74. data/lib/textus/store/staleness.rb +4 -16
  75. data/lib/textus/store/validator.rb +46 -20
  76. data/lib/textus/store/view.rb +8 -19
  77. data/lib/textus/store/writer.rb +51 -14
  78. data/lib/textus/store.rb +32 -12
  79. data/lib/textus/version.rb +1 -1
  80. data/lib/textus.rb +1 -0
  81. metadata +46 -2
  82. data/lib/textus/cli/verb/stale.rb +0 -14
@@ -0,0 +1,57 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Doctor
5
+ class Check
6
+ # Scans the raw manifest YAML for entry-level intake.ttl /
7
+ # intake.on_stale / intake.sync_budget_ms keys. Manifest parsing
8
+ # already raises on these in 0.9.2 — this check exists for the case
9
+ # where doctor is run against a problem manifest separately (e.g. CI
10
+ # lint or an exploration session that loads the YAML directly).
11
+ class LegacyIntakeFields < Check
12
+ LEGACY_KEYS = %w[ttl on_stale sync_budget_ms].freeze
13
+
14
+ def call
15
+ out = []
16
+ path = File.join(store.root, "manifest.yaml")
17
+ return out unless File.exist?(path)
18
+
19
+ raw = safe_load(path)
20
+ return out unless raw.is_a?(Hash)
21
+
22
+ Array(raw["entries"]).each do |entry|
23
+ next unless entry.is_a?(Hash)
24
+
25
+ intake = entry["intake"]
26
+ next unless intake.is_a?(Hash)
27
+
28
+ offending = LEGACY_KEYS.select { |k| intake.key?(k) }
29
+ next if offending.empty?
30
+
31
+ out << issue_for(entry["key"], offending)
32
+ end
33
+ out
34
+ end
35
+
36
+ private
37
+
38
+ def safe_load(path)
39
+ YAML.load_file(path)
40
+ rescue StandardError
41
+ nil
42
+ end
43
+
44
+ def issue_for(key, fields)
45
+ {
46
+ "code" => "manifest.legacy_intake_fields",
47
+ "level" => "error",
48
+ "subject" => key.to_s,
49
+ "message" => "entry '#{key}' carries legacy intake.#{fields.join(", intake.")} " \
50
+ "(removed in 0.9.2 — freshness lives in top-level policies:)",
51
+ "fix" => "hand-edit these into a top-level policies: block (see CHANGELOG migration recipe)",
52
+ }
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,49 @@
1
+ module Textus
2
+ module Doctor
3
+ class Check
4
+ # Flags entries whose key is matched by two or more policy blocks of the
5
+ # SAME specificity in the same slot (refresh / handler_allowlist /
6
+ # promote). Ties are non-deterministic in the parser's pick step, so
7
+ # they're a configuration smell — surface them.
8
+ class PolicyAmbiguity < Check
9
+ SLOTS = %i[refresh handler_allowlist promote].freeze
10
+
11
+ def call
12
+ out = []
13
+ policies = store.manifest.policies
14
+ store.manifest.entries.each do |mentry|
15
+ matches = policies.explain(mentry.key)
16
+ next if matches.length < 2
17
+
18
+ SLOTS.each { |slot| out.concat(ambiguities_for(mentry, slot, matches)) }
19
+ end
20
+ out
21
+ end
22
+
23
+ private
24
+
25
+ def ambiguities_for(mentry, slot, matches)
26
+ carriers = matches.select { |b| b.public_send(slot) }
27
+ return [] if carriers.length < 2
28
+
29
+ by_specificity = carriers.group_by { |b| Textus::Domain::Policy::Matcher.specificity(b.match) }
30
+ tied = by_specificity.values.select { |group| group.length > 1 }
31
+ tied.map { |group| issue_for(mentry, slot, group) }
32
+ end
33
+
34
+ def issue_for(mentry, slot, group)
35
+ globs = group.map(&:match).sort
36
+ {
37
+ "code" => "policy.ambiguity",
38
+ "level" => "warning",
39
+ "subject" => mentry.key,
40
+ "message" => "entry '#{mentry.key}' matches #{group.length} policy blocks at the same " \
41
+ "specificity for #{slot}: #{globs.join(", ")}",
42
+ "fix" => "narrow one of the conflicting match: globs in .textus/manifest.yaml so a single " \
43
+ "block wins for this key",
44
+ }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
data/lib/textus/doctor.rb CHANGED
@@ -13,11 +13,15 @@ module Textus
13
13
  Check::Schemas,
14
14
  Check::Templates,
15
15
  Check::Hooks,
16
+ Check::IntakeRegistration,
16
17
  Check::IllegalKeys,
17
18
  Check::Sentinels,
18
19
  Check::AuditLog,
19
20
  Check::UnownedSchemaFields,
20
21
  Check::SchemaViolations,
22
+ Check::PolicyAmbiguity,
23
+ Check::HandlerAllowlist,
24
+ Check::LegacyIntakeFields,
21
25
  ].freeze
22
26
 
23
27
  ALL_CHECKS = CHECKS.map(&:name_key).freeze
@@ -67,7 +71,7 @@ module Textus
67
71
  rescue StandardError => e
68
72
  out << fail_issue(name, code: "doctor_check.failed",
69
73
  message: "#{e.class}: #{e.message}",
70
- fix: "fix the doctor_check block in .textus/extensions/")
74
+ fix: "fix the :check hook in .textus/hooks/")
71
75
  end
72
76
  end
73
77
  out
@@ -0,0 +1,9 @@
1
+ module Textus
2
+ module Domain
3
+ module Action
4
+ Return = Data.define
5
+ RefreshSync = Data.define
6
+ RefreshTimed = Data.define(:budget_ms)
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,30 @@
1
+ require "time"
2
+
3
+ module Textus
4
+ module Domain
5
+ module Freshness
6
+ module Evaluator
7
+ module_function
8
+
9
+ def call(policy, envelope, now:)
10
+ return Verdict.fresh if policy.ttl_seconds.nil?
11
+
12
+ last_str = envelope.dig("_meta", "last_refreshed_at")
13
+ return Verdict.stale("never refreshed") if last_str.nil?
14
+
15
+ last = begin
16
+ Time.parse(last_str.to_s)
17
+ rescue ArgumentError, TypeError
18
+ nil
19
+ end
20
+ return Verdict.stale("unparseable last_refreshed_at: #{last_str.inspect}") if last.nil?
21
+
22
+ age = now - last
23
+ return Verdict.fresh if age <= policy.ttl_seconds
24
+
25
+ Verdict.stale("ttl exceeded (age=#{age.to_i}s, ttl=#{policy.ttl_seconds}s)")
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,18 @@
1
+ module Textus
2
+ module Domain
3
+ module Freshness
4
+ Policy = Data.define(:ttl_seconds, :on_stale, :sync_budget_ms) do
5
+ def decide(verdict)
6
+ return Action::Return.new if verdict.fresh?
7
+
8
+ case on_stale
9
+ when :warn then Action::Return.new
10
+ when :sync then Action::RefreshSync.new
11
+ when :timed_sync then Action::RefreshTimed.new(budget_ms: sync_budget_ms)
12
+ else Action::Return.new
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,12 @@
1
+ module Textus
2
+ module Domain
3
+ module Freshness
4
+ Verdict = Data.define(:fresh, :reason) do
5
+ def self.fresh = new(fresh: true, reason: nil)
6
+ def self.stale(reason) = new(fresh: false, reason: reason)
7
+ def fresh? = fresh
8
+ def stale? = !fresh
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,10 @@
1
+ module Textus
2
+ module Domain
3
+ module Outcome
4
+ Skipped = Data.define
5
+ Refreshed = Data.define(:envelope)
6
+ Detached = Data.define
7
+ Failed = Data.define(:error)
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Domain
3
+ Permission = Data.define(:zone, :writable_by, :readable_by) do
4
+ def allows_write?(role)
5
+ writable_by.include?(role.to_s)
6
+ end
7
+
8
+ def allows_read?(role)
9
+ return true if readable_by == :all
10
+
11
+ readable_by.include?(role.to_s)
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Domain
3
+ module Policy
4
+ class HandlerAllowlist
5
+ attr_reader :handlers
6
+
7
+ def initialize(handlers:)
8
+ @handlers = Array(handlers).map(&:to_s).freeze
9
+ end
10
+
11
+ def allows?(handler)
12
+ @handlers.include?(handler.to_s)
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,51 @@
1
+ module Textus
2
+ module Domain
3
+ module Policy
4
+ module Matcher
5
+ module_function
6
+
7
+ def matches?(glob, key)
8
+ glob_segs = glob.split(".")
9
+ key_segs = key.split(".")
10
+ consume(glob_segs, key_segs)
11
+ end
12
+
13
+ def specificity(glob)
14
+ glob.split(".").reduce(0) do |s, seg|
15
+ s + case seg
16
+ when "**" then 0
17
+ when "*" then 1
18
+ else 10
19
+ end
20
+ end
21
+ end
22
+
23
+ def pick_most_specific(globs, key:)
24
+ matching = globs.select { |g| matches?(g, key) }
25
+ return nil if matching.empty?
26
+
27
+ matching.max_by { |g| [specificity(g), -g.length, g] }
28
+ end
29
+
30
+ def self.consume(glob_segs, key_segs)
31
+ return key_segs.empty? if glob_segs.empty?
32
+
33
+ head = glob_segs.first
34
+ rest = glob_segs[1..]
35
+
36
+ if head == "**"
37
+ return true if rest.empty?
38
+
39
+ (0..key_segs.length).any? { |i| consume(rest, key_segs[i..]) }
40
+ elsif key_segs.empty?
41
+ false
42
+ elsif head == "*" || head == key_segs.first
43
+ consume(rest, key_segs[1..])
44
+ else
45
+ false
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,24 @@
1
+ module Textus
2
+ module Domain
3
+ module Policy
4
+ class Promote
5
+ KNOWN = %i[schema_valid human_accept].freeze
6
+ attr_reader :requires
7
+
8
+ def initialize(requires:)
9
+ syms = Array(requires).map { |r| r.to_s.to_sym }
10
+ unknown = syms - KNOWN
11
+ unless unknown.empty?
12
+ raise Textus::UsageError.new("unknown promote requirement: #{unknown.first.inspect} (known: #{KNOWN.join(", ")})")
13
+ end
14
+
15
+ @requires = syms
16
+ end
17
+
18
+ def demands?(req)
19
+ @requires.include?(req)
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,48 @@
1
+ module Textus
2
+ module Domain
3
+ module Policy
4
+ class Refresh
5
+ attr_reader :ttl, :on_stale, :sync_budget_ms
6
+
7
+ def initialize(ttl:, on_stale:, sync_budget_ms:)
8
+ on_stale_sym = on_stale.is_a?(Symbol) ? on_stale : on_stale.to_s.to_sym
9
+ unless ALLOWED_ON_STALE.include?(on_stale_sym)
10
+ raise Textus::UsageError.new(
11
+ "on_stale must be one of #{ALLOWED_ON_STALE.join(", ")} (got #{on_stale.inspect})",
12
+ )
13
+ end
14
+
15
+ @ttl = ttl
16
+ @on_stale = on_stale_sym
17
+ @sync_budget_ms = sync_budget_ms
18
+ end
19
+
20
+ def ttl_seconds
21
+ return nil if @ttl.nil?
22
+
23
+ str = @ttl.to_s.strip
24
+ return str.to_i if str.match?(/\A\d+\z/)
25
+
26
+ m = str.match(/\A(\d+)\s*([smhd])\z/)
27
+ return nil unless m
28
+
29
+ n = m[1].to_i
30
+ case m[2]
31
+ when "s" then n
32
+ when "m" then n * 60
33
+ when "h" then n * 3600
34
+ when "d" then n * 86_400
35
+ end
36
+ end
37
+
38
+ def to_freshness_policy
39
+ Textus::Domain::Freshness::Policy.new(
40
+ ttl_seconds: ttl_seconds,
41
+ on_stale: @on_stale,
42
+ sync_budget_ms: @sync_budget_ms,
43
+ )
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,7 @@
1
+ module Textus
2
+ module Domain
3
+ module Policy
4
+ ALLOWED_ON_STALE = %i[warn sync timed_sync].freeze
5
+ end
6
+ end
7
+ end
@@ -8,21 +8,21 @@ module Textus
8
8
  module Builtin
9
9
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
10
10
  def self.register_all
11
- Textus.hook(:fetch, :json) do |store:, config:, args:|
11
+ Textus.hook(:intake, :json) do |store:, config:, args:|
12
12
  _ = store
13
13
  _ = args
14
14
  data = JSON.parse(config["bytes"].to_s)
15
15
  { _meta: {}, body: YAML.dump(data) }
16
16
  end
17
17
 
18
- Textus.hook(:fetch, :csv) do |store:, config:, args:|
18
+ Textus.hook(:intake, :csv) do |store:, config:, args:|
19
19
  _ = store
20
20
  _ = args
21
21
  rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
22
22
  { _meta: {}, body: YAML.dump(rows) }
23
23
  end
24
24
 
25
- Textus.hook(:fetch, :"markdown-links") do |store:, config:, args:|
25
+ Textus.hook(:intake, :"markdown-links") do |store:, config:, args:|
26
26
  _ = store
27
27
  _ = args
28
28
  links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
@@ -31,7 +31,7 @@ module Textus
31
31
  { _meta: {}, body: YAML.dump(links) }
32
32
  end
33
33
 
34
- Textus.hook(:fetch, :"ical-events") do |store:, config:, args:|
34
+ Textus.hook(:intake, :"ical-events") do |store:, config:, args:|
35
35
  _ = store
36
36
  _ = args
37
37
  events = []
@@ -50,7 +50,7 @@ module Textus
50
50
  { _meta: {}, body: YAML.dump(events) }
51
51
  end
52
52
 
53
- Textus.hook(:fetch, :rss) do |store:, config:, args:|
53
+ Textus.hook(:intake, :rss) do |store:, config:, args:|
54
54
  _ = store
55
55
  _ = args
56
56
  doc = REXML::Document.new(config["bytes"].to_s)
@@ -28,7 +28,8 @@ module Textus
28
28
  private
29
29
 
30
30
  def invoke(event, sub, key, kwargs)
31
- Timeout.timeout(HOOK_TIMEOUT_SECONDS) { sub[:callable].call(**kwargs) }
31
+ accepted = filter_kwargs(sub[:callable], kwargs)
32
+ Timeout.timeout(HOOK_TIMEOUT_SECONDS) { sub[:callable].call(**accepted) }
32
33
  rescue StandardError => e
33
34
  extras = { "event" => event.to_s, "hook" => sub[:name].to_s, "error" => "#{e.class}: #{e.message}" }
34
35
  extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
@@ -39,6 +40,19 @@ module Textus
39
40
  )
40
41
  end
41
42
 
43
+ # Passes only the kwargs a hook block declares. Lets us extend event
44
+ # payloads (e.g., correlation_id) without breaking hooks written against
45
+ # the old signature.
46
+ def filter_kwargs(callable, kwargs)
47
+ params = callable.parameters
48
+ return kwargs if params.any? { |type, _| type == :keyrest }
49
+
50
+ accepted = params.each_with_object([]) do |(type, name), acc|
51
+ acc << name if %i[key keyreq].include?(type)
52
+ end
53
+ kwargs.slice(*accepted)
54
+ end
55
+
42
56
  def match?(globs, key)
43
57
  return true if globs.nil?
44
58
 
@@ -0,0 +1,18 @@
1
+ module Textus
2
+ module Hooks
3
+ module Dsl
4
+ EVENTS = %i[
5
+ intake reduce check
6
+ put deleted refreshed built published accepted
7
+ mv reject loaded
8
+ refresh_began refresh_failed refresh_detached
9
+ ].freeze
10
+
11
+ EVENTS.each do |event|
12
+ define_method(event) do |name, **opts, &blk|
13
+ Loader.current_registry.register(event, name, **opts, &blk)
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -3,16 +3,23 @@ module Textus
3
3
  class Registry
4
4
  EVENTS = {
5
5
  # RPC: exactly 1 handler per name; return value flows into store; failure aborts.
6
- fetch: { mode: :rpc, args: %i[store config args] },
6
+ intake: { mode: :rpc, args: %i[store config args] },
7
7
  reduce: { mode: :rpc, args: %i[store rows config] },
8
8
  check: { mode: :rpc, args: %i[store] },
9
9
 
10
10
  # Pub-sub: 0..N handlers per event; return discarded; failure logged to audit.
11
11
  put: { mode: :pubsub, args: %i[store key envelope] },
12
- delete: { mode: :pubsub, args: %i[store key] },
13
- refresh: { mode: :pubsub, args: %i[store key envelope change] },
14
- build: { mode: :pubsub, args: %i[store key envelope sources] },
15
- accept: { mode: :pubsub, args: %i[store key target_key] },
12
+ deleted: { mode: :pubsub, args: %i[store key] },
13
+ refreshed: { mode: :pubsub, args: %i[store key envelope change] },
14
+ built: { mode: :pubsub, args: %i[store key envelope sources] },
15
+ accepted: { mode: :pubsub, args: %i[store key target_key] },
16
+ published: { mode: :pubsub, args: %i[store key envelope source target] },
17
+ mv: { mode: :pubsub, args: %i[store key from_key to_key envelope] },
18
+ reject: { mode: :pubsub, args: %i[store key target_key] },
19
+ loaded: { mode: :pubsub, args: %i[store] },
20
+ refresh_began: { mode: :pubsub, args: %i[store key mode] },
21
+ refresh_failed: { mode: :pubsub, args: %i[store key error_class error_message] },
22
+ refresh_detached: { mode: :pubsub, args: %i[store key started_at budget_ms] },
16
23
  }.freeze
17
24
 
18
25
  def initialize(dispatcher: nil)
@@ -0,0 +1,9 @@
1
+ module Textus
2
+ module Infra
3
+ module Clock
4
+ module_function
5
+
6
+ def now = Time.now
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,27 @@
1
+ module Textus
2
+ module Infra
3
+ class EventBus
4
+ def initialize(registry:)
5
+ @registry = registry
6
+ end
7
+
8
+ def publish(event, **payload)
9
+ @registry.pubsub_handlers(event).each do |entry|
10
+ next unless entry[:keys].nil? || matches?(entry[:keys], payload[:key])
11
+
12
+ entry[:callable].call(**payload)
13
+ rescue StandardError => e
14
+ warn "[textus] pub-sub handler #{entry[:name].inspect} for #{event.inspect} failed: #{e.class}: #{e.message}"
15
+ end
16
+ end
17
+
18
+ private
19
+
20
+ def matches?(globs, key)
21
+ return true if key.nil?
22
+
23
+ Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
24
+ end
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,73 @@
1
+ require "json"
2
+ require "digest"
3
+ require "fileutils"
4
+
5
+ module Textus
6
+ module Infra
7
+ # Publishes built artifacts from the store to repo-relative consumer paths.
8
+ # Publish = copy + sentinel. The in-store file is already the consumer-shaped
9
+ # artifact; no parsing or stripping. Sentinels live under
10
+ # `<store_root>/sentinels/` and mirror the target's repo-relative layout so
11
+ # consumer directories aren't polluted with `.textus-managed.json` siblings.
12
+ module Publisher
13
+ SENTINEL_SUFFIX = ".textus-managed.json".freeze
14
+ SENTINEL_DIR = "sentinels".freeze
15
+
16
+ def self.publish(source:, target:, store_root:)
17
+ FileUtils.mkdir_p(File.dirname(target))
18
+ refuse_if_unmanaged(target, store_root)
19
+ File.delete(target) if File.symlink?(target)
20
+ FileUtils.cp(source, target)
21
+ write_sentinel(target, store_root: store_root, source: source)
22
+ cleanup_legacy_sentinel(target)
23
+ end
24
+
25
+ def self.refuse_if_unmanaged(target, store_root)
26
+ return unless File.exist?(target) || File.symlink?(target)
27
+ return if managed?(target, store_root)
28
+
29
+ raise PublishError.new("refusing to clobber unmanaged file at #{target}", target: target)
30
+ end
31
+
32
+ def self.managed?(target, store_root)
33
+ File.exist?(sentinel_path(target, store_root)) || File.exist?(legacy_sentinel_path(target))
34
+ end
35
+
36
+ def self.write_sentinel(target, store_root:, source:)
37
+ path = sentinel_path(target, store_root)
38
+ FileUtils.mkdir_p(File.dirname(path))
39
+ File.write(path, JSON.generate(
40
+ "source" => source,
41
+ "target" => target,
42
+ "sha256" => Digest::SHA256.hexdigest(File.binread(target)),
43
+ "mode" => "copy",
44
+ ))
45
+ end
46
+
47
+ # Sentinel layout: <store_root>/sentinels/<target_rel_to_repo>.textus-managed.json
48
+ # The full target extension is preserved so a marketplace.json and
49
+ # marketplace.yaml don't collide.
50
+ def self.sentinel_path(target, store_root)
51
+ repo_root = File.dirname(store_root)
52
+ rel = relative_to(target, repo_root) || File.basename(target)
53
+ File.join(store_root, SENTINEL_DIR, rel + SENTINEL_SUFFIX)
54
+ end
55
+
56
+ def self.legacy_sentinel_path(target)
57
+ target + SENTINEL_SUFFIX
58
+ end
59
+
60
+ def self.cleanup_legacy_sentinel(target)
61
+ FileUtils.rm_f(legacy_sentinel_path(target))
62
+ end
63
+
64
+ def self.relative_to(path, base)
65
+ path = File.expand_path(path)
66
+ base = File.expand_path(base)
67
+ return nil unless path.start_with?(base + File::SEPARATOR)
68
+
69
+ path[(base.length + 1)..]
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,38 @@
1
+ module Textus
2
+ module Infra
3
+ module Refresh
4
+ module Detached
5
+ module_function
6
+
7
+ def supported?
8
+ Process.respond_to?(:fork)
9
+ end
10
+
11
+ def spawn(store_root:, key:)
12
+ return nil unless supported?
13
+
14
+ pid = Process.fork do
15
+ $stdin.close
16
+ $stdout.reopen(File::NULL, "w")
17
+ $stderr.reopen(File::NULL, "w")
18
+
19
+ lock = Textus::Infra::Refresh::Lock.new(root: store_root, key: key)
20
+ exit(0) unless lock.try_acquire
21
+
22
+ begin
23
+ store = Textus::Store.new(store_root)
24
+ Textus::Refresh.call(store, key, as: "script")
25
+ rescue StandardError
26
+ # Already logged via :refresh_failed; exit cleanly.
27
+ ensure
28
+ lock.release
29
+ exit(0)
30
+ end
31
+ end
32
+ Process.detach(pid)
33
+ pid
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end