textus 0.10.5 → 0.12.1
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 +104 -3
- data/README.md +39 -26
- data/SPEC.md +222 -144
- data/lib/textus/application/reads/freshness.rb +2 -2
- data/lib/textus/application/reads/get.rb +1 -1
- data/lib/textus/application/reads/policy_explain.rb +2 -2
- data/lib/textus/application/refresh/orchestrator.rb +1 -1
- data/lib/textus/application/refresh/worker.rb +5 -5
- data/lib/textus/application/writes/accept.rb +19 -1
- data/lib/textus/application/writes/build.rb +5 -5
- data/lib/textus/application/writes/delete.rb +1 -1
- data/lib/textus/application/writes/publish.rb +1 -1
- data/lib/textus/application/writes/put.rb +1 -1
- data/lib/textus/builder/pipeline.rb +1 -1
- data/lib/textus/builder/renderer/json.rb +1 -1
- data/lib/textus/builder/renderer/yaml.rb +1 -1
- data/lib/textus/cli/group/key.rb +1 -1
- data/lib/textus/cli/group/refresh.rb +21 -0
- data/lib/textus/cli/group/rule.rb +11 -0
- data/lib/textus/cli/verb/build.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -2
- data/lib/textus/cli/verb/hooks.rb +1 -1
- data/lib/textus/cli/verb/{migrate_keys.rb → key_normalize.rb} +1 -1
- data/lib/textus/cli/verb/put.rb +1 -1
- data/lib/textus/cli/verb/{policy_explain.rb → rule_explain.rb} +1 -1
- data/lib/textus/cli/verb/{policy_list.rb → rule_list.rb} +3 -3
- data/lib/textus/cli/verb.rb +3 -2
- data/lib/textus/cli.rb +6 -6
- data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
- data/lib/textus/doctor/check/illegal_keys.rb +39 -16
- data/lib/textus/doctor/check/intake_registration.rb +4 -4
- data/lib/textus/doctor/check/protocol_version.rb +47 -0
- data/lib/textus/doctor/check/{policy_ambiguity.rb → rule_ambiguity.rb} +6 -6
- data/lib/textus/doctor.rb +5 -4
- data/lib/textus/domain/permission.rb +4 -4
- data/lib/textus/domain/policy/predicates/human_accept.rb +31 -0
- data/lib/textus/domain/policy/predicates/schema_valid.rb +50 -0
- data/lib/textus/domain/policy/promotion.rb +45 -0
- data/lib/textus/errors.rb +24 -5
- data/lib/textus/hooks/builtin.rb +5 -5
- data/lib/textus/hooks/dispatcher.rb +1 -1
- data/lib/textus/hooks/dsl.rb +3 -10
- data/lib/textus/hooks/loader.rb +1 -2
- data/lib/textus/hooks/registry.rb +22 -21
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/init.rb +25 -34
- data/lib/textus/intro.rb +9 -9
- data/lib/textus/manifest/entry.rb +33 -6
- data/lib/textus/manifest/{policies.rb → rules.rb} +12 -10
- data/lib/textus/manifest/schema.rb +49 -0
- data/lib/textus/manifest.rb +45 -9
- data/lib/textus/migrate_keys.rb +1 -1
- data/lib/textus/projection.rb +4 -4
- data/lib/textus/refresh.rb +1 -1
- data/lib/textus/store/mover.rb +1 -1
- data/lib/textus/store/staleness/intake_check.rb +1 -1
- data/lib/textus/store/writer.rb +1 -1
- data/lib/textus/store.rb +1 -1
- data/lib/textus/version.rb +2 -2
- data/lib/textus.rb +1 -0
- metadata +13 -7
- data/lib/textus/cli/group/policy.rb +0 -11
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Domain
|
|
3
|
-
Permission = Data.define(:zone, :
|
|
3
|
+
Permission = Data.define(:zone, :write_policy, :read_policy) do
|
|
4
4
|
def allows_write?(role)
|
|
5
|
-
|
|
5
|
+
write_policy.include?(role.to_s)
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def allows_read?(role)
|
|
9
|
-
return true if
|
|
9
|
+
return true if [:all, ["all"]].include?(read_policy)
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
read_policy.include?(role.to_s)
|
|
12
12
|
end
|
|
13
13
|
end
|
|
14
14
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
module Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
class HumanAccept
|
|
6
|
+
attr_reader :reason
|
|
7
|
+
|
|
8
|
+
def name
|
|
9
|
+
"human_accept"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# The role is passed via `store` (an Application::Context-like object
|
|
13
|
+
# with a `role` reader) or through the entry metadata. In practice,
|
|
14
|
+
# Accept already enforces role == "human" before reaching the
|
|
15
|
+
# promotion gate, so this predicate trivially passes. It documents
|
|
16
|
+
# intent and future-proofs multi-actor accept flows.
|
|
17
|
+
def call(store:, entry: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
18
|
+
role = store.respond_to?(:role) ? store.role.to_s : nil
|
|
19
|
+
# If we cannot determine the role (e.g. store doesn't expose it),
|
|
20
|
+
# we trust that Accept has already checked — allow through.
|
|
21
|
+
return true if role.nil?
|
|
22
|
+
|
|
23
|
+
ok = (role == "human")
|
|
24
|
+
@reason = "current role is '#{role}', expected 'human'" unless ok
|
|
25
|
+
ok
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
module Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
class SchemaValid
|
|
6
|
+
attr_reader :reason
|
|
7
|
+
|
|
8
|
+
def name
|
|
9
|
+
"schema_valid"
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(entry:, store:)
|
|
13
|
+
return true if entry.nil? || store.nil?
|
|
14
|
+
|
|
15
|
+
target_key = entry.dig("_meta", "proposal", "target_key")
|
|
16
|
+
return true unless target_key
|
|
17
|
+
|
|
18
|
+
mentry, = store.manifest.resolve(target_key)
|
|
19
|
+
schema_ref = mentry&.schema
|
|
20
|
+
return true unless schema_ref
|
|
21
|
+
|
|
22
|
+
schema = store.schema_for(schema_ref)
|
|
23
|
+
return true unless schema
|
|
24
|
+
|
|
25
|
+
frontmatter = entry.dig("_meta", "frontmatter") || {}
|
|
26
|
+
begin
|
|
27
|
+
schema.validate!(frontmatter)
|
|
28
|
+
rescue Textus::SchemaViolation => e
|
|
29
|
+
@reason = e.message.dup
|
|
30
|
+
d = e.details
|
|
31
|
+
if d.is_a?(Hash)
|
|
32
|
+
if d["missing"]
|
|
33
|
+
@reason = "missing required fields: #{Array(d["missing"]).join(", ")}"
|
|
34
|
+
elsif d["field"]
|
|
35
|
+
@reason = "field '#{d["field"]}': #{d["reason"]}"
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
return false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
true
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
@reason = "schema validation error: #{e.message}"
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
module Policy
|
|
4
|
+
# Promotion evaluates a list of named predicates against a pending-proposal
|
|
5
|
+
# entry and returns a Result indicating whether all requirements are met.
|
|
6
|
+
class Promotion
|
|
7
|
+
Result = Struct.new(:ok?, :reasons, keyword_init: true)
|
|
8
|
+
|
|
9
|
+
REGISTRY = {
|
|
10
|
+
"schema_valid" => -> { Predicates::SchemaValid.new },
|
|
11
|
+
"human_accept" => -> { Predicates::HumanAccept.new },
|
|
12
|
+
}.freeze
|
|
13
|
+
|
|
14
|
+
def self.from_names(names)
|
|
15
|
+
predicates = Array(names).map do |n|
|
|
16
|
+
ctor = REGISTRY[n.to_s] or raise Textus::UsageError.new(
|
|
17
|
+
"unknown promotion predicate: '#{n}' (known: #{REGISTRY.keys.join(", ")})",
|
|
18
|
+
)
|
|
19
|
+
ctor.call
|
|
20
|
+
end
|
|
21
|
+
new(predicates: predicates)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
attr_reader :predicates
|
|
25
|
+
|
|
26
|
+
def initialize(predicates:)
|
|
27
|
+
@predicates = predicates
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def predicate_names
|
|
31
|
+
@predicates.map(&:name)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def evaluate(entry:, store:)
|
|
35
|
+
reasons = []
|
|
36
|
+
@predicates.each do |pred|
|
|
37
|
+
ok = pred.call(entry: entry, store: store)
|
|
38
|
+
reasons << "#{pred.name}: #{pred.reason || "predicate failed"}" unless ok
|
|
39
|
+
end
|
|
40
|
+
Result.new(ok?: reasons.empty?, reasons: reasons)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/textus/errors.rb
CHANGED
|
@@ -34,7 +34,7 @@ module Textus
|
|
|
34
34
|
msg += "; did you mean: #{@suggestions.join(", ")}" unless @suggestions.empty?
|
|
35
35
|
hint =
|
|
36
36
|
if @suggestions.empty?
|
|
37
|
-
"run 'textus list --
|
|
37
|
+
"run 'textus list --output=json' to see all keys"
|
|
38
38
|
else
|
|
39
39
|
"did you mean: #{@suggestions.join(", ")}"
|
|
40
40
|
end
|
|
@@ -61,6 +61,12 @@ module Textus
|
|
|
61
61
|
end
|
|
62
62
|
end
|
|
63
63
|
|
|
64
|
+
class BadManifest < Error
|
|
65
|
+
def initialize(m, hint: nil)
|
|
66
|
+
super("bad_manifest", m, hint: hint)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
64
70
|
class BadContent < Error
|
|
65
71
|
def initialize(path, m)
|
|
66
72
|
super(
|
|
@@ -89,7 +95,7 @@ module Textus
|
|
|
89
95
|
if writers && !writers.empty?
|
|
90
96
|
writers.join(", ")
|
|
91
97
|
else
|
|
92
|
-
"the role(s) listed in the manifest '
|
|
98
|
+
"the role(s) listed in the manifest 'write_policy:'"
|
|
93
99
|
end
|
|
94
100
|
details = { "key" => k, "zone" => z }
|
|
95
101
|
details["writers"] = writers if writers
|
|
@@ -121,11 +127,12 @@ module Textus
|
|
|
121
127
|
end
|
|
122
128
|
|
|
123
129
|
class InvalidRole < Error
|
|
124
|
-
def initialize(r)
|
|
130
|
+
def initialize(r, message: nil)
|
|
125
131
|
super(
|
|
126
|
-
"invalid_role",
|
|
132
|
+
"invalid_role",
|
|
133
|
+
message || "role '#{r}' is not declared in any zone",
|
|
127
134
|
details: { "role" => r },
|
|
128
|
-
hint: "valid roles are declared in .textus/manifest.yaml under zones[].
|
|
135
|
+
hint: message ? nil : "valid roles are declared in .textus/manifest.yaml under zones[].write_policy",
|
|
129
136
|
)
|
|
130
137
|
end
|
|
131
138
|
end
|
|
@@ -165,4 +172,16 @@ module Textus
|
|
|
165
172
|
class ProposalError < Error
|
|
166
173
|
def initialize(m) = super("proposal_error", m)
|
|
167
174
|
end
|
|
175
|
+
|
|
176
|
+
class FlagRenamed < Error
|
|
177
|
+
def initialize(old_flag, new_flag)
|
|
178
|
+
super(
|
|
179
|
+
"flag_renamed",
|
|
180
|
+
"#{old_flag} was renamed in textus/3 — use #{new_flag}",
|
|
181
|
+
details: { "old" => old_flag, "new" => new_flag },
|
|
182
|
+
hint: "Use #{new_flag} instead.",
|
|
183
|
+
exit_code: 2,
|
|
184
|
+
)
|
|
185
|
+
end
|
|
186
|
+
end
|
|
168
187
|
end
|
data/lib/textus/hooks/builtin.rb
CHANGED
|
@@ -8,21 +8,21 @@ module Textus
|
|
|
8
8
|
module Builtin
|
|
9
9
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
10
10
|
def self.register_all
|
|
11
|
-
Textus.
|
|
11
|
+
Textus.on(:resolve_intake, :json) do |store:, config:, args:|
|
|
12
12
|
_ = store
|
|
13
13
|
_ = args
|
|
14
14
|
data = JSON.parse(config["bytes"].to_s)
|
|
15
15
|
{ _meta: {}, body: YAML.dump(data) }
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
Textus.
|
|
18
|
+
Textus.on(:resolve_intake, :csv) do |store:, config:, args:|
|
|
19
19
|
_ = store
|
|
20
20
|
_ = args
|
|
21
21
|
rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
|
|
22
22
|
{ _meta: {}, body: YAML.dump(rows) }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
Textus.
|
|
25
|
+
Textus.on(:resolve_intake, :"markdown-links") do |store:, config:, args:|
|
|
26
26
|
_ = store
|
|
27
27
|
_ = args
|
|
28
28
|
links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
|
|
@@ -31,7 +31,7 @@ module Textus
|
|
|
31
31
|
{ _meta: {}, body: YAML.dump(links) }
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
-
Textus.
|
|
34
|
+
Textus.on(:resolve_intake, :"ical-events") do |store:, config:, args:|
|
|
35
35
|
_ = store
|
|
36
36
|
_ = args
|
|
37
37
|
events = []
|
|
@@ -50,7 +50,7 @@ module Textus
|
|
|
50
50
|
{ _meta: {}, body: YAML.dump(events) }
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
-
Textus.
|
|
53
|
+
Textus.on(:resolve_intake, :rss) do |store:, config:, args:|
|
|
54
54
|
_ = store
|
|
55
55
|
_ = args
|
|
56
56
|
doc = REXML::Document.new(config["bytes"].to_s)
|
|
@@ -35,7 +35,7 @@ module Textus
|
|
|
35
35
|
extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
|
|
36
36
|
extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
|
|
37
37
|
@audit_log.append(
|
|
38
|
-
role: "
|
|
38
|
+
role: "runner", verb: "event_error", key: key,
|
|
39
39
|
etag_before: nil, etag_after: nil, extras: extras
|
|
40
40
|
)
|
|
41
41
|
end
|
data/lib/textus/hooks/dsl.rb
CHANGED
|
@@ -1,17 +1,10 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Hooks
|
|
3
3
|
module Dsl
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
put deleted refreshed built published accepted
|
|
7
|
-
mv reject loaded
|
|
8
|
-
refresh_began refresh_failed refresh_detached
|
|
9
|
-
].freeze
|
|
4
|
+
def on(event, name, **, &blk)
|
|
5
|
+
raise UsageError.new("hook needs a block") unless blk
|
|
10
6
|
|
|
11
|
-
|
|
12
|
-
define_method(event) do |name, **opts, &blk|
|
|
13
|
-
Loader.current_registry.register(event, name, **opts, &blk)
|
|
14
|
-
end
|
|
7
|
+
Loader.current_registry.register(event, name, **, &blk)
|
|
15
8
|
end
|
|
16
9
|
end
|
|
17
10
|
end
|
data/lib/textus/hooks/loader.rb
CHANGED
|
@@ -19,8 +19,7 @@ module Textus
|
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
# Public DSL
|
|
22
|
+
# Public DSL
|
|
23
23
|
def self.with_registry(registry, &) = Hooks::Loader.with_registry(registry, &)
|
|
24
24
|
def self.current_registry = Hooks::Loader.current_registry
|
|
25
|
-
def self.hook(event, name, **, &) = Hooks::Loader.current_registry.register(event, name, **, &)
|
|
26
25
|
end
|
|
@@ -3,23 +3,23 @@ module Textus
|
|
|
3
3
|
class Registry
|
|
4
4
|
EVENTS = {
|
|
5
5
|
# RPC: exactly 1 handler per name; return value flows into store; failure aborts.
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
resolve_intake: { mode: :rpc, args: %i[store config args] },
|
|
7
|
+
transform_rows: { mode: :rpc, args: %i[store rows config] },
|
|
8
|
+
validate: { mode: :rpc, args: %i[store] },
|
|
9
9
|
|
|
10
10
|
# Pub-sub: 0..N handlers per event; return discarded; failure logged to audit.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
11
|
+
entry_put: { mode: :pubsub, args: %i[store key envelope] },
|
|
12
|
+
entry_deleted: { mode: :pubsub, args: %i[store key] },
|
|
13
|
+
entry_refreshed: { mode: :pubsub, args: %i[store key envelope change] },
|
|
14
|
+
entry_renamed: { mode: :pubsub, args: %i[store key from_key to_key envelope] },
|
|
15
|
+
build_completed: { mode: :pubsub, args: %i[store key envelope sources] },
|
|
16
|
+
proposal_accepted: { mode: :pubsub, args: %i[store key target_key] },
|
|
17
|
+
proposal_rejected: { mode: :pubsub, args: %i[store key target_key] },
|
|
18
|
+
file_published: { mode: :pubsub, args: %i[store key envelope source target] },
|
|
19
|
+
store_loaded: { mode: :pubsub, args: %i[store] },
|
|
20
|
+
refresh_started: { mode: :pubsub, args: %i[store key mode] },
|
|
21
21
|
refresh_failed: { mode: :pubsub, args: %i[store key error_class error_message] },
|
|
22
|
-
|
|
22
|
+
refresh_backgrounded: { mode: :pubsub, args: %i[store key started_at budget_ms] },
|
|
23
23
|
}.freeze
|
|
24
24
|
|
|
25
25
|
def initialize(dispatcher: nil)
|
|
@@ -29,20 +29,21 @@ module Textus
|
|
|
29
29
|
end
|
|
30
30
|
|
|
31
31
|
def register(event, name, keys: nil, &blk)
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
event_sym = event.to_sym
|
|
33
|
+
spec = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
|
|
34
|
+
shape_check!(event_sym, spec, blk)
|
|
34
35
|
name = name.to_sym
|
|
35
36
|
|
|
36
37
|
case spec[:mode]
|
|
37
38
|
when :rpc
|
|
38
|
-
raise UsageError.new("#{
|
|
39
|
+
raise UsageError.new("#{event_sym} '#{name}' already registered") if @rpc[event_sym].key?(name)
|
|
39
40
|
|
|
40
|
-
@rpc[
|
|
41
|
+
@rpc[event_sym][name] = blk
|
|
41
42
|
when :pubsub
|
|
42
|
-
raise UsageError.new("#{
|
|
43
|
+
raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
|
|
43
44
|
|
|
44
|
-
@pubsub[
|
|
45
|
-
@dispatcher&.subscribe(
|
|
45
|
+
@pubsub[event_sym] << { name: name, callable: blk, keys: keys }
|
|
46
|
+
@dispatcher&.subscribe(event_sym, name, keys: keys, &blk)
|
|
46
47
|
end
|
|
47
48
|
end
|
|
48
49
|
|
data/lib/textus/init.rb
CHANGED
|
@@ -2,16 +2,16 @@ require "fileutils"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Init
|
|
5
|
-
ZONES = %w[identity working
|
|
5
|
+
ZONES = %w[identity working intake review output].freeze
|
|
6
6
|
|
|
7
7
|
DEFAULT_MANIFEST = <<~YAML
|
|
8
|
-
version: textus/
|
|
8
|
+
version: textus/3
|
|
9
9
|
zones:
|
|
10
|
-
- { name: identity,
|
|
11
|
-
- { name: working,
|
|
12
|
-
- { name:
|
|
13
|
-
- { name: review,
|
|
14
|
-
- { name: output,
|
|
10
|
+
- { name: identity, write_policy: [human], read_policy: [all] }
|
|
11
|
+
- { name: working, write_policy: [human, agent, runner], read_policy: [all] }
|
|
12
|
+
- { name: intake, write_policy: [runner], read_policy: [all] }
|
|
13
|
+
- { name: review, write_policy: [agent, human], read_policy: [all] }
|
|
14
|
+
- { name: output, write_policy: [builder], read_policy: [all] }
|
|
15
15
|
entries:
|
|
16
16
|
- { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self }
|
|
17
17
|
- { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true }
|
|
@@ -25,56 +25,47 @@ module Textus
|
|
|
25
25
|
startup in alphabetical order by full path. Subdirectory names are organizational
|
|
26
26
|
only — the registered event and name come from the DSL call, not the file path.
|
|
27
27
|
|
|
28
|
-
##
|
|
28
|
+
## DSL
|
|
29
29
|
|
|
30
30
|
```ruby
|
|
31
|
-
Textus.
|
|
31
|
+
Textus.on(:resolve_intake, :my_source) do |config:, args:, **|
|
|
32
32
|
{ _meta: { "last_refreshed_at" => Time.now.utc.iso8601 }, body: "…" }
|
|
33
33
|
end
|
|
34
34
|
|
|
35
|
-
Textus.
|
|
36
|
-
Textus.
|
|
37
|
-
Textus.
|
|
35
|
+
Textus.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
|
|
36
|
+
Textus.on(:validate, :my_check) { |store:, **| [] }
|
|
37
|
+
Textus.on(:entry_put, :my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
|
|
38
38
|
|
|
39
39
|
# Run a side-effect every time textus writes a file to your repo:
|
|
40
|
-
Textus.
|
|
40
|
+
Textus.on(:file_published, :notify) do |key:, target:, **|
|
|
41
41
|
warn "wrote \#{target} (from \#{key})"
|
|
42
42
|
end
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
The intake handler above is paired with a manifest entry plus a
|
|
46
|
-
top-level `
|
|
47
|
-
|
|
46
|
+
top-level `rules:` block for freshness (ttl/on_stale live in
|
|
47
|
+
rules, not in the entry):
|
|
48
48
|
|
|
49
49
|
```yaml
|
|
50
50
|
entries:
|
|
51
|
-
- key:
|
|
52
|
-
path:
|
|
53
|
-
zone:
|
|
51
|
+
- key: intake.foo
|
|
52
|
+
path: intake/foo.md
|
|
53
|
+
zone: intake
|
|
54
54
|
intake:
|
|
55
55
|
handler: my_source
|
|
56
56
|
|
|
57
|
-
|
|
58
|
-
- match:
|
|
57
|
+
rules:
|
|
58
|
+
- match: intake.foo
|
|
59
59
|
refresh:
|
|
60
60
|
ttl: 10m
|
|
61
61
|
on_stale: timed_sync # warn | sync | timed_sync (default: warn)
|
|
62
62
|
```
|
|
63
63
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
Textus.hook(:check, :name) { |store:| ... } # doctor check
|
|
70
|
-
Textus.hook(:put, :name, keys: ["..."]) # lifecycle listener
|
|
71
|
-
{ |store:, key:, envelope:| ... }
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
Events: :intake, :reduce, :check (rpc — return value used)
|
|
75
|
-
:put, :deleted, :refreshed, :built, :accepted, :published,
|
|
76
|
-
:mv, :reject, :loaded,
|
|
77
|
-
:refresh_began, :refresh_failed, :refresh_detached (pub-sub — return discarded)
|
|
64
|
+
Events: :resolve_intake, :transform_rows, :validate (rpc — return value used)
|
|
65
|
+
:entry_put, :entry_deleted, :entry_refreshed, :entry_renamed,
|
|
66
|
+
:build_completed, :proposal_accepted, :proposal_rejected,
|
|
67
|
+
:file_published, :store_loaded,
|
|
68
|
+
:refresh_started, :refresh_failed, :refresh_backgrounded (pub-sub — return discarded)
|
|
78
69
|
|
|
79
70
|
See SPEC.md §5.10 for the full table.
|
|
80
71
|
MD
|
data/lib/textus/intro.rb
CHANGED
|
@@ -13,17 +13,17 @@ module Textus
|
|
|
13
13
|
ZONE_PURPOSES = {
|
|
14
14
|
"identity" => "slow-changing identity; human-only writes",
|
|
15
15
|
"working" => "active project state; humans, AI, and scripts share this surface",
|
|
16
|
-
"
|
|
16
|
+
"intake" => "declared external inputs; script-refreshed via actions",
|
|
17
17
|
"review" => "AI proposals awaiting human accept",
|
|
18
18
|
"output" => "build-computed outputs; never hand-edited",
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
21
|
WRITE_FLOWS = {
|
|
22
22
|
"human" => "edit files in identity/working zones, then 'textus put KEY --as=human'",
|
|
23
|
-
"
|
|
24
|
-
|
|
25
|
-
"
|
|
26
|
-
"
|
|
23
|
+
"agent" => "propose changes by writing 'review.*' entries with --as=agent and a 'proposal:' frontmatter block; " \
|
|
24
|
+
"a human runs 'textus accept' to apply",
|
|
25
|
+
"runner" => "refresh intake entries with 'textus refresh KEY --as=runner' (uses the entry's declared action)",
|
|
26
|
+
"builder" => "'textus build' computes output entries from projections; output files are never hand-edited",
|
|
27
27
|
}.freeze
|
|
28
28
|
|
|
29
29
|
# The CLI verb catalog. Truth lives here; do not derive dynamically.
|
|
@@ -37,14 +37,14 @@ module Textus
|
|
|
37
37
|
{ "name" => "schema", "summary" => "field shape for a key family" },
|
|
38
38
|
{ "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
|
|
39
39
|
{ "name" => "accept", "summary" => "apply a review.* proposal; --as=human only" },
|
|
40
|
-
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key
|
|
40
|
+
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key normalize'" },
|
|
41
41
|
{ "name" => "delete", "summary" => "delete an entry; --as=<role>" },
|
|
42
42
|
{ "name" => "build", "summary" => "materialize output entries; publish_to and publish_each fan out copies" },
|
|
43
|
-
{ "name" => "refresh", "summary" => "run an action for an
|
|
43
|
+
{ "name" => "refresh", "summary" => "run an action for an intake entry" },
|
|
44
44
|
{ "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
|
|
45
45
|
{ "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
|
|
46
46
|
{ "name" => "blame", "summary" => "audit rows for one key joined with git commit metadata" },
|
|
47
|
-
{ "name" => "
|
|
47
|
+
{ "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
|
|
48
48
|
{ "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
|
|
49
49
|
{ "name" => "hook",
|
|
50
50
|
"summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
@@ -74,7 +74,7 @@ module Textus
|
|
|
74
74
|
|
|
75
75
|
def self.entries_for(store)
|
|
76
76
|
store.manifest.entries.map do |e|
|
|
77
|
-
derived = store.manifest.zone_writers(e.zone).include?("
|
|
77
|
+
derived = store.manifest.zone_writers(e.zone).include?("builder")
|
|
78
78
|
{
|
|
79
79
|
"key" => e.key,
|
|
80
80
|
"zone" => e.zone,
|
|
@@ -4,10 +4,12 @@ module Textus
|
|
|
4
4
|
PUBLISH_EACH_VARS = %w[leaf basename key ext].freeze
|
|
5
5
|
PUBLISH_EACH_VAR_RE = /\{([a-z]+)\}/
|
|
6
6
|
|
|
7
|
+
COMPUTE_KINDS = %w[projection external].freeze
|
|
8
|
+
|
|
7
9
|
attr_reader :key, :path, :zone, :schema, :owner, :nested, :generator, :raw, :format,
|
|
8
10
|
:projection, :template, :publish_to, :publish_each,
|
|
9
11
|
:intake_handler, :intake_config,
|
|
10
|
-
:events, :inject_intro, :index_filename
|
|
12
|
+
:events, :inject_intro, :index_filename, :compute
|
|
11
13
|
|
|
12
14
|
def initialize(manifest, raw)
|
|
13
15
|
@manifest = manifest
|
|
@@ -18,8 +20,7 @@ module Textus
|
|
|
18
20
|
@schema = raw["schema"]
|
|
19
21
|
@owner = raw["owner"]
|
|
20
22
|
@nested = raw["nested"] == true
|
|
21
|
-
|
|
22
|
-
@projection = raw["projection"]
|
|
23
|
+
parse_compute!(raw)
|
|
23
24
|
@template = raw["template"]
|
|
24
25
|
@publish_to = Array(raw["publish_to"])
|
|
25
26
|
@publish_each = raw["publish_each"]
|
|
@@ -56,14 +57,14 @@ module Textus
|
|
|
56
57
|
end
|
|
57
58
|
|
|
58
59
|
# Signal-based zone-kind predicates: derive the "kind" of a zone from its
|
|
59
|
-
#
|
|
60
|
+
# write_policy signals rather than its literal name, so detection keeps
|
|
60
61
|
# working when users rename the default zones.
|
|
61
62
|
def in_generator_zone?
|
|
62
|
-
zone_writers.include?("
|
|
63
|
+
zone_writers.include?("builder")
|
|
63
64
|
end
|
|
64
65
|
|
|
65
66
|
def in_proposal_zone?
|
|
66
|
-
zone_writers.include?("
|
|
67
|
+
zone_writers.include?("agent")
|
|
67
68
|
end
|
|
68
69
|
|
|
69
70
|
private
|
|
@@ -211,6 +212,32 @@ module Textus
|
|
|
211
212
|
end
|
|
212
213
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
213
214
|
|
|
215
|
+
def parse_compute!(raw)
|
|
216
|
+
src = raw["compute"]
|
|
217
|
+
unless src
|
|
218
|
+
@compute = nil
|
|
219
|
+
@projection = nil
|
|
220
|
+
@generator = nil
|
|
221
|
+
return
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
kind = src["kind"]
|
|
225
|
+
unless COMPUTE_KINDS.include?(kind)
|
|
226
|
+
raise BadManifest.new(
|
|
227
|
+
"entry '#{@key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{kind.inspect})",
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
@compute = src.freeze
|
|
232
|
+
if kind == "projection"
|
|
233
|
+
@projection = @compute
|
|
234
|
+
@generator = nil
|
|
235
|
+
else
|
|
236
|
+
@generator = @compute
|
|
237
|
+
@projection = nil
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
214
241
|
def parse_intake!(src)
|
|
215
242
|
src ||= {}
|
|
216
243
|
@intake_handler = src["handler"]
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
|
-
class
|
|
4
|
-
|
|
5
|
-
EMPTY_SET =
|
|
3
|
+
class Rules
|
|
4
|
+
RuleSet = Data.define(:refresh, :handler_allowlist, :promote, :retention)
|
|
5
|
+
EMPTY_SET = RuleSet.new(refresh: nil, handler_allowlist: nil, promote: nil, retention: nil)
|
|
6
6
|
|
|
7
7
|
def self.parse(raw)
|
|
8
8
|
new(Array(raw).map { |b| Block.new(b) })
|
|
@@ -21,7 +21,7 @@ module Textus
|
|
|
21
21
|
|
|
22
22
|
slots.each_key { |slot| slots[slot] << b if b.public_send(slot) }
|
|
23
23
|
end
|
|
24
|
-
|
|
24
|
+
RuleSet.new(
|
|
25
25
|
refresh: pick(slots[:refresh], :refresh, key),
|
|
26
26
|
handler_allowlist: pick(slots[:handler_allowlist], :handler_allowlist, key),
|
|
27
27
|
promote: pick(slots[:promote], :promote, key),
|
|
@@ -47,10 +47,10 @@ module Textus
|
|
|
47
47
|
attr_reader :match, :refresh, :handler_allowlist, :promote, :retention
|
|
48
48
|
|
|
49
49
|
def initialize(raw)
|
|
50
|
-
@match = raw["match"] or raise Textus::UsageError.new("
|
|
50
|
+
@match = raw["match"] or raise Textus::UsageError.new("rule block missing match:")
|
|
51
51
|
@refresh = parse_refresh(raw["refresh"])
|
|
52
|
-
@handler_allowlist = parse_handler_allowlist(raw["
|
|
53
|
-
@promote =
|
|
52
|
+
@handler_allowlist = parse_handler_allowlist(raw["intake_handler_allowlist"])
|
|
53
|
+
@promote = parse_promotion(raw["promotion"])
|
|
54
54
|
@retention = raw["retention"] # reserved — passthrough only
|
|
55
55
|
end
|
|
56
56
|
|
|
@@ -72,10 +72,12 @@ module Textus
|
|
|
72
72
|
Textus::Domain::Policy::HandlerAllowlist.new(handlers: arr)
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
def
|
|
76
|
-
return nil if
|
|
75
|
+
def parse_promotion(h)
|
|
76
|
+
return nil if h.nil?
|
|
77
|
+
|
|
78
|
+
raise Textus::BadManifest.new("promotion: must be a hash with a 'requires:' array") unless h.is_a?(Hash) && h.key?("requires")
|
|
77
79
|
|
|
78
|
-
Textus::Domain::Policy::Promote.new(requires:
|
|
80
|
+
Textus::Domain::Policy::Promote.new(requires: Array(h["requires"]))
|
|
79
81
|
end
|
|
80
82
|
end
|
|
81
83
|
end
|