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,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
|
data/lib/textus/write/build.rb
DELETED
|
@@ -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
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Write
|
|
3
|
-
class FetchOrchestrator
|
|
4
|
-
# Collaborator (not a Dispatcher verb): constructed directly by FetchWorker /
|
|
5
|
-
# Read::Get, which pass their derived hook_context in. That's why this takes
|
|
6
|
-
# hook_context: explicitly while verb use cases derive their own.
|
|
7
|
-
def initialize(worker:, store_root:, events:, hook_context: nil, detached_spawner: nil)
|
|
8
|
-
@worker = worker
|
|
9
|
-
@store_root = store_root
|
|
10
|
-
@events = events
|
|
11
|
-
@hook_context = hook_context
|
|
12
|
-
@detached_spawner = detached_spawner || default_spawner
|
|
13
|
-
@fetch_events = Textus::Write::FetchEvents.new(events: @events, hook_context: @hook_context)
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def execute(action, key:)
|
|
17
|
-
case action
|
|
18
|
-
when Textus::Domain::Action::Return then Textus::Domain::Outcome::Skipped.new
|
|
19
|
-
when Textus::Domain::Action::FetchSync then run_sync(key)
|
|
20
|
-
when Textus::Domain::Action::FetchTimed then run_timed(action.budget_ms, key)
|
|
21
|
-
else raise ArgumentError.new("unknown action: #{action.inspect}")
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
private
|
|
26
|
-
|
|
27
|
-
def run_sync(key)
|
|
28
|
-
envelope = @worker.run(key)
|
|
29
|
-
Textus::Domain::Outcome::Fetched.new(envelope: envelope)
|
|
30
|
-
rescue Textus::Error => e
|
|
31
|
-
Textus::Domain::Outcome::Failed.new(error: e)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def run_timed(budget_ms, key)
|
|
35
|
-
return run_timed_with_fork(budget_ms, key) if Textus::Ports::Fetch::Detached.supported?
|
|
36
|
-
|
|
37
|
-
run_timed_cooperative(budget_ms, key)
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
def run_timed_cooperative(budget_ms, key)
|
|
41
|
-
result = nil
|
|
42
|
-
thread = Thread.new do
|
|
43
|
-
result = @worker.run(key)
|
|
44
|
-
rescue Textus::Error => e
|
|
45
|
-
result = e
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
thread.join(budget_ms / 1000.0)
|
|
49
|
-
if thread.alive?
|
|
50
|
-
thread.kill
|
|
51
|
-
return Textus::Domain::Outcome::Failed.new(
|
|
52
|
-
error: Textus::UsageError.new(
|
|
53
|
-
"fetch exceeded budget #{budget_ms}ms (no fork available — cooperative cancel)",
|
|
54
|
-
),
|
|
55
|
-
)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
if result.is_a?(Textus::Error)
|
|
59
|
-
Textus::Domain::Outcome::Failed.new(error: result)
|
|
60
|
-
else
|
|
61
|
-
Textus::Domain::Outcome::Fetched.new(envelope: result)
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def run_timed_with_fork(budget_ms, key)
|
|
66
|
-
result = nil
|
|
67
|
-
thread = Thread.new do
|
|
68
|
-
result = @worker.run(key)
|
|
69
|
-
rescue Textus::Error => e
|
|
70
|
-
result = e
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
thread.join(budget_ms / 1000.0)
|
|
74
|
-
|
|
75
|
-
if thread.alive?
|
|
76
|
-
thread.kill
|
|
77
|
-
|
|
78
|
-
# Single-flight: if a sibling process / earlier fork holds the
|
|
79
|
-
# per-leaf lock, don't fork another worker — they're already
|
|
80
|
-
# doing this work.
|
|
81
|
-
probe = Textus::Ports::Fetch::Lock.new(root: @store_root, key: key)
|
|
82
|
-
return Textus::Domain::Outcome::Detached.new unless probe.try_acquire
|
|
83
|
-
|
|
84
|
-
probe.release
|
|
85
|
-
|
|
86
|
-
@fetch_events.backgrounded(key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms)
|
|
87
|
-
@detached_spawner.call(store_root: @store_root, key: key)
|
|
88
|
-
Textus::Domain::Outcome::Detached.new
|
|
89
|
-
elsif result.is_a?(Textus::Error)
|
|
90
|
-
Textus::Domain::Outcome::Failed.new(error: result)
|
|
91
|
-
else
|
|
92
|
-
Textus::Domain::Outcome::Fetched.new(envelope: result)
|
|
93
|
-
end
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def default_spawner
|
|
97
|
-
Textus::Ports::Fetch::Detached.method(:spawn)
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
@@ -1,127 +0,0 @@
|
|
|
1
|
-
require "timeout"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Write
|
|
5
|
-
# Internal fetch executor for one quarantine/intake entry. No longer a
|
|
6
|
-
# public verb (ADR 0079 collapsed the `fetch` surface): used by `get`'s
|
|
7
|
-
# orchestrator (read-through refresh) and by the `tend` sweep.
|
|
8
|
-
class FetchWorker
|
|
9
|
-
FETCH_TIMEOUT_SECONDS = IntakeFetch::FETCH_TIMEOUT_SECONDS
|
|
10
|
-
|
|
11
|
-
def initialize(container:, call:)
|
|
12
|
-
@container = container
|
|
13
|
-
@call = call
|
|
14
|
-
@manifest = container.manifest
|
|
15
|
-
@schemas = container.schemas
|
|
16
|
-
@rpc = container.rpc
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
# call(key) is the primary entry; run is kept as an alias for
|
|
20
|
-
# Orchestrator and FetchAll which call worker.run(key).
|
|
21
|
-
def call(key)
|
|
22
|
-
run(key)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def run(key)
|
|
26
|
-
res = @manifest.resolver.resolve(key)
|
|
27
|
-
mentry = res.entry
|
|
28
|
-
path = res.path
|
|
29
|
-
remaining = res.remaining
|
|
30
|
-
raise UsageError.new("no intake declared for '#{key}'") unless mentry.is_a?(Textus::Manifest::Entry::Intake)
|
|
31
|
-
|
|
32
|
-
before_etag = @container.file_store.exists?(path) ? @container.file_store.etag(path) : nil
|
|
33
|
-
result = fetch_with_events(key, mentry, remaining)
|
|
34
|
-
persist_and_notify(key, mentry, result, before_etag)
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def self.normalize_action_result(res, format:)
|
|
38
|
-
res = res.transform_keys(&:to_s) if res.is_a?(Hash)
|
|
39
|
-
res ||= {}
|
|
40
|
-
meta_val = res["_meta"]
|
|
41
|
-
body = res["body"]
|
|
42
|
-
content = res["content"]
|
|
43
|
-
|
|
44
|
-
case format
|
|
45
|
-
when "markdown" then { meta: meta_val || {}, body: body.to_s, content: nil }
|
|
46
|
-
when "text" then { meta: {}, body: body.to_s, content: nil }
|
|
47
|
-
when "json", "yaml"
|
|
48
|
-
if !content.nil?
|
|
49
|
-
{ meta: meta_val || {}, body: nil, content: content }
|
|
50
|
-
elsif !body.nil?
|
|
51
|
-
{ meta: {}, body: body.to_s, content: nil }
|
|
52
|
-
else
|
|
53
|
-
raise Textus::UsageError.new("intake for #{format} returned neither content nor body")
|
|
54
|
-
end
|
|
55
|
-
else
|
|
56
|
-
raise Textus::UsageError.new("unknown format #{format.inspect}")
|
|
57
|
-
end
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
private
|
|
61
|
-
|
|
62
|
-
def fetch_events
|
|
63
|
-
@fetch_events ||= FetchEvents.from(container: @container, call: @call)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# ADR 0079: a per-rule fetch_timeout_seconds override was an accepted loss
|
|
67
|
-
# in the fetch:/retention: → lifecycle: collapse; the constant ceiling
|
|
68
|
-
# applies to every intake.
|
|
69
|
-
def fetch_timeout_for(_key)
|
|
70
|
-
FETCH_TIMEOUT_SECONDS
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def fetch_with_events(key, mentry, remaining)
|
|
74
|
-
fetch_events.started(key)
|
|
75
|
-
call_intake(key, mentry, remaining)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def call_intake(key, mentry, remaining)
|
|
79
|
-
IntakeFetch.invoke(
|
|
80
|
-
caps: @container, handler: mentry.handler,
|
|
81
|
-
config: mentry.config,
|
|
82
|
-
args: { trigger_key: key, leaf_segments: remaining || [] },
|
|
83
|
-
label: "intake", timeout: fetch_timeout_for(key)
|
|
84
|
-
)
|
|
85
|
-
rescue Textus::Error => e
|
|
86
|
-
fetch_events.failed(key, e)
|
|
87
|
-
raise
|
|
88
|
-
rescue StandardError => e
|
|
89
|
-
fetch_events.failed(key, e)
|
|
90
|
-
raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
def persist_and_notify(key, mentry, result, before_etag)
|
|
94
|
-
normalized = self.class.normalize_action_result(result, format: mentry.format)
|
|
95
|
-
Textus::Domain::Policy::GuardFactory.new(
|
|
96
|
-
manifest: @manifest, schemas: @schemas,
|
|
97
|
-
).for(:fetch, key).check!(
|
|
98
|
-
Textus::Domain::Policy::Evaluation.new(
|
|
99
|
-
actor: @call.role, transition: :fetch, origin: nil,
|
|
100
|
-
target: key, envelope: nil, manifest: @manifest
|
|
101
|
-
),
|
|
102
|
-
)
|
|
103
|
-
envelope = writer.put(
|
|
104
|
-
key,
|
|
105
|
-
mentry: mentry,
|
|
106
|
-
payload: Textus::Envelope::IO::Writer::Payload.new(
|
|
107
|
-
meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
|
|
108
|
-
),
|
|
109
|
-
)
|
|
110
|
-
change = detect_change(before_etag, envelope)
|
|
111
|
-
fetch_events.fetched(key, envelope, change)
|
|
112
|
-
envelope
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def detect_change(before_etag, envelope)
|
|
116
|
-
if before_etag.nil? then :created
|
|
117
|
-
elsif envelope.etag == before_etag then :unchanged
|
|
118
|
-
else :updated
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def writer
|
|
123
|
-
@writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
|
|
124
|
-
end
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
end
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
require "timeout"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Write
|
|
5
|
-
# Invokes a :resolve_intake hook handler by name under a timeout — the single
|
|
6
|
-
# home for "call the intake handler under a deadline" (ADR 0048 D1). Shared by
|
|
7
|
-
# FetchWorker (the :fetch verb), `textus put --fetch`, and `textus hook run`.
|
|
8
|
-
# Always passes a Container as `caps:` so the hook contract (ADR 0027) is
|
|
9
|
-
# uniform across every entry point. Maps Timeout::Error to a UsageError;
|
|
10
|
-
# leaves any other error to the caller (call sites differ in how they wrap).
|
|
11
|
-
module IntakeFetch
|
|
12
|
-
FETCH_TIMEOUT_SECONDS = 30
|
|
13
|
-
|
|
14
|
-
module_function
|
|
15
|
-
|
|
16
|
-
def invoke(caps:, handler:, config:, args:, label:, timeout: FETCH_TIMEOUT_SECONDS)
|
|
17
|
-
Timeout.timeout(timeout) do
|
|
18
|
-
caps.rpc.invoke(:resolve_intake, handler, caps: caps, config: config, args: args)
|
|
19
|
-
end
|
|
20
|
-
rescue Timeout::Error
|
|
21
|
-
raise Textus::UsageError.new("#{label} '#{handler}' exceeded #{timeout}s timeout")
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
@@ -1,51 +0,0 @@
|
|
|
1
|
-
require "fileutils"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Write
|
|
5
|
-
# Materializes a single projection-derived manifest entry onto disk by
|
|
6
|
-
# running the builder pipeline (projection + template render). External
|
|
7
|
-
# entries are NOT materialized here — they are generated by an out-of-band
|
|
8
|
-
# runner and only staleness-tracked, so Derived#publish_via filters them out
|
|
9
|
-
# before reaching this point.
|
|
10
|
-
# Extracted from Write::Build so that Publish can reuse
|
|
11
|
-
# it without creating a Build dependency.
|
|
12
|
-
class Materializer
|
|
13
|
-
def initialize(container:, call:)
|
|
14
|
-
@container = container
|
|
15
|
-
@call = call
|
|
16
|
-
@manifest = container.manifest
|
|
17
|
-
@file_store = container.file_store
|
|
18
|
-
@rpc = container.rpc
|
|
19
|
-
@root = container.root
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# Runs the builder pipeline for `mentry` and returns the on-disk
|
|
23
|
-
# target_path string.
|
|
24
|
-
def run(mentry)
|
|
25
|
-
reader = Textus::Read::Get.new(container: @container, call: @call)
|
|
26
|
-
lister = Textus::Read::List.new(container: @container)
|
|
27
|
-
Builder::Pipeline.run(
|
|
28
|
-
mentry: mentry,
|
|
29
|
-
deps: Builder::Pipeline::Deps.new(
|
|
30
|
-
manifest: @manifest,
|
|
31
|
-
reader: reader.method(:call),
|
|
32
|
-
lister: lister.method(:call),
|
|
33
|
-
rpc: @rpc,
|
|
34
|
-
template_loader: ->(name) { read_template(name) },
|
|
35
|
-
transform_context: @container,
|
|
36
|
-
inject_boot: -> { Textus::Boot.build(container: @container) },
|
|
37
|
-
),
|
|
38
|
-
)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
private
|
|
42
|
-
|
|
43
|
-
def read_template(name)
|
|
44
|
-
tpl_path = File.join(@root, "templates", name)
|
|
45
|
-
raise TemplateError.new("template not found: #{tpl_path}", template_name: name) unless File.exist?(tpl_path)
|
|
46
|
-
|
|
47
|
-
File.read(tpl_path)
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
end
|
|
51
|
-
end
|