textus 0.10.5 → 0.14.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/ARCHITECTURE.md +60 -40
- data/CHANGELOG.md +318 -3
- data/README.md +34 -27
- data/SPEC.md +226 -145
- data/docs/conventions.md +8 -8
- data/lib/textus/application/context.rb +4 -0
- data/lib/textus/application/reads/blame.rb +1 -1
- data/lib/textus/application/reads/deps.rb +15 -0
- data/lib/textus/application/reads/freshness.rb +4 -4
- data/lib/textus/application/reads/get.rb +9 -12
- data/lib/textus/application/reads/list.rb +15 -0
- data/lib/textus/application/reads/policy_explain.rb +2 -2
- data/lib/textus/application/reads/published.rb +15 -0
- data/lib/textus/application/reads/rdeps.rb +15 -0
- data/lib/textus/application/reads/schema_envelope.rb +15 -0
- data/lib/textus/application/reads/stale.rb +15 -0
- data/lib/textus/application/reads/uid.rb +15 -0
- data/lib/textus/application/reads/validate_all.rb +15 -0
- data/lib/textus/application/reads/where.rb +15 -0
- data/lib/textus/application/refresh/all.rb +2 -2
- data/lib/textus/application/refresh/orchestrator.rb +1 -1
- data/lib/textus/application/refresh/worker.rb +8 -8
- data/lib/textus/application/writes/accept.rb +26 -8
- data/lib/textus/application/writes/build.rb +12 -49
- data/lib/textus/application/writes/delete.rb +1 -1
- data/lib/textus/application/writes/mv.rb +144 -0
- data/lib/textus/application/writes/publish.rb +42 -10
- data/lib/textus/application/writes/put.rb +1 -1
- data/lib/textus/application/writes/reject.rb +37 -0
- 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/accept.rb +1 -2
- data/lib/textus/cli/verb/audit.rb +3 -3
- data/lib/textus/cli/verb/blame.rb +1 -2
- data/lib/textus/cli/verb/build.rb +6 -2
- data/lib/textus/cli/verb/delete.rb +1 -2
- data/lib/textus/cli/verb/deps.rb +1 -1
- data/lib/textus/cli/verb/freshness.rb +1 -2
- data/lib/textus/cli/verb/get.rb +2 -3
- 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/list.rb +1 -1
- data/lib/textus/cli/verb/mv.rb +1 -1
- data/lib/textus/cli/verb/published.rb +1 -1
- data/lib/textus/cli/verb/put.rb +3 -3
- data/lib/textus/cli/verb/rdeps.rb +1 -1
- data/lib/textus/cli/verb/refresh.rb +1 -2
- data/lib/textus/cli/verb/reject.rb +1 -1
- data/lib/textus/cli/verb/{policy_explain.rb → rule_explain.rb} +2 -3
- data/lib/textus/cli/verb/{policy_list.rb → rule_list.rb} +3 -3
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb/uid.rb +1 -1
- data/lib/textus/cli/verb/where.rb +1 -1
- data/lib/textus/cli/verb.rb +9 -3
- 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/check/schema_violations.rb +1 -1
- data/lib/textus/doctor.rb +6 -5
- data/lib/textus/domain/freshness/evaluator.rb +1 -1
- 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/entry/base.rb +28 -0
- data/lib/textus/entry/json.rb +59 -0
- data/lib/textus/entry/markdown.rb +46 -0
- data/lib/textus/entry/text.rb +35 -0
- data/lib/textus/entry/yaml.rb +59 -0
- data/lib/textus/entry.rb +16 -0
- data/lib/textus/envelope.rb +44 -14
- 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 +65 -9
- data/lib/textus/manifest/entry/parser.rb +84 -0
- data/lib/textus/manifest/entry/validators/events.rb +21 -0
- data/lib/textus/manifest/entry/validators/format_matrix.rb +26 -0
- data/lib/textus/manifest/entry/validators/index_filename.rb +45 -0
- data/lib/textus/manifest/entry/validators/inject_intro.rb +18 -0
- data/lib/textus/manifest/entry/validators/publish_each.rb +37 -0
- data/lib/textus/manifest/entry/validators.rb +20 -0
- data/lib/textus/manifest/entry.rb +38 -189
- data/lib/textus/manifest/{policies.rb → rules.rb} +12 -10
- data/lib/textus/manifest/schema.rb +49 -0
- data/lib/textus/manifest.rb +50 -24
- data/lib/textus/migrate_keys.rb +1 -1
- data/lib/textus/operations/reads.rb +39 -0
- data/lib/textus/operations/refresh.rb +27 -0
- data/lib/textus/operations/writes.rb +21 -0
- data/lib/textus/operations.rb +44 -0
- data/lib/textus/projection.rb +9 -8
- data/lib/textus/refresh.rb +4 -5
- data/lib/textus/schema/tools.rb +8 -7
- data/lib/textus/store/reader.rb +1 -1
- data/lib/textus/store/staleness/intake_check.rb +1 -1
- data/lib/textus/store/validator.rb +3 -3
- data/lib/textus/store/writer.rb +5 -74
- data/lib/textus/store.rb +1 -55
- data/lib/textus/version.rb +2 -2
- data/lib/textus.rb +1 -0
- metadata +35 -10
- data/lib/textus/cli/group/policy.rb +0 -11
- data/lib/textus/composition.rb +0 -72
- data/lib/textus/proposal.rb +0 -10
- data/lib/textus/store/mover.rb +0 -167
data/lib/textus/entry/yaml.rb
CHANGED
|
@@ -40,6 +40,65 @@ module Textus
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def self.extensions = [".yaml", ".yml"]
|
|
43
|
+
|
|
44
|
+
def self.nested_glob = "**/*.{yaml,yml}"
|
|
45
|
+
|
|
46
|
+
def self.serialize_for_put(meta:, body:, content:, path:)
|
|
47
|
+
raise UsageError.new("put for yaml requires content: or body:") if content.nil? && (body.nil? || body.to_s.empty?)
|
|
48
|
+
|
|
49
|
+
if content.nil?
|
|
50
|
+
begin
|
|
51
|
+
parsed = parse(body.to_s, path: path)
|
|
52
|
+
rescue BadFrontmatter => e
|
|
53
|
+
raise BadContent.new(path, "bad_content: #{e.message}")
|
|
54
|
+
end
|
|
55
|
+
[body.to_s, parsed["_meta"], body.to_s, parsed["content"]]
|
|
56
|
+
else
|
|
57
|
+
bytes = serialize(meta: meta, body: "", content: content)
|
|
58
|
+
[bytes, meta, bytes, content]
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Mutating filesystem op; returns true if a write happened.
|
|
63
|
+
def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
|
|
64
|
+
raw = File.binread(path)
|
|
65
|
+
parsed = parse(raw, path: path)
|
|
66
|
+
meta = parsed["_meta"]
|
|
67
|
+
return false unless meta.is_a?(Hash) && meta["name"].is_a?(String) && meta["name"] != basename
|
|
68
|
+
|
|
69
|
+
new_meta = meta.merge("name" => basename)
|
|
70
|
+
File.binwrite(path, serialize(meta: new_meta, body: "", content: parsed["content"]))
|
|
71
|
+
true
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def self.enforce_name_match!(path, meta)
|
|
75
|
+
return unless meta.is_a?(Hash) && meta["name"]
|
|
76
|
+
|
|
77
|
+
ext = extensions.first
|
|
78
|
+
basename = File.basename(path, ext)
|
|
79
|
+
return if meta["name"] == basename
|
|
80
|
+
|
|
81
|
+
raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def self.inject_uid(meta, content, existing_uid)
|
|
85
|
+
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
86
|
+
m["uid"] = existing_uid || Textus::Store.mint_uid unless m["uid"].is_a?(String) && !m["uid"].empty?
|
|
87
|
+
[m, content]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def self.validate_path_extension(path, nested)
|
|
91
|
+
ext = File.extname(path)
|
|
92
|
+
if nested
|
|
93
|
+
return if ext == ""
|
|
94
|
+
|
|
95
|
+
raise UsageError.new("nested yaml path must not have an extension")
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
return if [".yaml", ".yml"].include?(ext)
|
|
99
|
+
|
|
100
|
+
raise UsageError.new("yaml format requires '.yaml' or '.yml' path (got #{ext.inspect})")
|
|
101
|
+
end
|
|
43
102
|
end
|
|
44
103
|
end
|
|
45
104
|
end
|
data/lib/textus/entry.rb
CHANGED
|
@@ -10,10 +10,26 @@ module Textus
|
|
|
10
10
|
"text" => Text,
|
|
11
11
|
}.freeze
|
|
12
12
|
|
|
13
|
+
EXT_TO_FORMAT = {
|
|
14
|
+
".md" => "markdown",
|
|
15
|
+
".json" => "json",
|
|
16
|
+
".yaml" => "yaml",
|
|
17
|
+
".yml" => "yaml",
|
|
18
|
+
".txt" => "text",
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
13
21
|
def self.for_format(format)
|
|
14
22
|
STRATEGIES.fetch(format.to_s) { raise UsageError.new("unknown entry format: #{format.inspect}") }
|
|
15
23
|
end
|
|
16
24
|
|
|
25
|
+
def self.infer_from_extension(ext)
|
|
26
|
+
EXT_TO_FORMAT[ext]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def self.formats
|
|
30
|
+
EXT_TO_FORMAT.values.uniq
|
|
31
|
+
end
|
|
32
|
+
|
|
17
33
|
def self.parse(raw, path: nil, format: "markdown")
|
|
18
34
|
for_format(format).parse(raw, path: path)
|
|
19
35
|
end
|
data/lib/textus/envelope.rb
CHANGED
|
@@ -1,30 +1,60 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
|
|
4
|
+
Envelope = Data.define(
|
|
5
|
+
:protocol, :key, :zone, :owner, :path, :format,
|
|
6
|
+
:uid, :etag, :schema_ref, :meta, :body, :content, :freshness
|
|
7
|
+
) do
|
|
5
8
|
# rubocop:disable Metrics/ParameterLists
|
|
6
|
-
def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil)
|
|
9
|
+
def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil, freshness: nil)
|
|
7
10
|
# rubocop:enable Metrics/ParameterLists
|
|
8
|
-
|
|
9
|
-
|
|
11
|
+
new(
|
|
12
|
+
protocol: Textus::PROTOCOL,
|
|
13
|
+
key: key,
|
|
14
|
+
zone: mentry.zone,
|
|
15
|
+
owner: mentry.owner,
|
|
16
|
+
path: path,
|
|
17
|
+
format: mentry.format,
|
|
18
|
+
uid: extract_uid(meta),
|
|
19
|
+
etag: etag,
|
|
20
|
+
schema_ref: mentry.schema,
|
|
21
|
+
meta: meta,
|
|
22
|
+
body: body,
|
|
23
|
+
content: content,
|
|
24
|
+
freshness: freshness,
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.extract_uid(meta)
|
|
29
|
+
v = meta.is_a?(Hash) ? meta["uid"] : nil
|
|
30
|
+
v.is_a?(String) ? v : nil
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def to_h_for_wire
|
|
34
|
+
h = {
|
|
35
|
+
"protocol" => protocol,
|
|
10
36
|
"key" => key,
|
|
11
|
-
"zone" =>
|
|
12
|
-
"owner" =>
|
|
37
|
+
"zone" => zone,
|
|
38
|
+
"owner" => owner,
|
|
13
39
|
"path" => path,
|
|
14
|
-
"format" =>
|
|
40
|
+
"format" => format,
|
|
15
41
|
"_meta" => meta,
|
|
16
42
|
"body" => body,
|
|
17
43
|
"etag" => etag,
|
|
18
|
-
"schema_ref" =>
|
|
19
|
-
"uid" =>
|
|
44
|
+
"schema_ref" => schema_ref,
|
|
45
|
+
"uid" => uid,
|
|
20
46
|
}
|
|
21
|
-
|
|
22
|
-
|
|
47
|
+
h["content"] = content unless content.nil?
|
|
48
|
+
freshness.each { |k, v| h[k.to_s] = v } if freshness.is_a?(Hash)
|
|
49
|
+
h
|
|
23
50
|
end
|
|
24
51
|
|
|
25
|
-
def
|
|
26
|
-
|
|
27
|
-
|
|
52
|
+
def stale?
|
|
53
|
+
freshness.is_a?(Hash) && (freshness["stale"] == true || freshness[:stale] == true)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def refreshing?
|
|
57
|
+
freshness.is_a?(Hash) && (freshness["refreshing"] == true || freshness[:refreshing] == true)
|
|
28
58
|
end
|
|
29
59
|
end
|
|
30
60
|
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,72 @@ 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
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
# Static, store-independent guide to the agent-facing protocol. Surfaced
|
|
30
|
+
# under the new top-level `agent_protocol` key in Intro.run. Recipes
|
|
31
|
+
# describe CLI verbs (not Ruby Operations) because the audience is an
|
|
32
|
+
# agent driving textus from the command line.
|
|
33
|
+
AGENT_PROTOCOL = {
|
|
34
|
+
"envelope_shape" => {
|
|
35
|
+
"summary" => "every read/write payload is a JSON envelope with _meta, body, uid, and etag",
|
|
36
|
+
"fields" => {
|
|
37
|
+
"_meta" => "hash of structured frontmatter; schema-validated per entry family",
|
|
38
|
+
"body" => "string payload (markdown/text) or nil for json/yaml formats where body lives in _meta",
|
|
39
|
+
"uid" => "stable 16-char hex identifier; preserved across writes and key renames",
|
|
40
|
+
"etag" => "content hash; pass back on writes to detect concurrent edits",
|
|
41
|
+
},
|
|
42
|
+
"ref" => "SPEC.md §8",
|
|
43
|
+
},
|
|
44
|
+
"role_resolution" => {
|
|
45
|
+
"summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, default human",
|
|
46
|
+
"roles" => %w[human agent runner builder],
|
|
47
|
+
"ref" => "SPEC.md §5",
|
|
48
|
+
},
|
|
49
|
+
"recipes" => {
|
|
50
|
+
"read" => {
|
|
51
|
+
"purpose" => "find and read an entry",
|
|
52
|
+
"steps" => [
|
|
53
|
+
"textus list --zone=ZONE --prefix=PREFIX # discover keys",
|
|
54
|
+
"textus get KEY # returns envelope JSON",
|
|
55
|
+
],
|
|
56
|
+
},
|
|
57
|
+
"write" => {
|
|
58
|
+
"purpose" => "create or update an entry",
|
|
59
|
+
"steps" => [
|
|
60
|
+
"textus schema get FAMILY # learn the _meta field shape",
|
|
61
|
+
"build an envelope JSON: {_meta: {...}, body: \"...\"}",
|
|
62
|
+
"echo ENVELOPE | textus put KEY --as=ROLE --stdin",
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
"propose" => {
|
|
66
|
+
"purpose" => "agent suggests a change for human review",
|
|
67
|
+
"agent_steps" => [
|
|
68
|
+
"echo ENVELOPE | textus put review.KEY --as=agent --stdin",
|
|
69
|
+
],
|
|
70
|
+
"human_steps" => [
|
|
71
|
+
"textus accept review.KEY --as=human # promotes the proposal to its target zone",
|
|
72
|
+
],
|
|
73
|
+
},
|
|
74
|
+
"refresh" => {
|
|
75
|
+
"purpose" => "rebuild stale intake-zone caches from their declared actions",
|
|
76
|
+
"steps" => [
|
|
77
|
+
"textus freshness --zone=intake # report fresh/stale per entry",
|
|
78
|
+
"textus refresh stale --zone=intake --as=runner",
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
27
82
|
}.freeze
|
|
28
83
|
|
|
29
84
|
# The CLI verb catalog. Truth lives here; do not derive dynamically.
|
|
@@ -37,14 +92,14 @@ module Textus
|
|
|
37
92
|
{ "name" => "schema", "summary" => "field shape for a key family" },
|
|
38
93
|
{ "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
|
|
39
94
|
{ "name" => "accept", "summary" => "apply a review.* proposal; --as=human only" },
|
|
40
|
-
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key
|
|
95
|
+
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key normalize'" },
|
|
41
96
|
{ "name" => "delete", "summary" => "delete an entry; --as=<role>" },
|
|
42
97
|
{ "name" => "build", "summary" => "materialize output entries; publish_to and publish_each fan out copies" },
|
|
43
|
-
{ "name" => "refresh", "summary" => "run an action for an
|
|
98
|
+
{ "name" => "refresh", "summary" => "run an action for an intake entry" },
|
|
44
99
|
{ "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
|
|
45
100
|
{ "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
|
|
46
101
|
{ "name" => "blame", "summary" => "audit rows for one key joined with git commit metadata" },
|
|
47
|
-
{ "name" => "
|
|
102
|
+
{ "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
|
|
48
103
|
{ "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
|
|
49
104
|
{ "name" => "hook",
|
|
50
105
|
"summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
@@ -59,6 +114,7 @@ module Textus
|
|
|
59
114
|
"hooks" => hooks_for(store),
|
|
60
115
|
"write_flows" => WRITE_FLOWS.dup,
|
|
61
116
|
"cli_verbs" => CLI_VERBS.map(&:dup),
|
|
117
|
+
"agent_protocol" => AGENT_PROTOCOL,
|
|
62
118
|
"docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
|
|
63
119
|
}
|
|
64
120
|
end
|
|
@@ -74,7 +130,7 @@ module Textus
|
|
|
74
130
|
|
|
75
131
|
def self.entries_for(store)
|
|
76
132
|
store.manifest.entries.map do |e|
|
|
77
|
-
derived = store.manifest.zone_writers(e.zone).include?("
|
|
133
|
+
derived = store.manifest.zone_writers(e.zone).include?("builder")
|
|
78
134
|
{
|
|
79
135
|
"key" => e.key,
|
|
80
136
|
"zone" => e.zone,
|