textus 0.22.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +148 -45
- data/CHANGELOG.md +102 -0
- data/README.md +1 -1
- data/SPEC.md +12 -12
- data/docs/conventions.md +10 -0
- data/lib/textus/application/caps.rb +49 -0
- data/lib/textus/application/context.rb +2 -2
- data/lib/textus/application/envelope/reader.rb +44 -0
- data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
- data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
- data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
- data/lib/textus/application/maintenance/migrate.rb +59 -0
- data/lib/textus/application/maintenance/rule_lint.rb +65 -0
- data/lib/textus/application/maintenance/zone_mv.rb +60 -0
- data/lib/textus/application/maintenance.rb +17 -0
- data/lib/textus/application/projection.rb +12 -10
- data/lib/textus/application/read/audit.rb +106 -0
- data/lib/textus/application/read/blame.rb +91 -0
- data/lib/textus/application/read/deps.rb +34 -0
- data/lib/textus/application/read/freshness.rb +110 -0
- data/lib/textus/application/read/get.rb +75 -0
- data/lib/textus/application/read/get_or_refresh.rb +63 -0
- data/lib/textus/application/read/list.rb +25 -0
- data/lib/textus/application/read/policy_explain.rb +47 -0
- data/lib/textus/application/read/published.rb +25 -0
- data/lib/textus/application/read/pulse.rb +101 -0
- data/lib/textus/application/read/rdeps.rb +35 -0
- data/lib/textus/application/read/schema_envelope.rb +26 -0
- data/lib/textus/application/read/stale.rb +23 -0
- data/lib/textus/application/read/uid.rb +30 -0
- data/lib/textus/application/read/validate_all.rb +32 -0
- data/lib/textus/application/{reads → read}/validator.rb +2 -2
- data/lib/textus/application/read/where.rb +26 -0
- data/lib/textus/application/use_case.rb +22 -0
- data/lib/textus/application/write/accept.rb +102 -0
- data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
- data/lib/textus/application/write/delete.rb +45 -0
- data/lib/textus/application/{writes → write}/materializer.rb +14 -15
- data/lib/textus/application/write/mv.rb +118 -0
- data/lib/textus/application/write/publish.rb +96 -0
- data/lib/textus/application/write/put.rb +49 -0
- data/lib/textus/application/write/refresh_all.rb +63 -0
- data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
- data/lib/textus/application/write/refresh_worker.rb +134 -0
- data/lib/textus/application/write/reject.rb +62 -0
- data/lib/textus/boot.rb +27 -29
- data/lib/textus/builder/pipeline.rb +3 -3
- data/lib/textus/cli/group/mcp.rb +9 -0
- data/lib/textus/cli/group/zone.rb +9 -0
- data/lib/textus/cli/verb/accept.rb +1 -1
- data/lib/textus/cli/verb/audit.rb +2 -2
- data/lib/textus/cli/verb/blame.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +1 -1
- data/lib/textus/cli/verb/build.rb +2 -2
- data/lib/textus/cli/verb/delete.rb +1 -1
- data/lib/textus/cli/verb/deps.rb +1 -1
- data/lib/textus/cli/verb/doctor.rb +1 -1
- data/lib/textus/cli/verb/freshness.rb +1 -1
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -4
- data/lib/textus/cli/verb/hooks.rb +11 -14
- data/lib/textus/cli/verb/key_delete.rb +24 -0
- data/lib/textus/cli/verb/list.rb +1 -1
- data/lib/textus/cli/verb/mcp_serve.rb +17 -0
- data/lib/textus/cli/verb/migrate.rb +18 -0
- data/lib/textus/cli/verb/mv.rb +11 -3
- data/lib/textus/cli/verb/published.rb +1 -1
- data/lib/textus/cli/verb/pulse.rb +1 -1
- data/lib/textus/cli/verb/put.rb +8 -6
- data/lib/textus/cli/verb/rdeps.rb +1 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +1 -1
- data/lib/textus/cli/verb/reject.rb +1 -1
- data/lib/textus/cli/verb/rule_explain.rb +1 -1
- data/lib/textus/cli/verb/rule_lint.rb +18 -0
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb/uid.rb +1 -1
- data/lib/textus/cli/verb/where.rb +1 -1
- data/lib/textus/cli/verb/zone_mv.rb +19 -0
- data/lib/textus/cli/verb.rb +4 -4
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
- data/lib/textus/doctor/check/hooks.rb +4 -3
- data/lib/textus/doctor/check/illegal_keys.rb +2 -2
- data/lib/textus/doctor/check/intake_registration.rb +2 -2
- data/lib/textus/doctor/check/manifest_files.rb +2 -2
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/refresh_locks.rb +2 -2
- data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/schemas.rb +2 -2
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +2 -2
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +5 -3
- data/lib/textus/doctor.rb +24 -27
- data/lib/textus/domain/authorizer.rb +4 -4
- data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
- data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
- data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
- data/lib/textus/domain/staleness/generator_check.rb +2 -2
- data/lib/textus/domain/staleness/intake_check.rb +2 -2
- data/lib/textus/domain/staleness.rb +1 -1
- data/lib/textus/hooks/builtin.rb +14 -14
- data/lib/textus/hooks/context.rb +13 -13
- data/lib/textus/hooks/error_log.rb +32 -0
- data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
- data/lib/textus/hooks/loader.rb +29 -3
- data/lib/textus/hooks/rpc_registry.rb +77 -0
- data/lib/textus/infra/audit_subscriber.rb +6 -7
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/key/path.rb +7 -3
- data/lib/textus/manifest/data.rb +78 -0
- data/lib/textus/manifest/entry/base.rb +4 -4
- data/lib/textus/manifest/entry/derived.rb +4 -5
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/manifest/policy.rb +48 -0
- data/lib/textus/manifest/resolver.rb +14 -14
- data/lib/textus/manifest/rules.rb +1 -1
- data/lib/textus/manifest.rb +53 -111
- data/lib/textus/mcp/errors.rb +32 -0
- data/lib/textus/mcp/server.rb +127 -0
- data/lib/textus/mcp/session.rb +31 -0
- data/lib/textus/mcp/tool_schemas.rb +71 -0
- data/lib/textus/mcp/tools.rb +129 -0
- data/lib/textus/mcp.rb +6 -0
- data/lib/textus/schema/tools.rb +14 -10
- data/lib/textus/session.rb +84 -0
- data/lib/textus/store.rb +14 -9
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +8 -1
- metadata +61 -36
- data/lib/textus/application/reads/audit.rb +0 -94
- data/lib/textus/application/reads/blame.rb +0 -82
- data/lib/textus/application/reads/deps.rb +0 -26
- data/lib/textus/application/reads/freshness.rb +0 -88
- data/lib/textus/application/reads/get.rb +0 -67
- data/lib/textus/application/reads/get_or_refresh.rb +0 -51
- data/lib/textus/application/reads/list.rb +0 -17
- data/lib/textus/application/reads/policy_explain.rb +0 -39
- data/lib/textus/application/reads/published.rb +0 -17
- data/lib/textus/application/reads/pulse.rb +0 -63
- data/lib/textus/application/reads/rdeps.rb +0 -27
- data/lib/textus/application/reads/schema_envelope.rb +0 -18
- data/lib/textus/application/reads/stale.rb +0 -15
- data/lib/textus/application/reads/uid.rb +0 -23
- data/lib/textus/application/reads/validate_all.rb +0 -24
- data/lib/textus/application/reads/where.rb +0 -18
- data/lib/textus/application/refresh/all.rb +0 -52
- data/lib/textus/application/refresh/worker.rb +0 -116
- data/lib/textus/application/writes/accept.rb +0 -89
- data/lib/textus/application/writes/delete.rb +0 -33
- data/lib/textus/application/writes/mv.rb +0 -105
- data/lib/textus/application/writes/publish.rb +0 -81
- data/lib/textus/application/writes/put.rb +0 -37
- data/lib/textus/application/writes/reject.rb +0 -50
- data/lib/textus/infra/event_bus.rb +0 -27
- data/lib/textus/operations.rb +0 -176
|
@@ -0,0 +1,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
|
|
@@ -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,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
|
-
|
|
21
|
-
def zone_writers(zone_name)
|
|
22
|
-
zones[zone_name] or raise UsageError.new("undeclared zone '#{zone_name}'")
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def permission_for(zone_name)
|
|
26
|
-
Textus::Domain::Permission.new(
|
|
27
|
-
zone: zone_name,
|
|
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
|
|
46
|
-
|
|
47
|
-
def role_kind(name)
|
|
48
|
-
role_mapping[name]
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def roles_with_kind(kind)
|
|
52
|
-
role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
|
|
53
|
-
end
|
|
54
32
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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)
|
|
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)
|
|
94
36
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
41
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
120
58
|
|
|
121
|
-
|
|
59
|
+
def check_version!(raw, source)
|
|
60
|
+
return if raw["version"] == PROTOCOL
|
|
122
61
|
|
|
123
|
-
|
|
124
|
-
|
|
62
|
+
raise BadFrontmatter.new(
|
|
63
|
+
source,
|
|
64
|
+
"unsupported manifest version #{raw["version"].inspect}; expected #{PROTOCOL.inspect}",
|
|
65
|
+
)
|
|
66
|
+
end
|
|
125
67
|
end
|
|
126
68
|
end
|
|
127
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
|
|
@@ -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
|