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/read/audit.rb
DELETED
|
@@ -1,130 +0,0 @@
|
|
|
1
|
-
require "json"
|
|
2
|
-
require "time"
|
|
3
|
-
|
|
4
|
-
module Textus
|
|
5
|
-
module Read
|
|
6
|
-
# Queries .textus/.run/audit/audit.log. Filters: key, zone, role, verb, since,
|
|
7
|
-
# correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
|
|
8
|
-
# rows produce nil and are skipped).
|
|
9
|
-
class Audit
|
|
10
|
-
# Value object that carries all filter parameters for an audit query.
|
|
11
|
-
# `matches?` checks the manifest-independent predicates so the loop body
|
|
12
|
-
# only needs to handle the zone check (which requires manifest access).
|
|
13
|
-
Query = Data.define(:key, :zone, :role, :verb, :since, :seq_since, :correlation_id, :limit) do
|
|
14
|
-
# rubocop:disable Metrics/ParameterLists
|
|
15
|
-
def self.build(key: nil, zone: nil, role: nil, verb: nil,
|
|
16
|
-
since: nil, seq_since: nil, correlation_id: nil, limit: nil)
|
|
17
|
-
new(key:, zone:, role:, verb:, since:, seq_since:, correlation_id:, limit:)
|
|
18
|
-
end
|
|
19
|
-
# rubocop:enable Metrics/ParameterLists
|
|
20
|
-
|
|
21
|
-
def matches?(row)
|
|
22
|
-
return false if key && row["key"] != key
|
|
23
|
-
return false if role && row["role"] != role
|
|
24
|
-
return false if verb && row["verb"] != verb
|
|
25
|
-
return false if since && (row["ts"].nil? || Time.parse(row["ts"]) < since)
|
|
26
|
-
return false if seq_since && (row["seq"].nil? || row["seq"] <= seq_since)
|
|
27
|
-
return false if correlation_id && row.dig("extras", "correlation_id") != correlation_id
|
|
28
|
-
|
|
29
|
-
true
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
extend Textus::Contract::DSL
|
|
34
|
-
|
|
35
|
-
verb :audit
|
|
36
|
-
summary "Query the audit log with optional filters."
|
|
37
|
-
surfaces :cli
|
|
38
|
-
cli "audit"
|
|
39
|
-
# #call(**filters) — args map to Query.build keyword params (ADR 0063)
|
|
40
|
-
arg :key, String, required: false, description: "filter to rows for this key"
|
|
41
|
-
arg :zone, String, required: false, description: "filter to keys in this zone"
|
|
42
|
-
arg :role, String, required: false, description: "filter to rows written under this role"
|
|
43
|
-
arg :verb, String, required: false, description: "filter to rows for this verb"
|
|
44
|
-
arg :since, String, required: false,
|
|
45
|
-
coerce: ->(s) { Textus::Read::Audit.parse_since(s, now: Time.now) },
|
|
46
|
-
description: "ISO-8601 timestamp or relative offset (e.g. 1h, 30m)"
|
|
47
|
-
arg :seq_since, Integer, required: false, description: "return rows with seq > this cursor value"
|
|
48
|
-
arg :correlation_id, String, required: false, description: "filter to rows with this correlation_id"
|
|
49
|
-
arg :limit, Integer, required: false, description: "maximum number of rows to return"
|
|
50
|
-
view(:cli) { |rows, _i| { "verb" => "audit", "rows" => rows } }
|
|
51
|
-
|
|
52
|
-
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
53
|
-
@manifest = container.manifest
|
|
54
|
-
@root = container.root
|
|
55
|
-
@log_path = Textus::Layout.audit_log(container.root)
|
|
56
|
-
@audit_log = container.audit_log
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def call(**filters)
|
|
60
|
-
query = Query.build(**filters)
|
|
61
|
-
check_cursor_expiry!(query.seq_since)
|
|
62
|
-
|
|
63
|
-
files = all_log_files
|
|
64
|
-
return [] if files.empty?
|
|
65
|
-
|
|
66
|
-
rows = []
|
|
67
|
-
files.each do |file|
|
|
68
|
-
File.foreach(file) do |line|
|
|
69
|
-
parsed = parse_row(line.chomp)
|
|
70
|
-
next unless parsed
|
|
71
|
-
next unless query.matches?(parsed)
|
|
72
|
-
next if query.zone && !key_in_zone?(parsed["key"], query.zone)
|
|
73
|
-
|
|
74
|
-
rows << parsed
|
|
75
|
-
break if limit_reached?(rows, query)
|
|
76
|
-
end
|
|
77
|
-
break if limit_reached?(rows, query)
|
|
78
|
-
end
|
|
79
|
-
rows
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# Accepts ISO8601 ("2026-01-15", "2026-01-15T10:00:00Z") or a relative
|
|
83
|
-
# offset matching /\A(\d+)([smhd])\z/. Returns nil for unparseable input.
|
|
84
|
-
def self.parse_since(str, now: Time.now.utc)
|
|
85
|
-
return nil if str.nil? || str.empty?
|
|
86
|
-
return Time.parse(str) if str =~ /\A\d{4}-\d{2}-\d{2}/
|
|
87
|
-
|
|
88
|
-
m = str.match(/\A(\d+)([smhd])\z/) or return nil
|
|
89
|
-
mult = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }[m[2]]
|
|
90
|
-
now - (m[1].to_i * mult)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
private
|
|
94
|
-
|
|
95
|
-
def limit_reached?(rows, query) = query.limit && rows.length >= query.limit
|
|
96
|
-
|
|
97
|
-
def check_cursor_expiry!(seq_since)
|
|
98
|
-
return unless seq_since
|
|
99
|
-
|
|
100
|
-
log = @audit_log || Textus::Ports::AuditLog.new(@root)
|
|
101
|
-
min = log.min_available_seq
|
|
102
|
-
raise Textus::CursorExpired.new(requested: seq_since, min_available: min) if min && seq_since < min - 1
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def all_log_files
|
|
106
|
-
rotated = Dir.glob(File.join(Textus::Layout.audit_dir(@root), "audit.log.*"))
|
|
107
|
-
.reject { |p| p.end_with?(".meta.json") }
|
|
108
|
-
.sort_by { |p| -p.scan(/\d+$/).first.to_i } # .5 .4 .3 .2 .1 → oldest first
|
|
109
|
-
active = File.exist?(@log_path) ? [@log_path] : []
|
|
110
|
-
rotated + active
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
def parse_row(line)
|
|
114
|
-
return nil if line.empty?
|
|
115
|
-
return nil unless line.start_with?("{")
|
|
116
|
-
|
|
117
|
-
JSON.parse(line)
|
|
118
|
-
rescue JSON::ParserError
|
|
119
|
-
nil
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def key_in_zone?(key, zone)
|
|
123
|
-
mentry = @manifest.resolver.resolve(key).entry
|
|
124
|
-
mentry && mentry.zone == zone
|
|
125
|
-
rescue Textus::Error
|
|
126
|
-
false
|
|
127
|
-
end
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
end
|
data/lib/textus/read/boot.rb
DELETED
|
@@ -1,26 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Read
|
|
3
|
-
# Dispatched use case for the `boot` verb. The orientation envelope is
|
|
4
|
-
# built by the Textus::Boot library module; this class is the uniform
|
|
5
|
-
# (container:, call:) entry point that Dispatcher::VERBS resolves to.
|
|
6
|
-
# Boot is role-independent, so `call` is not consulted.
|
|
7
|
-
class Boot
|
|
8
|
-
extend Textus::Contract::DSL
|
|
9
|
-
|
|
10
|
-
verb :boot
|
|
11
|
-
summary "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart."
|
|
12
|
-
surfaces :cli, :mcp
|
|
13
|
-
arg :lean, :boolean,
|
|
14
|
-
description: "return only orientation essentials (zones, agent_quickstart, contract_etag) for cheap session-start injection"
|
|
15
|
-
|
|
16
|
-
def initialize(container:, call:)
|
|
17
|
-
@container = container
|
|
18
|
-
@call = call
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def call(lean: false)
|
|
22
|
-
Textus::Boot.build(container: @container, lean: lean)
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Read
|
|
3
|
-
# A machine-readable projection of the contract surface: every verb, the
|
|
4
|
-
# transports it reaches, and its full argument schema — sourced from the
|
|
5
|
-
# same Contract DSL the CLI/MCP/boot already project from (ADR 0039/0063).
|
|
6
|
-
#
|
|
7
|
-
# Integrators assert their docs against this in CI so they can't drift
|
|
8
|
-
# (#161 F4 — patrick-nexus docs claimed "MCP exposes 3 verbs" while ~20 are
|
|
9
|
-
# surfaced). It also makes the per-surface `dry_run` default asymmetry
|
|
10
|
-
# (#161 F6) self-documenting: each arg carries both `default` (agent wire)
|
|
11
|
-
# and `cli_default` (CLI), so the divergence is visible, not folklore.
|
|
12
|
-
#
|
|
13
|
-
# Pure contract introspection — it reads no store data; `container` is
|
|
14
|
-
# accepted only for the uniform use-case constructor.
|
|
15
|
-
class Capabilities
|
|
16
|
-
extend Textus::Contract::DSL
|
|
17
|
-
|
|
18
|
-
verb :capabilities
|
|
19
|
-
summary "Machine-readable contract surface: every verb, its transports, and arg schema."
|
|
20
|
-
surfaces :cli, :mcp
|
|
21
|
-
arg :verb, String, required: false, description: "filter to a single verb by name"
|
|
22
|
-
view { |result, _i| result }
|
|
23
|
-
|
|
24
|
-
def initialize(container: nil, call: nil); end
|
|
25
|
-
|
|
26
|
-
def call(verb: nil)
|
|
27
|
-
klasses = Textus::Dispatcher::VERBS.values.select { |k| contract?(k) }
|
|
28
|
-
rows = klasses.map { |k| project(k.contract) }
|
|
29
|
-
rows.select! { |r| r["verb"] == verb } if verb
|
|
30
|
-
{ "verbs" => rows.sort_by { |r| r["verb"] } }
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
private
|
|
34
|
-
|
|
35
|
-
def contract?(klass)
|
|
36
|
-
klass.respond_to?(:contract?) && klass.contract?
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def project(spec)
|
|
40
|
-
{
|
|
41
|
-
"verb" => spec.verb.to_s,
|
|
42
|
-
"summary" => spec.summary,
|
|
43
|
-
"surfaces" => spec.surfaces.map(&:to_s) + ["ruby"],
|
|
44
|
-
"cli" => spec.cli? ? spec.cli_path : nil,
|
|
45
|
-
"args" => spec.args.map { |a| project_arg(a) },
|
|
46
|
-
}
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def project_arg(arg)
|
|
50
|
-
out = {
|
|
51
|
-
"name" => arg.wire.to_s,
|
|
52
|
-
"type" => json_type(arg.type),
|
|
53
|
-
"required" => arg.required,
|
|
54
|
-
"positional" => arg.positional,
|
|
55
|
-
}
|
|
56
|
-
out["description"] = arg.description if arg.description
|
|
57
|
-
out["default"] = arg.default unless arg.default.nil?
|
|
58
|
-
out["cli_default"] = arg.cli_default unless arg.cli_default == :__unset
|
|
59
|
-
out["session_default"] = arg.session_default.to_s if arg.session_default
|
|
60
|
-
out
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def json_type(type)
|
|
64
|
-
Textus::Contract.json_type(type)
|
|
65
|
-
rescue ArgumentError
|
|
66
|
-
"string"
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
data/lib/textus/read/deps.rb
DELETED
|
@@ -1,38 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Read
|
|
3
|
-
class Deps
|
|
4
|
-
extend Textus::Contract::DSL
|
|
5
|
-
|
|
6
|
-
verb :deps
|
|
7
|
-
summary "List the keys a derived entry depends on (its projection/external sources)."
|
|
8
|
-
surfaces :cli, :mcp
|
|
9
|
-
arg :key, String, required: true, positional: true,
|
|
10
|
-
description: "dotted key of the derived entry whose source keys you want"
|
|
11
|
-
|
|
12
|
-
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
13
|
-
@manifest = container.manifest
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def call(key)
|
|
17
|
-
{ "key" => key, "deps" => sources_for(key) }
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
private
|
|
21
|
-
|
|
22
|
-
def sources_for(key)
|
|
23
|
-
entry = @manifest.data.entries.find { |e| e.key == key }
|
|
24
|
-
return [] unless entry&.derived?
|
|
25
|
-
|
|
26
|
-
src = entry.source
|
|
27
|
-
result = if src.projection?
|
|
28
|
-
Array(src.select).compact
|
|
29
|
-
elsif src.external?
|
|
30
|
-
Array(src.sources).compact
|
|
31
|
-
else
|
|
32
|
-
[]
|
|
33
|
-
end
|
|
34
|
-
result.uniq
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
data/lib/textus/read/doctor.rb
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Read
|
|
3
|
-
# Dispatched use case for the `doctor` verb. The health-check report is
|
|
4
|
-
# built by the Textus::Doctor library module; this class is the uniform
|
|
5
|
-
# (container:, call:) entry point that Dispatcher::VERBS resolves to.
|
|
6
|
-
# The acting role is irrelevant to a read-only health check, so `call`
|
|
7
|
-
# is not consulted.
|
|
8
|
-
class Doctor
|
|
9
|
-
extend Textus::Contract::DSL
|
|
10
|
-
|
|
11
|
-
verb :doctor
|
|
12
|
-
summary "Run health checks on the textus store and report any issues."
|
|
13
|
-
surfaces :cli
|
|
14
|
-
cli "doctor"
|
|
15
|
-
arg :checks, Array, required: false, description: "subset of check names to run (default: all)"
|
|
16
|
-
|
|
17
|
-
def initialize(container:, call:)
|
|
18
|
-
@container = container
|
|
19
|
-
@call = call
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
def call(checks: nil)
|
|
23
|
-
Textus::Doctor.build(container: @container, checks: checks)
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
require "time"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Read
|
|
5
|
-
# Per-entry staleness scan (ADR 0079, 0085, 0093). Walks every entry declared
|
|
6
|
-
# in the manifest and reports a staleness verdict sourced from the two new
|
|
7
|
-
# policy slots (ADR 0093):
|
|
8
|
-
# - intake entries: `entry.source.ttl_seconds` is the re-pull cadence;
|
|
9
|
-
# basis = `_meta.last_fetched_at` (else file mtime). Past ttl ⇒ :expired.
|
|
10
|
-
# - entries matched by a `retention:` rule: `retention.ttl_seconds` is the
|
|
11
|
-
# GC age; basis = file mtime. Past ttl ⇒ :expired (:action = drop/archive).
|
|
12
|
-
# Intake cadence wins when both apply (freshness is content currency; GC dueness
|
|
13
|
-
# shows via `drain --dry-run`).
|
|
14
|
-
# Status is one of :fresh, :expired, or :no_policy; the row also carries
|
|
15
|
-
# :action (:refresh for intake, :drop/:archive for retention).
|
|
16
|
-
#
|
|
17
|
-
# ADR 0085 removed the public `freshness` verb: there is no `:cli`/`:mcp`
|
|
18
|
-
# surface. This is now a Ruby-only internal scan consumed by `pulse` (which
|
|
19
|
-
# derives `stale` + `next_due_at` from it) and the hook `Context`. Humans drill
|
|
20
|
-
# into per-entry staleness detail via `get` (last_fetched_at) + `rule_explain`
|
|
21
|
-
# (the ttl / action policy) instead of a dedicated verb.
|
|
22
|
-
class Freshness
|
|
23
|
-
extend Textus::Contract::DSL
|
|
24
|
-
|
|
25
|
-
verb :freshness
|
|
26
|
-
summary "Internal per-entry lifecycle scan (status, age, ttl, action); backs pulse + hook context. No public surface (ADR 0085)."
|
|
27
|
-
arg :prefix, String, required: false, description: "filter to keys with this prefix"
|
|
28
|
-
arg :zone, String, required: false, description: "filter to entries in this zone"
|
|
29
|
-
|
|
30
|
-
def initialize(container:, call:)
|
|
31
|
-
@container = container
|
|
32
|
-
@call = call
|
|
33
|
-
@manifest = container.manifest
|
|
34
|
-
@file_store = container.file_store
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# Returns the soonest `next_due_at` across all entries with a fetch
|
|
38
|
-
# policy, as an ISO-8601 string, or nil if none.
|
|
39
|
-
def soonest_due(prefix: nil, zone: nil)
|
|
40
|
-
times = call(prefix: prefix, zone: zone)
|
|
41
|
-
.map { |r| r[:next_due_at] }
|
|
42
|
-
.compact
|
|
43
|
-
.map { |t| Time.parse(t) }
|
|
44
|
-
return nil if times.empty?
|
|
45
|
-
|
|
46
|
-
times.min.utc.iso8601
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def call(prefix: nil, zone: nil)
|
|
50
|
-
rows = []
|
|
51
|
-
@manifest.data.entries.each do |mentry|
|
|
52
|
-
next if prefix && !mentry.key.start_with?(prefix)
|
|
53
|
-
next if zone && mentry.zone != zone
|
|
54
|
-
|
|
55
|
-
rows << row_for(mentry)
|
|
56
|
-
end
|
|
57
|
-
rows
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
private
|
|
61
|
-
|
|
62
|
-
def row_for(mentry)
|
|
63
|
-
envelope = safe_get(mentry.key)
|
|
64
|
-
last = envelope&.meta&.dig("last_fetched_at")
|
|
65
|
-
ttl, action = policy_for(mentry)
|
|
66
|
-
return base_row(mentry, last).merge(status: :no_policy) if ttl.nil?
|
|
67
|
-
|
|
68
|
-
basis = basis_for(mentry)
|
|
69
|
-
expired = expired?(mentry, basis, ttl)
|
|
70
|
-
base_row(mentry, last).merge(
|
|
71
|
-
ttl_seconds: ttl,
|
|
72
|
-
action: action,
|
|
73
|
-
status: expired ? :expired : :fresh,
|
|
74
|
-
next_due_at: basis.nil? ? nil : (basis + ttl).utc.iso8601,
|
|
75
|
-
)
|
|
76
|
-
end
|
|
77
|
-
|
|
78
|
-
# ADR 0093: staleness comes from the intake re-pull cadence (source.ttl)
|
|
79
|
-
# or a retention GC rule (retention.ttl). Intake cadence wins when an entry
|
|
80
|
-
# has both (freshness is about content currency; GC dueness still shows via
|
|
81
|
-
# `drain --dry-run`). Returns [ttl_seconds, action] or [nil, nil].
|
|
82
|
-
def policy_for(mentry)
|
|
83
|
-
if mentry.intake?
|
|
84
|
-
ttl = mentry.source.ttl_seconds
|
|
85
|
-
return [ttl, :refresh] unless ttl.nil?
|
|
86
|
-
end
|
|
87
|
-
ret = @manifest.rules.for(mentry.key).retention
|
|
88
|
-
return [ret.ttl_seconds, ret.action] unless ret.nil?
|
|
89
|
-
|
|
90
|
-
[nil, nil]
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
# Intake currency basis comes from the evaluator (single definition);
|
|
94
|
-
# retention dueness is keyed off file mtime.
|
|
95
|
-
def basis_for(mentry)
|
|
96
|
-
return evaluator.intake_basis(mentry) if mentry.intake? && mentry.source.ttl_seconds
|
|
97
|
-
|
|
98
|
-
mtime_for(mentry.key)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def expired?(mentry, basis, ttl)
|
|
102
|
-
if mentry.intake? && mentry.source.ttl_seconds
|
|
103
|
-
evaluator.verdict(mentry).stale
|
|
104
|
-
else
|
|
105
|
-
# Preserve pre-0099 pulse semantics: a never-recorded retention entry
|
|
106
|
-
# (no file => nil basis) is past due. Retention::Sweep.expired? alone
|
|
107
|
-
# returns false on nil mtime (it runs post-exists? in the sweep).
|
|
108
|
-
basis.nil? || Textus::Domain::Retention::Sweep.expired?(ttl_seconds: ttl, mtime: basis, now: @call.now)
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def evaluator
|
|
113
|
-
@evaluator ||= Textus::Domain::Freshness::Evaluator.new(
|
|
114
|
-
manifest: @manifest, file_stat: Textus::Ports::Storage::FileStat.new, clock: @call,
|
|
115
|
-
)
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
def mtime_for(key)
|
|
119
|
-
path = @manifest.resolver.resolve(key).path
|
|
120
|
-
@file_store.exists?(path) ? Textus::Ports::Storage::FileStat.new.mtime(path) : nil
|
|
121
|
-
rescue Textus::Error
|
|
122
|
-
nil
|
|
123
|
-
end
|
|
124
|
-
|
|
125
|
-
def base_row(mentry, last)
|
|
126
|
-
{
|
|
127
|
-
key: mentry.key,
|
|
128
|
-
zone: mentry.zone,
|
|
129
|
-
last_fetched_at: last,
|
|
130
|
-
age_seconds: last ? (@call.now - Time.parse(last)).to_i : nil,
|
|
131
|
-
}
|
|
132
|
-
end
|
|
133
|
-
|
|
134
|
-
# Returns the raw envelope or nil. Nested entries (mentry.key is a
|
|
135
|
-
# prefix, not a leaf) and missing files both resolve to nil.
|
|
136
|
-
def safe_get(key)
|
|
137
|
-
res = @manifest.resolver.resolve(key)
|
|
138
|
-
return nil unless @file_store.exists?(res.path)
|
|
139
|
-
|
|
140
|
-
raw = @file_store.read(res.path)
|
|
141
|
-
parsed = Entry.for_format(res.entry.format).parse(raw, path: res.path)
|
|
142
|
-
Textus::Envelope.build(
|
|
143
|
-
key: key, mentry: res.entry, path: res.path,
|
|
144
|
-
meta: parsed["_meta"], body: parsed["body"],
|
|
145
|
-
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
146
|
-
)
|
|
147
|
-
rescue Textus::Error
|
|
148
|
-
nil
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
end
|
data/lib/textus/read/get.rb
DELETED
|
@@ -1,73 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Read
|
|
3
|
-
# The one read path — a pure read (ADR 0089, 0093): the on-disk envelope
|
|
4
|
-
# annotated with a freshness annotation. It NEVER mutates and NEVER ingests.
|
|
5
|
-
# Quarantine freshness is system-pushed via `drain` (scheduled sweep) and
|
|
6
|
-
# `hook run` (event push). Lifecycle is removed from the get path (ADR 0093):
|
|
7
|
-
# intake cadence lives in `source.ttl`; GC lives in `retention:` rules; both
|
|
8
|
-
# are evaluated exclusively by the `drain` sweep, not by a read.
|
|
9
|
-
class Get
|
|
10
|
-
extend Textus::Contract::DSL
|
|
11
|
-
|
|
12
|
-
verb :get
|
|
13
|
-
summary "Read one entry — a pure on-disk read annotated with a freshness " \
|
|
14
|
-
"verdict; never ingests (quarantine freshness is drain + hook " \
|
|
15
|
-
"only, ADR 0089). Returns the envelope (uid, etag, _meta, body, " \
|
|
16
|
-
"freshness)."
|
|
17
|
-
surfaces :cli, :mcp
|
|
18
|
-
arg :key, String, required: true, positional: true,
|
|
19
|
-
description: "dotted entry key to read, e.g. 'knowledge.project'"
|
|
20
|
-
view { |v, _i| v.to_h_for_wire }
|
|
21
|
-
|
|
22
|
-
def initialize(container:, call:, file_stat: Textus::Ports::Storage::FileStat.new)
|
|
23
|
-
@container = container
|
|
24
|
-
@call = call
|
|
25
|
-
@manifest = container.manifest
|
|
26
|
-
@file_store = container.file_store
|
|
27
|
-
@file_stat = file_stat
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def call(key)
|
|
31
|
-
annotated_envelope(key)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# Strict variant: raises UnknownKey when the entry is missing.
|
|
35
|
-
# Used by consumers (e.g. uid, Validator) that distinguish absence.
|
|
36
|
-
def get(key)
|
|
37
|
-
call(key) ||
|
|
38
|
-
raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
private
|
|
42
|
-
|
|
43
|
-
def annotated_envelope(key)
|
|
44
|
-
envelope = read_raw_envelope(key)
|
|
45
|
-
return nil if envelope.nil?
|
|
46
|
-
|
|
47
|
-
entry = @manifest.resolver.resolve(key).entry
|
|
48
|
-
envelope.with(freshness: evaluator.verdict(entry))
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def evaluator
|
|
52
|
-
@evaluator ||= Textus::Domain::Freshness::Evaluator.new(
|
|
53
|
-
manifest: @manifest, file_stat: @file_stat, clock: @call,
|
|
54
|
-
)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
def read_raw_envelope(key)
|
|
58
|
-
res = @manifest.resolver.resolve(key)
|
|
59
|
-
mentry = res.entry
|
|
60
|
-
path = res.path
|
|
61
|
-
return nil unless @file_store.exists?(path)
|
|
62
|
-
|
|
63
|
-
raw = @file_store.read(path)
|
|
64
|
-
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
65
|
-
Textus::Envelope.build(
|
|
66
|
-
key: key, mentry: mentry, path: path,
|
|
67
|
-
meta: parsed["_meta"], body: parsed["body"],
|
|
68
|
-
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
69
|
-
)
|
|
70
|
-
end
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|
data/lib/textus/read/jobs.rb
DELETED
|
@@ -1,31 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Read
|
|
3
|
-
# Inspect and operate the job queue: list ids by state, retry a dead-lettered
|
|
4
|
-
# job, or purge a state. The agent's window into deferred convergence work.
|
|
5
|
-
class Jobs
|
|
6
|
-
extend Textus::Contract::DSL
|
|
7
|
-
|
|
8
|
-
verb :jobs
|
|
9
|
-
summary "List queued jobs by state; retry a dead-lettered job or purge a state."
|
|
10
|
-
surfaces :cli, :mcp
|
|
11
|
-
cli "jobs"
|
|
12
|
-
arg :state, String, default: "ready", description: "ready|leased|done|failed"
|
|
13
|
-
arg :action, String, default: nil, description: "retry|purge (optional)"
|
|
14
|
-
arg :job_id, String, default: nil, description: "job id (required for action=retry)"
|
|
15
|
-
|
|
16
|
-
def initialize(container:, call:)
|
|
17
|
-
@container = container
|
|
18
|
-
@call = call
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def call(state: "ready", action: nil, job_id: nil)
|
|
22
|
-
queue = Textus::Ports::Queue.new(root: @container.root)
|
|
23
|
-
case action
|
|
24
|
-
when "retry" then queue.retry_failed(job_id)
|
|
25
|
-
when "purge" then queue.purge(state)
|
|
26
|
-
end
|
|
27
|
-
{ "protocol" => Textus::PROTOCOL, "ok" => true, "state" => state, "jobs" => queue.list(state) }
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
end
|
|
31
|
-
end
|
data/lib/textus/read/list.rb
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Read
|
|
3
|
-
class List
|
|
4
|
-
extend Textus::Contract::DSL
|
|
5
|
-
|
|
6
|
-
verb :list
|
|
7
|
-
summary "List keys filtered by zone and/or prefix."
|
|
8
|
-
surfaces :cli, :mcp
|
|
9
|
-
arg :prefix, String, description: "restrict to keys starting with this dotted prefix, e.g. 'knowledge.runbooks'"
|
|
10
|
-
arg :zone, String, description: "restrict to one zone by name (see `boot` zones); combine with prefix to narrow further"
|
|
11
|
-
view(:cli) { |rows| { "entries" => rows } }
|
|
12
|
-
|
|
13
|
-
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
14
|
-
@manifest = container.manifest
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def call(prefix: nil, zone: nil)
|
|
18
|
-
rows = @manifest.resolver.enumerate(prefix: prefix)
|
|
19
|
-
rows = rows.select { |r| r[:manifest_entry].zone == zone } if zone
|
|
20
|
-
rows.map { |row| { "key" => row[:key], "zone" => row[:manifest_entry].zone, "path" => row[:path] } }
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Read
|
|
3
|
-
class Published
|
|
4
|
-
extend Textus::Contract::DSL
|
|
5
|
-
|
|
6
|
-
verb :published
|
|
7
|
-
summary "List all entries that declare a publish_to target."
|
|
8
|
-
surfaces :cli
|
|
9
|
-
cli "published"
|
|
10
|
-
|
|
11
|
-
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
12
|
-
@manifest = container.manifest
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def call
|
|
16
|
-
@manifest.data.entries.reject { |e| e.publish_to.empty? }.map do |e|
|
|
17
|
-
{ "key" => e.key, "publish_to" => e.publish_to }
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
end
|