textus 0.22.0 → 0.29.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 +195 -48
- data/CHANGELOG.md +178 -0
- data/README.md +55 -13
- data/SPEC.md +79 -42
- data/docs/conventions.md +10 -0
- data/lib/textus/boot.rb +31 -29
- data/lib/textus/builder/pipeline.rb +13 -12
- data/lib/textus/call.rb +28 -0
- 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 +3 -3
- 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 +7 -7
- data/lib/textus/cli.rb +0 -7
- data/lib/textus/container.rb +23 -0
- data/lib/textus/dispatcher.rb +49 -0
- 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 +11 -9
- 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 +12 -3
- data/lib/textus/doctor.rb +24 -27
- data/lib/textus/domain/authorizer.rb +6 -6
- 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/sentinel.rb +9 -65
- data/lib/textus/domain/staleness/generator_check.rb +46 -26
- data/lib/textus/domain/staleness/intake_check.rb +20 -12
- data/lib/textus/domain/staleness.rb +4 -4
- data/lib/textus/envelope/io/reader.rb +44 -0
- data/lib/textus/{application/writes/envelope_io.rb → envelope/io/writer.rb} +32 -58
- data/lib/textus/hooks/builtin.rb +14 -14
- data/lib/textus/hooks/context.rb +30 -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/key/path.rb +7 -3
- data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
- data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
- data/lib/textus/maintenance/migrate.rb +51 -0
- data/lib/textus/maintenance/rule_lint.rb +56 -0
- data/lib/textus/maintenance/zone_mv.rb +51 -0
- data/lib/textus/maintenance.rb +15 -0
- data/lib/textus/manifest/data.rb +79 -0
- data/lib/textus/manifest/entry/base.rb +38 -18
- data/lib/textus/manifest/entry/derived.rb +8 -9
- data/lib/textus/manifest/entry/nested.rb +7 -9
- data/lib/textus/manifest/entry/parser.rb +2 -2
- data/lib/textus/manifest/entry/validators/events.rb +2 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
- data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +2 -2
- data/lib/textus/manifest/entry.rb +0 -5
- 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 +47 -110
- data/lib/textus/mcp/errors.rb +32 -0
- data/lib/textus/mcp/server.rb +126 -0
- data/lib/textus/mcp/session.rb +40 -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/{infra → ports}/audit_log.rb +1 -1
- data/lib/textus/{infra → ports}/audit_subscriber.rb +7 -8
- data/lib/textus/{infra → ports}/build_lock.rb +1 -1
- data/lib/textus/{infra → ports}/clock.rb +1 -1
- data/lib/textus/{infra → ports}/publisher.rb +6 -6
- data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
- data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
- data/lib/textus/ports/sentinel_store.rb +67 -0
- data/lib/textus/ports/storage/file_stat.rb +19 -0
- data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
- data/lib/textus/projection.rb +91 -0
- data/lib/textus/read/audit.rb +111 -0
- data/lib/textus/read/blame.rb +81 -0
- data/lib/textus/read/boot.rb +18 -0
- data/lib/textus/read/deps.rb +24 -0
- data/lib/textus/read/doctor.rb +19 -0
- data/lib/textus/read/freshness.rb +101 -0
- data/lib/textus/read/get.rb +66 -0
- data/lib/textus/read/get_or_refresh.rb +69 -0
- data/lib/textus/read/list.rb +15 -0
- data/lib/textus/read/policy_explain.rb +37 -0
- data/lib/textus/read/published.rb +15 -0
- data/lib/textus/read/pulse.rb +89 -0
- data/lib/textus/read/rdeps.rb +25 -0
- data/lib/textus/read/schema_envelope.rb +16 -0
- data/lib/textus/read/stale.rb +17 -0
- data/lib/textus/read/uid.rb +20 -0
- data/lib/textus/read/validate_all.rb +22 -0
- data/lib/textus/read/validator.rb +84 -0
- data/lib/textus/read/where.rb +16 -0
- data/lib/textus/role_scope.rb +49 -0
- data/lib/textus/schema/tools.rb +14 -10
- data/lib/textus/store.rb +25 -11
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +86 -0
- data/lib/textus/write/authority_gate.rb +24 -0
- data/lib/textus/write/delete.rb +54 -0
- data/lib/textus/write/materializer.rb +48 -0
- data/lib/textus/write/mv.rb +123 -0
- data/lib/textus/write/publish.rb +66 -0
- data/lib/textus/write/put.rb +59 -0
- data/lib/textus/write/refresh_all.rb +44 -0
- data/lib/textus/write/refresh_orchestrator.rb +102 -0
- data/lib/textus/write/refresh_worker.rb +138 -0
- data/lib/textus/write/reject.rb +54 -0
- data/lib/textus.rb +7 -1
- metadata +75 -46
- data/lib/textus/application/context.rb +0 -34
- data/lib/textus/application/projection.rb +0 -91
- 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/validator.rb +0 -86
- data/lib/textus/application/reads/where.rb +0 -18
- data/lib/textus/application/refresh/all.rb +0 -52
- data/lib/textus/application/refresh/orchestrator.rb +0 -78
- data/lib/textus/application/refresh/worker.rb +0 -116
- data/lib/textus/application/writes/accept.rb +0 -89
- data/lib/textus/application/writes/authority_gate.rb +0 -26
- data/lib/textus/application/writes/delete.rb +0 -33
- data/lib/textus/application/writes/materializer.rb +0 -50
- 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,66 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
# Pure read: returns the on-disk envelope annotated with a freshness
|
|
4
|
+
# verdict. Never triggers refresh; never invokes the orchestrator.
|
|
5
|
+
#
|
|
6
|
+
# For interactive reads that want refresh-on-stale, use
|
|
7
|
+
# `Read::GetOrRefresh`, which composes this with the orchestrator.
|
|
8
|
+
class Get
|
|
9
|
+
def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
10
|
+
@container = container
|
|
11
|
+
@call = call
|
|
12
|
+
@manifest = container.manifest
|
|
13
|
+
@file_store = container.file_store
|
|
14
|
+
@evaluator = evaluator
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(key)
|
|
18
|
+
envelope = read_raw_envelope(key)
|
|
19
|
+
return nil if envelope.nil?
|
|
20
|
+
|
|
21
|
+
policy_set = @manifest.rules.for(key)
|
|
22
|
+
refresh_policy = policy_set.refresh
|
|
23
|
+
return annotate_fresh(envelope) if refresh_policy.nil?
|
|
24
|
+
|
|
25
|
+
policy = refresh_policy.to_freshness_policy
|
|
26
|
+
verdict = @evaluator.call(policy, envelope, now: @call.now)
|
|
27
|
+
|
|
28
|
+
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
29
|
+
stale: verdict.stale?,
|
|
30
|
+
reason: verdict.reason,
|
|
31
|
+
refreshing: false,
|
|
32
|
+
))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Strict variant: raises UnknownKey when the entry is missing.
|
|
36
|
+
# Used by consumers (e.g. Validator) that need to distinguish absence
|
|
37
|
+
# from emptiness.
|
|
38
|
+
def get(key)
|
|
39
|
+
call(key) || raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def read_raw_envelope(key)
|
|
45
|
+
res = @manifest.resolver.resolve(key)
|
|
46
|
+
mentry = res.entry
|
|
47
|
+
path = res.path
|
|
48
|
+
return nil unless @file_store.exists?(path)
|
|
49
|
+
|
|
50
|
+
raw = @file_store.read(path)
|
|
51
|
+
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
52
|
+
Textus::Envelope.build(
|
|
53
|
+
key: key, mentry: mentry, path: path,
|
|
54
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
55
|
+
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def annotate_fresh(envelope)
|
|
60
|
+
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
61
|
+
stale: false, reason: nil, refreshing: false,
|
|
62
|
+
))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
# Composes pure `Read::Get` with the refresh orchestrator: runs Get
|
|
4
|
+
# to obtain the envelope and freshness verdict, then if the verdict
|
|
5
|
+
# is stale and the rule's `on_stale` policy demands action, hands
|
|
6
|
+
# off to the orchestrator. Use for interactive reads where the
|
|
7
|
+
# caller wants the freshest obtainable envelope.
|
|
8
|
+
#
|
|
9
|
+
# Pure reads (build, projection, schema tooling) should use
|
|
10
|
+
# `Read::Get` directly; it has no orchestrator dependency.
|
|
11
|
+
class GetOrRefresh
|
|
12
|
+
def initialize(container:, call:, get: nil, orchestrator: nil)
|
|
13
|
+
@container = container
|
|
14
|
+
@call = call
|
|
15
|
+
@manifest = container.manifest
|
|
16
|
+
@get = get || Read::Get.new(container: container, call: call)
|
|
17
|
+
@orchestrator = orchestrator || build_orchestrator
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def hook_context
|
|
23
|
+
@hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def build_orchestrator
|
|
27
|
+
worker = Textus::Write::RefreshWorker.new(
|
|
28
|
+
container: @container, call: @call,
|
|
29
|
+
)
|
|
30
|
+
Textus::Write::RefreshOrchestrator.new(
|
|
31
|
+
worker: worker, store_root: @container.root, events: @container.events,
|
|
32
|
+
hook_context: hook_context
|
|
33
|
+
)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
public
|
|
37
|
+
|
|
38
|
+
def call(key)
|
|
39
|
+
envelope = @get.call(key)
|
|
40
|
+
return nil if envelope.nil?
|
|
41
|
+
return envelope unless envelope.freshness&.stale
|
|
42
|
+
|
|
43
|
+
policy_set = @manifest.rules.for(key)
|
|
44
|
+
refresh_policy = policy_set.refresh
|
|
45
|
+
return envelope if refresh_policy.nil?
|
|
46
|
+
|
|
47
|
+
policy = refresh_policy.to_freshness_policy
|
|
48
|
+
verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
|
|
49
|
+
action = policy.decide(verdict)
|
|
50
|
+
outcome = @orchestrator.execute(action, key: key)
|
|
51
|
+
|
|
52
|
+
case outcome
|
|
53
|
+
when Textus::Domain::Outcome::Skipped
|
|
54
|
+
envelope
|
|
55
|
+
when Textus::Domain::Outcome::Refreshed
|
|
56
|
+
outcome.envelope.with(
|
|
57
|
+
freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, refreshing: false),
|
|
58
|
+
)
|
|
59
|
+
when Textus::Domain::Outcome::Detached
|
|
60
|
+
envelope.with(freshness: envelope.freshness.with(refreshing: true))
|
|
61
|
+
when Textus::Domain::Outcome::Failed
|
|
62
|
+
envelope.with(
|
|
63
|
+
freshness: envelope.freshness.with(refresh_error: outcome.error.message),
|
|
64
|
+
)
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
class List
|
|
4
|
+
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
|
+
@manifest = container.manifest
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call(prefix: nil, zone: nil)
|
|
9
|
+
rows = @manifest.resolver.enumerate(prefix: prefix)
|
|
10
|
+
rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
|
|
11
|
+
rows.map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
# For one key, surface every matching policy block along with the
|
|
4
|
+
# per-slot effective value (which loses ties win-by-specificity).
|
|
5
|
+
class PolicyExplain
|
|
6
|
+
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
7
|
+
@manifest = container.manifest
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(key:)
|
|
11
|
+
policies = @manifest.rules
|
|
12
|
+
matching = policies.explain(key)
|
|
13
|
+
winners = policies.for(key)
|
|
14
|
+
|
|
15
|
+
{
|
|
16
|
+
key: key,
|
|
17
|
+
matched_blocks: matching.map do |b|
|
|
18
|
+
{
|
|
19
|
+
match: b.match,
|
|
20
|
+
refresh: !b.refresh.nil?,
|
|
21
|
+
handler_allowlist: !b.handler_allowlist.nil?,
|
|
22
|
+
promote: !b.promote.nil?,
|
|
23
|
+
}
|
|
24
|
+
end,
|
|
25
|
+
effective: {
|
|
26
|
+
refresh: winners.refresh && {
|
|
27
|
+
ttl_seconds: winners.refresh.ttl_seconds,
|
|
28
|
+
on_stale: winners.refresh.on_stale,
|
|
29
|
+
},
|
|
30
|
+
handler_allowlist: winners.handler_allowlist&.handlers,
|
|
31
|
+
promotion: winners.promote && { requires: winners.promote.requires },
|
|
32
|
+
},
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
class Published
|
|
4
|
+
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
|
+
@manifest = container.manifest
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call
|
|
9
|
+
@manifest.data.entries.reject { |e| e.publish_to.empty? }.map do |e|
|
|
10
|
+
{ "key" => e.key, "publish_to" => e.publish_to }
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Read
|
|
5
|
+
# Aggregator over audit + freshness + review + doctor. One round-trip
|
|
6
|
+
# for an agent's per-turn heartbeat. All component reads are existing
|
|
7
|
+
# APIs; pulse is sugar with a stable envelope shape and a monotonic
|
|
8
|
+
# cursor (seq).
|
|
9
|
+
class Pulse
|
|
10
|
+
def initialize(container:, call:)
|
|
11
|
+
@container = container
|
|
12
|
+
@call = call
|
|
13
|
+
@manifest = container.manifest
|
|
14
|
+
@file_store = container.file_store
|
|
15
|
+
@audit_log = container.audit_log
|
|
16
|
+
@root = container.root
|
|
17
|
+
@events = container.events
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(since: 0)
|
|
21
|
+
freshness_rows = freshness.call
|
|
22
|
+
{
|
|
23
|
+
"cursor" => @audit_log.latest_seq,
|
|
24
|
+
"changed" => audit_changes_since(since),
|
|
25
|
+
"stale" => freshness_rows.select { |r| r[:status] == :stale }.map { |r| r[:key] },
|
|
26
|
+
"pending_review" => review_keys,
|
|
27
|
+
"doctor" => doctor_summary,
|
|
28
|
+
"manifest_etag" => manifest_etag,
|
|
29
|
+
"next_due_at" => soonest_due(freshness_rows),
|
|
30
|
+
"hook_errors" => hook_errors_since(since),
|
|
31
|
+
}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def audit_changes_since(seq)
|
|
37
|
+
Read::Audit.new(container: @container).call(seq_since: seq)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def freshness
|
|
41
|
+
@freshness ||= Read::Freshness.new(container: @container, call: @call)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def soonest_due(rows)
|
|
45
|
+
times = rows.map { |r| r[:next_due_at] }.compact.map { |t| Time.parse(t) }
|
|
46
|
+
return nil if times.empty?
|
|
47
|
+
|
|
48
|
+
times.min.utc.iso8601
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def review_keys
|
|
52
|
+
# List constructor takes only manifest:; returns hashes with string keys.
|
|
53
|
+
# Guard: zones is a Hash keyed by name string.
|
|
54
|
+
return [] unless @manifest.data.zones.key?("review")
|
|
55
|
+
|
|
56
|
+
rows = Read::List.new(container: @container).call(zone: "review")
|
|
57
|
+
rows.map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def doctor_summary
|
|
61
|
+
result = Textus::Doctor.build(container: @container)
|
|
62
|
+
issues = result["issues"] || []
|
|
63
|
+
{
|
|
64
|
+
"ok" => result["ok"],
|
|
65
|
+
"warn" => issues.count { |i| i["level"] == "warning" },
|
|
66
|
+
"fail" => issues.count { |i| i["level"] == "error" },
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def manifest_etag
|
|
71
|
+
@file_store.etag(File.join(@root, "manifest.yaml"))
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def hook_errors_since(seq)
|
|
75
|
+
@events.error_log.since(seq).map do |r|
|
|
76
|
+
{
|
|
77
|
+
"seq" => r[:seq],
|
|
78
|
+
"event" => r[:event].to_s,
|
|
79
|
+
"hook" => r[:hook].to_s,
|
|
80
|
+
"key" => r[:key],
|
|
81
|
+
"error_class" => r[:error_class],
|
|
82
|
+
"error_message" => r[:error_message],
|
|
83
|
+
"at" => r[:at],
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
class Rdeps
|
|
4
|
+
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
|
+
@manifest = container.manifest
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call(key)
|
|
9
|
+
@manifest.data.entries.each_with_object([]) do |e, acc|
|
|
10
|
+
next unless e.is_a?(Textus::Manifest::Entry::Derived)
|
|
11
|
+
|
|
12
|
+
src = e.source
|
|
13
|
+
sources = if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
|
|
14
|
+
Array(src.select).compact
|
|
15
|
+
elsif src.is_a?(Textus::Manifest::Entry::Derived::External)
|
|
16
|
+
Array(src.sources).compact
|
|
17
|
+
else
|
|
18
|
+
[]
|
|
19
|
+
end
|
|
20
|
+
acc << e.key if sources.any? { |s| s == key || key.start_with?("#{s}.") }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
class SchemaEnvelope
|
|
4
|
+
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
|
+
@manifest = container.manifest
|
|
6
|
+
@schemas = container.schemas
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(key)
|
|
10
|
+
mentry = @manifest.resolver.resolve(key).entry
|
|
11
|
+
schema = @schemas.fetch_or_nil(mentry.schema)
|
|
12
|
+
{ "protocol" => PROTOCOL, "key" => key, "schema_ref" => mentry.schema, "schema" => schema&.to_h }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
class Stale
|
|
4
|
+
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
|
+
@manifest = container.manifest
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call(prefix: nil, zone: nil)
|
|
9
|
+
Textus::Domain::Staleness.new(
|
|
10
|
+
manifest: @manifest,
|
|
11
|
+
file_stat: Textus::Ports::Storage::FileStat.new,
|
|
12
|
+
clock: Textus::Ports::Clock,
|
|
13
|
+
).call(prefix: prefix, zone: zone)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
class Uid
|
|
4
|
+
def initialize(container:, call:)
|
|
5
|
+
@container = container
|
|
6
|
+
@call = call
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(key)
|
|
10
|
+
get.get(key).uid
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def get
|
|
16
|
+
@get ||= Get.new(container: @container, call: @call)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
class ValidateAll
|
|
4
|
+
def initialize(container:, call:)
|
|
5
|
+
@container = container
|
|
6
|
+
@call = call
|
|
7
|
+
@manifest = container.manifest
|
|
8
|
+
@schemas = container.schemas
|
|
9
|
+
@audit_log = container.audit_log
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call
|
|
13
|
+
Validator.new(
|
|
14
|
+
reader: Get.new(container: @container, call: @call),
|
|
15
|
+
manifest: @manifest,
|
|
16
|
+
audit_log: @audit_log,
|
|
17
|
+
schema_for: ->(name) { @schemas.fetch_or_nil(name) },
|
|
18
|
+
).call
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
class Validator
|
|
4
|
+
def initialize(reader:, manifest:, audit_log:, schema_for:)
|
|
5
|
+
@reader = reader
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
@audit_log = audit_log
|
|
8
|
+
@schema_for = schema_for
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call
|
|
12
|
+
violations = []
|
|
13
|
+
check_content_violations(violations)
|
|
14
|
+
check_role_authority_violations(violations)
|
|
15
|
+
{ "protocol" => PROTOCOL, "ok" => violations.empty?, "violations" => violations }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def check_content_violations(violations)
|
|
21
|
+
@manifest.resolver.enumerate.each do |row|
|
|
22
|
+
key = row[:key]
|
|
23
|
+
mentry = row[:manifest_entry]
|
|
24
|
+
env = fetch_envelope(key, violations) or next
|
|
25
|
+
schema = mentry.schema && @schema_for.call(mentry.schema)
|
|
26
|
+
next unless schema
|
|
27
|
+
|
|
28
|
+
begin
|
|
29
|
+
validate_schema!(schema, env, mentry.format)
|
|
30
|
+
rescue Textus::Error => e
|
|
31
|
+
violations << { "key" => key, "code" => e.code, "message" => e.message }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def check_role_authority_violations(violations)
|
|
37
|
+
@manifest.resolver.enumerate.each do |row|
|
|
38
|
+
mentry = row[:manifest_entry]
|
|
39
|
+
next unless mentry.schema
|
|
40
|
+
|
|
41
|
+
schema = @schema_for.call(mentry.schema)
|
|
42
|
+
next unless schema
|
|
43
|
+
|
|
44
|
+
env = begin
|
|
45
|
+
@reader.get(row[:key])
|
|
46
|
+
rescue StandardError
|
|
47
|
+
next
|
|
48
|
+
end
|
|
49
|
+
append_authority_violations(violations, row[:key], env, schema)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def append_authority_violations(violations, key, env, schema)
|
|
54
|
+
last_writer = @audit_log.last_writer_for(key)
|
|
55
|
+
return if last_writer.nil?
|
|
56
|
+
|
|
57
|
+
last_writer_is_authority = @manifest.policy.role_kind(last_writer) == :accept_authority
|
|
58
|
+
|
|
59
|
+
env.meta.each_key do |field|
|
|
60
|
+
owner = schema.maintained_by(field)
|
|
61
|
+
next if owner.nil? || last_writer == owner || last_writer_is_authority
|
|
62
|
+
|
|
63
|
+
violations << { "key" => key, "code" => "role_authority",
|
|
64
|
+
"field" => field, "expected" => owner, "last_writer" => last_writer }
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def fetch_envelope(key, violations)
|
|
69
|
+
@reader.get(key)
|
|
70
|
+
rescue Textus::Error => e
|
|
71
|
+
violations << { "key" => key, "code" => e.code, "message" => e.message }
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def validate_schema!(schema, envelope, format)
|
|
76
|
+
payload = case format
|
|
77
|
+
when "json", "yaml" then envelope.content || {}
|
|
78
|
+
else envelope.meta || {}
|
|
79
|
+
end
|
|
80
|
+
schema.validate!(payload)
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
class Where
|
|
4
|
+
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
|
+
@manifest = container.manifest
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call(key)
|
|
9
|
+
res = @manifest.resolver.resolve(key)
|
|
10
|
+
mentry = res.entry
|
|
11
|
+
path = res.path
|
|
12
|
+
{ "protocol" => PROTOCOL, "key" => key, "zone" => mentry.zone, "owner" => mentry.owner, "path" => path }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
# Thin role-scoped facade over a Container. Closes over a role default
|
|
3
|
+
# and a dry_run flag, then forwards every verb in Dispatcher::VERBS to
|
|
4
|
+
# the corresponding use case.
|
|
5
|
+
#
|
|
6
|
+
# Replaces the per-call Session under the 0.27.0 architecture: a Store
|
|
7
|
+
# exposes #as(role) to get a RoleScope, and Store#put / Store#get / etc
|
|
8
|
+
# delegate to RoleScope under the default role.
|
|
9
|
+
class RoleScope
|
|
10
|
+
attr_reader :container, :role, :correlation_id
|
|
11
|
+
|
|
12
|
+
def dry_run?
|
|
13
|
+
@dry_run
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(container:, role:, dry_run: false, correlation_id: nil)
|
|
17
|
+
@container = container
|
|
18
|
+
@role = role.to_s
|
|
19
|
+
@dry_run = dry_run
|
|
20
|
+
@correlation_id = correlation_id
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def with_role(role)
|
|
24
|
+
self.class.new(container: @container, role: role, dry_run: @dry_run, correlation_id: @correlation_id)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def with_correlation_id(cid)
|
|
28
|
+
self.class.new(container: @container, role: @role, dry_run: @dry_run, correlation_id: cid)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def hook_context
|
|
32
|
+
@hook_context ||= Textus::Hooks::Context.new(scope: self)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def with_dry_run
|
|
36
|
+
self.class.new(container: @container, role: @role, dry_run: true, correlation_id: @correlation_id)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
Textus::Dispatcher::VERBS.each_key do |verb|
|
|
40
|
+
define_method(verb) do |*args, **kwargs|
|
|
41
|
+
klass = Textus::Dispatcher.fetch(verb)
|
|
42
|
+
call_value = Textus::Call.build(
|
|
43
|
+
role: @role, correlation_id: @correlation_id, dry_run: @dry_run,
|
|
44
|
+
)
|
|
45
|
+
klass.new(container: @container, call: call_value).call(*args, **kwargs)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
data/lib/textus/schema/tools.rb
CHANGED
|
@@ -6,7 +6,7 @@ module Textus
|
|
|
6
6
|
module Tools
|
|
7
7
|
# textus schema init NAME --from=KEY → infer YAML schema from an entry's frontmatter
|
|
8
8
|
def self.init(store, name:, from:)
|
|
9
|
-
env = Textus::
|
|
9
|
+
env = store.as(Textus::Role::DEFAULT).get(from)
|
|
10
10
|
meta = env.meta
|
|
11
11
|
schema = {
|
|
12
12
|
"name" => name,
|
|
@@ -25,7 +25,7 @@ module Textus
|
|
|
25
25
|
schema = load_schema(store, name)
|
|
26
26
|
drift = []
|
|
27
27
|
store.manifest.resolver.enumerate.each do |row|
|
|
28
|
-
env = Textus::
|
|
28
|
+
env = store.as(Textus::Role::DEFAULT).get(row[:key])
|
|
29
29
|
begin
|
|
30
30
|
schema.validate!(env.meta)
|
|
31
31
|
rescue SchemaViolation => e
|
|
@@ -49,14 +49,8 @@ module Textus
|
|
|
49
49
|
end
|
|
50
50
|
raise UsageError.new("schema migrate needs --rename=OLD:NEW or schema.evolution.migrate_from") if renames.empty?
|
|
51
51
|
|
|
52
|
-
authority = store
|
|
53
|
-
|
|
54
|
-
raise UsageError.new(
|
|
55
|
-
"schema migrate requires a role with kind :accept_authority in the manifest; " \
|
|
56
|
-
"none declared (add e.g. `- { name: owner, kind: accept_authority }` to roles:)",
|
|
57
|
-
)
|
|
58
|
-
end
|
|
59
|
-
ops = Textus::Operations.for(store, role: authority)
|
|
52
|
+
authority = accept_authority_for(store)
|
|
53
|
+
ops = store.as(authority)
|
|
60
54
|
touched = []
|
|
61
55
|
store.manifest.resolver.enumerate.each do |row|
|
|
62
56
|
env = ops.get(row[:key])
|
|
@@ -92,6 +86,16 @@ module Textus
|
|
|
92
86
|
rescue IoError
|
|
93
87
|
raise UsageError.new("schema not found: #{name}")
|
|
94
88
|
end
|
|
89
|
+
|
|
90
|
+
def self.accept_authority_for(store)
|
|
91
|
+
authority = store.manifest.policy.roles_with_kind(:accept_authority).first
|
|
92
|
+
return authority if authority
|
|
93
|
+
|
|
94
|
+
raise UsageError.new(
|
|
95
|
+
"schema migrate requires a role with kind :accept_authority in the manifest; " \
|
|
96
|
+
"none declared (add e.g. `- { name: owner, kind: accept_authority }` to roles:)",
|
|
97
|
+
)
|
|
98
|
+
end
|
|
95
99
|
end
|
|
96
100
|
end
|
|
97
101
|
end
|
data/lib/textus/store.rb
CHANGED
|
@@ -2,7 +2,7 @@ require "fileutils"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
class Store
|
|
5
|
-
attr_reader :root, :manifest, :schemas, :file_store, :audit_log, :
|
|
5
|
+
attr_reader :root, :manifest, :schemas, :file_store, :audit_log, :events, :rpc
|
|
6
6
|
|
|
7
7
|
def self.discover(start_dir = Dir.pwd, root: nil)
|
|
8
8
|
explicit = root || ENV.fetch("TEXTUS_ROOT", nil)
|
|
@@ -32,18 +32,32 @@ module Textus
|
|
|
32
32
|
@root = File.expand_path(root)
|
|
33
33
|
@manifest = Manifest.load(@root)
|
|
34
34
|
@schemas = Schemas.new(File.join(@root, "schemas"))
|
|
35
|
-
@file_store =
|
|
36
|
-
@audit_log =
|
|
35
|
+
@file_store = Ports::Storage::FileStore.new
|
|
36
|
+
@audit_log = Ports::AuditLog.new(
|
|
37
37
|
@root,
|
|
38
|
-
max_size: @manifest.audit_config[:max_size],
|
|
39
|
-
keep: @manifest.audit_config[:keep],
|
|
38
|
+
max_size: @manifest.data.audit_config[:max_size],
|
|
39
|
+
keep: @manifest.data.audit_config[:keep],
|
|
40
40
|
)
|
|
41
|
-
@
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
Hooks::
|
|
45
|
-
|
|
46
|
-
@
|
|
41
|
+
@events = Hooks::EventBus.new
|
|
42
|
+
@rpc = Hooks::RpcRegistry.new
|
|
43
|
+
Ports::AuditSubscriber.new(@audit_log).attach(@events)
|
|
44
|
+
Hooks::Builtin.register_all(events: @events, rpc: @rpc)
|
|
45
|
+
Hooks::Loader.new(events: @events, rpc: @rpc).load_dir(File.join(@root, "hooks"))
|
|
46
|
+
@events.publish(:store_loaded, ctx: Hooks::Context.new(scope: as(Role::DEFAULT)))
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def container
|
|
50
|
+
@container ||= Textus::Container.from_store(self)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def as(role, dry_run: false, correlation_id: nil)
|
|
54
|
+
RoleScope.new(container: container, role: role, dry_run: dry_run, correlation_id: correlation_id)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Textus::Dispatcher::VERBS.each_key do |verb|
|
|
58
|
+
define_method(verb) do |*args, role: Role::DEFAULT, **kwargs|
|
|
59
|
+
as(role).public_send(verb, *args, **kwargs)
|
|
60
|
+
end
|
|
47
61
|
end
|
|
48
62
|
end
|
|
49
63
|
end
|
data/lib/textus/version.rb
CHANGED