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,138 @@
1
+ require "timeout"
2
+
3
+ module Textus
4
+ module Write
5
+ class RefreshWorker
6
+ FETCH_TIMEOUT_SECONDS = 30
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.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
+ )
135
+ end
136
+ end
137
+ end
138
+ 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
data/lib/textus.rb CHANGED
@@ -1,6 +1,8 @@
1
1
  require "zeitwerk"
2
2
  require_relative "textus/version"
3
3
  require_relative "textus/errors"
4
+ require_relative "textus/mcp"
5
+ require_relative "textus/mcp/errors"
4
6
 
5
7
  loader = Zeitwerk::Loader.for_gem
6
8
  loader.inflector.inflect(
@@ -8,9 +10,13 @@ loader.inflector.inflect(
8
10
  "json" => "Json",
9
11
  "yaml" => "Yaml",
10
12
  "hook_dsl_scanner" => "HookDSLScanner",
11
- "envelope_io" => "EnvelopeIO",
13
+ "io" => "IO",
14
+ "mcp" => "MCP",
15
+ "mcp_serve" => "MCPServe",
12
16
  )
13
17
  loader.ignore(File.expand_path("textus/errors.rb", __dir__))
18
+ loader.ignore(File.expand_path("textus/mcp.rb", __dir__))
19
+ loader.ignore(File.expand_path("textus/mcp/errors.rb", __dir__))
14
20
  loader.setup
15
21
  loader.eager_load
16
22
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.22.0
4
+ version: 0.29.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -109,40 +109,6 @@ files:
109
109
  - docs/conventions.md
110
110
  - exe/textus
111
111
  - lib/textus.rb
112
- - lib/textus/application/context.rb
113
- - lib/textus/application/policy/predicates/accept_authority_signed.rb
114
- - lib/textus/application/policy/predicates/schema_valid.rb
115
- - lib/textus/application/policy/promotion.rb
116
- - lib/textus/application/projection.rb
117
- - lib/textus/application/reads/audit.rb
118
- - lib/textus/application/reads/blame.rb
119
- - lib/textus/application/reads/deps.rb
120
- - lib/textus/application/reads/freshness.rb
121
- - lib/textus/application/reads/get.rb
122
- - lib/textus/application/reads/get_or_refresh.rb
123
- - lib/textus/application/reads/list.rb
124
- - lib/textus/application/reads/policy_explain.rb
125
- - lib/textus/application/reads/published.rb
126
- - lib/textus/application/reads/pulse.rb
127
- - lib/textus/application/reads/rdeps.rb
128
- - lib/textus/application/reads/schema_envelope.rb
129
- - lib/textus/application/reads/stale.rb
130
- - lib/textus/application/reads/uid.rb
131
- - lib/textus/application/reads/validate_all.rb
132
- - lib/textus/application/reads/validator.rb
133
- - lib/textus/application/reads/where.rb
134
- - lib/textus/application/refresh/all.rb
135
- - lib/textus/application/refresh/orchestrator.rb
136
- - lib/textus/application/refresh/worker.rb
137
- - lib/textus/application/writes/accept.rb
138
- - lib/textus/application/writes/authority_gate.rb
139
- - lib/textus/application/writes/delete.rb
140
- - lib/textus/application/writes/envelope_io.rb
141
- - lib/textus/application/writes/materializer.rb
142
- - lib/textus/application/writes/mv.rb
143
- - lib/textus/application/writes/publish.rb
144
- - lib/textus/application/writes/put.rb
145
- - lib/textus/application/writes/reject.rb
146
112
  - lib/textus/boot.rb
147
113
  - lib/textus/builder/pipeline.rb
148
114
  - lib/textus/builder/renderer.rb
@@ -150,13 +116,16 @@ files:
150
116
  - lib/textus/builder/renderer/markdown.rb
151
117
  - lib/textus/builder/renderer/text.rb
152
118
  - lib/textus/builder/renderer/yaml.rb
119
+ - lib/textus/call.rb
153
120
  - lib/textus/cli.rb
154
121
  - lib/textus/cli/group.rb
155
122
  - lib/textus/cli/group/hook.rb
156
123
  - lib/textus/cli/group/key.rb
124
+ - lib/textus/cli/group/mcp.rb
157
125
  - lib/textus/cli/group/refresh.rb
158
126
  - lib/textus/cli/group/rule.rb
159
127
  - lib/textus/cli/group/schema.rb
128
+ - lib/textus/cli/group/zone.rb
160
129
  - lib/textus/cli/verb.rb
161
130
  - lib/textus/cli/verb/accept.rb
162
131
  - lib/textus/cli/verb/audit.rb
@@ -171,7 +140,10 @@ files:
171
140
  - lib/textus/cli/verb/hook_run.rb
172
141
  - lib/textus/cli/verb/hooks.rb
173
142
  - lib/textus/cli/verb/init.rb
143
+ - lib/textus/cli/verb/key_delete.rb
174
144
  - lib/textus/cli/verb/list.rb
145
+ - lib/textus/cli/verb/mcp_serve.rb
146
+ - lib/textus/cli/verb/migrate.rb
175
147
  - lib/textus/cli/verb/mv.rb
176
148
  - lib/textus/cli/verb/published.rb
177
149
  - lib/textus/cli/verb/pulse.rb
@@ -181,6 +153,7 @@ files:
181
153
  - lib/textus/cli/verb/refresh_stale.rb
182
154
  - lib/textus/cli/verb/reject.rb
183
155
  - lib/textus/cli/verb/rule_explain.rb
156
+ - lib/textus/cli/verb/rule_lint.rb
184
157
  - lib/textus/cli/verb/rule_list.rb
185
158
  - lib/textus/cli/verb/schema.rb
186
159
  - lib/textus/cli/verb/schema_diff.rb
@@ -188,6 +161,9 @@ files:
188
161
  - lib/textus/cli/verb/schema_migrate.rb
189
162
  - lib/textus/cli/verb/uid.rb
190
163
  - lib/textus/cli/verb/where.rb
164
+ - lib/textus/cli/verb/zone_mv.rb
165
+ - lib/textus/container.rb
166
+ - lib/textus/dispatcher.rb
191
167
  - lib/textus/doctor.rb
192
168
  - lib/textus/doctor/check.rb
193
169
  - lib/textus/doctor/check/audit_log.rb
@@ -215,7 +191,10 @@ files:
215
191
  - lib/textus/domain/permission.rb
216
192
  - lib/textus/domain/policy/handler_allowlist.rb
217
193
  - lib/textus/domain/policy/matcher.rb
194
+ - lib/textus/domain/policy/predicates/accept_authority_signed.rb
195
+ - lib/textus/domain/policy/predicates/schema_valid.rb
218
196
  - lib/textus/domain/policy/promote.rb
197
+ - lib/textus/domain/policy/promotion.rb
219
198
  - lib/textus/domain/policy/refresh.rb
220
199
  - lib/textus/domain/sentinel.rb
221
200
  - lib/textus/domain/staleness.rb
@@ -228,27 +207,29 @@ files:
228
207
  - lib/textus/entry/text.rb
229
208
  - lib/textus/entry/yaml.rb
230
209
  - lib/textus/envelope.rb
210
+ - lib/textus/envelope/io/reader.rb
211
+ - lib/textus/envelope/io/writer.rb
231
212
  - lib/textus/errors.rb
232
213
  - lib/textus/etag.rb
233
214
  - lib/textus/hooks/builtin.rb
234
- - lib/textus/hooks/bus.rb
235
215
  - lib/textus/hooks/context.rb
216
+ - lib/textus/hooks/error_log.rb
217
+ - lib/textus/hooks/event_bus.rb
236
218
  - lib/textus/hooks/fire_report.rb
237
219
  - lib/textus/hooks/loader.rb
238
- - lib/textus/infra/audit_log.rb
239
- - lib/textus/infra/audit_subscriber.rb
240
- - lib/textus/infra/build_lock.rb
241
- - lib/textus/infra/clock.rb
242
- - lib/textus/infra/event_bus.rb
243
- - lib/textus/infra/publisher.rb
244
- - lib/textus/infra/refresh/detached.rb
245
- - lib/textus/infra/refresh/lock.rb
246
- - lib/textus/infra/storage/file_store.rb
220
+ - lib/textus/hooks/rpc_registry.rb
247
221
  - lib/textus/init.rb
248
222
  - lib/textus/key/distance.rb
249
223
  - lib/textus/key/grammar.rb
250
224
  - lib/textus/key/path.rb
225
+ - lib/textus/maintenance.rb
226
+ - lib/textus/maintenance/key_delete_prefix.rb
227
+ - lib/textus/maintenance/key_mv_prefix.rb
228
+ - lib/textus/maintenance/migrate.rb
229
+ - lib/textus/maintenance/rule_lint.rb
230
+ - lib/textus/maintenance/zone_mv.rb
251
231
  - lib/textus/manifest.rb
232
+ - lib/textus/manifest/data.rb
252
233
  - lib/textus/manifest/entry.rb
253
234
  - lib/textus/manifest/entry/base.rb
254
235
  - lib/textus/manifest/entry/derived.rb
@@ -262,19 +243,67 @@ files:
262
243
  - lib/textus/manifest/entry/validators/index_filename.rb
263
244
  - lib/textus/manifest/entry/validators/inject_boot.rb
264
245
  - lib/textus/manifest/entry/validators/publish_each.rb
246
+ - lib/textus/manifest/policy.rb
265
247
  - lib/textus/manifest/resolver.rb
266
248
  - lib/textus/manifest/role_kinds.rb
267
249
  - lib/textus/manifest/rules.rb
268
250
  - lib/textus/manifest/schema.rb
251
+ - lib/textus/mcp.rb
252
+ - lib/textus/mcp/errors.rb
253
+ - lib/textus/mcp/server.rb
254
+ - lib/textus/mcp/session.rb
255
+ - lib/textus/mcp/tool_schemas.rb
256
+ - lib/textus/mcp/tools.rb
269
257
  - lib/textus/mustache.rb
270
- - lib/textus/operations.rb
258
+ - lib/textus/ports/audit_log.rb
259
+ - lib/textus/ports/audit_subscriber.rb
260
+ - lib/textus/ports/build_lock.rb
261
+ - lib/textus/ports/clock.rb
262
+ - lib/textus/ports/publisher.rb
263
+ - lib/textus/ports/refresh/detached.rb
264
+ - lib/textus/ports/refresh/lock.rb
265
+ - lib/textus/ports/sentinel_store.rb
266
+ - lib/textus/ports/storage/file_stat.rb
267
+ - lib/textus/ports/storage/file_store.rb
268
+ - lib/textus/projection.rb
269
+ - lib/textus/read/audit.rb
270
+ - lib/textus/read/blame.rb
271
+ - lib/textus/read/boot.rb
272
+ - lib/textus/read/deps.rb
273
+ - lib/textus/read/doctor.rb
274
+ - lib/textus/read/freshness.rb
275
+ - lib/textus/read/get.rb
276
+ - lib/textus/read/get_or_refresh.rb
277
+ - lib/textus/read/list.rb
278
+ - lib/textus/read/policy_explain.rb
279
+ - lib/textus/read/published.rb
280
+ - lib/textus/read/pulse.rb
281
+ - lib/textus/read/rdeps.rb
282
+ - lib/textus/read/schema_envelope.rb
283
+ - lib/textus/read/stale.rb
284
+ - lib/textus/read/uid.rb
285
+ - lib/textus/read/validate_all.rb
286
+ - lib/textus/read/validator.rb
287
+ - lib/textus/read/where.rb
271
288
  - lib/textus/role.rb
289
+ - lib/textus/role_scope.rb
272
290
  - lib/textus/schema.rb
273
291
  - lib/textus/schema/tools.rb
274
292
  - lib/textus/schemas.rb
275
293
  - lib/textus/store.rb
276
294
  - lib/textus/uid.rb
277
295
  - lib/textus/version.rb
296
+ - lib/textus/write/accept.rb
297
+ - lib/textus/write/authority_gate.rb
298
+ - lib/textus/write/delete.rb
299
+ - lib/textus/write/materializer.rb
300
+ - lib/textus/write/mv.rb
301
+ - lib/textus/write/publish.rb
302
+ - lib/textus/write/put.rb
303
+ - lib/textus/write/refresh_all.rb
304
+ - lib/textus/write/refresh_orchestrator.rb
305
+ - lib/textus/write/refresh_worker.rb
306
+ - lib/textus/write/reject.rb
278
307
  homepage: https://github.com/patrick204nqh/textus
279
308
  licenses:
280
309
  - MIT
@@ -1,34 +0,0 @@
1
- require "securerandom"
2
-
3
- module Textus
4
- module Application
5
- # A Context describes the call: who is acting (role), what request this
6
- # is part of (correlation_id), what time it is (now), and whether
7
- # writes should be suppressed (dry_run).
8
- #
9
- # Collaborators (manifest, file_store, bus, audit log, authorizer) are
10
- # never read from Context — use cases declare them as explicit
11
- # constructor ports, and Operations wires them in from the Store.
12
- Context = Data.define(:role, :correlation_id, :now, :dry_run) do
13
- def self.build(role:, correlation_id: nil, now: nil, dry_run: false)
14
- new(
15
- role: role.to_s,
16
- correlation_id: correlation_id || SecureRandom.uuid,
17
- now: now || Time.now,
18
- dry_run: dry_run,
19
- )
20
- end
21
-
22
- def dry_run? = dry_run
23
-
24
- def with_role(new_role)
25
- self.class.new(
26
- role: new_role.to_s,
27
- correlation_id: correlation_id,
28
- now: now,
29
- dry_run: dry_run,
30
- )
31
- end
32
- end
33
- end
34
- end
@@ -1,91 +0,0 @@
1
- require "time"
2
- require "timeout"
3
-
4
- module Textus
5
- module Application
6
- class Projection
7
- MAX_LIMIT = 1000
8
- REDUCER_TIMEOUT_SECONDS = 2
9
-
10
- # `reader` — a callable `->(key) { envelope_or_nil }`. Caller picks
11
- # semantics: pure read (`ops.get`) for materialization paths;
12
- # `ops.get_or_refresh` if you want refresh-on-stale.
13
- # `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
14
- # `transform_resolver` — a callable `->(name) { callable_or_raise }`.
15
- # `transform_context` — `Application::Context` handed to the transform reducer.
16
- def initialize(reader:, spec:, lister:, transform_resolver:, transform_context:)
17
- @reader = reader
18
- @spec = spec || {}
19
- @lister = lister
20
- @transform_resolver = transform_resolver
21
- @transform_context = transform_context
22
- @limit = (@spec["limit"] || MAX_LIMIT).to_i
23
- raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
24
- end
25
-
26
- def run
27
- keys = collect_keys
28
- explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
29
- rows = keys.map do |key|
30
- env = @reader.call(key)
31
- row = pluck(env.meta, env.body)
32
- explicit_pluck ? row : row.merge("_key" => key)
33
- end
34
- reduced = apply_reducer(rows)
35
- # Reducers may return either an Array of rows (legacy / templated builds)
36
- # or a Hash that becomes the structured-format payload base. In the Hash
37
- # case, downstream sort/limit/position markers don't apply, and the
38
- # builder owns `_meta.generated_at` so we don't stamp it here.
39
- return reduced if reduced.is_a?(Hash)
40
-
41
- rows = reduced
42
- rows = sort(rows)
43
- rows = rows.first(@limit)
44
- mark_positions(rows)
45
- { "entries" => rows, "count" => rows.length, "generated_at" => Time.now.utc.iso8601 }
46
- end
47
-
48
- private
49
-
50
- def apply_reducer(rows)
51
- name = @spec["transform"] or return rows
52
- callable = @transform_resolver.call(name)
53
- Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
54
- callable.call(store: @transform_context, rows: rows, config: @spec["transform_config"] || {})
55
- end
56
- rescue Timeout::Error
57
- raise UsageError.new("transform_rows '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
58
- end
59
-
60
- def collect_keys
61
- prefixes = Array(@spec["select"])
62
- prefixes.flat_map { |p| @lister.call(prefix: p).map { |row| row["key"] } }.uniq
63
- end
64
-
65
- def pluck(frontmatter, _body)
66
- fields = @spec["pluck"]
67
- if fields.nil? || fields == "*"
68
- frontmatter
69
- else
70
- Array(fields).each_with_object({}) { |f, h| h[f] = frontmatter[f] if frontmatter.key?(f) }
71
- end
72
- end
73
-
74
- # Adds `_first`, `_last`, and `_index` markers so templates can emit
75
- # delimiters (e.g. JSON commas) via {{^_last}},{{/_last}}.
76
- def mark_positions(rows)
77
- last_idx = rows.length - 1
78
- rows.each_with_index do |row, i|
79
- row["_index"] = i
80
- row["_first"] = i.zero?
81
- row["_last"] = (i == last_idx)
82
- end
83
- end
84
-
85
- def sort(rows)
86
- sb = @spec["sort_by"] or return rows
87
- rows.sort_by { |r| r[sb].to_s }
88
- end
89
- end
90
- end
91
- end
@@ -1,94 +0,0 @@
1
- require "json"
2
- require "time"
3
-
4
- module Textus
5
- module Application
6
- module Reads
7
- # Queries .textus/audit.log. Filters: key, zone, role, verb, since,
8
- # correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
9
- # rows produce nil and are skipped).
10
- class Audit
11
- def initialize(manifest:, root:, audit_log: nil)
12
- @manifest = manifest
13
- @root = root
14
- @log_path = File.join(root, "audit.log")
15
- @audit_log = audit_log
16
- end
17
-
18
- # rubocop:disable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
19
- def call(key: nil, zone: nil, role: nil, verb: nil, since: nil, seq_since: nil, correlation_id: nil, limit: nil)
20
- check_cursor_expiry!(seq_since)
21
-
22
- files = all_log_files
23
- return [] if files.empty?
24
-
25
- rows = []
26
- files.each do |file|
27
- File.foreach(file) do |line|
28
- parsed = parse_row(line.chomp)
29
- next unless parsed
30
- next if key && parsed["key"] != key
31
- next if role && parsed["role"] != role
32
- next if verb && parsed["verb"] != verb
33
- next if zone && !key_in_zone?(parsed["key"], zone)
34
- next if since && (parsed["ts"].nil? || Time.parse(parsed["ts"]) < since)
35
- next if seq_since && (parsed["seq"].nil? || parsed["seq"] <= seq_since)
36
- next if correlation_id && parsed.dig("extras", "correlation_id") != correlation_id
37
-
38
- rows << parsed
39
- break if limit && rows.length >= limit
40
- end
41
- break if limit && rows.length >= limit
42
- end
43
- rows
44
- end
45
- # rubocop:enable Metrics/ParameterLists, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
46
-
47
- # Accepts ISO8601 ("2026-01-15", "2026-01-15T10:00:00Z") or a relative
48
- # offset matching /\A(\d+)([smhd])\z/. Returns nil for unparseable input.
49
- def self.parse_since(str, now: Time.now.utc)
50
- return nil if str.nil? || str.empty?
51
- return Time.parse(str) if str =~ /\A\d{4}-\d{2}-\d{2}/
52
-
53
- m = str.match(/\A(\d+)([smhd])\z/) or return nil
54
- mult = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }[m[2]]
55
- now - (m[1].to_i * mult)
56
- end
57
-
58
- private
59
-
60
- def check_cursor_expiry!(seq_since)
61
- return unless seq_since
62
-
63
- log = @audit_log || Textus::Infra::AuditLog.new(@root)
64
- min = log.min_available_seq
65
- raise Textus::CursorExpired.new(requested: seq_since, min_available: min) if min && seq_since < min - 1
66
- end
67
-
68
- def all_log_files
69
- rotated = Dir.glob(File.join(@root, "audit.log.*"))
70
- .reject { |p| p.end_with?(".meta.json") }
71
- .sort_by { |p| -p.scan(/\d+$/).first.to_i } # .5 .4 .3 .2 .1 → oldest first
72
- active = File.exist?(@log_path) ? [@log_path] : []
73
- rotated + active
74
- end
75
-
76
- def parse_row(line)
77
- return nil if line.empty?
78
- return nil unless line.start_with?("{")
79
-
80
- JSON.parse(line)
81
- rescue JSON::ParserError
82
- nil
83
- end
84
-
85
- def key_in_zone?(key, zone)
86
- mentry = @manifest.resolver.resolve(key).entry
87
- mentry && mentry.zone == zone
88
- rescue Textus::Error
89
- false
90
- end
91
- end
92
- end
93
- end
94
- end