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
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Produce
|
|
3
|
+
# The single convergence engine (ADR 0093/0094). "Make these machine entries
|
|
4
|
+
# current from upstream." Acquire is per-`from`; publish is one uniform
|
|
5
|
+
# `publish_via` entry point for all kinds (ADR 0094):
|
|
6
|
+
# intake (from: handler) -> re-pull (Produce::Acquire::Intake), then publish_via
|
|
7
|
+
# derived (from: project) -> build data + publish_via (ToPaths or None)
|
|
8
|
+
# derived (from: command) -> skip the build; publish_via publishes
|
|
9
|
+
# existing store bytes via mode resolution
|
|
10
|
+
# (None when no targets -> skipped)
|
|
11
|
+
# Runs as the reconcile build actor (self-elevating); the passed `call`
|
|
12
|
+
# supplies only correlation_id/dry_run. Callers choose the key set: the
|
|
13
|
+
# write subscriber passes rdeps ∩ derived; reconcile passes
|
|
14
|
+
# all-derived + stale-intake.
|
|
15
|
+
class Engine
|
|
16
|
+
# Locked + failure-isolated convergence — the shared entry point for the
|
|
17
|
+
# write trigger (ADR 0093). Both the sync path (inline, in the subscriber)
|
|
18
|
+
# and the async path (AsyncRunner) call this. A held lock is a soft miss
|
|
19
|
+
# (an in-flight build/reconcile already produces fresh output); any other
|
|
20
|
+
# error is republished as :produce_failed and never raised at the
|
|
21
|
+
# writer (ADR 0087 §5 failure isolation, preserved).
|
|
22
|
+
def self.converge(container:, call:, keys:)
|
|
23
|
+
Textus::Ports::BuildLock.with(root: container.root) do
|
|
24
|
+
new(container: container, call: call).call(keys: keys)
|
|
25
|
+
end
|
|
26
|
+
rescue Textus::BuildInProgress
|
|
27
|
+
nil
|
|
28
|
+
rescue Textus::Error => e
|
|
29
|
+
container.events.publish(
|
|
30
|
+
:produce_failed,
|
|
31
|
+
ctx: Textus::Hooks::Context.for(container: container, call: call),
|
|
32
|
+
keys: keys, error: e.message
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def initialize(container:, call:)
|
|
37
|
+
@container = container
|
|
38
|
+
@call = call
|
|
39
|
+
@manifest = container.manifest
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# keys: the machine entry keys to converge. Returns
|
|
43
|
+
# { produced: [k...], skipped: [k...], failed: [{ "key"=>, "error"=> }...] }
|
|
44
|
+
def call(keys:)
|
|
45
|
+
build_call = build_actor_call
|
|
46
|
+
context = build_context(build_call)
|
|
47
|
+
out = { produced: [], skipped: [], failed: [] }
|
|
48
|
+
|
|
49
|
+
keys.each do |key|
|
|
50
|
+
produce_one(key, build_call, context, out)
|
|
51
|
+
rescue Textus::Error => e
|
|
52
|
+
out[:failed] << { "key" => key, "error" => e.message }
|
|
53
|
+
end
|
|
54
|
+
out
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Acquire is per-`from`; publish is one uniform entry point (publish_via)
|
|
60
|
+
# for every kind. The command emit-vs-skip falls out of publish-mode
|
|
61
|
+
# resolution (Publish::None when no targets), so there is no command branch.
|
|
62
|
+
def produce_one(key, build_call, context, out)
|
|
63
|
+
entry = @manifest.resolver.resolve(key).entry
|
|
64
|
+
|
|
65
|
+
if entry.intake?
|
|
66
|
+
Textus::Produce::Acquire::Intake.new(container: @container, call: build_call).run(key) # acquire: re-pull
|
|
67
|
+
entry.publish_via(context) # emit any targets
|
|
68
|
+
out[:produced] << key # a fetch is production
|
|
69
|
+
else
|
|
70
|
+
result = entry.publish_via(context) # derived builds inside; command publishes-or-None
|
|
71
|
+
result.nil? ? (out[:skipped] << key) : (out[:produced] << key)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def build_actor_call
|
|
76
|
+
build_role = @manifest.policy.actor_for("reconcile") or
|
|
77
|
+
raise Textus::UsageError.new(
|
|
78
|
+
"no role holds the 'reconcile' capability",
|
|
79
|
+
hint: "declare a role with `can: [reconcile]` in .textus/manifest.yaml",
|
|
80
|
+
)
|
|
81
|
+
Textus::Call.build(
|
|
82
|
+
role: build_role,
|
|
83
|
+
correlation_id: @call.correlation_id,
|
|
84
|
+
dry_run: @call.dry_run,
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def build_context(call)
|
|
89
|
+
Textus::Manifest::Entry::Base::PublishContext.new(
|
|
90
|
+
container: @container, call: call,
|
|
91
|
+
reader: Textus::Read::Get.new(container: @container, call: call)
|
|
92
|
+
)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# In-process deferral for the async write trigger (ADR 0087/0093).
|
|
96
|
+
# Spawns a tracked thread that runs Produce.converge after the write
|
|
97
|
+
# returns; a one-time at_exit joins
|
|
98
|
+
# all pending threads so a short-lived CLI process cannot exit before an
|
|
99
|
+
# async rebuild completes. The write itself never blocks.
|
|
100
|
+
module AsyncRunner
|
|
101
|
+
@mutex = Mutex.new
|
|
102
|
+
@threads = []
|
|
103
|
+
@hooked = false
|
|
104
|
+
|
|
105
|
+
class << self
|
|
106
|
+
def enqueue(container:, call:, keys:)
|
|
107
|
+
thread = Thread.new { Textus::Produce::Engine.converge(container: container, call: call, keys: keys) }
|
|
108
|
+
track(thread)
|
|
109
|
+
thread
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Block until every spawned async rebuild has finished. Idempotent;
|
|
113
|
+
# safe to call from at_exit and directly from tests.
|
|
114
|
+
def drain
|
|
115
|
+
pending = @mutex.synchronize { @threads.dup }
|
|
116
|
+
pending.each(&:join)
|
|
117
|
+
@mutex.synchronize { @threads.delete_if { |t| !t.alive? } }
|
|
118
|
+
nil
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def track(thread)
|
|
124
|
+
@mutex.synchronize do
|
|
125
|
+
@threads.delete_if { |t| !t.alive? }
|
|
126
|
+
@threads << thread
|
|
127
|
+
install_drain_hook
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Register the join-before-exit hook exactly once. Guarded by the
|
|
132
|
+
# caller holding @mutex.
|
|
133
|
+
def install_drain_hook
|
|
134
|
+
return if @hooked
|
|
135
|
+
|
|
136
|
+
@hooked = true
|
|
137
|
+
at_exit { drain }
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
143
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Produce
|
|
3
|
+
# Single home for the fetch lifecycle event vocabulary (ADR 0048 D5).
|
|
4
|
+
# Produce::Acquire::Intake (the ingest executor driven by reconcile + hook) emits through
|
|
5
|
+
# this seam so the event names and payload shapes live in one place with one
|
|
6
|
+
# derived hook context.
|
|
7
|
+
class Events
|
|
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(:entry_fetch_started, ctx: @hook_context, key: key, mode: mode)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def failed(key, error)
|
|
25
|
+
@events.publish(:entry_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
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Produce
|
|
3
|
+
# Renders an entry's stored DATA into the bytes for one publish target
|
|
4
|
+
# (ADR 0094). Relocates the Mustache logic that used to live in the
|
|
5
|
+
# build-time Markdown renderer. Provenance is NOT added here — it lives in
|
|
6
|
+
# the data's `_meta`; a template surfaces it if the output should show it.
|
|
7
|
+
# A verbatim target (no template) is the caller's job to copy.
|
|
8
|
+
class Render
|
|
9
|
+
def initialize(template_loader:)
|
|
10
|
+
@template_loader = template_loader
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# target: a rendering Policy::PublishTarget. data: parsed entry data.
|
|
14
|
+
# boot: boot context hash or nil. Returns the rendered String.
|
|
15
|
+
def bytes_for(target:, data:, boot:)
|
|
16
|
+
raise ArgumentError.new("Produce::Render called for a verbatim target #{target.to.inspect}") unless target.renders?
|
|
17
|
+
|
|
18
|
+
ctx = target.inject_boot ? data.merge("boot" => boot) : data
|
|
19
|
+
Mustache.render(@template_loader.call(target.template), ctx)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/textus/projection.rb
CHANGED
|
@@ -6,9 +6,9 @@ module Textus
|
|
|
6
6
|
MAX_LIMIT = 1000
|
|
7
7
|
REDUCER_TIMEOUT_SECONDS = 2
|
|
8
8
|
|
|
9
|
-
# `reader` — a callable `->(key) { envelope_or_nil }`.
|
|
10
|
-
#
|
|
11
|
-
# materialization
|
|
9
|
+
# `reader` — a callable `->(key) { envelope_or_nil }`. `Read::Get` is a pure
|
|
10
|
+
# read on every path (ADR 0089): it annotates freshness but never ingests,
|
|
11
|
+
# so materialization and any other reader share the same side-effect-free read.
|
|
12
12
|
# `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
|
|
13
13
|
# `rpc` — a `Hooks::RpcRegistry` used to dispatch `transform_rows` callables.
|
|
14
14
|
# `transform_context` — capability object handed to transform reducers as `caps:`.
|
|
@@ -25,10 +25,15 @@ module Textus
|
|
|
25
25
|
def run
|
|
26
26
|
keys = collect_keys
|
|
27
27
|
explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
|
|
28
|
+
pluck_key = explicit_pluck && Array(@spec["pluck"]).include?("_key")
|
|
28
29
|
rows = keys.map do |key|
|
|
29
30
|
env = @reader.call(key)
|
|
30
31
|
row = pluck(env.meta, env.body)
|
|
31
|
-
explicit_pluck
|
|
32
|
+
if explicit_pluck
|
|
33
|
+
pluck_key ? row.merge("_key" => key) : row
|
|
34
|
+
else
|
|
35
|
+
row.merge("_key" => key)
|
|
36
|
+
end
|
|
32
37
|
end
|
|
33
38
|
reduced = apply_reducer(rows)
|
|
34
39
|
# Reducers may return either an Array of rows (legacy / templated builds)
|
|
@@ -64,12 +69,18 @@ module Textus
|
|
|
64
69
|
prefixes.flat_map { |p| @lister.call(prefix: p).map { |row| row["key"] } }.uniq
|
|
65
70
|
end
|
|
66
71
|
|
|
67
|
-
def pluck(frontmatter,
|
|
72
|
+
def pluck(frontmatter, body)
|
|
68
73
|
fields = @spec["pluck"]
|
|
69
74
|
if fields.nil? || fields == "*"
|
|
70
75
|
frontmatter
|
|
71
76
|
else
|
|
72
|
-
Array(fields).each_with_object({})
|
|
77
|
+
Array(fields).each_with_object({}) do |f, h|
|
|
78
|
+
if f == "body"
|
|
79
|
+
h["body"] = body
|
|
80
|
+
elsif frontmatter.key?(f)
|
|
81
|
+
h[f] = frontmatter[f]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
73
84
|
end
|
|
74
85
|
end
|
|
75
86
|
|
data/lib/textus/read/boot.rb
CHANGED
|
@@ -10,14 +10,16 @@ module Textus
|
|
|
10
10
|
verb :boot
|
|
11
11
|
summary "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart."
|
|
12
12
|
surfaces :cli, :mcp
|
|
13
|
+
arg :lean, :boolean,
|
|
14
|
+
description: "return only orientation essentials (zones, agent_quickstart, contract_etag) for cheap session-start injection"
|
|
13
15
|
|
|
14
16
|
def initialize(container:, call:)
|
|
15
17
|
@container = container
|
|
16
18
|
@call = call
|
|
17
19
|
end
|
|
18
20
|
|
|
19
|
-
def call
|
|
20
|
-
Textus::Boot.build(container: @container)
|
|
21
|
+
def call(lean: false)
|
|
22
|
+
Textus::Boot.build(container: @container, lean: lean)
|
|
21
23
|
end
|
|
22
24
|
end
|
|
23
25
|
end
|
data/lib/textus/read/deps.rb
CHANGED
|
@@ -21,12 +21,12 @@ module Textus
|
|
|
21
21
|
|
|
22
22
|
def sources_for(key)
|
|
23
23
|
entry = @manifest.data.entries.find { |e| e.key == key }
|
|
24
|
-
return [] unless entry
|
|
24
|
+
return [] unless entry&.derived?
|
|
25
25
|
|
|
26
26
|
src = entry.source
|
|
27
|
-
result = if src.
|
|
27
|
+
result = if src.projection?
|
|
28
28
|
Array(src.select).compact
|
|
29
|
-
elsif src.
|
|
29
|
+
elsif src.external?
|
|
30
30
|
Array(src.sources).compact
|
|
31
31
|
else
|
|
32
32
|
[]
|
|
@@ -2,20 +2,30 @@ require "time"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Read
|
|
5
|
-
# Per-entry
|
|
6
|
-
# manifest
|
|
7
|
-
#
|
|
8
|
-
#
|
|
5
|
+
# Per-entry staleness scan (ADR 0079, 0085, 0093). Walks every entry declared
|
|
6
|
+
# in the manifest and reports a staleness verdict sourced from the two new
|
|
7
|
+
# policy slots (ADR 0093):
|
|
8
|
+
# - intake entries: `entry.source.ttl_seconds` is the re-pull cadence;
|
|
9
|
+
# basis = `_meta.last_fetched_at` (else file mtime). Past ttl ⇒ :expired.
|
|
10
|
+
# - entries matched by a `retention:` rule: `retention.ttl_seconds` is the
|
|
11
|
+
# GC age; basis = file mtime. Past ttl ⇒ :expired (:action = drop/archive).
|
|
12
|
+
# Intake cadence wins when both apply (freshness is content currency; GC dueness
|
|
13
|
+
# shows via `reconcile --dry-run`).
|
|
14
|
+
# Status is one of :fresh, :expired, or :no_policy; the row also carries
|
|
15
|
+
# :action (:refresh for intake, :drop/:archive for retention).
|
|
16
|
+
#
|
|
17
|
+
# ADR 0085 removed the public `freshness` verb: there is no `:cli`/`:mcp`
|
|
18
|
+
# surface. This is now a Ruby-only internal scan consumed by `pulse` (which
|
|
19
|
+
# derives `stale` + `next_due_at` from it) and the hook `Context`. Humans drill
|
|
20
|
+
# into per-entry staleness detail via `get` (last_fetched_at) + `rule_explain`
|
|
21
|
+
# (the ttl / action policy) instead of a dedicated verb.
|
|
9
22
|
class Freshness
|
|
10
23
|
extend Textus::Contract::DSL
|
|
11
24
|
|
|
12
25
|
verb :freshness
|
|
13
|
-
summary "
|
|
14
|
-
surfaces :cli
|
|
15
|
-
cli "freshness"
|
|
26
|
+
summary "Internal per-entry lifecycle scan (status, age, ttl, action); backs pulse + hook context. No public surface (ADR 0085)."
|
|
16
27
|
arg :prefix, String, required: false, description: "filter to keys with this prefix"
|
|
17
28
|
arg :zone, String, required: false, description: "filter to entries in this zone"
|
|
18
|
-
view(:cli) { |rows| { "verb" => "freshness", "rows" => rows } }
|
|
19
29
|
|
|
20
30
|
def initialize(container:, call:)
|
|
21
31
|
@container = container
|
|
@@ -50,29 +60,59 @@ module Textus
|
|
|
50
60
|
private
|
|
51
61
|
|
|
52
62
|
def row_for(mentry)
|
|
53
|
-
policy = lifecycle_for(mentry.key)
|
|
54
63
|
envelope = safe_get(mentry.key)
|
|
55
64
|
last = envelope&.meta&.dig("last_fetched_at")
|
|
65
|
+
ttl, action = policy_for(mentry)
|
|
66
|
+
return base_row(mentry, last).merge(status: :no_policy) if ttl.nil?
|
|
56
67
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
expired, reason = Textus::Domain::Lifecycle.verdict(
|
|
60
|
-
policy: policy,
|
|
61
|
-
last_fetched_at: last,
|
|
62
|
-
mtime: mtime_for(mentry.key),
|
|
63
|
-
now: @call.now,
|
|
64
|
-
)
|
|
68
|
+
basis = basis_for(mentry)
|
|
69
|
+
expired = expired?(mentry, basis, ttl)
|
|
65
70
|
base_row(mentry, last).merge(
|
|
66
|
-
ttl_seconds:
|
|
67
|
-
action:
|
|
71
|
+
ttl_seconds: ttl,
|
|
72
|
+
action: action,
|
|
68
73
|
status: expired ? :expired : :fresh,
|
|
69
|
-
|
|
70
|
-
next_due_at: next_due(last, policy.ttl_seconds),
|
|
74
|
+
next_due_at: basis.nil? ? nil : (basis + ttl).utc.iso8601,
|
|
71
75
|
)
|
|
72
76
|
end
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
# ADR 0093: staleness comes from the intake re-pull cadence (source.ttl)
|
|
79
|
+
# or a retention GC rule (retention.ttl). Intake cadence wins when an entry
|
|
80
|
+
# has both (freshness is about content currency; GC dueness still shows via
|
|
81
|
+
# `reconcile --dry-run`). Returns [ttl_seconds, action] or [nil, nil].
|
|
82
|
+
def policy_for(mentry)
|
|
83
|
+
if mentry.intake?
|
|
84
|
+
ttl = mentry.source.ttl_seconds
|
|
85
|
+
return [ttl, :refresh] unless ttl.nil?
|
|
86
|
+
end
|
|
87
|
+
ret = @manifest.rules.for(mentry.key).retention
|
|
88
|
+
return [ret.ttl_seconds, ret.action] unless ret.nil?
|
|
89
|
+
|
|
90
|
+
[nil, nil]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Intake currency basis comes from the evaluator (single definition);
|
|
94
|
+
# retention dueness is keyed off file mtime.
|
|
95
|
+
def basis_for(mentry)
|
|
96
|
+
return evaluator.intake_basis(mentry) if mentry.intake? && mentry.source.ttl_seconds
|
|
97
|
+
|
|
98
|
+
mtime_for(mentry.key)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def expired?(mentry, basis, ttl)
|
|
102
|
+
if mentry.intake? && mentry.source.ttl_seconds
|
|
103
|
+
evaluator.verdict(mentry).stale
|
|
104
|
+
else
|
|
105
|
+
# Preserve pre-0099 pulse semantics: a never-recorded retention entry
|
|
106
|
+
# (no file => nil basis) is past due. Retention::Sweep.expired? alone
|
|
107
|
+
# returns false on nil mtime (it runs post-exists? in the sweep).
|
|
108
|
+
basis.nil? || Textus::Domain::Retention::Sweep.expired?(ttl_seconds: ttl, mtime: basis, now: @call.now)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def evaluator
|
|
113
|
+
@evaluator ||= Textus::Domain::Freshness::Evaluator.new(
|
|
114
|
+
manifest: @manifest, file_stat: Textus::Ports::Storage::FileStat.new, clock: @call,
|
|
115
|
+
)
|
|
76
116
|
end
|
|
77
117
|
|
|
78
118
|
def mtime_for(key)
|
|
@@ -107,12 +147,6 @@ module Textus
|
|
|
107
147
|
rescue Textus::Error
|
|
108
148
|
nil
|
|
109
149
|
end
|
|
110
|
-
|
|
111
|
-
def next_due(last, ttl)
|
|
112
|
-
return nil if last.nil? || ttl.nil?
|
|
113
|
-
|
|
114
|
-
(Time.parse(last) + ttl).utc.iso8601
|
|
115
|
-
end
|
|
116
150
|
end
|
|
117
151
|
end
|
|
118
152
|
end
|
data/lib/textus/read/get.rb
CHANGED
|
@@ -1,119 +1,59 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
|
-
# The one read path
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# hands off to the fetch orchestrator. A read NEVER performs a
|
|
10
|
-
# destructive action (drop/archive) — those belong to the `tend` sweep
|
|
11
|
-
# (ADR 0079).
|
|
12
|
-
#
|
|
13
|
-
# Lifecycle policy comes from the unified `lifecycle:` rule slot (ADR 0079).
|
|
3
|
+
# The one read path — a pure read (ADR 0089, 0093): the on-disk envelope
|
|
4
|
+
# annotated with a freshness annotation. It NEVER mutates and NEVER ingests.
|
|
5
|
+
# Quarantine freshness is system-pushed via `reconcile` (scheduled sweep) and
|
|
6
|
+
# `hook run` (event push). Lifecycle is removed from the get path (ADR 0093):
|
|
7
|
+
# intake cadence lives in `source.ttl`; GC lives in `retention:` rules; both
|
|
8
|
+
# are evaluated exclusively by the `reconcile` sweep, not by a read.
|
|
14
9
|
class Get
|
|
15
10
|
extend Textus::Contract::DSL
|
|
16
11
|
|
|
17
12
|
verb :get
|
|
18
|
-
summary "Read one entry
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"_meta, body, freshness)."
|
|
13
|
+
summary "Read one entry — a pure on-disk read annotated with a freshness " \
|
|
14
|
+
"verdict; never ingests (quarantine freshness is reconcile + hook " \
|
|
15
|
+
"only, ADR 0089). Returns the envelope (uid, etag, _meta, body, " \
|
|
16
|
+
"freshness)."
|
|
23
17
|
surfaces :cli, :mcp
|
|
24
18
|
arg :key, String, required: true, positional: true,
|
|
25
19
|
description: "dotted entry key to read, e.g. 'knowledge.project'"
|
|
26
|
-
arg :fetch, :boolean, default: true,
|
|
27
|
-
description: "read-through (refresh on stale per the " \
|
|
28
|
-
"entry's lifecycle rule) when true, the default; " \
|
|
29
|
-
"false returns the on-disk envelope without ever fetching"
|
|
30
20
|
view { |v, _i| v.to_h_for_wire }
|
|
31
21
|
|
|
32
|
-
def initialize(container:, call:,
|
|
22
|
+
def initialize(container:, call:, file_stat: Textus::Ports::Storage::FileStat.new)
|
|
33
23
|
@container = container
|
|
34
24
|
@call = call
|
|
35
25
|
@manifest = container.manifest
|
|
36
26
|
@file_store = container.file_store
|
|
37
27
|
@file_stat = file_stat
|
|
38
|
-
@orchestrator = orchestrator # nil → built lazily on first fetch only
|
|
39
28
|
end
|
|
40
29
|
|
|
41
|
-
def call(key
|
|
42
|
-
|
|
43
|
-
return envelope if envelope.nil?
|
|
44
|
-
return envelope unless fetch && envelope.freshness&.stale
|
|
45
|
-
|
|
46
|
-
policy = lifecycle_for(key)
|
|
47
|
-
return envelope unless policy&.on_expire == :refresh # only refresh acts on a read
|
|
48
|
-
|
|
49
|
-
verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
|
|
50
|
-
outcome = orchestrator.execute(refresh_policy(policy).decide(verdict), key: key)
|
|
51
|
-
resolve(outcome, envelope)
|
|
30
|
+
def call(key)
|
|
31
|
+
annotated_envelope(key)
|
|
52
32
|
end
|
|
53
33
|
|
|
54
34
|
# Strict variant: raises UnknownKey when the entry is missing.
|
|
55
35
|
# Used by consumers (e.g. uid, Validator) that distinguish absence.
|
|
56
|
-
def get(key
|
|
57
|
-
call(key
|
|
36
|
+
def get(key)
|
|
37
|
+
call(key) ||
|
|
58
38
|
raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
|
|
59
39
|
end
|
|
60
40
|
|
|
61
41
|
private
|
|
62
42
|
|
|
63
|
-
# Pure read + unified lifecycle verdict; no orchestrator dependency.
|
|
64
43
|
def annotated_envelope(key)
|
|
65
44
|
envelope = read_raw_envelope(key)
|
|
66
45
|
return nil if envelope.nil?
|
|
67
46
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
expired, reason = Textus::Domain::Lifecycle.verdict(
|
|
72
|
-
policy: policy,
|
|
73
|
-
last_fetched_at: envelope.meta&.dig("last_fetched_at"),
|
|
74
|
-
mtime: mtime_for(key),
|
|
75
|
-
now: @call.now,
|
|
76
|
-
)
|
|
77
|
-
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
78
|
-
stale: expired, reason: reason, fetching: false,
|
|
79
|
-
))
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def lifecycle_for(key)
|
|
83
|
-
@manifest.rules.for(key).lifecycle
|
|
47
|
+
entry = @manifest.resolver.resolve(key).entry
|
|
48
|
+
envelope.with(freshness: evaluator.verdict(entry))
|
|
84
49
|
end
|
|
85
50
|
|
|
86
|
-
def
|
|
87
|
-
Textus::Domain::Freshness::
|
|
88
|
-
|
|
89
|
-
on_stale: policy.budget_ms ? :timed_sync : :sync,
|
|
90
|
-
sync_budget_ms: policy.budget_ms,
|
|
51
|
+
def evaluator
|
|
52
|
+
@evaluator ||= Textus::Domain::Freshness::Evaluator.new(
|
|
53
|
+
manifest: @manifest, file_stat: @file_stat, clock: @call,
|
|
91
54
|
)
|
|
92
55
|
end
|
|
93
56
|
|
|
94
|
-
def mtime_for(key)
|
|
95
|
-
path = @manifest.resolver.resolve(key).path
|
|
96
|
-
@file_stat.exists?(path) ? @file_stat.mtime(path) : nil
|
|
97
|
-
rescue Textus::Error
|
|
98
|
-
nil
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def resolve(outcome, envelope)
|
|
102
|
-
case outcome
|
|
103
|
-
when Textus::Domain::Outcome::Skipped
|
|
104
|
-
envelope
|
|
105
|
-
when Textus::Domain::Outcome::Fetched
|
|
106
|
-
outcome.envelope.with(
|
|
107
|
-
freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, fetching: false),
|
|
108
|
-
)
|
|
109
|
-
when Textus::Domain::Outcome::Detached
|
|
110
|
-
envelope.with(freshness: envelope.freshness.with(fetching: true))
|
|
111
|
-
when Textus::Domain::Outcome::Failed
|
|
112
|
-
envelope.with(freshness: envelope.freshness.with(fetch_error: outcome.error.message))
|
|
113
|
-
else raise "unexpected fetch outcome: #{outcome.class}"
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
57
|
def read_raw_envelope(key)
|
|
118
58
|
res = @manifest.resolver.resolve(key)
|
|
119
59
|
mentry = res.entry
|
|
@@ -128,28 +68,6 @@ module Textus
|
|
|
128
68
|
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
129
69
|
)
|
|
130
70
|
end
|
|
131
|
-
|
|
132
|
-
def annotate_fresh(envelope)
|
|
133
|
-
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
134
|
-
stale: false, reason: nil, fetching: false,
|
|
135
|
-
))
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def orchestrator
|
|
139
|
-
@orchestrator ||= build_orchestrator
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def build_orchestrator
|
|
143
|
-
worker = Textus::Write::FetchWorker.new(container: @container, call: @call)
|
|
144
|
-
Textus::Write::FetchOrchestrator.new(
|
|
145
|
-
worker: worker, store_root: @container.root, events: @container.events,
|
|
146
|
-
hook_context: hook_context
|
|
147
|
-
)
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def hook_context
|
|
151
|
-
@hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
|
|
152
|
-
end
|
|
153
71
|
end
|
|
154
72
|
end
|
|
155
73
|
end
|
data/lib/textus/read/rdeps.rb
CHANGED
|
@@ -21,12 +21,12 @@ module Textus
|
|
|
21
21
|
|
|
22
22
|
def dependents_of(key)
|
|
23
23
|
@manifest.data.entries.each_with_object([]) do |e, acc|
|
|
24
|
-
next unless e.
|
|
24
|
+
next unless e.derived?
|
|
25
25
|
|
|
26
26
|
src = e.source
|
|
27
|
-
sources = if src.
|
|
27
|
+
sources = if src.projection?
|
|
28
28
|
Array(src.select).compact
|
|
29
|
-
elsif src.
|
|
29
|
+
elsif src.external?
|
|
30
30
|
Array(src.sources).compact
|
|
31
31
|
else
|
|
32
32
|
[]
|