textus 0.20.2 → 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 +194 -0
- data/README.md +8 -5
- data/SPEC.md +54 -15
- 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/{intro.rb → boot.rb} +49 -29
- data/lib/textus/builder/pipeline.rb +5 -5
- 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 +4 -2
- data/lib/textus/cli/verb/blame.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +13 -0
- 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 +17 -0
- 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/cli.rb +1 -1
- 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/errors.rb +16 -0
- 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_log.rb +126 -16
- 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 +44 -7
- data/lib/textus/manifest/entry/derived.rb +41 -6
- data/lib/textus/manifest/entry/intake.rb +15 -3
- data/lib/textus/manifest/entry/leaf.rb +6 -5
- data/lib/textus/manifest/entry/nested.rb +42 -3
- data/lib/textus/manifest/entry/parser.rb +8 -44
- data/lib/textus/manifest/entry/validators/events.rb +2 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
- data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
- data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
- data/lib/textus/manifest/entry/validators.rb +1 -1
- data/lib/textus/manifest/entry.rb +3 -0
- data/lib/textus/manifest/policy.rb +48 -0
- data/lib/textus/manifest/resolver.rb +18 -18
- data/lib/textus/manifest/rules.rb +1 -1
- data/lib/textus/manifest/schema.rb +20 -6
- data/lib/textus/manifest.rb +53 -101
- 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 +17 -8
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +8 -1
- metadata +65 -38
- data/lib/textus/application/reads/audit.rb +0 -69
- 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/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 -162
- data/lib/textus/application/writes/put.rb +0 -37
- data/lib/textus/application/writes/reject.rb +0 -50
- data/lib/textus/cli/verb/intro.rb +0 -13
- data/lib/textus/infra/event_bus.rb +0 -27
- data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
- data/lib/textus/operations.rb +0 -169
|
@@ -14,12 +14,13 @@ module Textus
|
|
|
14
14
|
raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
has_template =
|
|
18
|
-
is_external
|
|
19
|
-
|
|
17
|
+
has_template = !entry.template.nil?
|
|
18
|
+
is_external = entry.derived? && entry.external?
|
|
19
|
+
is_intake = entry.intake?
|
|
20
|
+
return unless entry.in_generator_zone? && !has_template && !is_external && !is_intake &&
|
|
20
21
|
%w[markdown text].include?(entry.format) && !entry.nested?
|
|
21
22
|
|
|
22
|
-
raise UsageError.new("entry '#{entry.key}':
|
|
23
|
+
raise UsageError.new("entry '#{entry.key}': #{entry.format} entries in a generator zone require a template")
|
|
23
24
|
end
|
|
24
25
|
end
|
|
25
26
|
end
|
|
@@ -4,7 +4,8 @@ module Textus
|
|
|
4
4
|
module Validators
|
|
5
5
|
module IndexFilename
|
|
6
6
|
def self.call(entry)
|
|
7
|
-
|
|
7
|
+
# Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
|
|
8
|
+
index_filename = entry.nested? ? entry.index_filename : entry.raw["index_filename"]
|
|
8
9
|
return if index_filename.nil?
|
|
9
10
|
|
|
10
11
|
check_shape!(entry, index_filename)
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Validators
|
|
5
|
+
module InjectBoot
|
|
6
|
+
def self.call(entry)
|
|
7
|
+
return unless entry.inject_boot
|
|
8
|
+
|
|
9
|
+
raise UsageError.new("entry '#{entry.key}': inject_boot: is only valid on derived entries") unless entry.in_generator_zone?
|
|
10
|
+
|
|
11
|
+
return unless entry.template.nil?
|
|
12
|
+
|
|
13
|
+
raise UsageError.new("entry '#{entry.key}': inject_boot: requires a template:")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -7,13 +7,14 @@ module Textus
|
|
|
7
7
|
VAR_RE = /\{([a-z]+)\}/
|
|
8
8
|
REQUIRED_DISCRIMINATOR_VARS = %w[leaf basename key].freeze
|
|
9
9
|
|
|
10
|
-
def self.call(entry)
|
|
11
|
-
|
|
10
|
+
def self.call(entry)
|
|
11
|
+
# Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
|
|
12
|
+
publish_each = entry.nested? ? entry.publish_each : entry.raw["publish_each"]
|
|
12
13
|
return if publish_each.nil?
|
|
13
14
|
|
|
14
15
|
raise UsageError.new("entry '#{entry.key}': publish_each requires nested: true") unless entry.nested?
|
|
15
16
|
|
|
16
|
-
publish_to = entry.
|
|
17
|
+
publish_to = entry.publish_to
|
|
17
18
|
raise UsageError.new("entry '#{entry.key}': publish_to and publish_each are mutually exclusive") unless publish_to.empty?
|
|
18
19
|
raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless publish_each.is_a?(String)
|
|
19
20
|
|
|
@@ -5,6 +5,9 @@ module Textus
|
|
|
5
5
|
# constants on Entry. Canonical source is the PublishEach validator.
|
|
6
6
|
PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
|
|
7
7
|
PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
|
|
8
|
+
|
|
9
|
+
# Populated by each Entry::* subclass at load time.
|
|
10
|
+
REGISTRY = {}
|
|
8
11
|
end
|
|
9
12
|
end
|
|
10
13
|
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
# Authority over zones and roles derived from a Manifest::Data snapshot.
|
|
4
|
+
# Encapsulates the lookups previously living on Manifest itself
|
|
5
|
+
# (zone_writers, zone_kinds, permission_for, role_kind, roles_with_kind).
|
|
6
|
+
class Policy
|
|
7
|
+
def initialize(data)
|
|
8
|
+
@data = data
|
|
9
|
+
@zone_kinds_cache = {}
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def zone_writers(zone_name)
|
|
13
|
+
@data.zones[zone_name] or raise UsageError.new("undeclared zone '#{zone_name}'")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def zone_readers
|
|
17
|
+
@data.zone_readers
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def permission_for(zone_name)
|
|
21
|
+
Textus::Domain::Permission.new(
|
|
22
|
+
zone: zone_name,
|
|
23
|
+
write_policy: zone_writers(zone_name),
|
|
24
|
+
read_policy: @data.zone_readers[zone_name] || :all,
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def zone_kinds(zone_name)
|
|
29
|
+
@zone_kinds_cache[zone_name] ||= zone_writers(zone_name).each_with_object(Set.new) do |w, acc|
|
|
30
|
+
k = role_kind(w)
|
|
31
|
+
acc << k if k
|
|
32
|
+
end.freeze
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def role_mapping
|
|
36
|
+
@data.role_mapping
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def role_kind(name)
|
|
40
|
+
@data.role_mapping[name]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def roles_with_kind(kind)
|
|
44
|
+
@data.role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
class Resolver
|
|
4
|
-
Resolution = Data.define(:entry, :path, :remaining)
|
|
4
|
+
Resolution = ::Data.define(:entry, :path, :remaining)
|
|
5
5
|
|
|
6
|
-
def initialize(
|
|
7
|
-
@
|
|
6
|
+
def initialize(data)
|
|
7
|
+
@data = data
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def resolve(key)
|
|
11
|
-
@
|
|
11
|
+
@data.validate_key!(key)
|
|
12
12
|
segments = key.split(".")
|
|
13
|
-
candidates = @
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
13
|
+
candidates = @data.entries
|
|
14
|
+
.map { |e| [e, e.key.split(".")] }
|
|
15
|
+
.select { |(_, esegs)| esegs == segments[0, esegs.length] }
|
|
16
|
+
.sort_by { |(_, esegs)| -esegs.length }
|
|
17
17
|
raise UnknownKey.new(key, suggestions: suggestions_for(key)) if candidates.empty?
|
|
18
18
|
|
|
19
19
|
entry, esegs = candidates.first
|
|
@@ -23,7 +23,7 @@ module Textus
|
|
|
23
23
|
|
|
24
24
|
def suggestions_for(key)
|
|
25
25
|
candidates = enumerate.map { |r| r[:key] }
|
|
26
|
-
candidates.concat(@
|
|
26
|
+
candidates.concat(@data.entries.reject { |e| nested_entry?(e) }.map(&:key))
|
|
27
27
|
candidates.uniq!
|
|
28
28
|
Key::Distance.suggest(key, candidates, limit: 5)
|
|
29
29
|
rescue StandardError
|
|
@@ -31,7 +31,7 @@ module Textus
|
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def enumerate(prefix: nil)
|
|
34
|
-
out = @
|
|
34
|
+
out = @data.entries.flat_map { |entry| nested_entry?(entry) ? enumerate_nested(entry) : enumerate_leaf(entry) }
|
|
35
35
|
out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
|
|
36
36
|
out.sort_by { |row| row[:key] }
|
|
37
37
|
end
|
|
@@ -42,7 +42,7 @@ module Textus
|
|
|
42
42
|
# entry with nested: true in the raw YAML — e.g. Intake entries covering
|
|
43
43
|
# a directory of leaf files).
|
|
44
44
|
def nested_entry?(entry)
|
|
45
|
-
entry.
|
|
45
|
+
entry.nested?
|
|
46
46
|
end
|
|
47
47
|
|
|
48
48
|
def build_resolution(entry, remaining, key)
|
|
@@ -51,12 +51,12 @@ module Textus
|
|
|
51
51
|
else
|
|
52
52
|
raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless nested_entry?(entry)
|
|
53
53
|
|
|
54
|
-
index_fn = entry.
|
|
54
|
+
index_fn = entry.index_filename
|
|
55
55
|
path = if index_fn
|
|
56
|
-
File.join(@
|
|
56
|
+
File.join(@data.root, "zones", entry.path, *remaining, index_fn)
|
|
57
57
|
else
|
|
58
58
|
primary_ext = Textus::Entry.for_format(entry.format).extensions.first
|
|
59
|
-
File.join(@
|
|
59
|
+
File.join(@data.root, "zones", entry.path, *remaining) + primary_ext
|
|
60
60
|
end
|
|
61
61
|
Resolution.new(entry: entry, path: path, remaining: remaining)
|
|
62
62
|
end
|
|
@@ -68,17 +68,17 @@ module Textus
|
|
|
68
68
|
end
|
|
69
69
|
|
|
70
70
|
def enumerate_nested(entry)
|
|
71
|
-
base = File.join(@
|
|
71
|
+
base = File.join(@data.root, "zones", entry.path)
|
|
72
72
|
return [] unless File.directory?(base)
|
|
73
73
|
|
|
74
|
-
entry_index_filename = entry.
|
|
74
|
+
entry_index_filename = entry.index_filename
|
|
75
75
|
glob_pattern = entry_index_filename ? "**/#{entry_index_filename}" : nested_glob(entry.format)
|
|
76
76
|
Dir.glob(File.join(base, glob_pattern)).filter_map { |path| nested_row_for(entry, base, path) }
|
|
77
77
|
end
|
|
78
78
|
|
|
79
79
|
def nested_row_for(entry, base, path)
|
|
80
80
|
rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
81
|
-
entry_if = entry.
|
|
81
|
+
entry_if = entry.index_filename
|
|
82
82
|
stripped = entry_if ? File.dirname(rel) : rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
|
|
83
83
|
segs = stripped.split("/").reject { |s| s.empty? || s == "." }
|
|
84
84
|
return nil if segs.empty?
|
|
@@ -101,7 +101,7 @@ module Textus
|
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
def resolve_leaf_path(entry)
|
|
104
|
-
Textus::Key::Path.resolve(@
|
|
104
|
+
Textus::Key::Path.resolve(@data, entry)
|
|
105
105
|
end
|
|
106
106
|
|
|
107
107
|
def nested_glob(format)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
class Rules
|
|
4
|
-
RuleSet = Data.define(:refresh, :handler_allowlist, :promote, :retention)
|
|
4
|
+
RuleSet = ::Data.define(:refresh, :handler_allowlist, :promote, :retention)
|
|
5
5
|
EMPTY_SET = RuleSet.new(refresh: nil, handler_allowlist: nil, promote: nil, retention: nil)
|
|
6
6
|
|
|
7
7
|
def self.parse(raw)
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
module Schema
|
|
4
|
-
ROOT_KEYS = %w[version roles zones entries rules].freeze
|
|
4
|
+
ROOT_KEYS = %w[version roles zones entries rules audit].freeze
|
|
5
5
|
ROLE_KEYS = %w[name kind].freeze
|
|
6
6
|
ROLE_KINDS = %w[accept_authority generator proposer runner].freeze
|
|
7
7
|
ZONE_KEYS = %w[name write_policy read_policy].freeze
|
|
8
8
|
ENTRY_KEYS = %w[
|
|
9
9
|
key path zone kind schema owner nested format
|
|
10
10
|
compute template publish_to publish_each
|
|
11
|
-
intake events
|
|
11
|
+
intake events inject_boot index_filename
|
|
12
12
|
].freeze
|
|
13
13
|
COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
|
|
14
14
|
INTAKE_KEYS = %w[handler config].freeze
|
|
@@ -16,22 +16,37 @@ module Textus
|
|
|
16
16
|
REFRESH_KEYS = %w[ttl on_stale sync_budget_ms fetch_timeout_seconds].freeze
|
|
17
17
|
FETCH_TIMEOUT_SECONDS_CEILING = 3600
|
|
18
18
|
PROMOTION_KEYS = %w[requires].freeze
|
|
19
|
+
AUDIT_KEYS = %w[max_size keep].freeze
|
|
19
20
|
|
|
20
21
|
def self.validate!(raw)
|
|
21
22
|
raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
|
|
22
23
|
|
|
23
24
|
walk(raw, ROOT_KEYS, "$")
|
|
24
25
|
validate_roles!(raw["roles"])
|
|
25
|
-
|
|
26
|
+
validate_zones!(raw["zones"])
|
|
27
|
+
validate_entries!(raw["entries"])
|
|
28
|
+
validate_rules!(raw["rules"])
|
|
29
|
+
walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
|
|
30
|
+
validate_zone_writers_declared!(raw)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.validate_zones!(zones)
|
|
34
|
+
Array(zones).each_with_index do |z, i|
|
|
26
35
|
walk(z, ZONE_KEYS, "$.zones[#{i}]")
|
|
27
36
|
end
|
|
28
|
-
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.validate_entries!(entries)
|
|
40
|
+
Array(entries).each_with_index do |e, i|
|
|
29
41
|
path = "$.entries[#{i}]"
|
|
30
42
|
walk(e, ENTRY_KEYS, path)
|
|
31
43
|
walk(e["compute"], COMPUTE_KEYS, "#{path}.compute") if e["compute"].is_a?(Hash)
|
|
32
44
|
walk(e["intake"], INTAKE_KEYS, "#{path}.intake") if e["intake"].is_a?(Hash)
|
|
33
45
|
end
|
|
34
|
-
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.validate_rules!(rules)
|
|
49
|
+
Array(rules).each_with_index do |r, i|
|
|
35
50
|
path = "$.rules[#{i}]"
|
|
36
51
|
walk(r, RULE_KEYS, path)
|
|
37
52
|
if r["refresh"].is_a?(Hash)
|
|
@@ -40,7 +55,6 @@ module Textus
|
|
|
40
55
|
end
|
|
41
56
|
walk(r["promotion"], PROMOTION_KEYS, "#{path}.promotion") if r["promotion"].is_a?(Hash)
|
|
42
57
|
end
|
|
43
|
-
validate_zone_writers_declared!(raw)
|
|
44
58
|
end
|
|
45
59
|
|
|
46
60
|
def self.validate_zone_writers_declared!(raw)
|
data/lib/textus/manifest.rb
CHANGED
|
@@ -1,117 +1,69 @@
|
|
|
1
1
|
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
# Manifest is the composition record for a parsed manifest. It bundles
|
|
5
|
+
# four collaborators:
|
|
6
|
+
#
|
|
7
|
+
# * data — frozen value: raw, root, zones, entries, audit_config, role_mapping
|
|
8
|
+
# * resolver — resolves keys → entry + path
|
|
9
|
+
# * policy — zone/role authority (zone_writers, zone_kinds, permission_for, …)
|
|
10
|
+
# * rules — match-block rule engine (refresh, handler allowlist, promotion, …)
|
|
11
|
+
#
|
|
12
|
+
# Use `manifest.data.entries`, `manifest.policy.zone_kinds(z)`, etc.
|
|
13
|
+
Manifest = Data.define(:data, :resolver, :policy, :rules)
|
|
14
|
+
end
|
|
15
|
+
|
|
2
16
|
require_relative "manifest/schema"
|
|
17
|
+
require_relative "manifest/data"
|
|
18
|
+
require_relative "manifest/policy"
|
|
3
19
|
require_relative "manifest/resolver"
|
|
4
20
|
require_relative "manifest/role_kinds"
|
|
5
21
|
|
|
6
|
-
|
|
22
|
+
# Reopen Textus::Manifest (defined above as a Data.define) to attach
|
|
23
|
+
# class-level loaders and helpers.
|
|
24
|
+
module Textus # rubocop:disable Style/OneClassPerFile
|
|
7
25
|
class Manifest
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def zone_readers
|
|
15
|
-
@zone_readers ||= Array(@raw["zones"]).to_h do |z|
|
|
16
|
-
rp = z["read_policy"]
|
|
17
|
-
[z["name"], rp.nil? ? :all : Array(rp)]
|
|
26
|
+
class << self
|
|
27
|
+
def parse(yaml_text, root: ".")
|
|
28
|
+
raw = YAML.safe_load(yaml_text, aliases: false)
|
|
29
|
+
check_version!(raw, "<string>")
|
|
30
|
+
build(raw, root)
|
|
18
31
|
end
|
|
19
|
-
end
|
|
20
32
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
33
|
+
def load(root)
|
|
34
|
+
manifest_path = File.join(root, "manifest.yaml")
|
|
35
|
+
raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)
|
|
24
36
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
write_policy: zone_writers(zone_name),
|
|
29
|
-
read_policy: zone_readers[zone_name] || :all,
|
|
30
|
-
)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def role_mapping
|
|
34
|
-
@role_mapping ||= RoleKinds.resolve(@raw["roles"])
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def role_kind(name)
|
|
38
|
-
role_mapping[name]
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def roles_with_kind(kind)
|
|
42
|
-
role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def zone_kinds(zone_name)
|
|
46
|
-
@zone_kinds_cache ||= {}
|
|
47
|
-
@zone_kinds_cache[zone_name] ||= zone_writers(zone_name).each_with_object(Set.new) do |w, acc|
|
|
48
|
-
k = role_kind(w)
|
|
49
|
-
acc << k if k
|
|
50
|
-
end.freeze
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def self.parse(yaml_text, root: ".")
|
|
54
|
-
raw = YAML.safe_load(yaml_text, aliases: false)
|
|
55
|
-
check_version!(raw, "<string>")
|
|
56
|
-
new(root, raw)
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def self.load(root)
|
|
60
|
-
manifest_path = File.join(root, "manifest.yaml")
|
|
61
|
-
raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)
|
|
62
|
-
|
|
63
|
-
raw = YAML.safe_load_file(manifest_path, aliases: false)
|
|
64
|
-
check_version!(raw, manifest_path)
|
|
65
|
-
new(root, raw)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
def self.check_version!(raw, source)
|
|
69
|
-
return if raw["version"] == PROTOCOL
|
|
70
|
-
|
|
71
|
-
raise BadFrontmatter.new(
|
|
72
|
-
source,
|
|
73
|
-
"unsupported manifest version #{raw["version"].inspect}; expected #{PROTOCOL.inspect}",
|
|
74
|
-
)
|
|
75
|
-
end
|
|
76
|
-
private_class_method :check_version!
|
|
77
|
-
|
|
78
|
-
def initialize(root, raw)
|
|
79
|
-
@root = root
|
|
80
|
-
@raw = raw
|
|
81
|
-
raise BadFrontmatter.new(File.join(root, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?
|
|
82
|
-
|
|
83
|
-
Schema.validate!(raw)
|
|
84
|
-
|
|
85
|
-
@entries = Array(raw["entries"]).map do |e|
|
|
86
|
-
entry = Manifest::Entry::Parser.call(self, e)
|
|
87
|
-
Manifest::Entry::Validators.run_all(entry)
|
|
88
|
-
entry
|
|
37
|
+
raw = YAML.safe_load_file(manifest_path, aliases: false)
|
|
38
|
+
check_version!(raw, manifest_path)
|
|
39
|
+
build(raw, root)
|
|
89
40
|
end
|
|
90
|
-
validate_declared_keys!
|
|
91
|
-
end
|
|
92
41
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
end
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def build(raw, root)
|
|
45
|
+
data = Manifest::Data.parse(raw, root: root)
|
|
46
|
+
composition = new(
|
|
47
|
+
data: data,
|
|
48
|
+
resolver: Manifest::Resolver.new(data),
|
|
49
|
+
policy: data.policy,
|
|
50
|
+
rules: Manifest::Rules.parse(raw["rules"] || []),
|
|
51
|
+
)
|
|
52
|
+
# Re-point entries' back-reference from Data to the composition
|
|
53
|
+
# record. Entries call `@manifest.policy.*` / `@manifest.resolver.*`
|
|
54
|
+
# at use time (see Entry::Base, Entry::Nested).
|
|
55
|
+
data.entries.each { |e| e.instance_variable_set(:@manifest, composition) }
|
|
56
|
+
composition
|
|
57
|
+
end
|
|
110
58
|
|
|
111
|
-
|
|
59
|
+
def check_version!(raw, source)
|
|
60
|
+
return if raw["version"] == PROTOCOL
|
|
112
61
|
|
|
113
|
-
|
|
114
|
-
|
|
62
|
+
raise BadFrontmatter.new(
|
|
63
|
+
source,
|
|
64
|
+
"unsupported manifest version #{raw["version"].inspect}; expected #{PROTOCOL.inspect}",
|
|
65
|
+
)
|
|
66
|
+
end
|
|
115
67
|
end
|
|
116
68
|
end
|
|
117
69
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module MCP
|
|
3
|
+
# Manifest fingerprint changed mid-session. Client should re-boot.
|
|
4
|
+
class ContractDrift < Textus::Error
|
|
5
|
+
JSONRPC_CODE = -32_001
|
|
6
|
+
|
|
7
|
+
def initialize(message, details: {})
|
|
8
|
+
super("contract_drift", message, details: details)
|
|
9
|
+
end
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Audit cursor fell off the keep window. Client should re-boot and
|
|
13
|
+
# resume from the new latest_seq.
|
|
14
|
+
class CursorExpired < Textus::Error
|
|
15
|
+
JSONRPC_CODE = -32_002
|
|
16
|
+
|
|
17
|
+
def initialize(message, details: {})
|
|
18
|
+
super("cursor_expired", message, details: details)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Tool execution failed (validation, authorization, IO). Wraps an
|
|
23
|
+
# underlying Textus::Error or generic StandardError.
|
|
24
|
+
class ToolError < Textus::Error
|
|
25
|
+
JSONRPC_CODE = -32_000
|
|
26
|
+
|
|
27
|
+
def initialize(message, details: {})
|
|
28
|
+
super("tool_error", message, details: details)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "digest"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
module MCP
|
|
6
|
+
# Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. One line per
|
|
7
|
+
# message (NDJSON). Holds a single Session for the lifetime of stdin.
|
|
8
|
+
class Server
|
|
9
|
+
PROTOCOL_VERSION = "2024-11-05"
|
|
10
|
+
SERVER_INFO = { "name" => "textus", "version" => Textus::VERSION }.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(store:, stdin: $stdin, stdout: $stdout, role: Textus::Role::DEFAULT)
|
|
13
|
+
@store = store
|
|
14
|
+
@stdin = stdin
|
|
15
|
+
@stdout = stdout
|
|
16
|
+
@role = role
|
|
17
|
+
@session = nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def run
|
|
21
|
+
@stdin.each_line do |line|
|
|
22
|
+
line = line.strip
|
|
23
|
+
next if line.empty?
|
|
24
|
+
|
|
25
|
+
handle_line(line)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def handle_line(line)
|
|
32
|
+
msg = JSON.parse(line)
|
|
33
|
+
rescue JSON::ParserError => e
|
|
34
|
+
emit_error(nil, -32_700, "parse error: #{e.message}")
|
|
35
|
+
else
|
|
36
|
+
dispatch(msg)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def dispatch(msg)
|
|
40
|
+
rid = msg["id"]
|
|
41
|
+
case msg["method"]
|
|
42
|
+
when "initialize" then handle_initialize(rid, msg["params"] || {})
|
|
43
|
+
when "tools/list" then handle_tools_list(rid)
|
|
44
|
+
when "tools/call" then handle_tools_call(rid, msg["params"] || {})
|
|
45
|
+
when "ping" then emit_result(rid, {})
|
|
46
|
+
when "shutdown" then emit_result(rid, nil)
|
|
47
|
+
when "notifications/initialized" then nil
|
|
48
|
+
else emit_error(rid, -32_601, "method not found: #{msg["method"]}")
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def handle_initialize(rid, _params)
|
|
53
|
+
proposer = @store.manifest.policy.roles_with_kind(:proposer).first
|
|
54
|
+
propose_zone = nil
|
|
55
|
+
if proposer
|
|
56
|
+
@store.manifest.data.zones.each do |zname, writers|
|
|
57
|
+
if writers.include?(proposer) && zname.include?("review")
|
|
58
|
+
propose_zone = zname
|
|
59
|
+
break
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
@session = Session.new(
|
|
65
|
+
role: @role,
|
|
66
|
+
cursor: @store.audit_log.latest_seq,
|
|
67
|
+
propose_zone: propose_zone,
|
|
68
|
+
manifest_etag: manifest_etag,
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
emit_result(rid, {
|
|
72
|
+
"protocolVersion" => PROTOCOL_VERSION,
|
|
73
|
+
"serverInfo" => SERVER_INFO,
|
|
74
|
+
"capabilities" => { "tools" => {} },
|
|
75
|
+
})
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def handle_tools_list(rid)
|
|
79
|
+
emit_result(rid, { "tools" => ToolSchemas.all })
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def handle_tools_call(rid, params)
|
|
83
|
+
unless @session
|
|
84
|
+
emit_error(rid, -32_002, "session not initialized; call 'initialize' first")
|
|
85
|
+
return
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
@session.check_etag!(manifest_etag)
|
|
89
|
+
|
|
90
|
+
name = params["name"]
|
|
91
|
+
args = params["arguments"] || {}
|
|
92
|
+
result = Tools.call(name, session: @session, store: @store, args: args)
|
|
93
|
+
@session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "tick"
|
|
94
|
+
|
|
95
|
+
emit_result(rid, {
|
|
96
|
+
"content" => [{ "type" => "text", "text" => JSON.dump(result) }],
|
|
97
|
+
"isError" => false,
|
|
98
|
+
})
|
|
99
|
+
rescue ContractDrift => e
|
|
100
|
+
emit_error(rid, ContractDrift::JSONRPC_CODE, e.message)
|
|
101
|
+
rescue CursorExpired => e
|
|
102
|
+
emit_error(rid, CursorExpired::JSONRPC_CODE, e.message)
|
|
103
|
+
rescue ToolError => e
|
|
104
|
+
emit_error(rid, ToolError::JSONRPC_CODE, e.message)
|
|
105
|
+
rescue StandardError => e
|
|
106
|
+
emit_error(rid, -32_603, "internal: #{e.class}: #{e.message}")
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def manifest_etag
|
|
110
|
+
Digest::SHA256.hexdigest(File.read(File.join(@store.root, "manifest.yaml")))
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def emit_result(rid, result)
|
|
114
|
+
write({ "jsonrpc" => "2.0", "id" => rid, "result" => result })
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def emit_error(rid, code, message)
|
|
118
|
+
write({ "jsonrpc" => "2.0", "id" => rid, "error" => { "code" => code, "message" => message } })
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def write(obj)
|
|
122
|
+
@stdout.puts(JSON.dump(obj))
|
|
123
|
+
@stdout.flush
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module MCP
|
|
3
|
+
# Per-connection state held by the server. Immutable; advance_cursor
|
|
4
|
+
# returns a new instance.
|
|
5
|
+
class Session
|
|
6
|
+
attr_reader :role, :cursor, :propose_zone, :manifest_etag
|
|
7
|
+
|
|
8
|
+
def initialize(role:, cursor:, propose_zone:, manifest_etag:)
|
|
9
|
+
@role = role
|
|
10
|
+
@cursor = cursor
|
|
11
|
+
@propose_zone = propose_zone
|
|
12
|
+
@manifest_etag = manifest_etag
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def advance_cursor(new_cursor)
|
|
16
|
+
self.class.new(
|
|
17
|
+
role: @role, cursor: new_cursor,
|
|
18
|
+
propose_zone: @propose_zone, manifest_etag: @manifest_etag
|
|
19
|
+
)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def check_etag!(observed_etag)
|
|
23
|
+
return if observed_etag == @manifest_etag
|
|
24
|
+
|
|
25
|
+
raise ContractDrift.new(
|
|
26
|
+
"manifest changed (was #{@manifest_etag[0, 8]}, now #{observed_etag[0, 8]}); re-run boot",
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|