textus 0.49.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 (118) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +45 -0
  3. data/README.md +41 -43
  4. data/SPEC.md +176 -197
  5. data/docs/architecture/README.md +46 -42
  6. data/docs/reference/conventions.md +33 -28
  7. data/lib/textus/boot.rb +58 -47
  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 -4
  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 -8
  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 +9 -2
  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/boot.rb +4 -2
  77. data/lib/textus/read/deps.rb +3 -3
  78. data/lib/textus/read/freshness.rb +63 -29
  79. data/lib/textus/read/get.rb +20 -102
  80. data/lib/textus/read/rdeps.rb +3 -3
  81. data/lib/textus/read/rule_explain.rb +41 -23
  82. data/lib/textus/read/rule_list.rb +25 -8
  83. data/lib/textus/read/validate_all.rb +14 -0
  84. data/lib/textus/role.rb +2 -1
  85. data/lib/textus/schemas.rb +8 -0
  86. data/lib/textus/store.rb +1 -0
  87. data/lib/textus/version.rb +1 -1
  88. data/lib/textus/write/put.rb +1 -1
  89. metadata +23 -30
  90. data/lib/textus/builder/pipeline.rb +0 -88
  91. data/lib/textus/builder/renderer/json.rb +0 -45
  92. data/lib/textus/builder/renderer/markdown.rb +0 -24
  93. data/lib/textus/builder/renderer/text.rb +0 -14
  94. data/lib/textus/builder/renderer/yaml.rb +0 -45
  95. data/lib/textus/builder/renderer.rb +0 -17
  96. data/lib/textus/cli/verb/boot.rb +0 -13
  97. data/lib/textus/cli/verb/build.rb +0 -15
  98. data/lib/textus/doctor/check/fetch_locks.rb +0 -49
  99. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
  100. data/lib/textus/domain/freshness/policy.rb +0 -18
  101. data/lib/textus/domain/lifecycle.rb +0 -83
  102. data/lib/textus/domain/outcome.rb +0 -10
  103. data/lib/textus/domain/policy/lifecycle.rb +0 -35
  104. data/lib/textus/domain/staleness/generator_check.rb +0 -109
  105. data/lib/textus/domain/staleness.rb +0 -29
  106. data/lib/textus/maintenance/tend.rb +0 -110
  107. data/lib/textus/manifest/entry/derived.rb +0 -65
  108. data/lib/textus/manifest/entry/intake.rb +0 -31
  109. data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
  110. data/lib/textus/mcp/tools.rb +0 -14
  111. data/lib/textus/ports/fetch/detached.rb +0 -52
  112. data/lib/textus/ports/fetch/lock.rb +0 -44
  113. data/lib/textus/write/build.rb +0 -90
  114. data/lib/textus/write/fetch_events.rb +0 -42
  115. data/lib/textus/write/fetch_orchestrator.rb +0 -101
  116. data/lib/textus/write/fetch_worker.rb +0 -127
  117. data/lib/textus/write/intake_fetch.rb +0 -25
  118. data/lib/textus/write/materializer.rb +0 -51
@@ -1,49 +0,0 @@
1
- module Textus
2
- module Doctor
3
- class Check
4
- # Lists per-key fetch lock files under <root>/.run/locks/ whose
5
- # recorded PID is no longer running. These are forensic artifacts only:
6
- # Fetch::Lock uses flock(2), which the kernel releases on process
7
- # death, so stale files do not block subsequent acquires. The check
8
- # exists to let users clean up clutter and notice unexpected accumulation
9
- # (e.g. a fetch path that crashes repeatedly).
10
- class FetchLocks < Check
11
- def call
12
- dir = Textus::Layout.locks(root)
13
- return [] unless File.directory?(dir)
14
-
15
- Dir.glob(File.join(dir, "*.lock")).filter_map { |path| inspect_lock(path) }
16
- end
17
-
18
- private
19
-
20
- def inspect_lock(path)
21
- pid = File.read(path).strip.to_i
22
- return nil if pid.zero?
23
- return nil if pid_alive?(pid)
24
-
25
- {
26
- "code" => "fetch_lock.stale",
27
- "level" => "info",
28
- "subject" => path,
29
- "message" => "fetch lock file at #{path} records dead PID #{pid} " \
30
- "(does not block fetch; flock is kernel-released on exit)",
31
- "fix" => "safe to delete: rm #{path}",
32
- }
33
- rescue Errno::ENOENT
34
- nil
35
- end
36
-
37
- def pid_alive?(pid)
38
- Process.kill(0, pid)
39
- true
40
- rescue Errno::ESRCH
41
- false
42
- rescue Errno::EPERM
43
- # Process exists but owned by another user — treat as alive.
44
- true
45
- end
46
- end
47
- end
48
- end
49
- end
@@ -1,39 +0,0 @@
1
- module Textus
2
- module Doctor
3
- class Check
4
- # ADR 0079: refresh is valid only for intake entries; drop/archive are
5
- # invalid for intake entries (they would re-fetch, not prune).
6
- class LifecycleActionInvalid < Check
7
- def call
8
- manifest.data.entries.filter_map do |mentry|
9
- policy = manifest.rules.for(mentry.key).lifecycle
10
- next if policy.nil?
11
-
12
- intake = mentry.is_a?(Textus::Manifest::Entry::Intake)
13
- bad = (policy.on_expire == :refresh && !intake) || (policy.destructive? && intake)
14
- next unless bad
15
-
16
- issue_for(mentry, policy, intake)
17
- end
18
- end
19
-
20
- private
21
-
22
- def issue_for(mentry, policy, intake)
23
- {
24
- "code" => "lifecycle.action_invalid",
25
- "level" => "error",
26
- "subject" => mentry.key,
27
- "message" => "on_expire: #{policy.on_expire} is not valid for a " \
28
- "#{intake ? "intake" : "stored"} entry",
29
- "fix" => if intake
30
- "use on_expire: refresh|warn for intake entries"
31
- else
32
- "use on_expire: drop|archive|warn for stored entries"
33
- end,
34
- }
35
- end
36
- end
37
- end
38
- end
39
- end
@@ -1,18 +0,0 @@
1
- module Textus
2
- module Domain
3
- class 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::FetchSync.new
11
- when :timed_sync then Action::FetchTimed.new(budget_ms: sync_budget_ms)
12
- else Action::Return.new
13
- end
14
- end
15
- end
16
- end
17
- end
18
- end
@@ -1,83 +0,0 @@
1
- require "time"
2
-
3
- module Textus
4
- module Domain
5
- # Unified lifecycle reporter (ADR 0079): which entries are past their ttl,
6
- # and the on_expire action that applies. Replaces both Staleness::IntakeCheck
7
- # and Retention. Age basis: _meta.last_fetched_at (intake) when present, else
8
- # file mtime (stored). `self.verdict` is the pure per-entry decision that BOTH
9
- # this reporter and `Read::Get` (Plan 2) call, so the basis logic lives once.
10
- class Lifecycle
11
- # Pure: is the entry past its ttl? -> [expired(bool), reason(String|nil)].
12
- def self.verdict(policy:, last_fetched_at:, mtime:, now:)
13
- ttl = policy.ttl_seconds
14
- return [false, nil] if ttl.nil?
15
-
16
- basis = parse_time(last_fetched_at) || mtime
17
- return [true, "never recorded"] if basis.nil?
18
-
19
- age = (now - basis).to_i
20
- age > ttl ? [true, "ttl exceeded (age=#{age}s, ttl=#{ttl}s)"] : [false, nil]
21
- end
22
-
23
- def self.parse_time(str)
24
- return nil if str.nil?
25
-
26
- Time.parse(str.to_s)
27
- rescue ArgumentError, TypeError
28
- nil
29
- end
30
-
31
- def initialize(manifest:, file_stat:, clock:)
32
- @manifest = manifest
33
- @file_stat = file_stat
34
- @clock = clock
35
- end
36
-
37
- def call(prefix: nil, zone: nil)
38
- @manifest.data.entries
39
- .select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
40
- .flat_map { |m| rows_for(m) }
41
- end
42
-
43
- private
44
-
45
- def entry_matches?(mentry, prefix:, zone:)
46
- return false if zone && mentry.zone != zone
47
- return false if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
48
-
49
- true
50
- end
51
-
52
- def rows_for(mentry)
53
- policy = @manifest.rules.for(mentry.key).lifecycle
54
- return [] if policy.nil?
55
-
56
- @manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
57
- path = row[:path]
58
- next unless @file_stat.exists?(path)
59
-
60
- expired, _reason = self.class.verdict(
61
- policy: policy,
62
- last_fetched_at: last_fetched_at_of(mentry, path),
63
- mtime: @file_stat.mtime(path),
64
- now: @clock.now,
65
- )
66
- next unless expired
67
-
68
- {
69
- "key" => row[:key], "path" => path,
70
- "action" => policy.on_expire.to_s, "expired" => true
71
- }
72
- end
73
- end
74
-
75
- # Reads _meta.last_fetched_at from the on-disk envelope (intake basis).
76
- def last_fetched_at_of(mentry, path)
77
- Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["last_fetched_at"]
78
- rescue StandardError
79
- nil
80
- end
81
- end
82
- end
83
- end
@@ -1,10 +0,0 @@
1
- module Textus
2
- module Domain
3
- module Outcome
4
- Skipped = Data.define
5
- Fetched = Data.define(:envelope)
6
- Detached = Data.define
7
- Failed = Data.define(:error)
8
- end
9
- end
10
- end
@@ -1,35 +0,0 @@
1
- module Textus
2
- module Domain
3
- module Policy
4
- # Unified per-entry lifecycle policy (ADR 0079): one ttl + one action.
5
- # Replaces the separate Fetch (ttl/on_stale) and Retention
6
- # (expire_after/archive_after) policies. The action's destructiveness
7
- # decides WHERE it runs: lazy actions (refresh/warn) on get/list reads;
8
- # destructive actions (drop/archive) only on the tend sweep.
9
- class Lifecycle
10
- LAZY = %i[refresh warn].freeze
11
- DESTRUCTIVE = %i[drop archive].freeze
12
- ALLOWED = (LAZY + DESTRUCTIVE).freeze
13
-
14
- attr_reader :on_expire, :budget_ms
15
-
16
- def initialize(ttl:, on_expire:, budget_ms: nil)
17
- action = on_expire.is_a?(Symbol) ? on_expire : on_expire.to_s.to_sym
18
- unless ALLOWED.include?(action)
19
- raise Textus::UsageError.new(
20
- "lifecycle on_expire must be one of #{ALLOWED.join("|")}, got #{on_expire.inspect}",
21
- )
22
- end
23
-
24
- @ttl = ttl
25
- @on_expire = action
26
- @budget_ms = budget_ms
27
- end
28
-
29
- def ttl_seconds = Textus::Domain::Duration.seconds(@ttl)
30
- def destructive? = DESTRUCTIVE.include?(@on_expire)
31
- def lazy? = LAZY.include?(@on_expire)
32
- end
33
- end
34
- end
35
- end
@@ -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,65 +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, :events
9
-
10
- def initialize(source:, template: nil, inject_boot: false, events: {}, **rest)
11
- super(**rest)
12
- @source = source
13
- @template = template
14
- @inject_boot = inject_boot
15
- @events = events || {}
16
- end
17
-
18
- def derived? = true
19
- def projection? = @source.is_a?(Projection)
20
- def external? = @source.is_a?(External)
21
-
22
- def publish_via(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
23
- return nil unless in_generator_zone?(pctx.manifest.policy)
24
- # External entries are produced by an out-of-band runner — textus has
25
- # no in-process runner. The build path only tracks their staleness
26
- # (Domain::Staleness::GeneratorCheck); materializing here would clobber
27
- # the runner's artifact with an empty render. Skip the build entirely.
28
- return nil if external?
29
-
30
- target_path = Textus::Write::Materializer.new(
31
- container: pctx.container, call: pctx.call,
32
- ).run(self)
33
-
34
- envelope = pctx.reader.call(@key)
35
- Array(publish_to).each do |rel|
36
- target_abs = File.join(pctx.repo_root, rel)
37
- Textus::Ports::Publisher.publish(source: target_path, target: target_abs, store_root: pctx.root)
38
- pctx.emit(:file_published, key: @key, envelope: envelope, source: target_path, target: target_abs)
39
- end
40
-
41
- src = @source
42
- selects = src.is_a?(Projection) ? Array(src.select).compact : []
43
- pctx.emit(:build_completed, key: @key, envelope: envelope, sources: selects)
44
-
45
- { kind: :built, value: { "key" => @key, "path" => target_path, "published_to" => publish_to } }
46
- end
47
-
48
- KIND = :derived
49
-
50
- def self.from_raw(common, raw)
51
- source = Parser.parse_source(raw, common[:key])
52
- new(
53
- source: source,
54
- template: raw["template"],
55
- inject_boot: raw["inject_boot"] == true,
56
- events: raw["events"] || {},
57
- **common,
58
- )
59
- end
60
-
61
- Entry::REGISTRY[KIND] = self
62
- end
63
- end
64
- end
65
- 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