textus 0.26.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 (157) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +118 -68
  3. data/CHANGELOG.md +132 -0
  4. data/README.md +61 -19
  5. data/SPEC.md +107 -46
  6. data/docs/conventions.md +4 -4
  7. data/lib/textus/boot.rb +18 -12
  8. data/lib/textus/builder/pipeline.rb +13 -12
  9. data/lib/textus/call.rb +28 -0
  10. data/lib/textus/cli/verb/audit.rb +1 -1
  11. data/lib/textus/cli/verb/boot.rb +1 -1
  12. data/lib/textus/cli/verb/build.rb +2 -2
  13. data/lib/textus/cli/verb/doctor.rb +1 -1
  14. data/lib/textus/cli/verb/hook_run.rb +2 -6
  15. data/lib/textus/cli/verb/put.rb +5 -14
  16. data/lib/textus/cli/verb/retain.rb +19 -0
  17. data/lib/textus/cli/verb/rule_list.rb +1 -1
  18. data/lib/textus/cli/verb.rb +6 -6
  19. data/lib/textus/cli.rb +19 -23
  20. data/lib/textus/container.rb +23 -0
  21. data/lib/textus/dispatcher.rb +57 -0
  22. data/lib/textus/doctor/check/audit_log.rb +1 -1
  23. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  24. data/lib/textus/doctor/check/sentinels.rb +10 -8
  25. data/lib/textus/doctor/check.rb +15 -5
  26. data/lib/textus/doctor.rb +7 -7
  27. data/lib/textus/domain/authorizer.rb +2 -2
  28. data/lib/textus/domain/duration.rb +22 -0
  29. data/lib/textus/domain/policy/refresh.rb +1 -15
  30. data/lib/textus/domain/policy/retention.rb +26 -0
  31. data/lib/textus/domain/retention.rb +44 -0
  32. data/lib/textus/domain/sentinel.rb +9 -65
  33. data/lib/textus/domain/staleness/generator_check.rb +46 -26
  34. data/lib/textus/domain/staleness/intake_check.rb +18 -10
  35. data/lib/textus/domain/staleness.rb +3 -3
  36. data/lib/textus/{application/envelope → envelope/io}/reader.rb +6 -2
  37. data/lib/textus/{application/envelope → envelope/io}/writer.rb +19 -11
  38. data/lib/textus/hooks/context.rb +30 -13
  39. data/lib/textus/hooks/event_bus.rb +8 -20
  40. data/lib/textus/hooks/rpc_registry.rb +9 -35
  41. data/lib/textus/hooks/signature.rb +31 -0
  42. data/lib/textus/init.rb +7 -6
  43. data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
  44. data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
  45. data/lib/textus/maintenance/migrate.rb +51 -0
  46. data/lib/textus/maintenance/rule_lint.rb +56 -0
  47. data/lib/textus/maintenance/zone_mv.rb +51 -0
  48. data/lib/textus/maintenance.rb +15 -0
  49. data/lib/textus/manifest/data.rb +9 -4
  50. data/lib/textus/manifest/entry/base.rb +38 -18
  51. data/lib/textus/manifest/entry/derived.rb +6 -6
  52. data/lib/textus/manifest/entry/nested.rb +7 -9
  53. data/lib/textus/manifest/entry/parser.rb +2 -2
  54. data/lib/textus/manifest/entry/validators/events.rb +1 -1
  55. data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
  56. data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
  57. data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
  58. data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
  59. data/lib/textus/manifest/entry/validators.rb +2 -2
  60. data/lib/textus/manifest/entry.rb +0 -5
  61. data/lib/textus/manifest/policy.rb +34 -7
  62. data/lib/textus/manifest/rules.rb +10 -1
  63. data/lib/textus/manifest/schema.rb +54 -4
  64. data/lib/textus/manifest.rb +4 -8
  65. data/lib/textus/mcp/server.rb +2 -11
  66. data/lib/textus/mcp/session.rb +13 -20
  67. data/lib/textus/mcp/tools.rb +2 -2
  68. data/lib/textus/mcp.rb +1 -1
  69. data/lib/textus/{infra → ports}/audit_log.rb +1 -1
  70. data/lib/textus/{infra → ports}/audit_subscriber.rb +2 -2
  71. data/lib/textus/{infra → ports}/build_lock.rb +1 -1
  72. data/lib/textus/{infra → ports}/clock.rb +1 -1
  73. data/lib/textus/{infra → ports}/publisher.rb +6 -6
  74. data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
  75. data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
  76. data/lib/textus/ports/sentinel_store.rb +67 -0
  77. data/lib/textus/ports/storage/file_stat.rb +19 -0
  78. data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
  79. data/lib/textus/projection.rb +91 -0
  80. data/lib/textus/read/audit.rb +111 -0
  81. data/lib/textus/read/blame.rb +81 -0
  82. data/lib/textus/read/boot.rb +18 -0
  83. data/lib/textus/read/deps.rb +24 -0
  84. data/lib/textus/read/doctor.rb +19 -0
  85. data/lib/textus/read/freshness.rb +101 -0
  86. data/lib/textus/read/get.rb +66 -0
  87. data/lib/textus/read/get_or_refresh.rb +69 -0
  88. data/lib/textus/read/list.rb +15 -0
  89. data/lib/textus/read/policy_explain.rb +42 -0
  90. data/lib/textus/read/published.rb +15 -0
  91. data/lib/textus/read/pulse.rb +89 -0
  92. data/lib/textus/read/rdeps.rb +25 -0
  93. data/lib/textus/read/retainable.rb +17 -0
  94. data/lib/textus/read/schema_envelope.rb +16 -0
  95. data/lib/textus/read/stale.rb +17 -0
  96. data/lib/textus/read/uid.rb +20 -0
  97. data/lib/textus/read/validate_all.rb +22 -0
  98. data/lib/textus/read/validator.rb +84 -0
  99. data/lib/textus/read/where.rb +16 -0
  100. data/lib/textus/role_scope.rb +50 -0
  101. data/lib/textus/schema/tools.rb +3 -3
  102. data/lib/textus/store.rb +16 -7
  103. data/lib/textus/version.rb +1 -1
  104. data/lib/textus/write/accept.rb +86 -0
  105. data/lib/textus/write/authority_gate.rb +24 -0
  106. data/lib/textus/write/delete.rb +40 -0
  107. data/lib/textus/write/intake_fetch.rb +23 -0
  108. data/lib/textus/write/materializer.rb +48 -0
  109. data/lib/textus/write/mv.rb +113 -0
  110. data/lib/textus/write/publish.rb +66 -0
  111. data/lib/textus/write/put.rb +45 -0
  112. data/lib/textus/write/refresh_all.rb +44 -0
  113. data/lib/textus/write/refresh_orchestrator.rb +102 -0
  114. data/lib/textus/write/refresh_worker.rb +124 -0
  115. data/lib/textus/write/reject.rb +54 -0
  116. data/lib/textus/write/retention_sweep.rb +55 -0
  117. data/lib/textus.rb +1 -2
  118. metadata +62 -50
  119. data/lib/textus/application/caps.rb +0 -49
  120. data/lib/textus/application/context.rb +0 -34
  121. data/lib/textus/application/maintenance/key_delete_prefix.rb +0 -44
  122. data/lib/textus/application/maintenance/key_mv_prefix.rb +0 -57
  123. data/lib/textus/application/maintenance/migrate.rb +0 -59
  124. data/lib/textus/application/maintenance/rule_lint.rb +0 -65
  125. data/lib/textus/application/maintenance/zone_mv.rb +0 -60
  126. data/lib/textus/application/maintenance.rb +0 -17
  127. data/lib/textus/application/projection.rb +0 -93
  128. data/lib/textus/application/read/audit.rb +0 -106
  129. data/lib/textus/application/read/blame.rb +0 -91
  130. data/lib/textus/application/read/deps.rb +0 -34
  131. data/lib/textus/application/read/freshness.rb +0 -110
  132. data/lib/textus/application/read/get.rb +0 -75
  133. data/lib/textus/application/read/get_or_refresh.rb +0 -63
  134. data/lib/textus/application/read/list.rb +0 -25
  135. data/lib/textus/application/read/policy_explain.rb +0 -47
  136. data/lib/textus/application/read/published.rb +0 -25
  137. data/lib/textus/application/read/pulse.rb +0 -101
  138. data/lib/textus/application/read/rdeps.rb +0 -35
  139. data/lib/textus/application/read/schema_envelope.rb +0 -26
  140. data/lib/textus/application/read/stale.rb +0 -23
  141. data/lib/textus/application/read/uid.rb +0 -30
  142. data/lib/textus/application/read/validate_all.rb +0 -32
  143. data/lib/textus/application/read/validator.rb +0 -86
  144. data/lib/textus/application/read/where.rb +0 -26
  145. data/lib/textus/application/use_case.rb +0 -22
  146. data/lib/textus/application/write/accept.rb +0 -102
  147. data/lib/textus/application/write/authority_gate.rb +0 -26
  148. data/lib/textus/application/write/delete.rb +0 -45
  149. data/lib/textus/application/write/materializer.rb +0 -49
  150. data/lib/textus/application/write/mv.rb +0 -118
  151. data/lib/textus/application/write/publish.rb +0 -96
  152. data/lib/textus/application/write/put.rb +0 -49
  153. data/lib/textus/application/write/refresh_all.rb +0 -63
  154. data/lib/textus/application/write/refresh_orchestrator.rb +0 -102
  155. data/lib/textus/application/write/refresh_worker.rb +0 -134
  156. data/lib/textus/application/write/reject.rb +0 -62
  157. data/lib/textus/session.rb +0 -84
@@ -0,0 +1,24 @@
1
+ module Textus
2
+ module Write
3
+ # Shared gate for write verbs that require the caller to hold the
4
+ # manifest's accept_authority role. Provides one method, expressed
5
+ # as two early-returns rather than a ternary, so each failure mode
6
+ # reads on its own line.
7
+ module AuthorityGate
8
+ def assert_accept_authority!(verb)
9
+ return if @manifest.policy.role_kind(@call.role) == :accept_authority
10
+
11
+ authority = @manifest.policy.roles_with_kind(:accept_authority).first
12
+ if authority.nil?
13
+ raise ProposalError.new(
14
+ "no role with accept_authority kind is declared in this manifest; #{verb} is disabled",
15
+ )
16
+ end
17
+
18
+ raise ProposalError.new(
19
+ "only #{authority} role can #{verb} proposals; got '#{@call.role}'",
20
+ )
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,40 @@
1
+ module Textus
2
+ module Write
3
+ class Delete
4
+ def initialize(container:, call:)
5
+ @container = container
6
+ @call = call
7
+ @manifest = container.manifest
8
+ @authorizer = container.authorizer
9
+ @events = container.events
10
+ end
11
+
12
+ def call(key, if_etag: nil, suppress_events: false)
13
+ Textus::Manifest::Data.validate_key!(key)
14
+ mentry = @manifest.resolver.resolve(key).entry
15
+
16
+ @authorizer.authorize_write!(mentry, role: @call.role)
17
+
18
+ writer.delete(key, mentry: mentry, if_etag: if_etag)
19
+
20
+ unless suppress_events
21
+ @events.publish(:entry_deleted,
22
+ ctx: hook_context,
23
+ key: key)
24
+ end
25
+
26
+ { "protocol" => Textus::PROTOCOL, "ok" => true, "key" => key, "deleted" => true }
27
+ end
28
+
29
+ private
30
+
31
+ def hook_context
32
+ @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
33
+ end
34
+
35
+ def writer
36
+ @writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
37
+ end
38
+ end
39
+ end
40
+ 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
@@ -0,0 +1,48 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Write
5
+ # Materializes a single Derived manifest entry onto disk by running
6
+ # the builder pipeline (template + projection + external runner).
7
+ # Extracted from Write::Build so that Publish can reuse
8
+ # it without creating a Build dependency.
9
+ class Materializer
10
+ def initialize(container:, call:)
11
+ @container = container
12
+ @call = call
13
+ @manifest = container.manifest
14
+ @file_store = container.file_store
15
+ @rpc = container.rpc
16
+ @root = container.root
17
+ end
18
+
19
+ # Runs the builder pipeline for `mentry` and returns the on-disk
20
+ # target_path string.
21
+ def run(mentry)
22
+ reader = Textus::Read::Get.new(container: @container, call: @call)
23
+ lister = Textus::Read::List.new(container: @container)
24
+ Builder::Pipeline.run(
25
+ mentry: mentry,
26
+ deps: Builder::Pipeline::Deps.new(
27
+ manifest: @manifest,
28
+ reader: reader.method(:call),
29
+ lister: lister.method(:call),
30
+ rpc: @rpc,
31
+ template_loader: ->(name) { read_template(name) },
32
+ transform_context: @container,
33
+ inject_boot: -> { Textus::Boot.build(container: @container) },
34
+ ),
35
+ )
36
+ end
37
+
38
+ private
39
+
40
+ def read_template(name)
41
+ tpl_path = File.join(@root, "templates", name)
42
+ raise TemplateError.new("template not found: #{tpl_path}", template_name: name) unless File.exist?(tpl_path)
43
+
44
+ File.read(tpl_path)
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,113 @@
1
+ module Textus
2
+ module Write
3
+ class Mv
4
+ def initialize(container:, call:)
5
+ @container = container
6
+ @call = call
7
+ @manifest = container.manifest
8
+ @events = container.events
9
+ @authorizer = container.authorizer
10
+ end
11
+
12
+ def call(old_key, new_key, dry_run: false)
13
+ old_res, new_res = prepare(old_key, new_key)
14
+ return dry_run_result(old_key, new_key, old_res, new_res) if dry_run
15
+
16
+ ensure_uid!(old_key, old_res.entry)
17
+ envelope = writer.move(
18
+ from_key: old_key, to_key: new_key,
19
+ new_mentry: new_res.entry
20
+ )
21
+ publish_renamed(old_key, new_key, envelope)
22
+ success_result(old_key, new_key, old_res, new_res, envelope)
23
+ end
24
+
25
+ private
26
+
27
+ def hook_context
28
+ @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
29
+ end
30
+
31
+ def prepare(old_key, new_key)
32
+ Textus::Manifest::Data.validate_key!(old_key)
33
+ Textus::Manifest::Data.validate_key!(new_key)
34
+ raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
35
+
36
+ old_res = @manifest.resolver.resolve(old_key)
37
+ new_res = @manifest.resolver.resolve(new_key)
38
+ raise UnknownKey.new(old_key) unless reader.exists?(old_key)
39
+
40
+ validate_zone_and_format!(old_res.entry, new_res.entry)
41
+ @authorizer.authorize_write!(old_res.entry, role: @call.role)
42
+ @authorizer.authorize_write!(new_res.entry, role: @call.role)
43
+ raise UsageError.new("mv: target '#{new_key}' already exists at #{new_res.path}") if reader.exists?(new_key)
44
+
45
+ [old_res, new_res]
46
+ end
47
+
48
+ def validate_zone_and_format!(old_mentry, new_mentry)
49
+ if old_mentry.zone != new_mentry.zone
50
+ raise UsageError.new(
51
+ "mv: cross-zone move refused (#{old_mentry.zone} → #{new_mentry.zone}). " \
52
+ "Use put+delete for cross-zone moves.",
53
+ )
54
+ end
55
+ return if old_mentry.format == new_mentry.format
56
+
57
+ raise UsageError.new("mv: format mismatch (#{old_mentry.format} → #{new_mentry.format}); refusing.")
58
+ end
59
+
60
+ # If the source file lacks a UID, rewrite it in-place via the writer
61
+ # so a UID gets injected before the move. This produces one "put"
62
+ # audit row, then the "mv" row from Writer#move.
63
+ def ensure_uid!(old_key, old_mentry)
64
+ pre_env = reader.read(old_key)
65
+ return if pre_env.uid
66
+
67
+ writer.put(
68
+ old_key, mentry: old_mentry,
69
+ payload: Textus::Envelope::IO::Writer::Payload.new(
70
+ meta: pre_env.meta, body: pre_env.body, content: pre_env.content,
71
+ )
72
+ )
73
+ end
74
+
75
+ def publish_renamed(old_key, new_key, envelope)
76
+ @events.publish(:entry_renamed,
77
+ ctx: hook_context,
78
+ key: new_key,
79
+ from_key: old_key,
80
+ to_key: new_key,
81
+ envelope: envelope)
82
+ end
83
+
84
+ def dry_run_result(old_key, new_key, old_res, new_res)
85
+ pre_env = reader.read(old_key)
86
+ {
87
+ "protocol" => PROTOCOL, "ok" => true, "dry_run" => true,
88
+ "from_key" => old_key, "to_key" => new_key,
89
+ "from_path" => old_res.path, "to_path" => new_res.path,
90
+ "uid" => pre_env.uid
91
+ }
92
+ end
93
+
94
+ def success_result(old_key, new_key, old_res, new_res, envelope)
95
+ {
96
+ "protocol" => PROTOCOL, "ok" => true,
97
+ "from_key" => old_key, "to_key" => new_key,
98
+ "from_path" => old_res.path, "to_path" => new_res.path,
99
+ "uid" => envelope.uid,
100
+ "envelope" => envelope.to_h_for_wire
101
+ }
102
+ end
103
+
104
+ def writer
105
+ @writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
106
+ end
107
+
108
+ def reader
109
+ @reader ||= Textus::Envelope::IO::Reader.from(container: @container)
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,66 @@
1
+ module Textus
2
+ module Write
3
+ # Single-pass publish use case: dispatches polymorphically to each
4
+ # entry's `publish_via` method. Derived entries materialize their body
5
+ # via Materializer; Nested entries fan out via publish_each; Leaf and
6
+ # Intake entries copy their stored body to publish_to targets. The
7
+ # Publish layer owns wiring (context, accumulation) but not per-kind
8
+ # logic.
9
+ #
10
+ # Return shape: { "protocol", "built", "published_leaves" }
11
+ class Publish
12
+ def initialize(container:, call:)
13
+ @container = container
14
+ @call = call
15
+ @manifest = container.manifest
16
+ end
17
+
18
+ def call(prefix: nil)
19
+ built = []
20
+ leaves = []
21
+ context = build_context
22
+
23
+ @manifest.data.entries.each do |mentry|
24
+ next if prefix && !entry_matches_prefix?(mentry, prefix)
25
+
26
+ result = mentry.publish_via(context, prefix: prefix)
27
+ next if result.nil?
28
+
29
+ case result[:kind]
30
+ when :built then built << result[:value]
31
+ when :leaves then leaves.concat(result[:value])
32
+ end
33
+ end
34
+
35
+ { "protocol" => Textus::PROTOCOL, "built" => built, "published_leaves" => leaves }
36
+ end
37
+
38
+ private
39
+
40
+ def build_context
41
+ Textus::Manifest::Entry::Base::PublishContext.new(
42
+ container: @container,
43
+ call: @call,
44
+ reader: reader,
45
+ )
46
+ end
47
+
48
+ # Whether the entry should be processed for the given prefix filter.
49
+ def entry_matches_prefix?(mentry, prefix)
50
+ return true unless prefix
51
+
52
+ case mentry
53
+ when Textus::Manifest::Entry::Nested
54
+ mentry.key.start_with?(prefix) ||
55
+ prefix.start_with?("#{mentry.key}.")
56
+ else
57
+ mentry.key.start_with?(prefix)
58
+ end
59
+ end
60
+
61
+ def reader
62
+ @reader ||= Textus::Read::Get.new(container: @container, call: @call)
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,45 @@
1
+ module Textus
2
+ module Write
3
+ class Put
4
+ def initialize(container:, call:)
5
+ @container = container
6
+ @call = call
7
+ @manifest = container.manifest
8
+ @authorizer = container.authorizer
9
+ @events = container.events
10
+ end
11
+
12
+ def call(key, meta: nil, body: nil, content: nil, if_etag: nil)
13
+ Textus::Manifest::Data.validate_key!(key)
14
+ mentry = @manifest.resolver.resolve(key).entry
15
+ @authorizer.authorize_write!(mentry, role: @call.role)
16
+
17
+ envelope = writer.put(
18
+ key,
19
+ mentry: mentry,
20
+ payload: Textus::Envelope::IO::Writer::Payload.new(
21
+ meta: meta, body: body, content: content,
22
+ ),
23
+ if_etag: if_etag,
24
+ )
25
+
26
+ @events.publish(:entry_put,
27
+ ctx: hook_context,
28
+ key: key,
29
+ envelope: envelope)
30
+
31
+ envelope
32
+ end
33
+
34
+ private
35
+
36
+ def hook_context
37
+ @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
38
+ end
39
+
40
+ def writer
41
+ @writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,44 @@
1
+ module Textus
2
+ module Write
3
+ class RefreshAll
4
+ def initialize(container:, call:)
5
+ @container = container
6
+ @call = call
7
+ end
8
+
9
+ def call(prefix: nil, zone: nil)
10
+ worker = Textus::Write::RefreshWorker.new(
11
+ container: @container, call: @call,
12
+ )
13
+
14
+ stale_rows = Textus::Read::Stale.new(container: @container, call: @call).call(prefix: prefix, zone: zone)
15
+ refreshed = []
16
+ failed = []
17
+ skipped = []
18
+
19
+ stale_rows.each do |row|
20
+ key = row["key"] || row[:key]
21
+ reason = row["reason"] || row[:reason]
22
+ if reason.to_s.match?(/ttl exceeded|never refreshed/)
23
+ begin
24
+ worker.run(key)
25
+ refreshed << key
26
+ rescue Textus::Error => e
27
+ failed << { "key" => key, "error" => e.message }
28
+ end
29
+ else
30
+ skipped << { "key" => key, "reason" => reason }
31
+ end
32
+ end
33
+
34
+ {
35
+ "protocol" => Textus::PROTOCOL,
36
+ "ok" => failed.empty?,
37
+ "refreshed" => refreshed,
38
+ "failed" => failed,
39
+ "skipped" => skipped,
40
+ }
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,102 @@
1
+ module Textus
2
+ module Write
3
+ class RefreshOrchestrator
4
+ # Collaborator (not a Dispatcher verb): constructed directly by RefreshWorker /
5
+ # GetOrRefresh, which pass their derived hook_context in. That's why this takes
6
+ # hook_context: explicitly while verb use cases derive their own.
7
+ def initialize(worker:, store_root:, events:, hook_context: nil, detached_spawner: nil)
8
+ @worker = worker
9
+ @store_root = store_root
10
+ @events = events
11
+ @hook_context = hook_context
12
+ @detached_spawner = detached_spawner || default_spawner
13
+ end
14
+
15
+ def execute(action, key:)
16
+ case action
17
+ when Textus::Domain::Action::Return then Textus::Domain::Outcome::Skipped.new
18
+ when Textus::Domain::Action::RefreshSync then run_sync(key)
19
+ when Textus::Domain::Action::RefreshTimed then run_timed(action.budget_ms, key)
20
+ else raise ArgumentError.new("unknown action: #{action.inspect}")
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def run_sync(key)
27
+ envelope = @worker.run(key)
28
+ Textus::Domain::Outcome::Refreshed.new(envelope: envelope)
29
+ rescue Textus::Error => e
30
+ Textus::Domain::Outcome::Failed.new(error: e)
31
+ end
32
+
33
+ def run_timed(budget_ms, key)
34
+ return run_timed_with_fork(budget_ms, key) if Textus::Ports::Refresh::Detached.supported?
35
+
36
+ run_timed_cooperative(budget_ms, key)
37
+ end
38
+
39
+ def run_timed_cooperative(budget_ms, key)
40
+ result = nil
41
+ thread = Thread.new do
42
+ result = @worker.run(key)
43
+ rescue Textus::Error => e
44
+ result = e
45
+ end
46
+
47
+ thread.join(budget_ms / 1000.0)
48
+ if thread.alive?
49
+ thread.kill
50
+ return Textus::Domain::Outcome::Failed.new(
51
+ error: Textus::UsageError.new(
52
+ "refresh exceeded budget #{budget_ms}ms (no fork available — cooperative cancel)",
53
+ ),
54
+ )
55
+ end
56
+
57
+ if result.is_a?(Textus::Error)
58
+ Textus::Domain::Outcome::Failed.new(error: result)
59
+ else
60
+ Textus::Domain::Outcome::Refreshed.new(envelope: result)
61
+ end
62
+ end
63
+
64
+ def run_timed_with_fork(budget_ms, key)
65
+ result = nil
66
+ thread = Thread.new do
67
+ result = @worker.run(key)
68
+ rescue Textus::Error => e
69
+ result = e
70
+ end
71
+
72
+ thread.join(budget_ms / 1000.0)
73
+
74
+ if thread.alive?
75
+ thread.kill
76
+
77
+ # Single-flight: if a sibling process / earlier fork holds the
78
+ # per-leaf lock, don't fork another worker — they're already
79
+ # doing this work.
80
+ probe = Textus::Ports::Refresh::Lock.new(root: @store_root, key: key)
81
+ return Textus::Domain::Outcome::Detached.new unless probe.try_acquire
82
+
83
+ probe.release
84
+
85
+ payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
86
+ payload[:ctx] = @hook_context if @hook_context
87
+ @events.publish(:refresh_backgrounded, **payload)
88
+ @detached_spawner.call(store_root: @store_root, key: key)
89
+ Textus::Domain::Outcome::Detached.new
90
+ elsif result.is_a?(Textus::Error)
91
+ Textus::Domain::Outcome::Failed.new(error: result)
92
+ else
93
+ Textus::Domain::Outcome::Refreshed.new(envelope: result)
94
+ end
95
+ end
96
+
97
+ def default_spawner
98
+ Textus::Ports::Refresh::Detached.method(:spawn)
99
+ end
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,124 @@
1
+ require "timeout"
2
+
3
+ module Textus
4
+ module Write
5
+ class RefreshWorker
6
+ FETCH_TIMEOUT_SECONDS = IntakeFetch::FETCH_TIMEOUT_SECONDS
7
+
8
+ def initialize(container:, call:)
9
+ @container = container
10
+ @call = call
11
+ @manifest = container.manifest
12
+ @events = container.events
13
+ @rpc = container.rpc
14
+ @authorizer = container.authorizer
15
+ end
16
+
17
+ # call(key) is the primary entry; run is kept as an alias for
18
+ # Orchestrator and RefreshAll which call worker.run(key).
19
+ def call(key)
20
+ run(key)
21
+ end
22
+
23
+ def run(key)
24
+ res = @manifest.resolver.resolve(key)
25
+ mentry = res.entry
26
+ path = res.path
27
+ remaining = res.remaining
28
+ raise UsageError.new("no intake declared for '#{key}'") unless mentry.is_a?(Textus::Manifest::Entry::Intake)
29
+
30
+ before_etag = File.exist?(path) ? Etag.for_file(path) : nil
31
+ result = fetch_with_events(key, mentry, remaining)
32
+ persist_and_notify(key, mentry, result, before_etag)
33
+ end
34
+
35
+ def self.normalize_action_result(res, format:)
36
+ res = res.transform_keys(&:to_s) if res.is_a?(Hash)
37
+ res ||= {}
38
+ meta_val = res["_meta"]
39
+ body = res["body"]
40
+ content = res["content"]
41
+
42
+ case format
43
+ when "markdown" then { meta: meta_val || {}, body: body.to_s, content: nil }
44
+ when "text" then { meta: {}, body: body.to_s, content: nil }
45
+ when "json", "yaml"
46
+ if !content.nil?
47
+ { meta: meta_val || {}, body: nil, content: content }
48
+ elsif !body.nil?
49
+ { meta: {}, body: body.to_s, content: nil }
50
+ else
51
+ raise Textus::UsageError.new("intake for #{format} returned neither content nor body")
52
+ end
53
+ else
54
+ raise Textus::UsageError.new("unknown format #{format.inspect}")
55
+ end
56
+ end
57
+
58
+ private
59
+
60
+ def hook_context
61
+ @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
62
+ end
63
+
64
+ def fetch_timeout_for(key)
65
+ rule = @manifest.rules.for(key)
66
+ rule&.refresh&.fetch_timeout_seconds || FETCH_TIMEOUT_SECONDS
67
+ end
68
+
69
+ def fetch_with_events(key, mentry, remaining)
70
+ @events.publish(:refresh_started, ctx: hook_context, key: key, mode: :sync)
71
+ call_intake(key, mentry, remaining)
72
+ end
73
+
74
+ def call_intake(key, mentry, remaining)
75
+ timeout = fetch_timeout_for(key)
76
+ Timeout.timeout(timeout) do
77
+ @rpc.invoke(:resolve_intake, mentry.handler,
78
+ caps: @container,
79
+ config: mentry.config,
80
+ args: { trigger_key: key, leaf_segments: remaining || [] })
81
+ end
82
+ rescue Timeout::Error
83
+ @events.publish(:refresh_failed, ctx: hook_context, key: key,
84
+ error_class: "Timeout::Error",
85
+ error_message: "intake '#{mentry.handler}' exceeded #{timeout}s")
86
+ raise UsageError.new("intake '#{mentry.handler}' exceeded #{timeout}s timeout")
87
+ rescue Textus::Error => e
88
+ @events.publish(:refresh_failed, ctx: hook_context, key: key, error_class: e.class.name,
89
+ error_message: e.message)
90
+ raise
91
+ rescue StandardError => e
92
+ @events.publish(:refresh_failed, ctx: hook_context, key: key, error_class: e.class.name,
93
+ error_message: e.message)
94
+ raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
95
+ end
96
+
97
+ def persist_and_notify(key, mentry, result, before_etag)
98
+ normalized = self.class.normalize_action_result(result, format: mentry.format)
99
+ @authorizer.authorize_write!(mentry, role: @call.role)
100
+ envelope = writer.put(
101
+ key,
102
+ mentry: mentry,
103
+ payload: Textus::Envelope::IO::Writer::Payload.new(
104
+ meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
105
+ ),
106
+ )
107
+ change = detect_change(before_etag, envelope)
108
+ @events.publish(:entry_refreshed, ctx: hook_context, key: key, envelope: envelope, change: change) unless change == :unchanged
109
+ envelope
110
+ end
111
+
112
+ def detect_change(before_etag, envelope)
113
+ if before_etag.nil? then :created
114
+ elsif envelope.etag == before_etag then :unchanged
115
+ else :updated
116
+ end
117
+ end
118
+
119
+ def writer
120
+ @writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,54 @@
1
+ require_relative "authority_gate"
2
+
3
+ module Textus
4
+ module Write
5
+ class Reject
6
+ include AuthorityGate
7
+
8
+ def initialize(container:, call:)
9
+ @container = container
10
+ @call = call
11
+ @manifest = container.manifest
12
+ @events = container.events
13
+ end
14
+
15
+ def call(pending_key)
16
+ assert_accept_authority!("reject")
17
+
18
+ mentry = @manifest.resolver.resolve(pending_key).entry
19
+ unless mentry.in_proposal_zone?(@manifest.policy)
20
+ raise ProposalError.new("reject: '#{pending_key}' is not in a proposal zone (zone=#{mentry.zone})")
21
+ end
22
+
23
+ env = Textus::Read::Get.new(
24
+ container: @container, call: @call,
25
+ ).call(pending_key)
26
+ proposal = env.meta&.dig("proposal") or
27
+ raise ProposalError.new("entry has no proposal block: #{pending_key}")
28
+ target_key = proposal["target_key"] or
29
+ raise ProposalError.new("proposal missing target_key")
30
+
31
+ delete_op.call(pending_key, suppress_events: true)
32
+
33
+ @events.publish(:proposal_rejected,
34
+ ctx: hook_context,
35
+ key: pending_key,
36
+ target_key: target_key)
37
+
38
+ { "protocol" => PROTOCOL, "rejected" => pending_key, "target_key" => target_key }
39
+ end
40
+
41
+ private
42
+
43
+ def hook_context
44
+ @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
45
+ end
46
+
47
+ def delete_op
48
+ @delete_op ||= Textus::Write::Delete.new(
49
+ container: @container, call: @call,
50
+ )
51
+ end
52
+ end
53
+ end
54
+ end