textus 0.22.0 → 0.26.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/ARCHITECTURE.md +148 -45
- data/CHANGELOG.md +102 -0
- data/README.md +1 -1
- data/SPEC.md +12 -12
- data/docs/conventions.md +10 -0
- data/lib/textus/application/caps.rb +49 -0
- data/lib/textus/application/context.rb +2 -2
- data/lib/textus/application/envelope/reader.rb +44 -0
- data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
- data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
- data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
- data/lib/textus/application/maintenance/migrate.rb +59 -0
- data/lib/textus/application/maintenance/rule_lint.rb +65 -0
- data/lib/textus/application/maintenance/zone_mv.rb +60 -0
- data/lib/textus/application/maintenance.rb +17 -0
- data/lib/textus/application/projection.rb +12 -10
- data/lib/textus/application/read/audit.rb +106 -0
- data/lib/textus/application/read/blame.rb +91 -0
- data/lib/textus/application/read/deps.rb +34 -0
- data/lib/textus/application/read/freshness.rb +110 -0
- data/lib/textus/application/read/get.rb +75 -0
- data/lib/textus/application/read/get_or_refresh.rb +63 -0
- data/lib/textus/application/read/list.rb +25 -0
- data/lib/textus/application/read/policy_explain.rb +47 -0
- data/lib/textus/application/read/published.rb +25 -0
- data/lib/textus/application/read/pulse.rb +101 -0
- data/lib/textus/application/read/rdeps.rb +35 -0
- data/lib/textus/application/read/schema_envelope.rb +26 -0
- data/lib/textus/application/read/stale.rb +23 -0
- data/lib/textus/application/read/uid.rb +30 -0
- data/lib/textus/application/read/validate_all.rb +32 -0
- data/lib/textus/application/{reads → read}/validator.rb +2 -2
- data/lib/textus/application/read/where.rb +26 -0
- data/lib/textus/application/use_case.rb +22 -0
- data/lib/textus/application/write/accept.rb +102 -0
- data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
- data/lib/textus/application/write/delete.rb +45 -0
- data/lib/textus/application/{writes → write}/materializer.rb +14 -15
- data/lib/textus/application/write/mv.rb +118 -0
- data/lib/textus/application/write/publish.rb +96 -0
- data/lib/textus/application/write/put.rb +49 -0
- data/lib/textus/application/write/refresh_all.rb +63 -0
- data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
- data/lib/textus/application/write/refresh_worker.rb +134 -0
- data/lib/textus/application/write/reject.rb +62 -0
- data/lib/textus/boot.rb +27 -29
- data/lib/textus/builder/pipeline.rb +3 -3
- data/lib/textus/cli/group/mcp.rb +9 -0
- data/lib/textus/cli/group/zone.rb +9 -0
- data/lib/textus/cli/verb/accept.rb +1 -1
- data/lib/textus/cli/verb/audit.rb +2 -2
- data/lib/textus/cli/verb/blame.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +1 -1
- data/lib/textus/cli/verb/build.rb +2 -2
- data/lib/textus/cli/verb/delete.rb +1 -1
- data/lib/textus/cli/verb/deps.rb +1 -1
- data/lib/textus/cli/verb/doctor.rb +1 -1
- data/lib/textus/cli/verb/freshness.rb +1 -1
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -4
- data/lib/textus/cli/verb/hooks.rb +11 -14
- data/lib/textus/cli/verb/key_delete.rb +24 -0
- data/lib/textus/cli/verb/list.rb +1 -1
- data/lib/textus/cli/verb/mcp_serve.rb +17 -0
- data/lib/textus/cli/verb/migrate.rb +18 -0
- data/lib/textus/cli/verb/mv.rb +11 -3
- data/lib/textus/cli/verb/published.rb +1 -1
- data/lib/textus/cli/verb/pulse.rb +1 -1
- data/lib/textus/cli/verb/put.rb +8 -6
- data/lib/textus/cli/verb/rdeps.rb +1 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +1 -1
- data/lib/textus/cli/verb/reject.rb +1 -1
- data/lib/textus/cli/verb/rule_explain.rb +1 -1
- data/lib/textus/cli/verb/rule_lint.rb +18 -0
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb/uid.rb +1 -1
- data/lib/textus/cli/verb/where.rb +1 -1
- data/lib/textus/cli/verb/zone_mv.rb +19 -0
- data/lib/textus/cli/verb.rb +4 -4
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
- data/lib/textus/doctor/check/hooks.rb +4 -3
- data/lib/textus/doctor/check/illegal_keys.rb +2 -2
- data/lib/textus/doctor/check/intake_registration.rb +2 -2
- data/lib/textus/doctor/check/manifest_files.rb +2 -2
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/refresh_locks.rb +2 -2
- data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/schemas.rb +2 -2
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +2 -2
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +5 -3
- data/lib/textus/doctor.rb +24 -27
- data/lib/textus/domain/authorizer.rb +4 -4
- data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
- data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
- data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
- data/lib/textus/domain/staleness/generator_check.rb +2 -2
- data/lib/textus/domain/staleness/intake_check.rb +2 -2
- data/lib/textus/domain/staleness.rb +1 -1
- data/lib/textus/hooks/builtin.rb +14 -14
- data/lib/textus/hooks/context.rb +13 -13
- data/lib/textus/hooks/error_log.rb +32 -0
- data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
- data/lib/textus/hooks/loader.rb +29 -3
- data/lib/textus/hooks/rpc_registry.rb +77 -0
- data/lib/textus/infra/audit_subscriber.rb +6 -7
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/key/path.rb +7 -3
- data/lib/textus/manifest/data.rb +78 -0
- data/lib/textus/manifest/entry/base.rb +4 -4
- data/lib/textus/manifest/entry/derived.rb +4 -5
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/manifest/policy.rb +48 -0
- data/lib/textus/manifest/resolver.rb +14 -14
- data/lib/textus/manifest/rules.rb +1 -1
- data/lib/textus/manifest.rb +53 -111
- data/lib/textus/mcp/errors.rb +32 -0
- data/lib/textus/mcp/server.rb +127 -0
- data/lib/textus/mcp/session.rb +31 -0
- data/lib/textus/mcp/tool_schemas.rb +71 -0
- data/lib/textus/mcp/tools.rb +129 -0
- data/lib/textus/mcp.rb +6 -0
- data/lib/textus/schema/tools.rb +14 -10
- data/lib/textus/session.rb +84 -0
- data/lib/textus/store.rb +14 -9
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +8 -1
- metadata +61 -36
- data/lib/textus/application/reads/audit.rb +0 -94
- data/lib/textus/application/reads/blame.rb +0 -82
- data/lib/textus/application/reads/deps.rb +0 -26
- data/lib/textus/application/reads/freshness.rb +0 -88
- data/lib/textus/application/reads/get.rb +0 -67
- data/lib/textus/application/reads/get_or_refresh.rb +0 -51
- data/lib/textus/application/reads/list.rb +0 -17
- data/lib/textus/application/reads/policy_explain.rb +0 -39
- data/lib/textus/application/reads/published.rb +0 -17
- data/lib/textus/application/reads/pulse.rb +0 -63
- data/lib/textus/application/reads/rdeps.rb +0 -27
- data/lib/textus/application/reads/schema_envelope.rb +0 -18
- data/lib/textus/application/reads/stale.rb +0 -15
- data/lib/textus/application/reads/uid.rb +0 -23
- data/lib/textus/application/reads/validate_all.rb +0 -24
- data/lib/textus/application/reads/where.rb +0 -18
- data/lib/textus/application/refresh/all.rb +0 -52
- data/lib/textus/application/refresh/worker.rb +0 -116
- data/lib/textus/application/writes/accept.rb +0 -89
- data/lib/textus/application/writes/delete.rb +0 -33
- data/lib/textus/application/writes/mv.rb +0 -105
- data/lib/textus/application/writes/publish.rb +0 -81
- data/lib/textus/application/writes/put.rb +0 -37
- data/lib/textus/application/writes/reject.rb +0 -50
- data/lib/textus/infra/event_bus.rb +0 -27
- data/lib/textus/operations.rb +0 -176
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Read
|
|
4
|
+
module Deps
|
|
5
|
+
def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
6
|
+
Impl.new(caps: caps).call(*, **)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class Impl
|
|
10
|
+
def initialize(caps:)
|
|
11
|
+
@manifest = caps.manifest
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(key)
|
|
15
|
+
entry = @manifest.data.entries.find { |e| e.key == key } or return []
|
|
16
|
+
return [] unless entry.is_a?(Textus::Manifest::Entry::Derived)
|
|
17
|
+
|
|
18
|
+
src = entry.source
|
|
19
|
+
result = if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
|
|
20
|
+
Array(src.select).compact
|
|
21
|
+
elsif src.is_a?(Textus::Manifest::Entry::Derived::External)
|
|
22
|
+
Array(src.sources).compact
|
|
23
|
+
else
|
|
24
|
+
[]
|
|
25
|
+
end
|
|
26
|
+
result.uniq
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
Textus::Application::UseCase.register(:deps, Textus::Application::Read::Deps, caps: :read)
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Application
|
|
5
|
+
module Read
|
|
6
|
+
# Per-entry freshness report. Walks every entry declared in the manifest,
|
|
7
|
+
# consults `rules_for(key)` for a refresh rule, and reports the
|
|
8
|
+
# current status. Status is one of :fresh, :stale, :never_refreshed, or
|
|
9
|
+
# :no_policy.
|
|
10
|
+
module Freshness
|
|
11
|
+
def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
12
|
+
Impl.new(ctx: ctx, caps: caps).call(*, **)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class Impl
|
|
16
|
+
def initialize(ctx:, caps:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
17
|
+
@ctx = ctx
|
|
18
|
+
@manifest = caps.manifest
|
|
19
|
+
@file_store = caps.file_store
|
|
20
|
+
@evaluator = evaluator
|
|
21
|
+
@cache = {}
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Returns the soonest `next_due_at` across all entries with a refresh
|
|
25
|
+
# policy, as an ISO-8601 string, or nil if none.
|
|
26
|
+
def soonest_due(prefix: nil, zone: nil)
|
|
27
|
+
times = call(prefix: prefix, zone: zone)
|
|
28
|
+
.map { |r| r[:next_due_at] }
|
|
29
|
+
.compact
|
|
30
|
+
.map { |t| Time.parse(t) }
|
|
31
|
+
return nil if times.empty?
|
|
32
|
+
|
|
33
|
+
times.min.utc.iso8601
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def call(prefix: nil, zone: nil)
|
|
37
|
+
rows = []
|
|
38
|
+
@manifest.data.entries.each do |mentry|
|
|
39
|
+
next if prefix && !mentry.key.start_with?(prefix)
|
|
40
|
+
next if zone && mentry.zone != zone
|
|
41
|
+
|
|
42
|
+
rows << row_for(mentry)
|
|
43
|
+
end
|
|
44
|
+
rows
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def row_for(mentry)
|
|
50
|
+
set = @manifest.rules.for(mentry.key)
|
|
51
|
+
refresh = set.refresh
|
|
52
|
+
envelope = safe_get(mentry.key)
|
|
53
|
+
last = envelope&.meta&.dig("last_refreshed_at")
|
|
54
|
+
|
|
55
|
+
return base_row(mentry, last).merge(status: :no_policy) if refresh.nil?
|
|
56
|
+
|
|
57
|
+
fp = refresh.to_freshness_policy
|
|
58
|
+
cache_key = [mentry.key, last]
|
|
59
|
+
verdict = (@cache[cache_key] ||= @evaluator.call(fp, envelope, now: @ctx.now))
|
|
60
|
+
status = if verdict.fresh? then :fresh
|
|
61
|
+
elsif last.nil? then :never_refreshed
|
|
62
|
+
else :stale
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
base_row(mentry, last).merge(
|
|
66
|
+
ttl_seconds: fp.ttl_seconds,
|
|
67
|
+
on_stale: fp.on_stale,
|
|
68
|
+
status: status,
|
|
69
|
+
next_due_at: next_due(last, fp.ttl_seconds),
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def base_row(mentry, last)
|
|
74
|
+
{
|
|
75
|
+
key: mentry.key,
|
|
76
|
+
zone: mentry.zone,
|
|
77
|
+
last_refreshed_at: last,
|
|
78
|
+
age_seconds: last ? (@ctx.now - Time.parse(last)).to_i : nil,
|
|
79
|
+
}
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Returns the raw envelope or nil. Nested entries (mentry.key is a
|
|
83
|
+
# prefix, not a leaf) and missing files both resolve to nil.
|
|
84
|
+
def safe_get(key)
|
|
85
|
+
res = @manifest.resolver.resolve(key)
|
|
86
|
+
return nil unless @file_store.exists?(res.path)
|
|
87
|
+
|
|
88
|
+
raw = @file_store.read(res.path)
|
|
89
|
+
parsed = Entry.for_format(res.entry.format).parse(raw, path: res.path)
|
|
90
|
+
Textus::Envelope.build(
|
|
91
|
+
key: key, mentry: res.entry, path: res.path,
|
|
92
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
93
|
+
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
94
|
+
)
|
|
95
|
+
rescue Textus::Error
|
|
96
|
+
nil
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def next_due(last, ttl)
|
|
100
|
+
return nil if last.nil? || ttl.nil?
|
|
101
|
+
|
|
102
|
+
(Time.parse(last) + ttl).utc.iso8601
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
Textus::Application::UseCase.register(:freshness, Textus::Application::Read::Freshness, caps: :read)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Read
|
|
4
|
+
# Pure read: returns the on-disk envelope annotated with a freshness
|
|
5
|
+
# verdict. Never triggers refresh; never invokes the orchestrator.
|
|
6
|
+
#
|
|
7
|
+
# For interactive reads that want refresh-on-stale, use
|
|
8
|
+
# `Read::GetOrRefresh`, which composes this with the orchestrator.
|
|
9
|
+
module Get
|
|
10
|
+
def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
11
|
+
Impl.new(ctx: ctx, caps: caps).call(*, **)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class Impl
|
|
15
|
+
def initialize(ctx:, caps:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
16
|
+
@ctx = ctx
|
|
17
|
+
@manifest = caps.manifest
|
|
18
|
+
@file_store = caps.file_store
|
|
19
|
+
@evaluator = evaluator
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def call(key)
|
|
23
|
+
envelope = read_raw_envelope(key)
|
|
24
|
+
return nil if envelope.nil?
|
|
25
|
+
|
|
26
|
+
policy_set = @manifest.rules.for(key)
|
|
27
|
+
refresh_policy = policy_set.refresh
|
|
28
|
+
return annotate_fresh(envelope) if refresh_policy.nil?
|
|
29
|
+
|
|
30
|
+
policy = refresh_policy.to_freshness_policy
|
|
31
|
+
verdict = @evaluator.call(policy, envelope, now: @ctx.now)
|
|
32
|
+
|
|
33
|
+
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
34
|
+
stale: verdict.stale?,
|
|
35
|
+
reason: verdict.reason,
|
|
36
|
+
refreshing: false,
|
|
37
|
+
))
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Strict variant: raises UnknownKey when the entry is missing.
|
|
41
|
+
# Used by consumers (e.g. Validator) that need to distinguish absence
|
|
42
|
+
# from emptiness.
|
|
43
|
+
def get(key)
|
|
44
|
+
call(key) || raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def read_raw_envelope(key)
|
|
50
|
+
res = @manifest.resolver.resolve(key)
|
|
51
|
+
mentry = res.entry
|
|
52
|
+
path = res.path
|
|
53
|
+
return nil unless @file_store.exists?(path)
|
|
54
|
+
|
|
55
|
+
raw = @file_store.read(path)
|
|
56
|
+
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
57
|
+
Textus::Envelope.build(
|
|
58
|
+
key: key, mentry: mentry, path: path,
|
|
59
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
60
|
+
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def annotate_fresh(envelope)
|
|
65
|
+
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
66
|
+
stale: false, reason: nil, refreshing: false,
|
|
67
|
+
))
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
Textus::Application::UseCase.register(:get, Textus::Application::Read::Get, caps: :read)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Read
|
|
4
|
+
# Composes pure `Read::Get` with the refresh orchestrator: runs Get
|
|
5
|
+
# to obtain the envelope and freshness verdict, then if the verdict
|
|
6
|
+
# is stale and the rule's `on_stale` policy demands action, hands
|
|
7
|
+
# off to the orchestrator. Use for interactive reads where the
|
|
8
|
+
# caller wants the freshest obtainable envelope.
|
|
9
|
+
#
|
|
10
|
+
# Pure reads (build, projection, schema tooling) should use
|
|
11
|
+
# `Read::Get` directly; it has no orchestrator dependency.
|
|
12
|
+
module GetOrRefresh
|
|
13
|
+
def self.call(*, session:, ctx:, caps:, **)
|
|
14
|
+
Impl.new(
|
|
15
|
+
caps: caps,
|
|
16
|
+
get: Read::Get::Impl.new(ctx: ctx, caps: caps),
|
|
17
|
+
orchestrator: session.refresh_orchestrator,
|
|
18
|
+
).call(*, **)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
class Impl
|
|
22
|
+
def initialize(caps:, get:, orchestrator:)
|
|
23
|
+
@manifest = caps.manifest
|
|
24
|
+
@get = get
|
|
25
|
+
@orchestrator = orchestrator
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(key)
|
|
29
|
+
envelope = @get.call(key)
|
|
30
|
+
return nil if envelope.nil?
|
|
31
|
+
return envelope unless envelope.freshness&.stale
|
|
32
|
+
|
|
33
|
+
policy_set = @manifest.rules.for(key)
|
|
34
|
+
refresh_policy = policy_set.refresh
|
|
35
|
+
return envelope if refresh_policy.nil?
|
|
36
|
+
|
|
37
|
+
policy = refresh_policy.to_freshness_policy
|
|
38
|
+
verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
|
|
39
|
+
action = policy.decide(verdict)
|
|
40
|
+
outcome = @orchestrator.execute(action, key: key)
|
|
41
|
+
|
|
42
|
+
case outcome
|
|
43
|
+
when Textus::Domain::Outcome::Skipped
|
|
44
|
+
envelope
|
|
45
|
+
when Textus::Domain::Outcome::Refreshed
|
|
46
|
+
outcome.envelope.with(
|
|
47
|
+
freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, refreshing: false),
|
|
48
|
+
)
|
|
49
|
+
when Textus::Domain::Outcome::Detached
|
|
50
|
+
envelope.with(freshness: envelope.freshness.with(refreshing: true))
|
|
51
|
+
when Textus::Domain::Outcome::Failed
|
|
52
|
+
envelope.with(
|
|
53
|
+
freshness: envelope.freshness.with(refresh_error: outcome.error.message),
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
Textus::Application::UseCase.register(:get_or_refresh, Textus::Application::Read::GetOrRefresh, caps: :write)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Read
|
|
4
|
+
module List
|
|
5
|
+
def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
6
|
+
Impl.new(caps: caps).call(*, **)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class Impl
|
|
10
|
+
def initialize(caps:)
|
|
11
|
+
@manifest = caps.manifest
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(prefix: nil, zone: nil)
|
|
15
|
+
rows = @manifest.resolver.enumerate(prefix: prefix)
|
|
16
|
+
rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
|
|
17
|
+
rows.map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
Textus::Application::UseCase.register(:list, Textus::Application::Read::List, caps: :read)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Read
|
|
4
|
+
# For one key, surface every matching policy block along with the
|
|
5
|
+
# per-slot effective value (which loses ties win-by-specificity).
|
|
6
|
+
module PolicyExplain
|
|
7
|
+
def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
8
|
+
Impl.new(caps: caps).call(*, **)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class Impl
|
|
12
|
+
def initialize(caps:)
|
|
13
|
+
@manifest = caps.manifest
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(key:)
|
|
17
|
+
policies = @manifest.rules
|
|
18
|
+
matching = policies.explain(key)
|
|
19
|
+
winners = policies.for(key)
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
key: key,
|
|
23
|
+
matched_blocks: matching.map do |b|
|
|
24
|
+
{
|
|
25
|
+
match: b.match,
|
|
26
|
+
refresh: !b.refresh.nil?,
|
|
27
|
+
handler_allowlist: !b.handler_allowlist.nil?,
|
|
28
|
+
promote: !b.promote.nil?,
|
|
29
|
+
}
|
|
30
|
+
end,
|
|
31
|
+
effective: {
|
|
32
|
+
refresh: winners.refresh && {
|
|
33
|
+
ttl_seconds: winners.refresh.ttl_seconds,
|
|
34
|
+
on_stale: winners.refresh.on_stale,
|
|
35
|
+
},
|
|
36
|
+
handler_allowlist: winners.handler_allowlist&.handlers,
|
|
37
|
+
promotion: winners.promote && { requires: winners.promote.requires },
|
|
38
|
+
},
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
Textus::Application::UseCase.register(:policy_explain, Textus::Application::Read::PolicyExplain, caps: :read)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Read
|
|
4
|
+
module Published
|
|
5
|
+
def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
6
|
+
Impl.new(caps: caps).call(*, **)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class Impl
|
|
10
|
+
def initialize(caps:)
|
|
11
|
+
@manifest = caps.manifest
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call
|
|
15
|
+
@manifest.data.entries.reject { |e| e.publish_to.empty? }.map do |e|
|
|
16
|
+
{ "key" => e.key, "publish_to" => e.publish_to }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
Textus::Application::UseCase.register(:published, Textus::Application::Read::Published, caps: :read)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
module Application
|
|
6
|
+
module Read
|
|
7
|
+
# Aggregator over audit + freshness + review + doctor. One round-trip
|
|
8
|
+
# for an agent's per-turn heartbeat. All component reads are existing
|
|
9
|
+
# APIs; pulse is sugar with a stable envelope shape and a monotonic
|
|
10
|
+
# cursor (seq).
|
|
11
|
+
module Pulse
|
|
12
|
+
def self.call(*, session:, ctx:, caps:, **)
|
|
13
|
+
Impl.new(ctx: ctx, caps: caps, session: session).call(*, **)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class Impl
|
|
17
|
+
def initialize(ctx:, caps:, session:)
|
|
18
|
+
@ctx = ctx
|
|
19
|
+
@caps = caps
|
|
20
|
+
@manifest = caps.manifest
|
|
21
|
+
@file_store = caps.file_store
|
|
22
|
+
@audit_log = caps.audit_log
|
|
23
|
+
@root = caps.root
|
|
24
|
+
@events = caps.events
|
|
25
|
+
@session = session
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(since: 0)
|
|
29
|
+
freshness_rows = freshness.call
|
|
30
|
+
{
|
|
31
|
+
"cursor" => @audit_log.latest_seq,
|
|
32
|
+
"changed" => audit_changes_since(since),
|
|
33
|
+
"stale" => freshness_rows.select { |r| r[:status] == :stale }.map { |r| r[:key] },
|
|
34
|
+
"pending_review" => review_keys,
|
|
35
|
+
"doctor" => doctor_summary,
|
|
36
|
+
"manifest_etag" => manifest_etag,
|
|
37
|
+
"next_due_at" => soonest_due(freshness_rows),
|
|
38
|
+
"hook_errors" => hook_errors_since(since),
|
|
39
|
+
}
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def audit_changes_since(seq)
|
|
45
|
+
Read::Audit::Impl.new(caps: @caps).call(seq_since: seq)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def freshness
|
|
49
|
+
@freshness ||= Read::Freshness::Impl.new(ctx: @ctx, caps: @caps)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def soonest_due(rows)
|
|
53
|
+
times = rows.map { |r| r[:next_due_at] }.compact.map { |t| Time.parse(t) }
|
|
54
|
+
return nil if times.empty?
|
|
55
|
+
|
|
56
|
+
times.min.utc.iso8601
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def review_keys
|
|
60
|
+
# List constructor takes only manifest:; returns hashes with string keys.
|
|
61
|
+
# Guard: zones is a Hash keyed by name string.
|
|
62
|
+
return [] unless @manifest.data.zones.key?("review")
|
|
63
|
+
|
|
64
|
+
rows = Read::List::Impl.new(caps: @caps).call(zone: "review")
|
|
65
|
+
rows.map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def doctor_summary
|
|
69
|
+
result = Textus::Doctor.run(@session)
|
|
70
|
+
issues = result["issues"] || []
|
|
71
|
+
{
|
|
72
|
+
"ok" => result["ok"],
|
|
73
|
+
"warn" => issues.count { |i| i["level"] == "warning" },
|
|
74
|
+
"fail" => issues.count { |i| i["level"] == "error" },
|
|
75
|
+
}
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def manifest_etag
|
|
79
|
+
Digest::SHA256.hexdigest(File.read(File.join(@root, "manifest.yaml")))
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def hook_errors_since(seq)
|
|
83
|
+
@events.error_log.since(seq).map do |r|
|
|
84
|
+
{
|
|
85
|
+
"seq" => r[:seq],
|
|
86
|
+
"event" => r[:event].to_s,
|
|
87
|
+
"hook" => r[:hook].to_s,
|
|
88
|
+
"key" => r[:key],
|
|
89
|
+
"error_class" => r[:error_class],
|
|
90
|
+
"error_message" => r[:error_message],
|
|
91
|
+
"at" => r[:at],
|
|
92
|
+
}
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
Textus::Application::UseCase.register(:pulse, Textus::Application::Read::Pulse, caps: :read)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Read
|
|
4
|
+
module Rdeps
|
|
5
|
+
def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
6
|
+
Impl.new(caps: caps).call(*, **)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class Impl
|
|
10
|
+
def initialize(caps:)
|
|
11
|
+
@manifest = caps.manifest
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(key)
|
|
15
|
+
@manifest.data.entries.each_with_object([]) do |e, acc|
|
|
16
|
+
next unless e.is_a?(Textus::Manifest::Entry::Derived)
|
|
17
|
+
|
|
18
|
+
src = e.source
|
|
19
|
+
sources = if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
|
|
20
|
+
Array(src.select).compact
|
|
21
|
+
elsif src.is_a?(Textus::Manifest::Entry::Derived::External)
|
|
22
|
+
Array(src.sources).compact
|
|
23
|
+
else
|
|
24
|
+
[]
|
|
25
|
+
end
|
|
26
|
+
acc << e.key if sources.any? { |s| s == key || key.start_with?("#{s}.") }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Textus::Application::UseCase.register(:rdeps, Textus::Application::Read::Rdeps, caps: :read)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Read
|
|
4
|
+
module SchemaEnvelope
|
|
5
|
+
def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
6
|
+
Impl.new(caps: caps).call(*, **)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class Impl
|
|
10
|
+
def initialize(caps:)
|
|
11
|
+
@manifest = caps.manifest
|
|
12
|
+
@schemas = caps.schemas
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(key)
|
|
16
|
+
mentry = @manifest.resolver.resolve(key).entry
|
|
17
|
+
schema = @schemas.fetch_or_nil(mentry.schema)
|
|
18
|
+
{ "protocol" => PROTOCOL, "key" => key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Textus::Application::UseCase.register(:schema_envelope, Textus::Application::Read::SchemaEnvelope, caps: :read)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Read
|
|
4
|
+
module Stale
|
|
5
|
+
def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
6
|
+
Impl.new(caps: caps).call(*, **)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class Impl
|
|
10
|
+
def initialize(caps:)
|
|
11
|
+
@manifest = caps.manifest
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(prefix: nil, zone: nil)
|
|
15
|
+
Textus::Domain::Staleness.new(manifest: @manifest).call(prefix: prefix, zone: zone)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
Textus::Application::UseCase.register(:stale, Textus::Application::Read::Stale, caps: :read)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Read
|
|
4
|
+
module Uid
|
|
5
|
+
def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
6
|
+
Impl.new(ctx: ctx, caps: caps).call(*, **)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class Impl
|
|
10
|
+
def initialize(ctx:, caps:)
|
|
11
|
+
@ctx = ctx
|
|
12
|
+
@caps = caps
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def call(key)
|
|
16
|
+
get.get(key).uid
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def get
|
|
22
|
+
@get ||= Get::Impl.new(ctx: @ctx, caps: @caps)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
Textus::Application::UseCase.register(:uid, Textus::Application::Read::Uid, caps: :read)
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Read
|
|
4
|
+
module ValidateAll
|
|
5
|
+
def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
6
|
+
Impl.new(ctx: ctx, caps: caps).call(*, **)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class Impl
|
|
10
|
+
def initialize(ctx:, caps:)
|
|
11
|
+
@ctx = ctx
|
|
12
|
+
@caps = caps
|
|
13
|
+
@manifest = caps.manifest
|
|
14
|
+
@schemas = caps.schemas
|
|
15
|
+
@audit_log = caps.audit_log
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
Validator.new(
|
|
20
|
+
reader: Get::Impl.new(ctx: @ctx, caps: @caps),
|
|
21
|
+
manifest: @manifest,
|
|
22
|
+
audit_log: @audit_log,
|
|
23
|
+
schema_for: ->(name) { @schemas.fetch_or_nil(name) },
|
|
24
|
+
).call
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
Textus::Application::UseCase.register(:validate_all, Textus::Application::Read::ValidateAll, caps: :read)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Application
|
|
3
|
-
module
|
|
3
|
+
module Read
|
|
4
4
|
class Validator
|
|
5
5
|
def initialize(reader:, manifest:, audit_log:, schema_for:)
|
|
6
6
|
@reader = reader
|
|
@@ -55,7 +55,7 @@ module Textus
|
|
|
55
55
|
last_writer = @audit_log.last_writer_for(key)
|
|
56
56
|
return if last_writer.nil?
|
|
57
57
|
|
|
58
|
-
last_writer_is_authority = @manifest.role_kind(last_writer) == :accept_authority
|
|
58
|
+
last_writer_is_authority = @manifest.policy.role_kind(last_writer) == :accept_authority
|
|
59
59
|
|
|
60
60
|
env.meta.each_key do |field|
|
|
61
61
|
owner = schema.maintained_by(field)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Read
|
|
4
|
+
module Where
|
|
5
|
+
def self.call(*, session:, ctx:, caps:, **) # rubocop:disable Lint/UnusedMethodArgument
|
|
6
|
+
Impl.new(caps: caps).call(*, **)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
class Impl
|
|
10
|
+
def initialize(caps:)
|
|
11
|
+
@manifest = caps.manifest
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(key)
|
|
15
|
+
res = @manifest.resolver.resolve(key)
|
|
16
|
+
mentry = res.entry
|
|
17
|
+
path = res.path
|
|
18
|
+
{ "protocol" => PROTOCOL, "key" => key, "zone" => mentry.zone, "owner" => mentry.owner, "path" => path }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Textus::Application::UseCase.register(:where, Textus::Application::Read::Where, caps: :read)
|