textus 0.54.2 → 0.55.2
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 +38 -1
- data/README.md +17 -10
- data/SPEC.md +39 -11
- data/docs/architecture/README.md +23 -11
- data/docs/reference/conventions.md +5 -2
- data/exe/textus +1 -1
- data/lib/textus/boot.rb +64 -93
- data/lib/textus/dispatch/binder.rb +48 -0
- data/lib/textus/dispatch/contracts.rb +63 -0
- data/lib/textus/dispatch/handler_registry.rb +21 -0
- data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
- data/lib/textus/dispatch/middleware/auth.rb +40 -0
- data/lib/textus/dispatch/middleware/base.rb +26 -0
- data/lib/textus/dispatch/middleware/binder.rb +20 -0
- data/lib/textus/dispatch/middleware/cascade.rb +53 -0
- data/lib/textus/dispatch/pipeline.rb +35 -0
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/generator_drift.rb +4 -4
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +2 -2
- data/lib/textus/doctor/check/schemas.rb +1 -1
- data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
- data/lib/textus/doctor/check/sentinels.rb +4 -4
- data/lib/textus/doctor/check/templates.rb +1 -1
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +9 -10
- data/lib/textus/doctor.rb +2 -2
- data/lib/textus/errors.rb +8 -0
- data/lib/textus/format/base.rb +34 -10
- data/lib/textus/format/json.rb +5 -27
- data/lib/textus/format/markdown.rb +5 -27
- data/lib/textus/format/text.rb +5 -4
- data/lib/textus/format/yaml.rb +30 -27
- data/lib/textus/format.rb +22 -1
- data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
- data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
- data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
- data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
- data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
- data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
- data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
- data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
- data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
- data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
- data/lib/textus/handlers/read/audit_entries.rb +48 -0
- data/lib/textus/handlers/read/blame_entry.rb +71 -0
- data/lib/textus/handlers/read/deps_entry.rb +17 -0
- data/lib/textus/handlers/read/get_entry.rb +68 -0
- data/lib/textus/handlers/read/list_keys.rb +36 -0
- data/lib/textus/handlers/read/pulse_entries.rb +66 -0
- data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
- data/lib/textus/handlers/read/uid_entry.rb +18 -0
- data/lib/textus/handlers/read/where_entry.rb +18 -0
- data/lib/textus/handlers/write/accept_proposal.rb +39 -0
- data/lib/textus/handlers/write/data_mv.rb +55 -0
- data/lib/textus/handlers/write/delete_key.rb +17 -0
- data/lib/textus/handlers/write/enqueue_job.rb +27 -0
- data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
- data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
- data/lib/textus/handlers/write/move_key.rb +80 -0
- data/lib/textus/handlers/write/propose_entry.rb +29 -0
- data/lib/textus/handlers/write/put_entry.rb +29 -0
- data/lib/textus/handlers/write/reject_proposal.rb +29 -0
- data/lib/textus/init.rb +5 -7
- data/lib/textus/jobs.rb +3 -9
- data/lib/textus/manifest/capabilities.rb +4 -4
- data/lib/textus/manifest/entry/base.rb +3 -3
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
- data/lib/textus/manifest/entry/publish/to_paths.rb +2 -2
- data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
- data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
- data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
- data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
- data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
- data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
- data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
- data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
- data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
- data/lib/textus/manifest/policy/predicates.rb +54 -0
- data/lib/textus/manifest/policy/retention.rb +1 -1
- data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
- data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
- data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
- data/lib/textus/manifest/schema/semantics.rb +11 -216
- data/lib/textus/orchestration.rb +55 -0
- data/lib/textus/{ports → port}/audit_log.rb +46 -6
- data/lib/textus/{ports → port}/build_lock.rb +2 -2
- data/lib/textus/{ports → port}/clock.rb +1 -1
- data/lib/textus/{ports → port}/publisher.rb +5 -5
- data/lib/textus/{core → port}/sentinel.rb +1 -6
- data/lib/textus/{ports → port}/sentinel_store.rb +4 -4
- data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
- data/lib/textus/port/storage/file_store.rb +49 -0
- data/lib/textus/port/storage/interface.rb +17 -0
- data/lib/textus/port/store.rb +149 -0
- data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
- data/lib/textus/produce/engine.rb +1 -11
- data/lib/textus/produce/publisher.rb +21 -0
- data/lib/textus/schema/registry.rb +42 -0
- data/lib/textus/schema/tools.rb +7 -10
- data/lib/textus/store/container.rb +173 -0
- data/lib/textus/store/cursor.rb +26 -0
- data/lib/textus/store/entry/reader.rb +47 -0
- data/lib/textus/store/entry/writer.rb +219 -0
- data/lib/textus/store/envelope/meta.rb +61 -0
- data/lib/textus/store/freshness/drift_detector.rb +93 -0
- data/lib/textus/store/freshness/evaluator.rb +20 -0
- data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
- data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
- data/lib/textus/store/freshness.rb +8 -0
- data/lib/textus/store/index/builder.rb +76 -0
- data/lib/textus/store/index/lookup.rb +60 -0
- data/lib/textus/store/jobs/base.rb +13 -0
- data/lib/textus/store/jobs/index.rb +15 -0
- data/lib/textus/store/jobs/materialize.rb +15 -0
- data/lib/textus/store/jobs/plan.rb +11 -0
- data/lib/textus/store/jobs/planner.rb +124 -0
- data/lib/textus/store/jobs/queue.rb +162 -0
- data/lib/textus/store/jobs/registry.rb +19 -0
- data/lib/textus/store/jobs/retention/base.rb +52 -0
- data/lib/textus/store/jobs/retention/sweep.rb +55 -0
- data/lib/textus/store/jobs/retention.rb +8 -0
- data/lib/textus/store/jobs/sweep.rb +21 -0
- data/lib/textus/store/jobs/worker.rb +64 -0
- data/lib/textus/store/layout.rb +97 -0
- data/lib/textus/store.rb +63 -32
- data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
- data/lib/textus/{surfaces → surface}/cli/runner.rb +14 -68
- data/lib/textus/surface/cli/sources.rb +41 -0
- data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +5 -6
- data/lib/textus/{surfaces → surface}/cli/verb/get.rb +7 -5
- data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
- data/lib/textus/surface/cli/verb/put.rb +27 -0
- data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
- data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
- data/lib/textus/{surfaces → surface}/cli.rb +1 -1
- data/lib/textus/surface/mcp/catalog.rb +58 -0
- data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -11
- data/lib/textus/surface/mcp/projector.rb +20 -0
- data/lib/textus/{surfaces → surface}/mcp/server.rb +24 -35
- data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
- data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
- data/lib/textus/value/call.rb +30 -0
- data/lib/textus/value/command.rb +16 -0
- data/lib/textus/{core → value}/duration.rb +1 -4
- data/lib/textus/value/envelope.rb +90 -0
- data/lib/textus/value/etag.rb +39 -0
- data/lib/textus/value/payload.rb +7 -0
- data/lib/textus/value/result.rb +46 -0
- data/lib/textus/value/role.rb +38 -0
- data/lib/textus/value/types.rb +13 -0
- data/lib/textus/{uid.rb → value/uid.rb} +9 -7
- data/lib/textus/verb_registry.rb +417 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/loader.rb +4 -4
- data/lib/textus/workflow/runner.rb +11 -33
- data/lib/textus.rb +4 -69
- metadata +142 -105
- data/lib/textus/action/accept.rb +0 -44
- data/lib/textus/action/audit.rb +0 -131
- data/lib/textus/action/base.rb +0 -42
- data/lib/textus/action/blame.rb +0 -97
- data/lib/textus/action/boot.rb +0 -17
- data/lib/textus/action/data_mv.rb +0 -69
- data/lib/textus/action/deps.rb +0 -34
- data/lib/textus/action/doctor.rb +0 -24
- data/lib/textus/action/drain.rb +0 -39
- data/lib/textus/action/enqueue.rb +0 -53
- data/lib/textus/action/get.rb +0 -78
- data/lib/textus/action/ingest.rb +0 -203
- data/lib/textus/action/jobs.rb +0 -36
- data/lib/textus/action/key_delete.rb +0 -36
- data/lib/textus/action/key_delete_prefix.rb +0 -44
- data/lib/textus/action/key_mv.rb +0 -129
- data/lib/textus/action/key_mv_prefix.rb +0 -57
- data/lib/textus/action/list.rb +0 -42
- data/lib/textus/action/propose.rb +0 -52
- data/lib/textus/action/published.rb +0 -24
- data/lib/textus/action/pulse.rb +0 -56
- data/lib/textus/action/put.rb +0 -52
- data/lib/textus/action/rdeps.rb +0 -39
- data/lib/textus/action/reject.rb +0 -37
- data/lib/textus/action/rule_explain.rb +0 -91
- data/lib/textus/action/rule_lint.rb +0 -68
- data/lib/textus/action/rule_list.rb +0 -42
- data/lib/textus/action/schema_envelope.rb +0 -29
- data/lib/textus/action/uid.rb +0 -33
- data/lib/textus/action/where.rb +0 -36
- data/lib/textus/action/write_verb.rb +0 -44
- data/lib/textus/call.rb +0 -28
- data/lib/textus/command.rb +0 -41
- data/lib/textus/container.rb +0 -26
- data/lib/textus/contract/around.rb +0 -29
- data/lib/textus/contract/binder.rb +0 -88
- data/lib/textus/contract/resources/build_lock.rb +0 -17
- data/lib/textus/contract/resources/cursor.rb +0 -26
- data/lib/textus/contract/sources.rb +0 -39
- data/lib/textus/contract/view.rb +0 -15
- data/lib/textus/contract.rb +0 -174
- data/lib/textus/core/freshness/evaluator.rb +0 -150
- data/lib/textus/core/freshness.rb +0 -11
- data/lib/textus/core/retention/sweep.rb +0 -57
- data/lib/textus/core/retention.rb +0 -11
- data/lib/textus/cursor_store.rb +0 -24
- data/lib/textus/envelope/reader.rb +0 -46
- data/lib/textus/envelope/writer.rb +0 -209
- data/lib/textus/envelope.rb +0 -79
- data/lib/textus/etag.rb +0 -36
- data/lib/textus/gate/auth.rb +0 -227
- data/lib/textus/gate.rb +0 -116
- data/lib/textus/jobs/base.rb +0 -23
- data/lib/textus/jobs/materialize.rb +0 -20
- data/lib/textus/jobs/plan.rb +0 -9
- data/lib/textus/jobs/planner.rb +0 -101
- data/lib/textus/jobs/retention.rb +0 -48
- data/lib/textus/jobs/sweep.rb +0 -27
- data/lib/textus/jobs/worker.rb +0 -67
- data/lib/textus/layout.rb +0 -91
- data/lib/textus/ports/job_store/job.rb +0 -65
- data/lib/textus/ports/job_store.rb +0 -123
- data/lib/textus/ports/raw_index.rb +0 -61
- data/lib/textus/ports/storage/file_store.rb +0 -26
- data/lib/textus/role.rb +0 -36
- data/lib/textus/schemas.rb +0 -54
- data/lib/textus/session.rb +0 -35
- data/lib/textus/surfaces/cli/verb/put.rb +0 -30
- data/lib/textus/surfaces/mcp/catalog.rb +0 -111
- data/lib/textus/surfaces/role_scope.rb +0 -34
- data/lib/textus/types.rb +0 -15
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Maintenance
|
|
4
|
+
class JobsAction
|
|
5
|
+
def initialize(job_store:)
|
|
6
|
+
@job_store = job_store
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, _call)
|
|
10
|
+
queue = Textus::Store::Jobs::Queue.new(store: @job_store)
|
|
11
|
+
case command.action
|
|
12
|
+
when "retry" then queue.retry_failed(command.job_id)
|
|
13
|
+
when "purge" then queue.purge(command.state)
|
|
14
|
+
end
|
|
15
|
+
Value::Result.success("protocol" => Textus::PROTOCOL, "ok" => true,
|
|
16
|
+
"state" => command.state, "jobs" => queue.list(command.state))
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Maintenance
|
|
4
|
+
class PublishedEntries
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(_command, _call)
|
|
10
|
+
Value::Result.success(@manifest.data.entries.reject { |entry| entry.publish_to.empty? }.map do |entry|
|
|
11
|
+
{ "key" => entry.key, "publish_to" => entry.publish_to }
|
|
12
|
+
end)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Maintenance
|
|
4
|
+
class RuleExplain
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, _call)
|
|
10
|
+
key = command.key
|
|
11
|
+
result = if command.detail
|
|
12
|
+
explain(key)
|
|
13
|
+
else
|
|
14
|
+
effective(key)
|
|
15
|
+
end
|
|
16
|
+
Value::Result.success(result)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
LEAN_FIELDS = Textus::Manifest::Schema::FIELD_REGISTRY
|
|
20
|
+
.select { |_, m| m[:in_rule_explain].include?(:lean) }.keys.freeze
|
|
21
|
+
DETAIL_FIELDS = Textus::Manifest::Schema::FIELD_REGISTRY
|
|
22
|
+
.select { |_, m| m[:in_rule_explain].include?(:detail) }.keys.freeze
|
|
23
|
+
EFFECTIVE_FIELDS = DETAIL_FIELDS.select { |f| Textus::Manifest::Schema::FIELD_REGISTRY[f][:policy_class] }.freeze
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def effective(key)
|
|
28
|
+
set = @manifest.rules.for(key)
|
|
29
|
+
LEAN_FIELDS.each_with_object({}) do |field, out|
|
|
30
|
+
value = set.public_send(field)
|
|
31
|
+
out[field.to_s] = lean_value(field, value) unless value.nil?
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def lean_value(field, value)
|
|
36
|
+
case field
|
|
37
|
+
when :retention then retention_hash(value, string_keys: true)
|
|
38
|
+
when :react then value.to_h
|
|
39
|
+
else value
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def explain(key)
|
|
44
|
+
matching = @manifest.rules.explain(key)
|
|
45
|
+
winners = @manifest.rules.for(key)
|
|
46
|
+
{
|
|
47
|
+
key: key,
|
|
48
|
+
matched_blocks: matching.map do |block|
|
|
49
|
+
{ match: block.match }.merge(DETAIL_FIELDS.to_h { |f| [f, !block.public_send(f).nil?] })
|
|
50
|
+
end,
|
|
51
|
+
effective: EFFECTIVE_FIELDS.to_h { |f| [f, effective_value(f, winners.public_send(f))] },
|
|
52
|
+
guards: Textus::Manifest::Policy::Predicates::FLOOR.keys.to_h do |action|
|
|
53
|
+
floor = Textus::Manifest::Policy::Predicates::FLOOR.fetch(action, [])
|
|
54
|
+
rule = Array(@manifest.rules.for(key).guard&.dig(action.to_s))
|
|
55
|
+
[action, { floor: floor, rule: rule }]
|
|
56
|
+
end,
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def effective_value(field, value)
|
|
61
|
+
return nil if value.nil?
|
|
62
|
+
|
|
63
|
+
case field
|
|
64
|
+
when :retention then retention_hash(value, string_keys: false)
|
|
65
|
+
when :react then value.to_h
|
|
66
|
+
else value
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def retention_hash(retention, string_keys:)
|
|
71
|
+
h = { ttl_seconds: retention.ttl_seconds, action: retention.action }
|
|
72
|
+
string_keys ? h.transform_keys(&:to_s) : h
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Handlers
|
|
5
|
+
module Maintenance
|
|
6
|
+
class RuleLint
|
|
7
|
+
def initialize(manifest:)
|
|
8
|
+
@manifest = manifest
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(command, _call)
|
|
12
|
+
root = @manifest.data.root
|
|
13
|
+
live_rules = current_rules(root)
|
|
14
|
+
candidate_result = parse_candidate(command.candidate_yaml)
|
|
15
|
+
return candidate_result if candidate_result.is_a?(Value::Result) && candidate_result.failure?
|
|
16
|
+
|
|
17
|
+
candidate_rules = candidate_result
|
|
18
|
+
live_by_match = live_rules.to_h { |rule| [rule["match"], rule] }
|
|
19
|
+
candidate_by_match = candidate_rules.to_h { |rule| [rule["match"], rule] }
|
|
20
|
+
|
|
21
|
+
steps = (candidate_by_match.keys - live_by_match.keys).map do |match|
|
|
22
|
+
{ "op" => "add_rule", "match" => match, "rule" => candidate_by_match[match] }
|
|
23
|
+
end
|
|
24
|
+
(live_by_match.keys - candidate_by_match.keys).each do |match|
|
|
25
|
+
steps << { "op" => "remove_rule", "match" => match }
|
|
26
|
+
end
|
|
27
|
+
(live_by_match.keys & candidate_by_match.keys).each do |match|
|
|
28
|
+
next if live_by_match[match] == candidate_by_match[match]
|
|
29
|
+
|
|
30
|
+
steps << { "op" => "change_rule", "match" => match, "from" => live_by_match[match], "to" => candidate_by_match[match] }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
Value::Result.success(Textus::Store::Jobs::Plan.new(steps: steps, warnings: []))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def current_rules(root)
|
|
39
|
+
raw = YAML.safe_load_file(File.join(root, "manifest.yaml"), permitted_classes: [Symbol], aliases: false)
|
|
40
|
+
Array(raw["rules"])
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def parse_candidate(yaml_text)
|
|
44
|
+
raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
|
|
45
|
+
return Value::Result.failure(:usage_error, "candidate is not a YAML mapping") unless raw.is_a?(Hash)
|
|
46
|
+
|
|
47
|
+
Array(raw["rules"])
|
|
48
|
+
rescue Psych::Exception => e
|
|
49
|
+
Value::Result.failure(:usage_error, "candidate YAML parse error: #{e.message}")
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Maintenance
|
|
4
|
+
class RuleList
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(_command, _call)
|
|
10
|
+
Value::Result.success(@manifest.rules.blocks.map do |block|
|
|
11
|
+
row = { "match" => block.match }
|
|
12
|
+
LIST_FIELDS.each do |field|
|
|
13
|
+
value = block.public_send(field)
|
|
14
|
+
row[field.to_s] = serialize(field, value) unless value.nil?
|
|
15
|
+
end
|
|
16
|
+
row
|
|
17
|
+
end)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
LIST_FIELDS = Textus::Manifest::Schema::FIELD_REGISTRY.select { |_, m| m[:in_rule_list] }.keys.freeze
|
|
21
|
+
|
|
22
|
+
def serialize(field, value)
|
|
23
|
+
case field
|
|
24
|
+
when :retention then { "ttl_seconds" => value.ttl_seconds, "action" => value.action.to_s }
|
|
25
|
+
when :react then value.to_h
|
|
26
|
+
else value
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Maintenance
|
|
4
|
+
class SchemaEnvelope
|
|
5
|
+
def initialize(manifest:, schemas:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
@schemas = schemas
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(command, _call)
|
|
11
|
+
mentry = @manifest.resolver.resolve(command.key).entry
|
|
12
|
+
schema = @schemas.fetch_or_nil(mentry.schema)
|
|
13
|
+
Value::Result.success("protocol" => Textus::PROTOCOL, "key" => command.key,
|
|
14
|
+
"schema_ref" => mentry.schema, "schema" => schema&.to_h)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class AuditEntries
|
|
5
|
+
def initialize(manifest:, audit_log:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
@audit_log = audit_log
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(command, _call)
|
|
11
|
+
cursor_check = check_cursor_expiry(command.seq_since)
|
|
12
|
+
return cursor_check if cursor_check
|
|
13
|
+
|
|
14
|
+
rows = @audit_log.scan(
|
|
15
|
+
seq_since: command.seq_since,
|
|
16
|
+
key: command.key, role: command.role, verb: command.verb,
|
|
17
|
+
correlation_id: command.correlation_id, limit: command.limit
|
|
18
|
+
).select do |row|
|
|
19
|
+
next false if command.lane && !key_in_lane?(row["key"], command.lane)
|
|
20
|
+
next false if command.since && (row["ts"].nil? || Time.parse(row["ts"]) < command.since)
|
|
21
|
+
|
|
22
|
+
true
|
|
23
|
+
end
|
|
24
|
+
Value::Result.success(rows)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
private
|
|
28
|
+
|
|
29
|
+
def check_cursor_expiry(seq_since)
|
|
30
|
+
return unless seq_since
|
|
31
|
+
|
|
32
|
+
min = @audit_log.min_available_seq
|
|
33
|
+
return unless min && seq_since < min - 1
|
|
34
|
+
|
|
35
|
+
Value::Result.failure(:cursor_expired, "requested seq #{seq_since} is below minimum available #{min}",
|
|
36
|
+
details: { requested: seq_since, min_available: min })
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def key_in_lane?(key, lane)
|
|
40
|
+
mentry = @manifest.resolver.resolve(key).entry
|
|
41
|
+
mentry && mentry.lane == lane
|
|
42
|
+
rescue Textus::Error
|
|
43
|
+
false
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class BlameEntry
|
|
5
|
+
def initialize(manifest:, orchestration:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
@orchestration = orchestration
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(command, call)
|
|
11
|
+
root = @manifest.data.root
|
|
12
|
+
audit = @orchestration.audit_entries(key: command.key, limit: command.limit, call: call)
|
|
13
|
+
return audit if audit.failure?
|
|
14
|
+
|
|
15
|
+
audit_rows = audit.value.fetch("rows")
|
|
16
|
+
|
|
17
|
+
path = resolve_path(command.key)
|
|
18
|
+
return Value::Result.success(audit_rows.map { |row| row.merge("git" => nil) }) unless git_tracked?(path, root: root)
|
|
19
|
+
|
|
20
|
+
Value::Result.success(audit_rows.map { |row| row.merge("git" => git_commit_at(path, timestamp: row["ts"], root: root)) })
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def resolve_path(key)
|
|
26
|
+
res = @manifest.resolver.resolve(key)
|
|
27
|
+
path = res.path
|
|
28
|
+
path || Textus::Key::Path.resolve(@manifest.data, res.entry)
|
|
29
|
+
rescue Textus::Error
|
|
30
|
+
nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def git_tracked?(path, root:)
|
|
34
|
+
return false if path.nil? || !File.exist?(path) || !git_repo?(root)
|
|
35
|
+
|
|
36
|
+
_out, _err, status = Open3.capture3("git", "ls-files", "--error-unmatch", path, chdir: root)
|
|
37
|
+
status.success?
|
|
38
|
+
rescue Errno::ENOENT
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def git_repo?(root)
|
|
43
|
+
dir = root
|
|
44
|
+
loop do
|
|
45
|
+
return true if File.directory?(File.join(dir, ".git"))
|
|
46
|
+
|
|
47
|
+
parent = File.dirname(dir)
|
|
48
|
+
return false if parent == dir
|
|
49
|
+
|
|
50
|
+
dir = parent
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def git_commit_at(path, timestamp:, root:)
|
|
55
|
+
args = ["git", "log", "-1"]
|
|
56
|
+
args << "--before=#{timestamp}" if timestamp
|
|
57
|
+
args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
|
|
58
|
+
out, _err, status = Open3.capture3(*args, chdir: root)
|
|
59
|
+
return nil unless status.success?
|
|
60
|
+
|
|
61
|
+
sha, author, date, subject = out.strip.split("\t", 4)
|
|
62
|
+
return nil if sha.nil? || sha.empty?
|
|
63
|
+
|
|
64
|
+
{ "sha" => sha, "author" => author, "date" => date, "subject" => subject }
|
|
65
|
+
rescue Errno::ENOENT
|
|
66
|
+
nil
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class DepsEntry
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, _call)
|
|
10
|
+
entry = @manifest.data.entries.find { |e| e.key == command.key }
|
|
11
|
+
deps = entry&.external? ? Array(entry.source&.sources).compact : []
|
|
12
|
+
Value::Result.success("key" => command.key, "deps" => deps.uniq)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class GetEntry
|
|
5
|
+
def initialize(container:, freshness_evaluator:)
|
|
6
|
+
@container = container
|
|
7
|
+
@freshness_evaluator = freshness_evaluator
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(command, _call)
|
|
11
|
+
envelope = Store::Entry::Reader.from(container: @container).read(command.key)
|
|
12
|
+
return Value::Result.failure(:not_found, "no entry at #{command.key}") unless envelope
|
|
13
|
+
|
|
14
|
+
envelope = expand_sources(envelope, depth: 0)
|
|
15
|
+
Value::Result.success(envelope.with(freshness: @freshness_evaluator.verdict(resolve_entry(command.key))))
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
MAX_SOURCE_DEPTH = 5
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def expand_sources(envelope, depth:)
|
|
23
|
+
return envelope if depth >= MAX_SOURCE_DEPTH
|
|
24
|
+
|
|
25
|
+
raw_sources = Array(envelope.meta["sources"])
|
|
26
|
+
return envelope if raw_sources.empty?
|
|
27
|
+
|
|
28
|
+
expanded = raw_sources.map { |src| expand_one_source(src, depth: depth) }
|
|
29
|
+
envelope.with(sources: expanded)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def expand_one_source(src, depth:)
|
|
33
|
+
src = { "key" => src } if src.is_a?(String)
|
|
34
|
+
return src unless src.is_a?(Hash) && src["key"].is_a?(String)
|
|
35
|
+
|
|
36
|
+
key = src["key"]
|
|
37
|
+
stored_etag = src["etag"]
|
|
38
|
+
current_etag = resolve_current_etag(key)
|
|
39
|
+
suspended = stored_etag && current_etag ? stored_etag != current_etag : false
|
|
40
|
+
|
|
41
|
+
result = src.merge("suspended" => suspended)
|
|
42
|
+
|
|
43
|
+
child_env = @container.reader.read(key)
|
|
44
|
+
if child_env
|
|
45
|
+
child_expanded = expand_sources(child_env, depth: depth + 1)
|
|
46
|
+
child_sources = Array(child_expanded.sources)
|
|
47
|
+
result = result.merge("sources" => child_sources) unless child_sources.empty?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
result
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def resolve_current_etag(key)
|
|
54
|
+
path = @container.manifest.resolver.resolve(key).path
|
|
55
|
+
return nil unless @container.file_store.exists?(path)
|
|
56
|
+
|
|
57
|
+
@container.file_store.etag(path)
|
|
58
|
+
rescue Textus::Error
|
|
59
|
+
nil
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def resolve_entry(key)
|
|
63
|
+
@container.manifest.resolver.resolve(key).entry
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class ListKeys
|
|
5
|
+
def initialize(manifest:, job_store: nil)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
@job_store = job_store
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(command, _call)
|
|
11
|
+
q = command.respond_to?(:q) ? command.q : nil
|
|
12
|
+
schema = command.respond_to?(:schema) ? command.schema : nil
|
|
13
|
+
|
|
14
|
+
return sqlite_list(q: q, schema: schema, lane: command.lane, prefix: command.prefix) if @job_store && (q || schema)
|
|
15
|
+
|
|
16
|
+
manifest_list(prefix: command.prefix, lane: command.lane)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def sqlite_list(q:, schema:, lane:, prefix:) # rubocop:disable Naming/MethodParameterName
|
|
22
|
+
rows = @job_store.search_entries(q: q, schema: schema, lane: lane, prefix: prefix)
|
|
23
|
+
Value::Result.success((rows || []).map { |r| { "key" => r["key"], "lane" => r["lane"] } })
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def manifest_list(prefix:, lane:)
|
|
27
|
+
rows = @manifest.resolver.enumerate(prefix: prefix)
|
|
28
|
+
rows = rows.select { |row| row[:manifest_entry].lane == lane } if lane
|
|
29
|
+
Value::Result.success(rows.map do |row|
|
|
30
|
+
{ "key" => row[:key], "lane" => row[:manifest_entry].lane, "path" => row[:path] }
|
|
31
|
+
end)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class PulseEntries
|
|
5
|
+
def initialize(manifest:, audit_log:, file_store:, orchestration:, job_store: nil)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
@audit_log = audit_log
|
|
8
|
+
@file_store = file_store
|
|
9
|
+
@orchestration = orchestration
|
|
10
|
+
@job_store = job_store
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(command, call)
|
|
14
|
+
root = @manifest.data.root
|
|
15
|
+
since = command.since || Textus::Store::Cursor.new(root: root, role: call.role).read
|
|
16
|
+
|
|
17
|
+
changed = changed_since(since, call)
|
|
18
|
+
|
|
19
|
+
result = {
|
|
20
|
+
"cursor" => @audit_log.latest_seq,
|
|
21
|
+
"changed" => changed,
|
|
22
|
+
"pending_review" => review_keys(call),
|
|
23
|
+
"contract_etag" => Textus::Value::Etag.for_contract(root),
|
|
24
|
+
"index_etag" => index_etag,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Textus::Store::Cursor.new(root: root, role: call.role).write(result["cursor"])
|
|
28
|
+
Value::Result.success(result)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def changed_since(since, call)
|
|
34
|
+
if @job_store
|
|
35
|
+
sqlite_rows = @job_store.audit_events_since(seq: since)
|
|
36
|
+
return sqlite_rows.map { |r| { "key" => r["key"], "verb" => r["verb"], "seq" => r["seq"] } } if sqlite_rows.any?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Fall back to flat-log scan when SQLite index is empty for this window
|
|
40
|
+
# (writes that bypassed dispatch, fresh stores, or pre-migration entries).
|
|
41
|
+
audit = @orchestration.audit_entries(seq_since: since, call: call)
|
|
42
|
+
return [] if audit.failure?
|
|
43
|
+
|
|
44
|
+
audit.value.fetch("rows") || []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def review_keys(call)
|
|
48
|
+
queue = @manifest.policy.queue_lane
|
|
49
|
+
return [] unless queue
|
|
50
|
+
|
|
51
|
+
result = @orchestration.list_keys(prefix: nil, lane: queue, call: call)
|
|
52
|
+
return [] unless result.success?
|
|
53
|
+
|
|
54
|
+
result.value.fetch("rows").map { |r| r["key"] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def index_etag
|
|
58
|
+
path = @manifest.resolver.resolve("artifacts.system.index").path
|
|
59
|
+
File.exist?(path) ? @file_store.etag(path) : nil
|
|
60
|
+
rescue Textus::Error
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class RdepsEntry
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, _call)
|
|
10
|
+
rdeps = @manifest.data.entries.each_with_object([]) do |entry, acc|
|
|
11
|
+
next unless entry.external?
|
|
12
|
+
|
|
13
|
+
sources = Array(entry.source&.sources).compact
|
|
14
|
+
acc << entry.key if sources.any? { |source| source == command.key || command.key.start_with?("#{source}.") }
|
|
15
|
+
end
|
|
16
|
+
Value::Result.success("key" => command.key, "rdeps" => rdeps)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class UidEntry
|
|
5
|
+
def initialize(container:)
|
|
6
|
+
@container = container
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, _call)
|
|
10
|
+
envelope = Store::Entry::Reader.from(container: @container).read(command.key)
|
|
11
|
+
return Value::Result.failure(:not_found, "no entry at #{command.key}") unless envelope
|
|
12
|
+
|
|
13
|
+
Value::Result.success(envelope.uid)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class WhereEntry
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, _call)
|
|
10
|
+
res = @manifest.resolver.resolve(command.key)
|
|
11
|
+
mentry = res.entry
|
|
12
|
+
Value::Result.success("protocol" => Textus::PROTOCOL, "key" => command.key,
|
|
13
|
+
"lane" => mentry.lane, "owner" => mentry.owner, "path" => res.path)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Write
|
|
4
|
+
class AcceptProposal
|
|
5
|
+
def initialize(container:)
|
|
6
|
+
@container = container
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, call)
|
|
10
|
+
reader = Store::Entry::Reader.from(container: @container)
|
|
11
|
+
env = reader.read(command.pending_key)
|
|
12
|
+
proposal = env&.meta&.dig("proposal") or
|
|
13
|
+
return Value::Result.failure(:proposal_error, "entry has no proposal block: #{command.pending_key}")
|
|
14
|
+
target = proposal["target_key"] or
|
|
15
|
+
return Value::Result.failure(:proposal_error, "proposal missing target_key")
|
|
16
|
+
action = proposal["action"] || "put"
|
|
17
|
+
|
|
18
|
+
writer = Store::Entry::Writer.from(container: @container, call: call)
|
|
19
|
+
case action
|
|
20
|
+
when "put"
|
|
21
|
+
mentry = @container.manifest.resolver.resolve(target).entry
|
|
22
|
+
writer.put(
|
|
23
|
+
target, mentry: mentry,
|
|
24
|
+
payload: Textus::Value::Payload.new(meta: env.meta["_meta"] || {}, body: env.body, content: nil)
|
|
25
|
+
)
|
|
26
|
+
when "delete"
|
|
27
|
+
writer.delete(target)
|
|
28
|
+
else
|
|
29
|
+
return Value::Result.failure(:proposal_error, "unknown action: #{action}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
writer.delete(command.pending_key)
|
|
33
|
+
Value::Result.success("protocol" => Textus::PROTOCOL, "accepted" => command.pending_key,
|
|
34
|
+
"target_key" => target, "action" => action, "cascade_key" => target)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|