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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +45 -0
- data/README.md +41 -43
- data/SPEC.md +176 -197
- data/docs/architecture/README.md +46 -42
- data/docs/reference/conventions.md +33 -28
- data/lib/textus/boot.rb +58 -47
- data/lib/textus/call.rb +1 -1
- data/lib/textus/cli/runner.rb +15 -10
- data/lib/textus/cli/verb/get.rb +1 -3
- data/lib/textus/cli/verb/hook_run.rb +1 -1
- data/lib/textus/cli/verb/put.rb +4 -20
- data/lib/textus/cli.rb +1 -4
- data/lib/textus/dispatcher.rb +1 -3
- data/lib/textus/doctor/check/generator_drift.rb +4 -3
- data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
- data/lib/textus/doctor/check/intake_registration.rb +5 -5
- data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +13 -11
- data/lib/textus/doctor.rb +0 -2
- data/lib/textus/domain/freshness/evaluator.rb +150 -14
- data/lib/textus/domain/freshness/verdict.rb +28 -6
- data/lib/textus/domain/freshness.rb +4 -33
- data/lib/textus/domain/policy/base_guards.rb +1 -1
- data/lib/textus/domain/policy/predicates/fresh_within.rb +1 -1
- data/lib/textus/domain/policy/publish_target.rb +34 -0
- data/lib/textus/domain/policy/retention.rb +29 -0
- data/lib/textus/domain/policy/source.rb +79 -0
- data/lib/textus/domain/retention/sweep.rb +57 -0
- data/lib/textus/domain/retention.rb +11 -0
- data/lib/textus/errors.rb +4 -4
- data/lib/textus/hooks/builtin.rb +5 -5
- data/lib/textus/hooks/catalog.rb +8 -7
- data/lib/textus/hooks/context.rb +5 -10
- data/lib/textus/init/templates/machine_intake.rb +4 -4
- data/lib/textus/init.rb +47 -47
- data/lib/textus/key/matching.rb +24 -0
- data/lib/textus/maintenance/reconcile.rb +160 -0
- data/lib/textus/manifest/capabilities.rb +1 -1
- data/lib/textus/manifest/data.rb +2 -2
- data/lib/textus/manifest/entry/base.rb +28 -8
- data/lib/textus/manifest/entry/nested.rb +3 -4
- data/lib/textus/manifest/entry/parser.rb +25 -21
- data/lib/textus/manifest/entry/produced.rb +56 -0
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +7 -6
- data/lib/textus/manifest/entry/publish/to_paths.rb +62 -11
- data/lib/textus/manifest/entry/validators/format_matrix.rb +3 -11
- data/lib/textus/manifest/entry/validators/publish.rb +3 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/manifest/policy.rb +16 -4
- data/lib/textus/manifest/resolver.rb +10 -4
- data/lib/textus/manifest/rules.rb +37 -36
- data/lib/textus/manifest/schema/keys.rb +98 -0
- data/lib/textus/manifest/schema/validator.rb +324 -0
- data/lib/textus/manifest/schema/vocabulary.rb +24 -0
- data/lib/textus/manifest/schema.rb +27 -247
- data/lib/textus/manifest.rb +5 -3
- data/lib/textus/mcp/server.rb +9 -2
- data/lib/textus/ports/audit_log.rb +6 -0
- data/lib/textus/ports/build_lock.rb +6 -0
- data/lib/textus/ports/clock.rb +4 -3
- data/lib/textus/ports/produce_on_write_subscriber.rb +69 -0
- data/lib/textus/ports/publisher.rb +11 -7
- data/lib/textus/produce/acquire/handler.rb +29 -0
- data/lib/textus/produce/acquire/intake.rb +130 -0
- data/lib/textus/produce/acquire/projection.rb +127 -0
- data/lib/textus/produce/acquire/serializer/json.rb +31 -0
- data/lib/textus/produce/acquire/serializer/text.rb +16 -0
- data/lib/textus/produce/acquire/serializer/yaml.rb +31 -0
- data/lib/textus/produce/acquire/serializer.rb +17 -0
- data/lib/textus/produce/engine.rb +143 -0
- data/lib/textus/produce/events.rb +36 -0
- data/lib/textus/produce/render.rb +23 -0
- data/lib/textus/projection.rb +17 -6
- data/lib/textus/read/boot.rb +4 -2
- data/lib/textus/read/deps.rb +3 -3
- data/lib/textus/read/freshness.rb +63 -29
- data/lib/textus/read/get.rb +20 -102
- data/lib/textus/read/rdeps.rb +3 -3
- data/lib/textus/read/rule_explain.rb +41 -23
- data/lib/textus/read/rule_list.rb +25 -8
- data/lib/textus/read/validate_all.rb +14 -0
- data/lib/textus/role.rb +2 -1
- data/lib/textus/schemas.rb +8 -0
- data/lib/textus/store.rb +1 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/put.rb +1 -1
- metadata +23 -30
- data/lib/textus/builder/pipeline.rb +0 -88
- data/lib/textus/builder/renderer/json.rb +0 -45
- data/lib/textus/builder/renderer/markdown.rb +0 -24
- data/lib/textus/builder/renderer/text.rb +0 -14
- data/lib/textus/builder/renderer/yaml.rb +0 -45
- data/lib/textus/builder/renderer.rb +0 -17
- data/lib/textus/cli/verb/boot.rb +0 -13
- data/lib/textus/cli/verb/build.rb +0 -15
- data/lib/textus/doctor/check/fetch_locks.rb +0 -49
- data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
- data/lib/textus/domain/freshness/policy.rb +0 -18
- data/lib/textus/domain/lifecycle.rb +0 -83
- data/lib/textus/domain/outcome.rb +0 -10
- data/lib/textus/domain/policy/lifecycle.rb +0 -35
- data/lib/textus/domain/staleness/generator_check.rb +0 -109
- data/lib/textus/domain/staleness.rb +0 -29
- data/lib/textus/maintenance/tend.rb +0 -110
- data/lib/textus/manifest/entry/derived.rb +0 -65
- data/lib/textus/manifest/entry/intake.rb +0 -31
- data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
- data/lib/textus/mcp/tools.rb +0 -14
- data/lib/textus/ports/fetch/detached.rb +0 -52
- data/lib/textus/ports/fetch/lock.rb +0 -44
- data/lib/textus/write/build.rb +0 -90
- data/lib/textus/write/fetch_events.rb +0 -42
- data/lib/textus/write/fetch_orchestrator.rb +0 -101
- data/lib/textus/write/fetch_worker.rb +0 -127
- data/lib/textus/write/intake_fetch.rb +0 -25
- 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,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
|
data/lib/textus/mcp/tools.rb
DELETED
|
@@ -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
|