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.
Files changed (42) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +8 -2
  3. data/CHANGELOG.md +56 -0
  4. data/README.md +9 -9
  5. data/SPEC.md +32 -8
  6. data/lib/textus/boot.rb +4 -2
  7. data/lib/textus/cli/verb/hook_run.rb +2 -6
  8. data/lib/textus/cli/verb/put.rb +4 -13
  9. data/lib/textus/cli/verb/retain.rb +19 -0
  10. data/lib/textus/cli/verb/rule_list.rb +1 -1
  11. data/lib/textus/cli.rb +19 -16
  12. data/lib/textus/dispatcher.rb +8 -0
  13. data/lib/textus/doctor/check.rb +8 -5
  14. data/lib/textus/domain/duration.rb +22 -0
  15. data/lib/textus/domain/policy/refresh.rb +1 -15
  16. data/lib/textus/domain/policy/retention.rb +26 -0
  17. data/lib/textus/domain/retention.rb +44 -0
  18. data/lib/textus/envelope/io/reader.rb +4 -0
  19. data/lib/textus/envelope/io/writer.rb +8 -0
  20. data/lib/textus/hooks/event_bus.rb +8 -20
  21. data/lib/textus/hooks/rpc_registry.rb +9 -35
  22. data/lib/textus/hooks/signature.rb +31 -0
  23. data/lib/textus/init.rb +7 -6
  24. data/lib/textus/manifest/data.rb +5 -1
  25. data/lib/textus/manifest/entry/base.rb +2 -2
  26. data/lib/textus/manifest/policy.rb +34 -7
  27. data/lib/textus/manifest/rules.rb +10 -1
  28. data/lib/textus/manifest/schema.rb +54 -4
  29. data/lib/textus/manifest.rb +3 -2
  30. data/lib/textus/mcp/server.rb +1 -9
  31. data/lib/textus/mcp/session.rb +7 -23
  32. data/lib/textus/read/policy_explain.rb +5 -0
  33. data/lib/textus/read/retainable.rb +17 -0
  34. data/lib/textus/role_scope.rb +3 -2
  35. data/lib/textus/version.rb +1 -1
  36. data/lib/textus/write/delete.rb +1 -15
  37. data/lib/textus/write/intake_fetch.rb +23 -0
  38. data/lib/textus/write/mv.rb +2 -12
  39. data/lib/textus/write/put.rb +1 -15
  40. data/lib/textus/write/refresh_worker.rb +2 -16
  41. data/lib/textus/write/retention_sweep.rb +55 -0
  42. 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
- shape_check!(event_sym, required, blk)
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 = filter_kwargs(sub[:callable], kwargs)
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
- shape_check!(event_sym, required, blk)
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:` under the kwarg name
37
- # the callable declares. Legacy `store:` is rejected (no shim).
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
- params = blk.parameters
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 || declared.include?(:caps)
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], read_policy: [all] }
11
- - { name: working, write_policy: [human, agent, runner], read_policy: [all] }
12
- - { name: intake, write_policy: [runner], read_policy: [all] }
13
- - { name: review, write_policy: [agent, human], read_policy: [all] }
14
- - { name: output, write_policy: [builder], read_policy: [all] }
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) { |store:, **| [] }
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:
@@ -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, :audit_config, :role_mapping, :policy
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.zone_kinds(@zone).include?(:generator)
27
- def in_proposal_zone?(policy) = policy.zone_kinds(@zone).include?(:proposer)
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, zone_kinds, permission_for, role_kind, roles_with_kind).
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
- def zone_kinds(zone_name)
29
- @zone_kinds_cache[zone_name] ||= zone_writers(zone_name).each_with_object(Set.new) do |w, acc|
30
- k = role_kind(w)
31
- acc << k if k
32
- end.freeze
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"] # reserved — passthrough only
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
- ENTRY_KEYS = %w[
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 = %w[requires].freeze
19
- AUDIT_KEYS = %w[max_size keep].freeze
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
@@ -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, zone_kinds, permission_for, …)
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.zone_kinds(z)`, etc.
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
 
@@ -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 = nil
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,
@@ -1,29 +1,15 @@
1
1
  module Textus
2
2
  module MCP
3
- # Per-connection state held by the server. Immutable; advance_cursor
4
- # returns a new instance.
5
- class Session
6
- attr_reader :role, :cursor, :propose_zone, :manifest_etag
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 == @manifest_etag
9
+ return if observed_etag == manifest_etag
24
10
 
25
11
  raise ContractDrift.new(
26
- "manifest changed (was #{short_etag(@manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
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
@@ -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
- klass.new(container: @container, call: call_value).call(*args, **kwargs)
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
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.29.0"
2
+ VERSION = "0.30.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -33,21 +33,7 @@ module Textus
33
33
  end
34
34
 
35
35
  def writer
36
- @writer ||= Textus::Envelope::IO::Writer.new(
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
@@ -102,21 +102,11 @@ module Textus
102
102
  end
103
103
 
104
104
  def writer
105
- @writer ||= Textus::Envelope::IO::Writer.new(
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.new(
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
@@ -38,21 +38,7 @@ module Textus
38
38
  end
39
39
 
40
40
  def writer
41
- @writer ||= Textus::Envelope::IO::Writer.new(
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 = 30
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.new(
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