textus 0.43.2 → 0.45.1
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 +57 -0
- data/README.md +3 -3
- data/SPEC.md +15 -13
- data/docs/architecture/README.md +28 -9
- data/docs/reference/conventions.md +8 -9
- data/lib/textus/boot.rb +3 -4
- data/lib/textus/cli/group/fetch.rb +2 -2
- data/lib/textus/cli/group.rb +1 -0
- data/lib/textus/cli/runner.rb +187 -0
- data/lib/textus/cli/verb/build.rb +4 -4
- data/lib/textus/cli/verb/{fetch_stale.rb → fetch_all.rb} +2 -2
- data/lib/textus/cli/verb/get.rb +6 -5
- data/lib/textus/cli/verb/put.rb +3 -3
- data/lib/textus/cli/verb.rb +3 -0
- data/lib/textus/cli.rb +8 -2
- data/lib/textus/contract/around.rb +29 -0
- data/lib/textus/contract/binder.rb +88 -0
- data/lib/textus/contract/resources/cursor.rb +26 -0
- data/lib/textus/contract/sources.rb +39 -0
- data/lib/textus/contract/view.rb +15 -0
- data/lib/textus/contract.rb +68 -8
- data/lib/textus/dispatcher.rb +5 -6
- data/lib/textus/hooks/context.rb +24 -2
- data/lib/textus/maintenance/key_delete_prefix.rb +6 -4
- data/lib/textus/maintenance/key_mv_prefix.rb +7 -5
- data/lib/textus/maintenance/migrate.rb +12 -9
- data/lib/textus/maintenance/rule_lint.rb +4 -3
- data/lib/textus/maintenance/zone_mv.rb +7 -5
- data/lib/textus/manifest/entry/base.rb +1 -1
- data/lib/textus/mcp/catalog.rb +6 -33
- data/lib/textus/projection.rb +2 -2
- data/lib/textus/read/audit.rb +19 -0
- data/lib/textus/read/blame.rb +11 -1
- data/lib/textus/read/deps.rb +15 -1
- data/lib/textus/read/doctor.rb +8 -0
- data/lib/textus/read/freshness.rb +10 -0
- data/lib/textus/read/get.rb +86 -21
- data/lib/textus/read/list.rb +1 -0
- data/lib/textus/read/published.rb +7 -0
- data/lib/textus/read/pulse.rb +1 -0
- data/lib/textus/read/rdeps.rb +14 -0
- data/lib/textus/read/rule_explain.rb +84 -0
- data/lib/textus/read/rule_list.rb +39 -0
- data/lib/textus/read/schema_envelope.rb +3 -2
- data/lib/textus/read/uid.rb +9 -0
- data/lib/textus/read/where.rb +8 -0
- data/lib/textus/role_scope.rb +34 -6
- data/lib/textus/schema/tools.rb +12 -3
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +8 -0
- data/lib/textus/write/{publish.rb → build.rb} +16 -7
- data/lib/textus/write/delete.rb +13 -0
- data/lib/textus/write/fetch_all.rb +1 -0
- data/lib/textus/write/fetch_orchestrator.rb +1 -1
- data/lib/textus/write/fetch_worker.rb +1 -1
- data/lib/textus/write/mv.rb +16 -0
- data/lib/textus/write/propose.rb +7 -2
- data/lib/textus/write/put.rb +2 -2
- data/lib/textus/write/reject.rb +8 -0
- data/lib/textus/write/retention_sweep.rb +9 -0
- metadata +11 -29
- data/lib/textus/cli/verb/accept.rb +0 -16
- data/lib/textus/cli/verb/audit.rb +0 -34
- data/lib/textus/cli/verb/blame.rb +0 -17
- data/lib/textus/cli/verb/delete.rb +0 -17
- data/lib/textus/cli/verb/deps.rb +0 -14
- data/lib/textus/cli/verb/freshness.rb +0 -17
- data/lib/textus/cli/verb/key_delete.rb +0 -24
- data/lib/textus/cli/verb/list.rb +0 -16
- data/lib/textus/cli/verb/migrate.rb +0 -18
- data/lib/textus/cli/verb/mv.rb +0 -27
- data/lib/textus/cli/verb/propose.rb +0 -28
- data/lib/textus/cli/verb/published.rb +0 -13
- data/lib/textus/cli/verb/pulse.rb +0 -26
- data/lib/textus/cli/verb/rdeps.rb +0 -14
- data/lib/textus/cli/verb/reject.rb +0 -16
- data/lib/textus/cli/verb/retain.rb +0 -19
- data/lib/textus/cli/verb/rule_explain.rb +0 -16
- data/lib/textus/cli/verb/rule_lint.rb +0 -18
- data/lib/textus/cli/verb/rule_list.rb +0 -29
- data/lib/textus/cli/verb/schema.rb +0 -15
- data/lib/textus/cli/verb/uid.rb +0 -15
- data/lib/textus/cli/verb/where.rb +0 -14
- data/lib/textus/cli/verb/zone_mv.rb +0 -19
- data/lib/textus/read/get_or_fetch.rb +0 -69
- data/lib/textus/read/policy_explain.rb +0 -46
- data/lib/textus/read/rules.rb +0 -25
data/lib/textus/cli/verb/put.rb
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class CLI
|
|
3
3
|
class Verb
|
|
4
|
-
class Put <
|
|
5
|
-
|
|
4
|
+
class Put < Runner::Base
|
|
5
|
+
self.spec = Textus::Write::Put.contract
|
|
6
6
|
|
|
7
7
|
option :as_flag, "--as=ROLE"
|
|
8
8
|
option :use_stdin, "--stdin"
|
|
9
9
|
option :fetch_name, "--fetch=NAME"
|
|
10
10
|
|
|
11
|
-
def
|
|
11
|
+
def invoke(store)
|
|
12
12
|
key = positional.shift or raise UsageError.new("put requires a key")
|
|
13
13
|
raise UsageError.new("put requires --stdin in v1") unless use_stdin
|
|
14
14
|
|
data/lib/textus/cli/verb.rb
CHANGED
data/lib/textus/cli.rb
CHANGED
|
@@ -7,9 +7,15 @@ module Textus
|
|
|
7
7
|
# declares `command_name "X"` and has no `parent_group` is a top-level
|
|
8
8
|
# verb. Sorted alphabetically for stable help output. Adding a new
|
|
9
9
|
# verb requires only a new file declaring its `command_name`.
|
|
10
|
+
#
|
|
11
|
+
# `k.name` gates out anonymous (Class.new) subclasses: real verbs are always
|
|
12
|
+
# named constants (generated Gen* or hand-authored classes), so this is a
|
|
13
|
+
# no-op in production but keeps throwaway test fixtures from leaking into the
|
|
14
|
+
# registry (and tripping the reconciliation guards order-dependently).
|
|
10
15
|
def self.verbs
|
|
16
|
+
Runner.install!
|
|
11
17
|
Verb.descendants
|
|
12
|
-
.select { |k| k.command_name && k.parent_group.nil? }
|
|
18
|
+
.select { |k| k.name && k.command_name && k.parent_group.nil? }
|
|
13
19
|
.sort_by(&:command_name)
|
|
14
20
|
.to_h { |k| [k.command_name, k] }
|
|
15
21
|
end
|
|
@@ -91,7 +97,7 @@ module Textus
|
|
|
91
97
|
textus put KEY --stdin [--fetch=NAME] --as=ROLE
|
|
92
98
|
textus freshness [--prefix=KEY] [--zone=Z]
|
|
93
99
|
textus fetch KEY
|
|
94
|
-
textus fetch
|
|
100
|
+
textus fetch all [--prefix=KEY] [--zone=Z]
|
|
95
101
|
textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
|
|
96
102
|
textus blame KEY [--limit=N]
|
|
97
103
|
textus doctor
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Contract
|
|
3
|
+
# Registry of named, stateful wrappers a verb may declare via `around :name`.
|
|
4
|
+
# A resource implements
|
|
5
|
+
# `wrap(scope:, inputs:, session:) { |effective_inputs| ... }`:
|
|
6
|
+
# it may adjust the inputs before the call and post-process the result after
|
|
7
|
+
# — exactly what build's lock and pulse's cursor need, without a hand-authored
|
|
8
|
+
# CLI class (ADR 0068). `session:` is the dispatching session (nil for the
|
|
9
|
+
# sessionless CLI/Ruby surfaces, present for MCP), so a session-aware resource
|
|
10
|
+
# like the cursor can defer to the session's own state instead of its file.
|
|
11
|
+
module Around
|
|
12
|
+
@registry = {}
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def register(name, resource)
|
|
17
|
+
@registry[name] = resource
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def fetch(name)
|
|
21
|
+
@registry.fetch(name) { raise "no around resource registered: #{name.inspect}" }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def with(name, scope:, inputs:, session: nil, &call)
|
|
25
|
+
fetch(name).wrap(scope: scope, inputs: inputs, session: session, &call)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Contract
|
|
3
|
+
# Raised when a required arg is absent from the bound input. Surface
|
|
4
|
+
# adapters translate this to their native error (MCP ToolError, CLI
|
|
5
|
+
# UsageError); a direct Ruby call lets it surface as-is.
|
|
6
|
+
class MissingArgs < Textus::Error
|
|
7
|
+
attr_reader :spec, :missing
|
|
8
|
+
|
|
9
|
+
def initialize(spec, missing)
|
|
10
|
+
@spec = spec
|
|
11
|
+
@missing = missing
|
|
12
|
+
super("missing_args", "#{spec.verb}: missing #{missing.map(&:wire).join(", ")}")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The single argument binder for every surface (spike: collapses the three
|
|
17
|
+
# historical implementations — MCP::Catalog.map_args, CLI::Runner.call_args,
|
|
18
|
+
# and RoleScope's default-injection loop — into one algorithm).
|
|
19
|
+
#
|
|
20
|
+
# Input is a uniform `inputs` hash keyed by arg NAME (the use-case kwarg
|
|
21
|
+
# name, never the wire name): each surface normalizes its own raw transport
|
|
22
|
+
# shape (MCP JSON keyed by wire-name, CLI argv+flags, Ruby args+kwargs) into
|
|
23
|
+
# this hash. Binder owns the shared algorithm and nothing transport-specific:
|
|
24
|
+
#
|
|
25
|
+
# 1. validate every required arg is present in `inputs`;
|
|
26
|
+
# 2. for absentees, fall back to session_default (when a session is given)
|
|
27
|
+
# then to the literal default; otherwise omit the arg entirely;
|
|
28
|
+
# 3. split into the (positional, keyword) pair to splat into the use-case,
|
|
29
|
+
# routing by `arg.positional`.
|
|
30
|
+
#
|
|
31
|
+
# Returns `[positional_array, keyword_hash]` — exactly what
|
|
32
|
+
# `RoleScope#<verb>(*pos, **kw)` expects.
|
|
33
|
+
module Binder
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# Validation is unconditional: a `required:` arg absent from `inputs` is a
|
|
37
|
+
# contract violation on every surface (ADR 0069). `required:` is now an
|
|
38
|
+
# honest contract invariant, not a surface policy — args the use-case
|
|
39
|
+
# treats as optional (e.g. `meta`, whose real requiredness lives in schema
|
|
40
|
+
# validation downstream) are declared `required: false`, so this check
|
|
41
|
+
# never fires spuriously and never needs an opt-out.
|
|
42
|
+
def bind(spec, inputs, session: nil)
|
|
43
|
+
missing = spec.required_args.reject { |a| inputs.key?(a.name) }
|
|
44
|
+
raise MissingArgs.new(spec, missing) unless missing.empty?
|
|
45
|
+
|
|
46
|
+
pos = []
|
|
47
|
+
kw = {}
|
|
48
|
+
spec.args.each do |a|
|
|
49
|
+
if inputs.key?(a.name)
|
|
50
|
+
value = inputs[a.name]
|
|
51
|
+
elsif a.session_default && session
|
|
52
|
+
value = session.public_send(a.session_default)
|
|
53
|
+
elsif !a.default.nil?
|
|
54
|
+
value = a.default
|
|
55
|
+
else
|
|
56
|
+
next
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
if a.positional
|
|
60
|
+
pos << value
|
|
61
|
+
else
|
|
62
|
+
kw[a.name] = value
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
[pos, kw]
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Normalize an ordered positional list + a by-name keyword hash (the shape
|
|
69
|
+
# CLI argv+flags and Ruby args+kwargs both arrive in) into the uniform
|
|
70
|
+
# by-name `inputs` hash bind expects. Positionals beyond what was supplied
|
|
71
|
+
# are dropped so bind's required-check sees them as absent.
|
|
72
|
+
def inputs_from_ordered(spec, ordered_positionals, by_name_keywords)
|
|
73
|
+
names = spec.args.select(&:positional).map(&:name)
|
|
74
|
+
names.zip(ordered_positionals).to_h.compact.merge(by_name_keywords)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Normalize a raw transport hash keyed by WIRE name (the shape MCP JSON
|
|
78
|
+
# arrives in) into the uniform by-name `inputs` hash bind expects. Keys
|
|
79
|
+
# not declared on the contract are ignored.
|
|
80
|
+
def inputs_from_wire(spec, raw)
|
|
81
|
+
raw ||= {}
|
|
82
|
+
spec.args.each_with_object({}) do |a, h|
|
|
83
|
+
h[a.name] = raw[a.wire.to_s] if raw.key?(a.wire.to_s)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Contract
|
|
3
|
+
module Resources
|
|
4
|
+
# Reads the persisted file cursor as the `since` default when the caller
|
|
5
|
+
# did not supply one, runs pulse, then persists the returned cursor.
|
|
6
|
+
# Replaces CLI::Verb::Pulse's hand-coded CursorStore read/write (ADR 0068).
|
|
7
|
+
#
|
|
8
|
+
# A session-bearing surface (MCP) carries its own cursor via the contract's
|
|
9
|
+
# `session_default: :cursor`, so this defers entirely when a session is
|
|
10
|
+
# present — it is the sessionless CLI/Ruby surfaces that need the file.
|
|
11
|
+
class Cursor
|
|
12
|
+
def wrap(scope:, inputs:, session:)
|
|
13
|
+
return yield(inputs) if session
|
|
14
|
+
|
|
15
|
+
store = Textus::CursorStore.new(root: scope.container.root, role: scope.role)
|
|
16
|
+
effective = inputs.key?(:since) ? inputs : inputs.merge(since: store.read)
|
|
17
|
+
result = yield(effective)
|
|
18
|
+
store.write(result["cursor"]) if result.is_a?(Hash) && result["cursor"]
|
|
19
|
+
result
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
Textus::Contract::Around.register(:cursor, Textus::Contract::Resources::Cursor.new)
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Contract
|
|
5
|
+
# CLI-only input acquisition. Transforms entries of the uniform `inputs`
|
|
6
|
+
# hash that declare a `source:`/`coerce:`, and builds `inputs` from a
|
|
7
|
+
# `cli_stdin` envelope — so put/propose/migrate/rule_lint/audit need no
|
|
8
|
+
# hand-authored CLI class (ADR 0068). MCP receives typed JSON, so these
|
|
9
|
+
# never run there.
|
|
10
|
+
module Sources
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# Apply per-arg :file sources (value is a path -> file contents) and
|
|
14
|
+
# :coerce callables to a by-name inputs hash. Returns a new hash.
|
|
15
|
+
def acquire(spec, inputs)
|
|
16
|
+
spec.args.each_with_object(inputs.dup) do |a, h|
|
|
17
|
+
next unless h.key?(a.name)
|
|
18
|
+
|
|
19
|
+
h[a.name] = File.read(h[a.name]) if a.source == :file
|
|
20
|
+
h[a.name] = a.coerce.call(h[a.name]) if a.coerce
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Parse a cli_stdin :json envelope into a by-name inputs hash, mapping
|
|
25
|
+
# envelope keys (wire-names) to arg names.
|
|
26
|
+
def from_stdin(spec, stream)
|
|
27
|
+
return {} unless spec.cli_stdin == :json
|
|
28
|
+
|
|
29
|
+
raw = stream.read.to_s
|
|
30
|
+
return {} if raw.strip.empty? # no envelope piped -> required args surface as missing
|
|
31
|
+
|
|
32
|
+
envelope = JSON.parse(raw)
|
|
33
|
+
spec.args.each_with_object({}) do |a, h|
|
|
34
|
+
h[a.name] = envelope[a.wire.to_s] if envelope.key?(a.wire.to_s)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Contract
|
|
3
|
+
# Renders a use-case result for a surface, using the verb's declared view
|
|
4
|
+
# (falling back to the default). The single replacement for the old
|
|
5
|
+
# response/cli_response split and the Proc#arity sniff: views are always
|
|
6
|
+
# called as (result, inputs); a one-parameter view ignores inputs.
|
|
7
|
+
module View
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def render(spec, surface, result, inputs)
|
|
11
|
+
spec.view(surface).call(result, inputs)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/textus/contract.rb
CHANGED
|
@@ -12,7 +12,14 @@ module Textus
|
|
|
12
12
|
# envelope key) when it must differ from the use-case kwarg `name` — e.g. `put`
|
|
13
13
|
# takes the `meta:` kwarg but exposes `_meta` on the wire to match what `get`
|
|
14
14
|
# returns and what the CLI `--stdin` envelope already speaks (ADR 0057).
|
|
15
|
-
|
|
15
|
+
# `source: :file` (CLI only) reads the arg's value as a path -> file
|
|
16
|
+
# contents; `coerce:` is a callable applied to the raw value (CLI only);
|
|
17
|
+
# `cli_default:` supplies a CLI-specific default that diverges from the
|
|
18
|
+
# contract `default` the agent surfaces use (`:__unset` sentinel = none).
|
|
19
|
+
Arg = Data.define(
|
|
20
|
+
:name, :type, :required, :positional, :session_default,
|
|
21
|
+
:description, :wire_name, :default, :source, :coerce, :cli_default
|
|
22
|
+
) do
|
|
16
23
|
# The name used on the wire (defaults to the kwarg name).
|
|
17
24
|
def wire = wire_name || name
|
|
18
25
|
end
|
|
@@ -26,8 +33,21 @@ module Textus
|
|
|
26
33
|
JSON_TYPES.fetch(type) { raise ArgumentError.new("no JSON type mapping for #{type.inspect}") }
|
|
27
34
|
end
|
|
28
35
|
|
|
29
|
-
Spec = Data.define(:verb, :summary, :args, :surfaces, :
|
|
36
|
+
Spec = Data.define(:verb, :summary, :args, :surfaces, :views, :cli, :around, :cli_stdin) do
|
|
30
37
|
def mcp? = surfaces.include?(:mcp)
|
|
38
|
+
def cli? = surfaces.include?(:cli)
|
|
39
|
+
|
|
40
|
+
# The output shaper for a surface; falls back to the default view. Every
|
|
41
|
+
# view is invoked uniformly as `view.call(result, inputs)` — a view that
|
|
42
|
+
# declares one parameter ignores `inputs` (procs tolerate extra args).
|
|
43
|
+
def view(surface = :default) = views[surface] || views.fetch(:default)
|
|
44
|
+
|
|
45
|
+
# Operator-facing command path. Defaults to the verb token; grouped verbs
|
|
46
|
+
# declare e.g. `cli "schema show"`.
|
|
47
|
+
def cli_path = cli || verb.to_s
|
|
48
|
+
def cli_words = cli_path.split
|
|
49
|
+
def cli_group = cli_words.size > 1 ? cli_words.first : nil
|
|
50
|
+
def cli_leaf = cli_words.last
|
|
31
51
|
|
|
32
52
|
def required_args = args.select(&:required)
|
|
33
53
|
|
|
@@ -77,19 +97,56 @@ module Textus
|
|
|
77
97
|
end
|
|
78
98
|
end
|
|
79
99
|
|
|
80
|
-
def
|
|
100
|
+
def cli(path = nil)
|
|
101
|
+
if path
|
|
102
|
+
raise "contract already built; declare cli before reading .contract" if defined?(@__contract) && @__contract
|
|
103
|
+
|
|
104
|
+
@__cli = path.to_s
|
|
105
|
+
else
|
|
106
|
+
@__cli
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Declare a stateful wrapper resource (Contract::Around) to run around
|
|
111
|
+
# dispatch — e.g. `around :cursor` (pulse) or `around :build_lock` (build).
|
|
112
|
+
def around(name = nil)
|
|
113
|
+
return @__around unless name
|
|
114
|
+
|
|
115
|
+
raise "contract already built; declare around before reading .contract" if defined?(@__contract) && @__contract
|
|
116
|
+
|
|
117
|
+
@__around = name
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def arg(name, type, required: false, positional: false, session_default: nil, description: nil, wire_name: nil, default: nil, source: nil, coerce: nil, cli_default: :__unset) # rubocop:disable Metrics/ParameterLists,Layout/LineLength
|
|
81
121
|
raise "contract already built; declare args before reading .contract" if defined?(@__contract) && @__contract
|
|
82
122
|
|
|
83
123
|
(@__args ||= []) << Arg.new(
|
|
84
124
|
name: name, type: type, required: required,
|
|
85
125
|
positional: positional, session_default: session_default,
|
|
86
|
-
description: description, wire_name: wire_name
|
|
126
|
+
description: description, wire_name: wire_name, default: default,
|
|
127
|
+
source: source, coerce: coerce, cli_default: cli_default
|
|
87
128
|
)
|
|
88
129
|
end
|
|
89
130
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
131
|
+
# Verb-level: the CLI reads its inputs from a stdin envelope of this mode.
|
|
132
|
+
# `:json` parses stdin as a JSON object and distributes its keys to args
|
|
133
|
+
# by wire-name. nil means no stdin acquisition.
|
|
134
|
+
def cli_stdin(mode = :__read)
|
|
135
|
+
return @__cli_stdin if mode == :__read
|
|
136
|
+
|
|
137
|
+
raise "contract already built; declare cli_stdin before reading .contract" if defined?(@__contract) && @__contract
|
|
138
|
+
|
|
139
|
+
@__cli_stdin = mode
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Declare an output shaper. `view { ... }` is the default (MCP + Ruby);
|
|
143
|
+
# `view(:cli) { ... }` overrides for the CLI. Both receive (result, inputs).
|
|
144
|
+
def view(surface = :default, &blk)
|
|
145
|
+
return (@__views ||= {})[surface] unless blk
|
|
146
|
+
|
|
147
|
+
raise "contract already built; declare view before reading .contract" if defined?(@__contract) && @__contract
|
|
148
|
+
|
|
149
|
+
(@__views ||= {})[surface] = blk
|
|
93
150
|
end
|
|
94
151
|
|
|
95
152
|
def contract?
|
|
@@ -105,7 +162,10 @@ module Textus
|
|
|
105
162
|
summary: @__summary,
|
|
106
163
|
args: (@__args || []).freeze,
|
|
107
164
|
surfaces: (@__surfaces || []).freeze,
|
|
108
|
-
|
|
165
|
+
views: ((@__views ||= {})[:default] ||= ->(v, _i) { v }) && @__views,
|
|
166
|
+
cli: @__cli,
|
|
167
|
+
around: @__around,
|
|
168
|
+
cli_stdin: @__cli_stdin,
|
|
109
169
|
)
|
|
110
170
|
end
|
|
111
171
|
# rubocop:enable Naming/MemoizedInstanceVariableName
|
data/lib/textus/dispatcher.rb
CHANGED
|
@@ -11,14 +11,13 @@ module Textus
|
|
|
11
11
|
mv: Textus::Write::Mv,
|
|
12
12
|
accept: Textus::Write::Accept,
|
|
13
13
|
reject: Textus::Write::Reject,
|
|
14
|
-
|
|
14
|
+
build: Textus::Write::Build,
|
|
15
15
|
fetch: Textus::Write::FetchWorker,
|
|
16
16
|
fetch_all: Textus::Write::FetchAll,
|
|
17
|
-
|
|
17
|
+
retain: Textus::Write::RetentionSweep,
|
|
18
18
|
|
|
19
19
|
# Read
|
|
20
20
|
get: Textus::Read::Get,
|
|
21
|
-
get_or_fetch: Textus::Read::GetOrFetch,
|
|
22
21
|
list: Textus::Read::List,
|
|
23
22
|
where: Textus::Read::Where,
|
|
24
23
|
uid: Textus::Read::Uid,
|
|
@@ -29,14 +28,14 @@ module Textus
|
|
|
29
28
|
deps: Textus::Read::Deps,
|
|
30
29
|
rdeps: Textus::Read::Rdeps,
|
|
31
30
|
pulse: Textus::Read::Pulse,
|
|
32
|
-
|
|
31
|
+
rule_explain: Textus::Read::RuleExplain,
|
|
32
|
+
rule_list: Textus::Read::RuleList,
|
|
33
33
|
published: Textus::Read::Published,
|
|
34
|
-
|
|
34
|
+
schema_show: Textus::Read::SchemaEnvelope,
|
|
35
35
|
validate_all: Textus::Read::ValidateAll,
|
|
36
36
|
doctor: Textus::Read::Doctor,
|
|
37
37
|
boot: Textus::Read::Boot,
|
|
38
38
|
retainable: Textus::Read::Retainable,
|
|
39
|
-
rules: Textus::Read::Rules,
|
|
40
39
|
|
|
41
40
|
# Maintenance
|
|
42
41
|
migrate: Textus::Maintenance::Migrate,
|
data/lib/textus/hooks/context.rb
CHANGED
|
@@ -28,8 +28,17 @@ module Textus
|
|
|
28
28
|
@scope
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
# read
|
|
32
|
-
|
|
31
|
+
# read — a deliberately pure-observation surface: NOTHING here fetches
|
|
32
|
+
# (`list`/`deps`/`freshness` don't either). The invariant is that a hook
|
|
33
|
+
# observes current state and never triggers an I/O cascade. `get` bypasses
|
|
34
|
+
# the read-through behavior (ADR 0062) and reads with fetch:false directly,
|
|
35
|
+
# because read-through inside a hook would: (1) fire fetch events → hooks →
|
|
36
|
+
# unbounded reentrancy; (2) spawn the orchestrator's threads/fork from
|
|
37
|
+
# inside a hook callback; (3) probe the single-flight fetch lock its own
|
|
38
|
+
# enclosing fetch may hold (deadlock); (4) inject network latency into
|
|
39
|
+
# every hook read. With the merged Read::Get class, `fetch:false` (the
|
|
40
|
+
# method default) guarantees no orchestrator is built.
|
|
41
|
+
def get(key) = pure_reader.call(key)
|
|
33
42
|
def list(**) = @scope.list(**)
|
|
34
43
|
def deps(key) = @scope.deps(key)
|
|
35
44
|
def freshness(key) = @scope.freshness(key)
|
|
@@ -50,6 +59,19 @@ module Textus
|
|
|
50
59
|
def inspect
|
|
51
60
|
"#<Textus::Hooks::Context role=#{@role} correlation_id=#{@correlation_id}>"
|
|
52
61
|
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def pure_reader
|
|
66
|
+
@pure_reader ||= Textus::Read::Get.new(
|
|
67
|
+
container: @scope.container,
|
|
68
|
+
call: Textus::Call.build(
|
|
69
|
+
role: @scope.role,
|
|
70
|
+
correlation_id: @scope.correlation_id,
|
|
71
|
+
dry_run: @scope.dry_run?,
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
end
|
|
53
75
|
end
|
|
54
76
|
end
|
|
55
77
|
end
|
|
@@ -7,16 +7,18 @@ module Textus
|
|
|
7
7
|
verb :key_delete_prefix
|
|
8
8
|
summary "Bulk-delete every leaf key under prefix."
|
|
9
9
|
surfaces :cli, :ruby, :mcp
|
|
10
|
-
|
|
11
|
-
arg :
|
|
12
|
-
|
|
10
|
+
cli "key delete-prefix"
|
|
11
|
+
arg :prefix, String, required: true, positional: true, description: "every leaf key under this dotted prefix is deleted"
|
|
12
|
+
arg :dry_run, :boolean, default: true, cli_default: false,
|
|
13
|
+
description: "agents plan by default; the CLI applies by default (pass --dry-run to plan only)"
|
|
14
|
+
view { |v, _i| v.to_h }
|
|
13
15
|
|
|
14
16
|
def initialize(container:, call:)
|
|
15
17
|
@container = container
|
|
16
18
|
@call = call
|
|
17
19
|
end
|
|
18
20
|
|
|
19
|
-
def call(prefix
|
|
21
|
+
def call(prefix, dry_run: true)
|
|
20
22
|
raise UsageError.new("prefix required") if prefix.nil? || prefix.empty?
|
|
21
23
|
|
|
22
24
|
leaves = Read::List.new(container: @container)
|
|
@@ -8,17 +8,19 @@ module Textus
|
|
|
8
8
|
verb :key_mv_prefix
|
|
9
9
|
summary "Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false."
|
|
10
10
|
surfaces :cli, :ruby, :mcp
|
|
11
|
-
|
|
12
|
-
arg :
|
|
13
|
-
arg :
|
|
14
|
-
|
|
11
|
+
cli "key mv-prefix"
|
|
12
|
+
arg :from_prefix, String, required: true, positional: true, description: "dotted prefix whose leaf keys are renamed"
|
|
13
|
+
arg :to_prefix, String, required: true, positional: true, description: "dotted prefix the keys are renamed to"
|
|
14
|
+
arg :dry_run, :boolean, default: true, cli_default: false,
|
|
15
|
+
description: "agents plan by default; the CLI applies by default (pass --dry-run to plan only)"
|
|
16
|
+
view { |v, _i| v.to_h }
|
|
15
17
|
|
|
16
18
|
def initialize(container:, call:)
|
|
17
19
|
@container = container
|
|
18
20
|
@call = call
|
|
19
21
|
end
|
|
20
22
|
|
|
21
|
-
def call(from_prefix
|
|
23
|
+
def call(from_prefix, to_prefix, dry_run: true)
|
|
22
24
|
raise UsageError.new("from_prefix and to_prefix required") if from_prefix.nil? || to_prefix.nil?
|
|
23
25
|
|
|
24
26
|
leaves = list_leaves_under(from_prefix)
|
|
@@ -10,17 +10,18 @@ module Textus
|
|
|
10
10
|
verb :migrate
|
|
11
11
|
summary "Run a YAML migration plan (multi-op)."
|
|
12
12
|
surfaces :cli, :ruby, :mcp
|
|
13
|
-
arg :plan_yaml, String, required: true,
|
|
14
|
-
description: "
|
|
15
|
-
arg :dry_run,
|
|
16
|
-
|
|
13
|
+
arg :plan_yaml, String, required: true, positional: true, source: :file,
|
|
14
|
+
description: "path to the YAML migration plan (zone_mv, key_mv_prefix, key_delete_prefix ops run in order)"
|
|
15
|
+
arg :dry_run, :boolean, default: true, cli_default: false,
|
|
16
|
+
description: "agents plan by default; the CLI applies by default (pass --dry-run to plan only)"
|
|
17
|
+
view { |v, _i| v.to_h }
|
|
17
18
|
|
|
18
19
|
def initialize(container:, call:)
|
|
19
20
|
@container = container
|
|
20
21
|
@call = call
|
|
21
22
|
end
|
|
22
23
|
|
|
23
|
-
def call(plan_yaml
|
|
24
|
+
def call(plan_yaml, dry_run: true)
|
|
24
25
|
raw = YAML.safe_load(plan_yaml, permitted_classes: [Symbol], aliases: false)
|
|
25
26
|
raise UsageError.new("migration plan must be a YAML mapping") unless raw.is_a?(Hash)
|
|
26
27
|
|
|
@@ -41,11 +42,13 @@ module Textus
|
|
|
41
42
|
private
|
|
42
43
|
|
|
43
44
|
def invoke_op(op_name, op_hash, dry_run:)
|
|
44
|
-
kwargs = op_hash.except("op").transform_keys(&:to_sym).merge(dry_run: dry_run)
|
|
45
45
|
klass = op_class(op_name)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
)
|
|
46
|
+
inputs = op_hash.except("op").transform_keys(&:to_sym).merge(dry_run: dry_run)
|
|
47
|
+
# Each op now carries positional args (from/to, from_prefix/to_prefix,
|
|
48
|
+
# prefix); split the YAML fields into (positional, keyword) via the op's
|
|
49
|
+
# own contract so we call its #call signature correctly (ADR 0066/0068).
|
|
50
|
+
args, kwargs = Textus::Contract::Binder.bind(klass.contract, inputs)
|
|
51
|
+
klass.new(container: @container, call: @call).call(*args, **kwargs)
|
|
49
52
|
end
|
|
50
53
|
|
|
51
54
|
def op_class(op_name)
|
|
@@ -11,9 +11,10 @@ module Textus
|
|
|
11
11
|
verb :rule_lint
|
|
12
12
|
summary "Diff candidate manifest YAML's rules against the live manifest. No writes."
|
|
13
13
|
surfaces :cli, :ruby, :mcp
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
cli "rule lint"
|
|
15
|
+
arg :candidate_yaml, String, required: true, wire_name: :against, source: :file,
|
|
16
|
+
description: "path to candidate manifest YAML; its `rules:` block is diffed against the live manifest"
|
|
17
|
+
view { |v, _i| v.to_h }
|
|
17
18
|
|
|
18
19
|
def initialize(container:, call:)
|
|
19
20
|
@container = container
|
|
@@ -11,10 +11,12 @@ module Textus
|
|
|
11
11
|
verb :zone_mv
|
|
12
12
|
summary "Rename a zone — manifest + files. Refuses if destination exists."
|
|
13
13
|
surfaces :cli, :ruby, :mcp
|
|
14
|
-
|
|
15
|
-
arg :
|
|
16
|
-
arg :
|
|
17
|
-
|
|
14
|
+
cli "zone mv"
|
|
15
|
+
arg :from, String, required: true, positional: true, description: "current zone name"
|
|
16
|
+
arg :to, String, required: true, positional: true, description: "new zone name; refused if a zone by this name already exists"
|
|
17
|
+
arg :dry_run, :boolean, default: true, cli_default: false,
|
|
18
|
+
description: "agents plan by default; the CLI applies by default (pass --dry-run to plan only)"
|
|
19
|
+
view { |v, _i| v.to_h }
|
|
18
20
|
|
|
19
21
|
def initialize(container:, call:)
|
|
20
22
|
@container = container
|
|
@@ -23,7 +25,7 @@ module Textus
|
|
|
23
25
|
@root = container.root
|
|
24
26
|
end
|
|
25
27
|
|
|
26
|
-
def call(from
|
|
28
|
+
def call(from, to, dry_run: true)
|
|
27
29
|
raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
|
|
28
30
|
raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.declared_zone_kinds.key?(from)
|
|
29
31
|
|
data/lib/textus/mcp/catalog.rb
CHANGED
|
@@ -4,7 +4,7 @@ module Textus
|
|
|
4
4
|
# (ADR 0039). `tool_schemas` feeds tools/list; `call` is the generic
|
|
5
5
|
# tools/call dispatch: map JSON args -> (positional, keyword) per the
|
|
6
6
|
# contract, invoke the verb through the role scope, then shape the
|
|
7
|
-
# return value with the contract's
|
|
7
|
+
# return value with the contract's default view. No per-tool code.
|
|
8
8
|
module Catalog
|
|
9
9
|
module_function
|
|
10
10
|
|
|
@@ -55,43 +55,16 @@ module Textus
|
|
|
55
55
|
raise ToolError.new("unknown tool: #{name}") unless klass && mcp_surfaced?(klass)
|
|
56
56
|
|
|
57
57
|
spec = klass.contract
|
|
58
|
-
|
|
59
|
-
result = store.as(session.role).
|
|
60
|
-
|
|
58
|
+
inputs = Textus::Contract::Binder.inputs_from_wire(spec, args)
|
|
59
|
+
result = store.as(session.role).dispatch_bound(spec.verb, inputs, session: session)
|
|
60
|
+
Textus::Contract::View.render(spec, :default, result, inputs)
|
|
61
|
+
rescue Textus::Contract::MissingArgs => e
|
|
62
|
+
raise ToolError.new("#{spec.verb}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
|
|
61
63
|
rescue ContractDrift, CursorExpired
|
|
62
64
|
raise
|
|
63
65
|
rescue Textus::Error => e
|
|
64
66
|
raise ToolError.new("#{name}: #{e.message}")
|
|
65
67
|
end
|
|
66
|
-
|
|
67
|
-
# Splits the raw JSON arg hash into the positional list and keyword hash
|
|
68
|
-
# the use-case expects, validating required presence first.
|
|
69
|
-
# Session-default args (session_default: :method_name) are injected from
|
|
70
|
-
# the session when absent from the wire; they are never treated as missing.
|
|
71
|
-
# Positional args are emitted in contract declaration order; use-case signatures must match.
|
|
72
|
-
def map_args(spec, raw, session = nil)
|
|
73
|
-
missing = spec.required_args.map { |a| a.wire.to_s } - raw.keys
|
|
74
|
-
raise ToolError.new("#{spec.verb}: missing #{missing.join(", ")}") unless missing.empty?
|
|
75
|
-
|
|
76
|
-
positional = []
|
|
77
|
-
keyword = {}
|
|
78
|
-
spec.args.each do |a|
|
|
79
|
-
if raw.key?(a.wire.to_s)
|
|
80
|
-
value = raw[a.wire.to_s]
|
|
81
|
-
elsif a.session_default && session
|
|
82
|
-
value = session.public_send(a.session_default)
|
|
83
|
-
else
|
|
84
|
-
next
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
if a.positional
|
|
88
|
-
positional << value
|
|
89
|
-
else
|
|
90
|
-
keyword[a.name] = value
|
|
91
|
-
end
|
|
92
|
-
end
|
|
93
|
-
[positional, keyword]
|
|
94
|
-
end
|
|
95
68
|
end
|
|
96
69
|
end
|
|
97
70
|
end
|