textus 0.35.1 → 0.39.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/CHANGELOG.md +135 -2
- data/README.md +34 -13
- data/SPEC.md +10 -4
- data/lib/textus/boot.rb +41 -21
- data/lib/textus/cli/verb/mcp_serve.rb +8 -3
- data/lib/textus/cli/verb/propose.rb +28 -0
- data/lib/textus/cli/verb/pulse.rb +12 -3
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb.rb +3 -2
- data/lib/textus/contract.rb +106 -0
- data/lib/textus/cursor_store.rb +24 -0
- data/lib/textus/dispatcher.rb +3 -1
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/fetch_locks.rb +2 -2
- data/lib/textus/doctor/check/illegal_keys.rb +10 -4
- data/lib/textus/domain/policy/evaluation.rb +3 -6
- data/lib/textus/init.rb +4 -0
- data/lib/textus/layout.rb +41 -0
- data/lib/textus/maintenance/key_delete_prefix.rb +9 -0
- data/lib/textus/maintenance/key_mv_prefix.rb +10 -0
- data/lib/textus/maintenance/migrate.rb +9 -0
- data/lib/textus/maintenance/rule_lint.rb +8 -0
- data/lib/textus/maintenance/zone_mv.rb +10 -0
- data/lib/textus/manifest/entry/base.rb +5 -0
- data/lib/textus/manifest/entry/ignore_matcher.rb +46 -0
- data/lib/textus/manifest/entry/nested.rb +9 -2
- data/lib/textus/manifest/entry/validators/ignore.rb +28 -0
- data/lib/textus/manifest/entry/validators.rb +1 -0
- data/lib/textus/manifest/resolver.rb +2 -0
- data/lib/textus/manifest/schema.rb +1 -1
- data/lib/textus/mcp/catalog.rb +72 -0
- data/lib/textus/mcp/server.rb +8 -5
- data/lib/textus/mcp/session.rb +3 -20
- data/lib/textus/mcp/tool_schemas.rb +6 -62
- data/lib/textus/mcp/tools.rb +4 -119
- data/lib/textus/ports/audit_log.rb +17 -15
- data/lib/textus/ports/build_lock.rb +1 -2
- data/lib/textus/ports/fetch/lock.rb +1 -1
- data/lib/textus/read/audit.rb +3 -3
- data/lib/textus/read/boot.rb +6 -0
- data/lib/textus/read/get.rb +8 -0
- data/lib/textus/read/list.rb +8 -0
- data/lib/textus/read/pulse.rb +7 -0
- data/lib/textus/read/rules.rb +24 -0
- data/lib/textus/read/schema_envelope.rb +7 -0
- data/lib/textus/role.rb +6 -2
- data/lib/textus/session.rb +24 -0
- data/lib/textus/store.rb +11 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +1 -1
- data/lib/textus/write/delete.rb +1 -1
- data/lib/textus/write/fetch_all.rb +8 -0
- data/lib/textus/write/fetch_worker.rb +9 -1
- data/lib/textus/write/mv.rb +1 -1
- data/lib/textus/write/propose.rb +46 -0
- data/lib/textus/write/put.rb +13 -1
- data/lib/textus/write/reject.rb +1 -1
- data/lib/textus.rb +4 -0
- metadata +15 -5
- data/docs/conventions.md +0 -148
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
# Per-role cursor cache under <root>/.run/state/cursor.<role>. A convenience so
|
|
5
|
+
# `textus pulse` (no --since) means "since I last looked". Gitignored;
|
|
6
|
+
# losing it just re-emits recent deltas, never corrupts the store. ADR 0036/0038.
|
|
7
|
+
class CursorStore
|
|
8
|
+
def initialize(root:, role:)
|
|
9
|
+
@path = Textus::Layout.cursor(root, role)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def read
|
|
13
|
+
Integer(File.read(@path).strip)
|
|
14
|
+
rescue Errno::ENOENT, ArgumentError
|
|
15
|
+
0
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def write(seq)
|
|
19
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
20
|
+
File.write(@path, seq.to_s)
|
|
21
|
+
seq
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/textus/dispatcher.rb
CHANGED
|
@@ -6,6 +6,7 @@ module Textus
|
|
|
6
6
|
VERBS = {
|
|
7
7
|
# Write
|
|
8
8
|
put: Textus::Write::Put,
|
|
9
|
+
propose: Textus::Write::Propose,
|
|
9
10
|
delete: Textus::Write::Delete,
|
|
10
11
|
mv: Textus::Write::Mv,
|
|
11
12
|
accept: Textus::Write::Accept,
|
|
@@ -30,11 +31,12 @@ module Textus
|
|
|
30
31
|
pulse: Textus::Read::Pulse,
|
|
31
32
|
policy_explain: Textus::Read::PolicyExplain,
|
|
32
33
|
published: Textus::Read::Published,
|
|
33
|
-
|
|
34
|
+
schema: Textus::Read::SchemaEnvelope,
|
|
34
35
|
validate_all: Textus::Read::ValidateAll,
|
|
35
36
|
doctor: Textus::Read::Doctor,
|
|
36
37
|
boot: Textus::Read::Boot,
|
|
37
38
|
retainable: Textus::Read::Retainable,
|
|
39
|
+
rules: Textus::Read::Rules,
|
|
38
40
|
|
|
39
41
|
# Maintenance
|
|
40
42
|
migrate: Textus::Maintenance::Migrate,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Doctor
|
|
3
3
|
class Check
|
|
4
|
-
# Lists per-key fetch lock files under <root>/.locks/ whose
|
|
4
|
+
# Lists per-key fetch lock files under <root>/.run/locks/ whose
|
|
5
5
|
# recorded PID is no longer running. These are forensic artifacts only:
|
|
6
6
|
# Fetch::Lock uses flock(2), which the kernel releases on process
|
|
7
7
|
# death, so stale files do not block subsequent acquires. The check
|
|
@@ -9,7 +9,7 @@ module Textus
|
|
|
9
9
|
# (e.g. a fetch path that crashes repeatedly).
|
|
10
10
|
class FetchLocks < Check
|
|
11
11
|
def call
|
|
12
|
-
dir =
|
|
12
|
+
dir = Textus::Layout.locks(root)
|
|
13
13
|
return [] unless File.directory?(dir)
|
|
14
14
|
|
|
15
15
|
Dir.glob(File.join(dir, "*.lock")).filter_map { |path| inspect_lock(path) }
|
|
@@ -11,15 +11,18 @@ module Textus
|
|
|
11
11
|
next unless File.directory?(base)
|
|
12
12
|
|
|
13
13
|
index_fn = entry.respond_to?(:index_filename) ? entry.index_filename : nil
|
|
14
|
-
index_fn ? check_index_paths(entry, index_fn, base, out) : check_all_paths(base, out)
|
|
14
|
+
index_fn ? check_index_paths(entry, index_fn, base, out) : check_all_paths(entry, base, out)
|
|
15
15
|
end
|
|
16
16
|
out
|
|
17
17
|
end
|
|
18
18
|
|
|
19
19
|
private
|
|
20
20
|
|
|
21
|
-
def check_all_paths(base, out)
|
|
21
|
+
def check_all_paths(entry, base, out)
|
|
22
22
|
walk_nested(base) do |abs_path, is_dir|
|
|
23
|
+
rel = abs_path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
24
|
+
next if entry.ignored?(rel)
|
|
25
|
+
|
|
23
26
|
basename = File.basename(abs_path)
|
|
24
27
|
stem = is_dir ? basename : basename.sub(/#{Regexp.escape(File.extname(basename))}\z/, "")
|
|
25
28
|
next if stem.match?(Key::Grammar::SEGMENT)
|
|
@@ -31,10 +34,13 @@ module Textus
|
|
|
31
34
|
# When the entry uses `index_filename:`, only the parent-directory
|
|
32
35
|
# segments leading to each index file participate in keys. Sibling
|
|
33
36
|
# files and unrelated subtrees are not enumerated and must not be
|
|
34
|
-
# flagged. Each illegal segment is reported once per path.
|
|
35
|
-
|
|
37
|
+
# flagged. Each illegal segment is reported once per path. Paths under
|
|
38
|
+
# an ignored subtree (ADR 0042) are excluded before any segment check.
|
|
39
|
+
def check_index_paths(entry, index_fn, base, out)
|
|
36
40
|
Dir.glob(File.join(base, "**", index_fn)).each do |fp|
|
|
37
41
|
rel = fp.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
42
|
+
next if entry.ignored?(rel)
|
|
43
|
+
|
|
38
44
|
File.dirname(rel).split("/").reject { |s| s.empty? || s == "." }.each do |seg|
|
|
39
45
|
next if seg.match?(Key::Grammar::SEGMENT)
|
|
40
46
|
|
|
@@ -3,16 +3,13 @@
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Domain
|
|
5
5
|
module Policy
|
|
6
|
-
# Immutable context handed to every predicate. `
|
|
6
|
+
# Immutable context handed to every predicate. `manifest` is the
|
|
7
7
|
# manifest (pure, no I/O); `envelope` is the entry under evaluation
|
|
8
8
|
# (nil when no bytes exist yet, e.g. a fresh put). `origin`/`target`
|
|
9
9
|
# are dotted keys; `transition` is the verb symbol.
|
|
10
10
|
Evaluation = Data.define(
|
|
11
|
-
:actor, :transition, :origin, :target, :envelope, :
|
|
12
|
-
)
|
|
13
|
-
def manifest = snapshot
|
|
14
|
-
def role = actor
|
|
15
|
-
end
|
|
11
|
+
:actor, :transition, :origin, :target, :envelope, :manifest
|
|
12
|
+
)
|
|
16
13
|
end
|
|
17
14
|
end
|
|
18
15
|
end
|
data/lib/textus/init.rb
CHANGED
|
@@ -92,6 +92,10 @@ module Textus
|
|
|
92
92
|
end
|
|
93
93
|
File.write(File.join(target_root, "hooks", "README.md"), HOOKS_README)
|
|
94
94
|
File.write(File.join(target_root, "manifest.yaml"), DEFAULT_MANIFEST)
|
|
95
|
+
FileUtils.mkdir_p(Textus::Layout.audit_dir(target_root))
|
|
96
|
+
FileUtils.mkdir_p(Textus::Layout.state(target_root))
|
|
97
|
+
FileUtils.mkdir_p(Textus::Layout.locks(target_root))
|
|
98
|
+
File.write(File.join(target_root, ".gitignore"), Textus::Layout::GITIGNORE)
|
|
95
99
|
{ "protocol" => PROTOCOL, "initialized" => target_root }
|
|
96
100
|
end
|
|
97
101
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
# Single source of truth for every path textus owns under a store root.
|
|
3
|
+
# All disposable runtime state nests under <root>/.run/ so the
|
|
4
|
+
# tracked/disposable boundary is a directory boundary. ADR 0038.
|
|
5
|
+
module Layout
|
|
6
|
+
RUN = ".run"
|
|
7
|
+
|
|
8
|
+
def self.run(root)
|
|
9
|
+
File.join(root, RUN)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.state(root)
|
|
13
|
+
File.join(run(root), "state")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.cursor(root, role)
|
|
17
|
+
File.join(state(root), "cursor.#{role}")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.locks(root)
|
|
21
|
+
File.join(run(root), "locks")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.build_lock(root)
|
|
25
|
+
File.join(run(root), "build.lock")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.audit_dir(root)
|
|
29
|
+
File.join(run(root), "audit")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.audit_log(root)
|
|
33
|
+
File.join(audit_dir(root), "audit.log")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
GITIGNORE = <<~GITIGNORE
|
|
37
|
+
# textus runtime artifacts — safe to delete, never commit
|
|
38
|
+
#{RUN}/
|
|
39
|
+
GITIGNORE
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -2,6 +2,15 @@ module Textus
|
|
|
2
2
|
module Maintenance
|
|
3
3
|
# Bulk-delete every leaf key under `prefix`.
|
|
4
4
|
class KeyDeletePrefix
|
|
5
|
+
extend Textus::Contract::DSL
|
|
6
|
+
|
|
7
|
+
verb :key_delete_prefix
|
|
8
|
+
summary "Bulk-delete every leaf key under prefix."
|
|
9
|
+
surfaces :cli, :ruby, :mcp
|
|
10
|
+
arg :prefix, String, required: true
|
|
11
|
+
arg :dry_run, :boolean
|
|
12
|
+
response(&:to_h)
|
|
13
|
+
|
|
5
14
|
def initialize(container:, call:)
|
|
6
15
|
@container = container
|
|
7
16
|
@call = call
|
|
@@ -3,6 +3,16 @@ module Textus
|
|
|
3
3
|
# Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
|
|
4
4
|
# Calls Write::Mv directly for each entry — emits one audit row per file moved.
|
|
5
5
|
class KeyMvPrefix
|
|
6
|
+
extend Textus::Contract::DSL
|
|
7
|
+
|
|
8
|
+
verb :key_mv_prefix
|
|
9
|
+
summary "Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false."
|
|
10
|
+
surfaces :cli, :ruby, :mcp
|
|
11
|
+
arg :from_prefix, String, required: true
|
|
12
|
+
arg :to_prefix, String, required: true
|
|
13
|
+
arg :dry_run, :boolean
|
|
14
|
+
response(&:to_h)
|
|
15
|
+
|
|
6
16
|
def initialize(container:, call:)
|
|
7
17
|
@container = container
|
|
8
18
|
@call = call
|
|
@@ -5,6 +5,15 @@ module Textus
|
|
|
5
5
|
# Loads a YAML migration plan and dispatches each op to the
|
|
6
6
|
# appropriate Maintenance use case. Concatenates resulting Plans.
|
|
7
7
|
class Migrate
|
|
8
|
+
extend Textus::Contract::DSL
|
|
9
|
+
|
|
10
|
+
verb :migrate
|
|
11
|
+
summary "Run a YAML migration plan (multi-op)."
|
|
12
|
+
surfaces :cli, :ruby, :mcp
|
|
13
|
+
arg :plan_yaml, String, required: true
|
|
14
|
+
arg :dry_run, :boolean
|
|
15
|
+
response(&:to_h)
|
|
16
|
+
|
|
8
17
|
def initialize(container:, call:)
|
|
9
18
|
@container = container
|
|
10
19
|
@call = call
|
|
@@ -6,6 +6,14 @@ module Textus
|
|
|
6
6
|
# YAML string. Returns a Plan describing rule additions/removals/
|
|
7
7
|
# changes. Does NOT write anything.
|
|
8
8
|
class RuleLint
|
|
9
|
+
extend Textus::Contract::DSL
|
|
10
|
+
|
|
11
|
+
verb :rule_lint
|
|
12
|
+
summary "Diff candidate manifest YAML's rules against the live manifest. No writes."
|
|
13
|
+
surfaces :cli, :ruby, :mcp
|
|
14
|
+
arg :candidate_yaml, String, required: true
|
|
15
|
+
response(&:to_h)
|
|
16
|
+
|
|
9
17
|
def initialize(container:, call:)
|
|
10
18
|
@container = container
|
|
11
19
|
@call = call
|
|
@@ -6,6 +6,16 @@ module Textus
|
|
|
6
6
|
# the `zone:` field on every entry under the old zone, and moves
|
|
7
7
|
# every file from zones/<old>/ to zones/<new>/.
|
|
8
8
|
class ZoneMv
|
|
9
|
+
extend Textus::Contract::DSL
|
|
10
|
+
|
|
11
|
+
verb :zone_mv
|
|
12
|
+
summary "Rename a zone — manifest + files. Refuses if destination exists."
|
|
13
|
+
surfaces :cli, :ruby, :mcp
|
|
14
|
+
arg :from, String, required: true
|
|
15
|
+
arg :to, String, required: true
|
|
16
|
+
arg :dry_run, :boolean
|
|
17
|
+
response(&:to_h)
|
|
18
|
+
|
|
9
19
|
def initialize(container:, call:)
|
|
10
20
|
@container = container
|
|
11
21
|
@call = call
|
|
@@ -39,6 +39,11 @@ module Textus
|
|
|
39
39
|
def events = {}
|
|
40
40
|
def publish_each = nil
|
|
41
41
|
def index_filename = nil
|
|
42
|
+
def ignore = []
|
|
43
|
+
|
|
44
|
+
# Per-entry ignore (ADR 0042). Base entries enumerate no tree, so
|
|
45
|
+
# nothing is ever ignored; Nested overrides with real patterns.
|
|
46
|
+
def ignored?(_rel_path) = false
|
|
42
47
|
|
|
43
48
|
# Minimal context object passed into entry `publish_via` hooks.
|
|
44
49
|
# Everything beyond the three primitives is derived. Data.define
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
# Pure glob matcher backing per-entry `ignore:` patterns (ADR 0042).
|
|
5
|
+
# `rel_path` is the slash-joined path of a candidate file relative to the
|
|
6
|
+
# entry's base directory.
|
|
7
|
+
#
|
|
8
|
+
# Matching is segment-wise so the `**` globstar means "zero or more path
|
|
9
|
+
# segments" — `File.fnmatch` alone cannot express this (under
|
|
10
|
+
# FNM_PATHNAME a trailing `**` will not cross a `/`; without it a leading
|
|
11
|
+
# `**/` will not match zero leading segments). So `**/node_modules/**`
|
|
12
|
+
# catches the `node_modules` subtree at any depth, including the store
|
|
13
|
+
# root, and the directory entry itself.
|
|
14
|
+
#
|
|
15
|
+
# Within a single segment, matching delegates to `File.fnmatch` with
|
|
16
|
+
# FNM_EXTGLOB, so a single `*` is anchored to that segment (it does not
|
|
17
|
+
# cross `/`) and `{a,b}` alternation works.
|
|
18
|
+
module IgnoreMatcher
|
|
19
|
+
SEGMENT_FLAGS = File::FNM_EXTGLOB
|
|
20
|
+
|
|
21
|
+
def self.match?(patterns, rel_path)
|
|
22
|
+
path_segs = rel_path.split("/").reject(&:empty?)
|
|
23
|
+
Array(patterns).any? do |pat|
|
|
24
|
+
match_segments(pat.split("/").reject(&:empty?), path_segs)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Classic globstar matcher. `**` matches zero or more whole segments;
|
|
29
|
+
# any other pattern segment matches exactly one path segment via fnmatch.
|
|
30
|
+
def self.match_segments(pat_segs, path_segs)
|
|
31
|
+
return path_segs.empty? if pat_segs.empty?
|
|
32
|
+
|
|
33
|
+
if pat_segs.first == "**"
|
|
34
|
+
match_segments(pat_segs[1..], path_segs) ||
|
|
35
|
+
(!path_segs.empty? && match_segments(pat_segs, path_segs[1..]))
|
|
36
|
+
else
|
|
37
|
+
!path_segs.empty? &&
|
|
38
|
+
File.fnmatch?(pat_segs.first, path_segs.first, SEGMENT_FLAGS) &&
|
|
39
|
+
match_segments(pat_segs[1..], path_segs[1..])
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
private_class_method :match_segments
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -5,16 +5,22 @@ module Textus
|
|
|
5
5
|
PUBLISH_EACH_VARS = Validators::PublishEach::KNOWN_VARS
|
|
6
6
|
PUBLISH_EACH_VAR_RE = Validators::PublishEach::VAR_RE
|
|
7
7
|
|
|
8
|
-
attr_reader :index_filename, :publish_each
|
|
8
|
+
attr_reader :index_filename, :publish_each, :ignore
|
|
9
9
|
|
|
10
|
-
def initialize(index_filename: nil, publish_each: nil, **rest)
|
|
10
|
+
def initialize(index_filename: nil, publish_each: nil, ignore: nil, **rest)
|
|
11
11
|
super(**rest)
|
|
12
12
|
@index_filename = index_filename
|
|
13
13
|
@publish_each = publish_each
|
|
14
|
+
@ignore = Array(ignore)
|
|
14
15
|
end
|
|
15
16
|
|
|
16
17
|
def nested? = true
|
|
17
18
|
|
|
19
|
+
# True when `rel_path` (slash-joined, relative to the entry base) matches
|
|
20
|
+
# any configured ignore glob. Evaluated ABOVE key-legality (ADR 0042):
|
|
21
|
+
# an ignored path is excluded, never judged.
|
|
22
|
+
def ignored?(rel_path) = IgnoreMatcher.match?(@ignore, rel_path)
|
|
23
|
+
|
|
18
24
|
def publish_target_for(full_key)
|
|
19
25
|
return nil if @publish_each.nil?
|
|
20
26
|
|
|
@@ -65,6 +71,7 @@ module Textus
|
|
|
65
71
|
new(
|
|
66
72
|
index_filename: raw["index_filename"],
|
|
67
73
|
publish_each: raw["publish_each"],
|
|
74
|
+
ignore: raw["ignore"],
|
|
68
75
|
**common,
|
|
69
76
|
)
|
|
70
77
|
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Validators
|
|
5
|
+
# Validates the per-entry `ignore:` field (ADR 0042): a list of
|
|
6
|
+
# non-empty glob strings, allowed only on nested entries.
|
|
7
|
+
module Ignore
|
|
8
|
+
def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
9
|
+
patterns = entry.raw["ignore"]
|
|
10
|
+
return if patterns.nil?
|
|
11
|
+
|
|
12
|
+
raise UsageError.new("entry '#{entry.key}': ignore requires nested: true") unless entry.nested?
|
|
13
|
+
|
|
14
|
+
raise UsageError.new("entry '#{entry.key}': ignore must be a list of glob strings") unless patterns.is_a?(Array)
|
|
15
|
+
|
|
16
|
+
patterns.each do |pat|
|
|
17
|
+
next if pat.is_a?(String) && !pat.empty?
|
|
18
|
+
|
|
19
|
+
raise UsageError.new(
|
|
20
|
+
"entry '#{entry.key}': each ignore pattern must be a non-empty string (got #{pat.inspect})",
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -78,6 +78,8 @@ module Textus
|
|
|
78
78
|
|
|
79
79
|
def nested_row_for(entry, base, path)
|
|
80
80
|
rel = path.sub(%r{\A#{Regexp.escape(base)}/?}, "")
|
|
81
|
+
return nil if entry.ignored?(rel)
|
|
82
|
+
|
|
81
83
|
entry_if = entry.index_filename
|
|
82
84
|
stripped = entry_if ? File.dirname(rel) : rel.sub(/#{Regexp.escape(File.extname(rel))}\z/, "")
|
|
83
85
|
segs = stripped.split("/").reject { |s| s.empty? || s == "." }
|
|
@@ -25,7 +25,7 @@ module Textus
|
|
|
25
25
|
ENTRY_KEYS = %w[
|
|
26
26
|
key path zone kind schema owner nested format
|
|
27
27
|
compute template publish_to publish_each
|
|
28
|
-
intake events inject_boot index_filename
|
|
28
|
+
intake events inject_boot index_filename ignore
|
|
29
29
|
].freeze
|
|
30
30
|
COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
|
|
31
31
|
INTAKE_KEYS = %w[handler config].freeze
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module MCP
|
|
3
|
+
# Derives the entire MCP tool surface from the per-verb contracts
|
|
4
|
+
# (ADR 0039). `tool_schemas` feeds tools/list; `call` is the generic
|
|
5
|
+
# tools/call dispatch: map JSON args -> (positional, keyword) per the
|
|
6
|
+
# contract, invoke the verb through the role scope, then shape the
|
|
7
|
+
# return value with the contract's response block. No per-tool code.
|
|
8
|
+
module Catalog
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Contracts of every MCP-surfaced verb, in Dispatcher order.
|
|
12
|
+
def specs
|
|
13
|
+
Textus::Dispatcher::VERBS.values
|
|
14
|
+
.select { |k| k.respond_to?(:contract?) && k.contract? && k.contract.mcp? }
|
|
15
|
+
.map(&:contract)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def tool_schemas
|
|
19
|
+
specs.map do |s|
|
|
20
|
+
{ name: s.verb.to_s, description: s.summary, inputSchema: s.input_schema }
|
|
21
|
+
end.freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def names
|
|
25
|
+
specs.map { |s| s.verb.to_s }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(name, session:, store:, args:)
|
|
29
|
+
klass = Textus::Dispatcher::VERBS[name.to_sym]
|
|
30
|
+
raise ToolError.new("unknown tool: #{name}") unless klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
|
|
31
|
+
|
|
32
|
+
spec = klass.contract
|
|
33
|
+
pos, kw = map_args(spec, args || {}, session)
|
|
34
|
+
result = store.as(session.role).public_send(spec.verb, *pos, **kw)
|
|
35
|
+
spec.response.call(result)
|
|
36
|
+
rescue ContractDrift, CursorExpired
|
|
37
|
+
raise
|
|
38
|
+
rescue Textus::Error => e
|
|
39
|
+
raise ToolError.new("#{name}: #{e.message}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Splits the raw JSON arg hash into the positional list and keyword hash
|
|
43
|
+
# the use-case expects, validating required presence first.
|
|
44
|
+
# Session-default args (session_default: :method_name) are injected from
|
|
45
|
+
# the session when absent from the wire; they are never treated as missing.
|
|
46
|
+
# Positional args are emitted in contract declaration order; use-case signatures must match.
|
|
47
|
+
def map_args(spec, raw, session = nil)
|
|
48
|
+
missing = spec.required_args.map { |a| a.name.to_s } - raw.keys
|
|
49
|
+
raise ToolError.new("#{spec.verb}: missing #{missing.join(", ")}") unless missing.empty?
|
|
50
|
+
|
|
51
|
+
positional = []
|
|
52
|
+
keyword = {}
|
|
53
|
+
spec.args.each do |a|
|
|
54
|
+
if raw.key?(a.name.to_s)
|
|
55
|
+
value = raw[a.name.to_s]
|
|
56
|
+
elsif a.session_default && session
|
|
57
|
+
value = session.public_send(a.session_default)
|
|
58
|
+
else
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if a.positional
|
|
63
|
+
positional << value
|
|
64
|
+
else
|
|
65
|
+
keyword[a.name] = value
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
[positional, keyword]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/textus/mcp/server.rb
CHANGED
|
@@ -49,8 +49,11 @@ module Textus
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def handle_initialize(rid, _params)
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
# The acting role IS the resolved connection role (ADR 0040): the MCP
|
|
53
|
+
# transport defaults to `agent`, which can write the queue, so its
|
|
54
|
+
# propose_zone resolves directly. If a connection's role cannot propose,
|
|
55
|
+
# propose_zone is nil and the `propose` tool reports that honestly.
|
|
56
|
+
propose_zone = @store.manifest.policy.propose_zone_for(@role)
|
|
54
57
|
|
|
55
58
|
@session = Session.new(
|
|
56
59
|
role: @role,
|
|
@@ -67,7 +70,7 @@ module Textus
|
|
|
67
70
|
end
|
|
68
71
|
|
|
69
72
|
def handle_tools_list(rid)
|
|
70
|
-
emit_result(rid, { "tools" =>
|
|
73
|
+
emit_result(rid, { "tools" => Catalog.tool_schemas })
|
|
71
74
|
end
|
|
72
75
|
|
|
73
76
|
def handle_tools_call(rid, params)
|
|
@@ -80,8 +83,8 @@ module Textus
|
|
|
80
83
|
|
|
81
84
|
name = params["name"]
|
|
82
85
|
args = params["arguments"] || {}
|
|
83
|
-
result =
|
|
84
|
-
@session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "
|
|
86
|
+
result = Catalog.call(name, session: @session, store: @store, args: args)
|
|
87
|
+
@session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "pulse"
|
|
85
88
|
|
|
86
89
|
emit_result(rid, {
|
|
87
90
|
"content" => [{ "type" => "text", "text" => JSON.dump(result) }],
|
data/lib/textus/mcp/session.rb
CHANGED
|
@@ -1,24 +1,7 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module MCP
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
Session =
|
|
6
|
-
def advance_cursor(new_cursor) = with(cursor: new_cursor)
|
|
7
|
-
|
|
8
|
-
def check_etag!(observed_etag)
|
|
9
|
-
return if observed_etag == manifest_etag
|
|
10
|
-
|
|
11
|
-
raise ContractDrift.new(
|
|
12
|
-
"manifest changed (was #{short_etag(manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
|
|
13
|
-
)
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
private
|
|
17
|
-
|
|
18
|
-
# First 8 hex chars after the "sha256:" prefix — a stable short id for
|
|
19
|
-
# the drift diagnostic. Tolerates non-prefixed values (delete_prefix is
|
|
20
|
-
# a no-op when the prefix is absent).
|
|
21
|
-
def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
|
|
22
|
-
end
|
|
3
|
+
# The session value now lives in core (ADR 0036); retained here as an
|
|
4
|
+
# alias so existing MCP references keep resolving.
|
|
5
|
+
Session = Textus::Session
|
|
23
6
|
end
|
|
24
7
|
end
|
|
@@ -1,70 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module MCP
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# Kept for name stability (ADR 0039). The JSON schemas are DERIVED from
|
|
4
|
+
# per-verb contracts; this delegates to MCP::Catalog. The hand-written
|
|
5
|
+
# array is gone — a kwarg rename now updates the schema automatically (and
|
|
6
|
+
# the signature guard fails if the contract lags the use-case).
|
|
6
7
|
module ToolSchemas
|
|
7
8
|
module_function
|
|
8
9
|
|
|
9
|
-
def all
|
|
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("fetch", "Run an intake fetch for one key. Returns the fetch Outcome.",
|
|
33
|
-
{ "key" => { "type" => "string" } }, ["key"]),
|
|
34
|
-
tool("fetch_stale", "Fetch 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 (fetch, 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
|
-
}
|
|
10
|
+
def all
|
|
11
|
+
Catalog.tool_schemas
|
|
68
12
|
end
|
|
69
13
|
end
|
|
70
14
|
end
|