textus 0.22.0 → 0.29.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 (186) hide show
  1. checksums.yaml +4 -4
  2. data/ARCHITECTURE.md +195 -48
  3. data/CHANGELOG.md +178 -0
  4. data/README.md +55 -13
  5. data/SPEC.md +79 -42
  6. data/docs/conventions.md +10 -0
  7. data/lib/textus/boot.rb +31 -29
  8. data/lib/textus/builder/pipeline.rb +13 -12
  9. data/lib/textus/call.rb +28 -0
  10. data/lib/textus/cli/group/mcp.rb +9 -0
  11. data/lib/textus/cli/group/zone.rb +9 -0
  12. data/lib/textus/cli/verb/accept.rb +1 -1
  13. data/lib/textus/cli/verb/audit.rb +2 -2
  14. data/lib/textus/cli/verb/blame.rb +1 -1
  15. data/lib/textus/cli/verb/boot.rb +1 -1
  16. data/lib/textus/cli/verb/build.rb +3 -3
  17. data/lib/textus/cli/verb/delete.rb +1 -1
  18. data/lib/textus/cli/verb/deps.rb +1 -1
  19. data/lib/textus/cli/verb/doctor.rb +1 -1
  20. data/lib/textus/cli/verb/freshness.rb +1 -1
  21. data/lib/textus/cli/verb/get.rb +1 -1
  22. data/lib/textus/cli/verb/hook_run.rb +3 -4
  23. data/lib/textus/cli/verb/hooks.rb +11 -14
  24. data/lib/textus/cli/verb/key_delete.rb +24 -0
  25. data/lib/textus/cli/verb/list.rb +1 -1
  26. data/lib/textus/cli/verb/mcp_serve.rb +17 -0
  27. data/lib/textus/cli/verb/migrate.rb +18 -0
  28. data/lib/textus/cli/verb/mv.rb +11 -3
  29. data/lib/textus/cli/verb/published.rb +1 -1
  30. data/lib/textus/cli/verb/pulse.rb +1 -1
  31. data/lib/textus/cli/verb/put.rb +8 -6
  32. data/lib/textus/cli/verb/rdeps.rb +1 -1
  33. data/lib/textus/cli/verb/refresh.rb +1 -1
  34. data/lib/textus/cli/verb/refresh_stale.rb +1 -1
  35. data/lib/textus/cli/verb/reject.rb +1 -1
  36. data/lib/textus/cli/verb/rule_explain.rb +1 -1
  37. data/lib/textus/cli/verb/rule_lint.rb +18 -0
  38. data/lib/textus/cli/verb/schema.rb +1 -1
  39. data/lib/textus/cli/verb/uid.rb +1 -1
  40. data/lib/textus/cli/verb/where.rb +1 -1
  41. data/lib/textus/cli/verb/zone_mv.rb +19 -0
  42. data/lib/textus/cli/verb.rb +7 -7
  43. data/lib/textus/cli.rb +0 -7
  44. data/lib/textus/container.rb +23 -0
  45. data/lib/textus/dispatcher.rb +49 -0
  46. data/lib/textus/doctor/check/audit_log.rb +2 -2
  47. data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
  48. data/lib/textus/doctor/check/hooks.rb +4 -3
  49. data/lib/textus/doctor/check/illegal_keys.rb +2 -2
  50. data/lib/textus/doctor/check/intake_registration.rb +2 -2
  51. data/lib/textus/doctor/check/manifest_files.rb +2 -2
  52. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  53. data/lib/textus/doctor/check/refresh_locks.rb +2 -2
  54. data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
  55. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  56. data/lib/textus/doctor/check/schema_violations.rb +1 -1
  57. data/lib/textus/doctor/check/schemas.rb +2 -2
  58. data/lib/textus/doctor/check/sentinels.rb +11 -9
  59. data/lib/textus/doctor/check/templates.rb +2 -2
  60. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  61. data/lib/textus/doctor/check.rb +12 -3
  62. data/lib/textus/doctor.rb +24 -27
  63. data/lib/textus/domain/authorizer.rb +6 -6
  64. data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
  65. data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
  66. data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
  67. data/lib/textus/domain/sentinel.rb +9 -65
  68. data/lib/textus/domain/staleness/generator_check.rb +46 -26
  69. data/lib/textus/domain/staleness/intake_check.rb +20 -12
  70. data/lib/textus/domain/staleness.rb +4 -4
  71. data/lib/textus/envelope/io/reader.rb +44 -0
  72. data/lib/textus/{application/writes/envelope_io.rb → envelope/io/writer.rb} +32 -58
  73. data/lib/textus/hooks/builtin.rb +14 -14
  74. data/lib/textus/hooks/context.rb +30 -13
  75. data/lib/textus/hooks/error_log.rb +32 -0
  76. data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
  77. data/lib/textus/hooks/loader.rb +29 -3
  78. data/lib/textus/hooks/rpc_registry.rb +77 -0
  79. data/lib/textus/key/path.rb +7 -3
  80. data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
  81. data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
  82. data/lib/textus/maintenance/migrate.rb +51 -0
  83. data/lib/textus/maintenance/rule_lint.rb +56 -0
  84. data/lib/textus/maintenance/zone_mv.rb +51 -0
  85. data/lib/textus/maintenance.rb +15 -0
  86. data/lib/textus/manifest/data.rb +79 -0
  87. data/lib/textus/manifest/entry/base.rb +38 -18
  88. data/lib/textus/manifest/entry/derived.rb +8 -9
  89. data/lib/textus/manifest/entry/nested.rb +7 -9
  90. data/lib/textus/manifest/entry/parser.rb +2 -2
  91. data/lib/textus/manifest/entry/validators/events.rb +2 -2
  92. data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
  93. data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
  94. data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
  95. data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
  96. data/lib/textus/manifest/entry/validators.rb +2 -2
  97. data/lib/textus/manifest/entry.rb +0 -5
  98. data/lib/textus/manifest/policy.rb +48 -0
  99. data/lib/textus/manifest/resolver.rb +14 -14
  100. data/lib/textus/manifest/rules.rb +1 -1
  101. data/lib/textus/manifest.rb +47 -110
  102. data/lib/textus/mcp/errors.rb +32 -0
  103. data/lib/textus/mcp/server.rb +126 -0
  104. data/lib/textus/mcp/session.rb +40 -0
  105. data/lib/textus/mcp/tool_schemas.rb +71 -0
  106. data/lib/textus/mcp/tools.rb +129 -0
  107. data/lib/textus/mcp.rb +6 -0
  108. data/lib/textus/{infra → ports}/audit_log.rb +1 -1
  109. data/lib/textus/{infra → ports}/audit_subscriber.rb +7 -8
  110. data/lib/textus/{infra → ports}/build_lock.rb +1 -1
  111. data/lib/textus/{infra → ports}/clock.rb +1 -1
  112. data/lib/textus/{infra → ports}/publisher.rb +6 -6
  113. data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
  114. data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
  115. data/lib/textus/ports/sentinel_store.rb +67 -0
  116. data/lib/textus/ports/storage/file_stat.rb +19 -0
  117. data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
  118. data/lib/textus/projection.rb +91 -0
  119. data/lib/textus/read/audit.rb +111 -0
  120. data/lib/textus/read/blame.rb +81 -0
  121. data/lib/textus/read/boot.rb +18 -0
  122. data/lib/textus/read/deps.rb +24 -0
  123. data/lib/textus/read/doctor.rb +19 -0
  124. data/lib/textus/read/freshness.rb +101 -0
  125. data/lib/textus/read/get.rb +66 -0
  126. data/lib/textus/read/get_or_refresh.rb +69 -0
  127. data/lib/textus/read/list.rb +15 -0
  128. data/lib/textus/read/policy_explain.rb +37 -0
  129. data/lib/textus/read/published.rb +15 -0
  130. data/lib/textus/read/pulse.rb +89 -0
  131. data/lib/textus/read/rdeps.rb +25 -0
  132. data/lib/textus/read/schema_envelope.rb +16 -0
  133. data/lib/textus/read/stale.rb +17 -0
  134. data/lib/textus/read/uid.rb +20 -0
  135. data/lib/textus/read/validate_all.rb +22 -0
  136. data/lib/textus/read/validator.rb +84 -0
  137. data/lib/textus/read/where.rb +16 -0
  138. data/lib/textus/role_scope.rb +49 -0
  139. data/lib/textus/schema/tools.rb +14 -10
  140. data/lib/textus/store.rb +25 -11
  141. data/lib/textus/version.rb +1 -1
  142. data/lib/textus/write/accept.rb +86 -0
  143. data/lib/textus/write/authority_gate.rb +24 -0
  144. data/lib/textus/write/delete.rb +54 -0
  145. data/lib/textus/write/materializer.rb +48 -0
  146. data/lib/textus/write/mv.rb +123 -0
  147. data/lib/textus/write/publish.rb +66 -0
  148. data/lib/textus/write/put.rb +59 -0
  149. data/lib/textus/write/refresh_all.rb +44 -0
  150. data/lib/textus/write/refresh_orchestrator.rb +102 -0
  151. data/lib/textus/write/refresh_worker.rb +138 -0
  152. data/lib/textus/write/reject.rb +54 -0
  153. data/lib/textus.rb +7 -1
  154. metadata +75 -46
  155. data/lib/textus/application/context.rb +0 -34
  156. data/lib/textus/application/projection.rb +0 -91
  157. data/lib/textus/application/reads/audit.rb +0 -94
  158. data/lib/textus/application/reads/blame.rb +0 -82
  159. data/lib/textus/application/reads/deps.rb +0 -26
  160. data/lib/textus/application/reads/freshness.rb +0 -88
  161. data/lib/textus/application/reads/get.rb +0 -67
  162. data/lib/textus/application/reads/get_or_refresh.rb +0 -51
  163. data/lib/textus/application/reads/list.rb +0 -17
  164. data/lib/textus/application/reads/policy_explain.rb +0 -39
  165. data/lib/textus/application/reads/published.rb +0 -17
  166. data/lib/textus/application/reads/pulse.rb +0 -63
  167. data/lib/textus/application/reads/rdeps.rb +0 -27
  168. data/lib/textus/application/reads/schema_envelope.rb +0 -18
  169. data/lib/textus/application/reads/stale.rb +0 -15
  170. data/lib/textus/application/reads/uid.rb +0 -23
  171. data/lib/textus/application/reads/validate_all.rb +0 -24
  172. data/lib/textus/application/reads/validator.rb +0 -86
  173. data/lib/textus/application/reads/where.rb +0 -18
  174. data/lib/textus/application/refresh/all.rb +0 -52
  175. data/lib/textus/application/refresh/orchestrator.rb +0 -78
  176. data/lib/textus/application/refresh/worker.rb +0 -116
  177. data/lib/textus/application/writes/accept.rb +0 -89
  178. data/lib/textus/application/writes/authority_gate.rb +0 -26
  179. data/lib/textus/application/writes/delete.rb +0 -33
  180. data/lib/textus/application/writes/materializer.rb +0 -50
  181. data/lib/textus/application/writes/mv.rb +0 -105
  182. data/lib/textus/application/writes/publish.rb +0 -81
  183. data/lib/textus/application/writes/put.rb +0 -37
  184. data/lib/textus/application/writes/reject.rb +0 -50
  185. data/lib/textus/infra/event_bus.rb +0 -27
  186. data/lib/textus/operations.rb +0 -176
@@ -0,0 +1,86 @@
1
+ require_relative "authority_gate"
2
+
3
+ module Textus
4
+ module Write
5
+ class Accept
6
+ include AuthorityGate
7
+
8
+ def initialize(container:, call:)
9
+ @container = container
10
+ @call = call
11
+ @manifest = container.manifest
12
+ @schemas = container.schemas
13
+ @events = container.events
14
+ end
15
+
16
+ def call(pending_key)
17
+ assert_accept_authority!("accept")
18
+
19
+ env = Textus::Read::Get.new(
20
+ container: @container, call: @call,
21
+ ).call(pending_key)
22
+ proposal = env.meta["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
23
+ target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
24
+ action = proposal["action"] || "put"
25
+
26
+ evaluate_promotion!(env, target)
27
+
28
+ case action
29
+ when "put"
30
+ # Nested proposal "frontmatter" — the meta to write to the accepted
31
+ # target. Not related to the removed intake-handler legacy bridge.
32
+ target_meta = env.meta["frontmatter"] || {}
33
+ target_body = env.body
34
+ put_op.call(target, meta: target_meta, body: target_body)
35
+ when "delete"
36
+ delete_op.call(target)
37
+ else
38
+ raise ProposalError.new("unknown action: #{action}")
39
+ end
40
+
41
+ delete_op.call(pending_key)
42
+
43
+ @events.publish(:proposal_accepted,
44
+ ctx: hook_context,
45
+ key: pending_key,
46
+ target_key: target)
47
+
48
+ { "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
49
+ end
50
+
51
+ private
52
+
53
+ def hook_context
54
+ @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
55
+ end
56
+
57
+ def put_op
58
+ @put_op ||= Textus::Write::Put.new(
59
+ container: @container, call: @call,
60
+ )
61
+ end
62
+
63
+ def delete_op
64
+ @delete_op ||= Textus::Write::Delete.new(
65
+ container: @container, call: @call,
66
+ )
67
+ end
68
+
69
+ def evaluate_promotion!(env, target_key)
70
+ rules = @manifest.rules.for(target_key)
71
+ promote = rules.promote
72
+ return if promote.nil? || promote.requires.empty?
73
+
74
+ policy = Textus::Domain::Policy::Promotion.from_names(promote.requires)
75
+ result = policy.evaluate(
76
+ entry: env, schemas: @schemas, manifest: @manifest, role: @call.role,
77
+ )
78
+ return if result.ok?
79
+
80
+ raise ProposalError.new(
81
+ "promotion gate failed: #{result.reasons.join("; ")}",
82
+ )
83
+ end
84
+ end
85
+ end
86
+ end
@@ -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,54 @@
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.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
+ )
51
+ end
52
+ end
53
+ end
54
+ 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,123 @@
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.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
+ )
113
+ end
114
+
115
+ def reader
116
+ @reader ||= Textus::Envelope::IO::Reader.new(
117
+ file_store: @container.file_store,
118
+ manifest: @container.manifest,
119
+ )
120
+ end
121
+ end
122
+ end
123
+ 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,59 @@
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.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
+ )
56
+ end
57
+ end
58
+ end
59
+ 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