textus 0.53.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/SPEC.md +2 -5
- data/docs/architecture/README.md +149 -159
- data/lib/textus/action/accept.rb +0 -9
- data/lib/textus/action/audit.rb +0 -2
- data/lib/textus/action/blame.rb +0 -2
- data/lib/textus/action/boot.rb +2 -11
- data/lib/textus/action/data_mv.rb +1 -3
- data/lib/textus/action/deps.rb +1 -15
- data/lib/textus/action/doctor.rb +0 -2
- data/lib/textus/action/drain.rb +4 -6
- data/lib/textus/action/enqueue.rb +3 -5
- data/lib/textus/action/get.rb +1 -3
- data/lib/textus/action/ingest.rb +135 -0
- data/lib/textus/action/jobs.rb +1 -3
- data/lib/textus/action/key_delete.rb +0 -10
- data/lib/textus/action/key_delete_prefix.rb +1 -3
- data/lib/textus/action/key_mv.rb +1 -15
- data/lib/textus/action/key_mv_prefix.rb +1 -3
- data/lib/textus/action/list.rb +0 -2
- data/lib/textus/action/propose.rb +0 -2
- data/lib/textus/action/published.rb +0 -2
- data/lib/textus/action/pulse.rb +7 -38
- data/lib/textus/action/put.rb +1 -12
- data/lib/textus/action/rdeps.rb +2 -12
- data/lib/textus/action/reject.rb +0 -12
- data/lib/textus/action/rule_explain.rb +0 -4
- data/lib/textus/action/rule_lint.rb +1 -3
- data/lib/textus/action/rule_list.rb +0 -4
- data/lib/textus/action/schema_envelope.rb +0 -2
- data/lib/textus/action/uid.rb +0 -2
- data/lib/textus/action/where.rb +0 -2
- data/lib/textus/action/write_verb.rb +4 -18
- data/lib/textus/boot.rb +33 -109
- data/lib/textus/command.rb +6 -1
- data/lib/textus/container.rb +1 -1
- data/lib/textus/core/freshness/evaluator.rb +18 -34
- data/lib/textus/doctor/check/notebook_sources.rb +50 -0
- data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
- data/lib/textus/doctor/check.rb +0 -1
- data/lib/textus/doctor.rb +2 -42
- data/lib/textus/envelope/reader.rb +46 -0
- data/lib/textus/envelope/writer.rb +209 -0
- data/lib/textus/errors.rb +8 -4
- data/lib/textus/{entry → format}/base.rb +1 -6
- data/lib/textus/{entry → format}/json.rb +1 -4
- data/lib/textus/{entry → format}/markdown.rb +2 -6
- data/lib/textus/{entry → format}/text.rb +2 -6
- data/lib/textus/{entry → format}/yaml.rb +1 -4
- data/lib/textus/{entry.rb → format.rb} +9 -10
- data/lib/textus/gate/auth.rb +58 -12
- data/lib/textus/gate.rb +2 -0
- data/lib/textus/init.rb +12 -75
- data/lib/textus/jobs/base.rb +23 -0
- data/lib/textus/jobs/materialize.rb +20 -0
- data/lib/textus/{background → jobs}/plan.rb +1 -1
- data/lib/textus/jobs/planner.rb +101 -0
- data/lib/textus/jobs/retention.rb +48 -0
- data/lib/textus/jobs/sweep.rb +27 -0
- data/lib/textus/{background → jobs}/worker.rb +2 -2
- data/lib/textus/jobs.rb +15 -0
- data/lib/textus/key/path.rb +1 -1
- data/lib/textus/manifest/entry/base.rb +4 -19
- data/lib/textus/manifest/entry/parser.rb +27 -10
- data/lib/textus/manifest/entry/produced.rb +13 -34
- data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
- data/lib/textus/manifest/entry/publish.rb +0 -12
- data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/manifest/policy/source.rb +14 -62
- data/lib/textus/manifest/policy.rb +2 -5
- data/lib/textus/manifest/resolver.rb +2 -2
- data/lib/textus/manifest/schema/keys.rb +6 -20
- data/lib/textus/manifest/schema/validator.rb +3 -11
- data/lib/textus/manifest/schema/vocabulary.rb +1 -0
- data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
- data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
- data/lib/textus/produce/engine.rb +50 -0
- data/lib/textus/produce/render.rb +18 -0
- data/lib/textus/session.rb +1 -1
- data/lib/textus/store.rb +1 -10
- data/lib/textus/surfaces/mcp/catalog.rb +1 -1
- data/lib/textus/surfaces/mcp/errors.rb +0 -9
- data/lib/textus/surfaces/mcp/routing.rb +51 -0
- data/lib/textus/surfaces/mcp/server.rb +101 -62
- data/lib/textus/surfaces/role_scope.rb +0 -4
- data/lib/textus/surfaces/watcher.rb +3 -3
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/collector.rb +27 -0
- data/lib/textus/workflow/context.rb +5 -0
- data/lib/textus/workflow/dsl.rb +44 -0
- data/lib/textus/workflow/loader.rb +17 -0
- data/lib/textus/workflow/pattern.rb +18 -0
- data/lib/textus/workflow/registry.rb +21 -0
- data/lib/textus/workflow/runner.rb +84 -0
- data/lib/textus/workflow.rb +28 -0
- data/lib/textus.rb +12 -1
- metadata +33 -61
- data/lib/textus/action/pulse/scanner.rb +0 -118
- data/lib/textus/background/job/base.rb +0 -27
- data/lib/textus/background/job/materialize.rb +0 -31
- data/lib/textus/background/job/refresh.rb +0 -22
- data/lib/textus/background/job/sweep.rb +0 -31
- data/lib/textus/background/job.rb +0 -19
- data/lib/textus/background/planner/plan.rb +0 -113
- data/lib/textus/background/retention/apply.rb +0 -50
- data/lib/textus/doctor/check/handler_permit.rb +0 -34
- data/lib/textus/doctor/check/hooks.rb +0 -25
- data/lib/textus/doctor/check/intake_registration.rb +0 -46
- data/lib/textus/envelope/io/reader.rb +0 -48
- data/lib/textus/envelope/io/writer.rb +0 -211
- data/lib/textus/events.rb +0 -21
- data/lib/textus/init/templates/machine_intake.rb +0 -49
- data/lib/textus/init/templates/orientation_reducer.rb +0 -21
- data/lib/textus/manifest/entry/validators/events.rb +0 -22
- data/lib/textus/manifest/policy/handler_permit.rb +0 -17
- data/lib/textus/pipeline/acquire/handler.rb +0 -29
- data/lib/textus/pipeline/acquire/intake.rb +0 -132
- data/lib/textus/pipeline/acquire/projection.rb +0 -129
- data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
- data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
- data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
- data/lib/textus/pipeline/acquire/serializer.rb +0 -17
- data/lib/textus/pipeline/engine.rb +0 -97
- data/lib/textus/pipeline/render.rb +0 -25
- data/lib/textus/ports/audit_subscriber.rb +0 -42
- data/lib/textus/projection.rb +0 -103
- data/lib/textus/step/base.rb +0 -35
- data/lib/textus/step/builtin/csv_fetch.rb +0 -19
- data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
- data/lib/textus/step/builtin/json_fetch.rb +0 -18
- data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
- data/lib/textus/step/builtin/rss_fetch.rb +0 -26
- data/lib/textus/step/builtin.rb +0 -22
- data/lib/textus/step/catalog.rb +0 -37
- data/lib/textus/step/context.rb +0 -74
- data/lib/textus/step/discovery.rb +0 -24
- data/lib/textus/step/error_log.rb +0 -32
- data/lib/textus/step/event_bus.rb +0 -113
- data/lib/textus/step/fetch.rb +0 -13
- data/lib/textus/step/fire_report.rb +0 -23
- data/lib/textus/step/loader.rb +0 -108
- data/lib/textus/step/observe.rb +0 -31
- data/lib/textus/step/registry_store.rb +0 -66
- data/lib/textus/step/signature.rb +0 -31
- data/lib/textus/step/transform.rb +0 -12
- data/lib/textus/step/validate.rb +0 -11
- data/lib/textus/step.rb +0 -10
data/lib/textus/action/where.rb
CHANGED
|
@@ -10,11 +10,11 @@ module Textus
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def writer(container, call)
|
|
13
|
-
Textus::Envelope::
|
|
13
|
+
Textus::Envelope::Writer.from(container: container, call: call)
|
|
14
14
|
end
|
|
15
15
|
|
|
16
16
|
def reader(container)
|
|
17
|
-
Textus::Envelope::
|
|
17
|
+
Textus::Envelope::Reader.from(container: container)
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
def run_with_cascade(target_key, container:, call:)
|
|
@@ -24,32 +24,18 @@ module Textus
|
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
def cascade_to_rdeps(key, container, call)
|
|
27
|
-
return if derived_write?(key, container)
|
|
28
|
-
|
|
29
27
|
rdeps = Textus::Action::Rdeps.new(key: key).call(container: container, call: call).fetch("rdeps", [])
|
|
30
28
|
producible = rdeps.select { |dep_key| producible?(dep_key, container) }
|
|
31
29
|
return if producible.empty?
|
|
32
30
|
|
|
33
31
|
producible.each do |dep_key|
|
|
34
|
-
Textus::
|
|
32
|
+
Textus::Jobs::Materialize.new(key: dep_key).call(container:, call:)
|
|
35
33
|
end
|
|
36
|
-
container.steps.publish(
|
|
37
|
-
:entry_written,
|
|
38
|
-
ctx: Textus::Step::Context.for(container: container, call: call),
|
|
39
|
-
key: key,
|
|
40
|
-
envelope: nil,
|
|
41
|
-
)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def derived_write?(key, container)
|
|
45
|
-
container.manifest.resolver.resolve(key).entry.derived?
|
|
46
|
-
rescue Textus::Error
|
|
47
|
-
false
|
|
48
34
|
end
|
|
49
35
|
|
|
50
36
|
def producible?(key, container)
|
|
51
37
|
entry = container.manifest.resolver.resolve(key).entry
|
|
52
|
-
|
|
38
|
+
!entry.publish_tree.nil?
|
|
53
39
|
rescue Textus::Error
|
|
54
40
|
false
|
|
55
41
|
end
|
data/lib/textus/boot.rb
CHANGED
|
@@ -1,49 +1,10 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
# Read-only "what's in this store and how do I use it" envelope.
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
# registered hooks, write flows, and the CLI verb catalog.
|
|
6
|
-
#
|
|
7
|
-
# Boot is side-effect-free.
|
|
3
|
+
# Boot is side-effect-free. Reads from pre-computed artifacts and
|
|
4
|
+
# the store catalog rather than computing inline.
|
|
8
5
|
module Boot
|
|
9
6
|
PROTOCOL_ID = PROTOCOL
|
|
10
7
|
|
|
11
|
-
# Per-capability write-flow templates. Each lambda receives the user-facing
|
|
12
|
-
# role name and the manifest, and returns guidance for that verb with the
|
|
13
|
-
# live lane named by kind (ADR 0034). A role holding multiple verbs gets one
|
|
14
|
-
# joined string; roles whose verbs have no template are omitted.
|
|
15
|
-
WRITE_FLOW_TEMPLATES = {
|
|
16
|
-
author: lambda do |name, manifest|
|
|
17
|
-
"edit files in #{lane_label(manifest, :canon, "your canon lane")}, " \
|
|
18
|
-
"then 'textus put KEY --as=#{name}'"
|
|
19
|
-
end,
|
|
20
|
-
keep: lambda do |name, manifest|
|
|
21
|
-
"keep durable notes in #{lane_label(manifest, :workspace, "your workspace")}: " \
|
|
22
|
-
"'textus put KEY --as=#{name}' (no accept needed)"
|
|
23
|
-
end,
|
|
24
|
-
propose: lambda do |name, manifest|
|
|
25
|
-
authority = manifest.policy.roles_with_capability("author").first || "the author-holder"
|
|
26
|
-
"propose changes by writing #{manifest.policy.queue_lane}.* entries with --as=#{name} " \
|
|
27
|
-
"and a 'proposal:' frontmatter block; the #{authority} role runs 'textus accept' to apply"
|
|
28
|
-
end,
|
|
29
|
-
converge: lambda do |_name, manifest|
|
|
30
|
-
machine = lane_label(manifest, :machine, "machine")
|
|
31
|
-
"'textus drain' materializes derived #{machine} entries from their sources and " \
|
|
32
|
-
"refreshes stale intake #{machine} entries from their declared source; " \
|
|
33
|
-
"derived files are never hand-edited (reactive on canon writes, or a full pass on demand)"
|
|
34
|
-
end,
|
|
35
|
-
}.freeze
|
|
36
|
-
|
|
37
|
-
def self.write_flows_for(manifest)
|
|
38
|
-
manifest.data.role_caps.each_with_object({}) do |(name, caps), acc|
|
|
39
|
-
flows = caps.filter_map do |verb|
|
|
40
|
-
tmpl = WRITE_FLOW_TEMPLATES[verb.to_sym]
|
|
41
|
-
tmpl&.call(name, manifest)
|
|
42
|
-
end
|
|
43
|
-
acc[name] = flows.join(" / ") unless flows.empty?
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
|
|
47
8
|
# Human-readable name(s) for the live lane(s) of a given kind, or `fallback`
|
|
48
9
|
# when the manifest declares none. Lets write-flow guidance name the live
|
|
49
10
|
# lane by kind instead of a hardcoded instance name (ADR 0034).
|
|
@@ -79,6 +40,7 @@ module Textus
|
|
|
79
40
|
{ "name" => "boot" },
|
|
80
41
|
{ "name" => "list" },
|
|
81
42
|
{ "name" => "get" },
|
|
43
|
+
{ "name" => "ingest" },
|
|
82
44
|
{ "name" => "where" },
|
|
83
45
|
{ "name" => "schema", "summary" => "schema operations: 'schema show KEY', 'schema diff', 'schema init', 'schema migrate'" },
|
|
84
46
|
{ "name" => "put" },
|
|
@@ -128,14 +90,6 @@ module Textus
|
|
|
128
90
|
propose_lane = manifest.policy.propose_lane_for(agent_role)
|
|
129
91
|
|
|
130
92
|
{
|
|
131
|
-
# Both verb lists derive from the MCP catalog (ADR 0056, ADR 0057): the
|
|
132
|
-
# agent's real read and write surface, named as verbs the agent calls —
|
|
133
|
-
# not CLI strings. read_verbs can neither advertise a verb the agent
|
|
134
|
-
# cannot call (audit/doctor are CLI-only; freshness is a Ruby-only
|
|
135
|
-
# internal scan, ADR 0085) nor omit one it can
|
|
136
|
-
# (schema_show/rules); write_verbs drops the old `put KEY --as=… --stdin` CLI
|
|
137
|
-
# framing (role is connection-resolved over MCP; there is no stdin).
|
|
138
|
-
# writable_lanes / propose_lane below carry the agent's write authority.
|
|
139
93
|
"read_verbs" => Textus::Surfaces::MCP::Catalog.read_verbs,
|
|
140
94
|
"write_verbs" => agent_role ? Textus::Surfaces::MCP::Catalog.write_verbs : [],
|
|
141
95
|
"writable_lanes" => writable_lanes,
|
|
@@ -144,10 +98,6 @@ module Textus
|
|
|
144
98
|
}
|
|
145
99
|
end
|
|
146
100
|
|
|
147
|
-
# Recipes reference verbs, not a transport's CLI strings (ADR 0056): every
|
|
148
|
-
# step names a verb the agent can call (each transport frames it — CLI as
|
|
149
|
-
# `textus get KEY`, MCP as the `get` tool) or is a plain materialize step. This
|
|
150
|
-
# keeps shell lines out of the surface an MCP agent reads.
|
|
151
101
|
def self.recipes(manifest)
|
|
152
102
|
queue = manifest.policy.queue_lane
|
|
153
103
|
feeds = lane_label(manifest, :machine, "the machine lane")
|
|
@@ -198,33 +148,44 @@ module Textus
|
|
|
198
148
|
)
|
|
199
149
|
end
|
|
200
150
|
|
|
201
|
-
def self.build(container
|
|
151
|
+
def self.build(container:)
|
|
202
152
|
manifest = container.manifest
|
|
203
153
|
etag = Textus::Etag.for_contract(container.root)
|
|
204
154
|
|
|
205
|
-
if lean
|
|
206
|
-
return {
|
|
207
|
-
"protocol" => PROTOCOL_ID,
|
|
208
|
-
"store_root" => container.root,
|
|
209
|
-
"lanes" => lanes_for(manifest),
|
|
210
|
-
"agent_quickstart" => agent_quickstart(manifest, container.audit_log),
|
|
211
|
-
"contract_etag" => etag,
|
|
212
|
-
}
|
|
213
|
-
end
|
|
214
|
-
|
|
215
155
|
{
|
|
216
156
|
"protocol" => PROTOCOL_ID,
|
|
217
157
|
"store_root" => container.root,
|
|
158
|
+
"contract_etag" => etag,
|
|
218
159
|
"lanes" => lanes_for(manifest),
|
|
219
|
-
"entries" => entries_for(manifest),
|
|
220
|
-
"hooks" => hooks_for_container(container),
|
|
221
|
-
"write_flows" => write_flows_for(manifest),
|
|
222
|
-
"cli_verbs" => CLI_VERBS.map(&:dup),
|
|
223
|
-
"agent_protocol" => agent_protocol(manifest),
|
|
224
160
|
"agent_quickstart" => agent_quickstart(manifest, container.audit_log),
|
|
225
|
-
"
|
|
226
|
-
"
|
|
227
|
-
|
|
161
|
+
"orientation" => read_artifact_content(container, "artifacts.orientation"),
|
|
162
|
+
"context" => read_boot_context(container),
|
|
163
|
+
"index_key" => "artifacts.index",
|
|
164
|
+
"agent_protocol" => agent_protocol(manifest),
|
|
165
|
+
}.compact
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def self.read_artifact_content(container, key)
|
|
169
|
+
res = container.manifest.resolver.resolve(key)
|
|
170
|
+
return nil unless res.path && File.exist?(res.path)
|
|
171
|
+
|
|
172
|
+
call = Textus::Call.build(role: Textus::Role::DEFAULT)
|
|
173
|
+
env = Textus::Action::Get.new(key: key).call(container: container, call: call)
|
|
174
|
+
env&.content
|
|
175
|
+
rescue Textus::Error
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def self.read_boot_context(container)
|
|
180
|
+
res = container.manifest.resolver.resolve("knowledge.boot")
|
|
181
|
+
return nil unless res.path && File.exist?(res.path)
|
|
182
|
+
|
|
183
|
+
call = Textus::Call.build(role: Textus::Role::DEFAULT)
|
|
184
|
+
env = Textus::Action::Get.new(key: "knowledge.boot").call(container: container, call: call)
|
|
185
|
+
body = env&.body&.strip
|
|
186
|
+
body.nil? || body.empty? ? nil : body
|
|
187
|
+
rescue Textus::Error
|
|
188
|
+
nil
|
|
228
189
|
end
|
|
229
190
|
|
|
230
191
|
def self.lanes_for(manifest)
|
|
@@ -238,42 +199,5 @@ module Textus
|
|
|
238
199
|
row
|
|
239
200
|
end
|
|
240
201
|
end
|
|
241
|
-
|
|
242
|
-
def self.entries_for(manifest)
|
|
243
|
-
manifest.data.entries.map do |e|
|
|
244
|
-
derived = e.derived?
|
|
245
|
-
{
|
|
246
|
-
"key" => e.key,
|
|
247
|
-
"lane" => e.lane,
|
|
248
|
-
"schema" => e.schema,
|
|
249
|
-
"nested" => e.is_a?(Textus::Manifest::Entry::Nested),
|
|
250
|
-
"owner" => e.owner,
|
|
251
|
-
"format" => e.format,
|
|
252
|
-
"derived" => derived,
|
|
253
|
-
"intake" => e.intake?,
|
|
254
|
-
"publish_to" => Array(e.publish_to),
|
|
255
|
-
}
|
|
256
|
-
end
|
|
257
|
-
end
|
|
258
|
-
|
|
259
|
-
def self.hooks_for_container(container)
|
|
260
|
-
hooks_for_container_internal(steps: container.steps)
|
|
261
|
-
end
|
|
262
|
-
|
|
263
|
-
def self.hooks_for_container_internal(steps:)
|
|
264
|
-
sections = {}
|
|
265
|
-
rpc_kind_map = {
|
|
266
|
-
resolve_handler: :fetch,
|
|
267
|
-
transform_rows: :transform,
|
|
268
|
-
validate: :validate,
|
|
269
|
-
}
|
|
270
|
-
Step::Catalog::RPC.each_key do |event|
|
|
271
|
-
sections[event.to_s] = steps.names(rpc_kind_map.fetch(event)).map(&:to_s).sort
|
|
272
|
-
end
|
|
273
|
-
Step::Catalog::PUBSUB.each_key do |event|
|
|
274
|
-
sections[event.to_s] = steps.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
|
|
275
|
-
end
|
|
276
|
-
sections
|
|
277
|
-
end
|
|
278
202
|
end
|
|
279
203
|
end
|
data/lib/textus/command.rb
CHANGED
|
@@ -12,6 +12,11 @@ module Textus
|
|
|
12
12
|
Accept = Data.define(:pending_key, :role)
|
|
13
13
|
Reject = Data.define(:pending_key, :role)
|
|
14
14
|
Enqueue = Data.define(:type, :args, :role)
|
|
15
|
+
Ingest = Data.define(:kind, :slug, :url, :path, :zone, :label, :role) do
|
|
16
|
+
def initialize(kind:, slug:, role:, url: nil, path: nil, zone: nil, label: nil)
|
|
17
|
+
super
|
|
18
|
+
end
|
|
19
|
+
end
|
|
15
20
|
List = Data.define(:prefix, :lane, :role)
|
|
16
21
|
Where = Data.define(:key, :role)
|
|
17
22
|
Uid = Data.define(:key, :role)
|
|
@@ -26,7 +31,7 @@ module Textus
|
|
|
26
31
|
Published = Data.define(:role)
|
|
27
32
|
SchemaShow = Data.define(:key, :role)
|
|
28
33
|
Doctor = Data.define(:checks, :role)
|
|
29
|
-
Boot = Data.define(:
|
|
34
|
+
Boot = Data.define(:role)
|
|
30
35
|
Jobs = Data.define(:state, :action, :job_id, :role)
|
|
31
36
|
DataMv = Data.define(:from, :to, :dry_run, :role)
|
|
32
37
|
KeyMvPrefix = Data.define(:from_prefix, :to_prefix, :dry_run, :role)
|
data/lib/textus/container.rb
CHANGED
|
@@ -4,15 +4,11 @@ module Textus
|
|
|
4
4
|
module Core
|
|
5
5
|
module Freshness
|
|
6
6
|
# The single currency evaluator (ADR 0099). Answers "is the stored data
|
|
7
|
-
# stale relative to its
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
# (skipped — a cadence-less handler is not auto-re-pulled).
|
|
7
|
+
# stale relative to its retention rule?" and detects generator drift for
|
|
8
|
+
# external entries.
|
|
9
|
+
# - retention rule TTL -> AGE signal: now - file_basis > ttl_seconds
|
|
11
10
|
# - external -> DRIFT signal: a source changed since generated.at
|
|
12
|
-
# (surfaced by the doctor generator_drift check
|
|
13
|
-
# fresh at read time because converge runs them reactively).
|
|
14
|
-
# Replaces Core::IntakeStaleness and Core::Staleness::GeneratorCheck and
|
|
15
|
-
# the inline copies in Read::Get / Read::Freshness.
|
|
11
|
+
# (surfaced by the doctor generator_drift check).
|
|
16
12
|
class Evaluator
|
|
17
13
|
def initialize(manifest:, file_stat:, clock:)
|
|
18
14
|
@manifest = manifest
|
|
@@ -20,32 +16,28 @@ module Textus
|
|
|
20
16
|
@clock = clock
|
|
21
17
|
end
|
|
22
18
|
|
|
23
|
-
# Per-entry currency Verdict (
|
|
24
|
-
# entries are always fresh (retention is GC, not content currency).
|
|
19
|
+
# Per-entry currency Verdict driven by the retention rule TTL (if any).
|
|
25
20
|
def verdict(mentry)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
ttl = mentry.source.ttl_seconds
|
|
21
|
+
ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
|
|
29
22
|
return fresh if ttl.nil?
|
|
30
23
|
|
|
31
|
-
stale = age_stale?(
|
|
24
|
+
stale = age_stale?(file_basis(mentry), ttl)
|
|
32
25
|
Verdict.build(stale: stale, reason: stale ? "ttl exceeded" : nil, fetching: false)
|
|
33
26
|
end
|
|
34
27
|
|
|
35
|
-
# Keys of
|
|
36
|
-
|
|
37
|
-
# is :no_policy and skipped; a never-recorded one (with a ttl) is stale.
|
|
38
|
-
def stale_intake_keys(prefix: nil, lane: nil)
|
|
28
|
+
# Keys of entries past their retention rule TTL — the refresh produce scope.
|
|
29
|
+
def stale_keys(prefix: nil, lane: nil)
|
|
39
30
|
@manifest.data.entries.select { |m| due?(m, prefix: prefix, lane: lane) }.map(&:key)
|
|
40
31
|
end
|
|
41
32
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
33
|
+
alias stale_intake_keys stale_keys
|
|
34
|
+
|
|
35
|
+
# File basis as a Time (or nil): file mtime when present, else nil.
|
|
36
|
+
def file_basis(mentry)
|
|
45
37
|
path = @manifest.resolver.resolve(mentry.key).path
|
|
46
38
|
return nil unless @file_stat.exists?(path)
|
|
47
39
|
|
|
48
|
-
|
|
40
|
+
@file_stat.mtime(path)
|
|
49
41
|
end
|
|
50
42
|
|
|
51
43
|
# Generator-drift rows for one entry (replaces Staleness::GeneratorCheck#
|
|
@@ -63,17 +55,16 @@ module Textus
|
|
|
63
55
|
def fresh = Verdict.build(stale: false, reason: nil, fetching: false)
|
|
64
56
|
|
|
65
57
|
def due?(mentry, prefix:, lane:)
|
|
66
|
-
return false unless mentry.intake?
|
|
67
58
|
return false if lane && mentry.lane != lane
|
|
68
59
|
return false if prefix && !mentry.key.start_with?(prefix)
|
|
69
60
|
|
|
70
|
-
ttl = mentry.
|
|
71
|
-
return false if ttl.nil?
|
|
61
|
+
ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
|
|
62
|
+
return false if ttl.nil?
|
|
72
63
|
|
|
73
64
|
path = @manifest.resolver.resolve(mentry.key).path
|
|
74
65
|
return true unless @file_stat.exists?(path)
|
|
75
66
|
|
|
76
|
-
age_stale?(
|
|
67
|
+
age_stale?(file_basis(mentry), ttl)
|
|
77
68
|
end
|
|
78
69
|
|
|
79
70
|
# The one age comparison. A never-recorded entry (nil basis) is stale.
|
|
@@ -83,13 +74,6 @@ module Textus
|
|
|
83
74
|
(@clock.now - basis).to_i > ttl
|
|
84
75
|
end
|
|
85
76
|
|
|
86
|
-
def last_fetched_at(mentry, path)
|
|
87
|
-
meta = Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]
|
|
88
|
-
Time.parse(meta["last_fetched_at"].to_s) if meta && meta["last_fetched_at"]
|
|
89
|
-
rescue StandardError
|
|
90
|
-
nil
|
|
91
|
-
end
|
|
92
|
-
|
|
93
77
|
# --- generator drift (lifted from Staleness::GeneratorCheck) ---
|
|
94
78
|
|
|
95
79
|
def drift_applicable?(mentry) = mentry.external?
|
|
@@ -108,7 +92,7 @@ module Textus
|
|
|
108
92
|
end
|
|
109
93
|
|
|
110
94
|
def generated_at_of(mentry, path)
|
|
111
|
-
|
|
95
|
+
Format.for(mentry.format).parse(@file_stat.read(path), path: path)["_meta"].dig("generated", "at")
|
|
112
96
|
end
|
|
113
97
|
|
|
114
98
|
def parse_time(str)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Doctor
|
|
3
|
+
class Check
|
|
4
|
+
class NotebookSources < Check
|
|
5
|
+
def call
|
|
6
|
+
issues = []
|
|
7
|
+
manifest.resolver.enumerate.each do |row|
|
|
8
|
+
next unless row[:key].start_with?("notebook.notes.")
|
|
9
|
+
next unless row[:path] && File.exist?(row[:path])
|
|
10
|
+
|
|
11
|
+
sources = parse_sources(row[:path])
|
|
12
|
+
sources.each do |raw_key|
|
|
13
|
+
next if raw_entry_exists?(raw_key)
|
|
14
|
+
|
|
15
|
+
issues << {
|
|
16
|
+
"code" => "notebook.source_missing",
|
|
17
|
+
"level" => "warning",
|
|
18
|
+
"subject" => row[:key],
|
|
19
|
+
"message" => "notebook entry '#{row[:key]}' references raw key '#{raw_key}' " \
|
|
20
|
+
"which does not exist in the store",
|
|
21
|
+
"fix" => "re-ingest the source: textus ingest ..., or remove the stale sources: entry",
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
issues
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def parse_sources(path)
|
|
31
|
+
content = File.read(path)
|
|
32
|
+
match = content.match(/\A---\n(.*?)\n---/m)
|
|
33
|
+
return [] unless match
|
|
34
|
+
|
|
35
|
+
front = YAML.safe_load(match[1])
|
|
36
|
+
Array(front&.dig("sources"))
|
|
37
|
+
rescue StandardError
|
|
38
|
+
[]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def raw_entry_exists?(key)
|
|
42
|
+
path = manifest.resolver.resolve(key).path
|
|
43
|
+
File.exist?(path)
|
|
44
|
+
rescue Textus::UnknownKey, Textus::Error
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Doctor
|
|
3
|
+
class Check
|
|
4
|
+
class RawAssetPaths < Check
|
|
5
|
+
def call
|
|
6
|
+
raw_lane = manifest.policy.lanes_of_kind(:raw).first
|
|
7
|
+
return [] unless raw_lane
|
|
8
|
+
|
|
9
|
+
issues = []
|
|
10
|
+
manifest.resolver.enumerate.each do |row|
|
|
11
|
+
next unless row[:key].start_with?("raw.")
|
|
12
|
+
next unless row[:path] && File.exist?(row[:path])
|
|
13
|
+
|
|
14
|
+
raw_content = load_content(row[:path])
|
|
15
|
+
next unless raw_content.is_a?(Hash)
|
|
16
|
+
|
|
17
|
+
asset = raw_content["asset"]
|
|
18
|
+
next unless asset.is_a?(String) && !asset.empty?
|
|
19
|
+
|
|
20
|
+
asset_path = find_asset_path(asset)
|
|
21
|
+
next if File.exist?(asset_path)
|
|
22
|
+
|
|
23
|
+
issues << {
|
|
24
|
+
"code" => "raw_asset.missing_file",
|
|
25
|
+
"level" => "error",
|
|
26
|
+
"subject" => row[:key],
|
|
27
|
+
"message" => "raw entry '#{row[:key]}' references asset '#{asset}' " \
|
|
28
|
+
"which does not exist at #{asset_path}",
|
|
29
|
+
"fix" => "re-ingest the asset: textus key-delete #{row[:key]}, then textus ingest",
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
issues
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def load_content(path)
|
|
38
|
+
require "yaml"
|
|
39
|
+
YAML.safe_load_file(path)
|
|
40
|
+
rescue StandardError
|
|
41
|
+
nil
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def find_asset_path(asset_rel)
|
|
45
|
+
File.join(root, "assets", asset_rel)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/textus/doctor/check.rb
CHANGED
data/lib/textus/doctor.rb
CHANGED
|
@@ -1,12 +1,9 @@
|
|
|
1
|
-
require "timeout"
|
|
2
|
-
|
|
3
1
|
module Textus
|
|
4
2
|
# Health check for a Textus store. Returns a JSON-friendly Hash envelope
|
|
5
3
|
# with an `issues` array and a summary. Each issue is a Hash with
|
|
6
4
|
# `code`, `level`, `subject`, `message`, and optionally `fix`.
|
|
7
5
|
module Doctor
|
|
8
6
|
LEVELS = %w[error warning info].freeze
|
|
9
|
-
DOCTOR_CHECK_TIMEOUT_SECONDS = 2
|
|
10
7
|
|
|
11
8
|
CHECKS = [
|
|
12
9
|
Check::ProtocolVersion,
|
|
@@ -14,19 +11,18 @@ module Textus
|
|
|
14
11
|
Check::Schemas,
|
|
15
12
|
Check::SchemaParseError,
|
|
16
13
|
Check::Templates,
|
|
17
|
-
Check::Hooks,
|
|
18
|
-
Check::IntakeRegistration,
|
|
19
14
|
Check::IllegalKeys,
|
|
20
15
|
Check::Sentinels,
|
|
21
16
|
Check::AuditLog,
|
|
22
17
|
Check::UnownedSchemaFields,
|
|
23
18
|
Check::SchemaViolations,
|
|
24
19
|
Check::RuleAmbiguity,
|
|
25
|
-
Check::HandlerPermit,
|
|
26
20
|
Check::OrphanedPublishTargets,
|
|
27
21
|
Check::PublishTreeIndexOverlap,
|
|
28
22
|
Check::ProposalTargets,
|
|
29
23
|
Check::GeneratorDrift,
|
|
24
|
+
Check::RawAssetPaths,
|
|
25
|
+
Check::NotebookSources,
|
|
30
26
|
].freeze
|
|
31
27
|
|
|
32
28
|
ALL_CHECKS = CHECKS.map(&:name_key).freeze
|
|
@@ -44,7 +40,6 @@ module Textus
|
|
|
44
40
|
|
|
45
41
|
selected = CHECKS.select { |c| selected_keys.include?(c.name_key) }
|
|
46
42
|
issues = selected.flat_map { |c| c.new(container, role:).call }
|
|
47
|
-
issues.concat(run_registered_checks(container))
|
|
48
43
|
|
|
49
44
|
summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
|
|
50
45
|
{
|
|
@@ -54,40 +49,5 @@ module Textus
|
|
|
54
49
|
"summary" => summary,
|
|
55
50
|
}
|
|
56
51
|
end
|
|
57
|
-
|
|
58
|
-
def run_registered_checks(container)
|
|
59
|
-
container.steps.names(:validate).flat_map { |name| invoke_registered_check(container, name) }
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def invoke_registered_check(container, name)
|
|
63
|
-
result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) do
|
|
64
|
-
container.steps.invoke(:validate, name, caps: container)
|
|
65
|
-
end
|
|
66
|
-
return result.map { |h| h.transform_keys(&:to_s) } if result.is_a?(Array)
|
|
67
|
-
|
|
68
|
-
[fail_issue(name, code: "doctor_check.bad_return",
|
|
69
|
-
message: "doctor_check '#{name}' returned #{result.class} (expected Array)",
|
|
70
|
-
fix: "return an array of issue hashes from the doctor_check block")]
|
|
71
|
-
rescue Timeout::Error
|
|
72
|
-
[fail_issue(name, code: "doctor_check.timeout",
|
|
73
|
-
message: "doctor_check '#{name}' exceeded #{DOCTOR_CHECK_TIMEOUT_SECONDS}s",
|
|
74
|
-
fix: "shorten the check or split it into smaller checks")]
|
|
75
|
-
rescue StandardError => e
|
|
76
|
-
[fail_issue(name, code: "doctor_check.failed",
|
|
77
|
-
message: "#{e.class}: #{e.message}",
|
|
78
|
-
fix: "fix the :validate step in .textus/steps/validate/")]
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def fail_issue(name, code:, message:, fix:)
|
|
82
|
-
{
|
|
83
|
-
"code" => code,
|
|
84
|
-
"level" => "error",
|
|
85
|
-
"subject" => name.to_s,
|
|
86
|
-
"message" => message,
|
|
87
|
-
"fix" => fix,
|
|
88
|
-
}
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
private_class_method :run_registered_checks, :invoke_registered_check, :fail_issue
|
|
92
52
|
end
|
|
93
53
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Envelope
|
|
3
|
+
# Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
|
|
4
|
+
# bytes, parses them via the format strategy, and hands back an
|
|
5
|
+
# Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
|
|
6
|
+
# (existing-uid lookup for the uid-preservation step in #put).
|
|
7
|
+
#
|
|
8
|
+
# No audit, no events, no permission checks — those live one layer up.
|
|
9
|
+
class Reader
|
|
10
|
+
def self.from(container:)
|
|
11
|
+
new(file_store: container.file_store, manifest: container.manifest)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(file_store:, manifest:)
|
|
15
|
+
@file_store = file_store
|
|
16
|
+
@manifest = manifest
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def read(key)
|
|
20
|
+
res = @manifest.resolver.resolve(key)
|
|
21
|
+
path = res.path
|
|
22
|
+
return nil unless @file_store.exists?(path)
|
|
23
|
+
|
|
24
|
+
mentry = res.entry
|
|
25
|
+
raw = @file_store.read(path)
|
|
26
|
+
parsed = Format.for(mentry.format).parse(raw, path: path)
|
|
27
|
+
Textus::Envelope.build(
|
|
28
|
+
key: key, mentry: mentry, path: path,
|
|
29
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
30
|
+
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def existing_uid(key)
|
|
35
|
+
env = read(key)
|
|
36
|
+
env&.uid
|
|
37
|
+
rescue StandardError
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def exists?(key)
|
|
42
|
+
@file_store.exists?(@manifest.resolver.resolve(key).path)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|