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.
- checksums.yaml +4 -4
- data/ARCHITECTURE.md +118 -68
- data/CHANGELOG.md +132 -0
- data/README.md +61 -19
- data/SPEC.md +107 -46
- data/docs/conventions.md +4 -4
- data/lib/textus/boot.rb +18 -12
- data/lib/textus/builder/pipeline.rb +13 -12
- data/lib/textus/call.rb +28 -0
- data/lib/textus/cli/verb/audit.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +1 -1
- data/lib/textus/cli/verb/build.rb +2 -2
- data/lib/textus/cli/verb/doctor.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +2 -6
- data/lib/textus/cli/verb/put.rb +5 -14
- data/lib/textus/cli/verb/retain.rb +19 -0
- data/lib/textus/cli/verb/rule_list.rb +1 -1
- data/lib/textus/cli/verb.rb +6 -6
- data/lib/textus/cli.rb +19 -23
- data/lib/textus/container.rb +23 -0
- data/lib/textus/dispatcher.rb +57 -0
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +10 -8
- data/lib/textus/doctor/check.rb +15 -5
- data/lib/textus/doctor.rb +7 -7
- data/lib/textus/domain/authorizer.rb +2 -2
- data/lib/textus/domain/duration.rb +22 -0
- data/lib/textus/domain/policy/refresh.rb +1 -15
- data/lib/textus/domain/policy/retention.rb +26 -0
- data/lib/textus/domain/retention.rb +44 -0
- data/lib/textus/domain/sentinel.rb +9 -65
- data/lib/textus/domain/staleness/generator_check.rb +46 -26
- data/lib/textus/domain/staleness/intake_check.rb +18 -10
- data/lib/textus/domain/staleness.rb +3 -3
- data/lib/textus/{application/envelope → envelope/io}/reader.rb +6 -2
- data/lib/textus/{application/envelope → envelope/io}/writer.rb +19 -11
- data/lib/textus/hooks/context.rb +30 -13
- data/lib/textus/hooks/event_bus.rb +8 -20
- data/lib/textus/hooks/rpc_registry.rb +9 -35
- data/lib/textus/hooks/signature.rb +31 -0
- data/lib/textus/init.rb +7 -6
- data/lib/textus/maintenance/key_delete_prefix.rb +36 -0
- data/lib/textus/maintenance/key_mv_prefix.rb +46 -0
- data/lib/textus/maintenance/migrate.rb +51 -0
- data/lib/textus/maintenance/rule_lint.rb +56 -0
- data/lib/textus/maintenance/zone_mv.rb +51 -0
- data/lib/textus/maintenance.rb +15 -0
- data/lib/textus/manifest/data.rb +9 -4
- data/lib/textus/manifest/entry/base.rb +38 -18
- data/lib/textus/manifest/entry/derived.rb +6 -6
- data/lib/textus/manifest/entry/nested.rb +7 -9
- data/lib/textus/manifest/entry/parser.rb +2 -2
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/manifest/entry/validators/format_matrix.rb +2 -2
- data/lib/textus/manifest/entry/validators/index_filename.rb +1 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +4 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +2 -2
- data/lib/textus/manifest/entry.rb +0 -5
- data/lib/textus/manifest/policy.rb +34 -7
- data/lib/textus/manifest/rules.rb +10 -1
- data/lib/textus/manifest/schema.rb +54 -4
- data/lib/textus/manifest.rb +4 -8
- data/lib/textus/mcp/server.rb +2 -11
- data/lib/textus/mcp/session.rb +13 -20
- data/lib/textus/mcp/tools.rb +2 -2
- data/lib/textus/mcp.rb +1 -1
- data/lib/textus/{infra → ports}/audit_log.rb +1 -1
- data/lib/textus/{infra → ports}/audit_subscriber.rb +2 -2
- data/lib/textus/{infra → ports}/build_lock.rb +1 -1
- data/lib/textus/{infra → ports}/clock.rb +1 -1
- data/lib/textus/{infra → ports}/publisher.rb +6 -6
- data/lib/textus/{infra → ports}/refresh/detached.rb +3 -3
- data/lib/textus/{infra → ports}/refresh/lock.rb +1 -1
- data/lib/textus/ports/sentinel_store.rb +67 -0
- data/lib/textus/ports/storage/file_stat.rb +19 -0
- data/lib/textus/{infra → ports}/storage/file_store.rb +1 -1
- data/lib/textus/projection.rb +91 -0
- data/lib/textus/read/audit.rb +111 -0
- data/lib/textus/read/blame.rb +81 -0
- data/lib/textus/read/boot.rb +18 -0
- data/lib/textus/read/deps.rb +24 -0
- data/lib/textus/read/doctor.rb +19 -0
- data/lib/textus/read/freshness.rb +101 -0
- data/lib/textus/read/get.rb +66 -0
- data/lib/textus/read/get_or_refresh.rb +69 -0
- data/lib/textus/read/list.rb +15 -0
- data/lib/textus/read/policy_explain.rb +42 -0
- data/lib/textus/read/published.rb +15 -0
- data/lib/textus/read/pulse.rb +89 -0
- data/lib/textus/read/rdeps.rb +25 -0
- data/lib/textus/read/retainable.rb +17 -0
- data/lib/textus/read/schema_envelope.rb +16 -0
- data/lib/textus/read/stale.rb +17 -0
- data/lib/textus/read/uid.rb +20 -0
- data/lib/textus/read/validate_all.rb +22 -0
- data/lib/textus/read/validator.rb +84 -0
- data/lib/textus/read/where.rb +16 -0
- data/lib/textus/role_scope.rb +50 -0
- data/lib/textus/schema/tools.rb +3 -3
- data/lib/textus/store.rb +16 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +86 -0
- data/lib/textus/write/authority_gate.rb +24 -0
- data/lib/textus/write/delete.rb +40 -0
- data/lib/textus/write/intake_fetch.rb +23 -0
- data/lib/textus/write/materializer.rb +48 -0
- data/lib/textus/write/mv.rb +113 -0
- data/lib/textus/write/publish.rb +66 -0
- data/lib/textus/write/put.rb +45 -0
- data/lib/textus/write/refresh_all.rb +44 -0
- data/lib/textus/write/refresh_orchestrator.rb +102 -0
- data/lib/textus/write/refresh_worker.rb +124 -0
- data/lib/textus/write/reject.rb +54 -0
- data/lib/textus/write/retention_sweep.rb +55 -0
- data/lib/textus.rb +1 -2
- metadata +62 -50
- data/lib/textus/application/caps.rb +0 -49
- data/lib/textus/application/context.rb +0 -34
- data/lib/textus/application/maintenance/key_delete_prefix.rb +0 -44
- data/lib/textus/application/maintenance/key_mv_prefix.rb +0 -57
- data/lib/textus/application/maintenance/migrate.rb +0 -59
- data/lib/textus/application/maintenance/rule_lint.rb +0 -65
- data/lib/textus/application/maintenance/zone_mv.rb +0 -60
- data/lib/textus/application/maintenance.rb +0 -17
- data/lib/textus/application/projection.rb +0 -93
- data/lib/textus/application/read/audit.rb +0 -106
- data/lib/textus/application/read/blame.rb +0 -91
- data/lib/textus/application/read/deps.rb +0 -34
- data/lib/textus/application/read/freshness.rb +0 -110
- data/lib/textus/application/read/get.rb +0 -75
- data/lib/textus/application/read/get_or_refresh.rb +0 -63
- data/lib/textus/application/read/list.rb +0 -25
- data/lib/textus/application/read/policy_explain.rb +0 -47
- data/lib/textus/application/read/published.rb +0 -25
- data/lib/textus/application/read/pulse.rb +0 -101
- data/lib/textus/application/read/rdeps.rb +0 -35
- data/lib/textus/application/read/schema_envelope.rb +0 -26
- data/lib/textus/application/read/stale.rb +0 -23
- data/lib/textus/application/read/uid.rb +0 -30
- data/lib/textus/application/read/validate_all.rb +0 -32
- data/lib/textus/application/read/validator.rb +0 -86
- data/lib/textus/application/read/where.rb +0 -26
- data/lib/textus/application/use_case.rb +0 -22
- data/lib/textus/application/write/accept.rb +0 -102
- data/lib/textus/application/write/authority_gate.rb +0 -26
- data/lib/textus/application/write/delete.rb +0 -45
- data/lib/textus/application/write/materializer.rb +0 -49
- data/lib/textus/application/write/mv.rb +0 -118
- data/lib/textus/application/write/publish.rb +0 -96
- data/lib/textus/application/write/put.rb +0 -49
- data/lib/textus/application/write/refresh_all.rb +0 -63
- data/lib/textus/application/write/refresh_orchestrator.rb +0 -102
- data/lib/textus/application/write/refresh_worker.rb +0 -134
- data/lib/textus/application/write/reject.rb +0 -62
- data/lib/textus/session.rb +0 -84
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
module
|
|
4
|
+
module Ports
|
|
5
5
|
# Publishes built artifacts from the store to repo-relative consumer paths.
|
|
6
6
|
# Publish = copy + sentinel. The in-store file is already the consumer-shaped
|
|
7
7
|
# artifact; no parsing or stripping.
|
|
8
8
|
#
|
|
9
|
-
# Sentinel I/O is delegated to Textus::
|
|
10
|
-
# `<store_root>/sentinels/` and mirror the target's repo-relative layout
|
|
11
|
-
# consumer directories aren't polluted with `.textus-managed.json` siblings.
|
|
9
|
+
# Sentinel I/O is delegated to Textus::Ports::SentinelStore. Sentinels live
|
|
10
|
+
# under `<store_root>/sentinels/` and mirror the target's repo-relative layout
|
|
11
|
+
# so consumer directories aren't polluted with `.textus-managed.json` siblings.
|
|
12
12
|
module Publisher
|
|
13
13
|
def self.publish(source:, target:, store_root:)
|
|
14
14
|
FileUtils.mkdir_p(File.dirname(target))
|
|
15
15
|
refuse_if_unmanaged(target, store_root)
|
|
16
16
|
File.delete(target) if File.symlink?(target)
|
|
17
17
|
FileUtils.cp(source, target)
|
|
18
|
-
Textus::
|
|
18
|
+
Textus::Ports::SentinelStore.new.write!(target: target, source: source, store_root: store_root)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def self.refuse_if_unmanaged(target, store_root)
|
|
@@ -26,7 +26,7 @@ module Textus
|
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def self.managed?(target, store_root)
|
|
29
|
-
File.exist?(Textus::
|
|
29
|
+
File.exist?(Textus::Ports::SentinelStore.new.sentinel_path(target, store_root))
|
|
30
30
|
end
|
|
31
31
|
end
|
|
32
32
|
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Ports
|
|
3
3
|
module Refresh
|
|
4
4
|
module Detached
|
|
5
5
|
module_function
|
|
@@ -16,12 +16,12 @@ module Textus
|
|
|
16
16
|
$stdout.reopen(File::NULL, "w")
|
|
17
17
|
$stderr.reopen(File::NULL, "w")
|
|
18
18
|
|
|
19
|
-
lock = Textus::
|
|
19
|
+
lock = Textus::Ports::Refresh::Lock.new(root: store_root, key: key)
|
|
20
20
|
exit(0) unless lock.try_acquire
|
|
21
21
|
|
|
22
22
|
begin
|
|
23
23
|
store = Textus::Store.new(store_root)
|
|
24
|
-
store.
|
|
24
|
+
store.as("runner").refresh(key)
|
|
25
25
|
rescue StandardError
|
|
26
26
|
# Already logged via :refresh_failed; exit cleanly.
|
|
27
27
|
ensure
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "digest"
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Textus
|
|
6
|
+
module Ports
|
|
7
|
+
# Persistence adapter for sentinel files. Owns the on-disk JSON shape, the
|
|
8
|
+
# path layout (<store_root>/sentinels/<target-rel-to-repo>.textus-managed.json),
|
|
9
|
+
# and all File/FileUtils I/O. Domain::Sentinel is a pure value object that
|
|
10
|
+
# depends on this port for reads and writes.
|
|
11
|
+
class SentinelStore
|
|
12
|
+
SUFFIX = ".textus-managed.json".freeze
|
|
13
|
+
DIR = "sentinels".freeze
|
|
14
|
+
|
|
15
|
+
def write!(target:, source:, store_root:)
|
|
16
|
+
path = sentinel_path(target, store_root)
|
|
17
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
18
|
+
repo_root = File.dirname(store_root)
|
|
19
|
+
File.write(path, JSON.generate(
|
|
20
|
+
"source" => rel_or_abs(source, repo_root),
|
|
21
|
+
"target" => rel_or_abs(target, repo_root),
|
|
22
|
+
"sha256" => Digest::SHA256.hexdigest(File.binread(target)),
|
|
23
|
+
"mode" => "copy",
|
|
24
|
+
))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def load(path, repo_root)
|
|
28
|
+
raw = JSON.parse(File.read(path))
|
|
29
|
+
Textus::Domain::Sentinel.new(
|
|
30
|
+
target: absolutize(raw["target"], repo_root),
|
|
31
|
+
source: absolutize(raw["source"], repo_root),
|
|
32
|
+
sha256: raw["sha256"],
|
|
33
|
+
mode: raw["mode"],
|
|
34
|
+
)
|
|
35
|
+
rescue JSON::ParserError, Errno::ENOENT
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def sentinel_path(target, store_root)
|
|
40
|
+
repo_root = File.dirname(store_root)
|
|
41
|
+
rel = relative_to(target, repo_root) || File.basename(target)
|
|
42
|
+
File.join(store_root, DIR, rel + SUFFIX)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def rel_or_abs(path, repo_root)
|
|
48
|
+
relative_to(path, repo_root) || File.expand_path(path)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def relative_to(path, repo_root)
|
|
52
|
+
path = File.expand_path(path)
|
|
53
|
+
base = File.expand_path(repo_root)
|
|
54
|
+
return nil unless path.start_with?(base + File::SEPARATOR)
|
|
55
|
+
|
|
56
|
+
path[(base.length + 1)..]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def absolutize(path, repo_root)
|
|
60
|
+
return path if path.nil?
|
|
61
|
+
return path if File.absolute_path?(path)
|
|
62
|
+
|
|
63
|
+
File.expand_path(path, repo_root)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Ports
|
|
3
|
+
module Storage
|
|
4
|
+
# Read-only filesystem query port. The narrow interface that pure
|
|
5
|
+
# domain logic (staleness checks, sentinel value) depends on, so the
|
|
6
|
+
# domain never touches File/Dir directly. FileStore owns the write side.
|
|
7
|
+
class FileStat
|
|
8
|
+
def exists?(path) = File.exist?(path)
|
|
9
|
+
def directory?(path) = File.directory?(path)
|
|
10
|
+
def read(path) = File.binread(path)
|
|
11
|
+
def mtime(path) = File.mtime(path)
|
|
12
|
+
|
|
13
|
+
# Ruby 3.3+ guarantees Dir.glob returns a sorted Array; no explicit sort
|
|
14
|
+
# needed, but callers can rely on ordered results for stable behaviour.
|
|
15
|
+
def glob(pattern) = Dir.glob(pattern)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
require "timeout"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
class Projection
|
|
6
|
+
MAX_LIMIT = 1000
|
|
7
|
+
REDUCER_TIMEOUT_SECONDS = 2
|
|
8
|
+
|
|
9
|
+
# `reader` — a callable `->(key) { envelope_or_nil }`. Caller picks
|
|
10
|
+
# semantics: pure read (`ops.get`) for materialization paths;
|
|
11
|
+
# `ops.get_or_refresh` if you want refresh-on-stale.
|
|
12
|
+
# `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
|
|
13
|
+
# `rpc` — a `Hooks::RpcRegistry` used to dispatch `transform_rows` callables.
|
|
14
|
+
# `transform_context` — capability object handed to transform reducers as `caps:`.
|
|
15
|
+
def initialize(reader:, spec:, lister:, rpc:, transform_context:)
|
|
16
|
+
@reader = reader
|
|
17
|
+
@spec = spec || {}
|
|
18
|
+
@lister = lister
|
|
19
|
+
@rpc = rpc
|
|
20
|
+
@transform_context = transform_context
|
|
21
|
+
@limit = (@spec["limit"] || MAX_LIMIT).to_i
|
|
22
|
+
raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run
|
|
26
|
+
keys = collect_keys
|
|
27
|
+
explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
|
|
28
|
+
rows = keys.map do |key|
|
|
29
|
+
env = @reader.call(key)
|
|
30
|
+
row = pluck(env.meta, env.body)
|
|
31
|
+
explicit_pluck ? row : row.merge("_key" => key)
|
|
32
|
+
end
|
|
33
|
+
reduced = apply_reducer(rows)
|
|
34
|
+
# Reducers may return either an Array of rows (legacy / templated builds)
|
|
35
|
+
# or a Hash that becomes the structured-format payload base. In the Hash
|
|
36
|
+
# case, downstream sort/limit/position markers don't apply, and the
|
|
37
|
+
# builder owns `_meta.generated_at` so we don't stamp it here.
|
|
38
|
+
return reduced if reduced.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
rows = reduced
|
|
41
|
+
rows = sort(rows)
|
|
42
|
+
rows = rows.first(@limit)
|
|
43
|
+
mark_positions(rows)
|
|
44
|
+
{ "entries" => rows, "count" => rows.length, "generated_at" => Time.now.utc.iso8601 }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def apply_reducer(rows)
|
|
50
|
+
name = @spec["transform"] or return rows
|
|
51
|
+
Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
|
|
52
|
+
@rpc.invoke(:transform_rows, name,
|
|
53
|
+
caps: @transform_context,
|
|
54
|
+
rows: rows,
|
|
55
|
+
config: @spec["transform_config"] || {})
|
|
56
|
+
end
|
|
57
|
+
rescue Timeout::Error
|
|
58
|
+
raise UsageError.new("transform_rows '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def collect_keys
|
|
62
|
+
prefixes = Array(@spec["select"])
|
|
63
|
+
prefixes.flat_map { |p| @lister.call(prefix: p).map { |row| row["key"] } }.uniq
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def pluck(frontmatter, _body)
|
|
67
|
+
fields = @spec["pluck"]
|
|
68
|
+
if fields.nil? || fields == "*"
|
|
69
|
+
frontmatter
|
|
70
|
+
else
|
|
71
|
+
Array(fields).each_with_object({}) { |f, h| h[f] = frontmatter[f] if frontmatter.key?(f) }
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Adds `_first`, `_last`, and `_index` markers so templates can emit
|
|
76
|
+
# delimiters (e.g. JSON commas) via {{^_last}},{{/_last}}.
|
|
77
|
+
def mark_positions(rows)
|
|
78
|
+
last_idx = rows.length - 1
|
|
79
|
+
rows.each_with_index do |row, i|
|
|
80
|
+
row["_index"] = i
|
|
81
|
+
row["_first"] = i.zero?
|
|
82
|
+
row["_last"] = (i == last_idx)
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def sort(rows)
|
|
87
|
+
sb = @spec["sort_by"] or return rows
|
|
88
|
+
rows.sort_by { |r| r[sb].to_s }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "time"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
module Read
|
|
6
|
+
# Queries .textus/audit.log. Filters: key, zone, role, verb, since,
|
|
7
|
+
# correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
|
|
8
|
+
# rows produce nil and are skipped).
|
|
9
|
+
class Audit
|
|
10
|
+
# Value object that carries all filter parameters for an audit query.
|
|
11
|
+
# `matches?` checks the manifest-independent predicates so the loop body
|
|
12
|
+
# only needs to handle the zone check (which requires manifest access).
|
|
13
|
+
Query = Data.define(:key, :zone, :role, :verb, :since, :seq_since, :correlation_id, :limit) do
|
|
14
|
+
# rubocop:disable Metrics/ParameterLists
|
|
15
|
+
def self.build(key: nil, zone: nil, role: nil, verb: nil,
|
|
16
|
+
since: nil, seq_since: nil, correlation_id: nil, limit: nil)
|
|
17
|
+
new(key:, zone:, role:, verb:, since:, seq_since:, correlation_id:, limit:)
|
|
18
|
+
end
|
|
19
|
+
# rubocop:enable Metrics/ParameterLists
|
|
20
|
+
|
|
21
|
+
def matches?(row)
|
|
22
|
+
return false if key && row["key"] != key
|
|
23
|
+
return false if role && row["role"] != role
|
|
24
|
+
return false if verb && row["verb"] != verb
|
|
25
|
+
return false if since && (row["ts"].nil? || Time.parse(row["ts"]) < since)
|
|
26
|
+
return false if seq_since && (row["seq"].nil? || row["seq"] <= seq_since)
|
|
27
|
+
return false if correlation_id && row.dig("extras", "correlation_id") != correlation_id
|
|
28
|
+
|
|
29
|
+
true
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
34
|
+
@manifest = container.manifest
|
|
35
|
+
@root = container.root
|
|
36
|
+
@log_path = File.join(container.root, "audit.log")
|
|
37
|
+
@audit_log = container.audit_log
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def call(**filters)
|
|
41
|
+
query = Query.build(**filters)
|
|
42
|
+
check_cursor_expiry!(query.seq_since)
|
|
43
|
+
|
|
44
|
+
files = all_log_files
|
|
45
|
+
return [] if files.empty?
|
|
46
|
+
|
|
47
|
+
rows = []
|
|
48
|
+
files.each do |file|
|
|
49
|
+
File.foreach(file) do |line|
|
|
50
|
+
parsed = parse_row(line.chomp)
|
|
51
|
+
next unless parsed
|
|
52
|
+
next unless query.matches?(parsed)
|
|
53
|
+
next if query.zone && !key_in_zone?(parsed["key"], query.zone)
|
|
54
|
+
|
|
55
|
+
rows << parsed
|
|
56
|
+
break if limit_reached?(rows, query)
|
|
57
|
+
end
|
|
58
|
+
break if limit_reached?(rows, query)
|
|
59
|
+
end
|
|
60
|
+
rows
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Accepts ISO8601 ("2026-01-15", "2026-01-15T10:00:00Z") or a relative
|
|
64
|
+
# offset matching /\A(\d+)([smhd])\z/. Returns nil for unparseable input.
|
|
65
|
+
def self.parse_since(str, now: Time.now.utc)
|
|
66
|
+
return nil if str.nil? || str.empty?
|
|
67
|
+
return Time.parse(str) if str =~ /\A\d{4}-\d{2}-\d{2}/
|
|
68
|
+
|
|
69
|
+
m = str.match(/\A(\d+)([smhd])\z/) or return nil
|
|
70
|
+
mult = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }[m[2]]
|
|
71
|
+
now - (m[1].to_i * mult)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def limit_reached?(rows, query) = query.limit && rows.length >= query.limit
|
|
77
|
+
|
|
78
|
+
def check_cursor_expiry!(seq_since)
|
|
79
|
+
return unless seq_since
|
|
80
|
+
|
|
81
|
+
log = @audit_log || Textus::Ports::AuditLog.new(@root)
|
|
82
|
+
min = log.min_available_seq
|
|
83
|
+
raise Textus::CursorExpired.new(requested: seq_since, min_available: min) if min && seq_since < min - 1
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def all_log_files
|
|
87
|
+
rotated = Dir.glob(File.join(@root, "audit.log.*"))
|
|
88
|
+
.reject { |p| p.end_with?(".meta.json") }
|
|
89
|
+
.sort_by { |p| -p.scan(/\d+$/).first.to_i } # .5 .4 .3 .2 .1 → oldest first
|
|
90
|
+
active = File.exist?(@log_path) ? [@log_path] : []
|
|
91
|
+
rotated + active
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def parse_row(line)
|
|
95
|
+
return nil if line.empty?
|
|
96
|
+
return nil unless line.start_with?("{")
|
|
97
|
+
|
|
98
|
+
JSON.parse(line)
|
|
99
|
+
rescue JSON::ParserError
|
|
100
|
+
nil
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def key_in_zone?(key, zone)
|
|
104
|
+
mentry = @manifest.resolver.resolve(key).entry
|
|
105
|
+
mentry && mentry.zone == zone
|
|
106
|
+
rescue Textus::Error
|
|
107
|
+
false
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
require "open3"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Read
|
|
5
|
+
# For one key, joins every audit-log row with the git commit (sha,
|
|
6
|
+
# author, date, subject) that introduced the file state at that audit
|
|
7
|
+
# row. Falls back to `git => nil` when not in a git repo or when the
|
|
8
|
+
# file is untracked.
|
|
9
|
+
class Blame
|
|
10
|
+
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
11
|
+
@container = container
|
|
12
|
+
@manifest = container.manifest
|
|
13
|
+
@root = container.root
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call(key:, limit: nil)
|
|
17
|
+
audit_rows = Textus::Read::Audit.new(container: @container).call(key: key, limit: limit)
|
|
18
|
+
path = resolve_path(key)
|
|
19
|
+
return audit_rows.map { |r| r.merge("git" => nil) } unless git_tracked?(path)
|
|
20
|
+
|
|
21
|
+
audit_rows.map { |r| r.merge("git" => git_commit_at(path, timestamp: r["ts"])) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
private
|
|
25
|
+
|
|
26
|
+
def resolve_path(key)
|
|
27
|
+
res = @manifest.resolver.resolve(key)
|
|
28
|
+
mentry = res.entry
|
|
29
|
+
path = res.path
|
|
30
|
+
# Nested entries resolve to a file under the entry path; leaf entries
|
|
31
|
+
# already have a fully-resolved path. Either way `path` is what git
|
|
32
|
+
# needs to know about.
|
|
33
|
+
path || Textus::Key::Path.resolve(@manifest.data, mentry)
|
|
34
|
+
rescue Textus::Error
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def git_tracked?(path)
|
|
39
|
+
return false if path.nil?
|
|
40
|
+
return false unless File.exist?(path)
|
|
41
|
+
return false unless git_repo?
|
|
42
|
+
|
|
43
|
+
_out, _err, status = Open3.capture3(
|
|
44
|
+
"git", "ls-files", "--error-unmatch", path,
|
|
45
|
+
chdir: @root
|
|
46
|
+
)
|
|
47
|
+
status.success?
|
|
48
|
+
rescue Errno::ENOENT
|
|
49
|
+
false
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def git_repo?
|
|
53
|
+
# Walk up from store root to find a .git directory.
|
|
54
|
+
dir = @root
|
|
55
|
+
loop do
|
|
56
|
+
return true if File.directory?(File.join(dir, ".git"))
|
|
57
|
+
|
|
58
|
+
parent = File.dirname(dir)
|
|
59
|
+
return false if parent == dir
|
|
60
|
+
|
|
61
|
+
dir = parent
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def git_commit_at(path, timestamp:)
|
|
66
|
+
args = ["git", "log", "-1"]
|
|
67
|
+
args << "--before=#{timestamp}" if timestamp
|
|
68
|
+
args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
|
|
69
|
+
out, _err, status = Open3.capture3(*args, chdir: @root)
|
|
70
|
+
return nil unless status.success?
|
|
71
|
+
|
|
72
|
+
sha, author, date, subject = out.strip.split("\t", 4)
|
|
73
|
+
return nil if sha.nil? || sha.empty?
|
|
74
|
+
|
|
75
|
+
{ "sha" => sha, "author" => author, "date" => date, "subject" => subject }
|
|
76
|
+
rescue Errno::ENOENT
|
|
77
|
+
nil
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
# Dispatched use case for the `boot` verb. The orientation envelope is
|
|
4
|
+
# built by the Textus::Boot library module; this class is the uniform
|
|
5
|
+
# (container:, call:) entry point that Dispatcher::VERBS resolves to.
|
|
6
|
+
# Boot is role-independent, so `call` is not consulted.
|
|
7
|
+
class Boot
|
|
8
|
+
def initialize(container:, call:)
|
|
9
|
+
@container = container
|
|
10
|
+
@call = call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call
|
|
14
|
+
Textus::Boot.build(container: @container)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
class Deps
|
|
4
|
+
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
|
+
@manifest = container.manifest
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def call(key)
|
|
9
|
+
entry = @manifest.data.entries.find { |e| e.key == key } or return []
|
|
10
|
+
return [] unless entry.is_a?(Textus::Manifest::Entry::Derived)
|
|
11
|
+
|
|
12
|
+
src = entry.source
|
|
13
|
+
result = if src.is_a?(Textus::Manifest::Entry::Derived::Projection)
|
|
14
|
+
Array(src.select).compact
|
|
15
|
+
elsif src.is_a?(Textus::Manifest::Entry::Derived::External)
|
|
16
|
+
Array(src.sources).compact
|
|
17
|
+
else
|
|
18
|
+
[]
|
|
19
|
+
end
|
|
20
|
+
result.uniq
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
# Dispatched use case for the `doctor` verb. The health-check report is
|
|
4
|
+
# built by the Textus::Doctor library module; this class is the uniform
|
|
5
|
+
# (container:, call:) entry point that Dispatcher::VERBS resolves to.
|
|
6
|
+
# The acting role is irrelevant to a read-only health check, so `call`
|
|
7
|
+
# is not consulted.
|
|
8
|
+
class Doctor
|
|
9
|
+
def initialize(container:, call:)
|
|
10
|
+
@container = container
|
|
11
|
+
@call = call
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(checks: nil)
|
|
15
|
+
Textus::Doctor.build(container: @container, checks: checks)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Read
|
|
5
|
+
# Per-entry freshness report. Walks every entry declared in the manifest,
|
|
6
|
+
# consults `rules_for(key)` for a refresh rule, and reports the
|
|
7
|
+
# current status. Status is one of :fresh, :stale, :never_refreshed, or
|
|
8
|
+
# :no_policy.
|
|
9
|
+
class Freshness
|
|
10
|
+
def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
11
|
+
@container = container
|
|
12
|
+
@call = call
|
|
13
|
+
@manifest = container.manifest
|
|
14
|
+
@file_store = container.file_store
|
|
15
|
+
@evaluator = evaluator
|
|
16
|
+
@cache = {}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Returns the soonest `next_due_at` across all entries with a refresh
|
|
20
|
+
# policy, as an ISO-8601 string, or nil if none.
|
|
21
|
+
def soonest_due(prefix: nil, zone: nil)
|
|
22
|
+
times = call(prefix: prefix, zone: zone)
|
|
23
|
+
.map { |r| r[:next_due_at] }
|
|
24
|
+
.compact
|
|
25
|
+
.map { |t| Time.parse(t) }
|
|
26
|
+
return nil if times.empty?
|
|
27
|
+
|
|
28
|
+
times.min.utc.iso8601
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(prefix: nil, zone: nil)
|
|
32
|
+
rows = []
|
|
33
|
+
@manifest.data.entries.each do |mentry|
|
|
34
|
+
next if prefix && !mentry.key.start_with?(prefix)
|
|
35
|
+
next if zone && mentry.zone != zone
|
|
36
|
+
|
|
37
|
+
rows << row_for(mentry)
|
|
38
|
+
end
|
|
39
|
+
rows
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def row_for(mentry)
|
|
45
|
+
set = @manifest.rules.for(mentry.key)
|
|
46
|
+
refresh = set.refresh
|
|
47
|
+
envelope = safe_get(mentry.key)
|
|
48
|
+
last = envelope&.meta&.dig("last_refreshed_at")
|
|
49
|
+
|
|
50
|
+
return base_row(mentry, last).merge(status: :no_policy) if refresh.nil?
|
|
51
|
+
|
|
52
|
+
fp = refresh.to_freshness_policy
|
|
53
|
+
cache_key = [mentry.key, last]
|
|
54
|
+
verdict = (@cache[cache_key] ||= @evaluator.call(fp, envelope, now: @call.now))
|
|
55
|
+
status = if verdict.fresh? then :fresh
|
|
56
|
+
elsif last.nil? then :never_refreshed
|
|
57
|
+
else :stale
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
base_row(mentry, last).merge(
|
|
61
|
+
ttl_seconds: fp.ttl_seconds,
|
|
62
|
+
on_stale: fp.on_stale,
|
|
63
|
+
status: status,
|
|
64
|
+
next_due_at: next_due(last, fp.ttl_seconds),
|
|
65
|
+
)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def base_row(mentry, last)
|
|
69
|
+
{
|
|
70
|
+
key: mentry.key,
|
|
71
|
+
zone: mentry.zone,
|
|
72
|
+
last_refreshed_at: last,
|
|
73
|
+
age_seconds: last ? (@call.now - Time.parse(last)).to_i : nil,
|
|
74
|
+
}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Returns the raw envelope or nil. Nested entries (mentry.key is a
|
|
78
|
+
# prefix, not a leaf) and missing files both resolve to nil.
|
|
79
|
+
def safe_get(key)
|
|
80
|
+
res = @manifest.resolver.resolve(key)
|
|
81
|
+
return nil unless @file_store.exists?(res.path)
|
|
82
|
+
|
|
83
|
+
raw = @file_store.read(res.path)
|
|
84
|
+
parsed = Entry.for_format(res.entry.format).parse(raw, path: res.path)
|
|
85
|
+
Textus::Envelope.build(
|
|
86
|
+
key: key, mentry: res.entry, path: res.path,
|
|
87
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
88
|
+
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
89
|
+
)
|
|
90
|
+
rescue Textus::Error
|
|
91
|
+
nil
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def next_due(last, ttl)
|
|
95
|
+
return nil if last.nil? || ttl.nil?
|
|
96
|
+
|
|
97
|
+
(Time.parse(last) + ttl).utc.iso8601
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
# Pure read: returns the on-disk envelope annotated with a freshness
|
|
4
|
+
# verdict. Never triggers refresh; never invokes the orchestrator.
|
|
5
|
+
#
|
|
6
|
+
# For interactive reads that want refresh-on-stale, use
|
|
7
|
+
# `Read::GetOrRefresh`, which composes this with the orchestrator.
|
|
8
|
+
class Get
|
|
9
|
+
def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
10
|
+
@container = container
|
|
11
|
+
@call = call
|
|
12
|
+
@manifest = container.manifest
|
|
13
|
+
@file_store = container.file_store
|
|
14
|
+
@evaluator = evaluator
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def call(key)
|
|
18
|
+
envelope = read_raw_envelope(key)
|
|
19
|
+
return nil if envelope.nil?
|
|
20
|
+
|
|
21
|
+
policy_set = @manifest.rules.for(key)
|
|
22
|
+
refresh_policy = policy_set.refresh
|
|
23
|
+
return annotate_fresh(envelope) if refresh_policy.nil?
|
|
24
|
+
|
|
25
|
+
policy = refresh_policy.to_freshness_policy
|
|
26
|
+
verdict = @evaluator.call(policy, envelope, now: @call.now)
|
|
27
|
+
|
|
28
|
+
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
29
|
+
stale: verdict.stale?,
|
|
30
|
+
reason: verdict.reason,
|
|
31
|
+
refreshing: false,
|
|
32
|
+
))
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Strict variant: raises UnknownKey when the entry is missing.
|
|
36
|
+
# Used by consumers (e.g. Validator) that need to distinguish absence
|
|
37
|
+
# from emptiness.
|
|
38
|
+
def get(key)
|
|
39
|
+
call(key) || raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def read_raw_envelope(key)
|
|
45
|
+
res = @manifest.resolver.resolve(key)
|
|
46
|
+
mentry = res.entry
|
|
47
|
+
path = res.path
|
|
48
|
+
return nil unless @file_store.exists?(path)
|
|
49
|
+
|
|
50
|
+
raw = @file_store.read(path)
|
|
51
|
+
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
52
|
+
Textus::Envelope.build(
|
|
53
|
+
key: key, mentry: mentry, path: path,
|
|
54
|
+
meta: parsed["_meta"], body: parsed["body"],
|
|
55
|
+
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
56
|
+
)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def annotate_fresh(envelope)
|
|
60
|
+
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
61
|
+
stale: false, reason: nil, refreshing: false,
|
|
62
|
+
))
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|