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
@@ -1,109 +0,0 @@
1
- require "time"
2
-
3
- module Textus
4
- module Domain
5
- class Staleness
6
- # Reports staleness for generator-zone entries — derived files whose
7
- # generator's listed sources have been modified more recently than the
8
- # entry's `_meta.generated.at` timestamp. Returns an Array of row hashes
9
- # (possibly empty) per entry.
10
- class GeneratorCheck
11
- def initialize(manifest:, file_stat:)
12
- @manifest = manifest
13
- @file_stat = file_stat
14
- end
15
-
16
- def rows_for(mentry)
17
- return [] unless applicable?(mentry)
18
-
19
- path = Textus::Key::Path.resolve(@manifest.data, mentry)
20
- reason = stale_reason(mentry, path)
21
- reason ? [stale_row(mentry, path, reason)] : []
22
- end
23
-
24
- private
25
-
26
- def applicable?(mentry)
27
- mentry.in_generator_zone?(@manifest.policy) &&
28
- mentry.is_a?(Textus::Manifest::Entry::Derived) &&
29
- mentry.source.is_a?(Textus::Manifest::Entry::Derived::External)
30
- end
31
-
32
- def stale_reason(mentry, path)
33
- return "derived entry has never been generated" unless @file_stat.exists?(path)
34
-
35
- generated_at = generated_at_of(mentry, path)
36
- return "missing generated.at frontmatter" unless generated_at
37
-
38
- gen_time = parse_time(generated_at)
39
- return "unparseable generated.at: #{generated_at.inspect}" unless gen_time
40
-
41
- offender = newest_source_after(mentry.source, gen_time)
42
- "source '#{offender}' modified after generated.at" if offender
43
- end
44
-
45
- def generated_at_of(mentry, path)
46
- Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"].dig("generated", "at")
47
- end
48
-
49
- def parse_time(str)
50
- Time.parse(str.to_s)
51
- rescue StandardError
52
- nil
53
- end
54
-
55
- def newest_source_after(external_src, gen_time)
56
- Array(external_src.sources).each do |src|
57
- offender = check_source(src, gen_time)
58
- return offender if offender
59
- end
60
- nil
61
- end
62
-
63
- def check_source(src, gen_time)
64
- if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
65
- @manifest.resolver.enumerate(prefix: src).each do |row|
66
- return src if @file_stat.mtime(row[:path]) > gen_time
67
- end
68
- nil
69
- else
70
- check_filesystem_source(src, gen_time)
71
- end
72
- end
73
-
74
- def check_filesystem_source(src, gen_time)
75
- abs = absolutize_source(src)
76
- if @file_stat.directory?(abs)
77
- dir_has_newer_file?(abs, gen_time) ? src : nil
78
- elsif @file_stat.exists?(abs) && @file_stat.mtime(abs) > gen_time
79
- src
80
- end
81
- end
82
-
83
- def absolutize_source(src)
84
- File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.data.root), src)
85
- end
86
-
87
- def dir_has_newer_file?(abs, gen_time)
88
- @file_stat.glob(File.join(abs, "**", "*")).any? do |fpath|
89
- file?(fpath) && @file_stat.mtime(fpath) > gen_time
90
- end
91
- end
92
-
93
- # FileStat substitute for File.file?: excludes directories but treats
94
- # special files (FIFOs/sockets/devices) as regular files — acceptable
95
- # because a generator source tree won't contain them.
96
- def file?(fpath) = !@file_stat.directory?(fpath) && @file_stat.exists?(fpath)
97
-
98
- def stale_row(mentry, path, reason)
99
- {
100
- "key" => mentry.key,
101
- "path" => path,
102
- "generator" => mentry.raw["compute"],
103
- "reason" => reason,
104
- }
105
- end
106
- end
107
- end
108
- end
109
- end
@@ -1,29 +0,0 @@
1
- module Textus
2
- module Domain
3
- class Staleness
4
- # ADR 0079: intake (age-based) staleness moved to the unified lifecycle
5
- # path (Domain::Lifecycle / freshness); only generator/build drift —
6
- # dependency-based, surfaced by the doctor `generator_drift` check —
7
- # remains here.
8
- def initialize(manifest:, file_stat:, clock: nil) # rubocop:disable Lint/UnusedMethodArgument
9
- @manifest = manifest
10
- @generator_check = GeneratorCheck.new(manifest: manifest, file_stat: file_stat)
11
- end
12
-
13
- def call(prefix: nil, zone: nil)
14
- @manifest.data.entries
15
- .select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
16
- .flat_map { |m| @generator_check.rows_for(m) }
17
- end
18
-
19
- private
20
-
21
- def entry_matches?(mentry, prefix:, zone:)
22
- return false if zone && mentry.zone != zone
23
- return false if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
24
-
25
- true
26
- end
27
- end
28
- end
29
- end
@@ -1,110 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Maintenance
5
- # The destructive-only lifecycle sweep (ADR 0079, supersedes the composite
6
- # 0078 body). Drives off the unified Domain::Lifecycle reporter: it applies
7
- # destructive actions a read never performs (drop = delete via Write::KeyDelete;
8
- # archive = copy to <store>/archive/ then delete) and refreshes cold expired
9
- # intake entries (on_expire: refresh) via Write::FetchWorker. Non-destructive
10
- # annotation (warn) is left to the lazy `get`/`freshness` path. Adds no new
11
- # authority — every sub-op runs with the CALLER's own `call` (role), and is
12
- # gated exactly as on its own.
13
- class Tend
14
- extend Textus::Contract::DSL
15
-
16
- verb :tend
17
- summary "Run the destructive lifecycle sweep: drop/archive expired entries, refresh cold intake, report health."
18
- surfaces :cli, :mcp
19
- cli "tend"
20
- arg :prefix, String, description: "restrict the sweep to keys under this dotted prefix"
21
- arg :zone, String, description: "restrict the sweep to entries in this zone"
22
- arg :dry_run, :boolean, default: false,
23
- description: "when true, report what the sweep WOULD do without applying; " \
24
- "defaults to false, so omitting it drops/archives/refreshes immediately"
25
-
26
- def initialize(container:, call:)
27
- @container = container
28
- @call = call
29
- end
30
-
31
- def call(prefix: nil, zone: nil, dry_run: false)
32
- rows = Textus::Domain::Lifecycle.new(
33
- manifest: @container.manifest,
34
- file_stat: Textus::Ports::Storage::FileStat.new,
35
- clock: Textus::Ports::Clock,
36
- ).call(prefix: prefix, zone: zone)
37
-
38
- health = Read::Doctor.new(container: @container, call: @call).call
39
- return dry_run_result(rows, health) if dry_run
40
-
41
- apply_result(apply(rows), health)
42
- end
43
-
44
- private
45
-
46
- def dry_run_result(rows, health)
47
- {
48
- "protocol" => Textus::PROTOCOL, "ok" => true, "dry_run" => true,
49
- "would_drop" => action_keys(rows, "drop"),
50
- "would_archive" => action_keys(rows, "archive"),
51
- "would_refresh" => action_keys(rows, "refresh"),
52
- "health" => health
53
- }
54
- end
55
-
56
- def apply_result(result, health)
57
- {
58
- "protocol" => Textus::PROTOCOL,
59
- "ok" => result[:failed].empty?,
60
- "dry_run" => false,
61
- "dropped" => result[:dropped], "archived" => result[:archived],
62
- "refreshed" => result[:refreshed], "failed" => result[:failed],
63
- "health" => health
64
- }
65
- end
66
-
67
- def action_keys(rows, action)
68
- rows.select { |r| r["action"] == action }.map { |r| r["key"] }
69
- end
70
-
71
- def apply(rows)
72
- out = { dropped: [], archived: [], refreshed: [], failed: [] }
73
- delete = Write::KeyDelete.new(container: @container, call: @call)
74
- refresh = Write::FetchWorker.new(container: @container, call: @call)
75
-
76
- rows.each do |row|
77
- key = row["key"]
78
- begin
79
- case row["action"]
80
- when "drop"
81
- delete.call(key)
82
- out[:dropped] << key
83
- when "archive"
84
- archive_leaf(row)
85
- delete.call(key)
86
- out[:archived] << key
87
- when "refresh"
88
- refresh.run(key)
89
- out[:refreshed] << key
90
- end
91
- rescue Textus::Error => e
92
- out[:failed] << { "key" => key, "error" => e.message }
93
- end
94
- end
95
- out
96
- end
97
-
98
- # Copy the leaf into <store>/archive/<relative-path> before deletion.
99
- # (Lifted from the retired RetentionSweep#archive_leaf.)
100
- def archive_leaf(row)
101
- src = row["path"]
102
- root = @container.root.to_s
103
- rel = src.delete_prefix("#{root}/")
104
- dest = File.join(root, "archive", rel)
105
- FileUtils.mkdir_p(File.dirname(dest))
106
- FileUtils.cp(src, dest)
107
- end
108
- end
109
- end
110
- end
@@ -1,67 +0,0 @@
1
- module Textus
2
- class Manifest
3
- class Entry
4
- class Derived < Base
5
- Projection = ::Data.define(:select, :pluck, :sort_by, :transform)
6
- External = ::Data.define(:sources, :command)
7
-
8
- attr_reader :source, :template, :inject_boot, :provenance, :events
9
-
10
- def initialize(source:, template: nil, inject_boot: false, provenance: true, events: {}, **rest)
11
- super(**rest)
12
- @source = source
13
- @template = template
14
- @inject_boot = inject_boot
15
- @provenance = provenance
16
- @events = events || {}
17
- end
18
-
19
- def derived? = true
20
- def projection? = @source.is_a?(Projection)
21
- def external? = @source.is_a?(External)
22
-
23
- def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
24
- return nil unless in_generator_zone?(pctx.manifest.policy)
25
- # External entries are produced by an out-of-band runner — textus has
26
- # no in-process runner. The build path only tracks their staleness
27
- # (Domain::Staleness::GeneratorCheck); materializing here would clobber
28
- # the runner's artifact with an empty render. Skip the build entirely.
29
- return nil if external?
30
-
31
- target_path = Textus::Write::Materializer.new(
32
- container: pctx.container, call: pctx.call,
33
- ).run(self)
34
-
35
- envelope = pctx.reader.call(@key)
36
- Array(publish_to).each do |rel|
37
- target_abs = File.join(pctx.repo_root, rel)
38
- Textus::Ports::Publisher.publish(source: target_path, target: target_abs, store_root: pctx.root)
39
- pctx.emit(:file_published, key: @key, envelope: envelope, source: target_path, target: target_abs)
40
- end
41
-
42
- src = @source
43
- selects = src.is_a?(Projection) ? Array(src.select).compact : []
44
- pctx.emit(:build_completed, key: @key, envelope: envelope, sources: selects)
45
-
46
- { kind: :built, value: { "key" => @key, "path" => target_path, "published_to" => publish_to } }
47
- end
48
-
49
- KIND = :derived
50
-
51
- def self.from_raw(common, raw)
52
- source = Parser.parse_source(raw, common[:key])
53
- new(
54
- source: source,
55
- template: raw["template"],
56
- inject_boot: raw["inject_boot"] == true,
57
- provenance: raw.fetch("provenance", true) != false,
58
- events: raw["events"] || {},
59
- **common,
60
- )
61
- end
62
-
63
- Entry::REGISTRY[KIND] = self
64
- end
65
- end
66
- end
67
- end
@@ -1,31 +0,0 @@
1
- module Textus
2
- class Manifest
3
- class Entry
4
- class Intake < Base
5
- attr_reader :handler, :config, :events
6
-
7
- def initialize(handler:, config: {}, events: {}, **rest)
8
- super(**rest)
9
- @handler = handler
10
- @config = config || {}
11
- @events = events || {}
12
- end
13
-
14
- def intake? = true
15
- def nested? = !!@raw["nested"]
16
-
17
- KIND = :intake
18
-
19
- def self.from_raw(common, raw)
20
- intake = raw["intake"] || {}
21
- handler = intake["handler"] || raw["intake_handler"] or
22
- raise UsageError.new("intake entry '#{common[:key]}' missing handler")
23
- config = intake["config"] || raw["intake_config"] || {}
24
- new(handler: handler, config: config, events: raw["events"] || {}, **common)
25
- end
26
-
27
- Entry::REGISTRY[KIND] = self
28
- end
29
- end
30
- end
31
- end
@@ -1,21 +0,0 @@
1
- module Textus
2
- class Manifest
3
- class Entry
4
- module Validators
5
- module InjectBoot
6
- def self.call(entry, policy:)
7
- return unless entry.inject_boot
8
-
9
- unless entry.in_generator_zone?(policy)
10
- raise UsageError.new("entry '#{entry.key}': inject_boot: is only valid on derived entries")
11
- end
12
-
13
- return unless entry.template.nil?
14
-
15
- raise UsageError.new("entry '#{entry.key}': inject_boot: requires a template:")
16
- end
17
- end
18
- end
19
- end
20
- end
21
- end
@@ -1,14 +0,0 @@
1
- module Textus
2
- module MCP
3
- # Thin delegator kept for name stability (ADR 0039). The dispatch table
4
- # and JSON schemas are now DERIVED from per-verb contracts by MCP::Catalog;
5
- # this module only forwards.
6
- module Tools
7
- module_function
8
-
9
- def call(name, session:, store:, args:)
10
- Catalog.call(name, session: session, store: store, args: args || {})
11
- end
12
- end
13
- end
14
- end
@@ -1,52 +0,0 @@
1
- module Textus
2
- module Ports
3
- module Fetch
4
- module Detached
5
- module_function
6
-
7
- def supported?
8
- Process.respond_to?(:fork)
9
- end
10
-
11
- def acting_role(store)
12
- store.manifest.policy.actor_for("fetch")
13
- end
14
-
15
- def spawn(store_root:, key:)
16
- return nil unless supported?
17
-
18
- pid = Process.fork do
19
- $stdin.close
20
- $stdout.reopen(File::NULL, "w")
21
- $stderr.reopen(File::NULL, "w")
22
-
23
- lock = Textus::Ports::Fetch::Lock.new(root: store_root, key: key)
24
- exit(0) unless lock.try_acquire
25
-
26
- begin
27
- store = Textus::Store.new(store_root)
28
- # No fetch-holder configured — exit the child cleanly. In practice
29
- # this is unreachable: the background fork only happens after a
30
- # foreground fetch was already authorized (so a fetch-holder
31
- # exists). Config-time detection is doctor's job (ADR 0044 Q2).
32
- role = acting_role(store)
33
- exit(0) unless role
34
- # FetchWorker is the internal executor since the public `fetch`
35
- # verb was collapsed (ADR 0079); drive it directly.
36
- Textus::Write::FetchWorker.new(
37
- container: store.container, call: Textus::Call.build(role: role),
38
- ).run(key)
39
- rescue StandardError
40
- # Already logged via :fetch_failed; exit cleanly.
41
- ensure
42
- lock.release
43
- exit(0)
44
- end
45
- end
46
- Process.detach(pid)
47
- pid
48
- end
49
- end
50
- end
51
- end
52
- end
@@ -1,44 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Ports
5
- module Fetch
6
- class Lock
7
- def initialize(root:, key:)
8
- @root = root
9
- @key = key
10
- @path = File.join(Textus::Layout.locks(root), "#{safe_key}.lock")
11
- @file = nil
12
- end
13
-
14
- def try_acquire # rubocop:disable Naming/PredicateMethod
15
- FileUtils.mkdir_p(File.dirname(@path))
16
- @file = File.open(@path, File::RDWR | File::CREAT, 0o644)
17
- acquired = @file.flock(File::LOCK_EX | File::LOCK_NB)
18
- unless acquired
19
- @file.close
20
- @file = nil
21
- return false
22
- end
23
- @file.write(Process.pid.to_s)
24
- @file.flush
25
- true
26
- end
27
-
28
- def release
29
- return unless @file
30
-
31
- @file.flock(File::LOCK_UN)
32
- @file.close
33
- @file = nil
34
- end
35
-
36
- private
37
-
38
- def safe_key
39
- @key.to_s.gsub(/[^a-zA-Z0-9._-]/, "_")
40
- end
41
- end
42
- end
43
- end
44
- end
@@ -1,90 +0,0 @@
1
- module Textus
2
- module Write
3
- # Single-pass build use case (the verb `build`, ADR 0061): dispatches
4
- # polymorphically to each entry's `publish_via` method — the copy-out step
5
- # (`publish` is the output-destination concept the verb drives, not the verb).
6
- # Derived entries materialize their body via Materializer; Nested entries
7
- # mirror their subtree via publish_tree; Leaf and Intake entries copy their
8
- # stored body to publish_to targets. The Build layer owns wiring (context,
9
- # accumulation) but not per-kind logic.
10
- #
11
- # Return shape: { "protocol", "built", "published_leaves" }
12
- class Build
13
- extend Textus::Contract::DSL
14
-
15
- verb :build
16
- summary "materialize derived entries; publish_to and publish_tree fan out copies"
17
- surfaces :cli, :mcp
18
- cli "build"
19
- around :build_lock
20
- arg :prefix, String, required: false, description: "limit the build to keys under this prefix"
21
-
22
- def initialize(container:, call:)
23
- @container = container
24
- @call = call
25
- @manifest = container.manifest
26
- end
27
-
28
- def call(prefix: nil)
29
- build_role = @manifest.policy.actor_for("build") or
30
- raise Textus::UsageError.new(
31
- "no role holds the 'build' capability",
32
- hint: "declare a role with `can: [build]` in .textus/manifest.yaml",
33
- )
34
- build_call = Textus::Call.build(
35
- role: build_role,
36
- correlation_id: @call.correlation_id,
37
- dry_run: @call.dry_run,
38
- )
39
-
40
- built = []
41
- leaves = []
42
- pruned = []
43
- context = build_context(build_call)
44
-
45
- @manifest.data.entries.each do |mentry|
46
- next if prefix && !entry_matches_prefix?(mentry, prefix)
47
-
48
- result = mentry.publish_via(context, prefix: prefix)
49
- next if result.nil?
50
-
51
- case result[:kind]
52
- when :built then built << result[:value]
53
- when :leaves
54
- leaves.concat(result[:value])
55
- pruned.concat(result[:pruned]) if result[:pruned]
56
- end
57
- end
58
-
59
- { "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => leaves, "pruned" => pruned }
60
- end
61
-
62
- private
63
-
64
- def build_context(call)
65
- Textus::Manifest::Entry::Base::PublishContext.new(
66
- container: @container,
67
- call: call,
68
- reader: reader(call),
69
- )
70
- end
71
-
72
- # Whether the entry should be processed for the given prefix filter.
73
- def entry_matches_prefix?(mentry, prefix)
74
- return true unless prefix
75
-
76
- case mentry
77
- when Textus::Manifest::Entry::Nested
78
- mentry.key.start_with?(prefix) ||
79
- prefix.start_with?("#{mentry.key}.")
80
- else
81
- mentry.key.start_with?(prefix)
82
- end
83
- end
84
-
85
- def reader(call)
86
- Textus::Read::Get.new(container: @container, call: call)
87
- end
88
- end
89
- end
90
- end
@@ -1,42 +0,0 @@
1
- module Textus
2
- module Write
3
- # Single home for the fetch lifecycle event vocabulary (ADR 0048 D5). Both
4
- # FetchWorker (synchronous semantics) and FetchOrchestrator (async policy)
5
- # emit through this seam so the event names and payload shapes live in one
6
- # place with one derived hook context.
7
- class FetchEvents
8
- def self.from(container:, call:)
9
- new(
10
- events: container.events,
11
- hook_context: Textus::Hooks::Context.for(container: container, call: call),
12
- )
13
- end
14
-
15
- def initialize(events:, hook_context:)
16
- @events = events
17
- @hook_context = hook_context
18
- end
19
-
20
- def started(key, mode: :sync)
21
- @events.publish(:fetch_started, ctx: @hook_context, key: key, mode: mode)
22
- end
23
-
24
- def failed(key, error)
25
- @events.publish(:fetch_failed, ctx: @hook_context, key: key,
26
- error_class: error.class.name, error_message: error.message)
27
- end
28
-
29
- def fetched(key, envelope, change)
30
- return if change == :unchanged
31
-
32
- @events.publish(:entry_fetched, ctx: @hook_context, key: key, envelope: envelope, change: change)
33
- end
34
-
35
- def backgrounded(key, started_at:, budget_ms:)
36
- payload = { key: key, started_at: started_at, budget_ms: budget_ms }
37
- payload[:ctx] = @hook_context if @hook_context
38
- @events.publish(:fetch_backgrounded, **payload)
39
- end
40
- end
41
- end
42
- end