textus 0.18.0 → 0.20.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/ARCHITECTURE.md +43 -48
- data/CHANGELOG.md +238 -0
- data/SPEC.md +35 -2
- data/lib/textus/application/context.rb +20 -58
- data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
- data/lib/textus/{domain → application}/policy/predicates/schema_valid.rb +5 -5
- data/lib/textus/{domain → application}/policy/promotion.rb +18 -6
- data/lib/textus/application/projection.rb +91 -0
- data/lib/textus/application/reads/audit.rb +4 -4
- data/lib/textus/application/reads/blame.rb +9 -8
- data/lib/textus/application/reads/deps.rb +14 -3
- data/lib/textus/application/reads/freshness.rb +10 -8
- data/lib/textus/application/reads/get.rb +10 -8
- data/lib/textus/application/reads/get_or_refresh.rb +3 -3
- data/lib/textus/application/reads/list.rb +3 -3
- data/lib/textus/application/reads/policy_explain.rb +3 -3
- data/lib/textus/application/reads/published.rb +5 -3
- data/lib/textus/application/reads/rdeps.rb +15 -3
- data/lib/textus/application/reads/schema_envelope.rb +5 -4
- data/lib/textus/application/reads/stale.rb +3 -3
- data/lib/textus/application/reads/uid.rb +11 -3
- data/lib/textus/application/reads/validate_all.rb +10 -6
- data/lib/textus/application/reads/validator.rb +5 -3
- data/lib/textus/application/reads/where.rb +3 -3
- data/lib/textus/application/refresh/all.rb +15 -11
- data/lib/textus/application/refresh/orchestrator.rb +9 -8
- data/lib/textus/application/refresh/worker.rb +56 -32
- data/lib/textus/application/writes/accept.rb +43 -16
- data/lib/textus/application/writes/authority_gate.rb +26 -0
- data/lib/textus/application/writes/delete.rb +13 -10
- data/lib/textus/application/writes/envelope_io.rb +64 -4
- data/lib/textus/application/writes/materializer.rb +50 -0
- data/lib/textus/application/writes/mv.rb +57 -94
- data/lib/textus/application/writes/publish.rb +132 -26
- data/lib/textus/application/writes/put.rb +15 -14
- data/lib/textus/application/writes/reject.rb +25 -12
- data/lib/textus/builder/pipeline.rb +21 -15
- data/lib/textus/builder/renderer/json.rb +4 -1
- data/lib/textus/builder/renderer/markdown.rb +7 -1
- data/lib/textus/builder/renderer/yaml.rb +4 -1
- data/lib/textus/cli/verb/build.rb +4 -6
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -4
- data/lib/textus/cli/verb/hooks.rb +5 -5
- data/lib/textus/cli/verb/put.rb +2 -3
- data/lib/textus/cli/verb/refresh_stale.rb +1 -2
- data/lib/textus/doctor/check/handler_allowlist.rb +3 -2
- data/lib/textus/doctor/check/hooks.rb +2 -2
- data/lib/textus/doctor/check/illegal_keys.rb +7 -7
- data/lib/textus/doctor/check/intake_registration.rb +2 -2
- data/lib/textus/doctor/check/manifest_files.rb +1 -1
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +4 -3
- data/lib/textus/doctor.rb +3 -4
- data/lib/textus/domain/authorizer.rb +37 -0
- data/lib/textus/domain/policy/promote.rb +4 -2
- data/lib/textus/domain/policy/refresh.rb +2 -0
- data/lib/textus/domain/staleness/generator_check.rb +8 -7
- data/lib/textus/domain/staleness/intake_check.rb +2 -2
- data/lib/textus/hooks/builtin.rb +6 -6
- data/lib/textus/hooks/bus.rb +155 -0
- data/lib/textus/hooks/context.rb +38 -0
- data/lib/textus/hooks/fire_report.rb +23 -0
- data/lib/textus/hooks/loader.rb +3 -3
- data/lib/textus/infra/audit_subscriber.rb +4 -4
- data/lib/textus/infra/event_bus.rb +3 -3
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/init.rb +3 -2
- data/lib/textus/intro.rb +51 -27
- data/lib/textus/manifest/entry/base.rb +38 -0
- data/lib/textus/manifest/entry/derived.rb +25 -0
- data/lib/textus/manifest/entry/intake.rb +19 -0
- data/lib/textus/manifest/entry/leaf.rb +16 -0
- data/lib/textus/manifest/entry/nested.rb +39 -0
- data/lib/textus/manifest/entry/parser.rb +58 -31
- data/lib/textus/manifest/entry/validators/events.rb +3 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -3
- data/lib/textus/manifest/entry/validators/index_filename.rb +11 -10
- data/lib/textus/manifest/entry/validators/inject_intro.rb +5 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +9 -6
- data/lib/textus/manifest/entry.rb +0 -72
- data/lib/textus/manifest/resolver.rb +112 -0
- data/lib/textus/manifest/role_kinds.rb +21 -0
- data/lib/textus/manifest/schema.rb +46 -2
- data/lib/textus/manifest.rb +24 -101
- data/lib/textus/operations.rb +131 -74
- data/lib/textus/schema/tools.rb +10 -3
- data/lib/textus/store.rb +6 -6
- data/lib/textus/version.rb +1 -1
- metadata +18 -14
- data/lib/textus/application/writes/build.rb +0 -78
- data/lib/textus/cli/verb/key_normalize.rb +0 -19
- data/lib/textus/dependencies.rb +0 -23
- data/lib/textus/domain/policy/predicates/human_accept.rb +0 -31
- data/lib/textus/domain/policy.rb +0 -7
- data/lib/textus/hooks/dispatcher.rb +0 -71
- data/lib/textus/hooks/registry.rb +0 -85
- data/lib/textus/manifest/resolution.rb +0 -5
- data/lib/textus/migrate_keys.rb +0 -187
- data/lib/textus/projection.rb +0 -89
- data/lib/textus/refresh.rb +0 -39
|
@@ -5,16 +5,17 @@ module Textus
|
|
|
5
5
|
def call
|
|
6
6
|
out = []
|
|
7
7
|
store.manifest.entries.each do |entry|
|
|
8
|
-
|
|
8
|
+
template = entry.respond_to?(:template) ? entry.template : nil
|
|
9
|
+
next if template.nil?
|
|
9
10
|
|
|
10
|
-
tp = File.join(store.root, "templates",
|
|
11
|
+
tp = File.join(store.root, "templates", template)
|
|
11
12
|
next if File.exist?(tp)
|
|
12
13
|
|
|
13
14
|
out << {
|
|
14
15
|
"code" => "template.missing",
|
|
15
16
|
"level" => "error",
|
|
16
17
|
"subject" => entry.key,
|
|
17
|
-
"message" => "template '#{
|
|
18
|
+
"message" => "template '#{template}' not found at #{tp}",
|
|
18
19
|
"fix" => "create the file at #{tp} or update the entry's template: field",
|
|
19
20
|
}
|
|
20
21
|
end
|
data/lib/textus/doctor.rb
CHANGED
|
@@ -54,11 +54,10 @@ module Textus
|
|
|
54
54
|
|
|
55
55
|
def run_registered_checks(store)
|
|
56
56
|
out = []
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
callable = store.registry.rpc_callable(:validate, name)
|
|
57
|
+
store.bus.rpc_names(:validate).each do |name|
|
|
58
|
+
callable = store.bus.rpc_callable(:validate, name)
|
|
60
59
|
begin
|
|
61
|
-
result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store:
|
|
60
|
+
result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: store) }
|
|
62
61
|
if result.is_a?(Array)
|
|
63
62
|
out.concat(result.map { |h| h.transform_keys(&:to_s) })
|
|
64
63
|
else
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Domain
|
|
5
|
+
# Authorization service. Single source of truth for "given a manifest
|
|
6
|
+
# entry and a role, may this caller read/write?". Extracted from
|
|
7
|
+
# Application::Context so the rule lives in Domain alongside Permission.
|
|
8
|
+
class Authorizer
|
|
9
|
+
def initialize(manifest:)
|
|
10
|
+
@manifest = manifest
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def can_write?(zone, role:)
|
|
14
|
+
@manifest.permission_for(zone.to_s).allows_write?(role)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def can_read?(zone, role:)
|
|
18
|
+
@manifest.permission_for(zone.to_s).allows_read?(role)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def authorize_write!(mentry, role:)
|
|
22
|
+
return if can_write?(mentry.zone, role: role)
|
|
23
|
+
|
|
24
|
+
writers = @manifest.zone_writers(mentry.zone)
|
|
25
|
+
raise WriteForbidden.new(mentry.key, mentry.zone, writers: writers)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def authorize_read!(mentry, role:)
|
|
29
|
+
return if can_read?(mentry.zone, role: role)
|
|
30
|
+
|
|
31
|
+
readers = @manifest.zone_readers[mentry.zone]
|
|
32
|
+
readers = nil if readers == :all
|
|
33
|
+
raise ReadForbidden.new(mentry.key, mentry.zone, readers: readers)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -2,14 +2,16 @@ module Textus
|
|
|
2
2
|
module Domain
|
|
3
3
|
module Policy
|
|
4
4
|
class Promote
|
|
5
|
-
KNOWN = %i[schema_valid
|
|
5
|
+
KNOWN = %i[schema_valid accept_authority_signed].freeze
|
|
6
6
|
attr_reader :requires
|
|
7
7
|
|
|
8
8
|
def initialize(requires:)
|
|
9
9
|
syms = Array(requires).map { |r| r.to_s.to_sym }
|
|
10
10
|
unknown = syms - KNOWN
|
|
11
11
|
unless unknown.empty?
|
|
12
|
-
raise Textus::UsageError.new(
|
|
12
|
+
raise Textus::UsageError.new(
|
|
13
|
+
"unknown promote requirement: #{unknown.first.inspect} (known: #{KNOWN.join(", ")})",
|
|
14
|
+
)
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
@requires = syms
|
|
@@ -2,6 +2,8 @@ module Textus
|
|
|
2
2
|
module Domain
|
|
3
3
|
module Policy
|
|
4
4
|
class Refresh
|
|
5
|
+
ALLOWED_ON_STALE = %i[warn sync timed_sync].freeze
|
|
6
|
+
|
|
5
7
|
attr_reader :ttl, :on_stale, :sync_budget_ms, :fetch_timeout_seconds
|
|
6
8
|
|
|
7
9
|
def initialize(ttl:, on_stale:, sync_budget_ms:, fetch_timeout_seconds: nil)
|
|
@@ -14,9 +14,10 @@ module Textus
|
|
|
14
14
|
|
|
15
15
|
def rows_for(mentry)
|
|
16
16
|
return [] unless mentry.in_generator_zone?
|
|
17
|
+
return [] unless mentry.is_a?(Textus::Manifest::Entry::Derived)
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
return [] unless
|
|
19
|
+
src = mentry.source
|
|
20
|
+
return [] unless src.is_a?(Textus::Manifest::Entry::Derived::External)
|
|
20
21
|
|
|
21
22
|
path = Textus::Key::Path.resolve(@manifest, mentry)
|
|
22
23
|
return [stale_row(mentry, path, "derived entry has never been generated")] unless File.exist?(path)
|
|
@@ -28,7 +29,7 @@ module Textus
|
|
|
28
29
|
gen_time = parse_time(generated_at)
|
|
29
30
|
return [stale_row(mentry, path, "unparseable generated.at: #{generated_at.inspect}")] unless gen_time
|
|
30
31
|
|
|
31
|
-
offender = newest_source_after(
|
|
32
|
+
offender = newest_source_after(src, gen_time)
|
|
32
33
|
return [stale_row(mentry, path, "source '#{offender}' modified after generated.at")] if offender
|
|
33
34
|
|
|
34
35
|
[]
|
|
@@ -42,8 +43,8 @@ module Textus
|
|
|
42
43
|
nil
|
|
43
44
|
end
|
|
44
45
|
|
|
45
|
-
def newest_source_after(
|
|
46
|
-
Array(
|
|
46
|
+
def newest_source_after(external_src, gen_time)
|
|
47
|
+
Array(external_src.sources).each do |src|
|
|
47
48
|
offender = check_source(src, gen_time)
|
|
48
49
|
return offender if offender
|
|
49
50
|
end
|
|
@@ -52,7 +53,7 @@ module Textus
|
|
|
52
53
|
|
|
53
54
|
def check_source(src, gen_time)
|
|
54
55
|
if src.match?(/\A[a-z0-9.][a-z0-9._-]*\z/) && !src.include?("/")
|
|
55
|
-
@manifest.enumerate(prefix: src).each do |row|
|
|
56
|
+
@manifest.resolver.enumerate(prefix: src).each do |row|
|
|
56
57
|
return src if File.mtime(row[:path]) > gen_time
|
|
57
58
|
end
|
|
58
59
|
nil
|
|
@@ -78,7 +79,7 @@ module Textus
|
|
|
78
79
|
{
|
|
79
80
|
"key" => mentry.key,
|
|
80
81
|
"path" => path,
|
|
81
|
-
"generator" => mentry.
|
|
82
|
+
"generator" => mentry.raw["compute"],
|
|
82
83
|
"reason" => reason,
|
|
83
84
|
}
|
|
84
85
|
end
|
|
@@ -11,7 +11,7 @@ module Textus
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def rows_for(mentry)
|
|
14
|
-
return [] unless mentry.
|
|
14
|
+
return [] unless mentry.is_a?(Textus::Manifest::Entry::Intake)
|
|
15
15
|
|
|
16
16
|
ttl = @manifest.rules_for(mentry.key).refresh&.ttl_seconds
|
|
17
17
|
return [] unless ttl
|
|
@@ -38,7 +38,7 @@ module Textus
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def row(mentry, path, reason)
|
|
41
|
-
{ "key" => mentry.key, "path" => path, "handler" => mentry.
|
|
41
|
+
{ "key" => mentry.key, "path" => path, "handler" => mentry.handler, "reason" => reason }
|
|
42
42
|
end
|
|
43
43
|
end
|
|
44
44
|
end
|
data/lib/textus/hooks/builtin.rb
CHANGED
|
@@ -7,22 +7,22 @@ module Textus
|
|
|
7
7
|
module Hooks
|
|
8
8
|
module Builtin
|
|
9
9
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
10
|
-
def self.register_all(
|
|
11
|
-
|
|
10
|
+
def self.register_all(bus)
|
|
11
|
+
bus.on(:resolve_intake, :json) do |store:, config:, args:|
|
|
12
12
|
_ = store
|
|
13
13
|
_ = args
|
|
14
14
|
data = JSON.parse(config["bytes"].to_s)
|
|
15
15
|
{ _meta: {}, body: YAML.dump(data) }
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
bus.on(:resolve_intake, :csv) do |store:, config:, args:|
|
|
19
19
|
_ = store
|
|
20
20
|
_ = args
|
|
21
21
|
rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
|
|
22
22
|
{ _meta: {}, body: YAML.dump(rows) }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
bus.on(:resolve_intake, :"markdown-links") do |store:, config:, args:|
|
|
26
26
|
_ = store
|
|
27
27
|
_ = args
|
|
28
28
|
links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
|
|
@@ -31,7 +31,7 @@ module Textus
|
|
|
31
31
|
{ _meta: {}, body: YAML.dump(links) }
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
|
|
34
|
+
bus.on(:resolve_intake, :"ical-events") do |store:, config:, args:|
|
|
35
35
|
_ = store
|
|
36
36
|
_ = args
|
|
37
37
|
events = []
|
|
@@ -50,7 +50,7 @@ module Textus
|
|
|
50
50
|
{ _meta: {}, body: YAML.dump(events) }
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
bus.on(:resolve_intake, :rss) do |store:, config:, args:|
|
|
54
54
|
_ = store
|
|
55
55
|
_ = args
|
|
56
56
|
doc = REXML::Document.new(config["bytes"].to_s)
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Hooks
|
|
5
|
+
class Bus
|
|
6
|
+
HOOK_TIMEOUT_SECONDS = 2
|
|
7
|
+
|
|
8
|
+
class HookTimeout < StandardError; end
|
|
9
|
+
|
|
10
|
+
EVENTS = {
|
|
11
|
+
# RPC events — gem-internal, keep :store
|
|
12
|
+
resolve_intake: { mode: :rpc, args: %i[store config args] },
|
|
13
|
+
transform_rows: { mode: :rpc, args: %i[store rows config] },
|
|
14
|
+
validate: { mode: :rpc, args: %i[store] },
|
|
15
|
+
|
|
16
|
+
# Pubsub events — ship :ctx (Hooks::Context) instead of raw store
|
|
17
|
+
entry_put: { mode: :pubsub, args: %i[ctx key envelope] },
|
|
18
|
+
entry_deleted: { mode: :pubsub, args: %i[ctx key] },
|
|
19
|
+
entry_refreshed: { mode: :pubsub, args: %i[ctx key envelope change] },
|
|
20
|
+
entry_renamed: { mode: :pubsub, args: %i[ctx key from_key to_key envelope] },
|
|
21
|
+
build_completed: { mode: :pubsub, args: %i[ctx key envelope sources] },
|
|
22
|
+
proposal_accepted: { mode: :pubsub, args: %i[ctx key target_key] },
|
|
23
|
+
proposal_rejected: { mode: :pubsub, args: %i[ctx key target_key] },
|
|
24
|
+
file_published: { mode: :pubsub, args: %i[ctx key envelope source target] },
|
|
25
|
+
store_loaded: { mode: :pubsub, args: %i[ctx] },
|
|
26
|
+
refresh_started: { mode: :pubsub, args: %i[ctx key mode] },
|
|
27
|
+
refresh_failed: { mode: :pubsub, args: %i[ctx key error_class error_message] },
|
|
28
|
+
refresh_backgrounded: { mode: :pubsub, args: %i[ctx key started_at budget_ms] },
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
def initialize
|
|
32
|
+
@rpc = Hash.new { |h, k| h[k] = {} }
|
|
33
|
+
@pubsub = Hash.new { |h, k| h[k] = [] }
|
|
34
|
+
@error_handlers = []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def on(event, name, keys: nil, &) = register(event, name, keys: keys, &)
|
|
38
|
+
|
|
39
|
+
def register(event, name, keys: nil, &blk)
|
|
40
|
+
event_sym = event.to_sym
|
|
41
|
+
spec = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
|
|
42
|
+
shape_check!(event_sym, spec, blk)
|
|
43
|
+
name = name.to_sym
|
|
44
|
+
|
|
45
|
+
case spec[:mode]
|
|
46
|
+
when :rpc
|
|
47
|
+
raise UsageError.new("#{event_sym} '#{name}' already registered") if @rpc[event_sym].key?(name)
|
|
48
|
+
|
|
49
|
+
@rpc[event_sym][name] = blk
|
|
50
|
+
when :pubsub
|
|
51
|
+
raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
|
|
52
|
+
|
|
53
|
+
@pubsub[event_sym] << { name: name, callable: blk, keys: keys }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def on_error(&block) = @error_handlers << block
|
|
58
|
+
|
|
59
|
+
def rpc_callable(event, name)
|
|
60
|
+
@rpc[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def rpc_names(event) = @rpc[event.to_sym].keys
|
|
64
|
+
def pubsub_handlers(event) = @pubsub[event.to_sym]
|
|
65
|
+
def listeners(event, key:) = @pubsub[event.to_sym].select { |h| h[:keys].nil? || matches_any?(h[:keys], key) }
|
|
66
|
+
|
|
67
|
+
def publish(event, strict: false, **kwargs)
|
|
68
|
+
key = kwargs[:key] || "-"
|
|
69
|
+
fired = []
|
|
70
|
+
errored = []
|
|
71
|
+
timed_out = []
|
|
72
|
+
raised = nil
|
|
73
|
+
|
|
74
|
+
@pubsub[event.to_sym].each do |sub|
|
|
75
|
+
next unless match?(sub[:keys], key)
|
|
76
|
+
|
|
77
|
+
outcome, err = invoke(event, sub, key, kwargs)
|
|
78
|
+
case outcome
|
|
79
|
+
when :ok then fired << sub[:name]
|
|
80
|
+
when :errored then errored << sub[:name]
|
|
81
|
+
when :timed_out then timed_out << sub[:name]
|
|
82
|
+
end
|
|
83
|
+
raised ||= err if strict && err
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
raise raised if strict && raised
|
|
87
|
+
|
|
88
|
+
FireReport.new(fired: fired, errored: errored, timed_out: timed_out)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def invoke(event, sub, key, kwargs)
|
|
94
|
+
accepted = filter_kwargs(sub[:callable], kwargs)
|
|
95
|
+
error = nil
|
|
96
|
+
|
|
97
|
+
thread = Thread.new do
|
|
98
|
+
sub[:callable].call(**accepted)
|
|
99
|
+
rescue StandardError => e
|
|
100
|
+
error = e
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
if thread.join(HOOK_TIMEOUT_SECONDS).nil?
|
|
104
|
+
thread.kill
|
|
105
|
+
err = HookTimeout.new("hook #{sub[:name]} exceeded #{HOOK_TIMEOUT_SECONDS}s on event #{event}")
|
|
106
|
+
notify_error(event, sub, key, kwargs, err)
|
|
107
|
+
return [:timed_out, err]
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
if error
|
|
111
|
+
notify_error(event, sub, key, kwargs, error)
|
|
112
|
+
return [:errored, error]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
[:ok, nil]
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def notify_error(event, sub, key, kwargs, error)
|
|
119
|
+
@error_handlers.each do |handler|
|
|
120
|
+
handler.call(event: event, hook: sub[:name], key: key, kwargs: kwargs, error: error)
|
|
121
|
+
rescue StandardError => e
|
|
122
|
+
warn "[textus] error handler failed: #{e.class}: #{e.message}"
|
|
123
|
+
end
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def filter_kwargs(callable, kwargs)
|
|
127
|
+
params = callable.parameters
|
|
128
|
+
return kwargs if params.any? { |type, _| type == :keyrest }
|
|
129
|
+
|
|
130
|
+
accepted = params.each_with_object([]) do |(type, name), acc|
|
|
131
|
+
acc << name if %i[key keyreq].include?(type)
|
|
132
|
+
end
|
|
133
|
+
kwargs.slice(*accepted)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def shape_check!(event, spec, blk)
|
|
137
|
+
required = spec[:args]
|
|
138
|
+
provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
|
|
139
|
+
keyrest = provided.any? { |t, _| t == :keyrest }
|
|
140
|
+
missing = required - provided.map { |_, n| n }
|
|
141
|
+
return if keyrest || missing.empty?
|
|
142
|
+
|
|
143
|
+
raise UsageError.new("#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def match?(globs, key)
|
|
147
|
+
return true if globs.nil?
|
|
148
|
+
|
|
149
|
+
Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def matches_any?(globs, key) = match?(globs, key)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Hooks
|
|
5
|
+
# A narrow handle passed to user hooks in place of the raw Store.
|
|
6
|
+
# All writes route back through Operations so authorization, audit
|
|
7
|
+
# logging, and schema validation always fire.
|
|
8
|
+
class Context
|
|
9
|
+
attr_reader :role, :correlation_id
|
|
10
|
+
|
|
11
|
+
def initialize(ops:)
|
|
12
|
+
@ops = ops
|
|
13
|
+
@role = ops.ctx.role
|
|
14
|
+
@correlation_id = ops.ctx.correlation_id
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# read
|
|
18
|
+
def get(key) = @ops.get(key)
|
|
19
|
+
def list(**) = @ops.list(**)
|
|
20
|
+
def deps(key) = @ops.deps(key)
|
|
21
|
+
def freshness(key) = @ops.freshness(key)
|
|
22
|
+
|
|
23
|
+
# write (authorized + audited)
|
|
24
|
+
def put(key, **) = @ops.put(key, **)
|
|
25
|
+
def delete(key, **) = @ops.delete(key, **)
|
|
26
|
+
def audit(verb, key:, **) = @ops.store.audit_log.append(role: @role, verb: verb, key: key, **)
|
|
27
|
+
|
|
28
|
+
# fan-out
|
|
29
|
+
def publish_followup(event, **)
|
|
30
|
+
@ops.store.bus.publish(event, ctx: self, **)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def inspect
|
|
34
|
+
"#<Textus::Hooks::Context role=#{@role} correlation_id=#{@correlation_id}>"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Hooks
|
|
5
|
+
# Outcome of a single Dispatcher#publish call.
|
|
6
|
+
#
|
|
7
|
+
# fired — hook names that ran to completion
|
|
8
|
+
# errored — hook names that raised
|
|
9
|
+
# timed_out — hook names whose worker thread exceeded the deadline
|
|
10
|
+
#
|
|
11
|
+
# Callers that care about hook health (tests, strict embedders) can
|
|
12
|
+
# check #ok? or inspect #failures. The dispatcher itself never raises
|
|
13
|
+
# on a hook failure unless strict: true was passed to #publish.
|
|
14
|
+
FireReport = Data.define(:fired, :errored, :timed_out) do
|
|
15
|
+
def initialize(fired:, errored:, timed_out:)
|
|
16
|
+
super(fired: fired.dup.freeze, errored: errored.dup.freeze, timed_out: timed_out.dup.freeze)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def ok? = errored.empty? && timed_out.empty?
|
|
20
|
+
def failures = errored + timed_out
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
data/lib/textus/hooks/loader.rb
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Hooks
|
|
3
3
|
class Loader
|
|
4
|
-
def initialize(
|
|
5
|
-
@
|
|
4
|
+
def initialize(bus:)
|
|
5
|
+
@bus = bus
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def load_dir(dir)
|
|
@@ -18,7 +18,7 @@ module Textus
|
|
|
18
18
|
end
|
|
19
19
|
|
|
20
20
|
Textus.drain_hook_blocks.each do |blk|
|
|
21
|
-
blk.call(@
|
|
21
|
+
blk.call(@bus)
|
|
22
22
|
rescue StandardError, ScriptError => e
|
|
23
23
|
raise UsageError.new("failed registering hook: #{e.class}: #{e.message}")
|
|
24
24
|
end
|
|
@@ -3,11 +3,11 @@
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Infra
|
|
5
5
|
# Writes an "event_error" audit row when a user hook raises during
|
|
6
|
-
# Hooks::
|
|
6
|
+
# Hooks::Bus publish. Attached at Store boot.
|
|
7
7
|
#
|
|
8
|
-
# Integration: uses Hooks::
|
|
9
|
-
# synthetic :hook_error event because the
|
|
10
|
-
# rescue and the failure is a
|
|
8
|
+
# Integration: uses Hooks::Bus#on_error callback (chosen over a
|
|
9
|
+
# synthetic :hook_error event because the bus already owns the
|
|
10
|
+
# rescue and the failure is a bus-internal concern, not a domain
|
|
11
11
|
# event subscribers should be able to filter by key glob).
|
|
12
12
|
#
|
|
13
13
|
# NOTE (0.16 scope): lifecycle audit rows for verb: "put" / "delete" /
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Infra
|
|
3
3
|
class EventBus
|
|
4
|
-
def initialize(
|
|
5
|
-
@
|
|
4
|
+
def initialize(bus:)
|
|
5
|
+
@bus = bus
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def publish(event, **payload)
|
|
9
|
-
@
|
|
9
|
+
@bus.pubsub_handlers(event).each do |entry|
|
|
10
10
|
next unless entry[:keys].nil? || matches?(entry[:keys], payload[:key])
|
|
11
11
|
|
|
12
12
|
entry[:callable].call(**payload)
|
|
@@ -21,7 +21,7 @@ module Textus
|
|
|
21
21
|
|
|
22
22
|
begin
|
|
23
23
|
store = Textus::Store.new(store_root)
|
|
24
|
-
Textus::
|
|
24
|
+
Textus::Operations.for(store, role: "runner").refresh(key)
|
|
25
25
|
rescue StandardError
|
|
26
26
|
# Already logged via :refresh_failed; exit cleanly.
|
|
27
27
|
ensure
|
data/lib/textus/init.rb
CHANGED
|
@@ -13,8 +13,8 @@ module Textus
|
|
|
13
13
|
- { name: review, write_policy: [agent, human], read_policy: [all] }
|
|
14
14
|
- { name: output, write_policy: [builder], read_policy: [all] }
|
|
15
15
|
entries:
|
|
16
|
-
- { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self }
|
|
17
|
-
- { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true }
|
|
16
|
+
- { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self, kind: leaf }
|
|
17
|
+
- { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true, kind: nested }
|
|
18
18
|
YAML
|
|
19
19
|
|
|
20
20
|
HOOKS_README = <<~MD
|
|
@@ -51,6 +51,7 @@ module Textus
|
|
|
51
51
|
```yaml
|
|
52
52
|
entries:
|
|
53
53
|
- key: intake.foo
|
|
54
|
+
kind: intake
|
|
54
55
|
path: intake/foo.md
|
|
55
56
|
zone: intake
|
|
56
57
|
intake:
|
data/lib/textus/intro.rb
CHANGED
|
@@ -18,19 +18,37 @@ module Textus
|
|
|
18
18
|
"output" => "build-computed outputs; never hand-edited",
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
# Per-kind write-flow templates. Each lambda receives the user-facing role
|
|
22
|
+
# name and returns a guidance string for that role. Roles whose kind has
|
|
23
|
+
# no template (e.g. unknown future kinds) are omitted from write_flows.
|
|
24
|
+
WRITE_FLOW_TEMPLATES = {
|
|
25
|
+
accept_authority: lambda do |name, _manifest|
|
|
26
|
+
"edit files in identity/working zones, then 'textus put KEY --as=#{name}'"
|
|
27
|
+
end,
|
|
28
|
+
proposer: lambda do |name, manifest|
|
|
29
|
+
authority = manifest.roles_with_kind(:accept_authority).first || "accept_authority"
|
|
30
|
+
"propose changes by writing review.* entries with --as=#{name} and a 'proposal:' frontmatter block; " \
|
|
31
|
+
"the #{authority} role runs 'textus accept' to apply"
|
|
32
|
+
end,
|
|
33
|
+
runner: lambda do |name, _manifest|
|
|
34
|
+
"refresh intake entries with 'textus refresh KEY --as=#{name}' (uses the entry's declared action)"
|
|
35
|
+
end,
|
|
36
|
+
generator: lambda do |_name, _manifest|
|
|
37
|
+
"'textus build' computes output entries from projections; output files are never hand-edited"
|
|
38
|
+
end,
|
|
27
39
|
}.freeze
|
|
28
40
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
def self.write_flows_for(manifest)
|
|
42
|
+
manifest.role_mapping.each_with_object({}) do |(name, kind), acc|
|
|
43
|
+
tmpl = WRITE_FLOW_TEMPLATES[kind]
|
|
44
|
+
acc[name] = tmpl.call(name, manifest) if tmpl
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Static, store-independent parts of the agent-facing protocol. The
|
|
49
|
+
# `role_resolution` block is derived per-manifest in agent_protocol(...)
|
|
50
|
+
# because role names are user-configurable.
|
|
51
|
+
AGENT_PROTOCOL_TEMPLATE = {
|
|
34
52
|
"envelope_shape" => {
|
|
35
53
|
"summary" => "every read/write payload is a JSON envelope with _meta, body, uid, and etag",
|
|
36
54
|
"fields" => {
|
|
@@ -41,11 +59,6 @@ module Textus
|
|
|
41
59
|
},
|
|
42
60
|
"ref" => "SPEC.md §8",
|
|
43
61
|
},
|
|
44
|
-
"role_resolution" => {
|
|
45
|
-
"summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, default human",
|
|
46
|
-
"roles" => %w[human agent runner builder],
|
|
47
|
-
"ref" => "SPEC.md §5",
|
|
48
|
-
},
|
|
49
62
|
"recipes" => {
|
|
50
63
|
"read" => {
|
|
51
64
|
"purpose" => "find and read an entry",
|
|
@@ -92,7 +105,7 @@ module Textus
|
|
|
92
105
|
{ "name" => "schema", "summary" => "field shape for a key family" },
|
|
93
106
|
{ "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
|
|
94
107
|
{ "name" => "accept", "summary" => "apply a review.* proposal; --as=human only" },
|
|
95
|
-
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid'
|
|
108
|
+
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid'" },
|
|
96
109
|
{ "name" => "delete", "summary" => "delete an entry; --as=<role>" },
|
|
97
110
|
{ "name" => "build", "summary" => "materialize output entries; publish_to and publish_each fan out copies" },
|
|
98
111
|
{ "name" => "refresh", "summary" => "run an action for an intake entry" },
|
|
@@ -105,6 +118,17 @@ module Textus
|
|
|
105
118
|
"summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
106
119
|
].freeze
|
|
107
120
|
|
|
121
|
+
def self.agent_protocol(manifest)
|
|
122
|
+
AGENT_PROTOCOL_TEMPLATE.merge(
|
|
123
|
+
"role_resolution" => {
|
|
124
|
+
"summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, " \
|
|
125
|
+
"default 'human'",
|
|
126
|
+
"roles" => manifest.role_mapping.keys,
|
|
127
|
+
"ref" => "SPEC.md §5",
|
|
128
|
+
},
|
|
129
|
+
)
|
|
130
|
+
end
|
|
131
|
+
|
|
108
132
|
def self.run(store)
|
|
109
133
|
{
|
|
110
134
|
"protocol" => PROTOCOL_ID,
|
|
@@ -112,9 +136,9 @@ module Textus
|
|
|
112
136
|
"zones" => zones_for(store),
|
|
113
137
|
"entries" => entries_for(store),
|
|
114
138
|
"hooks" => hooks_for(store),
|
|
115
|
-
"write_flows" =>
|
|
139
|
+
"write_flows" => write_flows_for(store.manifest),
|
|
116
140
|
"cli_verbs" => CLI_VERBS.map(&:dup),
|
|
117
|
-
"agent_protocol" =>
|
|
141
|
+
"agent_protocol" => agent_protocol(store.manifest),
|
|
118
142
|
"docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
|
|
119
143
|
}
|
|
120
144
|
end
|
|
@@ -130,31 +154,31 @@ module Textus
|
|
|
130
154
|
|
|
131
155
|
def self.entries_for(store)
|
|
132
156
|
store.manifest.entries.map do |e|
|
|
133
|
-
derived = store.manifest.
|
|
157
|
+
derived = store.manifest.zone_kinds(e.zone).include?(:generator)
|
|
134
158
|
{
|
|
135
159
|
"key" => e.key,
|
|
136
160
|
"zone" => e.zone,
|
|
137
161
|
"schema" => e.schema,
|
|
138
|
-
"nested" => e.
|
|
162
|
+
"nested" => e.is_a?(Textus::Manifest::Entry::Nested),
|
|
139
163
|
"owner" => e.owner,
|
|
140
164
|
"format" => e.format,
|
|
141
165
|
"derived" => derived,
|
|
142
|
-
"intake" =>
|
|
166
|
+
"intake" => e.is_a?(Textus::Manifest::Entry::Intake),
|
|
143
167
|
"publish_to" => Array(e.publish_to),
|
|
144
|
-
"publish_each" => e.publish_each,
|
|
168
|
+
"publish_each" => e.respond_to?(:publish_each) ? e.publish_each : nil,
|
|
145
169
|
}
|
|
146
170
|
end
|
|
147
171
|
end
|
|
148
172
|
|
|
149
173
|
def self.hooks_for(store)
|
|
150
|
-
|
|
174
|
+
bus = store.bus
|
|
151
175
|
sections = {}
|
|
152
|
-
Hooks::
|
|
176
|
+
Hooks::Bus::EVENTS.each do |event, spec|
|
|
153
177
|
case spec[:mode]
|
|
154
178
|
when :rpc
|
|
155
|
-
sections[event.to_s] =
|
|
179
|
+
sections[event.to_s] = bus.rpc_names(event).map(&:to_s).sort
|
|
156
180
|
when :pubsub
|
|
157
|
-
sections[event.to_s] =
|
|
181
|
+
sections[event.to_s] = bus.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
|
|
158
182
|
end
|
|
159
183
|
end
|
|
160
184
|
sections
|