textus 0.8.1 → 0.10.0

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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +329 -0
  3. data/README.md +50 -22
  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 +44 -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 +69 -0
  15. data/lib/textus/application/refresh/worker.rb +79 -0
  16. data/lib/textus/application/writes/accept.rb +44 -0
  17. data/lib/textus/application/writes/build.rb +116 -0
  18. data/lib/textus/application/writes/delete.rb +36 -0
  19. data/lib/textus/application/writes/publish.rb +25 -0
  20. data/lib/textus/application/writes/put.rb +43 -0
  21. data/lib/textus/builder/pipeline.rb +1 -1
  22. data/lib/textus/builder/renderer/json.rb +1 -1
  23. data/lib/textus/builder/renderer/markdown.rb +1 -1
  24. data/lib/textus/builder/renderer/text.rb +1 -1
  25. data/lib/textus/builder/renderer/yaml.rb +1 -1
  26. data/lib/textus/builder/renderer.rb +1 -1
  27. data/lib/textus/cli/group/policy.rb +11 -0
  28. data/lib/textus/cli/verb/accept.rb +2 -2
  29. data/lib/textus/cli/verb/audit.rb +30 -0
  30. data/lib/textus/cli/verb/blame.rb +16 -0
  31. data/lib/textus/cli/verb/build.rb +2 -1
  32. data/lib/textus/cli/verb/delete.rb +2 -2
  33. data/lib/textus/cli/verb/freshness.rb +16 -0
  34. data/lib/textus/cli/verb/get.rb +7 -1
  35. data/lib/textus/cli/verb/hook_run.rb +4 -4
  36. data/lib/textus/cli/verb/mv.rb +1 -2
  37. data/lib/textus/cli/verb/policy_explain.rb +14 -0
  38. data/lib/textus/cli/verb/policy_list.rb +25 -0
  39. data/lib/textus/cli/verb/put.rb +10 -8
  40. data/lib/textus/cli/verb/refresh.rb +2 -2
  41. data/lib/textus/cli/verb/refresh_stale.rb +18 -0
  42. data/lib/textus/cli/verb/reject.rb +14 -0
  43. data/lib/textus/cli/verb.rb +14 -0
  44. data/lib/textus/cli.rb +16 -2
  45. data/lib/textus/composition.rb +72 -0
  46. data/lib/textus/doctor/check/handler_allowlist.rb +33 -0
  47. data/lib/textus/doctor/check/intake_registration.rb +46 -0
  48. data/lib/textus/doctor/check/legacy_intake_fields.rb +57 -0
  49. data/lib/textus/doctor/check/policy_ambiguity.rb +49 -0
  50. data/lib/textus/doctor.rb +7 -1
  51. data/lib/textus/domain/action.rb +9 -0
  52. data/lib/textus/domain/freshness/evaluator.rb +30 -0
  53. data/lib/textus/domain/freshness/policy.rb +18 -0
  54. data/lib/textus/domain/freshness/verdict.rb +12 -0
  55. data/lib/textus/domain/outcome.rb +10 -0
  56. data/lib/textus/domain/permission.rb +15 -0
  57. data/lib/textus/domain/policy/handler_allowlist.rb +17 -0
  58. data/lib/textus/domain/policy/matcher.rb +51 -0
  59. data/lib/textus/domain/policy/promote.rb +24 -0
  60. data/lib/textus/domain/policy/refresh.rb +48 -0
  61. data/lib/textus/domain/policy.rb +7 -0
  62. data/lib/textus/hooks/builtin.rb +5 -5
  63. data/lib/textus/hooks/dispatcher.rb +15 -1
  64. data/lib/textus/hooks/dsl.rb +18 -0
  65. data/lib/textus/hooks/registry.rb +12 -5
  66. data/lib/textus/infra/clock.rb +9 -0
  67. data/lib/textus/infra/event_bus.rb +27 -0
  68. data/lib/textus/infra/publisher.rb +73 -0
  69. data/lib/textus/infra/refresh/detached.rb +38 -0
  70. data/lib/textus/infra/refresh/lock.rb +44 -0
  71. data/lib/textus/init.rb +71 -28
  72. data/lib/textus/intro.rb +17 -14
  73. data/lib/textus/manifest/entry.rb +39 -13
  74. data/lib/textus/manifest/policies.rb +83 -0
  75. data/lib/textus/manifest.rb +30 -11
  76. data/lib/textus/projection.rb +1 -1
  77. data/lib/textus/proposal.rb +4 -21
  78. data/lib/textus/refresh.rb +9 -45
  79. data/lib/textus/store/mover.rb +14 -9
  80. data/lib/textus/store/reader.rb +10 -8
  81. data/lib/textus/store/staleness.rb +5 -17
  82. data/lib/textus/store/validator.rb +46 -20
  83. data/lib/textus/store/writer.rb +51 -14
  84. data/lib/textus/store.rb +30 -10
  85. data/lib/textus/version.rb +1 -1
  86. data/lib/textus.rb +1 -0
  87. metadata +46 -5
  88. data/lib/textus/builder.rb +0 -86
  89. data/lib/textus/cli/verb/stale.rb +0 -14
  90. data/lib/textus/publisher.rb +0 -71
  91. data/lib/textus/store/view.rb +0 -29
@@ -0,0 +1,69 @@
1
+ require "json"
2
+ require "time"
3
+
4
+ module Textus
5
+ module Application
6
+ module Reads
7
+ # Queries .textus/audit.log. Filters: key, zone, role, verb, since,
8
+ # correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
9
+ # rows produce nil and are skipped).
10
+ class Audit
11
+ def initialize(ctx:)
12
+ @ctx = ctx
13
+ @log_path = File.join(@ctx.store.root, "audit.log")
14
+ end
15
+
16
+ # rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
17
+ def call(key: nil, zone: nil, role: nil, verb: nil, since: nil, correlation_id: nil, limit: nil)
18
+ return [] unless File.exist?(@log_path)
19
+
20
+ rows = []
21
+ File.foreach(@log_path) do |line|
22
+ parsed = parse_row(line.chomp)
23
+ next unless parsed
24
+ next if key && parsed["key"] != key
25
+ next if role && parsed["role"] != role
26
+ next if verb && parsed["verb"] != verb
27
+ next if zone && !key_in_zone?(parsed["key"], zone)
28
+ next if since && (parsed["ts"].nil? || Time.parse(parsed["ts"]) < since)
29
+ next if correlation_id && parsed.dig("extras", "correlation_id") != correlation_id
30
+
31
+ rows << parsed
32
+ break if limit && rows.length >= limit
33
+ end
34
+ rows
35
+ end
36
+ # rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
37
+
38
+ # Accepts ISO8601 ("2026-01-15", "2026-01-15T10:00:00Z") or a relative
39
+ # offset matching /\A(\d+)([smhd])\z/. Returns nil for unparseable input.
40
+ def self.parse_since(str, now: Time.now.utc)
41
+ return nil if str.nil? || str.empty?
42
+ return Time.parse(str) if str =~ /\A\d{4}-\d{2}-\d{2}/
43
+
44
+ m = str.match(/\A(\d+)([smhd])\z/) or return nil
45
+ mult = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }[m[2]]
46
+ now - (m[1].to_i * mult)
47
+ end
48
+
49
+ private
50
+
51
+ def parse_row(line)
52
+ return nil if line.empty?
53
+ return nil unless line.start_with?("{")
54
+
55
+ JSON.parse(line)
56
+ rescue JSON::ParserError
57
+ nil
58
+ end
59
+
60
+ def key_in_zone?(key, zone)
61
+ mentry, = @ctx.store.manifest.resolve(key)
62
+ mentry && mentry.zone == zone
63
+ rescue Textus::Error
64
+ false
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,79 @@
1
+ require "open3"
2
+
3
+ module Textus
4
+ module Application
5
+ module Reads
6
+ # For one key, joins every audit-log row with the git commit (sha,
7
+ # author, date, subject) that introduced the file state at that audit
8
+ # row. Falls back to `git => nil` when not in a git repo or when the
9
+ # file is untracked.
10
+ class Blame
11
+ def initialize(ctx:)
12
+ @ctx = ctx
13
+ end
14
+
15
+ def call(key:, limit: nil)
16
+ audit_rows = Textus::Composition.audit(@ctx).call(key: key, limit: limit)
17
+ path = resolve_path(key)
18
+ return audit_rows.map { |r| r.merge("git" => nil) } unless git_tracked?(path)
19
+
20
+ audit_rows.map { |r| r.merge("git" => git_commit_at(path, timestamp: r["ts"])) }
21
+ end
22
+
23
+ private
24
+
25
+ def resolve_path(key)
26
+ mentry, path, = @ctx.store.manifest.resolve(key)
27
+ # Nested entries resolve to a file under the entry path; leaf entries
28
+ # already have a fully-resolved path. Either way `path` is what git
29
+ # needs to know about.
30
+ path || Textus::Key::Path.resolve(@ctx.store.manifest, mentry)
31
+ rescue Textus::Error
32
+ nil
33
+ end
34
+
35
+ def git_tracked?(path)
36
+ return false if path.nil?
37
+ return false unless File.exist?(path)
38
+ return false unless git_repo?
39
+
40
+ _out, _err, status = Open3.capture3(
41
+ "git", "ls-files", "--error-unmatch", path,
42
+ chdir: @ctx.store.root
43
+ )
44
+ status.success?
45
+ rescue Errno::ENOENT
46
+ false
47
+ end
48
+
49
+ def git_repo?
50
+ # Walk up from store root to find a .git directory.
51
+ dir = @ctx.store.root
52
+ loop do
53
+ return true if File.directory?(File.join(dir, ".git"))
54
+
55
+ parent = File.dirname(dir)
56
+ return false if parent == dir
57
+
58
+ dir = parent
59
+ end
60
+ end
61
+
62
+ def git_commit_at(path, timestamp:)
63
+ args = ["git", "log", "-1"]
64
+ args << "--before=#{timestamp}" if timestamp
65
+ args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
66
+ out, _err, status = Open3.capture3(*args, chdir: @ctx.store.root)
67
+ return nil unless status.success?
68
+
69
+ sha, author, date, subject = out.strip.split("\t", 4)
70
+ return nil if sha.nil? || sha.empty?
71
+
72
+ { "sha" => sha, "author" => author, "date" => date, "subject" => subject }
73
+ rescue Errno::ENOENT
74
+ nil
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,77 @@
1
+ require "time"
2
+
3
+ module Textus
4
+ module Application
5
+ module Reads
6
+ # Per-entry freshness report. Walks every entry declared in the manifest,
7
+ # consults `policies_for(key)` for a refresh policy, and reports the
8
+ # current status. Status is one of :fresh, :stale, :never_refreshed, or
9
+ # :no_policy.
10
+ class Freshness
11
+ def initialize(ctx:, evaluator: Textus::Domain::Freshness::Evaluator)
12
+ @ctx = ctx
13
+ @evaluator = evaluator
14
+ end
15
+
16
+ def call(prefix: nil, zone: nil)
17
+ rows = []
18
+ @ctx.store.manifest.entries.each do |mentry|
19
+ next if prefix && !mentry.key.start_with?(prefix)
20
+ next if zone && mentry.zone != zone
21
+
22
+ rows << row_for(mentry)
23
+ end
24
+ rows
25
+ end
26
+
27
+ private
28
+
29
+ def row_for(mentry)
30
+ set = @ctx.store.manifest.policies_for(mentry.key)
31
+ refresh = set.refresh
32
+ envelope = safe_get(mentry.key)
33
+ last = envelope&.dig("_meta", "last_refreshed_at")
34
+
35
+ return base_row(mentry, last).merge(status: :no_policy) if refresh.nil?
36
+
37
+ fp = refresh.to_freshness_policy
38
+ verdict = @evaluator.call(fp, envelope || {}, now: @ctx.now)
39
+ status = if verdict.fresh? then :fresh
40
+ elsif last.nil? then :never_refreshed
41
+ else :stale
42
+ end
43
+
44
+ base_row(mentry, last).merge(
45
+ ttl_seconds: fp.ttl_seconds,
46
+ on_stale: fp.on_stale,
47
+ status: status,
48
+ next_due_at: next_due(last, fp.ttl_seconds),
49
+ )
50
+ end
51
+
52
+ def base_row(mentry, last)
53
+ {
54
+ key: mentry.key,
55
+ zone: mentry.zone,
56
+ last_refreshed_at: last,
57
+ age_seconds: last ? (@ctx.now - Time.parse(last)).to_i : nil,
58
+ }
59
+ end
60
+
61
+ # Returns the raw envelope or nil. Nested entries (mentry.key is a
62
+ # prefix, not a leaf) and missing files both resolve to nil.
63
+ def safe_get(key)
64
+ @ctx.store.reader.read_raw_envelope(key)
65
+ rescue Textus::Error
66
+ nil
67
+ end
68
+
69
+ def next_due(last, ttl)
70
+ return nil if last.nil? || ttl.nil?
71
+
72
+ (Time.parse(last) + ttl).utc.iso8601
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,62 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ class Get
5
+ def initialize(ctx:, orchestrator:, evaluator: Textus::Domain::Freshness::Evaluator)
6
+ @ctx = ctx
7
+ @orchestrator = orchestrator
8
+ @evaluator = evaluator
9
+ end
10
+
11
+ def call(key)
12
+ envelope = @ctx.store.reader.read_raw_envelope(key)
13
+ return nil if envelope.nil?
14
+
15
+ policy_set = @ctx.store.manifest.policies_for(key)
16
+ refresh_policy = policy_set.refresh
17
+ return annotate_fresh(envelope) if refresh_policy.nil?
18
+
19
+ policy = refresh_policy.to_freshness_policy
20
+ verdict = @evaluator.call(policy, envelope, now: @ctx.now)
21
+
22
+ return annotate(envelope, verdict, refreshing: false) if verdict.fresh?
23
+
24
+ action = policy.decide(verdict)
25
+ outcome = @orchestrator.execute(action, key: key)
26
+
27
+ case outcome
28
+ when Textus::Domain::Outcome::Skipped
29
+ annotate(envelope, verdict, refreshing: false)
30
+ when Textus::Domain::Outcome::Refreshed
31
+ fresh_verdict = @evaluator.call(policy, outcome.envelope, now: @ctx.now)
32
+ annotate(outcome.envelope, fresh_verdict, refreshing: false)
33
+ when Textus::Domain::Outcome::Detached
34
+ annotate(envelope, verdict, refreshing: true)
35
+ when Textus::Domain::Outcome::Failed
36
+ annotate(envelope, verdict, refreshing: false, refresh_error: outcome.error.message)
37
+ end
38
+ end
39
+
40
+ private
41
+
42
+ def annotate(envelope, verdict, refreshing:, refresh_error: nil)
43
+ envelope = envelope.dup
44
+ envelope["stale"] = verdict.stale?
45
+ envelope["stale_reason"] = verdict.reason
46
+ envelope["refreshing"] = refreshing
47
+ envelope["refresh_error"] = refresh_error if refresh_error
48
+ envelope
49
+ end
50
+
51
+ # No refresh policy applies to this key — treat as fresh, skip evaluation/orchestration.
52
+ def annotate_fresh(envelope)
53
+ envelope = envelope.dup
54
+ envelope["stale"] = false
55
+ envelope["stale_reason"] = nil
56
+ envelope["refreshing"] = false
57
+ envelope
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,39 @@
1
+ module Textus
2
+ module Application
3
+ module Reads
4
+ # For one key, surface every matching policy block along with the
5
+ # per-slot effective value (which loses ties win-by-specificity).
6
+ class PolicyExplain
7
+ def initialize(ctx:)
8
+ @ctx = ctx
9
+ end
10
+
11
+ def call(key:)
12
+ policies = @ctx.store.manifest.policies
13
+ matching = policies.explain(key)
14
+ winners = policies.for(key)
15
+
16
+ {
17
+ key: key,
18
+ matched_blocks: matching.map do |b|
19
+ {
20
+ match: b.match,
21
+ refresh: !b.refresh.nil?,
22
+ handler_allowlist: !b.handler_allowlist.nil?,
23
+ promote: !b.promote.nil?,
24
+ }
25
+ end,
26
+ effective: {
27
+ refresh: winners.refresh && {
28
+ ttl_seconds: winners.refresh.ttl_seconds,
29
+ on_stale: winners.refresh.on_stale,
30
+ },
31
+ handler_allowlist: winners.handler_allowlist&.handlers,
32
+ promote_requires: winners.promote&.requires,
33
+ },
34
+ }
35
+ end
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,41 @@
1
+ module Textus
2
+ module Application
3
+ module Refresh
4
+ module All
5
+ module_function
6
+
7
+ def call(ctx, prefix: nil, zone: nil)
8
+ worker = Textus::Composition.refresh_worker(ctx)
9
+
10
+ stale_rows = ctx.store.stale(prefix: prefix, zone: zone)
11
+ refreshed = []
12
+ failed = []
13
+ skipped = []
14
+
15
+ stale_rows.each do |row|
16
+ key = row["key"] || row[:key]
17
+ reason = row["reason"] || row[:reason]
18
+ if reason.to_s.match?(/ttl exceeded|never refreshed/)
19
+ begin
20
+ worker.run(key)
21
+ refreshed << key
22
+ rescue Textus::Error => e
23
+ failed << { "key" => key, "error" => e.message }
24
+ end
25
+ else
26
+ skipped << { "key" => key, "reason" => reason }
27
+ end
28
+ end
29
+
30
+ {
31
+ "protocol" => Textus::PROTOCOL,
32
+ "ok" => failed.empty?,
33
+ "refreshed" => refreshed,
34
+ "failed" => failed,
35
+ "skipped" => skipped,
36
+ }
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,69 @@
1
+ module Textus
2
+ module Application
3
+ module Refresh
4
+ class Orchestrator
5
+ def initialize(worker:, bus:, store_root:, store: nil, role: "human", detached_spawner: nil)
6
+ @worker = worker
7
+ @bus = bus
8
+ @store_root = store_root
9
+ @store = store
10
+ @role = role
11
+ @detached_spawner = detached_spawner || default_spawner
12
+ end
13
+
14
+ def execute(action, key:)
15
+ case action
16
+ when Textus::Domain::Action::Return then Textus::Domain::Outcome::Skipped.new
17
+ when Textus::Domain::Action::RefreshSync then run_sync(key)
18
+ when Textus::Domain::Action::RefreshTimed then run_timed(action.budget_ms, key)
19
+ else raise ArgumentError.new("unknown action: #{action.inspect}")
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ def run_sync(key)
26
+ envelope = @worker.run(key)
27
+ Textus::Domain::Outcome::Refreshed.new(envelope: envelope)
28
+ rescue Textus::Error => e
29
+ Textus::Domain::Outcome::Failed.new(error: e)
30
+ end
31
+
32
+ def run_timed(budget_ms, key)
33
+ unless Textus::Infra::Refresh::Detached.supported?
34
+ return Textus::Domain::Outcome::Failed.new(
35
+ error: Textus::UsageError.new("timed_sync requires fork (Unix only)"),
36
+ )
37
+ end
38
+
39
+ result = nil
40
+ thread = Thread.new do
41
+ result = @worker.run(key)
42
+ rescue Textus::Error => e
43
+ result = e
44
+ end
45
+
46
+ thread.join(budget_ms / 1000.0)
47
+
48
+ if thread.alive?
49
+ thread.kill
50
+ store_view = @store ? Textus::Application::Context.new(store: @store, role: @role) : nil
51
+ payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
52
+ payload[:store] = store_view if store_view
53
+ @bus.publish(:refresh_detached, **payload)
54
+ @detached_spawner.call(store_root: @store_root, key: key)
55
+ Textus::Domain::Outcome::Detached.new
56
+ elsif result.is_a?(Textus::Error)
57
+ Textus::Domain::Outcome::Failed.new(error: result)
58
+ else
59
+ Textus::Domain::Outcome::Refreshed.new(envelope: result)
60
+ end
61
+ end
62
+
63
+ def default_spawner
64
+ Textus::Infra::Refresh::Detached.method(:spawn)
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,79 @@
1
+ require "timeout"
2
+
3
+ module Textus
4
+ module Application
5
+ module Refresh
6
+ class Worker
7
+ FETCH_TIMEOUT_SECONDS = 30
8
+
9
+ def initialize(ctx:, bus:)
10
+ @ctx = ctx
11
+ @bus = bus
12
+ end
13
+
14
+ def run(key)
15
+ mentry, path, = @ctx.store.manifest.resolve(key)
16
+ raise UsageError.new("no intake declared for '#{key}'") unless mentry.intake_handler
17
+
18
+ before_etag = File.exist?(path) ? Etag.for_file(path) : nil
19
+ result = fetch_with_bus(key, mentry)
20
+ persist_and_notify(key, mentry, result, before_etag)
21
+ end
22
+
23
+ private
24
+
25
+ def read_view
26
+ Application::Context.new(store: @ctx.store, role: @ctx.role)
27
+ end
28
+
29
+ def fetch_with_bus(key, mentry)
30
+ callable = @ctx.store.registry.rpc_callable(:intake, mentry.intake_handler)
31
+ @bus.publish(:refresh_began, store: read_view, key: key, mode: :sync,
32
+ correlation_id: @ctx.correlation_id)
33
+ call_intake(key, mentry, callable)
34
+ end
35
+
36
+ def call_intake(key, mentry, callable)
37
+ Timeout.timeout(FETCH_TIMEOUT_SECONDS) do
38
+ callable.call(store: @ctx, config: mentry.intake_config, args: {})
39
+ end
40
+ rescue Timeout::Error
41
+ @bus.publish(:refresh_failed, store: read_view, key: key, error_class: "Timeout::Error",
42
+ error_message: "intake '#{mentry.intake_handler}' exceeded #{FETCH_TIMEOUT_SECONDS}s",
43
+ correlation_id: @ctx.correlation_id)
44
+ raise UsageError.new("intake '#{mentry.intake_handler}' exceeded #{FETCH_TIMEOUT_SECONDS}s timeout")
45
+ rescue Textus::Error => e
46
+ @bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
47
+ error_message: e.message, correlation_id: @ctx.correlation_id)
48
+ raise
49
+ rescue StandardError => e
50
+ @bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
51
+ error_message: e.message, correlation_id: @ctx.correlation_id)
52
+ raise UsageError.new("intake '#{mentry.intake_handler}' raised: #{e.class}: #{e.message}")
53
+ end
54
+
55
+ def persist_and_notify(key, mentry, result, before_etag)
56
+ normalized = Textus::Refresh.normalize_action_result(result, format: mentry.format)
57
+ envelope = @ctx.store.put(
58
+ key,
59
+ meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
60
+ as: @ctx.role, suppress_events: true
61
+ )
62
+ change = detect_change(before_etag, envelope)
63
+ unless change == :unchanged
64
+ @bus.publish(:refreshed, store: read_view, key: key, envelope: envelope, change: change,
65
+ correlation_id: @ctx.correlation_id)
66
+ end
67
+ envelope
68
+ end
69
+
70
+ def detect_change(before_etag, envelope)
71
+ if before_etag.nil? then :created
72
+ elsif envelope["etag"] == before_etag then :unchanged
73
+ else :updated
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,44 @@
1
+ module Textus
2
+ module Application
3
+ module Writes
4
+ class Accept
5
+ def initialize(ctx:, bus:)
6
+ @ctx = ctx
7
+ @bus = bus
8
+ end
9
+
10
+ def call(pending_key)
11
+ raise ProposalError.new("only human role can accept proposals; got '#{@ctx.role}'") unless @ctx.role == "human"
12
+
13
+ env = @ctx.store.get(pending_key)
14
+ proposal = env["_meta"]["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
15
+ target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
16
+ action = proposal["action"] || "put"
17
+
18
+ case action
19
+ when "put"
20
+ # Nested proposal "frontmatter" — the meta to write to the accepted
21
+ # target. Not related to the removed intake-handler legacy bridge.
22
+ target_meta = env["_meta"]["frontmatter"] || {}
23
+ target_body = env["body"]
24
+ Composition.writes_put(@ctx).call(target, meta: target_meta, body: target_body)
25
+ when "delete"
26
+ Composition.writes_delete(@ctx).call(target)
27
+ else
28
+ raise ProposalError.new("unknown action: #{action}")
29
+ end
30
+
31
+ Composition.writes_delete(@ctx).call(pending_key)
32
+
33
+ @bus.publish(:accepted,
34
+ store: @ctx.with_role(@ctx.role),
35
+ key: pending_key,
36
+ target_key: target,
37
+ correlation_id: @ctx.correlation_id)
38
+
39
+ { "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,116 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Application
5
+ module Writes
6
+ # Materializes generator-zone entries (template + projection) onto disk
7
+ # and copies the result to any configured `publish_to` / `publish_each`
8
+ # targets. Fires `:built` and `:published` events on the bus, tagged with
9
+ # the request's correlation_id for traceability.
10
+ class Build
11
+ def initialize(ctx:, bus:)
12
+ @ctx = ctx
13
+ @bus = bus
14
+ end
15
+
16
+ def call(prefix: nil)
17
+ built = []
18
+ manifest.entries.each do |mentry|
19
+ next unless mentry.in_generator_zone?
20
+ next unless mentry.projection || mentry.template
21
+ next if prefix && !mentry.key.start_with?(prefix)
22
+
23
+ built << materialize(mentry)
24
+ end
25
+ published_leaves = publish_leaves(prefix: prefix)
26
+ { "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => published_leaves }
27
+ end
28
+
29
+ private
30
+
31
+ def store = @ctx.store
32
+ def manifest = store.manifest
33
+ def root = store.root
34
+
35
+ def publish_leaves(prefix: nil)
36
+ repo_root = File.dirname(root)
37
+ out = []
38
+ manifest.entries.each do |mentry|
39
+ next unless mentry.nested && mentry.publish_each
40
+ next if prefix && !mentry.key.start_with?(prefix) && !prefix.start_with?("#{mentry.key}.")
41
+
42
+ manifest.enumerate(prefix: mentry.key).each do |row|
43
+ next unless row[:manifest_entry].equal?(mentry)
44
+ next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
45
+
46
+ out << publish_leaf(mentry, row, repo_root)
47
+ end
48
+ end
49
+ out
50
+ end
51
+
52
+ def publish_leaf(mentry, row, repo_root)
53
+ target_rel = mentry.publish_target_for(row[:key])
54
+ target_abs = File.expand_path(File.join(repo_root, target_rel))
55
+ unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
56
+ raise PublishError.new(
57
+ "entry '#{mentry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
58
+ )
59
+ end
60
+
61
+ Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: root)
62
+ publish_event(:published,
63
+ key: row[:key],
64
+ envelope: store.get(row[:key]),
65
+ source: row[:path],
66
+ target: target_abs)
67
+ { "key" => row[:key], "source" => row[:path], "target" => target_abs }
68
+ end
69
+
70
+ def materialize(mentry)
71
+ target_path = Builder::Pipeline.run(
72
+ store: store,
73
+ mentry: mentry,
74
+ template_loader: ->(name) { read_template(name) },
75
+ )
76
+ publish_and_fire(mentry, target_path)
77
+ { "key" => mentry.key, "path" => target_path, "published_to" => mentry.publish_to }
78
+ end
79
+
80
+ def read_template(name)
81
+ tpl_path = File.join(root, "templates", name)
82
+ raise TemplateError.new("template not found: #{tpl_path}", template_name: name) unless File.exist?(tpl_path)
83
+
84
+ File.read(tpl_path)
85
+ end
86
+
87
+ def publish_and_fire(mentry, target_path)
88
+ envelope = store.get(mentry.key)
89
+ repo_root = File.dirname(root)
90
+
91
+ mentry.publish_to.each do |rel|
92
+ target_abs = File.join(repo_root, rel)
93
+ Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: root)
94
+ publish_event(:published,
95
+ key: mentry.key,
96
+ envelope: envelope,
97
+ source: target_path,
98
+ target: target_abs)
99
+ end
100
+
101
+ publish_event(:built,
102
+ key: mentry.key,
103
+ envelope: envelope,
104
+ sources: Array(mentry.projection&.fetch("select", nil)).compact)
105
+ end
106
+
107
+ def publish_event(event, **payload)
108
+ # `with_role` returns a Context that preserves the original
109
+ # correlation_id, so hooks reading `store.correlation_id` see the
110
+ # same value as the event's top-level correlation_id key.
111
+ @bus.publish(event, store: @ctx.with_role(@ctx.role), correlation_id: @ctx.correlation_id, **payload)
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end