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
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Action
|
|
5
|
+
class List < Base
|
|
6
|
+
extend Textus::Contract::DSL
|
|
7
|
+
|
|
8
|
+
verb :list
|
|
9
|
+
summary "List keys filtered by lane and/or prefix."
|
|
10
|
+
surfaces :cli, :mcp
|
|
11
|
+
arg :prefix, String,
|
|
12
|
+
description: "restrict to keys starting with this dotted prefix, e.g. 'knowledge.runbooks'"
|
|
13
|
+
arg :lane, String,
|
|
14
|
+
description: "restrict to one lane by name (see `boot` lanes); combine with prefix to narrow further"
|
|
15
|
+
view(:cli) { |rows| { "entries" => rows } }
|
|
16
|
+
|
|
17
|
+
BURN = :sync
|
|
18
|
+
|
|
19
|
+
def initialize(prefix: nil, lane: nil)
|
|
20
|
+
super()
|
|
21
|
+
@prefix = prefix
|
|
22
|
+
@lane = lane
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
26
|
+
manifest = container.manifest
|
|
27
|
+
rows = manifest.resolver.enumerate(prefix: @prefix)
|
|
28
|
+
rows = rows.select { |row| row[:manifest_entry].lane == @lane } if @lane
|
|
29
|
+
rows.map { |row| { "key" => row[:key], "lane" => row[:manifest_entry].lane, "path" => row[:path] } }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.new(*args, **kwargs)
|
|
33
|
+
return super(**kwargs) unless args.any?
|
|
34
|
+
|
|
35
|
+
call_spec = instance_method(:initialize).parameters
|
|
36
|
+
required = call_spec.slice(:keyreq).map(&:last)
|
|
37
|
+
optional = call_spec.slice(:key).map(&:last)
|
|
38
|
+
positional = required + optional
|
|
39
|
+
mapped = positional.zip(args).to_h
|
|
40
|
+
super(**mapped.merge(kwargs))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Action
|
|
5
|
+
class Propose < WriteVerb
|
|
6
|
+
extend Textus::Contract::DSL
|
|
7
|
+
|
|
8
|
+
verb :propose
|
|
9
|
+
summary "Write a proposal to the role's propose_lane. Auto-prefixes the key."
|
|
10
|
+
surfaces :cli, :mcp
|
|
11
|
+
cli_stdin :json
|
|
12
|
+
arg :key, String, required: true, positional: true,
|
|
13
|
+
description: "key relative to propose_lane, e.g. 'decisions.feature-x'"
|
|
14
|
+
arg :meta, Hash, required: false, wire_name: :_meta,
|
|
15
|
+
description: "frontmatter; reads back as `_meta` from `get`. Include a 'proposal:' block naming the target_key"
|
|
16
|
+
arg :body, String,
|
|
17
|
+
description: "markdown/text payload for markdown-format entries; omit (use `content`) for json/yaml entries. Do not send both"
|
|
18
|
+
arg :content, Hash,
|
|
19
|
+
description: "structured payload for json/yaml-format entries; omit (use `body`) for markdown entries. Do not send both"
|
|
20
|
+
view { |env, _i| env.to_h_for_wire }
|
|
21
|
+
|
|
22
|
+
BURN = :sync
|
|
23
|
+
|
|
24
|
+
def initialize(key:, meta: nil, body: nil, content: nil)
|
|
25
|
+
super()
|
|
26
|
+
@key = key
|
|
27
|
+
@meta = meta
|
|
28
|
+
@body = body
|
|
29
|
+
@content = content
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(container:, call:)
|
|
33
|
+
zone = container.manifest.policy.propose_lane_for(call.role)
|
|
34
|
+
unless zone
|
|
35
|
+
raise Textus::Error.new(
|
|
36
|
+
"propose_forbidden",
|
|
37
|
+
"role '#{call.role}' has no writable propose_lane",
|
|
38
|
+
details: { "role" => call.role },
|
|
39
|
+
hint: "the manifest must define a queue zone and '#{call.role}' must hold the 'propose' capability",
|
|
40
|
+
)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
run_with_cascade("#{zone}.#{@key}", container:, call:) do
|
|
44
|
+
Textus::Action::Put.new(
|
|
45
|
+
key: "#{zone}.#{@key}",
|
|
46
|
+
meta: @meta || {},
|
|
47
|
+
body: @body,
|
|
48
|
+
content: @content,
|
|
49
|
+
).call(container: container, call: call)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Action
|
|
5
|
+
class Published < Base
|
|
6
|
+
extend Textus::Contract::DSL
|
|
7
|
+
|
|
8
|
+
verb :published
|
|
9
|
+
summary "List all entries that declare a publish_to target."
|
|
10
|
+
surfaces :cli
|
|
11
|
+
cli "published"
|
|
12
|
+
|
|
13
|
+
BURN = :sync
|
|
14
|
+
|
|
15
|
+
def args
|
|
16
|
+
{}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def call(container:, **)
|
|
20
|
+
container.manifest.data.entries.reject { |entry| entry.publish_to.empty? }.map do |entry|
|
|
21
|
+
{ "key" => entry.key, "publish_to" => entry.publish_to }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Textus
|
|
6
|
+
module Action
|
|
7
|
+
class Pulse
|
|
8
|
+
class Scanner
|
|
9
|
+
def initialize(prefix: nil, lane: nil)
|
|
10
|
+
@prefix = prefix
|
|
11
|
+
@lane = lane
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def call(container:, call:)
|
|
15
|
+
@container = container
|
|
16
|
+
@call = call
|
|
17
|
+
@manifest = container.manifest
|
|
18
|
+
@file_store = container.file_store
|
|
19
|
+
|
|
20
|
+
rows = []
|
|
21
|
+
@manifest.data.entries.each do |mentry|
|
|
22
|
+
next if @prefix && !mentry.key.start_with?(@prefix)
|
|
23
|
+
next if @lane && mentry.lane != @lane
|
|
24
|
+
|
|
25
|
+
rows << row_for(mentry)
|
|
26
|
+
end
|
|
27
|
+
rows
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def row_for(mentry)
|
|
33
|
+
envelope = safe_get(mentry.key)
|
|
34
|
+
last = envelope&.meta&.dig("last_fetched_at")
|
|
35
|
+
ttl, action = policy_for(mentry)
|
|
36
|
+
return base_row(mentry, last).merge(status: :no_policy) if ttl.nil?
|
|
37
|
+
|
|
38
|
+
basis = basis_for(mentry)
|
|
39
|
+
expired = expired?(mentry, basis, ttl)
|
|
40
|
+
base_row(mentry, last).merge(
|
|
41
|
+
ttl_seconds: ttl,
|
|
42
|
+
action: action,
|
|
43
|
+
status: expired ? :expired : :fresh,
|
|
44
|
+
next_due_at: basis.nil? ? nil : (basis + ttl).utc.iso8601,
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def policy_for(mentry)
|
|
49
|
+
if mentry.intake?
|
|
50
|
+
ttl = mentry.source.ttl_seconds
|
|
51
|
+
return [ttl, :refresh] unless ttl.nil?
|
|
52
|
+
end
|
|
53
|
+
ret = @manifest.rules.for(mentry.key).retention
|
|
54
|
+
return [ret.ttl_seconds, ret.action] unless ret.nil?
|
|
55
|
+
|
|
56
|
+
[nil, nil]
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def basis_for(mentry)
|
|
60
|
+
return evaluator.intake_basis(mentry) if mentry.intake? && mentry.source.ttl_seconds
|
|
61
|
+
|
|
62
|
+
mtime_for(mentry.key)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def expired?(mentry, basis, ttl)
|
|
66
|
+
if mentry.intake? && mentry.source.ttl_seconds
|
|
67
|
+
evaluator.verdict(mentry).stale
|
|
68
|
+
else
|
|
69
|
+
basis.nil? || Textus::Core::Retention::Sweep.expired?(ttl_seconds: ttl, mtime: basis, now: @call.now)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def evaluator
|
|
74
|
+
@evaluator ||= Textus::Core::Freshness::Evaluator.new(
|
|
75
|
+
manifest: @manifest,
|
|
76
|
+
file_stat: Textus::Ports::Storage::FileStat.new,
|
|
77
|
+
clock: @call,
|
|
78
|
+
)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def mtime_for(key)
|
|
82
|
+
path = @manifest.resolver.resolve(key).path
|
|
83
|
+
@file_store.exists?(path) ? Textus::Ports::Storage::FileStat.new.mtime(path) : nil
|
|
84
|
+
rescue Textus::Error
|
|
85
|
+
nil
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def base_row(mentry, last)
|
|
89
|
+
{
|
|
90
|
+
key: mentry.key,
|
|
91
|
+
lane: mentry.lane,
|
|
92
|
+
last_fetched_at: last,
|
|
93
|
+
age_seconds: last ? (@call.now - Time.parse(last)).to_i : nil,
|
|
94
|
+
}
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def safe_get(key)
|
|
98
|
+
res = @manifest.resolver.resolve(key)
|
|
99
|
+
return nil unless @file_store.exists?(res.path)
|
|
100
|
+
|
|
101
|
+
raw = @file_store.read(res.path)
|
|
102
|
+
parsed = Textus::Entry.for_format(res.entry.format).parse(raw, path: res.path)
|
|
103
|
+
Textus::Envelope.build(
|
|
104
|
+
key: key,
|
|
105
|
+
mentry: res.entry,
|
|
106
|
+
path: res.path,
|
|
107
|
+
meta: parsed["_meta"],
|
|
108
|
+
body: parsed["body"],
|
|
109
|
+
etag: Textus::Etag.for_bytes(raw),
|
|
110
|
+
content: parsed["content"],
|
|
111
|
+
)
|
|
112
|
+
rescue Textus::Error
|
|
113
|
+
nil
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Textus
|
|
6
|
+
module Action
|
|
7
|
+
class Pulse < Base
|
|
8
|
+
extend Textus::Contract::DSL
|
|
9
|
+
|
|
10
|
+
verb :pulse
|
|
11
|
+
summary "Delta since cursor — changed entries, stale, pending proposals, doctor summary."
|
|
12
|
+
surfaces :cli, :mcp
|
|
13
|
+
around :cursor
|
|
14
|
+
arg :since, Integer, session_default: :cursor,
|
|
15
|
+
description: "audit seq to diff from; defaults to the session cursor"
|
|
16
|
+
|
|
17
|
+
BURN = :sync
|
|
18
|
+
|
|
19
|
+
def initialize(since: nil)
|
|
20
|
+
super()
|
|
21
|
+
@since = since
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(container:, call:)
|
|
25
|
+
@container = container
|
|
26
|
+
@call = call
|
|
27
|
+
@manifest = container.manifest
|
|
28
|
+
@audit_log = container.audit_log
|
|
29
|
+
@root = container.root
|
|
30
|
+
@steps = container.steps
|
|
31
|
+
|
|
32
|
+
freshness_rows = Pulse::Scanner.new.call(container: container, call: call)
|
|
33
|
+
{
|
|
34
|
+
"cursor" => @audit_log.latest_seq,
|
|
35
|
+
"changed" => Textus::Action::Audit.new(seq_since: @since).call(container: container),
|
|
36
|
+
"stale" => freshness_rows.select { |row| row[:status] == :expired }.map { |row| row[:key] },
|
|
37
|
+
"pending_review" => review_keys,
|
|
38
|
+
"doctor" => doctor_summary,
|
|
39
|
+
"contract_etag" => Textus::Etag.for_contract(@root),
|
|
40
|
+
"next_due_at" => soonest_due(freshness_rows),
|
|
41
|
+
"hook_errors" => hook_errors_since(@since || 0),
|
|
42
|
+
}
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
def soonest_due(rows)
|
|
48
|
+
times = rows.map { |row| row[:next_due_at] }.compact.map { |t| Time.parse(t) }
|
|
49
|
+
return nil if times.empty?
|
|
50
|
+
|
|
51
|
+
times.min.utc.iso8601
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def review_keys
|
|
55
|
+
queue = @manifest.policy.queue_lane
|
|
56
|
+
return [] unless queue
|
|
57
|
+
|
|
58
|
+
rows = Textus::Action::List.new(lane: queue).call(container: @container)
|
|
59
|
+
rows.map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def doctor_summary
|
|
63
|
+
result = Textus::Doctor.build(container: @container)
|
|
64
|
+
issues = result["issues"] || []
|
|
65
|
+
{
|
|
66
|
+
"ok" => result["ok"],
|
|
67
|
+
"warn" => issues.count { |i| i["level"] == "warning" },
|
|
68
|
+
"fail" => issues.count { |i| i["level"] == "error" },
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def hook_errors_since(seq)
|
|
73
|
+
@steps.error_log.since(seq).map do |row|
|
|
74
|
+
{
|
|
75
|
+
"seq" => row[:seq],
|
|
76
|
+
"event" => row[:event].to_s,
|
|
77
|
+
"hook" => row[:hook].to_s,
|
|
78
|
+
"key" => row[:key],
|
|
79
|
+
"error_class" => row[:error_class],
|
|
80
|
+
"error_message" => row[:error_message],
|
|
81
|
+
"at" => row[:at],
|
|
82
|
+
}
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Action
|
|
5
|
+
class Put < WriteVerb
|
|
6
|
+
extend Textus::Contract::DSL
|
|
7
|
+
|
|
8
|
+
verb :put
|
|
9
|
+
summary "Create or update an entry. Schema-validated. Returns {uid, etag}."
|
|
10
|
+
surfaces :cli, :mcp
|
|
11
|
+
arg :key, String, required: true, positional: true,
|
|
12
|
+
description: "dotted entry key, e.g. 'knowledge.project'; must resolve to a zone the role may write"
|
|
13
|
+
arg :meta, Hash, required: false, wire_name: :_meta,
|
|
14
|
+
description: "frontmatter; reads back as `_meta` from `get`. Schema-validated — call `schema KEY` first"
|
|
15
|
+
arg :body, String,
|
|
16
|
+
description: "markdown/text payload for markdown-format entries; omit (use `content`) for json/yaml entries. Do not send both"
|
|
17
|
+
arg :content, Hash,
|
|
18
|
+
description: "structured payload for json/yaml-format entries; omit (use `body`) for markdown entries. Do not send both"
|
|
19
|
+
arg :if_etag, String,
|
|
20
|
+
description: "optimistic-concurrency guard: the etag you last read; the write is rejected if the entry changed since"
|
|
21
|
+
view { |env| { "uid" => env.uid, "etag" => env.etag } }
|
|
22
|
+
|
|
23
|
+
BURN = :sync
|
|
24
|
+
|
|
25
|
+
def initialize(key:, meta: nil, body: nil, content: nil, if_etag: nil)
|
|
26
|
+
super()
|
|
27
|
+
@key = key
|
|
28
|
+
@meta = meta
|
|
29
|
+
@body = body
|
|
30
|
+
@content = content
|
|
31
|
+
@if_etag = if_etag
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call(container:, call:)
|
|
35
|
+
run_with_cascade(@key, container:, call:) do
|
|
36
|
+
Textus::Manifest::Data.validate_key!(@key)
|
|
37
|
+
mentry = container.manifest.resolver.resolve(@key).entry
|
|
38
|
+
auth(container).check_action!(action: :put, actor: call.role, key: @key, extra: { if_etag: @if_etag })
|
|
39
|
+
|
|
40
|
+
envelope = writer(container, call).put(
|
|
41
|
+
@key,
|
|
42
|
+
mentry: mentry,
|
|
43
|
+
payload: Textus::Envelope::IO::Writer::Payload.new(
|
|
44
|
+
meta: @meta,
|
|
45
|
+
body: @body,
|
|
46
|
+
content: @content,
|
|
47
|
+
),
|
|
48
|
+
if_etag: @if_etag,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
container.steps.publish(
|
|
52
|
+
:entry_written,
|
|
53
|
+
ctx: Textus::Step::Context.for(container: container, call: call),
|
|
54
|
+
key: @key,
|
|
55
|
+
envelope: envelope,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
envelope
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Action
|
|
5
|
+
class Rdeps < Base
|
|
6
|
+
extend Textus::Contract::DSL
|
|
7
|
+
|
|
8
|
+
verb :rdeps
|
|
9
|
+
summary "List the derived entries that depend on a key (reverse deps / impact set)."
|
|
10
|
+
surfaces :cli, :mcp
|
|
11
|
+
arg :key, String, required: true, positional: true,
|
|
12
|
+
description: "dotted key whose dependents (what would be stranded if it moved) you want"
|
|
13
|
+
|
|
14
|
+
BURN = :sync
|
|
15
|
+
|
|
16
|
+
def initialize(key:)
|
|
17
|
+
super()
|
|
18
|
+
@key = key
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(container:, **)
|
|
22
|
+
manifest = container.manifest
|
|
23
|
+
rdeps = manifest.data.entries.each_with_object([]) do |entry, acc|
|
|
24
|
+
next unless entry.derived?
|
|
25
|
+
|
|
26
|
+
src = entry.source
|
|
27
|
+
sources =
|
|
28
|
+
if src.projection?
|
|
29
|
+
Array(src.select).compact
|
|
30
|
+
elsif src.external?
|
|
31
|
+
Array(src.sources).compact
|
|
32
|
+
else
|
|
33
|
+
[]
|
|
34
|
+
end
|
|
35
|
+
acc << entry.key if sources.any? { |source| source == @key || @key.start_with?("#{source}.") }
|
|
36
|
+
end
|
|
37
|
+
{ "key" => @key, "rdeps" => rdeps }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.new(*args, **kwargs)
|
|
41
|
+
return super(**kwargs) unless args.any?
|
|
42
|
+
|
|
43
|
+
positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
|
|
44
|
+
mapped = positional.zip(args).to_h
|
|
45
|
+
super(**mapped.merge(kwargs))
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Action
|
|
5
|
+
class Reject < WriteVerb
|
|
6
|
+
extend Textus::Contract::DSL
|
|
7
|
+
|
|
8
|
+
verb :reject
|
|
9
|
+
summary "discard a queued proposal without applying it"
|
|
10
|
+
surfaces :cli, :mcp
|
|
11
|
+
cli "reject"
|
|
12
|
+
arg :pending_key, String, required: true, positional: true, description: "the queued proposal's key"
|
|
13
|
+
|
|
14
|
+
BURN = :sync
|
|
15
|
+
|
|
16
|
+
def initialize(pending_key:)
|
|
17
|
+
super()
|
|
18
|
+
@pending_key = pending_key
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(container:, call:)
|
|
22
|
+
run_with_cascade(@pending_key, container:, call:) do
|
|
23
|
+
auth = Textus::Gate::Auth.new(container)
|
|
24
|
+
auth.check_action!(action: :reject, actor: call.role, key: @pending_key)
|
|
25
|
+
|
|
26
|
+
mentry = container.manifest.resolver.resolve(@pending_key).entry
|
|
27
|
+
unless mentry.in_proposal_lane?(container.manifest.policy)
|
|
28
|
+
raise ProposalError.new("reject: '#{@pending_key}' is not in a proposal zone (zone=#{mentry.lane})")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
env = Textus::Action::Get.new(key: @pending_key).call(container: container, call: call)
|
|
32
|
+
proposal = env.meta&.dig("proposal") or raise ProposalError.new("entry has no proposal block: #{@pending_key}")
|
|
33
|
+
target_key = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
|
|
34
|
+
|
|
35
|
+
writer(container, call).delete(@pending_key, mentry: mentry)
|
|
36
|
+
|
|
37
|
+
container.steps.publish(
|
|
38
|
+
:proposal_rejected,
|
|
39
|
+
ctx: Textus::Step::Context.for(container: container, call: call),
|
|
40
|
+
key: @pending_key,
|
|
41
|
+
target_key: target_key,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
{ "protocol" => PROTOCOL, "rejected" => @pending_key, "target_key" => target_key }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Action
|
|
5
|
+
class RuleExplain < Base
|
|
6
|
+
extend Textus::Contract::DSL
|
|
7
|
+
|
|
8
|
+
verb :rule_explain
|
|
9
|
+
summary "Effective rules for a key. Lean {lifecycle, guard} by default; detail: true adds matched blocks + guard predicates."
|
|
10
|
+
surfaces :cli, :mcp
|
|
11
|
+
cli "rule explain"
|
|
12
|
+
arg :key, String, required: true, positional: true,
|
|
13
|
+
description: "dotted key whose effective rules you want (lifecycle ttl/action, write guard, ...)"
|
|
14
|
+
arg :detail, :boolean,
|
|
15
|
+
description: "defaults false: lean {lifecycle, guard}. detail: true adds matched blocks + guard predicates per transition."
|
|
16
|
+
view(:cli) { |r| { "verb" => "rule_explain" }.merge(r.transform_keys(&:to_s)) }
|
|
17
|
+
|
|
18
|
+
BURN = :sync
|
|
19
|
+
|
|
20
|
+
def initialize(key:, detail: nil)
|
|
21
|
+
super()
|
|
22
|
+
@key = key
|
|
23
|
+
@detail = detail
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def call(container:, **)
|
|
27
|
+
@manifest = container.manifest
|
|
28
|
+
@detail ? explain(@key) : effective(@key)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
REGISTRY = Textus::Manifest::Schema::FIELD_REGISTRY
|
|
32
|
+
LEAN_FIELDS = REGISTRY.select { |_, m| m[:in_rule_explain].include?(:lean) }.keys.freeze
|
|
33
|
+
DETAIL_FIELDS = REGISTRY.select { |_, m| m[:in_rule_explain].include?(:detail) }.keys.freeze
|
|
34
|
+
EFFECTIVE_FIELDS = DETAIL_FIELDS.select { |f| REGISTRY[f][:policy_class] }.freeze
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def effective(key)
|
|
39
|
+
set = @manifest.rules.for(key)
|
|
40
|
+
LEAN_FIELDS.each_with_object({}) do |field, out|
|
|
41
|
+
value = set.public_send(field)
|
|
42
|
+
out[field.to_s] = lean_value(field, value) unless value.nil?
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def lean_value(field, value)
|
|
47
|
+
case field
|
|
48
|
+
when :retention
|
|
49
|
+
retention_hash(value, string_keys: true)
|
|
50
|
+
when :react
|
|
51
|
+
value.to_h
|
|
52
|
+
else
|
|
53
|
+
value
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def explain(key)
|
|
58
|
+
matching = @manifest.rules.explain(key)
|
|
59
|
+
winners = @manifest.rules.for(key)
|
|
60
|
+
{
|
|
61
|
+
key: key,
|
|
62
|
+
matched_blocks: matching.map do |block|
|
|
63
|
+
{ match: block.match }.merge(DETAIL_FIELDS.to_h { |f| [f, !block.public_send(f).nil?] })
|
|
64
|
+
end,
|
|
65
|
+
effective: EFFECTIVE_FIELDS.to_h { |f| [f, effective_value(f, winners.public_send(f))] },
|
|
66
|
+
guards: Textus::Gate::Auth::FLOOR.keys.to_h do |action|
|
|
67
|
+
floor = Textus::Gate::Auth::FLOOR.fetch(action, [])
|
|
68
|
+
rule = Array(@manifest.rules.for(key).guard&.dig(action.to_s))
|
|
69
|
+
[action, { floor: floor, rule: rule }]
|
|
70
|
+
end,
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def effective_value(field, value)
|
|
75
|
+
return nil if value.nil?
|
|
76
|
+
|
|
77
|
+
case field
|
|
78
|
+
when :retention
|
|
79
|
+
retention_hash(value, string_keys: false)
|
|
80
|
+
when :react
|
|
81
|
+
value.to_h
|
|
82
|
+
when :handler_permit
|
|
83
|
+
value.handlers
|
|
84
|
+
else
|
|
85
|
+
value
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def retention_hash(retention, string_keys:)
|
|
90
|
+
h = { ttl_seconds: retention.ttl_seconds, action: retention.action }
|
|
91
|
+
string_keys ? h.transform_keys(&:to_s) : h
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Textus
|
|
6
|
+
module Action
|
|
7
|
+
class RuleLint < Base
|
|
8
|
+
extend Textus::Contract::DSL
|
|
9
|
+
|
|
10
|
+
verb :rule_lint
|
|
11
|
+
summary "Diff candidate manifest YAML's rules against the live manifest. No writes."
|
|
12
|
+
surfaces :cli, :mcp
|
|
13
|
+
cli "rule lint"
|
|
14
|
+
arg :candidate_yaml, String, required: true, wire_name: :against, source: :file,
|
|
15
|
+
description: "path to candidate manifest YAML; its `rules:` block is diffed against the live manifest"
|
|
16
|
+
view { |v, _i| v.to_h }
|
|
17
|
+
|
|
18
|
+
BURN = :sync
|
|
19
|
+
|
|
20
|
+
def initialize(candidate_yaml:)
|
|
21
|
+
super()
|
|
22
|
+
@candidate_yaml = candidate_yaml
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def call(container:, **)
|
|
26
|
+
root = container.root
|
|
27
|
+
live_rules = current_rules(root)
|
|
28
|
+
candidate_rules = parse_candidate(@candidate_yaml)
|
|
29
|
+
|
|
30
|
+
live_by_match = live_rules.to_h { |rule| [rule["match"], rule] }
|
|
31
|
+
candidate_by_match = candidate_rules.to_h { |rule| [rule["match"], rule] }
|
|
32
|
+
|
|
33
|
+
steps = (candidate_by_match.keys - live_by_match.keys).map do |match|
|
|
34
|
+
{ "op" => "add_rule", "match" => match, "rule" => candidate_by_match[match] }
|
|
35
|
+
end
|
|
36
|
+
(live_by_match.keys - candidate_by_match.keys).each do |match|
|
|
37
|
+
steps << { "op" => "remove_rule", "match" => match }
|
|
38
|
+
end
|
|
39
|
+
(live_by_match.keys & candidate_by_match.keys).each do |match|
|
|
40
|
+
next if live_by_match[match] == candidate_by_match[match]
|
|
41
|
+
|
|
42
|
+
steps << {
|
|
43
|
+
"op" => "change_rule",
|
|
44
|
+
"match" => match,
|
|
45
|
+
"from" => live_by_match[match],
|
|
46
|
+
"to" => candidate_by_match[match],
|
|
47
|
+
}
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
Textus::Background::Plan.new(steps: steps, warnings: [])
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
private
|
|
54
|
+
|
|
55
|
+
def current_rules(root)
|
|
56
|
+
raw = YAML.safe_load_file(File.join(root, "manifest.yaml"), permitted_classes: [Symbol], aliases: false)
|
|
57
|
+
Array(raw["rules"])
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def parse_candidate(yaml_text)
|
|
61
|
+
raw = YAML.safe_load(yaml_text, permitted_classes: [Symbol], aliases: false)
|
|
62
|
+
raise UsageError.new("candidate is not a YAML mapping") unless raw.is_a?(Hash)
|
|
63
|
+
|
|
64
|
+
Array(raw["rules"])
|
|
65
|
+
rescue Psych::Exception => e
|
|
66
|
+
raise UsageError.new("candidate YAML parse error: #{e.message}")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|