textus 0.50.0 → 0.51.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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +41 -43
  4. data/SPEC.md +174 -176
  5. data/docs/architecture/README.md +46 -42
  6. data/docs/reference/conventions.md +31 -26
  7. data/lib/textus/boot.rb +13 -17
  8. data/lib/textus/call.rb +1 -1
  9. data/lib/textus/cli/runner.rb +15 -10
  10. data/lib/textus/cli/verb/get.rb +1 -3
  11. data/lib/textus/cli/verb/hook_run.rb +1 -1
  12. data/lib/textus/cli/verb/put.rb +4 -20
  13. data/lib/textus/cli.rb +1 -3
  14. data/lib/textus/dispatcher.rb +1 -3
  15. data/lib/textus/doctor/check/generator_drift.rb +4 -3
  16. data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
  17. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  18. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  19. data/lib/textus/doctor/check/sentinels.rb +2 -2
  20. data/lib/textus/doctor/check/templates.rb +13 -11
  21. data/lib/textus/doctor.rb +0 -2
  22. data/lib/textus/domain/freshness/evaluator.rb +150 -14
  23. data/lib/textus/domain/freshness/verdict.rb +28 -6
  24. data/lib/textus/domain/freshness.rb +4 -33
  25. data/lib/textus/domain/policy/base_guards.rb +1 -1
  26. data/lib/textus/domain/policy/predicates/fresh_within.rb +1 -1
  27. data/lib/textus/domain/policy/publish_target.rb +34 -0
  28. data/lib/textus/domain/policy/retention.rb +29 -0
  29. data/lib/textus/domain/policy/source.rb +79 -0
  30. data/lib/textus/domain/retention/sweep.rb +57 -0
  31. data/lib/textus/domain/retention.rb +11 -0
  32. data/lib/textus/errors.rb +4 -4
  33. data/lib/textus/hooks/builtin.rb +5 -5
  34. data/lib/textus/hooks/catalog.rb +8 -7
  35. data/lib/textus/hooks/context.rb +5 -10
  36. data/lib/textus/init/templates/machine_intake.rb +4 -4
  37. data/lib/textus/init.rb +47 -47
  38. data/lib/textus/key/matching.rb +24 -0
  39. data/lib/textus/maintenance/reconcile.rb +160 -0
  40. data/lib/textus/manifest/capabilities.rb +1 -1
  41. data/lib/textus/manifest/data.rb +2 -2
  42. data/lib/textus/manifest/entry/base.rb +28 -9
  43. data/lib/textus/manifest/entry/nested.rb +3 -4
  44. data/lib/textus/manifest/entry/parser.rb +25 -21
  45. data/lib/textus/manifest/entry/produced.rb +56 -0
  46. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +7 -6
  47. data/lib/textus/manifest/entry/publish/to_paths.rb +62 -11
  48. data/lib/textus/manifest/entry/validators/format_matrix.rb +3 -11
  49. data/lib/textus/manifest/entry/validators/publish.rb +3 -1
  50. data/lib/textus/manifest/entry/validators.rb +0 -1
  51. data/lib/textus/manifest/policy.rb +16 -4
  52. data/lib/textus/manifest/resolver.rb +10 -4
  53. data/lib/textus/manifest/rules.rb +37 -36
  54. data/lib/textus/manifest/schema/keys.rb +98 -0
  55. data/lib/textus/manifest/schema/validator.rb +324 -0
  56. data/lib/textus/manifest/schema/vocabulary.rb +24 -0
  57. data/lib/textus/manifest/schema.rb +27 -247
  58. data/lib/textus/manifest.rb +5 -3
  59. data/lib/textus/mcp/server.rb +1 -1
  60. data/lib/textus/ports/audit_log.rb +6 -0
  61. data/lib/textus/ports/build_lock.rb +6 -0
  62. data/lib/textus/ports/clock.rb +4 -3
  63. data/lib/textus/ports/produce_on_write_subscriber.rb +69 -0
  64. data/lib/textus/ports/publisher.rb +11 -7
  65. data/lib/textus/produce/acquire/handler.rb +29 -0
  66. data/lib/textus/produce/acquire/intake.rb +130 -0
  67. data/lib/textus/produce/acquire/projection.rb +127 -0
  68. data/lib/textus/produce/acquire/serializer/json.rb +31 -0
  69. data/lib/textus/produce/acquire/serializer/text.rb +16 -0
  70. data/lib/textus/produce/acquire/serializer/yaml.rb +31 -0
  71. data/lib/textus/produce/acquire/serializer.rb +17 -0
  72. data/lib/textus/produce/engine.rb +143 -0
  73. data/lib/textus/produce/events.rb +36 -0
  74. data/lib/textus/produce/render.rb +23 -0
  75. data/lib/textus/projection.rb +17 -6
  76. data/lib/textus/read/deps.rb +3 -3
  77. data/lib/textus/read/freshness.rb +61 -31
  78. data/lib/textus/read/get.rb +20 -102
  79. data/lib/textus/read/rdeps.rb +3 -3
  80. data/lib/textus/read/rule_explain.rb +41 -23
  81. data/lib/textus/read/rule_list.rb +25 -8
  82. data/lib/textus/read/validate_all.rb +14 -0
  83. data/lib/textus/role.rb +2 -1
  84. data/lib/textus/schemas.rb +8 -0
  85. data/lib/textus/store.rb +1 -0
  86. data/lib/textus/version.rb +1 -1
  87. data/lib/textus/write/put.rb +1 -1
  88. metadata +23 -30
  89. data/lib/textus/builder/pipeline.rb +0 -88
  90. data/lib/textus/builder/renderer/json.rb +0 -45
  91. data/lib/textus/builder/renderer/markdown.rb +0 -24
  92. data/lib/textus/builder/renderer/text.rb +0 -14
  93. data/lib/textus/builder/renderer/yaml.rb +0 -45
  94. data/lib/textus/builder/renderer.rb +0 -17
  95. data/lib/textus/cli/verb/boot.rb +0 -14
  96. data/lib/textus/cli/verb/build.rb +0 -15
  97. data/lib/textus/doctor/check/fetch_locks.rb +0 -49
  98. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
  99. data/lib/textus/domain/freshness/policy.rb +0 -18
  100. data/lib/textus/domain/lifecycle.rb +0 -83
  101. data/lib/textus/domain/outcome.rb +0 -10
  102. data/lib/textus/domain/policy/lifecycle.rb +0 -35
  103. data/lib/textus/domain/staleness/generator_check.rb +0 -109
  104. data/lib/textus/domain/staleness.rb +0 -29
  105. data/lib/textus/maintenance/tend.rb +0 -110
  106. data/lib/textus/manifest/entry/derived.rb +0 -67
  107. data/lib/textus/manifest/entry/intake.rb +0 -31
  108. data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
  109. data/lib/textus/mcp/tools.rb +0 -14
  110. data/lib/textus/ports/fetch/detached.rb +0 -52
  111. data/lib/textus/ports/fetch/lock.rb +0 -44
  112. data/lib/textus/write/build.rb +0 -90
  113. data/lib/textus/write/fetch_events.rb +0 -42
  114. data/lib/textus/write/fetch_orchestrator.rb +0 -101
  115. data/lib/textus/write/fetch_worker.rb +0 -127
  116. data/lib/textus/write/intake_fetch.rb +0 -25
  117. data/lib/textus/write/materializer.rb +0 -51
@@ -4,6 +4,12 @@ require "time"
4
4
 
5
5
  module Textus
6
6
  module Ports
7
+ # Cross-process build lock: a pid/host-stamped lockfile under the store root
8
+ # that serializes reconcile's produce/sweep. An instantiable class — it holds
9
+ # the root and lock state; `self.with(root:)` is a convenience that constructs
10
+ # one and runs the block under the held lock. It already satisfied ADR 0109's
11
+ # single-shape rule (every port is an instantiable class) before that ADR's
12
+ # Clock/Publisher conversions, so it was unchanged by them.
7
13
  class BuildLock
8
14
  MAX_HOLDER_BYTES = 512
9
15
 
@@ -1,8 +1,9 @@
1
1
  module Textus
2
2
  module Ports
3
- module Clock
4
- module_function
5
-
3
+ # The wall clock. An instantiable class (ADR 0109) — uniform with the other
4
+ # ports; `now` reads the system time. Callers that need a fixed time still
5
+ # pass it as data via `Call#now`.
6
+ class Clock
6
7
  def now = Time.now
7
8
  end
8
9
  end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Textus
4
+ module Ports
5
+ # ADR 0093: on a canon write, converge the derived entries that depend on the
6
+ # written key (rdeps ∩ derived) by running Produce — scoped + non-destructive.
7
+ # This IS reconcile narrowed to a write's blast radius; there is no separate
8
+ # "reactive materialize" subsystem. Per-entry source.on_write (sync|async)
9
+ # picks inline-under-lock vs deferred. A write INTO a derived entry does not
10
+ # fan out (recursion guard). Failures never reach the writer (Produce.converge
11
+ # isolates them). Attached at Store boot, alongside AuditSubscriber.
12
+ class ProduceOnWriteSubscriber
13
+ def initialize(container)
14
+ @container = container
15
+ end
16
+
17
+ def attach(bus)
18
+ bus.on(:entry_written, :produce_on_write) do |ctx:, key:, **|
19
+ call = Textus::Call.build(role: ctx.role, correlation_id: ctx.correlation_id)
20
+ on_write(key: key, call: call)
21
+ end
22
+ self
23
+ end
24
+
25
+ def on_write(key:, call:)
26
+ return if derived_write?(key) # recursion guard: produce output is not a source change
27
+
28
+ affected = Textus::Read::Rdeps.new(container: @container).call(key)["rdeps"]
29
+ producible = affected.select { |k| producible?(k) }
30
+ return if producible.empty?
31
+
32
+ if any_sync?(producible)
33
+ Textus::Produce::Engine.converge(container: @container, call: call, keys: producible)
34
+ else
35
+ Textus::Produce::Engine::AsyncRunner.enqueue(container: @container, call: call, keys: producible)
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def derived_write?(key)
42
+ @container.manifest.resolver.resolve(key).entry.derived?
43
+ rescue Textus::Error
44
+ false
45
+ end
46
+
47
+ # The producible scope mirrors Produce::Engine#produce_one: derived
48
+ # entries render+publish, and nested publish_tree entries mirror their
49
+ # source subtree (ADR 0047). Including the latter restores reactive
50
+ # re-mirroring on a write into a tree's source — dropped when the scope
51
+ # narrowed to `derived?` only.
52
+ def producible?(key)
53
+ entry = @container.manifest.resolver.resolve(key).entry
54
+ entry.derived? || !entry.publish_tree.nil?
55
+ rescue Textus::Error
56
+ false
57
+ end
58
+
59
+ # Only derived entries carry a source with on_write semantics; a nested
60
+ # publish_tree entry has no source and defaults to async.
61
+ def any_sync?(keys)
62
+ keys.any? do |k|
63
+ entry = @container.manifest.resolver.resolve(k).entry
64
+ entry.derived? && entry.source.sync?
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -10,18 +10,20 @@ module Textus
10
10
  # under `<store_root>/.run/sentinels/` (runtime, git-ignored — ADR 0070) and
11
11
  # mirror the target's repo-relative layout so consumer directories aren't
12
12
  # polluted with `.textus-managed.json` siblings.
13
- module Publisher
14
- def self.publish(source:, target:, store_root:)
13
+ #
14
+ # An instantiable class (ADR 0109).
15
+ class Publisher
16
+ def publish(source:, target:, store_root:, provenance_source: source)
15
17
  FileUtils.mkdir_p(File.dirname(target))
16
18
  guard_clobber(source, target, store_root)
17
19
  File.delete(target) if File.symlink?(target)
18
20
  FileUtils.cp(source, target)
19
- Textus::Ports::SentinelStore.new.write!(target: target, source: source, store_root: store_root)
21
+ Textus::Ports::SentinelStore.new.write!(target: target, source: provenance_source, store_root: store_root)
20
22
  end
21
23
 
22
24
  # Removes a previously-published file and its sentinel. No-op unless the
23
25
  # target is textus-managed — never deletes an unmanaged file.
24
- def self.unpublish(target:, store_root:)
26
+ def unpublish(target:, store_root:)
25
27
  return unless managed?(target, store_root)
26
28
 
27
29
  FileUtils.rm_f(target)
@@ -29,6 +31,8 @@ module Textus
29
31
  FileUtils.rm_f(sentinel)
30
32
  end
31
33
 
34
+ private
35
+
32
36
  # Refuse to clobber an unmanaged target — EXCEPT adopt one whose bytes
33
37
  # already equal the source (ADR 0050: a migration copies files into the
34
38
  # store and publishes them back to where they already live, so the target
@@ -36,7 +40,7 @@ module Textus
36
40
  # here; the normal publish path below does, and the cp is a content no-op.
37
41
  # An unmanaged target whose content DIFFERS, or any unmanaged symlink, is
38
42
  # still refused — that is the guard's real job.
39
- def self.guard_clobber(source, target, store_root)
43
+ def guard_clobber(source, target, store_root)
40
44
  return unless File.exist?(target) || File.symlink?(target)
41
45
  return if managed?(target, store_root)
42
46
  return if adoptable?(source, target)
@@ -44,11 +48,11 @@ module Textus
44
48
  raise PublishError.new("refusing to clobber unmanaged file at #{target}", target: target)
45
49
  end
46
50
 
47
- def self.adoptable?(source, target)
51
+ def adoptable?(source, target)
48
52
  !File.symlink?(target) && File.file?(target) && FileUtils.identical?(source, target)
49
53
  end
50
54
 
51
- def self.managed?(target, store_root)
55
+ def managed?(target, store_root)
52
56
  File.exist?(Textus::Ports::SentinelStore.new.sentinel_path(target, store_root))
53
57
  end
54
58
  end
@@ -0,0 +1,29 @@
1
+ require "timeout"
2
+
3
+ module Textus
4
+ module Produce
5
+ module Acquire
6
+ # Invokes a :resolve_handler hook handler by name under a timeout — the single
7
+ # home for "call the intake handler under a deadline" (ADR 0048 D1). Shared by
8
+ # Produce::Acquire::Intake (the internal ingest mechanism — no public verb since ADR 0079)
9
+ # as driven by the `reconcile` sweep and `textus hook run` (ADR 0089 made
10
+ # ingest system-pushed; there is no read or put trigger).
11
+ # Always passes a Container as `caps:` so the hook contract (ADR 0027) is
12
+ # uniform across every entry point. Maps Timeout::Error to a UsageError;
13
+ # leaves any other error to the caller (call sites differ in how they wrap).
14
+ module Handler
15
+ FETCH_TIMEOUT_SECONDS = 30
16
+
17
+ module_function
18
+
19
+ def invoke(caps:, handler:, config:, args:, label:, timeout: FETCH_TIMEOUT_SECONDS)
20
+ Timeout.timeout(timeout) do
21
+ caps.rpc.invoke(:resolve_handler, handler, caps: caps, config: config, args: args)
22
+ end
23
+ rescue Timeout::Error
24
+ raise Textus::UsageError.new("#{label} '#{handler}' exceeded #{timeout}s timeout")
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,130 @@
1
+ require "timeout"
2
+
3
+ module Textus
4
+ module Produce
5
+ module Acquire
6
+ # Internal ingest executor for one machine-zone intake entry. No longer a
7
+ # public verb (ADR 0079 collapsed the `fetch` surface): used by the
8
+ # `reconcile` sweep and `textus hook run` only — ingest is system-pushed
9
+ # (ADR 0089 removed the read-through that once also drove it).
10
+ class Intake
11
+ FETCH_TIMEOUT_SECONDS = Textus::Produce::Acquire::Handler::FETCH_TIMEOUT_SECONDS
12
+
13
+ def initialize(container:, call:)
14
+ @container = container
15
+ @call = call
16
+ @manifest = container.manifest
17
+ @schemas = container.schemas
18
+ @rpc = container.rpc
19
+ end
20
+
21
+ # call(key) is the primary entry; run is kept as an alias for
22
+ # Orchestrator and FetchAll which call worker.run(key).
23
+ def call(key)
24
+ run(key)
25
+ end
26
+
27
+ def run(key)
28
+ res = @manifest.resolver.resolve(key)
29
+ mentry = res.entry
30
+ path = res.path
31
+ remaining = res.remaining
32
+ raise UsageError.new("no intake declared for '#{key}'") unless mentry.intake?
33
+
34
+ before_etag = @container.file_store.exists?(path) ? @container.file_store.etag(path) : nil
35
+ result = fetch_with_events(key, mentry, remaining)
36
+ persist_and_notify(key, mentry, result, before_etag)
37
+ end
38
+
39
+ def self.normalize_action_result(res, format:)
40
+ res = res.transform_keys(&:to_s) if res.is_a?(Hash)
41
+ res ||= {}
42
+ meta_val = res["_meta"]
43
+ body = res["body"]
44
+ content = res["content"]
45
+
46
+ case format
47
+ when "markdown" then { meta: meta_val || {}, body: body.to_s, content: nil }
48
+ when "text" then { meta: {}, body: body.to_s, content: nil }
49
+ when "json", "yaml"
50
+ if !content.nil?
51
+ { meta: meta_val || {}, body: nil, content: content }
52
+ elsif !body.nil?
53
+ { meta: {}, body: body.to_s, content: nil }
54
+ else
55
+ raise Textus::UsageError.new("intake for #{format} returned neither content nor body")
56
+ end
57
+ else
58
+ raise Textus::UsageError.new("unknown format #{format.inspect}")
59
+ end
60
+ end
61
+
62
+ private
63
+
64
+ def fetch_events
65
+ @fetch_events ||= Textus::Produce::Events.from(container: @container, call: @call)
66
+ end
67
+
68
+ # ADR 0079: a per-rule fetch_timeout_seconds override was an accepted loss
69
+ # in the fetch:/retention: → lifecycle: collapse; the constant ceiling
70
+ # applies to every intake.
71
+ def fetch_timeout_for(_key)
72
+ FETCH_TIMEOUT_SECONDS
73
+ end
74
+
75
+ def fetch_with_events(key, mentry, remaining)
76
+ fetch_events.started(key)
77
+ call_intake(key, mentry, remaining)
78
+ end
79
+
80
+ def call_intake(key, mentry, remaining)
81
+ Textus::Produce::Acquire::Handler.invoke(
82
+ caps: @container, handler: mentry.handler,
83
+ config: mentry.config,
84
+ args: { trigger_key: key, leaf_segments: remaining || [] },
85
+ label: "intake", timeout: fetch_timeout_for(key)
86
+ )
87
+ rescue Textus::Error => e
88
+ fetch_events.failed(key, e)
89
+ raise
90
+ rescue StandardError => e
91
+ fetch_events.failed(key, e)
92
+ raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
93
+ end
94
+
95
+ def persist_and_notify(key, mentry, result, before_etag)
96
+ normalized = self.class.normalize_action_result(result, format: mentry.format)
97
+ Textus::Domain::Policy::GuardFactory.new(
98
+ manifest: @manifest, schemas: @schemas,
99
+ ).for(:reconcile, key).check!(
100
+ Textus::Domain::Policy::Evaluation.new(
101
+ actor: @call.role, transition: :reconcile, origin: nil,
102
+ target: key, envelope: nil, manifest: @manifest
103
+ ),
104
+ )
105
+ envelope = writer.put(
106
+ key,
107
+ mentry: mentry,
108
+ payload: Textus::Envelope::IO::Writer::Payload.new(
109
+ meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
110
+ ),
111
+ )
112
+ change = detect_change(before_etag, envelope)
113
+ fetch_events.fetched(key, envelope, change)
114
+ envelope
115
+ end
116
+
117
+ def detect_change(before_etag, envelope)
118
+ if before_etag.nil? then :created
119
+ elsif envelope.etag == before_etag then :unchanged
120
+ else :updated
121
+ end
122
+ end
123
+
124
+ def writer
125
+ @writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
126
+ end
127
+ end
128
+ end
129
+ end
130
+ end
@@ -0,0 +1,127 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Produce
5
+ module Acquire
6
+ # Builds an entry's DATA artifact (ADR 0094) by running the projection
7
+ # pipeline; rendering is a publish concern. External entries are NOT built
8
+ # here — they are generated by an out-of-band runner; Derived#publish_via
9
+ # filters them out before reaching this point.
10
+ #
11
+ # Merges the former Write::DataBuilder wrapper and Builder::Pipeline module
12
+ # into one class (ADR 0100 produce/ topology refactor).
13
+ class Projection
14
+ # Injects provenance metadata as the first key in the serialized output.
15
+ # Carries only deterministic provenance (`from`/`reduce`) — the volatile
16
+ # `generated_at` is deliberately NOT stamped, so the built artifact is
17
+ # content-addressed and a rebuild is a byte-for-byte no-op (ADR 0070).
18
+ # Build time lives out of the tracked artifact.
19
+ module InjectMeta
20
+ def self.call(content_hash, mentry)
21
+ meta = {}
22
+ if mentry.derived?
23
+ src = mentry.source
24
+ if src.projection?
25
+ from = Array(src.select).compact
26
+ meta["from"] = from unless from.empty?
27
+ meta["reduce"] = src.transform if src.transform
28
+ end
29
+ end
30
+
31
+ out = { "_meta" => meta }
32
+ content_hash.each { |k, v| out[k] = v unless k == "_meta" }
33
+ out
34
+ end
35
+ end
36
+
37
+ Deps = Data.define(:manifest, :reader, :lister, :rpc, :transform_context)
38
+
39
+ def self.renderers
40
+ @renderers ||= {
41
+ "text" => Produce::Acquire::Serializer::Text,
42
+ "json" => Produce::Acquire::Serializer::Json,
43
+ "yaml" => Produce::Acquire::Serializer::Yaml,
44
+ }
45
+ end
46
+
47
+ def initialize(container:, call:)
48
+ @container = container
49
+ @call = call
50
+ @manifest = container.manifest
51
+ @file_store = container.file_store
52
+ @rpc = container.rpc
53
+ @root = container.root
54
+ end
55
+
56
+ # Runs the projection pipeline for `mentry` and returns the on-disk
57
+ # target_path string.
58
+ def run(mentry)
59
+ reader = Textus::Read::Get.new(container: @container, call: @call)
60
+ # Projections must be able to read source data from any nested entry,
61
+ # including keyless (publish_tree) ones like knowledge.decisions.
62
+ # The `include_keyless: true` option makes the resolver walk those dirs
63
+ # without exposing them on the public `list` / CLI surface (ADR 0047).
64
+ resolver = @manifest.resolver
65
+ lister = lambda do |prefix:|
66
+ resolver.enumerate(prefix: prefix, include_keyless: true)
67
+ .map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
68
+ end
69
+ self.class.pipeline_run(
70
+ mentry: mentry,
71
+ deps: Deps.new(
72
+ manifest: @manifest,
73
+ reader: reader.method(:call),
74
+ lister: lister,
75
+ rpc: @rpc,
76
+ transform_context: @container,
77
+ ),
78
+ )
79
+ end
80
+
81
+ def self.pipeline_run(mentry:, deps:)
82
+ # 1. Load sources + project + reduce. Only projection-derived entries are
83
+ # buildable in-process; External entries are generated out-of-band and are
84
+ # filtered out upstream (Derived#publish_via), so reaching here with a
85
+ # non-projection source is a wiring bug — fail loudly rather than emit an
86
+ # empty payload (and never re-stamp the volatile generated_at, ADR 0070).
87
+ unless mentry.projection?
88
+ raise UsageError.new(
89
+ "builder: '#{mentry.key}' is not a projection-derived entry; only projections are buildable",
90
+ )
91
+ end
92
+
93
+ data =
94
+ Textus::Projection.new(
95
+ reader: deps.reader,
96
+ spec: mentry.source.projection_spec,
97
+ lister: deps.lister,
98
+ rpc: deps.rpc,
99
+ transform_context: deps.transform_context,
100
+ ).run
101
+
102
+ # 2. Serialize as DATA. Rendering through a template is a publish concern
103
+ # (ADR 0094) — the build never consults a template.
104
+ klass = renderers[mentry.format] or
105
+ raise UsageError.new("builder: unsupported data format #{mentry.format.inspect} for '#{mentry.key}'")
106
+ bytes = klass.new.call(mentry: mentry, data: data)
107
+
108
+ # 3. Write (idempotent: skip if only generated_at would differ)
109
+ target_path = Key::Path.resolve(deps.manifest.data, mentry)
110
+ FileUtils.mkdir_p(File.dirname(target_path))
111
+ write_if_changed(target_path, bytes, mentry.format)
112
+
113
+ target_path
114
+ end
115
+
116
+ # Built artifacts are content-addressed (no volatile timestamp, ADR 0070),
117
+ # so identity is plain byte-equality: skip the write when nothing changed.
118
+ # `format` is retained for signature stability across renderers.
119
+ def self.write_if_changed(target_path, bytes, _format)
120
+ return if File.exist?(target_path) && File.binread(target_path) == bytes
121
+
122
+ File.binwrite(target_path, bytes)
123
+ end
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,31 @@
1
+ require "json"
2
+
3
+ module Textus
4
+ module Produce
5
+ module Acquire
6
+ class Serializer
7
+ class Json < Serializer
8
+ def call(mentry:, data:)
9
+ content = default_shape(mentry, data)
10
+ final = Produce::Acquire::Projection::InjectMeta.call(content, mentry)
11
+ Entry.for_format("json").serialize(meta: {}, body: "", content: final)
12
+ end
13
+
14
+ private
15
+
16
+ def default_shape(mentry, data)
17
+ has_transform = mentry.projection? &&
18
+ mentry.source.transform
19
+ if has_transform && data.is_a?(Hash) && !data.key?("entries")
20
+ data
21
+ elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
22
+ { "entries" => data["entries"] }
23
+ else
24
+ data.is_a?(Hash) ? data : { "entries" => Array(data) }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,16 @@
1
+ module Textus
2
+ module Produce
3
+ module Acquire
4
+ class Serializer
5
+ class Text < Serializer
6
+ def call(mentry:, data:) # rubocop:disable Lint/UnusedMethodArgument
7
+ # Text format serializes data as plain-text. Rendering through a
8
+ # template is a publish concern (ADR 0094) — build emits data only.
9
+ body = data.is_a?(Hash) ? data.to_s : data.inspect
10
+ Entry.for_format("text").serialize(meta: {}, body: body)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,31 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Produce
5
+ module Acquire
6
+ class Serializer
7
+ class Yaml < Serializer
8
+ def call(mentry:, data:)
9
+ content = default_shape(mentry, data)
10
+ final = Produce::Acquire::Projection::InjectMeta.call(content, mentry)
11
+ Entry.for_format("yaml").serialize(meta: {}, body: "", content: final)
12
+ end
13
+
14
+ private
15
+
16
+ def default_shape(mentry, data)
17
+ has_transform = mentry.projection? &&
18
+ mentry.source.transform
19
+ if has_transform && data.is_a?(Hash) && !data.key?("entries")
20
+ data
21
+ elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
22
+ { "entries" => data["entries"] }
23
+ else
24
+ data.is_a?(Hash) ? data : { "entries" => Array(data) }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,17 @@
1
+ module Textus
2
+ module Produce
3
+ module Acquire
4
+ # Abstract base for output serializers. Each concrete serializer owns
5
+ # producing the bytes for one manifest format (json/yaml/text).
6
+ # Rendering through a template is a publish concern (ADR 0094) — serializers
7
+ # here only serialize data; they take no arguments.
8
+ class Serializer
9
+ def call(mentry:, data:)
10
+ _ = mentry
11
+ _ = data
12
+ raise NotImplementedError.new("#{self.class.name}#call not implemented")
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,143 @@
1
+ module Textus
2
+ module Produce
3
+ # The single convergence engine (ADR 0093/0094). "Make these machine entries
4
+ # current from upstream." Acquire is per-`from`; publish is one uniform
5
+ # `publish_via` entry point for all kinds (ADR 0094):
6
+ # intake (from: handler) -> re-pull (Produce::Acquire::Intake), then publish_via
7
+ # derived (from: project) -> build data + publish_via (ToPaths or None)
8
+ # derived (from: command) -> skip the build; publish_via publishes
9
+ # existing store bytes via mode resolution
10
+ # (None when no targets -> skipped)
11
+ # Runs as the reconcile build actor (self-elevating); the passed `call`
12
+ # supplies only correlation_id/dry_run. Callers choose the key set: the
13
+ # write subscriber passes rdeps ∩ derived; reconcile passes
14
+ # all-derived + stale-intake.
15
+ class Engine
16
+ # Locked + failure-isolated convergence — the shared entry point for the
17
+ # write trigger (ADR 0093). Both the sync path (inline, in the subscriber)
18
+ # and the async path (AsyncRunner) call this. A held lock is a soft miss
19
+ # (an in-flight build/reconcile already produces fresh output); any other
20
+ # error is republished as :produce_failed and never raised at the
21
+ # writer (ADR 0087 §5 failure isolation, preserved).
22
+ def self.converge(container:, call:, keys:)
23
+ Textus::Ports::BuildLock.with(root: container.root) do
24
+ new(container: container, call: call).call(keys: keys)
25
+ end
26
+ rescue Textus::BuildInProgress
27
+ nil
28
+ rescue Textus::Error => e
29
+ container.events.publish(
30
+ :produce_failed,
31
+ ctx: Textus::Hooks::Context.for(container: container, call: call),
32
+ keys: keys, error: e.message
33
+ )
34
+ end
35
+
36
+ def initialize(container:, call:)
37
+ @container = container
38
+ @call = call
39
+ @manifest = container.manifest
40
+ end
41
+
42
+ # keys: the machine entry keys to converge. Returns
43
+ # { produced: [k...], skipped: [k...], failed: [{ "key"=>, "error"=> }...] }
44
+ def call(keys:)
45
+ build_call = build_actor_call
46
+ context = build_context(build_call)
47
+ out = { produced: [], skipped: [], failed: [] }
48
+
49
+ keys.each do |key|
50
+ produce_one(key, build_call, context, out)
51
+ rescue Textus::Error => e
52
+ out[:failed] << { "key" => key, "error" => e.message }
53
+ end
54
+ out
55
+ end
56
+
57
+ private
58
+
59
+ # Acquire is per-`from`; publish is one uniform entry point (publish_via)
60
+ # for every kind. The command emit-vs-skip falls out of publish-mode
61
+ # resolution (Publish::None when no targets), so there is no command branch.
62
+ def produce_one(key, build_call, context, out)
63
+ entry = @manifest.resolver.resolve(key).entry
64
+
65
+ if entry.intake?
66
+ Textus::Produce::Acquire::Intake.new(container: @container, call: build_call).run(key) # acquire: re-pull
67
+ entry.publish_via(context) # emit any targets
68
+ out[:produced] << key # a fetch is production
69
+ else
70
+ result = entry.publish_via(context) # derived builds inside; command publishes-or-None
71
+ result.nil? ? (out[:skipped] << key) : (out[:produced] << key)
72
+ end
73
+ end
74
+
75
+ def build_actor_call
76
+ build_role = @manifest.policy.actor_for("reconcile") or
77
+ raise Textus::UsageError.new(
78
+ "no role holds the 'reconcile' capability",
79
+ hint: "declare a role with `can: [reconcile]` in .textus/manifest.yaml",
80
+ )
81
+ Textus::Call.build(
82
+ role: build_role,
83
+ correlation_id: @call.correlation_id,
84
+ dry_run: @call.dry_run,
85
+ )
86
+ end
87
+
88
+ def build_context(call)
89
+ Textus::Manifest::Entry::Base::PublishContext.new(
90
+ container: @container, call: call,
91
+ reader: Textus::Read::Get.new(container: @container, call: call)
92
+ )
93
+ end
94
+
95
+ # In-process deferral for the async write trigger (ADR 0087/0093).
96
+ # Spawns a tracked thread that runs Produce.converge after the write
97
+ # returns; a one-time at_exit joins
98
+ # all pending threads so a short-lived CLI process cannot exit before an
99
+ # async rebuild completes. The write itself never blocks.
100
+ module AsyncRunner
101
+ @mutex = Mutex.new
102
+ @threads = []
103
+ @hooked = false
104
+
105
+ class << self
106
+ def enqueue(container:, call:, keys:)
107
+ thread = Thread.new { Textus::Produce::Engine.converge(container: container, call: call, keys: keys) }
108
+ track(thread)
109
+ thread
110
+ end
111
+
112
+ # Block until every spawned async rebuild has finished. Idempotent;
113
+ # safe to call from at_exit and directly from tests.
114
+ def drain
115
+ pending = @mutex.synchronize { @threads.dup }
116
+ pending.each(&:join)
117
+ @mutex.synchronize { @threads.delete_if { |t| !t.alive? } }
118
+ nil
119
+ end
120
+
121
+ private
122
+
123
+ def track(thread)
124
+ @mutex.synchronize do
125
+ @threads.delete_if { |t| !t.alive? }
126
+ @threads << thread
127
+ install_drain_hook
128
+ end
129
+ end
130
+
131
+ # Register the join-before-exit hook exactly once. Guarded by the
132
+ # caller holding @mutex.
133
+ def install_drain_hook
134
+ return if @hooked
135
+
136
+ @hooked = true
137
+ at_exit { drain }
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end