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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +26 -0
- data/README.md +41 -43
- data/SPEC.md +174 -176
- data/docs/architecture/README.md +46 -42
- data/docs/reference/conventions.md +31 -26
- data/lib/textus/boot.rb +13 -17
- 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 -3
- 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 -9
- 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 +1 -1
- 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/deps.rb +3 -3
- data/lib/textus/read/freshness.rb +61 -31
- 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 -14
- 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 -67
- 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,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
|