textus 0.20.2 → 0.26.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 +148 -45
- data/CHANGELOG.md +194 -0
- data/README.md +8 -5
- data/SPEC.md +54 -15
- data/docs/conventions.md +10 -0
- data/lib/textus/application/caps.rb +49 -0
- data/lib/textus/application/context.rb +2 -2
- data/lib/textus/application/envelope/reader.rb +44 -0
- data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
- data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
- data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
- data/lib/textus/application/maintenance/migrate.rb +59 -0
- data/lib/textus/application/maintenance/rule_lint.rb +65 -0
- data/lib/textus/application/maintenance/zone_mv.rb +60 -0
- data/lib/textus/application/maintenance.rb +17 -0
- data/lib/textus/application/projection.rb +12 -10
- data/lib/textus/application/read/audit.rb +106 -0
- data/lib/textus/application/read/blame.rb +91 -0
- data/lib/textus/application/read/deps.rb +34 -0
- data/lib/textus/application/read/freshness.rb +110 -0
- data/lib/textus/application/read/get.rb +75 -0
- data/lib/textus/application/read/get_or_refresh.rb +63 -0
- data/lib/textus/application/read/list.rb +25 -0
- data/lib/textus/application/read/policy_explain.rb +47 -0
- data/lib/textus/application/read/published.rb +25 -0
- data/lib/textus/application/read/pulse.rb +101 -0
- data/lib/textus/application/read/rdeps.rb +35 -0
- data/lib/textus/application/read/schema_envelope.rb +26 -0
- data/lib/textus/application/read/stale.rb +23 -0
- data/lib/textus/application/read/uid.rb +30 -0
- data/lib/textus/application/read/validate_all.rb +32 -0
- data/lib/textus/application/{reads → read}/validator.rb +2 -2
- data/lib/textus/application/read/where.rb +26 -0
- data/lib/textus/application/use_case.rb +22 -0
- data/lib/textus/application/write/accept.rb +102 -0
- data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
- data/lib/textus/application/write/delete.rb +45 -0
- data/lib/textus/application/{writes → write}/materializer.rb +14 -15
- data/lib/textus/application/write/mv.rb +118 -0
- data/lib/textus/application/write/publish.rb +96 -0
- data/lib/textus/application/write/put.rb +49 -0
- data/lib/textus/application/write/refresh_all.rb +63 -0
- data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
- data/lib/textus/application/write/refresh_worker.rb +134 -0
- data/lib/textus/application/write/reject.rb +62 -0
- data/lib/textus/{intro.rb → boot.rb} +49 -29
- data/lib/textus/builder/pipeline.rb +5 -5
- data/lib/textus/cli/group/mcp.rb +9 -0
- data/lib/textus/cli/group/zone.rb +9 -0
- data/lib/textus/cli/verb/accept.rb +1 -1
- data/lib/textus/cli/verb/audit.rb +4 -2
- data/lib/textus/cli/verb/blame.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +13 -0
- data/lib/textus/cli/verb/build.rb +2 -2
- data/lib/textus/cli/verb/delete.rb +1 -1
- data/lib/textus/cli/verb/deps.rb +1 -1
- data/lib/textus/cli/verb/doctor.rb +1 -1
- data/lib/textus/cli/verb/freshness.rb +1 -1
- 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 +11 -14
- data/lib/textus/cli/verb/key_delete.rb +24 -0
- data/lib/textus/cli/verb/list.rb +1 -1
- data/lib/textus/cli/verb/mcp_serve.rb +17 -0
- data/lib/textus/cli/verb/migrate.rb +18 -0
- data/lib/textus/cli/verb/mv.rb +11 -3
- data/lib/textus/cli/verb/published.rb +1 -1
- data/lib/textus/cli/verb/pulse.rb +17 -0
- data/lib/textus/cli/verb/put.rb +8 -6
- data/lib/textus/cli/verb/rdeps.rb +1 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +1 -1
- data/lib/textus/cli/verb/reject.rb +1 -1
- data/lib/textus/cli/verb/rule_explain.rb +1 -1
- data/lib/textus/cli/verb/rule_lint.rb +18 -0
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb/uid.rb +1 -1
- data/lib/textus/cli/verb/where.rb +1 -1
- data/lib/textus/cli/verb/zone_mv.rb +19 -0
- data/lib/textus/cli/verb.rb +4 -4
- data/lib/textus/cli.rb +1 -1
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
- data/lib/textus/doctor/check/hooks.rb +4 -3
- data/lib/textus/doctor/check/illegal_keys.rb +2 -2
- data/lib/textus/doctor/check/intake_registration.rb +2 -2
- data/lib/textus/doctor/check/manifest_files.rb +2 -2
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/refresh_locks.rb +2 -2
- data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/schemas.rb +2 -2
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +2 -2
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +5 -3
- data/lib/textus/doctor.rb +24 -27
- data/lib/textus/domain/authorizer.rb +4 -4
- data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
- data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
- data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
- data/lib/textus/domain/staleness/generator_check.rb +2 -2
- data/lib/textus/domain/staleness/intake_check.rb +2 -2
- data/lib/textus/domain/staleness.rb +1 -1
- data/lib/textus/errors.rb +16 -0
- data/lib/textus/hooks/builtin.rb +14 -14
- data/lib/textus/hooks/context.rb +13 -13
- data/lib/textus/hooks/error_log.rb +32 -0
- data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
- data/lib/textus/hooks/loader.rb +29 -3
- data/lib/textus/hooks/rpc_registry.rb +77 -0
- data/lib/textus/infra/audit_log.rb +126 -16
- data/lib/textus/infra/audit_subscriber.rb +6 -7
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/key/path.rb +7 -3
- data/lib/textus/manifest/data.rb +78 -0
- data/lib/textus/manifest/entry/base.rb +44 -7
- data/lib/textus/manifest/entry/derived.rb +41 -6
- data/lib/textus/manifest/entry/intake.rb +15 -3
- data/lib/textus/manifest/entry/leaf.rb +6 -5
- data/lib/textus/manifest/entry/nested.rb +42 -3
- data/lib/textus/manifest/entry/parser.rb +8 -44
- data/lib/textus/manifest/entry/validators/events.rb +2 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
- data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
- data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
- data/lib/textus/manifest/entry/validators.rb +1 -1
- data/lib/textus/manifest/entry.rb +3 -0
- data/lib/textus/manifest/policy.rb +48 -0
- data/lib/textus/manifest/resolver.rb +18 -18
- data/lib/textus/manifest/rules.rb +1 -1
- data/lib/textus/manifest/schema.rb +20 -6
- data/lib/textus/manifest.rb +53 -101
- data/lib/textus/mcp/errors.rb +32 -0
- data/lib/textus/mcp/server.rb +127 -0
- data/lib/textus/mcp/session.rb +31 -0
- data/lib/textus/mcp/tool_schemas.rb +71 -0
- data/lib/textus/mcp/tools.rb +129 -0
- data/lib/textus/mcp.rb +6 -0
- data/lib/textus/schema/tools.rb +14 -10
- data/lib/textus/session.rb +84 -0
- data/lib/textus/store.rb +17 -8
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +8 -1
- metadata +65 -38
- data/lib/textus/application/reads/audit.rb +0 -69
- data/lib/textus/application/reads/blame.rb +0 -82
- data/lib/textus/application/reads/deps.rb +0 -26
- data/lib/textus/application/reads/freshness.rb +0 -88
- data/lib/textus/application/reads/get.rb +0 -67
- data/lib/textus/application/reads/get_or_refresh.rb +0 -51
- data/lib/textus/application/reads/list.rb +0 -17
- data/lib/textus/application/reads/policy_explain.rb +0 -39
- data/lib/textus/application/reads/published.rb +0 -17
- data/lib/textus/application/reads/rdeps.rb +0 -27
- data/lib/textus/application/reads/schema_envelope.rb +0 -18
- data/lib/textus/application/reads/stale.rb +0 -15
- data/lib/textus/application/reads/uid.rb +0 -23
- data/lib/textus/application/reads/validate_all.rb +0 -24
- data/lib/textus/application/reads/where.rb +0 -18
- data/lib/textus/application/refresh/all.rb +0 -52
- data/lib/textus/application/refresh/worker.rb +0 -116
- data/lib/textus/application/writes/accept.rb +0 -89
- data/lib/textus/application/writes/delete.rb +0 -33
- data/lib/textus/application/writes/mv.rb +0 -105
- data/lib/textus/application/writes/publish.rb +0 -162
- data/lib/textus/application/writes/put.rb +0 -37
- data/lib/textus/application/writes/reject.rb +0 -50
- data/lib/textus/cli/verb/intro.rb +0 -13
- data/lib/textus/infra/event_bus.rb +0 -27
- data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
- data/lib/textus/operations.rb +0 -169
data/lib/textus/doctor.rb
CHANGED
|
@@ -30,7 +30,7 @@ module Textus
|
|
|
30
30
|
|
|
31
31
|
module_function
|
|
32
32
|
|
|
33
|
-
def run(
|
|
33
|
+
def run(session, checks: nil)
|
|
34
34
|
selected_keys = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
|
|
35
35
|
unknown = selected_keys - ALL_CHECKS
|
|
36
36
|
unless unknown.empty?
|
|
@@ -40,8 +40,8 @@ module Textus
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
selected = CHECKS.select { |c| selected_keys.include?(c.name_key) }
|
|
43
|
-
issues = selected.flat_map { |c| c.new(
|
|
44
|
-
issues.concat(run_registered_checks(
|
|
43
|
+
issues = selected.flat_map { |c| c.new(session).call }
|
|
44
|
+
issues.concat(run_registered_checks(session))
|
|
45
45
|
|
|
46
46
|
summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
|
|
47
47
|
{
|
|
@@ -52,30 +52,27 @@ module Textus
|
|
|
52
52
|
}
|
|
53
53
|
end
|
|
54
54
|
|
|
55
|
-
def run_registered_checks(
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
out.concat(result.map { |h| h.transform_keys(&:to_s) })
|
|
63
|
-
else
|
|
64
|
-
out << fail_issue(name, code: "doctor_check.bad_return",
|
|
65
|
-
message: "doctor_check '#{name}' returned #{result.class} (expected Array)",
|
|
66
|
-
fix: "return an array of issue hashes from the doctor_check block")
|
|
67
|
-
end
|
|
68
|
-
rescue Timeout::Error
|
|
69
|
-
out << fail_issue(name, code: "doctor_check.timeout",
|
|
70
|
-
message: "doctor_check '#{name}' exceeded #{DOCTOR_CHECK_TIMEOUT_SECONDS}s",
|
|
71
|
-
fix: "shorten the check or split it into smaller checks")
|
|
72
|
-
rescue StandardError => e
|
|
73
|
-
out << fail_issue(name, code: "doctor_check.failed",
|
|
74
|
-
message: "#{e.class}: #{e.message}",
|
|
75
|
-
fix: "fix the :validate hook in .textus/hooks/")
|
|
76
|
-
end
|
|
55
|
+
def run_registered_checks(session)
|
|
56
|
+
session.rpc.names(:validate).flat_map { |name| invoke_registered_check(session, name) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def invoke_registered_check(session, name)
|
|
60
|
+
result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) do
|
|
61
|
+
session.rpc.invoke(:validate, name, caps: session.write_caps)
|
|
77
62
|
end
|
|
78
|
-
|
|
63
|
+
return result.map { |h| h.transform_keys(&:to_s) } if result.is_a?(Array)
|
|
64
|
+
|
|
65
|
+
[fail_issue(name, code: "doctor_check.bad_return",
|
|
66
|
+
message: "doctor_check '#{name}' returned #{result.class} (expected Array)",
|
|
67
|
+
fix: "return an array of issue hashes from the doctor_check block")]
|
|
68
|
+
rescue Timeout::Error
|
|
69
|
+
[fail_issue(name, code: "doctor_check.timeout",
|
|
70
|
+
message: "doctor_check '#{name}' exceeded #{DOCTOR_CHECK_TIMEOUT_SECONDS}s",
|
|
71
|
+
fix: "shorten the check or split it into smaller checks")]
|
|
72
|
+
rescue StandardError => e
|
|
73
|
+
[fail_issue(name, code: "doctor_check.failed",
|
|
74
|
+
message: "#{e.class}: #{e.message}",
|
|
75
|
+
fix: "fix the :validate hook in .textus/hooks/")]
|
|
79
76
|
end
|
|
80
77
|
|
|
81
78
|
def fail_issue(name, code:, message:, fix:)
|
|
@@ -88,6 +85,6 @@ module Textus
|
|
|
88
85
|
}
|
|
89
86
|
end
|
|
90
87
|
|
|
91
|
-
private_class_method :run_registered_checks, :fail_issue
|
|
88
|
+
private_class_method :run_registered_checks, :invoke_registered_check, :fail_issue
|
|
92
89
|
end
|
|
93
90
|
end
|
|
@@ -11,24 +11,24 @@ module Textus
|
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def can_write?(zone, role:)
|
|
14
|
-
@manifest.permission_for(zone.to_s).allows_write?(role)
|
|
14
|
+
@manifest.policy.permission_for(zone.to_s).allows_write?(role)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def can_read?(zone, role:)
|
|
18
|
-
@manifest.permission_for(zone.to_s).allows_read?(role)
|
|
18
|
+
@manifest.policy.permission_for(zone.to_s).allows_read?(role)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def authorize_write!(mentry, role:)
|
|
22
22
|
return if can_write?(mentry.zone, role: role)
|
|
23
23
|
|
|
24
|
-
writers = @manifest.zone_writers(mentry.zone)
|
|
24
|
+
writers = @manifest.policy.zone_writers(mentry.zone)
|
|
25
25
|
raise WriteForbidden.new(mentry.key, mentry.zone, writers: writers)
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def authorize_read!(mentry, role:)
|
|
29
29
|
return if can_read?(mentry.zone, role: role)
|
|
30
30
|
|
|
31
|
-
readers = @manifest.zone_readers[mentry.zone]
|
|
31
|
+
readers = @manifest.policy.zone_readers[mentry.zone]
|
|
32
32
|
readers = nil if readers == :all
|
|
33
33
|
raise ReadForbidden.new(mentry.key, mentry.zone, readers: readers)
|
|
34
34
|
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Domain
|
|
3
3
|
module Policy
|
|
4
4
|
module Predicates
|
|
5
5
|
# Promotion predicate: the role driving the promotion must have
|
|
@@ -20,7 +20,7 @@ module Textus
|
|
|
20
20
|
role_str = role&.to_s
|
|
21
21
|
return true if role_str.nil? || role_str.empty?
|
|
22
22
|
|
|
23
|
-
kind = manifest.role_kind(role_str)
|
|
23
|
+
kind = manifest.policy.role_kind(role_str)
|
|
24
24
|
return true if kind == :accept_authority
|
|
25
25
|
|
|
26
26
|
@reason = "role '#{role_str}' has kind '#{kind.inspect}', expected ':accept_authority'"
|
|
@@ -19,7 +19,7 @@ module Textus
|
|
|
19
19
|
src = mentry.source
|
|
20
20
|
return [] unless src.is_a?(Textus::Manifest::Entry::Derived::External)
|
|
21
21
|
|
|
22
|
-
path = Textus::Key::Path.resolve(@manifest, mentry)
|
|
22
|
+
path = Textus::Key::Path.resolve(@manifest.data, mentry)
|
|
23
23
|
return [stale_row(mentry, path, "derived entry has never been generated")] unless File.exist?(path)
|
|
24
24
|
|
|
25
25
|
parsed = Entry.for_format(mentry.format).parse(File.binread(path), path: path)
|
|
@@ -63,7 +63,7 @@ module Textus
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
def check_filesystem_source(src, gen_time)
|
|
66
|
-
abs = File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.root), src)
|
|
66
|
+
abs = File.absolute_path?(src) ? src : File.join(File.dirname(@manifest.data.root), src)
|
|
67
67
|
if File.directory?(abs)
|
|
68
68
|
Dir.glob(File.join(abs, "**", "*")).each do |fp|
|
|
69
69
|
next unless File.file?(fp)
|
|
@@ -13,10 +13,10 @@ module Textus
|
|
|
13
13
|
def rows_for(mentry)
|
|
14
14
|
return [] unless mentry.is_a?(Textus::Manifest::Entry::Intake)
|
|
15
15
|
|
|
16
|
-
ttl = @manifest.
|
|
16
|
+
ttl = @manifest.rules.for(mentry.key).refresh&.ttl_seconds
|
|
17
17
|
return [] unless ttl
|
|
18
18
|
|
|
19
|
-
path = Textus::Key::Path.resolve(@manifest, mentry)
|
|
19
|
+
path = Textus::Key::Path.resolve(@manifest.data, mentry)
|
|
20
20
|
return [row(mentry, path, "never refreshed")] unless File.exist?(path)
|
|
21
21
|
|
|
22
22
|
meta = Entry.for_format(mentry.format).parse(File.binread(path), path: path)["_meta"]
|
data/lib/textus/errors.rb
CHANGED
|
@@ -215,4 +215,20 @@ module Textus
|
|
|
215
215
|
)
|
|
216
216
|
end
|
|
217
217
|
end
|
|
218
|
+
|
|
219
|
+
class CursorExpired < Error
|
|
220
|
+
attr_reader :requested, :min_available
|
|
221
|
+
|
|
222
|
+
def initialize(requested:, min_available:)
|
|
223
|
+
@requested = requested
|
|
224
|
+
@min_available = min_available
|
|
225
|
+
super(
|
|
226
|
+
"cursor_expired",
|
|
227
|
+
"audit cursor expired: requested seq=#{requested} but oldest available is #{min_available}; " \
|
|
228
|
+
"call `textus boot` to re-orient and resume from latest_seq",
|
|
229
|
+
details: { "requested" => requested, "min_available" => min_available },
|
|
230
|
+
hint: "call `textus boot` to get the current latest_seq and resume from there",
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
218
234
|
end
|
data/lib/textus/hooks/builtin.rb
CHANGED
|
@@ -7,23 +7,23 @@ module Textus
|
|
|
7
7
|
module Hooks
|
|
8
8
|
module Builtin
|
|
9
9
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
10
|
-
def self.register_all(
|
|
11
|
-
|
|
12
|
-
_ =
|
|
10
|
+
def self.register_all(events:, rpc:) # rubocop:disable Lint/UnusedMethodArgument
|
|
11
|
+
rpc.register(:resolve_intake, :json) do |caps:, config:, args:|
|
|
12
|
+
_ = caps
|
|
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
|
-
|
|
19
|
-
_ =
|
|
18
|
+
rpc.register(:resolve_intake, :csv) do |caps:, config:, args:|
|
|
19
|
+
_ = caps
|
|
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
|
-
|
|
26
|
-
_ =
|
|
25
|
+
rpc.register(:resolve_intake, :"markdown-links") do |caps:, config:, args:|
|
|
26
|
+
_ = caps
|
|
27
27
|
_ = args
|
|
28
28
|
links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
|
|
29
29
|
{ "text" => text, "href" => href }
|
|
@@ -31,27 +31,27 @@ module Textus
|
|
|
31
31
|
{ _meta: {}, body: YAML.dump(links) }
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
_ =
|
|
34
|
+
rpc.register(:resolve_intake, :"ical-events") do |caps:, config:, args:|
|
|
35
|
+
_ = caps
|
|
36
36
|
_ = args
|
|
37
|
-
|
|
37
|
+
events_list = []
|
|
38
38
|
current = nil
|
|
39
39
|
config["bytes"].to_s.each_line do |line|
|
|
40
40
|
line = line.strip
|
|
41
41
|
case line
|
|
42
42
|
when "BEGIN:VEVENT" then current = {}
|
|
43
43
|
when "END:VEVENT"
|
|
44
|
-
|
|
44
|
+
events_list << current if current
|
|
45
45
|
current = nil
|
|
46
46
|
when /\A(SUMMARY|DTSTART|DTEND|UID|LOCATION|DESCRIPTION):(.*)\z/
|
|
47
47
|
current[Regexp.last_match(1).downcase] = Regexp.last_match(2) if current
|
|
48
48
|
end
|
|
49
49
|
end
|
|
50
|
-
{ _meta: {}, body: YAML.dump(
|
|
50
|
+
{ _meta: {}, body: YAML.dump(events_list) }
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
_ =
|
|
53
|
+
rpc.register(:resolve_intake, :rss) do |caps:, config:, args:|
|
|
54
|
+
_ = caps
|
|
55
55
|
_ = args
|
|
56
56
|
doc = REXML::Document.new(config["bytes"].to_s)
|
|
57
57
|
items = doc.elements.to_a("//item").map do |item|
|
data/lib/textus/hooks/context.rb
CHANGED
|
@@ -3,31 +3,31 @@
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Hooks
|
|
5
5
|
# A narrow handle passed to user hooks in place of the raw Store.
|
|
6
|
-
# All writes route back through
|
|
6
|
+
# All writes route back through the Session so authorization, audit
|
|
7
7
|
# logging, and schema validation always fire.
|
|
8
8
|
class Context
|
|
9
9
|
attr_reader :role, :correlation_id
|
|
10
10
|
|
|
11
|
-
def initialize(
|
|
12
|
-
@
|
|
13
|
-
@role =
|
|
14
|
-
@correlation_id =
|
|
11
|
+
def initialize(session:)
|
|
12
|
+
@session = session
|
|
13
|
+
@role = session.ctx.role
|
|
14
|
+
@correlation_id = session.ctx.correlation_id
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
# read
|
|
18
|
-
def get(key) = @
|
|
19
|
-
def list(**) = @
|
|
20
|
-
def deps(key) = @
|
|
21
|
-
def freshness(key) = @
|
|
18
|
+
def get(key) = @session.get(key)
|
|
19
|
+
def list(**) = @session.list(**)
|
|
20
|
+
def deps(key) = @session.deps(key)
|
|
21
|
+
def freshness(key) = @session.freshness(key)
|
|
22
22
|
|
|
23
23
|
# write (authorized + audited)
|
|
24
|
-
def put(key, **) = @
|
|
25
|
-
def delete(key, **) = @
|
|
26
|
-
def audit(verb, key:, **) = @
|
|
24
|
+
def put(key, **) = @session.put(key, **)
|
|
25
|
+
def delete(key, **) = @session.delete(key, **)
|
|
26
|
+
def audit(verb, key:, **) = @session.write_caps.audit_log.append(role: @role, verb: verb, key: key, **)
|
|
27
27
|
|
|
28
28
|
# fan-out
|
|
29
29
|
def publish_followup(event, **)
|
|
30
|
-
@
|
|
30
|
+
@session.write_caps.events.publish(event, ctx: self, **)
|
|
31
31
|
end
|
|
32
32
|
|
|
33
33
|
def inspect
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Hooks
|
|
3
|
+
# Bounded in-memory ring buffer of recent hook failures (errored and
|
|
4
|
+
# timed_out). Each row carries the audit `seq` observed at the time of
|
|
5
|
+
# failure so pulse can filter "errors since cursor".
|
|
6
|
+
class ErrorLog
|
|
7
|
+
DEFAULT_CAPACITY = 256
|
|
8
|
+
|
|
9
|
+
def initialize(capacity: DEFAULT_CAPACITY)
|
|
10
|
+
@capacity = capacity
|
|
11
|
+
@rows = []
|
|
12
|
+
@mutex = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def record(seq:, event:, hook:, key:, error_class:, error_message:)
|
|
16
|
+
row = {
|
|
17
|
+
seq: seq, event: event, hook: hook, key: key,
|
|
18
|
+
error_class: error_class, error_message: error_message,
|
|
19
|
+
at: Time.now.utc.iso8601
|
|
20
|
+
}
|
|
21
|
+
@mutex.synchronize do
|
|
22
|
+
@rows << row
|
|
23
|
+
@rows.shift while @rows.size > @capacity
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def since(seq)
|
|
28
|
+
@mutex.synchronize { @rows.select { |r| r[:seq] > seq }.dup }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -2,67 +2,55 @@
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Hooks
|
|
5
|
-
class
|
|
5
|
+
class EventBus
|
|
6
6
|
HOOK_TIMEOUT_SECONDS = 2
|
|
7
7
|
|
|
8
8
|
class HookTimeout < StandardError; end
|
|
9
9
|
|
|
10
10
|
EVENTS = {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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] },
|
|
11
|
+
entry_put: %i[ctx key envelope],
|
|
12
|
+
entry_deleted: %i[ctx key],
|
|
13
|
+
entry_refreshed: %i[ctx key envelope change],
|
|
14
|
+
entry_renamed: %i[ctx key from_key to_key envelope],
|
|
15
|
+
build_completed: %i[ctx key envelope sources],
|
|
16
|
+
proposal_accepted: %i[ctx key target_key],
|
|
17
|
+
proposal_rejected: %i[ctx key target_key],
|
|
18
|
+
file_published: %i[ctx key envelope source target],
|
|
19
|
+
store_loaded: %i[ctx],
|
|
20
|
+
refresh_started: %i[ctx key mode],
|
|
21
|
+
refresh_failed: %i[ctx key error_class error_message],
|
|
22
|
+
refresh_backgrounded: %i[ctx key started_at budget_ms],
|
|
29
23
|
}.freeze
|
|
30
24
|
|
|
31
|
-
|
|
32
|
-
|
|
25
|
+
RPC_EVENTS = %i[resolve_intake transform_rows validate].freeze
|
|
26
|
+
|
|
27
|
+
def initialize(error_log: ErrorLog.new)
|
|
33
28
|
@pubsub = Hash.new { |h, k| h[k] = [] }
|
|
34
29
|
@error_handlers = []
|
|
30
|
+
@error_log = error_log
|
|
35
31
|
end
|
|
36
32
|
|
|
33
|
+
attr_reader :error_log
|
|
34
|
+
|
|
37
35
|
def on(event, name, keys: nil, &) = register(event, name, keys: keys, &)
|
|
38
36
|
|
|
39
37
|
def register(event, name, keys: nil, &blk)
|
|
40
38
|
event_sym = event.to_sym
|
|
41
|
-
|
|
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)
|
|
39
|
+
raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if RPC_EVENTS.include?(event_sym)
|
|
48
40
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
41
|
+
required = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
|
|
42
|
+
shape_check!(event_sym, required, blk)
|
|
43
|
+
name = name.to_sym
|
|
44
|
+
raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
|
|
52
45
|
|
|
53
|
-
|
|
54
|
-
end
|
|
46
|
+
@pubsub[event_sym] << { name: name, callable: blk, keys: keys }
|
|
55
47
|
end
|
|
56
48
|
|
|
57
49
|
def on_error(&block) = @error_handlers << block
|
|
58
50
|
|
|
59
|
-
def
|
|
60
|
-
@rpc[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
|
|
61
|
-
end
|
|
51
|
+
def listeners(event, key:) = @pubsub[event.to_sym].select { |h| match?(h[:keys], key) }
|
|
62
52
|
|
|
63
|
-
def
|
|
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) }
|
|
53
|
+
def pubsub_handlers(event) = @pubsub[event.to_sym]
|
|
66
54
|
|
|
67
55
|
def publish(event, strict: false, **kwargs)
|
|
68
56
|
key = kwargs[:key] || "-"
|
|
@@ -93,29 +81,33 @@ module Textus
|
|
|
93
81
|
def invoke(event, sub, key, kwargs)
|
|
94
82
|
accepted = filter_kwargs(sub[:callable], kwargs)
|
|
95
83
|
error = nil
|
|
96
|
-
|
|
97
84
|
thread = Thread.new do
|
|
98
85
|
sub[:callable].call(**accepted)
|
|
99
86
|
rescue StandardError => e
|
|
100
87
|
error = e
|
|
101
88
|
end
|
|
102
|
-
|
|
103
89
|
if thread.join(HOOK_TIMEOUT_SECONDS).nil?
|
|
104
90
|
thread.kill
|
|
105
91
|
err = HookTimeout.new("hook #{sub[:name]} exceeded #{HOOK_TIMEOUT_SECONDS}s on event #{event}")
|
|
106
92
|
notify_error(event, sub, key, kwargs, err)
|
|
107
93
|
return [:timed_out, err]
|
|
108
94
|
end
|
|
109
|
-
|
|
110
95
|
if error
|
|
111
96
|
notify_error(event, sub, key, kwargs, error)
|
|
112
97
|
return [:errored, error]
|
|
113
98
|
end
|
|
114
|
-
|
|
115
99
|
[:ok, nil]
|
|
116
100
|
end
|
|
117
101
|
|
|
118
102
|
def notify_error(event, sub, key, kwargs, error)
|
|
103
|
+
@error_log.record(
|
|
104
|
+
seq: kwargs[:_audit_seq] || -1,
|
|
105
|
+
event: event,
|
|
106
|
+
hook: sub[:name],
|
|
107
|
+
key: key,
|
|
108
|
+
error_class: error.class.name,
|
|
109
|
+
error_message: error.message,
|
|
110
|
+
)
|
|
119
111
|
@error_handlers.each do |handler|
|
|
120
112
|
handler.call(event: event, hook: sub[:name], key: key, kwargs: kwargs, error: error)
|
|
121
113
|
rescue StandardError => e
|
|
@@ -127,18 +119,16 @@ module Textus
|
|
|
127
119
|
params = callable.parameters
|
|
128
120
|
return kwargs if params.any? { |type, _| type == :keyrest }
|
|
129
121
|
|
|
130
|
-
accepted = params.each_with_object([])
|
|
131
|
-
acc << name if %i[key keyreq].include?(type)
|
|
132
|
-
end
|
|
122
|
+
accepted = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
|
|
133
123
|
kwargs.slice(*accepted)
|
|
134
124
|
end
|
|
135
125
|
|
|
136
|
-
def shape_check!(event,
|
|
137
|
-
required = spec[:args]
|
|
126
|
+
def shape_check!(event, required, blk)
|
|
138
127
|
provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
128
|
+
return if provided.any? { |t, _| t == :keyrest }
|
|
129
|
+
|
|
130
|
+
missing = required - provided.map { |_, n| n }
|
|
131
|
+
return if missing.empty?
|
|
142
132
|
|
|
143
133
|
raise UsageError.new("#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
|
|
144
134
|
end
|
|
@@ -148,8 +138,6 @@ module Textus
|
|
|
148
138
|
|
|
149
139
|
Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
|
|
150
140
|
end
|
|
151
|
-
|
|
152
|
-
def matches_any?(globs, key) = match?(globs, key)
|
|
153
141
|
end
|
|
154
142
|
end
|
|
155
143
|
end
|
data/lib/textus/hooks/loader.rb
CHANGED
|
@@ -1,8 +1,34 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Hooks
|
|
3
3
|
class Loader
|
|
4
|
-
|
|
5
|
-
|
|
4
|
+
# A small DSL object passed to user hook blocks. Routes `.on(...)` to the
|
|
5
|
+
# EventBus and `.rpc(...)` / `.register(...)` to the RpcRegistry.
|
|
6
|
+
class Dsl
|
|
7
|
+
def initialize(events:, rpc:)
|
|
8
|
+
@events = events
|
|
9
|
+
@rpc = rpc
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Pubsub registration — delegates to EventBus.
|
|
13
|
+
# Also handles RPC event names by delegating to RpcRegistry.
|
|
14
|
+
def on(event, name, keys: nil, &)
|
|
15
|
+
if Hooks::RpcRegistry::EVENTS.key?(event.to_sym)
|
|
16
|
+
@rpc.register(event, name, &)
|
|
17
|
+
else
|
|
18
|
+
@events.register(event, name, keys: keys, &)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Explicit RPC registration.
|
|
23
|
+
def register(event, name, &)
|
|
24
|
+
@rpc.register(event, name, &)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(events:, rpc:)
|
|
29
|
+
@events = events
|
|
30
|
+
@rpc = rpc
|
|
31
|
+
@dsl = Dsl.new(events: @events, rpc: @rpc)
|
|
6
32
|
end
|
|
7
33
|
|
|
8
34
|
def load_dir(dir)
|
|
@@ -18,7 +44,7 @@ module Textus
|
|
|
18
44
|
end
|
|
19
45
|
|
|
20
46
|
Textus.drain_hook_blocks.each do |blk|
|
|
21
|
-
blk.call(@
|
|
47
|
+
blk.call(@dsl)
|
|
22
48
|
rescue StandardError, ScriptError => e
|
|
23
49
|
raise UsageError.new("failed registering hook: #{e.class}: #{e.message}")
|
|
24
50
|
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Hooks
|
|
5
|
+
class RpcRegistry
|
|
6
|
+
EVENTS = {
|
|
7
|
+
resolve_intake: %i[caps config args],
|
|
8
|
+
transform_rows: %i[caps rows config],
|
|
9
|
+
validate: %i[caps],
|
|
10
|
+
}.freeze
|
|
11
|
+
|
|
12
|
+
PUBSUB_EVENTS = EventBus::EVENTS.keys.freeze
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@table = Hash.new { |h, k| h[k] = {} }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def register(event, name, &blk)
|
|
19
|
+
event_sym = event.to_sym
|
|
20
|
+
raise UsageError.new("#{event_sym} is a pubsub event; register on EventBus") if PUBSUB_EVENTS.include?(event_sym)
|
|
21
|
+
|
|
22
|
+
required = EVENTS[event_sym] or raise UsageError.new("unknown RPC event: #{event}")
|
|
23
|
+
shape_check!(event_sym, required, blk)
|
|
24
|
+
name = name.to_sym
|
|
25
|
+
raise UsageError.new("#{event_sym} '#{name}' already registered") if @table[event_sym].key?(name)
|
|
26
|
+
|
|
27
|
+
@table[event_sym][name] = blk
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def names(event) = @table[event.to_sym].keys
|
|
31
|
+
|
|
32
|
+
def callable(event, name)
|
|
33
|
+
@table[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Invoke a registered callable, injecting `caps:` under the kwarg name
|
|
37
|
+
# the callable declares. Legacy `store:` is rejected (no shim).
|
|
38
|
+
def invoke(event, name, caps:, **other)
|
|
39
|
+
blk = callable(event, name)
|
|
40
|
+
params = blk.parameters
|
|
41
|
+
accepts_keyrest = params.any? { |t, _| t == :keyrest }
|
|
42
|
+
declared = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
|
|
43
|
+
|
|
44
|
+
if declared.include?(:store)
|
|
45
|
+
raise UsageError.new(
|
|
46
|
+
"RPC callable for #{event} '#{name}' declares legacy `store:`; rename to `caps:` " \
|
|
47
|
+
"(Textus::Application::ReadCaps / WriteCaps)",
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
kwargs = other.dup
|
|
52
|
+
kwargs[:caps] = caps if accepts_keyrest || declared.include?(:caps)
|
|
53
|
+
blk.call(**kwargs)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def shape_check!(event, required, blk)
|
|
59
|
+
provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
|
|
60
|
+
return if provided.any? { |t, _| t == :keyrest }
|
|
61
|
+
|
|
62
|
+
param_names = provided.map { |_, n| n }
|
|
63
|
+
# Allow `store:` as a stand-in for `caps:` so registration succeeds;
|
|
64
|
+
# invoke will raise UsageError when the callable is actually called.
|
|
65
|
+
effective_required = if param_names.include?(:store)
|
|
66
|
+
required.map { |r| r == :caps ? :store : r }
|
|
67
|
+
else
|
|
68
|
+
required
|
|
69
|
+
end
|
|
70
|
+
missing = effective_required - param_names
|
|
71
|
+
return if missing.empty?
|
|
72
|
+
|
|
73
|
+
raise UsageError.new("#{event} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|