textus 0.29.0 → 0.30.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/ARCHITECTURE.md +8 -2
- data/CHANGELOG.md +56 -0
- data/README.md +9 -9
- data/SPEC.md +32 -8
- data/lib/textus/boot.rb +4 -2
- data/lib/textus/cli/verb/hook_run.rb +2 -6
- data/lib/textus/cli/verb/put.rb +4 -13
- data/lib/textus/cli/verb/retain.rb +19 -0
- data/lib/textus/cli/verb/rule_list.rb +1 -1
- data/lib/textus/cli.rb +19 -16
- data/lib/textus/dispatcher.rb +8 -0
- data/lib/textus/doctor/check.rb +8 -5
- data/lib/textus/domain/duration.rb +22 -0
- data/lib/textus/domain/policy/refresh.rb +1 -15
- data/lib/textus/domain/policy/retention.rb +26 -0
- data/lib/textus/domain/retention.rb +44 -0
- data/lib/textus/envelope/io/reader.rb +4 -0
- data/lib/textus/envelope/io/writer.rb +8 -0
- data/lib/textus/hooks/event_bus.rb +8 -20
- data/lib/textus/hooks/rpc_registry.rb +9 -35
- data/lib/textus/hooks/signature.rb +31 -0
- data/lib/textus/init.rb +7 -6
- data/lib/textus/manifest/data.rb +5 -1
- data/lib/textus/manifest/entry/base.rb +2 -2
- data/lib/textus/manifest/policy.rb +34 -7
- data/lib/textus/manifest/rules.rb +10 -1
- data/lib/textus/manifest/schema.rb +54 -4
- data/lib/textus/manifest.rb +3 -2
- data/lib/textus/mcp/server.rb +1 -9
- data/lib/textus/mcp/session.rb +7 -23
- data/lib/textus/read/policy_explain.rb +5 -0
- data/lib/textus/read/retainable.rb +17 -0
- data/lib/textus/role_scope.rb +3 -2
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/delete.rb +1 -15
- data/lib/textus/write/intake_fetch.rb +23 -0
- data/lib/textus/write/mv.rb +2 -12
- data/lib/textus/write/put.rb +1 -15
- data/lib/textus/write/refresh_worker.rb +2 -16
- data/lib/textus/write/retention_sweep.rb +55 -0
- metadata +9 -1
|
@@ -39,7 +39,12 @@ module Textus
|
|
|
39
39
|
raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if RPC_EVENTS.include?(event_sym)
|
|
40
40
|
|
|
41
41
|
required = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
|
|
42
|
-
|
|
42
|
+
sig = Signature.new(blk)
|
|
43
|
+
missing = sig.missing(required)
|
|
44
|
+
if missing.any?
|
|
45
|
+
raise UsageError.new("#{event_sym} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
|
|
46
|
+
end
|
|
47
|
+
|
|
43
48
|
name = name.to_sym
|
|
44
49
|
raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
|
|
45
50
|
|
|
@@ -79,8 +84,9 @@ module Textus
|
|
|
79
84
|
private
|
|
80
85
|
|
|
81
86
|
def invoke(event, sub, key, kwargs)
|
|
82
|
-
accepted =
|
|
87
|
+
accepted = Signature.new(sub[:callable]).filter(kwargs)
|
|
83
88
|
error = nil
|
|
89
|
+
# Thread#kill is unsafe in general but bounded here: post-commit, isolated, only a runaway user hook is affected.
|
|
84
90
|
thread = Thread.new do
|
|
85
91
|
sub[:callable].call(**accepted)
|
|
86
92
|
rescue StandardError => e
|
|
@@ -115,24 +121,6 @@ module Textus
|
|
|
115
121
|
end
|
|
116
122
|
end
|
|
117
123
|
|
|
118
|
-
def filter_kwargs(callable, kwargs)
|
|
119
|
-
params = callable.parameters
|
|
120
|
-
return kwargs if params.any? { |type, _| type == :keyrest }
|
|
121
|
-
|
|
122
|
-
accepted = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
|
|
123
|
-
kwargs.slice(*accepted)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def shape_check!(event, required, blk)
|
|
127
|
-
provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
|
|
128
|
-
return if provided.any? { |t, _| t == :keyrest }
|
|
129
|
-
|
|
130
|
-
missing = required - provided.map { |_, n| n }
|
|
131
|
-
return if missing.empty?
|
|
132
|
-
|
|
133
|
-
raise UsageError.new("#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
|
|
134
|
-
end
|
|
135
|
-
|
|
136
124
|
def match?(globs, key)
|
|
137
125
|
return true if globs.nil?
|
|
138
126
|
|
|
@@ -20,7 +20,10 @@ module Textus
|
|
|
20
20
|
raise UsageError.new("#{event_sym} is a pubsub event; register on EventBus") if PUBSUB_EVENTS.include?(event_sym)
|
|
21
21
|
|
|
22
22
|
required = EVENTS[event_sym] or raise UsageError.new("unknown RPC event: #{event}")
|
|
23
|
-
|
|
23
|
+
sig = Signature.new(blk)
|
|
24
|
+
missing = sig.missing(required)
|
|
25
|
+
raise UsageError.new("#{event_sym} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})") if missing.any?
|
|
26
|
+
|
|
24
27
|
name = name.to_sym
|
|
25
28
|
raise UsageError.new("#{event_sym} '#{name}' already registered") if @table[event_sym].key?(name)
|
|
26
29
|
|
|
@@ -33,45 +36,16 @@ module Textus
|
|
|
33
36
|
@table[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
|
|
34
37
|
end
|
|
35
38
|
|
|
36
|
-
# Invoke a registered callable, injecting `caps:`
|
|
37
|
-
#
|
|
39
|
+
# Invoke a registered callable, injecting `caps:` only if the callable
|
|
40
|
+
# declares it (or accepts keyrest). Mis-named kwargs (e.g. the legacy
|
|
41
|
+
# `caps:`-alternative) are rejected at registration time, not here.
|
|
38
42
|
def invoke(event, name, caps:, **other)
|
|
39
43
|
blk = callable(event, name)
|
|
40
|
-
|
|
41
|
-
accepts_keyrest = params.any? { |t, _| t == :keyrest }
|
|
42
|
-
declared = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
|
|
43
|
-
|
|
44
|
-
if declared.include?(:store)
|
|
45
|
-
raise UsageError.new(
|
|
46
|
-
"RPC callable for #{event} '#{name}' declares legacy `store:`; rename to `caps:` " \
|
|
47
|
-
"(Textus::Container)",
|
|
48
|
-
)
|
|
49
|
-
end
|
|
50
|
-
|
|
44
|
+
sig = Signature.new(blk)
|
|
51
45
|
kwargs = other.dup
|
|
52
|
-
kwargs[:caps] = caps if accepts_keyrest ||
|
|
46
|
+
kwargs[:caps] = caps if sig.accepts_keyrest? || sig.declared_keys.include?(:caps)
|
|
53
47
|
blk.call(**kwargs)
|
|
54
48
|
end
|
|
55
|
-
|
|
56
|
-
private
|
|
57
|
-
|
|
58
|
-
def shape_check!(event, required, blk)
|
|
59
|
-
provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
|
|
60
|
-
return if provided.any? { |t, _| t == :keyrest }
|
|
61
|
-
|
|
62
|
-
param_names = provided.map { |_, n| n }
|
|
63
|
-
# Allow `store:` as a stand-in for `caps:` so registration succeeds;
|
|
64
|
-
# invoke will raise UsageError when the callable is actually called.
|
|
65
|
-
effective_required = if param_names.include?(:store)
|
|
66
|
-
required.map { |r| r == :caps ? :store : r }
|
|
67
|
-
else
|
|
68
|
-
required
|
|
69
|
-
end
|
|
70
|
-
missing = effective_required - param_names
|
|
71
|
-
return if missing.empty?
|
|
72
|
-
|
|
73
|
-
raise UsageError.new("#{event} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
|
|
74
|
-
end
|
|
75
49
|
end
|
|
76
50
|
end
|
|
77
51
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Hooks
|
|
5
|
+
class Signature
|
|
6
|
+
def initialize(callable)
|
|
7
|
+
@params = callable.parameters
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def accepts_keyrest?
|
|
11
|
+
@params.any? { |type, _| type == :keyrest }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def declared_keys
|
|
15
|
+
@params.each_with_object([]) { |(t, n), acc| acc << n if %i[keyreq key].include?(t) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def missing(required)
|
|
19
|
+
return [] if accepts_keyrest?
|
|
20
|
+
|
|
21
|
+
required - declared_keys
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def filter(kwargs)
|
|
25
|
+
return kwargs if accepts_keyrest?
|
|
26
|
+
|
|
27
|
+
kwargs.slice(*declared_keys)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/textus/init.rb
CHANGED
|
@@ -7,14 +7,15 @@ module Textus
|
|
|
7
7
|
DEFAULT_MANIFEST = <<~YAML
|
|
8
8
|
version: textus/3
|
|
9
9
|
zones:
|
|
10
|
-
- { name: identity, write_policy: [human],
|
|
11
|
-
- { name: working, write_policy: [human
|
|
12
|
-
- { name: intake, write_policy: [runner],
|
|
13
|
-
- { name: review, write_policy: [agent, human],
|
|
14
|
-
- { name: output, write_policy: [builder],
|
|
10
|
+
- { name: identity, kind: origin, write_policy: [human], read_policy: [all] }
|
|
11
|
+
- { name: working, kind: origin, write_policy: [human], read_policy: [all] }
|
|
12
|
+
- { name: intake, kind: quarantine, write_policy: [runner], read_policy: [all] }
|
|
13
|
+
- { name: review, kind: queue, write_policy: [agent, human], read_policy: [all] }
|
|
14
|
+
- { name: output, kind: derived, write_policy: [builder], read_policy: [all] }
|
|
15
15
|
entries:
|
|
16
16
|
- { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self, kind: leaf }
|
|
17
17
|
- { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true, kind: nested }
|
|
18
|
+
- { key: review.notes, path: review/notes, zone: review, schema: null, owner: agent:self, nested: true, kind: nested }
|
|
18
19
|
YAML
|
|
19
20
|
|
|
20
21
|
HOOKS_README = <<~MD
|
|
@@ -34,7 +35,7 @@ module Textus
|
|
|
34
35
|
end
|
|
35
36
|
|
|
36
37
|
reg.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
|
|
37
|
-
reg.on(:validate, :my_check) { |
|
|
38
|
+
reg.on(:validate, :my_check) { |caps:, **| [] }
|
|
38
39
|
reg.on(:entry_put, :my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
|
|
39
40
|
|
|
40
41
|
# Run a side-effect every time textus writes a file to your repo:
|
data/lib/textus/manifest/data.rb
CHANGED
|
@@ -11,7 +11,8 @@ module Textus
|
|
|
11
11
|
class Data
|
|
12
12
|
AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
|
|
13
13
|
|
|
14
|
-
attr_reader :raw, :root, :entries, :zones, :zone_readers, :
|
|
14
|
+
attr_reader :raw, :root, :entries, :zones, :zone_readers, :declared_zone_kinds,
|
|
15
|
+
:audit_config, :role_mapping, :policy
|
|
15
16
|
|
|
16
17
|
def self.validate_key!(key)
|
|
17
18
|
raise UsageError.new("empty key") if key.nil? || key.empty?
|
|
@@ -38,6 +39,9 @@ module Textus
|
|
|
38
39
|
rp = z["read_policy"]
|
|
39
40
|
[z["name"], rp.nil? ? :all : Array(rp)]
|
|
40
41
|
end
|
|
42
|
+
@declared_zone_kinds = Array(raw["zones"]).to_h do |z|
|
|
43
|
+
[z["name"], z["kind"]&.to_sym]
|
|
44
|
+
end
|
|
41
45
|
@audit_config = build_audit_config(raw)
|
|
42
46
|
@role_mapping = RoleKinds.resolve(raw["roles"])
|
|
43
47
|
# Policy is constructed before entries because Entry validators
|
|
@@ -23,8 +23,8 @@ module Textus
|
|
|
23
23
|
raise UsageError.new("entry '#{@key}': #{e.message}")
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def in_generator_zone?(policy) = policy.
|
|
27
|
-
def in_proposal_zone?(policy) = policy.
|
|
26
|
+
def in_generator_zone?(policy) = policy.derived_zone?(@zone)
|
|
27
|
+
def in_proposal_zone?(policy) = policy.queue_zone?(@zone)
|
|
28
28
|
|
|
29
29
|
def nested? = false
|
|
30
30
|
def derived? = false
|
|
@@ -2,11 +2,13 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
# Authority over zones and roles derived from a Manifest::Data snapshot.
|
|
4
4
|
# Encapsulates the lookups previously living on Manifest itself
|
|
5
|
-
# (zone_writers,
|
|
5
|
+
# (zone_writers, permission_for, role_kind, roles_with_kind). Derived /
|
|
6
|
+
# proposal-queue status is authoritative via the declared-kind family
|
|
7
|
+
# (declared_kind, derived_zone?, queue_zone?, queue_zone), not inferred
|
|
8
|
+
# from writers.
|
|
6
9
|
class Policy
|
|
7
10
|
def initialize(data)
|
|
8
11
|
@data = data
|
|
9
|
-
@zone_kinds_cache = {}
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
def zone_writers(zone_name)
|
|
@@ -25,11 +27,24 @@ module Textus
|
|
|
25
27
|
)
|
|
26
28
|
end
|
|
27
29
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
# The kind declared on a zone in the manifest, or nil if undeclared.
|
|
31
|
+
def declared_kind(zone_name)
|
|
32
|
+
@data.declared_zone_kinds[zone_name]
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# The single zone declaring `kind: queue`, or nil. Schema guarantees <=1.
|
|
36
|
+
def queue_zone
|
|
37
|
+
@data.declared_zone_kinds.key(:queue)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# A zone is derived iff it declares kind: derived.
|
|
41
|
+
def derived_zone?(zone_name)
|
|
42
|
+
declared_kind(zone_name) == :derived
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# A zone is a proposal queue iff it declares kind: queue.
|
|
46
|
+
def queue_zone?(zone_name)
|
|
47
|
+
declared_kind(zone_name) == :queue
|
|
33
48
|
end
|
|
34
49
|
|
|
35
50
|
def role_mapping
|
|
@@ -43,6 +58,18 @@ module Textus
|
|
|
43
58
|
def roles_with_kind(kind)
|
|
44
59
|
@data.role_mapping.each_with_object([]) { |(name, k), acc| acc << name if k == kind }
|
|
45
60
|
end
|
|
61
|
+
|
|
62
|
+
# The zone a proposer role writes proposals into: the single zone that
|
|
63
|
+
# declares kind: queue, when the role can write it. Returns nil if there
|
|
64
|
+
# is no queue zone or the role cannot write it.
|
|
65
|
+
def propose_zone_for(role)
|
|
66
|
+
return nil if role.nil?
|
|
67
|
+
|
|
68
|
+
q = queue_zone
|
|
69
|
+
return nil unless q && zone_writers(q).include?(role)
|
|
70
|
+
|
|
71
|
+
q
|
|
72
|
+
end
|
|
46
73
|
end
|
|
47
74
|
end
|
|
48
75
|
end
|
|
@@ -51,7 +51,7 @@ module Textus
|
|
|
51
51
|
@refresh = parse_refresh(raw["refresh"])
|
|
52
52
|
@handler_allowlist = parse_handler_allowlist(raw["intake_handler_allowlist"])
|
|
53
53
|
@promote = parse_promotion(raw["promotion"])
|
|
54
|
-
@retention = raw["retention"]
|
|
54
|
+
@retention = parse_retention(raw["retention"])
|
|
55
55
|
end
|
|
56
56
|
|
|
57
57
|
private
|
|
@@ -80,6 +80,15 @@ module Textus
|
|
|
80
80
|
|
|
81
81
|
Textus::Domain::Policy::Promote.new(requires: Array(h["requires"]))
|
|
82
82
|
end
|
|
83
|
+
|
|
84
|
+
def parse_retention(h)
|
|
85
|
+
return nil if h.nil?
|
|
86
|
+
|
|
87
|
+
Textus::Domain::Policy::Retention.new(
|
|
88
|
+
expire_after: h["expire_after"],
|
|
89
|
+
archive_after: h["archive_after"],
|
|
90
|
+
)
|
|
91
|
+
end
|
|
83
92
|
end
|
|
84
93
|
end
|
|
85
94
|
end
|
|
@@ -4,8 +4,14 @@ module Textus
|
|
|
4
4
|
ROOT_KEYS = %w[version roles zones entries rules audit].freeze
|
|
5
5
|
ROLE_KEYS = %w[name kind].freeze
|
|
6
6
|
ROLE_KINDS = %w[accept_authority generator proposer runner].freeze
|
|
7
|
-
ZONE_KEYS = %w[name write_policy read_policy].freeze
|
|
8
|
-
|
|
7
|
+
ZONE_KEYS = %w[name kind write_policy read_policy].freeze
|
|
8
|
+
ZONE_KINDS = %w[origin quarantine queue derived].freeze
|
|
9
|
+
KIND_REQUIRES_ROLE_KIND = {
|
|
10
|
+
"derived" => "generator",
|
|
11
|
+
"queue" => "proposer",
|
|
12
|
+
"quarantine" => "runner",
|
|
13
|
+
}.freeze
|
|
14
|
+
ENTRY_KEYS = %w[
|
|
9
15
|
key path zone kind schema owner nested format
|
|
10
16
|
compute template publish_to publish_each
|
|
11
17
|
intake events inject_boot index_filename
|
|
@@ -15,8 +21,9 @@ module Textus
|
|
|
15
21
|
RULE_KEYS = %w[match refresh intake_handler_allowlist promotion retention].freeze
|
|
16
22
|
REFRESH_KEYS = %w[ttl on_stale sync_budget_ms fetch_timeout_seconds].freeze
|
|
17
23
|
FETCH_TIMEOUT_SECONDS_CEILING = 3600
|
|
18
|
-
PROMOTION_KEYS
|
|
19
|
-
|
|
24
|
+
PROMOTION_KEYS = %w[requires].freeze
|
|
25
|
+
RETENTION_KEYS = %w[expire_after archive_after].freeze
|
|
26
|
+
AUDIT_KEYS = %w[max_size keep].freeze
|
|
20
27
|
|
|
21
28
|
def self.validate!(raw)
|
|
22
29
|
raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
|
|
@@ -28,11 +35,21 @@ module Textus
|
|
|
28
35
|
validate_rules!(raw["rules"])
|
|
29
36
|
walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
|
|
30
37
|
validate_zone_writers_declared!(raw)
|
|
38
|
+
validate_single_queue!(raw)
|
|
39
|
+
validate_zone_kind_consistency!(raw)
|
|
31
40
|
end
|
|
32
41
|
|
|
33
42
|
def self.validate_zones!(zones)
|
|
34
43
|
Array(zones).each_with_index do |z, i|
|
|
35
44
|
walk(z, ZONE_KEYS, "$.zones[#{i}]")
|
|
45
|
+
if z["kind"].nil?
|
|
46
|
+
raise BadManifest.new("zone '#{z["name"]}' at '$.zones[#{i}]' must declare a kind (one of: #{ZONE_KINDS.join(", ")})")
|
|
47
|
+
end
|
|
48
|
+
next if ZONE_KINDS.include?(z["kind"])
|
|
49
|
+
|
|
50
|
+
raise BadManifest.new(
|
|
51
|
+
"unknown zone kind '#{z["kind"]}' at '$.zones[#{i}]' (known: #{ZONE_KINDS.join(", ")})",
|
|
52
|
+
)
|
|
36
53
|
end
|
|
37
54
|
end
|
|
38
55
|
|
|
@@ -54,6 +71,7 @@ module Textus
|
|
|
54
71
|
validate_fetch_timeout!(r["refresh"]["fetch_timeout_seconds"], "#{path}.refresh.fetch_timeout_seconds")
|
|
55
72
|
end
|
|
56
73
|
walk(r["promotion"], PROMOTION_KEYS, "#{path}.promotion") if r["promotion"].is_a?(Hash)
|
|
74
|
+
walk(r["retention"], RETENTION_KEYS, "#{path}.retention") if r["retention"].is_a?(Hash)
|
|
57
75
|
end
|
|
58
76
|
end
|
|
59
77
|
|
|
@@ -115,6 +133,38 @@ module Textus
|
|
|
115
133
|
raise BadManifest.new("unknown key '#{k}' at '#{path}'")
|
|
116
134
|
end
|
|
117
135
|
end
|
|
136
|
+
|
|
137
|
+
def self.validate_single_queue!(raw)
|
|
138
|
+
queues = Array(raw["zones"]).select { |z| z["kind"] == "queue" }.map { |z| z["name"] }
|
|
139
|
+
return if queues.size <= 1
|
|
140
|
+
|
|
141
|
+
raise BadManifest.new(
|
|
142
|
+
"at most one zone may declare kind: queue (found: #{queues.join(", ")})",
|
|
143
|
+
)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def self.validate_zone_kind_consistency!(raw)
|
|
147
|
+
mapping = role_kind_mapping(raw)
|
|
148
|
+
Array(raw["zones"]).each do |z|
|
|
149
|
+
required = KIND_REQUIRES_ROLE_KIND[z["kind"]] or next
|
|
150
|
+
writers = Array(z["write_policy"])
|
|
151
|
+
next if writers.any? { |w| mapping[w] == required }
|
|
152
|
+
|
|
153
|
+
raise BadManifest.new(
|
|
154
|
+
"zone '#{z["name"]}' declares kind: #{z["kind"]} but no writer is a #{required} " \
|
|
155
|
+
"(writers: #{writers.join(", ")})",
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# name => kind string, honouring an explicit roles: block or the default mapping.
|
|
161
|
+
def self.role_kind_mapping(raw)
|
|
162
|
+
if raw["roles"].nil?
|
|
163
|
+
RoleKinds::DEFAULT_MAPPING.transform_values(&:to_s)
|
|
164
|
+
else
|
|
165
|
+
Array(raw["roles"]).to_h { |r| [r["name"], r["kind"]] }
|
|
166
|
+
end
|
|
167
|
+
end
|
|
118
168
|
end
|
|
119
169
|
end
|
|
120
170
|
end
|
data/lib/textus/manifest.rb
CHANGED
|
@@ -6,10 +6,11 @@ module Textus
|
|
|
6
6
|
#
|
|
7
7
|
# * data — frozen value: raw, root, zones, entries, audit_config, role_mapping
|
|
8
8
|
# * resolver — resolves keys → entry + path
|
|
9
|
-
# * policy — zone/role authority (zone_writers,
|
|
9
|
+
# * policy — zone/role authority (zone_writers, declared_kind/derived_zone?/
|
|
10
|
+
# queue_zone?, permission_for, …)
|
|
10
11
|
# * rules — match-block rule engine (refresh, handler allowlist, promotion, …)
|
|
11
12
|
#
|
|
12
|
-
# Use `manifest.data.entries`, `manifest.policy.
|
|
13
|
+
# Use `manifest.data.entries`, `manifest.policy.declared_kind(z)`, etc.
|
|
13
14
|
Manifest = Data.define(:data, :resolver, :policy, :rules)
|
|
14
15
|
end
|
|
15
16
|
|
data/lib/textus/mcp/server.rb
CHANGED
|
@@ -50,15 +50,7 @@ module Textus
|
|
|
50
50
|
|
|
51
51
|
def handle_initialize(rid, _params)
|
|
52
52
|
proposer = @store.manifest.policy.roles_with_kind(:proposer).first
|
|
53
|
-
propose_zone =
|
|
54
|
-
if proposer
|
|
55
|
-
@store.manifest.data.zones.each do |zname, writers|
|
|
56
|
-
if writers.include?(proposer) && zname.include?("review")
|
|
57
|
-
propose_zone = zname
|
|
58
|
-
break
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|
|
53
|
+
propose_zone = @store.manifest.policy.propose_zone_for(proposer)
|
|
62
54
|
|
|
63
55
|
@session = Session.new(
|
|
64
56
|
role: @role,
|
data/lib/textus/mcp/session.rb
CHANGED
|
@@ -1,29 +1,15 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module MCP
|
|
3
|
-
# Per-connection state held by the server. Immutable;
|
|
4
|
-
# returns a new instance.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
def initialize(role:, cursor:, propose_zone:, manifest_etag:)
|
|
9
|
-
@role = role
|
|
10
|
-
@cursor = cursor
|
|
11
|
-
@propose_zone = propose_zone
|
|
12
|
-
@manifest_etag = manifest_etag
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def advance_cursor(new_cursor)
|
|
16
|
-
self.class.new(
|
|
17
|
-
role: @role, cursor: new_cursor,
|
|
18
|
-
propose_zone: @propose_zone, manifest_etag: @manifest_etag
|
|
19
|
-
)
|
|
20
|
-
end
|
|
3
|
+
# Per-connection state held by the server. Immutable Data value;
|
|
4
|
+
# advance_cursor returns a new instance via #with.
|
|
5
|
+
Session = Data.define(:role, :cursor, :propose_zone, :manifest_etag) do
|
|
6
|
+
def advance_cursor(new_cursor) = with(cursor: new_cursor)
|
|
21
7
|
|
|
22
8
|
def check_etag!(observed_etag)
|
|
23
|
-
return if observed_etag ==
|
|
9
|
+
return if observed_etag == manifest_etag
|
|
24
10
|
|
|
25
11
|
raise ContractDrift.new(
|
|
26
|
-
"manifest changed (was #{short_etag(
|
|
12
|
+
"manifest changed (was #{short_etag(manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
|
|
27
13
|
)
|
|
28
14
|
end
|
|
29
15
|
|
|
@@ -32,9 +18,7 @@ module Textus
|
|
|
32
18
|
# First 8 hex chars after the "sha256:" prefix — a stable short id for
|
|
33
19
|
# the drift diagnostic. Tolerates non-prefixed values (delete_prefix is
|
|
34
20
|
# a no-op when the prefix is absent).
|
|
35
|
-
def short_etag(etag)
|
|
36
|
-
etag.to_s.delete_prefix("sha256:")[0, 8]
|
|
37
|
-
end
|
|
21
|
+
def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
|
|
38
22
|
end
|
|
39
23
|
end
|
|
40
24
|
end
|
|
@@ -20,6 +20,7 @@ module Textus
|
|
|
20
20
|
refresh: !b.refresh.nil?,
|
|
21
21
|
handler_allowlist: !b.handler_allowlist.nil?,
|
|
22
22
|
promote: !b.promote.nil?,
|
|
23
|
+
retention: !b.retention.nil?,
|
|
23
24
|
}
|
|
24
25
|
end,
|
|
25
26
|
effective: {
|
|
@@ -29,6 +30,10 @@ module Textus
|
|
|
29
30
|
},
|
|
30
31
|
handler_allowlist: winners.handler_allowlist&.handlers,
|
|
31
32
|
promotion: winners.promote && { requires: winners.promote.requires },
|
|
33
|
+
retention: winners.retention && {
|
|
34
|
+
expire_after: winners.retention.expire_after,
|
|
35
|
+
archive_after: winners.retention.archive_after,
|
|
36
|
+
},
|
|
32
37
|
},
|
|
33
38
|
}
|
|
34
39
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
class Retainable
|
|
4
|
+
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
|
+
@manifest = container.manifest
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call(prefix: nil, zone: nil)
|
|
9
|
+
Textus::Domain::Retention.new(
|
|
10
|
+
manifest: @manifest,
|
|
11
|
+
file_stat: Textus::Ports::Storage::FileStat.new,
|
|
12
|
+
clock: Textus::Ports::Clock,
|
|
13
|
+
).call(prefix: prefix, zone: zone)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/textus/role_scope.rb
CHANGED
|
@@ -38,11 +38,12 @@ module Textus
|
|
|
38
38
|
|
|
39
39
|
Textus::Dispatcher::VERBS.each_key do |verb|
|
|
40
40
|
define_method(verb) do |*args, **kwargs|
|
|
41
|
-
klass = Textus::Dispatcher.fetch(verb)
|
|
42
41
|
call_value = Textus::Call.build(
|
|
43
42
|
role: @role, correlation_id: @correlation_id, dry_run: @dry_run,
|
|
44
43
|
)
|
|
45
|
-
|
|
44
|
+
Textus::Dispatcher.invoke(
|
|
45
|
+
verb, container: @container, call: call_value, args: args, kwargs: kwargs
|
|
46
|
+
)
|
|
46
47
|
end
|
|
47
48
|
end
|
|
48
49
|
end
|
data/lib/textus/version.rb
CHANGED
data/lib/textus/write/delete.rb
CHANGED
|
@@ -33,21 +33,7 @@ module Textus
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def writer
|
|
36
|
-
@writer ||= Textus::Envelope::IO::Writer.
|
|
37
|
-
file_store: @container.file_store,
|
|
38
|
-
manifest: @container.manifest,
|
|
39
|
-
schemas: @container.schemas,
|
|
40
|
-
audit_log: @container.audit_log,
|
|
41
|
-
call: @call,
|
|
42
|
-
reader: reader,
|
|
43
|
-
)
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def reader
|
|
47
|
-
@reader ||= Textus::Envelope::IO::Reader.new(
|
|
48
|
-
file_store: @container.file_store,
|
|
49
|
-
manifest: @container.manifest,
|
|
50
|
-
)
|
|
36
|
+
@writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
|
|
51
37
|
end
|
|
52
38
|
end
|
|
53
39
|
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
require "timeout"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Write
|
|
5
|
+
# Invokes a :resolve_intake hook handler by name under a timeout.
|
|
6
|
+
# The transport-side fetch kernel shared by `textus put --fetch` and
|
|
7
|
+
# `textus hook run`. Maps Timeout::Error to a UsageError; leaves any
|
|
8
|
+
# other error to the caller (call sites differ in how they wrap those).
|
|
9
|
+
module IntakeFetch
|
|
10
|
+
FETCH_TIMEOUT_SECONDS = 30
|
|
11
|
+
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def invoke(rpc:, handler:, config:, args:, label:, timeout: FETCH_TIMEOUT_SECONDS)
|
|
15
|
+
Timeout.timeout(timeout) do
|
|
16
|
+
rpc.invoke(:resolve_intake, handler, caps: nil, config: config, args: args)
|
|
17
|
+
end
|
|
18
|
+
rescue Timeout::Error
|
|
19
|
+
raise Textus::UsageError.new("#{label} '#{handler}' exceeded #{timeout}s timeout")
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/textus/write/mv.rb
CHANGED
|
@@ -102,21 +102,11 @@ module Textus
|
|
|
102
102
|
end
|
|
103
103
|
|
|
104
104
|
def writer
|
|
105
|
-
@writer ||= Textus::Envelope::IO::Writer.
|
|
106
|
-
file_store: @container.file_store,
|
|
107
|
-
manifest: @container.manifest,
|
|
108
|
-
schemas: @container.schemas,
|
|
109
|
-
audit_log: @container.audit_log,
|
|
110
|
-
call: @call,
|
|
111
|
-
reader: reader,
|
|
112
|
-
)
|
|
105
|
+
@writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
|
|
113
106
|
end
|
|
114
107
|
|
|
115
108
|
def reader
|
|
116
|
-
@reader ||= Textus::Envelope::IO::Reader.
|
|
117
|
-
file_store: @container.file_store,
|
|
118
|
-
manifest: @container.manifest,
|
|
119
|
-
)
|
|
109
|
+
@reader ||= Textus::Envelope::IO::Reader.from(container: @container)
|
|
120
110
|
end
|
|
121
111
|
end
|
|
122
112
|
end
|
data/lib/textus/write/put.rb
CHANGED
|
@@ -38,21 +38,7 @@ module Textus
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def writer
|
|
41
|
-
@writer ||= Textus::Envelope::IO::Writer.
|
|
42
|
-
file_store: @container.file_store,
|
|
43
|
-
manifest: @container.manifest,
|
|
44
|
-
schemas: @container.schemas,
|
|
45
|
-
audit_log: @container.audit_log,
|
|
46
|
-
call: @call,
|
|
47
|
-
reader: reader,
|
|
48
|
-
)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def reader
|
|
52
|
-
@reader ||= Textus::Envelope::IO::Reader.new(
|
|
53
|
-
file_store: @container.file_store,
|
|
54
|
-
manifest: @container.manifest,
|
|
55
|
-
)
|
|
41
|
+
@writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
|
|
56
42
|
end
|
|
57
43
|
end
|
|
58
44
|
end
|
|
@@ -3,7 +3,7 @@ require "timeout"
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Write
|
|
5
5
|
class RefreshWorker
|
|
6
|
-
FETCH_TIMEOUT_SECONDS =
|
|
6
|
+
FETCH_TIMEOUT_SECONDS = IntakeFetch::FETCH_TIMEOUT_SECONDS
|
|
7
7
|
|
|
8
8
|
def initialize(container:, call:)
|
|
9
9
|
@container = container
|
|
@@ -117,21 +117,7 @@ module Textus
|
|
|
117
117
|
end
|
|
118
118
|
|
|
119
119
|
def writer
|
|
120
|
-
@writer ||= Textus::Envelope::IO::Writer.
|
|
121
|
-
file_store: @container.file_store,
|
|
122
|
-
manifest: @container.manifest,
|
|
123
|
-
schemas: @container.schemas,
|
|
124
|
-
audit_log: @container.audit_log,
|
|
125
|
-
call: @call,
|
|
126
|
-
reader: reader,
|
|
127
|
-
)
|
|
128
|
-
end
|
|
129
|
-
|
|
130
|
-
def reader
|
|
131
|
-
@reader ||= Textus::Envelope::IO::Reader.new(
|
|
132
|
-
file_store: @container.file_store,
|
|
133
|
-
manifest: @container.manifest,
|
|
134
|
-
)
|
|
120
|
+
@writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
|
|
135
121
|
end
|
|
136
122
|
end
|
|
137
123
|
end
|