textus 0.52.0 → 0.53.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 +25 -0
- data/README.md +62 -54
- data/SPEC.md +62 -187
- data/docs/architecture/README.md +88 -77
- data/exe/textus +1 -1
- data/lib/textus/action/accept.rb +53 -0
- data/lib/textus/action/audit.rb +133 -0
- data/lib/textus/action/base.rb +42 -0
- data/lib/textus/{read → action}/blame.rb +30 -22
- data/lib/textus/action/boot.rb +26 -0
- data/lib/textus/action/data_mv.rb +71 -0
- data/lib/textus/action/deps.rb +48 -0
- data/lib/textus/action/doctor.rb +26 -0
- data/lib/textus/action/drain.rb +41 -0
- data/lib/textus/action/enqueue.rb +55 -0
- data/lib/textus/action/get.rb +80 -0
- data/lib/textus/action/jobs.rb +38 -0
- data/lib/textus/action/key_delete.rb +46 -0
- data/lib/textus/action/key_delete_prefix.rb +46 -0
- data/lib/textus/action/key_mv.rb +143 -0
- data/lib/textus/action/key_mv_prefix.rb +59 -0
- data/lib/textus/action/list.rb +44 -0
- data/lib/textus/action/propose.rb +54 -0
- data/lib/textus/action/published.rb +26 -0
- data/lib/textus/action/pulse/scanner.rb +118 -0
- data/lib/textus/action/pulse.rb +87 -0
- data/lib/textus/action/put.rb +63 -0
- data/lib/textus/action/rdeps.rb +49 -0
- data/lib/textus/action/reject.rb +49 -0
- data/lib/textus/action/rule_explain.rb +95 -0
- data/lib/textus/action/rule_lint.rb +70 -0
- data/lib/textus/action/rule_list.rb +46 -0
- data/lib/textus/action/schema_envelope.rb +31 -0
- data/lib/textus/action/uid.rb +35 -0
- data/lib/textus/action/where.rb +38 -0
- data/lib/textus/action/write_verb.rb +58 -0
- data/lib/textus/background/job/base.rb +27 -0
- data/lib/textus/background/job/materialize.rb +31 -0
- data/lib/textus/background/job/refresh.rb +22 -0
- data/lib/textus/background/job/sweep.rb +31 -0
- data/lib/textus/background/job.rb +19 -0
- data/lib/textus/background/plan.rb +9 -0
- data/lib/textus/background/planner/plan.rb +113 -0
- data/lib/textus/{maintenance → background}/retention/apply.rb +7 -9
- data/lib/textus/background/worker.rb +67 -0
- data/lib/textus/boot.rb +53 -45
- data/lib/textus/command.rb +36 -0
- data/lib/textus/container.rb +1 -1
- data/lib/textus/{domain → core}/duration.rb +1 -1
- data/lib/textus/{domain → core}/freshness/evaluator.rb +11 -11
- data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
- data/lib/textus/{domain → core}/freshness.rb +2 -2
- data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
- data/lib/textus/{domain → core}/retention.rb +2 -2
- data/lib/textus/{domain → core}/sentinel.rb +1 -1
- data/lib/textus/doctor/check/generator_drift.rb +1 -1
- data/lib/textus/doctor/check/handler_permit.rb +34 -0
- data/lib/textus/doctor/check/hooks.rb +11 -18
- data/lib/textus/doctor/check/illegal_keys.rb +1 -1
- data/lib/textus/doctor/check/intake_registration.rb +5 -5
- data/lib/textus/doctor/check/proposal_targets.rb +3 -3
- data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
- data/lib/textus/doctor/check/schema_violations.rb +8 -2
- data/lib/textus/doctor/check.rb +12 -9
- data/lib/textus/{read → doctor}/validator.rb +22 -13
- data/lib/textus/doctor.rb +6 -6
- data/lib/textus/envelope/io/writer.rb +65 -36
- data/lib/textus/envelope.rb +5 -3
- data/lib/textus/errors.rb +17 -9
- data/lib/textus/events.rb +21 -0
- data/lib/textus/gate/auth.rb +181 -0
- data/lib/textus/gate.rb +114 -0
- data/lib/textus/init/templates/machine_intake.rb +39 -35
- data/lib/textus/init/templates/orientation_reducer.rb +15 -11
- data/lib/textus/init.rb +90 -73
- data/lib/textus/key/path.rb +9 -2
- data/lib/textus/layout.rb +13 -0
- data/lib/textus/manifest/data.rb +14 -14
- data/lib/textus/manifest/entry/base.rb +15 -11
- data/lib/textus/manifest/entry/parser.rb +6 -6
- data/lib/textus/manifest/entry/produced.rb +3 -2
- data/lib/textus/manifest/entry/publish/mode.rb +1 -1
- data/lib/textus/manifest/entry/publish/to_paths.rb +2 -1
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/{domain/policy/handler_allowlist.rb → manifest/policy/handler_permit.rb} +4 -4
- data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
- data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
- data/lib/textus/manifest/policy/react.rb +30 -0
- data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
- data/lib/textus/{domain → manifest}/policy/source.rb +24 -19
- data/lib/textus/manifest/policy.rb +36 -48
- data/lib/textus/manifest/resolver.rb +3 -2
- data/lib/textus/manifest/rules.rb +4 -4
- data/lib/textus/manifest/schema/keys.rb +17 -11
- data/lib/textus/manifest/schema/validator.rb +24 -22
- data/lib/textus/manifest/schema/vocabulary.rb +1 -1
- data/lib/textus/manifest/schema.rb +2 -2
- data/lib/textus/manifest.rb +2 -2
- data/lib/textus/{produce → pipeline}/acquire/handler.rb +2 -2
- data/lib/textus/{produce → pipeline}/acquire/intake.rb +22 -20
- data/lib/textus/{produce → pipeline}/acquire/projection.rb +13 -11
- data/lib/textus/{produce → pipeline}/acquire/serializer/json.rb +2 -2
- data/lib/textus/{produce → pipeline}/acquire/serializer/text.rb +1 -1
- data/lib/textus/{produce → pipeline}/acquire/serializer/yaml.rb +2 -2
- data/lib/textus/{produce → pipeline}/acquire/serializer.rb +1 -1
- data/lib/textus/{produce → pipeline}/engine.rb +7 -5
- data/lib/textus/{produce → pipeline}/render.rb +3 -1
- data/lib/textus/ports/audit_log.rb +31 -5
- data/lib/textus/ports/audit_subscriber.rb +4 -4
- data/lib/textus/{domain/jobs → ports/queue}/job.rb +19 -12
- data/lib/textus/ports/queue.rb +1 -1
- data/lib/textus/ports/sentinel_store.rb +2 -2
- data/lib/textus/ports/watcher_lock.rb +48 -0
- data/lib/textus/projection.rb +8 -8
- data/lib/textus/schema/tools.rb +4 -3
- data/lib/textus/session.rb +6 -3
- data/lib/textus/step/base.rb +35 -0
- data/lib/textus/step/builtin/csv_fetch.rb +19 -0
- data/lib/textus/step/builtin/ical_events_fetch.rb +30 -0
- data/lib/textus/step/builtin/json_fetch.rb +18 -0
- data/lib/textus/step/builtin/markdown_links_fetch.rb +20 -0
- data/lib/textus/step/builtin/rss_fetch.rb +26 -0
- data/lib/textus/step/builtin.rb +22 -0
- data/lib/textus/{hooks → step}/catalog.rb +3 -3
- data/lib/textus/{hooks → step}/context.rb +15 -13
- data/lib/textus/step/discovery.rb +24 -0
- data/lib/textus/{hooks → step}/error_log.rb +1 -1
- data/lib/textus/{hooks → step}/event_bus.rb +15 -16
- data/lib/textus/step/fetch.rb +13 -0
- data/lib/textus/{hooks → step}/fire_report.rb +1 -1
- data/lib/textus/step/loader.rb +108 -0
- data/lib/textus/step/observe.rb +31 -0
- data/lib/textus/step/registry_store.rb +66 -0
- data/lib/textus/{hooks → step}/signature.rb +1 -1
- data/lib/textus/step/transform.rb +12 -0
- data/lib/textus/step/validate.rb +11 -0
- data/lib/textus/step.rb +10 -0
- data/lib/textus/store.rb +17 -15
- data/lib/textus/surfaces/cli/group/data.rb +11 -0
- data/lib/textus/surfaces/cli/group/key.rb +11 -0
- data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
- data/lib/textus/surfaces/cli/group/rule.rb +11 -0
- data/lib/textus/surfaces/cli/group/schema.rb +11 -0
- data/lib/textus/surfaces/cli/group.rb +50 -0
- data/lib/textus/surfaces/cli/runner.rb +236 -0
- data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
- data/lib/textus/surfaces/cli/verb/get.rb +21 -0
- data/lib/textus/surfaces/cli/verb/init.rb +20 -0
- data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
- data/lib/textus/surfaces/cli/verb/put.rb +30 -0
- data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
- data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
- data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
- data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
- data/lib/textus/surfaces/cli/verb.rb +111 -0
- data/lib/textus/surfaces/cli.rb +148 -0
- data/lib/textus/surfaces/mcp/catalog.rb +99 -0
- data/lib/textus/surfaces/mcp/errors.rb +34 -0
- data/lib/textus/surfaces/mcp/server.rb +145 -0
- data/lib/textus/surfaces/mcp/session.rb +9 -0
- data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
- data/lib/textus/surfaces/mcp.rb +8 -0
- data/lib/textus/surfaces/role_scope.rb +38 -0
- data/lib/textus/surfaces/watcher.rb +38 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +64 -22
- metadata +132 -118
- data/lib/textus/cli/group/hook.rb +0 -9
- data/lib/textus/cli/group/key.rb +0 -9
- data/lib/textus/cli/group/mcp.rb +0 -9
- data/lib/textus/cli/group/rule.rb +0 -9
- data/lib/textus/cli/group/schema.rb +0 -9
- data/lib/textus/cli/group/zone.rb +0 -9
- data/lib/textus/cli/group.rb +0 -48
- data/lib/textus/cli/runner.rb +0 -193
- data/lib/textus/cli/verb/doctor.rb +0 -17
- data/lib/textus/cli/verb/get.rb +0 -18
- data/lib/textus/cli/verb/hook_run.rb +0 -48
- data/lib/textus/cli/verb/hooks.rb +0 -50
- data/lib/textus/cli/verb/init.rb +0 -18
- data/lib/textus/cli/verb/mcp_serve.rb +0 -22
- data/lib/textus/cli/verb/put.rb +0 -30
- data/lib/textus/cli/verb/schema_diff.rb +0 -15
- data/lib/textus/cli/verb/schema_init.rb +0 -19
- data/lib/textus/cli/verb/schema_migrate.rb +0 -19
- data/lib/textus/cli/verb/serve.rb +0 -19
- data/lib/textus/cli/verb.rb +0 -116
- data/lib/textus/cli.rb +0 -138
- data/lib/textus/dispatcher.rb +0 -54
- data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
- data/lib/textus/domain/action.rb +0 -9
- data/lib/textus/domain/jobs/registry.rb +0 -37
- data/lib/textus/domain/permission.rb +0 -7
- data/lib/textus/domain/policy/base_guards.rb +0 -25
- data/lib/textus/domain/policy/evaluation.rb +0 -15
- data/lib/textus/domain/policy/guard.rb +0 -35
- data/lib/textus/domain/policy/guard_factory.rb +0 -40
- data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
- data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
- data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
- data/lib/textus/domain/policy/predicates/registry.rb +0 -39
- data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
- data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
- data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
- data/lib/textus/hooks/builtin.rb +0 -70
- data/lib/textus/hooks/loader.rb +0 -54
- data/lib/textus/hooks/rpc_registry.rb +0 -43
- data/lib/textus/jobs/handlers.rb +0 -62
- data/lib/textus/jobs/scheduler.rb +0 -36
- data/lib/textus/jobs/seeder.rb +0 -57
- data/lib/textus/maintenance/drain.rb +0 -42
- data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
- data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
- data/lib/textus/maintenance/rule_lint.rb +0 -66
- data/lib/textus/maintenance/serve.rb +0 -30
- data/lib/textus/maintenance/worker.rb +0 -74
- data/lib/textus/maintenance/zone_mv.rb +0 -64
- data/lib/textus/maintenance.rb +0 -15
- data/lib/textus/mcp/catalog.rb +0 -70
- data/lib/textus/mcp/errors.rb +0 -32
- data/lib/textus/mcp/server.rb +0 -138
- data/lib/textus/mcp/session.rb +0 -7
- data/lib/textus/mcp/tool_schemas.rb +0 -15
- data/lib/textus/mcp.rb +0 -6
- data/lib/textus/mustache.rb +0 -117
- data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
- data/lib/textus/produce/events.rb +0 -36
- data/lib/textus/read/audit.rb +0 -130
- data/lib/textus/read/boot.rb +0 -26
- data/lib/textus/read/capabilities.rb +0 -70
- data/lib/textus/read/deps.rb +0 -38
- data/lib/textus/read/doctor.rb +0 -27
- data/lib/textus/read/freshness.rb +0 -152
- data/lib/textus/read/get.rb +0 -73
- data/lib/textus/read/jobs.rb +0 -31
- data/lib/textus/read/list.rb +0 -24
- data/lib/textus/read/published.rb +0 -22
- data/lib/textus/read/pulse.rb +0 -98
- data/lib/textus/read/rdeps.rb +0 -39
- data/lib/textus/read/rule_explain.rb +0 -96
- data/lib/textus/read/rule_list.rb +0 -54
- data/lib/textus/read/schema_envelope.rb +0 -25
- data/lib/textus/read/uid.rb +0 -29
- data/lib/textus/read/validate_all.rb +0 -36
- data/lib/textus/read/where.rb +0 -24
- data/lib/textus/role_scope.rb +0 -78
- data/lib/textus/write/accept.rb +0 -58
- data/lib/textus/write/enqueue.rb +0 -50
- data/lib/textus/write/key_delete.rb +0 -65
- data/lib/textus/write/key_mv.rb +0 -141
- data/lib/textus/write/propose.rb +0 -54
- data/lib/textus/write/put.rb +0 -74
- data/lib/textus/write/reject.rb +0 -68
data/lib/textus/projection.rb
CHANGED
|
@@ -10,13 +10,13 @@ module Textus
|
|
|
10
10
|
# read on every path (ADR 0089): it annotates freshness but never ingests,
|
|
11
11
|
# so materialization and any other reader share the same side-effect-free read.
|
|
12
12
|
# `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
|
|
13
|
-
# `rpc` — a `
|
|
13
|
+
# `rpc` — a `Step::RpcRegistry` used to dispatch `transform_rows` callables.
|
|
14
14
|
# `transform_context` — capability object handed to transform reducers as `caps:`.
|
|
15
|
-
def initialize(reader:, spec:, lister:,
|
|
15
|
+
def initialize(reader:, spec:, lister:, steps:, transform_context:)
|
|
16
16
|
@reader = reader
|
|
17
17
|
@spec = spec || {}
|
|
18
18
|
@lister = lister
|
|
19
|
-
@
|
|
19
|
+
@steps = steps
|
|
20
20
|
@transform_context = transform_context
|
|
21
21
|
@limit = (@spec["limit"] || MAX_LIMIT).to_i
|
|
22
22
|
raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
|
|
@@ -55,13 +55,13 @@ module Textus
|
|
|
55
55
|
def apply_reducer(rows)
|
|
56
56
|
name = @spec["transform"] or return rows
|
|
57
57
|
Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
|
|
58
|
-
@
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
@steps.invoke(:transform, name,
|
|
59
|
+
caps: @transform_context,
|
|
60
|
+
rows: rows,
|
|
61
|
+
config: @spec["transform_config"] || {})
|
|
62
62
|
end
|
|
63
63
|
rescue Timeout::Error
|
|
64
|
-
raise UsageError.new("
|
|
64
|
+
raise UsageError.new("transform '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
|
|
65
65
|
end
|
|
66
66
|
|
|
67
67
|
def collect_keys
|
data/lib/textus/schema/tools.rb
CHANGED
|
@@ -84,10 +84,11 @@ module Textus
|
|
|
84
84
|
# Orchestrator-free read: schema tooling must never trigger a fetch
|
|
85
85
|
# while inspecting/migrating entries (ADR 0062).
|
|
86
86
|
def self.pure_get(store, role, key)
|
|
87
|
-
|
|
88
|
-
|
|
87
|
+
scope = store.as(role)
|
|
88
|
+
Textus::Action::Get.new(key: key).call(
|
|
89
|
+
container: scope.container,
|
|
89
90
|
call: Textus::Call.build(role: role),
|
|
90
|
-
)
|
|
91
|
+
)
|
|
91
92
|
end
|
|
92
93
|
|
|
93
94
|
def self.load_schema(store, name)
|
data/lib/textus/session.rb
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
# The agent session: per-connection (MCP), per-process (CLI), or per-loop
|
|
3
3
|
# (Ruby) orientation state — the audit cursor plus the contract etag and
|
|
4
|
-
#
|
|
4
|
+
# propose_lane captured at boot. Immutable Data value; advance_cursor
|
|
5
5
|
# returns a new instance. ADR 0036; contract_etag widened in ADR 0074.
|
|
6
|
-
Session = Data.define(:role, :cursor, :
|
|
6
|
+
Session = Data.define(:role, :cursor, :propose_lane, :contract_etag) do
|
|
7
|
+
# Back-compat reader while lane terminology migrates.
|
|
8
|
+
def propose_zone = propose_lane
|
|
9
|
+
|
|
7
10
|
def advance_cursor(new_cursor) = with(cursor: new_cursor)
|
|
8
11
|
|
|
9
12
|
def check_etag!(observed_etag)
|
|
10
13
|
return if observed_etag == contract_etag
|
|
11
14
|
|
|
12
|
-
raise Textus::MCP::ContractDrift.new(
|
|
15
|
+
raise Textus::Surfaces::MCP::ContractDrift.new(
|
|
13
16
|
"contract changed (manifest/hooks/schemas were #{short_etag(contract_etag)}, " \
|
|
14
17
|
"now #{short_etag(observed_etag)}); re-run boot",
|
|
15
18
|
)
|
|
@@ -0,0 +1,35 @@
|
|
|
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
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
|
@@ -0,0 +1,30 @@
|
|
|
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
|
|
@@ -0,0 +1,18 @@
|
|
|
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
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
|
@@ -0,0 +1,26 @@
|
|
|
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
|
|
@@ -0,0 +1,22 @@
|
|
|
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
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
module
|
|
5
|
-
# The single source of truth for
|
|
6
|
-
# kwargs. EventBus,
|
|
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
7
|
# tables directly — the registries do not keep their own copies. Catalog
|
|
8
8
|
# references no other constant, so it has no load-order cycle, which is
|
|
9
9
|
# what removed the previous drift hazard (EventBus held a hard-coded
|
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
module
|
|
5
|
-
# A narrow handle passed to user
|
|
4
|
+
module Step
|
|
5
|
+
# A narrow handle passed to user steps in place of the raw Store.
|
|
6
6
|
# All writes route back through the RoleScope 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
11
|
def self.for(container:, call:)
|
|
12
|
-
scope = Textus::RoleScope.new(
|
|
12
|
+
scope = Textus::Surfaces::RoleScope.new(
|
|
13
13
|
container: container,
|
|
14
14
|
role: call.role,
|
|
15
15
|
correlation_id: call.correlation_id,
|
|
@@ -48,24 +48,26 @@ module Textus
|
|
|
48
48
|
|
|
49
49
|
# fan-out
|
|
50
50
|
def publish_followup(event, **)
|
|
51
|
-
@scope.container.
|
|
51
|
+
@scope.container.steps.publish(event, ctx: self, **)
|
|
52
52
|
end
|
|
53
53
|
|
|
54
54
|
def inspect
|
|
55
|
-
"#<Textus::
|
|
55
|
+
"#<Textus::Step::Context role=#{@role} correlation_id=#{@correlation_id}>"
|
|
56
56
|
end
|
|
57
57
|
|
|
58
58
|
private
|
|
59
59
|
|
|
60
60
|
def pure_reader
|
|
61
|
-
@pure_reader ||=
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
69
71
|
end
|
|
70
72
|
end
|
|
71
73
|
end
|
|
@@ -0,0 +1,24 @@
|
|
|
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,7 +1,9 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "timeout"
|
|
4
|
+
|
|
3
5
|
module Textus
|
|
4
|
-
module
|
|
6
|
+
module Step
|
|
5
7
|
class EventBus
|
|
6
8
|
HOOK_TIMEOUT_SECONDS = 2
|
|
7
9
|
|
|
@@ -40,6 +42,8 @@ module Textus
|
|
|
40
42
|
|
|
41
43
|
def pubsub_handlers(event) = @pubsub[event.to_sym]
|
|
42
44
|
|
|
45
|
+
def pubsub_handlers_names = @pubsub.values.flatten.map { |h| h[:name] }
|
|
46
|
+
|
|
43
47
|
def publish(event, strict: false, **kwargs)
|
|
44
48
|
key = kwargs[:key] || "-"
|
|
45
49
|
fired = []
|
|
@@ -68,24 +72,19 @@ module Textus
|
|
|
68
72
|
|
|
69
73
|
def invoke(event, sub, key, kwargs)
|
|
70
74
|
accepted = Signature.new(sub[:callable]).filter(kwargs)
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
end
|
|
78
|
-
if thread.join(HOOK_TIMEOUT_SECONDS).nil?
|
|
79
|
-
thread.kill
|
|
75
|
+
begin
|
|
76
|
+
Timeout.timeout(HOOK_TIMEOUT_SECONDS) do
|
|
77
|
+
sub[:callable].call(**accepted)
|
|
78
|
+
end
|
|
79
|
+
[:ok, nil]
|
|
80
|
+
rescue Timeout::Error
|
|
80
81
|
err = HookTimeout.new("hook #{sub[:name]} exceeded #{HOOK_TIMEOUT_SECONDS}s on event #{event}")
|
|
81
82
|
notify_error(event, sub, key, kwargs, err)
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
return [:errored, error]
|
|
83
|
+
[:timed_out, err]
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
notify_error(event, sub, key, kwargs, e)
|
|
86
|
+
[:errored, e]
|
|
87
87
|
end
|
|
88
|
-
[:ok, nil]
|
|
89
88
|
end
|
|
90
89
|
|
|
91
90
|
def notify_error(event, sub, key, kwargs, error)
|
|
@@ -0,0 +1,13 @@
|
|
|
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
|
|
@@ -0,0 +1,108 @@
|
|
|
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
|
|
@@ -0,0 +1,31 @@
|
|
|
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
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Step
|
|
5
|
+
# The single home for registered steps. Reuses Step::EventBus for observe
|
|
6
|
+
# (pub/sub) dispatch — including its timeout isolation and error ring buffer
|
|
7
|
+
# — and holds a kind->{name->instance} table for the invocable kinds
|
|
8
|
+
# (fetch/transform/validate). Replaces the EventBus+RpcRegistryStore pair.
|
|
9
|
+
class RegistryStore
|
|
10
|
+
def initialize(error_log: Step::ErrorLog.new)
|
|
11
|
+
@bus = Step::EventBus.new(error_log: error_log)
|
|
12
|
+
@table = Hash.new { |h, k| h[k] = {} }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# Register either a Step instance (invocable/observe table) or a pub/sub
|
|
16
|
+
# subscriber (`register(event, name, keys:, &block)`) for internal/spec
|
|
17
|
+
# probes.
|
|
18
|
+
def register(step, *, keys: nil, &)
|
|
19
|
+
return @bus.register(step, *, keys: keys, &) unless step.is_a?(Step::Base)
|
|
20
|
+
|
|
21
|
+
kind = step.class.kind
|
|
22
|
+
name = step.name.to_sym
|
|
23
|
+
return register_observe(step) if kind == :observe
|
|
24
|
+
|
|
25
|
+
raise UsageError.new("#{kind} '#{name}' already registered") if @table[kind].key?(name)
|
|
26
|
+
|
|
27
|
+
@table[kind][name] = step
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Invoke an invocable step. Mirrors the old RpcRegistryStore#invoke: inject
|
|
31
|
+
# caps only when #call declares :caps or accepts keyrest.
|
|
32
|
+
def invoke(kind, name, caps:, **other)
|
|
33
|
+
step = @table[kind][name.to_sym] or raise UsageError.new("unknown #{kind}: #{name}")
|
|
34
|
+
sig = Step::Signature.new(step.method(:call))
|
|
35
|
+
kwargs = other.dup
|
|
36
|
+
kwargs[:caps] = caps if sig.accepts_keyrest? || sig.declared_keys.include?(:caps)
|
|
37
|
+
step.call(**kwargs)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def names(kind)
|
|
41
|
+
return @bus.pubsub_handlers_names if kind.to_sym == :observe
|
|
42
|
+
|
|
43
|
+
@table[kind.to_sym].keys
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def pubsub_handlers(event)
|
|
47
|
+
@bus.pubsub_handlers(event)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Pub/sub passthrough (observe + internal built-in subscribers).
|
|
51
|
+
def publish(event, **) = @bus.publish(event, **)
|
|
52
|
+
def on(event, name, keys: nil, &) = @bus.register(event, name, keys: keys, &)
|
|
53
|
+
def on_error(&) = @bus.on_error(&)
|
|
54
|
+
def error_log = @bus.error_log
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
def register_observe(step)
|
|
59
|
+
sig = Step::Signature.new(step.method(:call))
|
|
60
|
+
@bus.register(step.class.event, step.name, keys: step.class.match) do |**kw|
|
|
61
|
+
step.call(**sig.filter(kw))
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Step
|
|
5
|
+
# Combines/reshapes projected rows into an artifact shape. Returns a Hash
|
|
6
|
+
# (the structured payload base) or an Array of rows. Replaces the
|
|
7
|
+
# :transform_rows RPC. (Phase 2 will widen `rows:` to a named `inputs:` map.)
|
|
8
|
+
class Transform < Base
|
|
9
|
+
def self.required_kwargs = %i[rows config]
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|