textus 0.53.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/SPEC.md +2 -5
- data/docs/architecture/README.md +149 -159
- data/lib/textus/action/accept.rb +0 -9
- data/lib/textus/action/audit.rb +0 -2
- data/lib/textus/action/blame.rb +0 -2
- data/lib/textus/action/boot.rb +2 -11
- data/lib/textus/action/data_mv.rb +1 -3
- data/lib/textus/action/deps.rb +1 -15
- data/lib/textus/action/doctor.rb +0 -2
- data/lib/textus/action/drain.rb +4 -6
- data/lib/textus/action/enqueue.rb +3 -5
- data/lib/textus/action/get.rb +1 -3
- data/lib/textus/action/ingest.rb +135 -0
- data/lib/textus/action/jobs.rb +1 -3
- data/lib/textus/action/key_delete.rb +0 -10
- data/lib/textus/action/key_delete_prefix.rb +1 -3
- data/lib/textus/action/key_mv.rb +1 -15
- data/lib/textus/action/key_mv_prefix.rb +1 -3
- data/lib/textus/action/list.rb +0 -2
- data/lib/textus/action/propose.rb +0 -2
- data/lib/textus/action/published.rb +0 -2
- data/lib/textus/action/pulse.rb +7 -38
- data/lib/textus/action/put.rb +1 -12
- data/lib/textus/action/rdeps.rb +2 -12
- data/lib/textus/action/reject.rb +0 -12
- data/lib/textus/action/rule_explain.rb +0 -4
- data/lib/textus/action/rule_lint.rb +1 -3
- data/lib/textus/action/rule_list.rb +0 -4
- data/lib/textus/action/schema_envelope.rb +0 -2
- data/lib/textus/action/uid.rb +0 -2
- data/lib/textus/action/where.rb +0 -2
- data/lib/textus/action/write_verb.rb +4 -18
- data/lib/textus/boot.rb +33 -109
- data/lib/textus/command.rb +6 -1
- data/lib/textus/container.rb +1 -1
- data/lib/textus/core/freshness/evaluator.rb +18 -34
- data/lib/textus/doctor/check/notebook_sources.rb +50 -0
- data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
- data/lib/textus/doctor/check.rb +0 -1
- data/lib/textus/doctor.rb +2 -42
- data/lib/textus/envelope/reader.rb +46 -0
- data/lib/textus/envelope/writer.rb +209 -0
- data/lib/textus/errors.rb +8 -4
- data/lib/textus/{entry → format}/base.rb +1 -6
- data/lib/textus/{entry → format}/json.rb +1 -4
- data/lib/textus/{entry → format}/markdown.rb +2 -6
- data/lib/textus/{entry → format}/text.rb +2 -6
- data/lib/textus/{entry → format}/yaml.rb +1 -4
- data/lib/textus/{entry.rb → format.rb} +9 -10
- data/lib/textus/gate/auth.rb +58 -12
- data/lib/textus/gate.rb +2 -0
- data/lib/textus/init.rb +12 -75
- data/lib/textus/jobs/base.rb +23 -0
- data/lib/textus/jobs/materialize.rb +20 -0
- data/lib/textus/{background → jobs}/plan.rb +1 -1
- data/lib/textus/jobs/planner.rb +101 -0
- data/lib/textus/jobs/retention.rb +48 -0
- data/lib/textus/jobs/sweep.rb +27 -0
- data/lib/textus/{background → jobs}/worker.rb +2 -2
- data/lib/textus/jobs.rb +15 -0
- data/lib/textus/key/path.rb +1 -1
- data/lib/textus/manifest/entry/base.rb +4 -19
- data/lib/textus/manifest/entry/parser.rb +27 -10
- data/lib/textus/manifest/entry/produced.rb +13 -34
- data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
- data/lib/textus/manifest/entry/publish.rb +0 -12
- data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/manifest/policy/source.rb +14 -62
- data/lib/textus/manifest/policy.rb +2 -5
- data/lib/textus/manifest/resolver.rb +2 -2
- data/lib/textus/manifest/schema/keys.rb +6 -20
- data/lib/textus/manifest/schema/validator.rb +3 -11
- data/lib/textus/manifest/schema/vocabulary.rb +1 -0
- data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
- data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
- data/lib/textus/produce/engine.rb +50 -0
- data/lib/textus/produce/render.rb +18 -0
- data/lib/textus/session.rb +1 -1
- data/lib/textus/store.rb +1 -10
- data/lib/textus/surfaces/mcp/catalog.rb +1 -1
- data/lib/textus/surfaces/mcp/errors.rb +0 -9
- data/lib/textus/surfaces/mcp/routing.rb +51 -0
- data/lib/textus/surfaces/mcp/server.rb +101 -62
- data/lib/textus/surfaces/role_scope.rb +0 -4
- data/lib/textus/surfaces/watcher.rb +3 -3
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/collector.rb +27 -0
- data/lib/textus/workflow/context.rb +5 -0
- data/lib/textus/workflow/dsl.rb +44 -0
- data/lib/textus/workflow/loader.rb +17 -0
- data/lib/textus/workflow/pattern.rb +18 -0
- data/lib/textus/workflow/registry.rb +21 -0
- data/lib/textus/workflow/runner.rb +84 -0
- data/lib/textus/workflow.rb +28 -0
- data/lib/textus.rb +12 -1
- metadata +33 -61
- data/lib/textus/action/pulse/scanner.rb +0 -118
- data/lib/textus/background/job/base.rb +0 -27
- data/lib/textus/background/job/materialize.rb +0 -31
- data/lib/textus/background/job/refresh.rb +0 -22
- data/lib/textus/background/job/sweep.rb +0 -31
- data/lib/textus/background/job.rb +0 -19
- data/lib/textus/background/planner/plan.rb +0 -113
- data/lib/textus/background/retention/apply.rb +0 -50
- data/lib/textus/doctor/check/handler_permit.rb +0 -34
- data/lib/textus/doctor/check/hooks.rb +0 -25
- data/lib/textus/doctor/check/intake_registration.rb +0 -46
- data/lib/textus/envelope/io/reader.rb +0 -48
- data/lib/textus/envelope/io/writer.rb +0 -211
- data/lib/textus/events.rb +0 -21
- data/lib/textus/init/templates/machine_intake.rb +0 -49
- data/lib/textus/init/templates/orientation_reducer.rb +0 -21
- data/lib/textus/manifest/entry/validators/events.rb +0 -22
- data/lib/textus/manifest/policy/handler_permit.rb +0 -17
- data/lib/textus/pipeline/acquire/handler.rb +0 -29
- data/lib/textus/pipeline/acquire/intake.rb +0 -132
- data/lib/textus/pipeline/acquire/projection.rb +0 -129
- data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
- data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
- data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
- data/lib/textus/pipeline/acquire/serializer.rb +0 -17
- data/lib/textus/pipeline/engine.rb +0 -97
- data/lib/textus/pipeline/render.rb +0 -25
- data/lib/textus/ports/audit_subscriber.rb +0 -42
- data/lib/textus/projection.rb +0 -103
- data/lib/textus/step/base.rb +0 -35
- data/lib/textus/step/builtin/csv_fetch.rb +0 -19
- data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
- data/lib/textus/step/builtin/json_fetch.rb +0 -18
- data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
- data/lib/textus/step/builtin/rss_fetch.rb +0 -26
- data/lib/textus/step/builtin.rb +0 -22
- data/lib/textus/step/catalog.rb +0 -37
- data/lib/textus/step/context.rb +0 -74
- data/lib/textus/step/discovery.rb +0 -24
- data/lib/textus/step/error_log.rb +0 -32
- data/lib/textus/step/event_bus.rb +0 -113
- data/lib/textus/step/fetch.rb +0 -13
- data/lib/textus/step/fire_report.rb +0 -23
- data/lib/textus/step/loader.rb +0 -108
- data/lib/textus/step/observe.rb +0 -31
- data/lib/textus/step/registry_store.rb +0 -66
- data/lib/textus/step/signature.rb +0 -31
- data/lib/textus/step/transform.rb +0 -12
- data/lib/textus/step/validate.rb +0 -11
- data/lib/textus/step.rb +0 -10
data/lib/textus/step/base.rb
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Step
|
|
5
|
-
class Base
|
|
6
|
-
class << self
|
|
7
|
-
# The step kind is derived from class hierarchy.
|
|
8
|
-
def kind
|
|
9
|
-
return :fetch if defined?(Step::Fetch) && self <= Step::Fetch
|
|
10
|
-
return :transform if defined?(Step::Transform) && self <= Step::Transform
|
|
11
|
-
return :validate if defined?(Step::Validate) && self <= Step::Validate
|
|
12
|
-
return :observe if defined?(Step::Observe) && self <= Step::Observe
|
|
13
|
-
|
|
14
|
-
raise NotImplementedError.new("#{self} is not a known step kind")
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
# Required #call kwargs the loader validates against the subclass.
|
|
18
|
-
def required_kwargs = []
|
|
19
|
-
|
|
20
|
-
# Built-ins (and only built-ins) override the registered name when the
|
|
21
|
-
# Ruby class name can't carry it (e.g. "markdown-links").
|
|
22
|
-
def step_name(value = :__read__)
|
|
23
|
-
if value == :__read__
|
|
24
|
-
@step_name
|
|
25
|
-
else
|
|
26
|
-
@step_name = value.to_s
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# Assigned by the loader/registry at registration time.
|
|
32
|
-
attr_accessor :name
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
end
|
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "csv"
|
|
4
|
-
require "yaml"
|
|
5
|
-
|
|
6
|
-
module Textus
|
|
7
|
-
module Step
|
|
8
|
-
module Builtin
|
|
9
|
-
class CsvFetch < Step::Fetch
|
|
10
|
-
step_name "csv"
|
|
11
|
-
def call(config:, args:, **)
|
|
12
|
-
_ = args
|
|
13
|
-
rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
|
|
14
|
-
{ _meta: {}, body: YAML.dump(rows) }
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "yaml"
|
|
4
|
-
|
|
5
|
-
module Textus
|
|
6
|
-
module Step
|
|
7
|
-
module Builtin
|
|
8
|
-
class IcalEventsFetch < Step::Fetch
|
|
9
|
-
step_name "ical-events"
|
|
10
|
-
def call(config:, args:, **)
|
|
11
|
-
_ = args
|
|
12
|
-
events_list = []
|
|
13
|
-
current = nil
|
|
14
|
-
config["bytes"].to_s.each_line do |line|
|
|
15
|
-
line = line.strip
|
|
16
|
-
case line
|
|
17
|
-
when "BEGIN:VEVENT" then current = {}
|
|
18
|
-
when "END:VEVENT"
|
|
19
|
-
events_list << current if current
|
|
20
|
-
current = nil
|
|
21
|
-
when /\A(SUMMARY|DTSTART|DTEND|UID|LOCATION|DESCRIPTION):(.*)\z/
|
|
22
|
-
current[Regexp.last_match(1).downcase] = Regexp.last_match(2) if current
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
{ _meta: {}, body: YAML.dump(events_list) }
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
@@ -1,18 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
require "yaml"
|
|
5
|
-
|
|
6
|
-
module Textus
|
|
7
|
-
module Step
|
|
8
|
-
module Builtin
|
|
9
|
-
class JsonFetch < Step::Fetch
|
|
10
|
-
step_name "json"
|
|
11
|
-
def call(config:, args:, **)
|
|
12
|
-
_ = args
|
|
13
|
-
{ _meta: {}, body: YAML.dump(JSON.parse(config["bytes"].to_s)) }
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "yaml"
|
|
4
|
-
|
|
5
|
-
module Textus
|
|
6
|
-
module Step
|
|
7
|
-
module Builtin
|
|
8
|
-
class MarkdownLinksFetch < Step::Fetch
|
|
9
|
-
step_name "markdown-links"
|
|
10
|
-
def call(config:, args:, **)
|
|
11
|
-
_ = args
|
|
12
|
-
links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
|
|
13
|
-
{ "text" => text, "href" => href }
|
|
14
|
-
end
|
|
15
|
-
{ _meta: {}, body: YAML.dump(links) }
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "rexml/document"
|
|
4
|
-
require "yaml"
|
|
5
|
-
|
|
6
|
-
module Textus
|
|
7
|
-
module Step
|
|
8
|
-
module Builtin
|
|
9
|
-
class RssFetch < Step::Fetch
|
|
10
|
-
step_name "rss"
|
|
11
|
-
def call(config:, args:, **)
|
|
12
|
-
_ = args
|
|
13
|
-
doc = REXML::Document.new(config["bytes"].to_s)
|
|
14
|
-
items = doc.elements.to_a("//item").map do |item|
|
|
15
|
-
{
|
|
16
|
-
"title" => item.elements["title"]&.text,
|
|
17
|
-
"link" => item.elements["link"]&.text,
|
|
18
|
-
"pubDate" => item.elements["pubDate"]&.text,
|
|
19
|
-
}
|
|
20
|
-
end
|
|
21
|
-
{ _meta: {}, body: YAML.dump(items) }
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
data/lib/textus/step/builtin.rb
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Step
|
|
5
|
-
# Registers the framework-provided fetch steps (json/csv/markdown-links/
|
|
6
|
-
# ical-events/rss) into a registry at Store construction. The successor to
|
|
7
|
-
# Step::Builtin.register_all.
|
|
8
|
-
module Builtin
|
|
9
|
-
STEPS = [
|
|
10
|
-
JsonFetch, CsvFetch, MarkdownLinksFetch, IcalEventsFetch, RssFetch
|
|
11
|
-
].freeze
|
|
12
|
-
|
|
13
|
-
def self.register_all(registry)
|
|
14
|
-
STEPS.each do |klass|
|
|
15
|
-
step = klass.new
|
|
16
|
-
step.name = klass.step_name
|
|
17
|
-
registry.register(step)
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
end
|
data/lib/textus/step/catalog.rb
DELETED
|
@@ -1,37 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Step
|
|
5
|
-
# The single source of truth for step event names and their required
|
|
6
|
-
# kwargs. EventBus, RegistryStore, and the Loader all read these
|
|
7
|
-
# tables directly — the registries do not keep their own copies. Catalog
|
|
8
|
-
# references no other constant, so it has no load-order cycle, which is
|
|
9
|
-
# what removed the previous drift hazard (EventBus held a hard-coded
|
|
10
|
-
# `RPC_EVENTS` list that could fall out of sync with RpcRegistry's table).
|
|
11
|
-
module Catalog
|
|
12
|
-
# Pub-sub events: 0..N handlers, fire-and-forget, receive `ctx:`.
|
|
13
|
-
PUBSUB = {
|
|
14
|
-
entry_written: %i[ctx key envelope],
|
|
15
|
-
entry_deleted: %i[ctx key],
|
|
16
|
-
entry_fetched: %i[ctx key envelope change],
|
|
17
|
-
entry_renamed: %i[ctx key from_key to_key envelope],
|
|
18
|
-
entry_produced: %i[ctx key envelope sources],
|
|
19
|
-
produce_failed: %i[ctx keys error],
|
|
20
|
-
proposal_accepted: %i[ctx key target_key],
|
|
21
|
-
proposal_rejected: %i[ctx key target_key],
|
|
22
|
-
entry_published: %i[ctx key envelope source target],
|
|
23
|
-
store_loaded: %i[ctx],
|
|
24
|
-
session_opened: %i[ctx role cursor],
|
|
25
|
-
entry_fetch_started: %i[ctx key mode],
|
|
26
|
-
entry_fetch_failed: %i[ctx key error_class error_message],
|
|
27
|
-
}.freeze
|
|
28
|
-
|
|
29
|
-
# RPC events: single handler, return value matters, receive `caps:`.
|
|
30
|
-
RPC = {
|
|
31
|
-
resolve_handler: %i[caps config args],
|
|
32
|
-
transform_rows: %i[caps rows config],
|
|
33
|
-
validate: %i[caps],
|
|
34
|
-
}.freeze
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
data/lib/textus/step/context.rb
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Step
|
|
5
|
-
# A narrow handle passed to user steps in place of the raw Store.
|
|
6
|
-
# All writes route back through the RoleScope so authorization, audit
|
|
7
|
-
# logging, and schema validation always fire.
|
|
8
|
-
class Context
|
|
9
|
-
attr_reader :role, :correlation_id
|
|
10
|
-
|
|
11
|
-
def self.for(container:, call:)
|
|
12
|
-
scope = Textus::Surfaces::RoleScope.new(
|
|
13
|
-
container: container,
|
|
14
|
-
role: call.role,
|
|
15
|
-
correlation_id: call.correlation_id,
|
|
16
|
-
dry_run: call.dry_run,
|
|
17
|
-
)
|
|
18
|
-
new(scope: scope)
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def initialize(scope:)
|
|
22
|
-
@scope = scope
|
|
23
|
-
@role = scope.role
|
|
24
|
-
@correlation_id = scope.correlation_id
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def backend
|
|
28
|
-
@scope
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# read — a pure-observation surface: nothing here ingests. Since ADR 0089
|
|
32
|
-
# `get` itself is a pure read (the read-through that once forced this
|
|
33
|
-
# surface to opt out is gone, so the old re-entrancy/deadlock guard is no
|
|
34
|
-
# longer needed); `list`/`deps`/`freshness` are reads too. A hook observes
|
|
35
|
-
# current state and never triggers an I/O cascade.
|
|
36
|
-
def get(key) = pure_reader.call(key)
|
|
37
|
-
def list(**) = @scope.list(**)
|
|
38
|
-
def deps(key) = @scope.deps(key)
|
|
39
|
-
def freshness(key) = @scope.freshness(key)
|
|
40
|
-
|
|
41
|
-
# write (authorized + audited)
|
|
42
|
-
def put(key, **) = @scope.put(key, **)
|
|
43
|
-
def delete(key, **) = @scope.key_delete(key, **)
|
|
44
|
-
|
|
45
|
-
def audit(verb, key:, **)
|
|
46
|
-
@scope.container.audit_log.append(role: @role, verb: verb, key: key, **)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# fan-out
|
|
50
|
-
def publish_followup(event, **)
|
|
51
|
-
@scope.container.steps.publish(event, ctx: self, **)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def inspect
|
|
55
|
-
"#<Textus::Step::Context role=#{@role} correlation_id=#{@correlation_id}>"
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
private
|
|
59
|
-
|
|
60
|
-
def pure_reader
|
|
61
|
-
@pure_reader ||= lambda do |key|
|
|
62
|
-
Textus::Action::Get.new(key: key).call(
|
|
63
|
-
container: @scope.container,
|
|
64
|
-
call: Textus::Call.build(
|
|
65
|
-
role: @scope.role,
|
|
66
|
-
correlation_id: @scope.correlation_id,
|
|
67
|
-
dry_run: @scope.dry_run?,
|
|
68
|
-
),
|
|
69
|
-
)
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
end
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Step
|
|
5
|
-
# Maps a discovered file path under .textus/steps to its (kind, name).
|
|
6
|
-
# kind = the directory segment directly under steps/; name = the basename
|
|
7
|
-
# without .rb (hyphens preserved). The single source of truth for valid
|
|
8
|
-
# kinds is the set of Base subclasses.
|
|
9
|
-
KINDS = %i[fetch transform validate observe].freeze
|
|
10
|
-
|
|
11
|
-
Discovery = Data.define(:kind, :name) do
|
|
12
|
-
def self.parse(path, base:)
|
|
13
|
-
rel = path.delete_prefix(base.to_s).delete_prefix("/")
|
|
14
|
-
parts = rel.split("/")
|
|
15
|
-
raise UsageError.new("step #{rel} must live under steps/<kind>/<name>.rb") unless parts.length == 2
|
|
16
|
-
|
|
17
|
-
kind = parts[0].to_sym
|
|
18
|
-
raise UsageError.new("unknown step kind '#{parts[0]}' (expected one of: #{KINDS.join(", ")})") unless KINDS.include?(kind)
|
|
19
|
-
|
|
20
|
-
new(kind: kind, name: File.basename(parts[1], ".rb").to_sym)
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Step
|
|
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
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "timeout"
|
|
4
|
-
|
|
5
|
-
module Textus
|
|
6
|
-
module Step
|
|
7
|
-
class EventBus
|
|
8
|
-
HOOK_TIMEOUT_SECONDS = 2
|
|
9
|
-
|
|
10
|
-
class HookTimeout < StandardError; end
|
|
11
|
-
|
|
12
|
-
def initialize(error_log: ErrorLog.new)
|
|
13
|
-
@pubsub = Hash.new { |h, k| h[k] = [] }
|
|
14
|
-
@error_handlers = []
|
|
15
|
-
@error_log = error_log
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
attr_reader :error_log
|
|
19
|
-
|
|
20
|
-
def on(event, name, keys: nil, &) = register(event, name, keys: keys, &)
|
|
21
|
-
|
|
22
|
-
def register(event, name, keys: nil, &blk)
|
|
23
|
-
event_sym = event.to_sym
|
|
24
|
-
raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if Catalog::RPC.key?(event_sym)
|
|
25
|
-
|
|
26
|
-
required = Catalog::PUBSUB[event_sym] or raise UsageError.new("unknown event: #{event}")
|
|
27
|
-
sig = Signature.new(blk)
|
|
28
|
-
missing = sig.missing(required)
|
|
29
|
-
if missing.any?
|
|
30
|
-
raise UsageError.new("#{event_sym} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
name = name.to_sym
|
|
34
|
-
raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
|
|
35
|
-
|
|
36
|
-
@pubsub[event_sym] << { name: name, callable: blk, keys: keys }
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def on_error(&block) = @error_handlers << block
|
|
40
|
-
|
|
41
|
-
def listeners(event, key:) = @pubsub[event.to_sym].select { |h| match?(h[:keys], key) }
|
|
42
|
-
|
|
43
|
-
def pubsub_handlers(event) = @pubsub[event.to_sym]
|
|
44
|
-
|
|
45
|
-
def pubsub_handlers_names = @pubsub.values.flatten.map { |h| h[:name] }
|
|
46
|
-
|
|
47
|
-
def publish(event, strict: false, **kwargs)
|
|
48
|
-
key = kwargs[:key] || "-"
|
|
49
|
-
fired = []
|
|
50
|
-
errored = []
|
|
51
|
-
timed_out = []
|
|
52
|
-
raised = nil
|
|
53
|
-
|
|
54
|
-
@pubsub[event.to_sym].each do |sub|
|
|
55
|
-
next unless match?(sub[:keys], key)
|
|
56
|
-
|
|
57
|
-
outcome, err = invoke(event, sub, key, kwargs)
|
|
58
|
-
case outcome
|
|
59
|
-
when :ok then fired << sub[:name]
|
|
60
|
-
when :errored then errored << sub[:name]
|
|
61
|
-
when :timed_out then timed_out << sub[:name]
|
|
62
|
-
end
|
|
63
|
-
raised ||= err if strict && err
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
raise raised if strict && raised
|
|
67
|
-
|
|
68
|
-
FireReport.new(fired: fired, errored: errored, timed_out: timed_out)
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
private
|
|
72
|
-
|
|
73
|
-
def invoke(event, sub, key, kwargs)
|
|
74
|
-
accepted = Signature.new(sub[:callable]).filter(kwargs)
|
|
75
|
-
begin
|
|
76
|
-
Timeout.timeout(HOOK_TIMEOUT_SECONDS) do
|
|
77
|
-
sub[:callable].call(**accepted)
|
|
78
|
-
end
|
|
79
|
-
[:ok, nil]
|
|
80
|
-
rescue Timeout::Error
|
|
81
|
-
err = HookTimeout.new("hook #{sub[:name]} exceeded #{HOOK_TIMEOUT_SECONDS}s on event #{event}")
|
|
82
|
-
notify_error(event, sub, key, kwargs, err)
|
|
83
|
-
[:timed_out, err]
|
|
84
|
-
rescue StandardError => e
|
|
85
|
-
notify_error(event, sub, key, kwargs, e)
|
|
86
|
-
[:errored, e]
|
|
87
|
-
end
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def notify_error(event, sub, key, kwargs, error)
|
|
91
|
-
@error_log.record(
|
|
92
|
-
seq: kwargs[:_audit_seq] || -1,
|
|
93
|
-
event: event,
|
|
94
|
-
hook: sub[:name],
|
|
95
|
-
key: key,
|
|
96
|
-
error_class: error.class.name,
|
|
97
|
-
error_message: error.message,
|
|
98
|
-
)
|
|
99
|
-
@error_handlers.each do |handler|
|
|
100
|
-
handler.call(event: event, hook: sub[:name], key: key, kwargs: kwargs, error: error)
|
|
101
|
-
rescue StandardError => e
|
|
102
|
-
warn "[textus] error handler failed: #{e.class}: #{e.message}"
|
|
103
|
-
end
|
|
104
|
-
end
|
|
105
|
-
|
|
106
|
-
def match?(globs, key)
|
|
107
|
-
return true if globs.nil?
|
|
108
|
-
|
|
109
|
-
Array(globs).any? { |g| File.fnmatch?(g, key.to_s, File::FNM_PATHNAME) }
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
end
|
data/lib/textus/step/fetch.rb
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Step
|
|
5
|
-
# Acquires data from outside the store (the `fetch:` build input). Returns
|
|
6
|
-
# either { "content" => Hash } (structured) or { _meta:, body: } (rendered
|
|
7
|
-
# text). Replaces the :resolve_handler RPC. `caps:` is injected by the
|
|
8
|
-
# registry only if #call declares it.
|
|
9
|
-
class Fetch < Base
|
|
10
|
-
def self.required_kwargs = %i[config args]
|
|
11
|
-
end
|
|
12
|
-
end
|
|
13
|
-
end
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Step
|
|
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/step/loader.rb
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Step
|
|
5
|
-
# Convention discovery: glob .textus/steps/<kind>/<name>.rb, load each file,
|
|
6
|
-
# validate the class it defines against the discovered kind, assign the
|
|
7
|
-
# discovered name, and register it. No global queue, no Textus.hook.
|
|
8
|
-
class Loader
|
|
9
|
-
BASE_FOR = {
|
|
10
|
-
fetch: Step::Fetch, transform: Step::Transform,
|
|
11
|
-
validate: Step::Validate, observe: Step::Observe
|
|
12
|
-
}.freeze
|
|
13
|
-
|
|
14
|
-
def initialize(registry:)
|
|
15
|
-
@registry = registry
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def load_dir(dir)
|
|
19
|
-
return unless File.directory?(dir)
|
|
20
|
-
|
|
21
|
-
loaded_paths = Set.new
|
|
22
|
-
Dir.glob(File.join(dir, "**/*.rb")).sort.each do |path| # rubocop:disable Lint/RedundantDirGlobSort
|
|
23
|
-
real_path = File.realpath(path)
|
|
24
|
-
next if loaded_paths.include?(real_path)
|
|
25
|
-
|
|
26
|
-
loaded_paths << real_path
|
|
27
|
-
load_one(dir, path)
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
private
|
|
32
|
-
|
|
33
|
-
def load_one(dir, path)
|
|
34
|
-
disc = Discovery.parse(path, base: dir)
|
|
35
|
-
klass = capture_defined_class(path, disc)
|
|
36
|
-
validate!(disc, klass, path, dir)
|
|
37
|
-
|
|
38
|
-
step = klass.new
|
|
39
|
-
step.name = disc.name
|
|
40
|
-
@registry.register(step)
|
|
41
|
-
rescue StandardError, ScriptError => e
|
|
42
|
-
raise UsageError.new("failed loading step #{rel(dir, path)}: #{e.class}: #{e.message}") unless e.is_a?(UsageError)
|
|
43
|
-
|
|
44
|
-
raise
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Load the file and return the Step::Base subclass it newly defined.
|
|
48
|
-
def capture_defined_class(path, disc)
|
|
49
|
-
before = descendants
|
|
50
|
-
load(path)
|
|
51
|
-
defined = descendants - before
|
|
52
|
-
return defined.first if defined.length == 1
|
|
53
|
-
raise UsageError.new("step #{path} defined more than one Textus::Step subclass") if defined.length > 1
|
|
54
|
-
|
|
55
|
-
fallback = find_existing_class_for(disc)
|
|
56
|
-
return fallback if fallback
|
|
57
|
-
|
|
58
|
-
raise UsageError.new("step #{path} defined no Textus::Step subclass")
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def find_existing_class_for(disc)
|
|
62
|
-
expected = inferred_class_name_for(disc)
|
|
63
|
-
candidates = descendants.select do |klass|
|
|
64
|
-
klass < BASE_FOR.fetch(disc.kind) && klass.name&.split("::")&.last == expected
|
|
65
|
-
end
|
|
66
|
-
return nil if candidates.empty?
|
|
67
|
-
return candidates.first if candidates.length == 1
|
|
68
|
-
|
|
69
|
-
namespaced = candidates.find { |klass| klass.name == "Textus::Step::#{expected}" }
|
|
70
|
-
namespaced || candidates.first
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def inferred_class_name_for(disc)
|
|
74
|
-
stem = disc.name.to_s.split(/[-_]/).map(&:capitalize).join
|
|
75
|
-
suffix = { fetch: "Fetch", transform: "Transform", validate: "Validate", observe: "Observe" }.fetch(disc.kind)
|
|
76
|
-
"#{stem}#{suffix}"
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def validate!(disc, klass, path, dir)
|
|
80
|
-
expected = BASE_FOR.fetch(disc.kind)
|
|
81
|
-
actual_kind = klass.respond_to?(:kind) ? safe_kind(klass) : nil
|
|
82
|
-
unless klass < expected
|
|
83
|
-
raise UsageError.new("#{rel(dir, path)} defines a #{actual_kind || "non-step"} step but lives under #{disc.kind}/")
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
sig = Step::Signature.new(klass.instance_method(:call))
|
|
87
|
-
missing = sig.missing(klass.required_kwargs)
|
|
88
|
-
return if missing.empty?
|
|
89
|
-
|
|
90
|
-
msg = "#{disc.kind} step '#{disc.name}' #call must accept kwargs: " \
|
|
91
|
-
"#{klass.required_kwargs.join(", ")} (missing: #{missing.join(", ")})"
|
|
92
|
-
raise UsageError.new(msg)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
def safe_kind(klass)
|
|
96
|
-
klass.kind
|
|
97
|
-
rescue StandardError
|
|
98
|
-
nil
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def descendants
|
|
102
|
-
ObjectSpace.each_object(Class).select { |c| c < Step::Base }
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def rel(dir, path) = path.delete_prefix(dir.to_s).delete_prefix("/")
|
|
106
|
-
end
|
|
107
|
-
end
|
|
108
|
-
end
|
data/lib/textus/step/observe.rb
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Step
|
|
5
|
-
# Reacts to a lifecycle event (Catalog::PUBSUB). 0..N per event,
|
|
6
|
-
# fire-and-forget, no meaningful return, timeout-isolated by the EventBus.
|
|
7
|
-
# Declares its event with `on :event_name` and an optional key filter with
|
|
8
|
-
# `match "glob.**"`. Replaces user pub/sub subscribers.
|
|
9
|
-
class Observe < Base
|
|
10
|
-
def self.on(event = :__read__)
|
|
11
|
-
if event == :__read__
|
|
12
|
-
@event
|
|
13
|
-
else
|
|
14
|
-
@event = event.to_sym
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def self.match(glob = :__read__)
|
|
19
|
-
if glob == :__read__
|
|
20
|
-
@match
|
|
21
|
-
else
|
|
22
|
-
@match = glob
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
class << self
|
|
27
|
-
attr_reader :event
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|