textus 0.43.2 → 0.46.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 +70 -0
- data/README.md +56 -29
- data/SPEC.md +24 -22
- data/docs/architecture/README.md +32 -32
- data/docs/reference/conventions.md +8 -9
- data/lib/textus/boot.rb +4 -4
- data/lib/textus/builder/pipeline.rb +11 -42
- data/lib/textus/builder/renderer/markdown.rb +4 -8
- 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 +37 -3
- data/lib/textus/container.rb +3 -15
- 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 +6 -6
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +1 -1
- data/lib/textus/domain/policy/predicates/fresh_within.rb +6 -5
- data/lib/textus/envelope/io/writer.rb +34 -0
- data/lib/textus/hooks/context.rb +24 -2
- data/lib/textus/layout.rb +8 -0
- data/lib/textus/maintenance/key_delete_prefix.rb +8 -5
- data/lib/textus/maintenance/key_mv_prefix.rb +18 -6
- data/lib/textus/maintenance/migrate.rb +14 -10
- data/lib/textus/maintenance/rule_lint.rb +5 -4
- data/lib/textus/maintenance/zone_mv.rb +9 -6
- data/lib/textus/manifest/entry/base.rb +1 -1
- data/lib/textus/mcp/catalog.rb +6 -33
- data/lib/textus/ports/publisher.rb +3 -2
- data/lib/textus/ports/sentinel_store.rb +8 -7
- data/lib/textus/projection.rb +6 -5
- data/lib/textus/read/audit.rb +19 -0
- data/lib/textus/read/blame.rb +11 -1
- data/lib/textus/read/boot.rb +1 -1
- data/lib/textus/read/capabilities.rb +70 -0
- 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 +87 -22
- data/lib/textus/read/list.rb +2 -1
- data/lib/textus/read/published.rb +7 -0
- data/lib/textus/read/pulse.rb +2 -1
- 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/store.rb +47 -24
- 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 +2 -1
- data/lib/textus/write/fetch_orchestrator.rb +1 -1
- data/lib/textus/write/fetch_worker.rb +2 -2
- data/lib/textus/write/mv.rb +16 -0
- data/lib/textus/write/propose.rb +8 -3
- data/lib/textus/write/put.rb +3 -3
- data/lib/textus/write/reject.rb +8 -0
- data/lib/textus/write/retention_sweep.rb +9 -0
- metadata +12 -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
|
@@ -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,15 @@ 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
|
-
|
|
39
|
+
capabilities: Textus::Read::Capabilities,
|
|
40
40
|
|
|
41
41
|
# Maintenance
|
|
42
42
|
migrate: Textus::Maintenance::Migrate,
|
|
@@ -8,7 +8,7 @@ module Textus
|
|
|
8
8
|
# that drift without making `build` scan globally.
|
|
9
9
|
class OrphanedPublishTargets < Check
|
|
10
10
|
def call
|
|
11
|
-
sdir =
|
|
11
|
+
sdir = Textus::Layout.sentinels(root)
|
|
12
12
|
return [] unless File.directory?(sdir)
|
|
13
13
|
|
|
14
14
|
repo_root = File.dirname(root)
|
|
@@ -5,7 +5,7 @@ module Textus
|
|
|
5
5
|
def call
|
|
6
6
|
store = Textus::Ports::SentinelStore.new
|
|
7
7
|
file_stat = Textus::Ports::Storage::FileStat.new
|
|
8
|
-
dir =
|
|
8
|
+
dir = Textus::Layout.sentinels(root)
|
|
9
9
|
return [] unless file_stat.directory?(dir)
|
|
10
10
|
|
|
11
11
|
repo_root = File.dirname(root)
|
|
@@ -35,13 +35,14 @@ module Textus
|
|
|
35
35
|
private
|
|
36
36
|
|
|
37
37
|
# Domain-pure: reads the stored write timestamp from the envelope's
|
|
38
|
-
# freshness (checked_at) or meta (last_fetched_at
|
|
39
|
-
#
|
|
40
|
-
#
|
|
38
|
+
# freshness (checked_at) or meta (last_fetched_at) and parses the
|
|
39
|
+
# stored ISO-8601 string. Parsing a stored string is not I/O (allowed
|
|
40
|
+
# in domain, ADR 0024). `generated_at` is intentionally NOT consulted:
|
|
41
|
+
# build-generation time is no longer carried in the artifact (ADR
|
|
42
|
+
# 0070), and fetch-freshness is a fetch concept, not a build one.
|
|
41
43
|
def written_at(envelope)
|
|
42
44
|
raw = envelope.freshness&.checked_at ||
|
|
43
|
-
envelope.meta&.dig("last_fetched_at")
|
|
44
|
-
envelope.meta&.dig("generated_at")
|
|
45
|
+
envelope.meta&.dig("last_fetched_at")
|
|
45
46
|
return raw if raw.is_a?(Time)
|
|
46
47
|
return nil if raw.nil?
|
|
47
48
|
|
|
@@ -82,6 +82,7 @@ module Textus
|
|
|
82
82
|
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
83
83
|
|
|
84
84
|
@file_store.delete(path)
|
|
85
|
+
prune_empty_parents(path)
|
|
85
86
|
@audit_log.append(
|
|
86
87
|
role: @call.role, verb: "delete", key: key,
|
|
87
88
|
etag_before: etag_before, etag_after: nil,
|
|
@@ -99,6 +100,7 @@ module Textus
|
|
|
99
100
|
|
|
100
101
|
FileUtils.mkdir_p(File.dirname(to_path))
|
|
101
102
|
FileUtils.mv(from_path, to_path)
|
|
103
|
+
prune_empty_parents(from_path)
|
|
102
104
|
basename = to_key.split(".").last
|
|
103
105
|
Entry.for_format(new_mentry.format).rewrite_name(to_path, basename)
|
|
104
106
|
etag_after = Etag.for_file(to_path)
|
|
@@ -129,6 +131,38 @@ module Textus
|
|
|
129
131
|
|
|
130
132
|
private
|
|
131
133
|
|
|
134
|
+
# After a file leaves a directory (delete or move-source), remove any
|
|
135
|
+
# now-empty parent dirs so bulk move/delete doesn't accrue orphan dirs
|
|
136
|
+
# (F3 of #161). Floored at the entry's *zone directory* — a zone is a
|
|
137
|
+
# declared, first-class container, so its own dir is preserved even when
|
|
138
|
+
# momentarily empty; only the sub-dirs the bulk op carved out are
|
|
139
|
+
# pruned. Stops at the first non-empty ancestor, so a dir holding a
|
|
140
|
+
# `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
|
|
141
|
+
# non-empty dir is silently fine, never fatal to the write.
|
|
142
|
+
def prune_empty_parents(path)
|
|
143
|
+
floor = zone_floor(path)
|
|
144
|
+
return unless floor
|
|
145
|
+
|
|
146
|
+
dir = File.dirname(path)
|
|
147
|
+
while dir.start_with?("#{floor}/") && Dir.empty?(dir)
|
|
148
|
+
Dir.rmdir(dir)
|
|
149
|
+
dir = File.dirname(dir)
|
|
150
|
+
end
|
|
151
|
+
rescue SystemCallError
|
|
152
|
+
nil
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# The zone directory under which `path` lives (`<root>/zones/<zone>`),
|
|
156
|
+
# or nil if `path` is not under the store's zones tree.
|
|
157
|
+
def zone_floor(path)
|
|
158
|
+
zones_root = File.join(@manifest.data.root, "zones")
|
|
159
|
+
prefix = "#{zones_root}/"
|
|
160
|
+
return nil unless path.start_with?(prefix)
|
|
161
|
+
|
|
162
|
+
zone_seg = path.delete_prefix(prefix).split("/").first
|
|
163
|
+
zone_seg && File.join(zones_root, zone_seg)
|
|
164
|
+
end
|
|
165
|
+
|
|
132
166
|
def ensure_uid(format, meta, content, existing_uid)
|
|
133
167
|
Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
|
|
134
168
|
end
|
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
|
data/lib/textus/layout.rb
CHANGED
|
@@ -29,6 +29,14 @@ module Textus
|
|
|
29
29
|
File.join(run(root), "audit")
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
+
# Sentinels are machine-generated (the published target's sha), not authored
|
|
33
|
+
# source, so they live on the runtime side under `.run/` — git-ignored,
|
|
34
|
+
# regenerated by the next build via content-identical adoption (ADR 0070,
|
|
35
|
+
# superseding ADR 0038's `:config` classification).
|
|
36
|
+
def self.sentinels(root)
|
|
37
|
+
File.join(run(root), "sentinels")
|
|
38
|
+
end
|
|
39
|
+
|
|
32
40
|
def self.audit_log(root)
|
|
33
41
|
File.join(audit_dir(root), "audit.log")
|
|
34
42
|
end
|
|
@@ -6,17 +6,20 @@ module Textus
|
|
|
6
6
|
|
|
7
7
|
verb :key_delete_prefix
|
|
8
8
|
summary "Bulk-delete every leaf key under prefix."
|
|
9
|
-
surfaces :cli, :
|
|
10
|
-
|
|
11
|
-
arg :
|
|
12
|
-
|
|
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 }
|
|
13
16
|
|
|
14
17
|
def initialize(container:, call:)
|
|
15
18
|
@container = container
|
|
16
19
|
@call = call
|
|
17
20
|
end
|
|
18
21
|
|
|
19
|
-
def call(prefix
|
|
22
|
+
def call(prefix, dry_run: false)
|
|
20
23
|
raise UsageError.new("prefix required") if prefix.nil? || prefix.empty?
|
|
21
24
|
|
|
22
25
|
leaves = Read::List.new(container: @container)
|
|
@@ -7,21 +7,33 @@ module Textus
|
|
|
7
7
|
|
|
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
|
-
surfaces :cli, :
|
|
11
|
-
|
|
12
|
-
arg :
|
|
13
|
-
arg :
|
|
14
|
-
|
|
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 }
|
|
15
18
|
|
|
16
19
|
def initialize(container:, call:)
|
|
17
20
|
@container = container
|
|
18
21
|
@call = call
|
|
19
22
|
end
|
|
20
23
|
|
|
21
|
-
def call(from_prefix
|
|
24
|
+
def call(from_prefix, to_prefix, dry_run: false)
|
|
22
25
|
raise UsageError.new("from_prefix and to_prefix required") if from_prefix.nil? || to_prefix.nil?
|
|
23
26
|
|
|
24
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
|
+
|
|
25
37
|
warnings = []
|
|
26
38
|
warnings << "no keys under #{from_prefix}" if leaves.empty?
|
|
27
39
|
|
|
@@ -9,18 +9,20 @@ module Textus
|
|
|
9
9
|
|
|
10
10
|
verb :migrate
|
|
11
11
|
summary "Run a YAML migration plan (multi-op)."
|
|
12
|
-
surfaces :cli, :
|
|
13
|
-
arg :plan_yaml, String, required: true,
|
|
14
|
-
description: "
|
|
15
|
-
arg :dry_run,
|
|
16
|
-
|
|
12
|
+
surfaces :cli, :mcp
|
|
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: false,
|
|
16
|
+
description: "when true, returns the planned ops without applying them; " \
|
|
17
|
+
"defaults to false, so omitting it runs the migration immediately"
|
|
18
|
+
view { |v, _i| v.to_h }
|
|
17
19
|
|
|
18
20
|
def initialize(container:, call:)
|
|
19
21
|
@container = container
|
|
20
22
|
@call = call
|
|
21
23
|
end
|
|
22
24
|
|
|
23
|
-
def call(plan_yaml
|
|
25
|
+
def call(plan_yaml, dry_run: false)
|
|
24
26
|
raw = YAML.safe_load(plan_yaml, permitted_classes: [Symbol], aliases: false)
|
|
25
27
|
raise UsageError.new("migration plan must be a YAML mapping") unless raw.is_a?(Hash)
|
|
26
28
|
|
|
@@ -41,11 +43,13 @@ module Textus
|
|
|
41
43
|
private
|
|
42
44
|
|
|
43
45
|
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
46
|
klass = op_class(op_name)
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
)
|
|
47
|
+
inputs = op_hash.except("op").transform_keys(&:to_sym).merge(dry_run: dry_run)
|
|
48
|
+
# Each op now carries positional args (from/to, from_prefix/to_prefix,
|
|
49
|
+
# prefix); split the YAML fields into (positional, keyword) via the op's
|
|
50
|
+
# own contract so we call its #call signature correctly (ADR 0066/0068).
|
|
51
|
+
args, kwargs = Textus::Contract::Binder.bind(klass.contract, inputs)
|
|
52
|
+
klass.new(container: @container, call: @call).call(*args, **kwargs)
|
|
49
53
|
end
|
|
50
54
|
|
|
51
55
|
def op_class(op_name)
|
|
@@ -10,10 +10,11 @@ module Textus
|
|
|
10
10
|
|
|
11
11
|
verb :rule_lint
|
|
12
12
|
summary "Diff candidate manifest YAML's rules against the live manifest. No writes."
|
|
13
|
-
surfaces :cli, :
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 }
|
|
17
18
|
|
|
18
19
|
def initialize(container:, call:)
|
|
19
20
|
@container = container
|
|
@@ -10,11 +10,14 @@ module Textus
|
|
|
10
10
|
|
|
11
11
|
verb :zone_mv
|
|
12
12
|
summary "Rename a zone — manifest + files. Refuses if destination exists."
|
|
13
|
-
surfaces :cli, :
|
|
14
|
-
|
|
15
|
-
arg :
|
|
16
|
-
arg :
|
|
17
|
-
|
|
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 }
|
|
18
21
|
|
|
19
22
|
def initialize(container:, call:)
|
|
20
23
|
@container = container
|
|
@@ -23,7 +26,7 @@ module Textus
|
|
|
23
26
|
@root = container.root
|
|
24
27
|
end
|
|
25
28
|
|
|
26
|
-
def call(from
|
|
29
|
+
def call(from, to, dry_run: false)
|
|
27
30
|
raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
|
|
28
31
|
raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.declared_zone_kinds.key?(from)
|
|
29
32
|
|
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
|
|
@@ -7,8 +7,9 @@ module Textus
|
|
|
7
7
|
# artifact; no parsing or stripping.
|
|
8
8
|
#
|
|
9
9
|
# Sentinel I/O is delegated to Textus::Ports::SentinelStore. Sentinels live
|
|
10
|
-
# under `<store_root
|
|
11
|
-
# so consumer directories aren't
|
|
10
|
+
# under `<store_root>/.run/sentinels/` (runtime, git-ignored — ADR 0070) and
|
|
11
|
+
# mirror the target's repo-relative layout so consumer directories aren't
|
|
12
|
+
# polluted with `.textus-managed.json` siblings.
|
|
12
13
|
module Publisher
|
|
13
14
|
def self.publish(source:, target:, store_root:)
|
|
14
15
|
FileUtils.mkdir_p(File.dirname(target))
|