textus 0.51.0 → 0.53.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 +37 -0
- data/README.md +74 -66
- data/SPEC.md +100 -223
- data/docs/architecture/README.md +91 -80
- data/docs/reference/conventions.md +8 -8
- data/exe/textus +1 -1
- data/lib/textus/action/accept.rb +53 -0
- data/lib/textus/action/audit.rb +133 -0
- data/lib/textus/action/base.rb +42 -0
- data/lib/textus/{read → action}/blame.rb +30 -22
- data/lib/textus/action/boot.rb +26 -0
- data/lib/textus/action/data_mv.rb +71 -0
- data/lib/textus/action/deps.rb +48 -0
- data/lib/textus/action/doctor.rb +26 -0
- data/lib/textus/action/drain.rb +41 -0
- data/lib/textus/action/enqueue.rb +55 -0
- data/lib/textus/action/get.rb +80 -0
- data/lib/textus/action/jobs.rb +38 -0
- data/lib/textus/action/key_delete.rb +46 -0
- data/lib/textus/action/key_delete_prefix.rb +46 -0
- data/lib/textus/action/key_mv.rb +143 -0
- data/lib/textus/action/key_mv_prefix.rb +59 -0
- data/lib/textus/action/list.rb +44 -0
- data/lib/textus/action/propose.rb +54 -0
- data/lib/textus/action/published.rb +26 -0
- data/lib/textus/action/pulse/scanner.rb +118 -0
- data/lib/textus/action/pulse.rb +87 -0
- data/lib/textus/action/put.rb +63 -0
- data/lib/textus/action/rdeps.rb +49 -0
- data/lib/textus/action/reject.rb +49 -0
- data/lib/textus/action/rule_explain.rb +95 -0
- data/lib/textus/action/rule_lint.rb +70 -0
- data/lib/textus/action/rule_list.rb +46 -0
- data/lib/textus/action/schema_envelope.rb +31 -0
- data/lib/textus/action/uid.rb +35 -0
- data/lib/textus/action/where.rb +38 -0
- data/lib/textus/action/write_verb.rb +58 -0
- data/lib/textus/background/job/base.rb +27 -0
- data/lib/textus/background/job/materialize.rb +31 -0
- data/lib/textus/background/job/refresh.rb +22 -0
- data/lib/textus/background/job/sweep.rb +31 -0
- data/lib/textus/background/job.rb +19 -0
- data/lib/textus/background/plan.rb +9 -0
- data/lib/textus/background/planner/plan.rb +113 -0
- data/lib/textus/background/retention/apply.rb +50 -0
- data/lib/textus/background/worker.rb +67 -0
- data/lib/textus/boot.rb +59 -49
- data/lib/textus/command.rb +36 -0
- data/lib/textus/container.rb +1 -1
- data/lib/textus/{domain → core}/duration.rb +1 -1
- data/lib/textus/{domain → core}/freshness/evaluator.rb +13 -13
- data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
- data/lib/textus/{domain → core}/freshness.rb +2 -2
- data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
- data/lib/textus/{domain → core}/retention.rb +2 -2
- data/lib/textus/{domain → core}/sentinel.rb +1 -1
- data/lib/textus/doctor/check/generator_drift.rb +2 -2
- data/lib/textus/doctor/check/handler_permit.rb +34 -0
- data/lib/textus/doctor/check/hooks.rb +11 -18
- data/lib/textus/doctor/check/illegal_keys.rb +1 -1
- data/lib/textus/doctor/check/intake_registration.rb +5 -5
- data/lib/textus/doctor/check/proposal_targets.rb +3 -3
- data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
- data/lib/textus/doctor/check/schema_violations.rb +8 -2
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check.rb +12 -9
- data/lib/textus/{read → doctor}/validator.rb +22 -13
- data/lib/textus/doctor.rb +6 -6
- data/lib/textus/envelope/io/writer.rb +65 -36
- data/lib/textus/envelope.rb +5 -3
- data/lib/textus/errors.rb +19 -11
- data/lib/textus/events.rb +21 -0
- data/lib/textus/gate/auth.rb +181 -0
- data/lib/textus/gate.rb +114 -0
- data/lib/textus/init/templates/machine_intake.rb +40 -36
- data/lib/textus/init/templates/orientation_reducer.rb +15 -11
- data/lib/textus/init.rb +93 -76
- data/lib/textus/key/path.rb +9 -2
- data/lib/textus/layout.rb +21 -0
- data/lib/textus/manifest/capabilities.rb +1 -1
- data/lib/textus/manifest/data.rb +30 -15
- data/lib/textus/manifest/entry/base.rb +15 -11
- data/lib/textus/manifest/entry/parser.rb +6 -6
- data/lib/textus/manifest/entry/produced.rb +3 -2
- data/lib/textus/manifest/entry/publish/mode.rb +1 -1
- data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
- data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
- data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
- data/lib/textus/manifest/policy/react.rb +30 -0
- data/lib/textus/{domain → manifest}/policy/retention.rb +4 -4
- data/lib/textus/{domain → manifest}/policy/source.rb +27 -28
- data/lib/textus/manifest/policy.rb +36 -48
- data/lib/textus/manifest/resolver.rb +3 -2
- data/lib/textus/manifest/rules.rb +4 -4
- data/lib/textus/manifest/schema/keys.rb +18 -12
- data/lib/textus/manifest/schema/validator.rb +27 -25
- data/lib/textus/manifest/schema/vocabulary.rb +3 -3
- data/lib/textus/manifest/schema.rb +2 -2
- data/lib/textus/manifest.rb +2 -2
- data/lib/textus/{produce → pipeline}/acquire/handler.rb +3 -3
- data/lib/textus/{produce → pipeline}/acquire/intake.rb +23 -21
- data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
- data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
- data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
- data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
- data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
- data/lib/textus/{produce → pipeline}/engine.rb +17 -63
- data/lib/textus/{produce → pipeline}/render.rb +3 -1
- data/lib/textus/ports/audit_log.rb +31 -5
- data/lib/textus/ports/audit_subscriber.rb +4 -4
- data/lib/textus/ports/build_lock.rb +1 -1
- data/lib/textus/ports/queue/job.rb +65 -0
- data/lib/textus/ports/queue.rb +130 -0
- data/lib/textus/ports/sentinel_store.rb +2 -2
- data/lib/textus/ports/watcher_lock.rb +48 -0
- data/lib/textus/projection.rb +8 -8
- data/lib/textus/role.rb +1 -1
- data/lib/textus/schema/tools.rb +4 -3
- data/lib/textus/session.rb +6 -3
- data/lib/textus/step/base.rb +35 -0
- data/lib/textus/step/builtin/csv_fetch.rb +19 -0
- data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
- data/lib/textus/step/builtin/json_fetch.rb +18 -0
- data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
- data/lib/textus/step/builtin/rss_fetch.rb +26 -0
- data/lib/textus/step/builtin.rb +22 -0
- data/lib/textus/{hooks → step}/catalog.rb +3 -4
- data/lib/textus/{hooks → step}/context.rb +15 -13
- data/lib/textus/step/discovery.rb +24 -0
- data/lib/textus/{hooks → step}/error_log.rb +1 -1
- data/lib/textus/{hooks → step}/event_bus.rb +15 -16
- data/lib/textus/step/fetch.rb +13 -0
- data/lib/textus/{hooks → step}/fire_report.rb +1 -1
- data/lib/textus/step/loader.rb +108 -0
- data/lib/textus/step/observe.rb +31 -0
- data/lib/textus/step/registry_store.rb +66 -0
- data/lib/textus/{hooks → step}/signature.rb +1 -1
- data/lib/textus/step/transform.rb +12 -0
- data/lib/textus/step/validate.rb +11 -0
- data/lib/textus/step.rb +10 -0
- data/lib/textus/store.rb +17 -15
- data/lib/textus/surfaces/cli/group/data.rb +11 -0
- data/lib/textus/surfaces/cli/group/key.rb +11 -0
- data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
- data/lib/textus/surfaces/cli/group/rule.rb +11 -0
- data/lib/textus/surfaces/cli/group/schema.rb +11 -0
- data/lib/textus/surfaces/cli/group.rb +50 -0
- data/lib/textus/surfaces/cli/runner.rb +236 -0
- data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
- data/lib/textus/surfaces/cli/verb/get.rb +21 -0
- data/lib/textus/surfaces/cli/verb/init.rb +20 -0
- data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
- data/lib/textus/surfaces/cli/verb/put.rb +30 -0
- data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
- data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
- data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
- data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
- data/lib/textus/surfaces/cli/verb.rb +111 -0
- data/lib/textus/surfaces/cli.rb +148 -0
- data/lib/textus/surfaces/mcp/catalog.rb +99 -0
- data/lib/textus/surfaces/mcp/errors.rb +34 -0
- data/lib/textus/surfaces/mcp/server.rb +145 -0
- data/lib/textus/surfaces/mcp/session.rb +9 -0
- data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
- data/lib/textus/surfaces/mcp.rb +8 -0
- data/lib/textus/surfaces/role_scope.rb +38 -0
- data/lib/textus/surfaces/watcher.rb +38 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +64 -22
- metadata +133 -107
- data/lib/textus/cli/group/hook.rb +0 -9
- data/lib/textus/cli/group/key.rb +0 -9
- data/lib/textus/cli/group/mcp.rb +0 -9
- data/lib/textus/cli/group/rule.rb +0 -9
- data/lib/textus/cli/group/schema.rb +0 -9
- data/lib/textus/cli/group/zone.rb +0 -9
- data/lib/textus/cli/group.rb +0 -48
- data/lib/textus/cli/runner.rb +0 -193
- data/lib/textus/cli/verb/doctor.rb +0 -17
- data/lib/textus/cli/verb/get.rb +0 -18
- data/lib/textus/cli/verb/hook_run.rb +0 -48
- data/lib/textus/cli/verb/hooks.rb +0 -50
- data/lib/textus/cli/verb/init.rb +0 -18
- data/lib/textus/cli/verb/mcp_serve.rb +0 -22
- data/lib/textus/cli/verb/put.rb +0 -30
- data/lib/textus/cli/verb/schema_diff.rb +0 -15
- data/lib/textus/cli/verb/schema_init.rb +0 -19
- data/lib/textus/cli/verb/schema_migrate.rb +0 -19
- data/lib/textus/cli/verb.rb +0 -116
- data/lib/textus/cli.rb +0 -138
- data/lib/textus/dispatcher.rb +0 -52
- data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
- data/lib/textus/domain/action.rb +0 -9
- data/lib/textus/domain/permission.rb +0 -7
- data/lib/textus/domain/policy/base_guards.rb +0 -25
- data/lib/textus/domain/policy/evaluation.rb +0 -15
- data/lib/textus/domain/policy/guard.rb +0 -35
- data/lib/textus/domain/policy/guard_factory.rb +0 -40
- data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
- data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
- data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
- data/lib/textus/domain/policy/predicates/registry.rb +0 -39
- data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
- data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
- data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
- data/lib/textus/hooks/builtin.rb +0 -70
- data/lib/textus/hooks/loader.rb +0 -54
- data/lib/textus/hooks/rpc_registry.rb +0 -43
- data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
- data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
- data/lib/textus/maintenance/reconcile.rb +0 -160
- data/lib/textus/maintenance/rule_lint.rb +0 -66
- data/lib/textus/maintenance/zone_mv.rb +0 -64
- data/lib/textus/maintenance.rb +0 -15
- data/lib/textus/mcp/catalog.rb +0 -70
- data/lib/textus/mcp/errors.rb +0 -32
- data/lib/textus/mcp/server.rb +0 -138
- data/lib/textus/mcp/session.rb +0 -7
- data/lib/textus/mcp/tool_schemas.rb +0 -15
- data/lib/textus/mcp.rb +0 -6
- data/lib/textus/mustache.rb +0 -117
- data/lib/textus/ports/produce_on_write_subscriber.rb +0 -69
- data/lib/textus/produce/events.rb +0 -36
- data/lib/textus/read/audit.rb +0 -130
- data/lib/textus/read/boot.rb +0 -26
- data/lib/textus/read/capabilities.rb +0 -70
- data/lib/textus/read/deps.rb +0 -38
- data/lib/textus/read/doctor.rb +0 -27
- data/lib/textus/read/freshness.rb +0 -152
- data/lib/textus/read/get.rb +0 -73
- data/lib/textus/read/list.rb +0 -24
- data/lib/textus/read/published.rb +0 -22
- data/lib/textus/read/pulse.rb +0 -98
- data/lib/textus/read/rdeps.rb +0 -39
- data/lib/textus/read/rule_explain.rb +0 -96
- data/lib/textus/read/rule_list.rb +0 -54
- data/lib/textus/read/schema_envelope.rb +0 -25
- data/lib/textus/read/uid.rb +0 -29
- data/lib/textus/read/validate_all.rb +0 -36
- data/lib/textus/read/where.rb +0 -24
- data/lib/textus/role_scope.rb +0 -78
- data/lib/textus/write/accept.rb +0 -58
- data/lib/textus/write/key_delete.rb +0 -65
- data/lib/textus/write/key_mv.rb +0 -141
- data/lib/textus/write/propose.rb +0 -54
- data/lib/textus/write/put.rb +0 -74
- data/lib/textus/write/reject.rb +0 -68
data/lib/textus/hooks/builtin.rb
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
require "json"
|
|
2
|
-
require "csv"
|
|
3
|
-
require "yaml"
|
|
4
|
-
require "rexml/document"
|
|
5
|
-
|
|
6
|
-
module Textus
|
|
7
|
-
module Hooks
|
|
8
|
-
module Builtin
|
|
9
|
-
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
10
|
-
def self.register_all(events:, rpc:) # rubocop:disable Lint/UnusedMethodArgument
|
|
11
|
-
rpc.register(:resolve_handler, :json) do |caps:, config:, args:|
|
|
12
|
-
_ = caps
|
|
13
|
-
_ = args
|
|
14
|
-
data = JSON.parse(config["bytes"].to_s)
|
|
15
|
-
{ _meta: {}, body: YAML.dump(data) }
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
rpc.register(:resolve_handler, :csv) do |caps:, config:, args:|
|
|
19
|
-
_ = caps
|
|
20
|
-
_ = args
|
|
21
|
-
rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
|
|
22
|
-
{ _meta: {}, body: YAML.dump(rows) }
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
rpc.register(:resolve_handler, :"markdown-links") do |caps:, config:, args:|
|
|
26
|
-
_ = caps
|
|
27
|
-
_ = args
|
|
28
|
-
links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
|
|
29
|
-
{ "text" => text, "href" => href }
|
|
30
|
-
end
|
|
31
|
-
{ _meta: {}, body: YAML.dump(links) }
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
rpc.register(:resolve_handler, :"ical-events") do |caps:, config:, args:|
|
|
35
|
-
_ = caps
|
|
36
|
-
_ = args
|
|
37
|
-
events_list = []
|
|
38
|
-
current = nil
|
|
39
|
-
config["bytes"].to_s.each_line do |line|
|
|
40
|
-
line = line.strip
|
|
41
|
-
case line
|
|
42
|
-
when "BEGIN:VEVENT" then current = {}
|
|
43
|
-
when "END:VEVENT"
|
|
44
|
-
events_list << current if current
|
|
45
|
-
current = nil
|
|
46
|
-
when /\A(SUMMARY|DTSTART|DTEND|UID|LOCATION|DESCRIPTION):(.*)\z/
|
|
47
|
-
current[Regexp.last_match(1).downcase] = Regexp.last_match(2) if current
|
|
48
|
-
end
|
|
49
|
-
end
|
|
50
|
-
{ _meta: {}, body: YAML.dump(events_list) }
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
rpc.register(:resolve_handler, :rss) do |caps:, config:, args:|
|
|
54
|
-
_ = caps
|
|
55
|
-
_ = args
|
|
56
|
-
doc = REXML::Document.new(config["bytes"].to_s)
|
|
57
|
-
items = doc.elements.to_a("//item").map do |item|
|
|
58
|
-
{
|
|
59
|
-
"title" => item.elements["title"]&.text,
|
|
60
|
-
"link" => item.elements["link"]&.text,
|
|
61
|
-
"pubDate" => item.elements["pubDate"]&.text,
|
|
62
|
-
}
|
|
63
|
-
end
|
|
64
|
-
{ _meta: {}, body: YAML.dump(items) }
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
# rubocop:enable Metrics/AbcSize, Metrics/MethodLength
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
data/lib/textus/hooks/loader.rb
DELETED
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Hooks
|
|
3
|
-
class Loader
|
|
4
|
-
# A small DSL object passed to user hook blocks. Routes `.on(...)` to the
|
|
5
|
-
# EventBus and `.rpc(...)` / `.register(...)` to the RpcRegistry.
|
|
6
|
-
class Dsl
|
|
7
|
-
def initialize(events:, rpc:)
|
|
8
|
-
@events = events
|
|
9
|
-
@rpc = rpc
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
# Pubsub registration — delegates to EventBus.
|
|
13
|
-
# Also handles RPC event names by delegating to RpcRegistry.
|
|
14
|
-
def on(event, name, keys: nil, &)
|
|
15
|
-
if Hooks::Catalog::RPC.key?(event.to_sym)
|
|
16
|
-
@rpc.register(event, name, &)
|
|
17
|
-
else
|
|
18
|
-
@events.register(event, name, keys: keys, &)
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
# Explicit RPC registration.
|
|
23
|
-
def register(event, name, &)
|
|
24
|
-
@rpc.register(event, name, &)
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def initialize(events:, rpc:)
|
|
29
|
-
@events = events
|
|
30
|
-
@rpc = rpc
|
|
31
|
-
@dsl = Dsl.new(events: @events, rpc: @rpc)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def load_dir(dir)
|
|
35
|
-
return unless File.directory?(dir)
|
|
36
|
-
|
|
37
|
-
# Discard any leftover blocks from a prior partial load.
|
|
38
|
-
Textus.drain_hook_blocks
|
|
39
|
-
|
|
40
|
-
Dir.glob(File.join(dir, "**/*.rb")).sort.each do |f| # rubocop:disable Lint/RedundantDirGlobSort
|
|
41
|
-
load(f)
|
|
42
|
-
rescue StandardError, ScriptError => e
|
|
43
|
-
raise UsageError.new("failed loading hook #{File.basename(f)}: #{e.class}: #{e.message}")
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
Textus.drain_hook_blocks.each do |blk|
|
|
47
|
-
blk.call(@dsl)
|
|
48
|
-
rescue StandardError, ScriptError => e
|
|
49
|
-
raise UsageError.new("failed registering hook: #{e.class}: #{e.message}")
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
|
54
|
-
end
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Hooks
|
|
5
|
-
class RpcRegistry
|
|
6
|
-
def initialize
|
|
7
|
-
@table = Hash.new { |h, k| h[k] = {} }
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
def register(event, name, &blk)
|
|
11
|
-
event_sym = event.to_sym
|
|
12
|
-
raise UsageError.new("#{event_sym} is a pubsub event; register on EventBus") if Catalog::PUBSUB.key?(event_sym)
|
|
13
|
-
|
|
14
|
-
required = Catalog::RPC[event_sym] or raise UsageError.new("unknown RPC event: #{event}")
|
|
15
|
-
sig = Signature.new(blk)
|
|
16
|
-
missing = sig.missing(required)
|
|
17
|
-
raise UsageError.new("#{event_sym} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})") if missing.any?
|
|
18
|
-
|
|
19
|
-
name = name.to_sym
|
|
20
|
-
raise UsageError.new("#{event_sym} '#{name}' already registered") if @table[event_sym].key?(name)
|
|
21
|
-
|
|
22
|
-
@table[event_sym][name] = blk
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def names(event) = @table[event.to_sym].keys
|
|
26
|
-
|
|
27
|
-
def callable(event, name)
|
|
28
|
-
@table[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Invoke a registered callable, injecting `caps:` only if the callable
|
|
32
|
-
# declares it (or accepts keyrest). Mis-named kwargs (e.g. the legacy
|
|
33
|
-
# `store:`) are rejected at registration time, not here.
|
|
34
|
-
def invoke(event, name, caps:, **other)
|
|
35
|
-
blk = callable(event, name)
|
|
36
|
-
sig = Signature.new(blk)
|
|
37
|
-
kwargs = other.dup
|
|
38
|
-
kwargs[:caps] = caps if sig.accepts_keyrest? || sig.declared_keys.include?(:caps)
|
|
39
|
-
blk.call(**kwargs)
|
|
40
|
-
end
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Maintenance
|
|
3
|
-
# Bulk-delete every leaf key under `prefix`.
|
|
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, :mcp
|
|
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: false,
|
|
13
|
-
description: "when true, returns the keys that would be deleted without deleting them; " \
|
|
14
|
-
"defaults to false, so omitting it deletes immediately"
|
|
15
|
-
view { |v, _i| v.to_h }
|
|
16
|
-
|
|
17
|
-
def initialize(container:, call:)
|
|
18
|
-
@container = container
|
|
19
|
-
@call = call
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def call(prefix, dry_run: false)
|
|
23
|
-
raise UsageError.new("prefix required") if prefix.nil? || prefix.empty?
|
|
24
|
-
|
|
25
|
-
leaves = Read::List.new(container: @container)
|
|
26
|
-
.call(prefix: prefix)
|
|
27
|
-
.map { |r| r.is_a?(Hash) ? (r["key"] || r[:key]) : r }
|
|
28
|
-
|
|
29
|
-
warnings = leaves.empty? ? ["no keys under #{prefix}"] : []
|
|
30
|
-
steps = leaves.map { |k| { "op" => "delete", "key" => k } }
|
|
31
|
-
|
|
32
|
-
plan = Plan.new(steps: steps, warnings: warnings)
|
|
33
|
-
return plan if dry_run
|
|
34
|
-
|
|
35
|
-
steps.each do |s|
|
|
36
|
-
delete.call(s["key"])
|
|
37
|
-
end
|
|
38
|
-
plan
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
private
|
|
42
|
-
|
|
43
|
-
def delete
|
|
44
|
-
Write::KeyDelete.new(container: @container, call: @call)
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Maintenance
|
|
3
|
-
# Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
|
|
4
|
-
# Calls Write::KeyMv directly for each entry — emits one audit row per file moved.
|
|
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, :mcp
|
|
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: false,
|
|
15
|
-
description: "when true, returns the planned moves without applying them; " \
|
|
16
|
-
"defaults to false, so omitting it applies the rename immediately"
|
|
17
|
-
view { |v, _i| v.to_h }
|
|
18
|
-
|
|
19
|
-
def initialize(container:, call:)
|
|
20
|
-
@container = container
|
|
21
|
-
@call = call
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def call(from_prefix, to_prefix, dry_run: false)
|
|
25
|
-
raise UsageError.new("from_prefix and to_prefix required") if from_prefix.nil? || to_prefix.nil?
|
|
26
|
-
|
|
27
|
-
leaves = list_leaves_under(from_prefix)
|
|
28
|
-
|
|
29
|
-
# When from_prefix is itself a leaf, `delete_prefix("#{from_prefix}.")`
|
|
30
|
-
# finds no trailing dot to strip, so the tail keeps the whole key and the
|
|
31
|
-
# move silently targets "to_prefix.<full-from_prefix>". Refuse it — a
|
|
32
|
-
# single-key rename is `mv`'s job, not the bulk prefix verb's.
|
|
33
|
-
if leaves.include?(from_prefix)
|
|
34
|
-
raise UsageError.new("from_prefix '#{from_prefix}' is itself a leaf — use `mv` to rename a single key")
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
warnings = []
|
|
38
|
-
warnings << "no keys under #{from_prefix}" if leaves.empty?
|
|
39
|
-
|
|
40
|
-
steps = leaves.map do |old_key|
|
|
41
|
-
tail = old_key.delete_prefix("#{from_prefix}.")
|
|
42
|
-
new_key = "#{to_prefix}.#{tail}"
|
|
43
|
-
{ "op" => "mv", "from" => old_key, "to" => new_key }
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
plan = Plan.new(steps: steps, warnings: warnings)
|
|
47
|
-
return plan if dry_run
|
|
48
|
-
|
|
49
|
-
steps.each do |s|
|
|
50
|
-
mv.call(s["from"], s["to"], dry_run: false)
|
|
51
|
-
end
|
|
52
|
-
plan
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
private
|
|
56
|
-
|
|
57
|
-
def list_leaves_under(prefix)
|
|
58
|
-
Read::List.new(container: @container)
|
|
59
|
-
.call(prefix: prefix)
|
|
60
|
-
.map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def mv
|
|
64
|
-
Write::KeyMv.new(container: @container, call: @call)
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
end
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
require "fileutils"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Maintenance
|
|
5
|
-
# Two-phase convergence pass (ADR 0093). Replaces the old Lifecycle-reporter
|
|
6
|
-
# sweep.
|
|
7
|
-
#
|
|
8
|
-
# Phase 1 — Produce (non-destructive): re-render ALL derived entries (cheap,
|
|
9
|
-
# idempotent) plus every intake entry past its source.ttl (stale-only, so
|
|
10
|
-
# external sources are not hammered). Driven by Produce::Engine.
|
|
11
|
-
#
|
|
12
|
-
# Phase 2 — Retention sweep (destructive): drop or archive entries past their
|
|
13
|
-
# retention ttl. Driven by Domain::Retention::Sweep. The old refresh/warn
|
|
14
|
-
# actions are gone — intake re-pull is now Produce's responsibility.
|
|
15
|
-
class Reconcile
|
|
16
|
-
extend Textus::Contract::DSL
|
|
17
|
-
|
|
18
|
-
verb :reconcile
|
|
19
|
-
summary "Run the convergence pass: produce derived + stale intake, then drop/archive aged entries; report health."
|
|
20
|
-
surfaces :cli, :mcp
|
|
21
|
-
cli "reconcile"
|
|
22
|
-
arg :prefix, String, description: "restrict the sweep to keys under this dotted prefix"
|
|
23
|
-
arg :zone, String, description: "restrict the sweep to entries in this zone"
|
|
24
|
-
arg :dry_run, :boolean, default: false,
|
|
25
|
-
description: "when true, report what the pass WOULD do without applying; " \
|
|
26
|
-
"defaults to false, so omitting it produces + drops/archives immediately"
|
|
27
|
-
|
|
28
|
-
def initialize(container:, call:)
|
|
29
|
-
@container = container
|
|
30
|
-
@call = call
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def call(prefix: nil, zone: nil, dry_run: false)
|
|
34
|
-
file_stat = Textus::Ports::Storage::FileStat.new
|
|
35
|
-
retention_rows = Textus::Domain::Retention::Sweep.new(
|
|
36
|
-
manifest: @container.manifest, file_stat: file_stat, clock: Textus::Ports::Clock.new,
|
|
37
|
-
).call(prefix: prefix, zone: zone)
|
|
38
|
-
|
|
39
|
-
produce_keys = produce_scope(prefix, zone, file_stat)
|
|
40
|
-
health = Read::Doctor.new(container: @container, call: @call).call
|
|
41
|
-
return dry_run_result(produce_keys, retention_rows, health) if dry_run
|
|
42
|
-
|
|
43
|
-
# reconcile is the authoritative "make everything current now" pass, so
|
|
44
|
-
# it subsumes any in-flight reactive produce: drain pending async
|
|
45
|
-
# produce-on-write threads first, both to fold their work in and to free
|
|
46
|
-
# the shared maintenance lock (BuildLock is non-blocking — a thread still
|
|
47
|
-
# holding it would make the acquire below raise BuildInProgress). ADR 0093.
|
|
48
|
-
Textus::Produce::Engine::AsyncRunner.drain
|
|
49
|
-
|
|
50
|
-
Textus::Ports::BuildLock.with(root: @container.root) do
|
|
51
|
-
produced = Textus::Produce::Engine.new(container: @container, call: @call).call(keys: produce_keys)
|
|
52
|
-
swept = apply(retention_rows)
|
|
53
|
-
publish_failed(swept[:failed]) unless swept[:failed].empty?
|
|
54
|
-
apply_result(produced, swept, health)
|
|
55
|
-
end
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
private
|
|
59
|
-
|
|
60
|
-
# The full produce scope (ADR 0093): every derived entry (always
|
|
61
|
-
# re-render — cheap, idempotent), every entry that mirrors a publish_tree
|
|
62
|
-
# (the nested-subtree publishers, ADR 0047 — mirrored each pass so a
|
|
63
|
-
# removed source leaf is swept from the published tree), every authored
|
|
64
|
-
# leaf with a `publish.to` target (the single-file canon publishers —
|
|
65
|
-
# docs/README.md, the architecture index, the root README; ADR 0103 —
|
|
66
|
-
# converged each pass so a stale published copy is rewritten and the
|
|
67
|
-
# `reconcile`-is-a-no-op check guards them), plus every intake entry past
|
|
68
|
-
# its source.ttl (re-pull only when due, so external sources aren't
|
|
69
|
-
# hammered). Ttl-less intake entries (:no_policy) are skipped — they have
|
|
70
|
-
# no freshness contract and are never auto-re-pulled (ADR 0099). All are
|
|
71
|
-
# idempotent: publish writes only when the target's content changed.
|
|
72
|
-
def produce_scope(prefix, zone, file_stat)
|
|
73
|
-
publishable = @container.manifest.data.entries
|
|
74
|
-
.select { |e| e.derived? || !e.publish_tree.nil? || !e.publish_to.empty? }
|
|
75
|
-
.select { |e| in_scope?(e, prefix, zone) }.map(&:key)
|
|
76
|
-
stale_intake = Textus::Domain::Freshness::Evaluator.new(
|
|
77
|
-
manifest: @container.manifest, file_stat: file_stat, clock: Textus::Ports::Clock.new,
|
|
78
|
-
).stale_intake_keys(prefix: prefix, zone: zone)
|
|
79
|
-
(publishable + stale_intake).uniq
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def in_scope?(entry, prefix, zone)
|
|
83
|
-
return false if zone && entry.zone != zone
|
|
84
|
-
return false if prefix && !entry.key.start_with?(prefix)
|
|
85
|
-
|
|
86
|
-
true
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
def dry_run_result(produce_keys, rows, health)
|
|
90
|
-
{
|
|
91
|
-
"protocol" => Textus::PROTOCOL, "ok" => true, "dry_run" => true,
|
|
92
|
-
"would_produce" => produce_keys,
|
|
93
|
-
"would_drop" => action_keys(rows, "drop"),
|
|
94
|
-
"would_archive" => action_keys(rows, "archive"),
|
|
95
|
-
"health" => health
|
|
96
|
-
}
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def apply_result(produced, swept, health)
|
|
100
|
-
{
|
|
101
|
-
"protocol" => Textus::PROTOCOL,
|
|
102
|
-
"ok" => produced[:failed].empty? && swept[:failed].empty?,
|
|
103
|
-
"dry_run" => false,
|
|
104
|
-
"produced" => produced[:produced],
|
|
105
|
-
"produce_failed" => produced[:failed],
|
|
106
|
-
"dropped" => swept[:dropped], "archived" => swept[:archived],
|
|
107
|
-
"failed" => swept[:failed],
|
|
108
|
-
"health" => health
|
|
109
|
-
}
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def action_keys(rows, action)
|
|
113
|
-
rows.select { |r| r["action"] == action }.map { |r| r["key"] }
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def publish_failed(failed)
|
|
117
|
-
@container.events.publish(
|
|
118
|
-
:reconcile_failed,
|
|
119
|
-
ctx: Textus::Hooks::Context.for(container: @container, call: @call),
|
|
120
|
-
failed: failed,
|
|
121
|
-
)
|
|
122
|
-
end
|
|
123
|
-
|
|
124
|
-
# Phase 2: destructive retention only (drop/archive). No refresh — intake
|
|
125
|
-
# re-pull is Produce's job (Phase 1). ADR 0093.
|
|
126
|
-
def apply(rows)
|
|
127
|
-
out = { dropped: [], archived: [], failed: [] }
|
|
128
|
-
delete = Write::KeyDelete.new(container: @container, call: @call)
|
|
129
|
-
rows.each do |row|
|
|
130
|
-
key = row["key"]
|
|
131
|
-
begin
|
|
132
|
-
case row["action"]
|
|
133
|
-
when "drop"
|
|
134
|
-
delete.call(key)
|
|
135
|
-
out[:dropped] << key
|
|
136
|
-
when "archive"
|
|
137
|
-
archive_leaf(row)
|
|
138
|
-
delete.call(key)
|
|
139
|
-
out[:archived] << key
|
|
140
|
-
end
|
|
141
|
-
rescue Textus::Error => e
|
|
142
|
-
out[:failed] << { "key" => key, "error" => e.message }
|
|
143
|
-
end
|
|
144
|
-
end
|
|
145
|
-
out
|
|
146
|
-
end
|
|
147
|
-
|
|
148
|
-
# Copy the leaf into <store>/archive/<relative-path> before deletion.
|
|
149
|
-
# (Lifted from the retired RetentionSweep#archive_leaf.)
|
|
150
|
-
def archive_leaf(row)
|
|
151
|
-
src = row["path"]
|
|
152
|
-
root = @container.root.to_s
|
|
153
|
-
rel = src.delete_prefix("#{root}/")
|
|
154
|
-
dest = File.join(root, "archive", rel)
|
|
155
|
-
FileUtils.mkdir_p(File.dirname(dest))
|
|
156
|
-
FileUtils.cp(src, dest)
|
|
157
|
-
end
|
|
158
|
-
end
|
|
159
|
-
end
|
|
160
|
-
end
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
require "yaml"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Maintenance
|
|
5
|
-
# Compare the live manifest's `rules:` block against a candidate
|
|
6
|
-
# YAML string. Returns a Plan describing rule additions/removals/
|
|
7
|
-
# changes. Does NOT write anything.
|
|
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, :mcp
|
|
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 }
|
|
18
|
-
|
|
19
|
-
def initialize(container:, call:)
|
|
20
|
-
@container = container
|
|
21
|
-
@call = call
|
|
22
|
-
@root = container.root
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def call(candidate_yaml:)
|
|
26
|
-
live_rules = current_rules
|
|
27
|
-
candidate_rules = parse_candidate(candidate_yaml)
|
|
28
|
-
|
|
29
|
-
live_by_match = live_rules.to_h { |r| [r["match"], r] }
|
|
30
|
-
candidate_by_match = candidate_rules.to_h { |r| [r["match"], r] }
|
|
31
|
-
|
|
32
|
-
steps = (candidate_by_match.keys - live_by_match.keys).map do |m|
|
|
33
|
-
{ "op" => "add_rule", "match" => m, "rule" => candidate_by_match[m] }
|
|
34
|
-
end
|
|
35
|
-
(live_by_match.keys - candidate_by_match.keys).each do |m|
|
|
36
|
-
steps << { "op" => "remove_rule", "match" => m }
|
|
37
|
-
end
|
|
38
|
-
(live_by_match.keys & candidate_by_match.keys).each do |m|
|
|
39
|
-
next if live_by_match[m] == candidate_by_match[m]
|
|
40
|
-
|
|
41
|
-
steps << { "op" => "change_rule", "match" => m,
|
|
42
|
-
"from" => live_by_match[m], "to" => candidate_by_match[m] }
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
Plan.new(steps: steps, warnings: [])
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
private
|
|
49
|
-
|
|
50
|
-
def current_rules
|
|
51
|
-
raw = YAML.safe_load_file(File.join(@root, "manifest.yaml"),
|
|
52
|
-
permitted_classes: [Symbol], aliases: false)
|
|
53
|
-
Array(raw["rules"])
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def parse_candidate(yaml_text)
|
|
57
|
-
raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
|
|
58
|
-
raise UsageError.new("candidate is not a YAML mapping") unless raw.is_a?(Hash)
|
|
59
|
-
|
|
60
|
-
Array(raw["rules"])
|
|
61
|
-
rescue Psych::Exception => e
|
|
62
|
-
raise UsageError.new("candidate YAML parse error: #{e.message}")
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
require "yaml"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Maintenance
|
|
5
|
-
# Rename a zone — rewrites the manifest's zones[] entry, rewrites
|
|
6
|
-
# the `zone:` field on every entry under the old zone, and moves
|
|
7
|
-
# every file from zones/<old>/ to zones/<new>/.
|
|
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, :mcp
|
|
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: false,
|
|
18
|
-
description: "when true, returns the planned zone move without applying it; " \
|
|
19
|
-
"defaults to false, so omitting it applies the move immediately"
|
|
20
|
-
view { |v, _i| v.to_h }
|
|
21
|
-
|
|
22
|
-
def initialize(container:, call:)
|
|
23
|
-
@container = container
|
|
24
|
-
@call = call
|
|
25
|
-
@manifest = container.manifest
|
|
26
|
-
@root = container.root
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def call(from, to, dry_run: false)
|
|
30
|
-
raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
|
|
31
|
-
raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.declared_zone_kinds.key?(from)
|
|
32
|
-
|
|
33
|
-
dest_dir = File.join(@root, "zones", to)
|
|
34
|
-
raise UsageError.new("destination 'zones/#{to}' already exists") if File.exist?(dest_dir)
|
|
35
|
-
|
|
36
|
-
affected_keys = @manifest.data.entries.select { |e| e.zone == from }.map(&:key)
|
|
37
|
-
|
|
38
|
-
steps = [{ "op" => "rename_zone", "from" => from, "to" => to }]
|
|
39
|
-
steps += affected_keys.map { |k| { "op" => "mv", "from" => k, "to" => "#{to}#{k[from.length..]}" } }
|
|
40
|
-
|
|
41
|
-
plan = Plan.new(steps: steps, warnings: [])
|
|
42
|
-
return plan if dry_run
|
|
43
|
-
|
|
44
|
-
rewrite_manifest!(from, to)
|
|
45
|
-
FileUtils.mv(File.join(@root, "zones", from), dest_dir)
|
|
46
|
-
plan
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
private
|
|
50
|
-
|
|
51
|
-
def rewrite_manifest!(from, to)
|
|
52
|
-
path = File.join(@root, "manifest.yaml")
|
|
53
|
-
raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
|
|
54
|
-
raw["zones"].each { |z| z["name"] = to if z["name"] == from }
|
|
55
|
-
raw["entries"].each do |e|
|
|
56
|
-
e["zone"] = to if e["zone"] == from
|
|
57
|
-
e["key"] = e["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
|
|
58
|
-
e["path"] = e["path"].sub(%r{\A#{Regexp.escape(from)}(/|\z)}, "#{to}\\1")
|
|
59
|
-
end
|
|
60
|
-
File.write(path, YAML.dump(raw))
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
end
|
data/lib/textus/maintenance.rb
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
# Bulk and structural changes to a textus store. Each use case returns
|
|
3
|
-
# a Plan when called with dry_run: true, and applies the plan when
|
|
4
|
-
# called with dry_run: false.
|
|
5
|
-
module Maintenance
|
|
6
|
-
# A Plan is a JSON-shaped preview. Steps are op-tagged hashes the
|
|
7
|
-
# use case knows how to apply. Warnings are strings surfaced to
|
|
8
|
-
# the operator (skipped keys, ambiguities).
|
|
9
|
-
Plan = Data.define(:steps, :warnings) do
|
|
10
|
-
def to_h
|
|
11
|
-
{ "steps" => steps, "warnings" => warnings }
|
|
12
|
-
end
|
|
13
|
-
end
|
|
14
|
-
end
|
|
15
|
-
end
|
data/lib/textus/mcp/catalog.rb
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
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 default view. 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| mcp_surfaced?(k) }
|
|
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
|
-
# MCP-surfaced read verbs, by Dispatcher class namespace — the agent's
|
|
29
|
-
# real read/discovery surface. `boot.agent_quickstart.read_verbs` derives
|
|
30
|
-
# from this so it can never advertise a verb the agent cannot call, nor
|
|
31
|
-
# omit one it can (ADR 0056). Excludes Write/Maintenance.
|
|
32
|
-
def read_verbs
|
|
33
|
-
Textus::Dispatcher::VERBS
|
|
34
|
-
.select { |_verb, klass| mcp_surfaced?(klass) && klass.name.start_with?("Textus::Read::") }
|
|
35
|
-
.keys.map(&:to_s)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
# MCP-surfaced write verbs, by Dispatcher class namespace — the mirror of
|
|
39
|
-
# read_verbs for the write side. `boot.agent_quickstart.write_verbs` derives
|
|
40
|
-
# from this so it advertises bare verb names the agent can call (no `--as`/
|
|
41
|
-
# `--stdin` CLI framing), finishing the de-CLI-ing of the agent surface
|
|
42
|
-
# (ADR 0056, ADR 0057).
|
|
43
|
-
def write_verbs
|
|
44
|
-
Textus::Dispatcher::VERBS
|
|
45
|
-
.select { |_verb, klass| mcp_surfaced?(klass) && klass.name.start_with?("Textus::Write::") }
|
|
46
|
-
.keys.map(&:to_s)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def mcp_surfaced?(klass)
|
|
50
|
-
klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
def call(name, session:, store:, args:)
|
|
54
|
-
klass = Textus::Dispatcher::VERBS[name.to_sym]
|
|
55
|
-
raise ToolError.new("unknown tool: #{name}") unless klass && mcp_surfaced?(klass)
|
|
56
|
-
|
|
57
|
-
spec = klass.contract
|
|
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(", ")}")
|
|
63
|
-
rescue ContractDrift, CursorExpired
|
|
64
|
-
raise
|
|
65
|
-
rescue Textus::Error => e
|
|
66
|
-
raise ToolError.new("#{name}: #{e.message}")
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|