textus 0.43.1 → 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 +70 -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 +10 -7
- 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 +78 -10
- 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 -8
- data/lib/textus/maintenance/rule_lint.rb +4 -2
- data/lib/textus/maintenance/zone_mv.rb +7 -5
- data/lib/textus/manifest/entry/base.rb +1 -1
- data/lib/textus/mcp/catalog.rb +17 -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 +88 -22
- data/lib/textus/read/list.rb +3 -2
- 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 +5 -3
- 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 +3 -2
- data/lib/textus/write/fetch_orchestrator.rb +1 -1
- data/lib/textus/write/fetch_worker.rb +3 -2
- data/lib/textus/write/mv.rb +16 -0
- data/lib/textus/write/propose.rb +12 -4
- data/lib/textus/write/put.rb +11 -6
- 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 -24
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
|
|
|
@@ -35,6 +35,17 @@ module Textus
|
|
|
35
35
|
.keys.map(&:to_s)
|
|
36
36
|
end
|
|
37
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
|
+
|
|
38
49
|
def mcp_surfaced?(klass)
|
|
39
50
|
klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
|
|
40
51
|
end
|
|
@@ -44,43 +55,16 @@ module Textus
|
|
|
44
55
|
raise ToolError.new("unknown tool: #{name}") unless klass && mcp_surfaced?(klass)
|
|
45
56
|
|
|
46
57
|
spec = klass.contract
|
|
47
|
-
|
|
48
|
-
result = store.as(session.role).
|
|
49
|
-
|
|
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(", ")}")
|
|
50
63
|
rescue ContractDrift, CursorExpired
|
|
51
64
|
raise
|
|
52
65
|
rescue Textus::Error => e
|
|
53
66
|
raise ToolError.new("#{name}: #{e.message}")
|
|
54
67
|
end
|
|
55
|
-
|
|
56
|
-
# Splits the raw JSON arg hash into the positional list and keyword hash
|
|
57
|
-
# the use-case expects, validating required presence first.
|
|
58
|
-
# Session-default args (session_default: :method_name) are injected from
|
|
59
|
-
# the session when absent from the wire; they are never treated as missing.
|
|
60
|
-
# Positional args are emitted in contract declaration order; use-case signatures must match.
|
|
61
|
-
def map_args(spec, raw, session = nil)
|
|
62
|
-
missing = spec.required_args.map { |a| a.name.to_s } - raw.keys
|
|
63
|
-
raise ToolError.new("#{spec.verb}: missing #{missing.join(", ")}") unless missing.empty?
|
|
64
|
-
|
|
65
|
-
positional = []
|
|
66
|
-
keyword = {}
|
|
67
|
-
spec.args.each do |a|
|
|
68
|
-
if raw.key?(a.name.to_s)
|
|
69
|
-
value = raw[a.name.to_s]
|
|
70
|
-
elsif a.session_default && session
|
|
71
|
-
value = session.public_send(a.session_default)
|
|
72
|
-
else
|
|
73
|
-
next
|
|
74
|
-
end
|
|
75
|
-
|
|
76
|
-
if a.positional
|
|
77
|
-
positional << value
|
|
78
|
-
else
|
|
79
|
-
keyword[a.name] = value
|
|
80
|
-
end
|
|
81
|
-
end
|
|
82
|
-
[positional, keyword]
|
|
83
|
-
end
|
|
84
68
|
end
|
|
85
69
|
end
|
|
86
70
|
end
|
data/lib/textus/projection.rb
CHANGED
|
@@ -7,8 +7,8 @@ module Textus
|
|
|
7
7
|
REDUCER_TIMEOUT_SECONDS = 2
|
|
8
8
|
|
|
9
9
|
# `reader` — a callable `->(key) { envelope_or_nil }`. Caller picks
|
|
10
|
-
# semantics: pure read (`
|
|
11
|
-
# `ops.
|
|
10
|
+
# semantics: pure read (`Read::Get.new(...).call(key)`, fetch:false default) for
|
|
11
|
+
# materialization paths; `ops.get` (read-through, fetch:true injected) for fetch-on-stale.
|
|
12
12
|
# `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
|
|
13
13
|
# `rpc` — a `Hooks::RpcRegistry` used to dispatch `transform_rows` callables.
|
|
14
14
|
# `transform_context` — capability object handed to transform reducers as `caps:`.
|
data/lib/textus/read/audit.rb
CHANGED
|
@@ -30,6 +30,25 @@ module Textus
|
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
+
extend Textus::Contract::DSL
|
|
34
|
+
|
|
35
|
+
verb :audit
|
|
36
|
+
summary "Query the audit log with optional filters."
|
|
37
|
+
surfaces :cli, :ruby
|
|
38
|
+
cli "audit"
|
|
39
|
+
# #call(**filters) — args map to Query.build keyword params (ADR 0063)
|
|
40
|
+
arg :key, String, required: false, description: "filter to rows for this key"
|
|
41
|
+
arg :zone, String, required: false, description: "filter to keys in this zone"
|
|
42
|
+
arg :role, String, required: false, description: "filter to rows written under this role"
|
|
43
|
+
arg :verb, String, required: false, description: "filter to rows for this verb"
|
|
44
|
+
arg :since, String, required: false,
|
|
45
|
+
coerce: ->(s) { Textus::Read::Audit.parse_since(s, now: Time.now) },
|
|
46
|
+
description: "ISO-8601 timestamp or relative offset (e.g. 1h, 30m)"
|
|
47
|
+
arg :seq_since, Integer, required: false, description: "return rows with seq > this cursor value"
|
|
48
|
+
arg :correlation_id, String, required: false, description: "filter to rows with this correlation_id"
|
|
49
|
+
arg :limit, Integer, required: false, description: "maximum number of rows to return"
|
|
50
|
+
view(:cli) { |rows, _i| { "verb" => "audit", "rows" => rows } }
|
|
51
|
+
|
|
33
52
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
34
53
|
@manifest = container.manifest
|
|
35
54
|
@root = container.root
|
data/lib/textus/read/blame.rb
CHANGED
|
@@ -7,13 +7,23 @@ module Textus
|
|
|
7
7
|
# row. Falls back to `git => nil` when not in a git repo or when the
|
|
8
8
|
# file is untracked.
|
|
9
9
|
class Blame
|
|
10
|
+
extend Textus::Contract::DSL
|
|
11
|
+
|
|
12
|
+
verb :blame
|
|
13
|
+
summary "Annotate audit rows for a key with the git commit that introduced each file state."
|
|
14
|
+
surfaces :cli, :ruby
|
|
15
|
+
cli "blame"
|
|
16
|
+
arg :key, String, required: true, positional: true, description: "entry key to blame"
|
|
17
|
+
arg :limit, Integer, required: false, description: "maximum number of audit rows to return"
|
|
18
|
+
view(:cli) { |rows, inputs| { "verb" => "blame", "key" => inputs[:key], "rows" => rows } }
|
|
19
|
+
|
|
10
20
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
11
21
|
@container = container
|
|
12
22
|
@manifest = container.manifest
|
|
13
23
|
@root = container.root
|
|
14
24
|
end
|
|
15
25
|
|
|
16
|
-
def call(key
|
|
26
|
+
def call(key, limit: nil)
|
|
17
27
|
audit_rows = Textus::Read::Audit.new(container: @container).call(key: key, limit: limit)
|
|
18
28
|
path = resolve_path(key)
|
|
19
29
|
return audit_rows.map { |r| r.merge("git" => nil) } unless git_tracked?(path)
|
data/lib/textus/read/deps.rb
CHANGED
|
@@ -1,12 +1,26 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
class Deps
|
|
4
|
+
extend Textus::Contract::DSL
|
|
5
|
+
|
|
6
|
+
verb :deps
|
|
7
|
+
summary "List the keys a derived entry depends on (its projection/external sources)."
|
|
8
|
+
surfaces :cli, :ruby, :mcp
|
|
9
|
+
arg :key, String, required: true, positional: true,
|
|
10
|
+
description: "dotted key of the derived entry whose source keys you want"
|
|
11
|
+
|
|
4
12
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
13
|
@manifest = container.manifest
|
|
6
14
|
end
|
|
7
15
|
|
|
8
16
|
def call(key)
|
|
9
|
-
|
|
17
|
+
{ "key" => key, "deps" => sources_for(key) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def sources_for(key)
|
|
23
|
+
entry = @manifest.data.entries.find { |e| e.key == key }
|
|
10
24
|
return [] unless entry.is_a?(Textus::Manifest::Entry::Derived)
|
|
11
25
|
|
|
12
26
|
src = entry.source
|
data/lib/textus/read/doctor.rb
CHANGED
|
@@ -6,6 +6,14 @@ module Textus
|
|
|
6
6
|
# The acting role is irrelevant to a read-only health check, so `call`
|
|
7
7
|
# is not consulted.
|
|
8
8
|
class Doctor
|
|
9
|
+
extend Textus::Contract::DSL
|
|
10
|
+
|
|
11
|
+
verb :doctor
|
|
12
|
+
summary "Run health checks on the textus store and report any issues."
|
|
13
|
+
surfaces :cli, :ruby
|
|
14
|
+
cli "doctor"
|
|
15
|
+
arg :checks, Array, required: false, description: "subset of check names to run (default: all)"
|
|
16
|
+
|
|
9
17
|
def initialize(container:, call:)
|
|
10
18
|
@container = container
|
|
11
19
|
@call = call
|
|
@@ -7,6 +7,16 @@ module Textus
|
|
|
7
7
|
# current status. Status is one of :fresh, :stale, :never_fetched, or
|
|
8
8
|
# :no_policy.
|
|
9
9
|
class Freshness
|
|
10
|
+
extend Textus::Contract::DSL
|
|
11
|
+
|
|
12
|
+
verb :freshness
|
|
13
|
+
summary "Report the fetch-freshness status of every entry with a fetch policy."
|
|
14
|
+
surfaces :cli, :ruby
|
|
15
|
+
cli "freshness"
|
|
16
|
+
arg :prefix, String, required: false, description: "filter to keys with this prefix"
|
|
17
|
+
arg :zone, String, required: false, description: "filter to entries in this zone"
|
|
18
|
+
view(:cli) { |rows| { "verb" => "freshness", "rows" => rows } }
|
|
19
|
+
|
|
10
20
|
def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
11
21
|
@container = container
|
|
12
22
|
@call = call
|
data/lib/textus/read/get.rb
CHANGED
|
@@ -1,53 +1,103 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
|
-
#
|
|
4
|
-
#
|
|
3
|
+
# The one read path. `fetch:` controls behavior:
|
|
4
|
+
# fetch: false (default) — pure read: the on-disk envelope annotated with
|
|
5
|
+
# a freshness verdict. NEVER builds the orchestrator (no threads/forks/
|
|
6
|
+
# locks/events). This is the safe default for direct (in-process)
|
|
7
|
+
# callers — accept/reject/publish, materializer, uid, validate_all/
|
|
8
|
+
# validator, schema tooling, and the hook context — that must read
|
|
9
|
+
# persisted truth without triggering a fetch.
|
|
10
|
+
# fetch: true — read-through: after a stale verdict, hands off to the
|
|
11
|
+
# fetch orchestrator per the entry's fetch rule (degrades to the pure
|
|
12
|
+
# result when the key has no rule).
|
|
5
13
|
#
|
|
6
|
-
#
|
|
7
|
-
# `
|
|
14
|
+
# The public `get` verb is read-through because the contract declares
|
|
15
|
+
# `arg :fetch, default: true`, injected on every verb surface (RoleScope +
|
|
16
|
+
# MCP map_args, ADR 0062 amendment). Direct construction bypasses that
|
|
17
|
+
# injection and so gets the safe `fetch: false` method default.
|
|
8
18
|
class Get
|
|
9
19
|
extend Textus::Contract::DSL
|
|
10
20
|
|
|
11
21
|
verb :get
|
|
12
|
-
summary "Read one entry.
|
|
22
|
+
summary "Read one entry. Read-through by default — fetches on stale per " \
|
|
23
|
+
"the entry's fetch rule, degrading to a pure read when the key " \
|
|
24
|
+
"has no rule. Pass fetch:false for a guaranteed pure on-disk " \
|
|
25
|
+
"read. Returns the envelope (uid, etag, _meta, body, freshness)."
|
|
13
26
|
surfaces :cli, :ruby, :mcp
|
|
14
|
-
arg :key, String, required: true, positional: true
|
|
15
|
-
|
|
27
|
+
arg :key, String, required: true, positional: true,
|
|
28
|
+
description: "dotted entry key to read, e.g. 'knowledge.project'"
|
|
29
|
+
arg :fetch, :boolean, default: true,
|
|
30
|
+
description: "read-through (fetch on stale per the " \
|
|
31
|
+
"entry's fetch rule) when true, the default; " \
|
|
32
|
+
"false returns the on-disk envelope without ever fetching"
|
|
33
|
+
view { |v, _i| v.to_h_for_wire }
|
|
16
34
|
|
|
17
|
-
def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
35
|
+
def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator, orchestrator: nil)
|
|
18
36
|
@container = container
|
|
19
37
|
@call = call
|
|
20
38
|
@manifest = container.manifest
|
|
21
39
|
@file_store = container.file_store
|
|
22
40
|
@evaluator = evaluator
|
|
41
|
+
@orchestrator = orchestrator # nil → built lazily on first fetch only
|
|
23
42
|
end
|
|
24
43
|
|
|
25
|
-
def call(key)
|
|
44
|
+
def call(key, fetch: false)
|
|
45
|
+
envelope = annotated_envelope(key)
|
|
46
|
+
return envelope if envelope.nil?
|
|
47
|
+
return envelope unless fetch && envelope.freshness&.stale
|
|
48
|
+
|
|
49
|
+
fetch_policy = fetch_policy_for(key)
|
|
50
|
+
return envelope if fetch_policy.nil?
|
|
51
|
+
|
|
52
|
+
policy = fetch_policy.to_freshness_policy
|
|
53
|
+
verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
|
|
54
|
+
outcome = orchestrator.execute(policy.decide(verdict), key: key)
|
|
55
|
+
resolve(outcome, envelope)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Strict variant: raises UnknownKey when the entry is missing.
|
|
59
|
+
# Used by consumers (e.g. uid, Validator) that distinguish absence.
|
|
60
|
+
def get(key, fetch: false)
|
|
61
|
+
call(key, fetch: fetch) ||
|
|
62
|
+
raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
private
|
|
66
|
+
|
|
67
|
+
# Pure read + freshness verdict; no orchestrator dependency.
|
|
68
|
+
def annotated_envelope(key)
|
|
26
69
|
envelope = read_raw_envelope(key)
|
|
27
70
|
return nil if envelope.nil?
|
|
28
71
|
|
|
29
|
-
|
|
30
|
-
fetch_policy = policy_set.fetch
|
|
72
|
+
fetch_policy = fetch_policy_for(key)
|
|
31
73
|
return annotate_fresh(envelope) if fetch_policy.nil?
|
|
32
74
|
|
|
33
|
-
policy
|
|
75
|
+
policy = fetch_policy.to_freshness_policy
|
|
34
76
|
verdict = @evaluator.call(policy, envelope, now: @call.now)
|
|
35
|
-
|
|
36
77
|
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
37
|
-
stale: verdict.stale?,
|
|
38
|
-
reason: verdict.reason,
|
|
39
|
-
fetching: false,
|
|
78
|
+
stale: verdict.stale?, reason: verdict.reason, fetching: false,
|
|
40
79
|
))
|
|
41
80
|
end
|
|
42
81
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
# from emptiness.
|
|
46
|
-
def get(key)
|
|
47
|
-
call(key) || raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
|
|
82
|
+
def fetch_policy_for(key)
|
|
83
|
+
@manifest.rules.for(key).fetch
|
|
48
84
|
end
|
|
49
85
|
|
|
50
|
-
|
|
86
|
+
def resolve(outcome, envelope)
|
|
87
|
+
case outcome
|
|
88
|
+
when Textus::Domain::Outcome::Skipped
|
|
89
|
+
envelope
|
|
90
|
+
when Textus::Domain::Outcome::Fetched
|
|
91
|
+
outcome.envelope.with(
|
|
92
|
+
freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, fetching: false),
|
|
93
|
+
)
|
|
94
|
+
when Textus::Domain::Outcome::Detached
|
|
95
|
+
envelope.with(freshness: envelope.freshness.with(fetching: true))
|
|
96
|
+
when Textus::Domain::Outcome::Failed
|
|
97
|
+
envelope.with(freshness: envelope.freshness.with(fetch_error: outcome.error.message))
|
|
98
|
+
else raise "unexpected fetch outcome: #{outcome.class}"
|
|
99
|
+
end
|
|
100
|
+
end
|
|
51
101
|
|
|
52
102
|
def read_raw_envelope(key)
|
|
53
103
|
res = @manifest.resolver.resolve(key)
|
|
@@ -69,6 +119,22 @@ module Textus
|
|
|
69
119
|
stale: false, reason: nil, fetching: false,
|
|
70
120
|
))
|
|
71
121
|
end
|
|
122
|
+
|
|
123
|
+
def orchestrator
|
|
124
|
+
@orchestrator ||= build_orchestrator
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def build_orchestrator
|
|
128
|
+
worker = Textus::Write::FetchWorker.new(container: @container, call: @call)
|
|
129
|
+
Textus::Write::FetchOrchestrator.new(
|
|
130
|
+
worker: worker, store_root: @container.root, events: @container.events,
|
|
131
|
+
hook_context: hook_context
|
|
132
|
+
)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def hook_context
|
|
136
|
+
@hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
|
|
137
|
+
end
|
|
72
138
|
end
|
|
73
139
|
end
|
|
74
140
|
end
|
data/lib/textus/read/list.rb
CHANGED
|
@@ -6,8 +6,9 @@ module Textus
|
|
|
6
6
|
verb :list
|
|
7
7
|
summary "List keys filtered by zone and/or prefix."
|
|
8
8
|
surfaces :cli, :ruby, :mcp
|
|
9
|
-
arg :prefix, String
|
|
10
|
-
arg :zone, String
|
|
9
|
+
arg :prefix, String, description: "restrict to keys starting with this dotted prefix, e.g. 'knowledge.runbooks'"
|
|
10
|
+
arg :zone, String, description: "restrict to one zone by name (see `boot` zones); combine with prefix to narrow further"
|
|
11
|
+
view(:cli) { |rows| { "entries" => rows } }
|
|
11
12
|
|
|
12
13
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
13
14
|
@manifest = container.manifest
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
class Published
|
|
4
|
+
extend Textus::Contract::DSL
|
|
5
|
+
|
|
6
|
+
verb :published
|
|
7
|
+
summary "List all entries that declare a publish_to target."
|
|
8
|
+
surfaces :cli, :ruby
|
|
9
|
+
cli "published"
|
|
10
|
+
|
|
4
11
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
12
|
@manifest = container.manifest
|
|
6
13
|
end
|
data/lib/textus/read/pulse.rb
CHANGED
|
@@ -12,6 +12,7 @@ module Textus
|
|
|
12
12
|
verb :pulse
|
|
13
13
|
summary "Delta since cursor — changed entries, stale, pending proposals, doctor summary."
|
|
14
14
|
surfaces :cli, :ruby, :mcp
|
|
15
|
+
around :cursor
|
|
15
16
|
arg :since, Integer, session_default: :cursor, description: "audit seq to diff from; defaults to the session cursor"
|
|
16
17
|
|
|
17
18
|
def initialize(container:, call:)
|
data/lib/textus/read/rdeps.rb
CHANGED
|
@@ -1,11 +1,25 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
class Rdeps
|
|
4
|
+
extend Textus::Contract::DSL
|
|
5
|
+
|
|
6
|
+
verb :rdeps
|
|
7
|
+
summary "List the derived entries that depend on a key (reverse deps / impact set)."
|
|
8
|
+
surfaces :cli, :ruby, :mcp
|
|
9
|
+
arg :key, String, required: true, positional: true,
|
|
10
|
+
description: "dotted key whose dependents (what would be stranded if it moved) you want"
|
|
11
|
+
|
|
4
12
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
13
|
@manifest = container.manifest
|
|
6
14
|
end
|
|
7
15
|
|
|
8
16
|
def call(key)
|
|
17
|
+
{ "key" => key, "rdeps" => dependents_of(key) }
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def dependents_of(key)
|
|
9
23
|
@manifest.data.entries.each_with_object([]) do |e, acc|
|
|
10
24
|
next unless e.is_a?(Textus::Manifest::Entry::Derived)
|
|
11
25
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
# Effective rules for a key, at two depths (ADR 0059). Lean by default —
|
|
4
|
+
# `{ fetch, guard }`, the agent-cheap read that was the `rules` verb. With
|
|
5
|
+
# `detail: true` it returns the verbose explanation — every matching policy
|
|
6
|
+
# block plus the per-transition guard predicate names — that was
|
|
7
|
+
# `policy_explain`. One verb, one name across CLI/MCP/method; the audience
|
|
8
|
+
# split is a parameter, not two tools.
|
|
9
|
+
class RuleExplain
|
|
10
|
+
extend Textus::Contract::DSL
|
|
11
|
+
|
|
12
|
+
verb :rule_explain
|
|
13
|
+
summary "Effective rules for a key. Lean {fetch, guard} by default; detail: true adds matched blocks + guard predicates."
|
|
14
|
+
surfaces :cli, :ruby, :mcp
|
|
15
|
+
cli "rule explain"
|
|
16
|
+
arg :key, String, required: true, positional: true,
|
|
17
|
+
description: "dotted key whose effective rules you want (fetch ttl/action, write guard, ...)"
|
|
18
|
+
arg :detail, :boolean,
|
|
19
|
+
description: "defaults false: lean {fetch, guard}. detail: true adds matched blocks + guard predicates per transition."
|
|
20
|
+
view(:cli) { |r| { "verb" => "rule_explain" }.merge(r.transform_keys(&:to_s)) }
|
|
21
|
+
|
|
22
|
+
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
23
|
+
@manifest = container.manifest
|
|
24
|
+
@schemas = container.schemas
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(key, detail: false)
|
|
28
|
+
detail ? explain(key) : effective(key)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# Lean: the effective winners only (formerly Read::Rules / the `rules` verb).
|
|
34
|
+
def effective(key)
|
|
35
|
+
set = @manifest.rules.for(key)
|
|
36
|
+
{
|
|
37
|
+
"fetch" => set.fetch && {
|
|
38
|
+
"ttl_seconds" => set.fetch.ttl_seconds,
|
|
39
|
+
"on_stale" => set.fetch.on_stale,
|
|
40
|
+
"sync_budget_ms" => set.fetch.sync_budget_ms,
|
|
41
|
+
"fetch_timeout_seconds" => set.fetch.fetch_timeout_seconds,
|
|
42
|
+
},
|
|
43
|
+
"guard" => set.guard,
|
|
44
|
+
}.compact
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Verbose: every matching block, per-slot effective value, and the
|
|
48
|
+
# effective guard predicate names for each write transition (formerly
|
|
49
|
+
# Read::PolicyExplain, ADR 0031).
|
|
50
|
+
def explain(key)
|
|
51
|
+
matching = @manifest.rules.explain(key)
|
|
52
|
+
winners = @manifest.rules.for(key)
|
|
53
|
+
factory = Textus::Domain::Policy::GuardFactory.new(manifest: @manifest, schemas: @schemas)
|
|
54
|
+
|
|
55
|
+
{
|
|
56
|
+
key: key,
|
|
57
|
+
matched_blocks: matching.map do |b|
|
|
58
|
+
{
|
|
59
|
+
match: b.match,
|
|
60
|
+
fetch: !b.fetch.nil?,
|
|
61
|
+
handler_allowlist: !b.handler_allowlist.nil?,
|
|
62
|
+
guard: !b.guard.nil?,
|
|
63
|
+
retention: !b.retention.nil?,
|
|
64
|
+
}
|
|
65
|
+
end,
|
|
66
|
+
effective: {
|
|
67
|
+
fetch: winners.fetch && {
|
|
68
|
+
ttl_seconds: winners.fetch.ttl_seconds,
|
|
69
|
+
on_stale: winners.fetch.on_stale,
|
|
70
|
+
},
|
|
71
|
+
handler_allowlist: winners.handler_allowlist&.handlers,
|
|
72
|
+
retention: winners.retention && {
|
|
73
|
+
expire_after: winners.retention.expire_after,
|
|
74
|
+
archive_after: winners.retention.archive_after,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
guards: Textus::Domain::Policy::BaseGuards::BASE.keys.to_h do |transition|
|
|
78
|
+
[transition, factory.for(transition, key).predicates.map(&:name)]
|
|
79
|
+
end,
|
|
80
|
+
}
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
# Enumerate every declared rule block in the manifest, in order. This is
|
|
4
|
+
# the whole-manifest view; `rule_explain` is the for-key view. Extracted
|
|
5
|
+
# from the CLI verb so the rule family is fully use-case-backed (ADR 0059);
|
|
6
|
+
# CLI-only (no MCP contract) — an agent reasons per-key via rule_explain.
|
|
7
|
+
class RuleList
|
|
8
|
+
extend Textus::Contract::DSL
|
|
9
|
+
|
|
10
|
+
verb :rule_list
|
|
11
|
+
summary "List every rule block in the manifest."
|
|
12
|
+
surfaces :cli, :ruby
|
|
13
|
+
cli "rule list"
|
|
14
|
+
view(:cli) { |policies| { "verb" => "rule_list", "policies" => policies } }
|
|
15
|
+
|
|
16
|
+
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
17
|
+
@manifest = container.manifest
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call
|
|
21
|
+
@manifest.rules.blocks.map do |b|
|
|
22
|
+
row = { "match" => b.match }
|
|
23
|
+
if b.fetch
|
|
24
|
+
row["fetch"] = {
|
|
25
|
+
"ttl_seconds" => b.fetch.ttl_seconds,
|
|
26
|
+
"on_stale" => b.fetch.on_stale,
|
|
27
|
+
"sync_budget_ms" => b.fetch.sync_budget_ms,
|
|
28
|
+
"fetch_timeout_seconds" => b.fetch.fetch_timeout_seconds,
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
|
|
32
|
+
row["guard"] = b.guard if b.guard
|
|
33
|
+
row["retention"] = { "expire_after" => b.retention.expire_after, "archive_after" => b.retention.archive_after } if b.retention
|
|
34
|
+
row
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -3,10 +3,12 @@ module Textus
|
|
|
3
3
|
class SchemaEnvelope
|
|
4
4
|
extend Textus::Contract::DSL
|
|
5
5
|
|
|
6
|
-
verb :
|
|
6
|
+
verb :schema_show
|
|
7
7
|
summary "Return the schema (field shape) for an entry's family, by key."
|
|
8
|
-
surfaces :ruby, :mcp
|
|
9
|
-
|
|
8
|
+
surfaces :cli, :ruby, :mcp
|
|
9
|
+
cli "schema show"
|
|
10
|
+
arg :key, String, required: true, positional: true,
|
|
11
|
+
description: "any key in the family whose schema you want; returns required/optional fields and their types"
|
|
10
12
|
|
|
11
13
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
12
14
|
@manifest = container.manifest
|
data/lib/textus/read/uid.rb
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
class Uid
|
|
4
|
+
extend Textus::Contract::DSL
|
|
5
|
+
|
|
6
|
+
verb :uid
|
|
7
|
+
summary "Return the stable UID of an entry without reading its body."
|
|
8
|
+
surfaces :cli, :ruby
|
|
9
|
+
cli "key uid"
|
|
10
|
+
arg :key, String, required: true, positional: true, description: "entry key"
|
|
11
|
+
view(:cli) { |uid, inputs| { "key" => inputs[:key], "uid" => uid } }
|
|
12
|
+
|
|
4
13
|
def initialize(container:, call:)
|
|
5
14
|
@container = container
|
|
6
15
|
@call = call
|
data/lib/textus/read/where.rb
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
class Where
|
|
4
|
+
extend Textus::Contract::DSL
|
|
5
|
+
|
|
6
|
+
verb :where
|
|
7
|
+
summary "Resolve a key to its zone, owner, and path without reading the body."
|
|
8
|
+
surfaces :cli, :ruby, :mcp
|
|
9
|
+
arg :key, String, required: true, positional: true,
|
|
10
|
+
description: "dotted key to locate (returns zone, owner, path; does not read content)"
|
|
11
|
+
|
|
4
12
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
13
|
@manifest = container.manifest
|
|
6
14
|
end
|
data/lib/textus/role_scope.rb
CHANGED
|
@@ -36,14 +36,42 @@ module Textus
|
|
|
36
36
|
self.class.new(container: @container, role: @role, dry_run: true, correlation_id: @correlation_id)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
# Single bind + invoke site for every surface. `inputs` is the uniform
|
|
40
|
+
# by-name hash (the binder's currency). MCP/CLI build it from their raw
|
|
41
|
+
# transport shape and call this directly; the per-verb Ruby methods below
|
|
42
|
+
# normalize positional+keyword Ruby args into `inputs` and delegate here.
|
|
43
|
+
def dispatch_bound(verb, inputs, session: nil)
|
|
44
|
+
klass = Textus::Dispatcher::VERBS[verb]
|
|
45
|
+
spec = (klass.contract if klass.respond_to?(:contract?) && klass.contract?)
|
|
46
|
+
|
|
47
|
+
invoke = lambda do |effective_inputs|
|
|
48
|
+
args, kwargs =
|
|
49
|
+
if spec
|
|
50
|
+
Textus::Contract::Binder.bind(spec, effective_inputs, session: session)
|
|
51
|
+
else
|
|
52
|
+
[[], effective_inputs]
|
|
53
|
+
end
|
|
54
|
+
call_value = Textus::Call.build(role: @role, correlation_id: @correlation_id, dry_run: @dry_run)
|
|
55
|
+
Textus::Dispatcher.invoke(verb, container: @container, call: call_value, args: args, kwargs: kwargs)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
if spec&.around
|
|
59
|
+
Textus::Contract::Around.with(spec.around, scope: self, inputs: inputs, session: session, &invoke)
|
|
60
|
+
else
|
|
61
|
+
invoke.call(inputs)
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
39
65
|
Textus::Dispatcher::VERBS.each_key do |verb|
|
|
40
66
|
define_method(verb) do |*args, **kwargs|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
67
|
+
klass = Textus::Dispatcher::VERBS[verb]
|
|
68
|
+
inputs =
|
|
69
|
+
if klass.respond_to?(:contract?) && klass.contract?
|
|
70
|
+
Textus::Contract::Binder.inputs_from_ordered(klass.contract, args, kwargs)
|
|
71
|
+
else
|
|
72
|
+
kwargs
|
|
73
|
+
end
|
|
74
|
+
dispatch_bound(verb, inputs)
|
|
47
75
|
end
|
|
48
76
|
end
|
|
49
77
|
end
|