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
|
@@ -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
|
|
@@ -53,10 +53,10 @@ module Textus
|
|
|
53
53
|
|
|
54
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,7 +68,7 @@ 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
74
|
entry_index_filename = entry.index_filename
|
|
@@ -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)
|
data/lib/textus/manifest.rb
CHANGED
|
@@ -1,127 +1,64 @@
|
|
|
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
|
-
|
|
21
|
-
def zone_writers(zone_name)
|
|
22
|
-
zones[zone_name] or raise UsageError.new("undeclared zone '#{zone_name}'")
|
|
23
|
-
end
|
|
24
32
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
write_policy: zone_writers(zone_name),
|
|
29
|
-
read_policy: zone_readers[zone_name] || :all,
|
|
30
|
-
)
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
|
|
34
|
-
|
|
35
|
-
def audit_config
|
|
36
|
-
raw = @raw["audit"] || {}
|
|
37
|
-
{
|
|
38
|
-
max_size: raw["max_size"] || AUDIT_DEFAULTS[:max_size],
|
|
39
|
-
keep: raw["keep"] || AUDIT_DEFAULTS[:keep],
|
|
40
|
-
}
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def role_mapping
|
|
44
|
-
@role_mapping ||= RoleKinds.resolve(@raw["roles"])
|
|
45
|
-
end
|
|
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)
|
|
46
36
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def roles_with_kind(kind)
|
|
52
|
-
role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def zone_kinds(zone_name)
|
|
56
|
-
@zone_kinds_cache ||= {}
|
|
57
|
-
@zone_kinds_cache[zone_name] ||= zone_writers(zone_name).each_with_object(Set.new) do |w, acc|
|
|
58
|
-
k = role_kind(w)
|
|
59
|
-
acc << k if k
|
|
60
|
-
end.freeze
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def self.parse(yaml_text, root: ".")
|
|
64
|
-
raw = YAML.safe_load(yaml_text, aliases: false)
|
|
65
|
-
check_version!(raw, "<string>")
|
|
66
|
-
new(root, raw)
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def self.load(root)
|
|
70
|
-
manifest_path = File.join(root, "manifest.yaml")
|
|
71
|
-
raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)
|
|
72
|
-
|
|
73
|
-
raw = YAML.safe_load_file(manifest_path, aliases: false)
|
|
74
|
-
check_version!(raw, manifest_path)
|
|
75
|
-
new(root, raw)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
def self.check_version!(raw, source)
|
|
79
|
-
return if raw["version"] == PROTOCOL
|
|
80
|
-
|
|
81
|
-
raise BadFrontmatter.new(
|
|
82
|
-
source,
|
|
83
|
-
"unsupported manifest version #{raw["version"].inspect}; expected #{PROTOCOL.inspect}",
|
|
84
|
-
)
|
|
85
|
-
end
|
|
86
|
-
private_class_method :check_version!
|
|
87
|
-
|
|
88
|
-
def initialize(root, raw)
|
|
89
|
-
@root = root
|
|
90
|
-
@raw = raw
|
|
91
|
-
raise BadFrontmatter.new(File.join(root, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?
|
|
92
|
-
|
|
93
|
-
Schema.validate!(raw)
|
|
94
|
-
|
|
95
|
-
@entries = Array(raw["entries"]).map do |e|
|
|
96
|
-
entry = Manifest::Entry::Parser.call(self, e)
|
|
97
|
-
Manifest::Entry::Validators.run_all(entry)
|
|
98
|
-
entry
|
|
37
|
+
raw = YAML.safe_load_file(manifest_path, aliases: false)
|
|
38
|
+
check_version!(raw, manifest_path)
|
|
39
|
+
build(raw, root)
|
|
99
40
|
end
|
|
100
|
-
validate_declared_keys!
|
|
101
|
-
end
|
|
102
|
-
|
|
103
|
-
def rules
|
|
104
|
-
@rules ||= Textus::Manifest::Rules.parse(@raw["rules"] || [])
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
def rules_for(key)
|
|
108
|
-
rules.for(key)
|
|
109
|
-
end
|
|
110
41
|
|
|
111
|
-
|
|
112
|
-
@resolver ||= Resolver.new(self)
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def validate_key!(key)
|
|
116
|
-
raise UsageError.new("empty key") if key.nil? || key.empty?
|
|
42
|
+
private
|
|
117
43
|
|
|
118
|
-
|
|
119
|
-
|
|
44
|
+
def build(raw, root)
|
|
45
|
+
data = Manifest::Data.parse(raw, root: root)
|
|
46
|
+
new(
|
|
47
|
+
data: data,
|
|
48
|
+
resolver: Manifest::Resolver.new(data),
|
|
49
|
+
policy: data.policy,
|
|
50
|
+
rules: Manifest::Rules.parse(raw["rules"] || []),
|
|
51
|
+
)
|
|
52
|
+
end
|
|
120
53
|
|
|
121
|
-
|
|
54
|
+
def check_version!(raw, source)
|
|
55
|
+
return if raw["version"] == PROTOCOL
|
|
122
56
|
|
|
123
|
-
|
|
124
|
-
|
|
57
|
+
raise BadFrontmatter.new(
|
|
58
|
+
source,
|
|
59
|
+
"unsupported manifest version #{raw["version"].inspect}; expected #{PROTOCOL.inspect}",
|
|
60
|
+
)
|
|
61
|
+
end
|
|
125
62
|
end
|
|
126
63
|
end
|
|
127
64
|
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,126 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module MCP
|
|
5
|
+
# Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. One line per
|
|
6
|
+
# message (NDJSON). Holds a single Session for the lifetime of stdin.
|
|
7
|
+
class Server
|
|
8
|
+
PROTOCOL_VERSION = "2024-11-05"
|
|
9
|
+
SERVER_INFO = { "name" => "textus", "version" => Textus::VERSION }.freeze
|
|
10
|
+
|
|
11
|
+
def initialize(store:, stdin: $stdin, stdout: $stdout, role: Textus::Role::DEFAULT)
|
|
12
|
+
@store = store
|
|
13
|
+
@stdin = stdin
|
|
14
|
+
@stdout = stdout
|
|
15
|
+
@role = role
|
|
16
|
+
@session = nil
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def run
|
|
20
|
+
@stdin.each_line do |line|
|
|
21
|
+
line = line.strip
|
|
22
|
+
next if line.empty?
|
|
23
|
+
|
|
24
|
+
handle_line(line)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def handle_line(line)
|
|
31
|
+
msg = JSON.parse(line)
|
|
32
|
+
rescue JSON::ParserError => e
|
|
33
|
+
emit_error(nil, -32_700, "parse error: #{e.message}")
|
|
34
|
+
else
|
|
35
|
+
dispatch(msg)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def dispatch(msg)
|
|
39
|
+
rid = msg["id"]
|
|
40
|
+
case msg["method"]
|
|
41
|
+
when "initialize" then handle_initialize(rid, msg["params"] || {})
|
|
42
|
+
when "tools/list" then handle_tools_list(rid)
|
|
43
|
+
when "tools/call" then handle_tools_call(rid, msg["params"] || {})
|
|
44
|
+
when "ping" then emit_result(rid, {})
|
|
45
|
+
when "shutdown" then emit_result(rid, nil)
|
|
46
|
+
when "notifications/initialized" then nil
|
|
47
|
+
else emit_error(rid, -32_601, "method not found: #{msg["method"]}")
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def handle_initialize(rid, _params)
|
|
52
|
+
proposer = @store.manifest.policy.roles_with_kind(:proposer).first
|
|
53
|
+
propose_zone = nil
|
|
54
|
+
if proposer
|
|
55
|
+
@store.manifest.data.zones.each do |zname, writers|
|
|
56
|
+
if writers.include?(proposer) && zname.include?("review")
|
|
57
|
+
propose_zone = zname
|
|
58
|
+
break
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
@session = Session.new(
|
|
64
|
+
role: @role,
|
|
65
|
+
cursor: @store.audit_log.latest_seq,
|
|
66
|
+
propose_zone: propose_zone,
|
|
67
|
+
manifest_etag: manifest_etag,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
emit_result(rid, {
|
|
71
|
+
"protocolVersion" => PROTOCOL_VERSION,
|
|
72
|
+
"serverInfo" => SERVER_INFO,
|
|
73
|
+
"capabilities" => { "tools" => {} },
|
|
74
|
+
})
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def handle_tools_list(rid)
|
|
78
|
+
emit_result(rid, { "tools" => ToolSchemas.all })
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def handle_tools_call(rid, params)
|
|
82
|
+
unless @session
|
|
83
|
+
emit_error(rid, -32_002, "session not initialized; call 'initialize' first")
|
|
84
|
+
return
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
@session.check_etag!(manifest_etag)
|
|
88
|
+
|
|
89
|
+
name = params["name"]
|
|
90
|
+
args = params["arguments"] || {}
|
|
91
|
+
result = Tools.call(name, session: @session, store: @store, args: args)
|
|
92
|
+
@session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "tick"
|
|
93
|
+
|
|
94
|
+
emit_result(rid, {
|
|
95
|
+
"content" => [{ "type" => "text", "text" => JSON.dump(result) }],
|
|
96
|
+
"isError" => false,
|
|
97
|
+
})
|
|
98
|
+
rescue ContractDrift => e
|
|
99
|
+
emit_error(rid, ContractDrift::JSONRPC_CODE, e.message)
|
|
100
|
+
rescue CursorExpired => e
|
|
101
|
+
emit_error(rid, CursorExpired::JSONRPC_CODE, e.message)
|
|
102
|
+
rescue ToolError => e
|
|
103
|
+
emit_error(rid, ToolError::JSONRPC_CODE, e.message)
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
emit_error(rid, -32_603, "internal: #{e.class}: #{e.message}")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def manifest_etag
|
|
109
|
+
@store.file_store.etag(File.join(@store.root, "manifest.yaml"))
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def emit_result(rid, result)
|
|
113
|
+
write({ "jsonrpc" => "2.0", "id" => rid, "result" => result })
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def emit_error(rid, code, message)
|
|
117
|
+
write({ "jsonrpc" => "2.0", "id" => rid, "error" => { "code" => code, "message" => message } })
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def write(obj)
|
|
121
|
+
@stdout.puts(JSON.dump(obj))
|
|
122
|
+
@stdout.flush
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
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 #{short_etag(@manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# First 8 hex chars after the "sha256:" prefix — a stable short id for
|
|
33
|
+
# the drift diagnostic. Tolerates non-prefixed values (delete_prefix is
|
|
34
|
+
# a no-op when the prefix is absent).
|
|
35
|
+
def short_etag(etag)
|
|
36
|
+
etag.to_s.delete_prefix("sha256:")[0, 8]
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module MCP
|
|
3
|
+
# JSON-Schema definitions for every MCP tool's inputSchema. Returned by
|
|
4
|
+
# the server in tools/list. Static today — a follow-up will enrich with
|
|
5
|
+
# manifest-derived enums for `zone`, `key`, etc.
|
|
6
|
+
module ToolSchemas
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def all # rubocop:disable Metrics/MethodLength
|
|
10
|
+
[
|
|
11
|
+
tool("boot", "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart.", {}, []),
|
|
12
|
+
tool("tick", "Delta since cursor. Returns {cursor, changed, stale, pending_review, doctor}.",
|
|
13
|
+
{ "since" => { "type" => "integer", "minimum" => 0 } }, []),
|
|
14
|
+
tool("find", "List keys filtered by zone and/or prefix.",
|
|
15
|
+
{ "zone" => { "type" => "string" }, "prefix" => { "type" => "string" } }, []),
|
|
16
|
+
tool("read", "Read one entry. Returns the envelope (uid, etag, _meta, body, freshness).",
|
|
17
|
+
{ "key" => { "type" => "string" } }, ["key"]),
|
|
18
|
+
tool("write", "Create or update an entry. Schema-validated. Returns {uid, etag}.",
|
|
19
|
+
{
|
|
20
|
+
"key" => { "type" => "string" },
|
|
21
|
+
"meta" => { "type" => "object" },
|
|
22
|
+
"body" => { "type" => "string" },
|
|
23
|
+
"content" => { "type" => "object" },
|
|
24
|
+
"if_etag" => { "type" => "string" },
|
|
25
|
+
}, %w[key meta]),
|
|
26
|
+
tool("propose", "Write a proposal to the session's propose_zone. Auto-prefixes the key.",
|
|
27
|
+
{
|
|
28
|
+
"key" => { "type" => "string", "description" => "Key relative to propose_zone, e.g. 'proposal.feature-x'" },
|
|
29
|
+
"meta" => { "type" => "object" },
|
|
30
|
+
"body" => { "type" => "string" },
|
|
31
|
+
}, %w[key meta]),
|
|
32
|
+
tool("refresh", "Run an intake refresh for one key. Returns the refresh Outcome.",
|
|
33
|
+
{ "key" => { "type" => "string" } }, ["key"]),
|
|
34
|
+
tool("refresh_stale", "Refresh all stale intake entries, optionally scoped by zone/prefix.",
|
|
35
|
+
{
|
|
36
|
+
"zone" => { "type" => "string" },
|
|
37
|
+
"prefix" => { "type" => "string" },
|
|
38
|
+
}, []),
|
|
39
|
+
tool("schema", "Return the schema (field shape) for an entry family.",
|
|
40
|
+
{ "family" => { "type" => "string" } }, ["family"]),
|
|
41
|
+
tool("rules", "Return effective rules for a key (refresh, promote, ...).",
|
|
42
|
+
{ "key" => { "type" => "string" } }, ["key"]),
|
|
43
|
+
tool("key_mv_prefix",
|
|
44
|
+
"Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false.",
|
|
45
|
+
{ "from_prefix" => { "type" => "string" }, "to_prefix" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
|
|
46
|
+
%w[from_prefix to_prefix]),
|
|
47
|
+
tool("key_delete_prefix", "Bulk-delete every leaf key under prefix.",
|
|
48
|
+
{ "prefix" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
|
|
49
|
+
["prefix"]),
|
|
50
|
+
tool("zone_mv", "Rename a zone — manifest + files. Refuses if destination exists.",
|
|
51
|
+
{ "from" => { "type" => "string" }, "to" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
|
|
52
|
+
%w[from to]),
|
|
53
|
+
tool("rule_lint", "Diff candidate manifest YAML's rules against the live manifest. No writes.",
|
|
54
|
+
{ "candidate_yaml" => { "type" => "string" } },
|
|
55
|
+
["candidate_yaml"]),
|
|
56
|
+
tool("migrate", "Run a YAML migration plan (multi-op).",
|
|
57
|
+
{ "plan_yaml" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
|
|
58
|
+
["plan_yaml"]),
|
|
59
|
+
].freeze
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def tool(name, description, properties, required)
|
|
63
|
+
{
|
|
64
|
+
name: name,
|
|
65
|
+
description: description,
|
|
66
|
+
inputSchema: { type: "object", properties: properties, required: required },
|
|
67
|
+
}
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module MCP
|
|
3
|
+
# Dispatch table for MCP tool names → implementations. Each implementation
|
|
4
|
+
# receives (session:, store:, args:) and returns a JSON-encodable value.
|
|
5
|
+
# Tool errors are wrapped in ToolError; ContractDrift / CursorExpired
|
|
6
|
+
# propagate verbatim so the server can map them to JSON-RPC codes.
|
|
7
|
+
module Tools
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def call(name, session:, store:, args:)
|
|
11
|
+
impl = REGISTRY[name] or raise ToolError.new("unknown tool: #{name}")
|
|
12
|
+
impl.call(session, store, args || {})
|
|
13
|
+
rescue ContractDrift, CursorExpired
|
|
14
|
+
raise
|
|
15
|
+
rescue Textus::Error => e
|
|
16
|
+
raise ToolError.new("#{name}: #{e.message}")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ops_for(session, store)
|
|
20
|
+
store.as(session.role)
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
REGISTRY = {
|
|
24
|
+
"boot" => ->(_s, store, _a) { store.boot },
|
|
25
|
+
|
|
26
|
+
"find" => lambda do |s, store, args|
|
|
27
|
+
ops_for(s, store).list(zone: args["zone"], prefix: args["prefix"])
|
|
28
|
+
end,
|
|
29
|
+
|
|
30
|
+
"read" => lambda do |s, store, args|
|
|
31
|
+
key = args.fetch("key") { raise ToolError.new("read: missing key") }
|
|
32
|
+
env = ops_for(s, store).get(key)
|
|
33
|
+
env.to_h_for_wire
|
|
34
|
+
end,
|
|
35
|
+
|
|
36
|
+
"tick" => lambda do |s, store, args|
|
|
37
|
+
since = (args["since"] || s.cursor).to_i
|
|
38
|
+
ops_for(s, store).pulse(since: since)
|
|
39
|
+
end,
|
|
40
|
+
|
|
41
|
+
"write" => lambda do |s, store, args|
|
|
42
|
+
key = args.fetch("key") { raise ToolError.new("write: missing key") }
|
|
43
|
+
env = ops_for(s, store).put(
|
|
44
|
+
key,
|
|
45
|
+
meta: args["meta"] || {},
|
|
46
|
+
body: args["body"],
|
|
47
|
+
content: args["content"],
|
|
48
|
+
if_etag: args["if_etag"],
|
|
49
|
+
)
|
|
50
|
+
{ "uid" => env.uid, "etag" => env.etag }
|
|
51
|
+
end,
|
|
52
|
+
|
|
53
|
+
"propose" => lambda do |s, store, args|
|
|
54
|
+
raise ToolError.new("propose: session has no propose_zone") unless s.propose_zone
|
|
55
|
+
|
|
56
|
+
rel = args.fetch("key") { raise ToolError.new("propose: missing key") }
|
|
57
|
+
target = "#{s.propose_zone}.#{rel}"
|
|
58
|
+
env = ops_for(s, store).put(
|
|
59
|
+
target,
|
|
60
|
+
meta: args["meta"] || {},
|
|
61
|
+
body: args["body"],
|
|
62
|
+
content: args["content"],
|
|
63
|
+
)
|
|
64
|
+
{ "uid" => env.uid, "etag" => env.etag, "key" => target }
|
|
65
|
+
end,
|
|
66
|
+
|
|
67
|
+
"refresh" => lambda do |s, store, args|
|
|
68
|
+
key = args.fetch("key") { raise ToolError.new("refresh: missing key") }
|
|
69
|
+
outcome = ops_for(s, store).refresh(key)
|
|
70
|
+
{ "outcome" => outcome.class.name.split("::").last.downcase }
|
|
71
|
+
end,
|
|
72
|
+
|
|
73
|
+
"refresh_stale" => lambda do |s, store, args|
|
|
74
|
+
ops_for(s, store).refresh_all(zone: args["zone"], prefix: args["prefix"])
|
|
75
|
+
end,
|
|
76
|
+
|
|
77
|
+
"schema" => lambda do |_s, store, args|
|
|
78
|
+
family = args.fetch("family") { raise ToolError.new("schema: missing family") }
|
|
79
|
+
store.schemas.fetch(family)
|
|
80
|
+
end,
|
|
81
|
+
|
|
82
|
+
"rules" => lambda do |_s, store, args|
|
|
83
|
+
key = args.fetch("key") { raise ToolError.new("rules: missing key") }
|
|
84
|
+
set = store.manifest.rules.for(key)
|
|
85
|
+
{
|
|
86
|
+
"refresh" => set.refresh&.to_h,
|
|
87
|
+
"promote" => set.respond_to?(:promote) ? set.promote&.to_h : nil,
|
|
88
|
+
}.compact
|
|
89
|
+
end,
|
|
90
|
+
|
|
91
|
+
"key_mv_prefix" => lambda do |s, store, args|
|
|
92
|
+
ops_for(s, store).key_mv_prefix(
|
|
93
|
+
from_prefix: args.fetch("from_prefix") { raise ToolError.new("key_mv_prefix: missing from_prefix") },
|
|
94
|
+
to_prefix: args.fetch("to_prefix") { raise ToolError.new("key_mv_prefix: missing to_prefix") },
|
|
95
|
+
dry_run: args["dry_run"] || false,
|
|
96
|
+
).to_h
|
|
97
|
+
end,
|
|
98
|
+
|
|
99
|
+
"key_delete_prefix" => lambda do |s, store, args|
|
|
100
|
+
ops_for(s, store).key_delete_prefix(
|
|
101
|
+
prefix: args.fetch("prefix") { raise ToolError.new("key_delete_prefix: missing prefix") },
|
|
102
|
+
dry_run: args["dry_run"] || false,
|
|
103
|
+
).to_h
|
|
104
|
+
end,
|
|
105
|
+
|
|
106
|
+
"zone_mv" => lambda do |s, store, args|
|
|
107
|
+
ops_for(s, store).zone_mv(
|
|
108
|
+
from: args.fetch("from") { raise ToolError.new("zone_mv: missing from") },
|
|
109
|
+
to: args.fetch("to") { raise ToolError.new("zone_mv: missing to") },
|
|
110
|
+
dry_run: args["dry_run"] || false,
|
|
111
|
+
).to_h
|
|
112
|
+
end,
|
|
113
|
+
|
|
114
|
+
"rule_lint" => lambda do |s, store, args|
|
|
115
|
+
ops_for(s, store).rule_lint(
|
|
116
|
+
candidate_yaml: args.fetch("candidate_yaml") { raise ToolError.new("rule_lint: missing candidate_yaml") },
|
|
117
|
+
).to_h
|
|
118
|
+
end,
|
|
119
|
+
|
|
120
|
+
"migrate" => lambda do |s, store, args|
|
|
121
|
+
ops_for(s, store).migrate(
|
|
122
|
+
plan_yaml: args.fetch("plan_yaml") { raise ToolError.new("migrate: missing plan_yaml") },
|
|
123
|
+
dry_run: args["dry_run"] || false,
|
|
124
|
+
).to_h
|
|
125
|
+
end,
|
|
126
|
+
}.freeze
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
data/lib/textus/mcp.rb
ADDED