textus 0.20.0 → 0.22.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 +157 -0
- data/README.md +7 -4
- data/SPEC.md +77 -5
- data/lib/textus/application/policy/predicates/accept_authority_signed.rb +33 -0
- data/lib/textus/application/policy/promotion.rb +6 -11
- data/lib/textus/application/reads/audit.rb +40 -15
- data/lib/textus/application/reads/pulse.rb +63 -0
- data/lib/textus/application/reads/validator.rb +3 -1
- data/lib/textus/application/writes/accept.rb +5 -1
- data/lib/textus/application/writes/authority_gate.rb +26 -0
- data/lib/textus/application/writes/materializer.rb +1 -1
- data/lib/textus/application/writes/publish.rb +25 -106
- data/lib/textus/application/writes/reject.rb +5 -1
- data/lib/textus/{intro.rb → boot.rb} +71 -25
- data/lib/textus/builder/pipeline.rb +2 -2
- data/lib/textus/cli/verb/audit.rb +2 -0
- data/lib/textus/cli/verb/{intro.rb → boot.rb} +3 -3
- data/lib/textus/cli/verb/build.rb +2 -1
- data/lib/textus/cli/verb/pulse.rb +17 -0
- data/lib/textus/cli.rb +1 -1
- data/lib/textus/doctor/check/illegal_keys.rb +2 -3
- data/lib/textus/domain/policy/promote.rb +4 -2
- data/lib/textus/domain/policy/refresh.rb +2 -0
- data/lib/textus/errors.rb +16 -0
- data/lib/textus/infra/audit_log.rb +126 -16
- data/lib/textus/manifest/entry/base.rb +43 -6
- data/lib/textus/manifest/entry/derived.rb +40 -4
- data/lib/textus/manifest/entry/intake.rb +15 -3
- data/lib/textus/manifest/entry/leaf.rb +6 -5
- data/lib/textus/manifest/entry/nested.rb +42 -3
- data/lib/textus/manifest/entry/parser.rb +9 -51
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
- data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
- data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
- data/lib/textus/manifest/entry/validators.rb +1 -1
- data/lib/textus/manifest/entry.rb +3 -0
- data/lib/textus/manifest/resolver.rb +8 -5
- data/lib/textus/manifest/role_kinds.rb +21 -0
- data/lib/textus/manifest/schema.rb +63 -5
- data/lib/textus/manifest.rb +31 -1
- data/lib/textus/operations.rb +8 -1
- data/lib/textus/schema/tools.rb +8 -1
- data/lib/textus/store.rb +5 -1
- data/lib/textus/version.rb +1 -1
- metadata +9 -10
- data/lib/textus/application/policy/predicates/human_accept.rb +0 -30
- data/lib/textus/application/tools/migrate_keys.rb +0 -191
- data/lib/textus/application/tools/migrate_manifest_to_kinds.rb +0 -31
- data/lib/textus/cli/verb/key_normalize.rb +0 -48
- data/lib/textus/domain/policy.rb +0 -7
- data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
- data/lib/textus/manifest/resolution.rb +0 -5
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Application
|
|
3
3
|
module Writes
|
|
4
|
-
# Single-pass publish use case:
|
|
5
|
-
#
|
|
6
|
-
#
|
|
4
|
+
# Single-pass publish use case: dispatches polymorphically to each
|
|
5
|
+
# entry's `publish_via` method. Derived entries materialize their body
|
|
6
|
+
# via Materializer; Nested entries fan out via publish_each; Leaf and
|
|
7
|
+
# Intake entries copy their stored body to publish_to targets. The
|
|
8
|
+
# Publish layer owns wiring (context, accumulation) but not per-kind
|
|
9
|
+
# logic.
|
|
7
10
|
#
|
|
8
11
|
# Return shape: { "protocol", "built", "published_leaves" }
|
|
9
|
-
# — wire-compatible with what the `textus build` CLI verb previously
|
|
10
|
-
# assembled by merging Build + old Publish results.
|
|
11
12
|
class Publish
|
|
12
13
|
def initialize(ctx:, manifest:, file_store:, bus:, root:, store:, hook_context:) # rubocop:disable Metrics/ParameterLists
|
|
13
14
|
@ctx = ctx
|
|
@@ -22,26 +23,17 @@ module Textus
|
|
|
22
23
|
def call(prefix: nil)
|
|
23
24
|
built = []
|
|
24
25
|
leaves = []
|
|
25
|
-
|
|
26
|
+
context = build_context
|
|
26
27
|
|
|
27
28
|
@manifest.entries.each do |mentry|
|
|
28
29
|
next if prefix && !entry_matches_prefix?(mentry, prefix)
|
|
29
30
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
next unless mentry.in_generator_zone?
|
|
31
|
+
result = mentry.publish_via(context, prefix: prefix)
|
|
32
|
+
next if result.nil?
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
when
|
|
37
|
-
next unless mentry.publish_each
|
|
38
|
-
|
|
39
|
-
publish_nested(mentry, repo_root, prefix, leaves)
|
|
40
|
-
when Textus::Manifest::Entry::Leaf
|
|
41
|
-
next if Array(mentry.publish_to).empty?
|
|
42
|
-
|
|
43
|
-
result = publish_leaf_entry(mentry, repo_root)
|
|
44
|
-
built << result if result
|
|
34
|
+
case result[:kind]
|
|
35
|
+
when :built then built << result[:value]
|
|
36
|
+
when :leaves then leaves.concat(result[:value])
|
|
45
37
|
end
|
|
46
38
|
end
|
|
47
39
|
|
|
@@ -50,86 +42,19 @@ module Textus
|
|
|
50
42
|
|
|
51
43
|
private
|
|
52
44
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
def publish_derived_copies(mentry, target_path, repo_root)
|
|
67
|
-
envelope = reader.call(mentry.key)
|
|
68
|
-
mentry.publish_to.each do |rel|
|
|
69
|
-
target_abs = File.join(repo_root, rel)
|
|
70
|
-
Textus::Infra::Publisher.publish(source: target_path, target: target_abs, store_root: @root)
|
|
71
|
-
publish_event(:file_published,
|
|
72
|
-
key: mentry.key,
|
|
73
|
-
envelope: envelope,
|
|
74
|
-
source: target_path,
|
|
75
|
-
target: target_abs)
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
def fire_build_completed(mentry, target_path) # rubocop:disable Lint/UnusedMethodArgument
|
|
80
|
-
envelope = reader.call(mentry.key)
|
|
81
|
-
src = mentry.source
|
|
82
|
-
selects = src.is_a?(Textus::Manifest::Entry::Derived::Projection) ? Array(src.select).compact : []
|
|
83
|
-
publish_event(:build_completed,
|
|
84
|
-
key: mentry.key,
|
|
85
|
-
envelope: envelope,
|
|
86
|
-
sources: selects)
|
|
87
|
-
end
|
|
88
|
-
|
|
89
|
-
# Publish each leaf under a Nested entry's publish_each pattern.
|
|
90
|
-
def publish_nested(mentry, repo_root, prefix, accumulator)
|
|
91
|
-
@manifest.resolver.enumerate(prefix: mentry.key).each do |row|
|
|
92
|
-
next unless row[:manifest_entry].equal?(mentry)
|
|
93
|
-
next if prefix && !row[:key].start_with?(prefix) && row[:key] != prefix
|
|
94
|
-
|
|
95
|
-
accumulator << publish_nested_leaf(mentry, row, repo_root)
|
|
96
|
-
end
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def publish_nested_leaf(mentry, row, repo_root)
|
|
100
|
-
target_rel = mentry.publish_target_for(row[:key])
|
|
101
|
-
target_abs = File.expand_path(File.join(repo_root, target_rel))
|
|
102
|
-
unless target_abs.start_with?(File.expand_path(repo_root) + File::SEPARATOR)
|
|
103
|
-
raise PublishError.new(
|
|
104
|
-
"entry '#{mentry.key}': publish_each target '#{target_rel}' for key '#{row[:key]}' escapes repo root",
|
|
105
|
-
)
|
|
106
|
-
end
|
|
107
|
-
|
|
108
|
-
Textus::Infra::Publisher.publish(source: row[:path], target: target_abs, store_root: @root)
|
|
109
|
-
publish_event(:file_published,
|
|
110
|
-
key: row[:key],
|
|
111
|
-
envelope: reader.call(row[:key]),
|
|
112
|
-
source: row[:path],
|
|
113
|
-
target: target_abs)
|
|
114
|
-
{ "key" => row[:key], "source" => row[:path], "target" => target_abs }
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# Publish a standalone Leaf entry that has publish_to targets.
|
|
118
|
-
def publish_leaf_entry(mentry, repo_root)
|
|
119
|
-
source_path = @manifest.resolver.resolve(mentry.key).path
|
|
120
|
-
envelope = reader.call(mentry.key)
|
|
121
|
-
|
|
122
|
-
mentry.publish_to.each do |rel|
|
|
123
|
-
target_abs = File.join(repo_root, rel)
|
|
124
|
-
Textus::Infra::Publisher.publish(source: source_path, target: target_abs, store_root: @root)
|
|
125
|
-
publish_event(:file_published,
|
|
126
|
-
key: mentry.key,
|
|
127
|
-
envelope: envelope,
|
|
128
|
-
source: source_path,
|
|
129
|
-
target: target_abs)
|
|
130
|
-
end
|
|
131
|
-
|
|
132
|
-
{ "key" => mentry.key, "path" => source_path, "published_to" => mentry.publish_to }
|
|
45
|
+
def build_context
|
|
46
|
+
Textus::Manifest::Entry::Base::PublishContext.new(
|
|
47
|
+
repo_root: File.dirname(@root),
|
|
48
|
+
manifest: @manifest,
|
|
49
|
+
file_store: @file_store,
|
|
50
|
+
root: @root,
|
|
51
|
+
store: @store,
|
|
52
|
+
ctx: @ctx,
|
|
53
|
+
bus: @bus,
|
|
54
|
+
hook_context: @hook_context,
|
|
55
|
+
reader: reader,
|
|
56
|
+
emit: ->(event, **payload) { @bus.publish(event, ctx: @hook_context, **payload) },
|
|
57
|
+
)
|
|
133
58
|
end
|
|
134
59
|
|
|
135
60
|
# Whether the entry should be processed for the given prefix filter.
|
|
@@ -138,8 +63,6 @@ module Textus
|
|
|
138
63
|
|
|
139
64
|
case mentry
|
|
140
65
|
when Textus::Manifest::Entry::Nested
|
|
141
|
-
# Nested: process if the entry key is a prefix of `prefix` or
|
|
142
|
-
# `prefix` is a prefix of the entry key (a leaf under it).
|
|
143
66
|
mentry.key.start_with?(prefix) ||
|
|
144
67
|
prefix.start_with?("#{mentry.key}.")
|
|
145
68
|
else
|
|
@@ -152,10 +75,6 @@ module Textus
|
|
|
152
75
|
ctx: @ctx, manifest: @manifest, file_store: @file_store,
|
|
153
76
|
)
|
|
154
77
|
end
|
|
155
|
-
|
|
156
|
-
def publish_event(event, **payload)
|
|
157
|
-
@bus.publish(event, ctx: @hook_context, **payload)
|
|
158
|
-
end
|
|
159
78
|
end
|
|
160
79
|
end
|
|
161
80
|
end
|
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
require_relative "authority_gate"
|
|
2
|
+
|
|
1
3
|
module Textus
|
|
2
4
|
module Application
|
|
3
5
|
module Writes
|
|
4
6
|
class Reject
|
|
7
|
+
include AuthorityGate
|
|
8
|
+
|
|
5
9
|
def initialize(ctx:, manifest:, file_store:, envelope_io:, bus:, authorizer:, hook_context:) # rubocop:disable Metrics/ParameterLists
|
|
6
10
|
@ctx = ctx
|
|
7
11
|
@manifest = manifest
|
|
@@ -13,7 +17,7 @@ module Textus
|
|
|
13
17
|
end
|
|
14
18
|
|
|
15
19
|
def call(pending_key)
|
|
16
|
-
|
|
20
|
+
assert_accept_authority!("reject")
|
|
17
21
|
|
|
18
22
|
mentry = @manifest.resolver.resolve(pending_key).entry
|
|
19
23
|
unless mentry.in_proposal_zone?
|
|
@@ -4,8 +4,8 @@ module Textus
|
|
|
4
4
|
# project: zones and their write authority, entries and their flags,
|
|
5
5
|
# registered hooks, write flows, and the CLI verb catalog.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
module
|
|
7
|
+
# Boot is side-effect-free.
|
|
8
|
+
module Boot
|
|
9
9
|
PROTOCOL_ID = PROTOCOL
|
|
10
10
|
|
|
11
11
|
# Conventional zone purposes. Unknown zones (declared in the manifest
|
|
@@ -18,19 +18,37 @@ module Textus
|
|
|
18
18
|
"output" => "build-computed outputs; never hand-edited",
|
|
19
19
|
}.freeze
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
21
|
+
# Per-kind write-flow templates. Each lambda receives the user-facing role
|
|
22
|
+
# name and returns a guidance string for that role. Roles whose kind has
|
|
23
|
+
# no template (e.g. unknown future kinds) are omitted from write_flows.
|
|
24
|
+
WRITE_FLOW_TEMPLATES = {
|
|
25
|
+
accept_authority: lambda do |name, _manifest|
|
|
26
|
+
"edit files in identity/working zones, then 'textus put KEY --as=#{name}'"
|
|
27
|
+
end,
|
|
28
|
+
proposer: lambda do |name, manifest|
|
|
29
|
+
authority = manifest.roles_with_kind(:accept_authority).first || "accept_authority"
|
|
30
|
+
"propose changes by writing review.* entries with --as=#{name} and a 'proposal:' frontmatter block; " \
|
|
31
|
+
"the #{authority} role runs 'textus accept' to apply"
|
|
32
|
+
end,
|
|
33
|
+
runner: lambda do |name, _manifest|
|
|
34
|
+
"refresh intake entries with 'textus refresh KEY --as=#{name}' (uses the entry's declared action)"
|
|
35
|
+
end,
|
|
36
|
+
generator: lambda do |_name, _manifest|
|
|
37
|
+
"'textus build' computes output entries from projections; output files are never hand-edited"
|
|
38
|
+
end,
|
|
27
39
|
}.freeze
|
|
28
40
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
41
|
+
def self.write_flows_for(manifest)
|
|
42
|
+
manifest.role_mapping.each_with_object({}) do |(name, kind), acc|
|
|
43
|
+
tmpl = WRITE_FLOW_TEMPLATES[kind]
|
|
44
|
+
acc[name] = tmpl.call(name, manifest) if tmpl
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Static, store-independent parts of the agent-facing protocol. The
|
|
49
|
+
# `role_resolution` block is derived per-manifest in agent_protocol(...)
|
|
50
|
+
# because role names are user-configurable.
|
|
51
|
+
AGENT_PROTOCOL_TEMPLATE = {
|
|
34
52
|
"envelope_shape" => {
|
|
35
53
|
"summary" => "every read/write payload is a JSON envelope with _meta, body, uid, and etag",
|
|
36
54
|
"fields" => {
|
|
@@ -41,11 +59,6 @@ module Textus
|
|
|
41
59
|
},
|
|
42
60
|
"ref" => "SPEC.md §8",
|
|
43
61
|
},
|
|
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
62
|
"recipes" => {
|
|
50
63
|
"read" => {
|
|
51
64
|
"purpose" => "find and read an entry",
|
|
@@ -82,17 +95,17 @@ module Textus
|
|
|
82
95
|
}.freeze
|
|
83
96
|
|
|
84
97
|
# The CLI verb catalog. Truth lives here; do not derive dynamically.
|
|
85
|
-
# Agents that read
|
|
98
|
+
# Agents that read boot should see a stable shape regardless of how
|
|
86
99
|
# verb implementations evolve.
|
|
87
100
|
CLI_VERBS = [
|
|
88
|
-
{ "name" => "
|
|
101
|
+
{ "name" => "boot", "summary" => "this output — orientation for agents and tools" },
|
|
89
102
|
{ "name" => "list", "summary" => "enumerate keys (optional --prefix)" },
|
|
90
103
|
{ "name" => "get", "summary" => "read an entry; envelope with _meta, body, uid, etag" },
|
|
91
104
|
{ "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
|
|
92
105
|
{ "name" => "schema", "summary" => "field shape for a key family" },
|
|
93
106
|
{ "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
|
|
94
107
|
{ "name" => "accept", "summary" => "apply a review.* proposal; --as=human only" },
|
|
95
|
-
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid'
|
|
108
|
+
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid'" },
|
|
96
109
|
{ "name" => "delete", "summary" => "delete an entry; --as=<role>" },
|
|
97
110
|
{ "name" => "build", "summary" => "materialize output entries; publish_to and publish_each fan out copies" },
|
|
98
111
|
{ "name" => "refresh", "summary" => "run an action for an intake entry" },
|
|
@@ -103,8 +116,40 @@ module Textus
|
|
|
103
116
|
{ "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
|
|
104
117
|
{ "name" => "hook",
|
|
105
118
|
"summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
119
|
+
{ "name" => "pulse",
|
|
120
|
+
"summary" => "delta since cursor — changed entries, stale, pending review, doctor summary" },
|
|
106
121
|
].freeze
|
|
107
122
|
|
|
123
|
+
def self.agent_quickstart(manifest, store)
|
|
124
|
+
proposer_roles = manifest.roles_with_kind(:proposer)
|
|
125
|
+
agent_role = proposer_roles.first
|
|
126
|
+
|
|
127
|
+
writable_zones = manifest.zones.each_with_object([]) do |(zname, writers), acc|
|
|
128
|
+
acc << zname if agent_role && writers.include?(agent_role)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
propose_zone = writable_zones.find { |z| z.include?("review") } || writable_zones.first
|
|
132
|
+
|
|
133
|
+
{
|
|
134
|
+
"read_verbs" => %w[boot get list audit pulse freshness doctor],
|
|
135
|
+
"write_verbs" => agent_role ? ["put KEY --as=#{agent_role} --stdin"] : [],
|
|
136
|
+
"writable_zones" => writable_zones,
|
|
137
|
+
"propose_zone" => propose_zone,
|
|
138
|
+
"latest_seq" => store.audit_log.latest_seq,
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def self.agent_protocol(manifest)
|
|
143
|
+
AGENT_PROTOCOL_TEMPLATE.merge(
|
|
144
|
+
"role_resolution" => {
|
|
145
|
+
"summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, " \
|
|
146
|
+
"default 'human'",
|
|
147
|
+
"roles" => manifest.role_mapping.keys,
|
|
148
|
+
"ref" => "SPEC.md §5",
|
|
149
|
+
},
|
|
150
|
+
)
|
|
151
|
+
end
|
|
152
|
+
|
|
108
153
|
def self.run(store)
|
|
109
154
|
{
|
|
110
155
|
"protocol" => PROTOCOL_ID,
|
|
@@ -112,9 +157,10 @@ module Textus
|
|
|
112
157
|
"zones" => zones_for(store),
|
|
113
158
|
"entries" => entries_for(store),
|
|
114
159
|
"hooks" => hooks_for(store),
|
|
115
|
-
"write_flows" =>
|
|
160
|
+
"write_flows" => write_flows_for(store.manifest),
|
|
116
161
|
"cli_verbs" => CLI_VERBS.map(&:dup),
|
|
117
|
-
"agent_protocol" =>
|
|
162
|
+
"agent_protocol" => agent_protocol(store.manifest),
|
|
163
|
+
"agent_quickstart" => agent_quickstart(store.manifest, store),
|
|
118
164
|
"docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
|
|
119
165
|
}
|
|
120
166
|
end
|
|
@@ -130,7 +176,7 @@ module Textus
|
|
|
130
176
|
|
|
131
177
|
def self.entries_for(store)
|
|
132
178
|
store.manifest.entries.map do |e|
|
|
133
|
-
derived = store.manifest.
|
|
179
|
+
derived = store.manifest.zone_kinds(e.zone).include?(:generator)
|
|
134
180
|
{
|
|
135
181
|
"key" => e.key,
|
|
136
182
|
"zone" => e.zone,
|
|
@@ -141,7 +187,7 @@ module Textus
|
|
|
141
187
|
"derived" => derived,
|
|
142
188
|
"intake" => e.is_a?(Textus::Manifest::Entry::Intake),
|
|
143
189
|
"publish_to" => Array(e.publish_to),
|
|
144
|
-
"publish_each" => e.
|
|
190
|
+
"publish_each" => e.publish_each,
|
|
145
191
|
}
|
|
146
192
|
end
|
|
147
193
|
end
|
|
@@ -64,7 +64,7 @@ module Textus
|
|
|
64
64
|
|
|
65
65
|
# rubocop:disable Metrics/ParameterLists
|
|
66
66
|
def self.run(mentry:, manifest:, reader:, lister:, transform_resolver:, template_loader:,
|
|
67
|
-
transform_context: nil,
|
|
67
|
+
transform_context: nil, inject_boot: nil)
|
|
68
68
|
# 1. Load sources + project + reduce
|
|
69
69
|
data =
|
|
70
70
|
if mentry.is_a?(Textus::Manifest::Entry::Derived) && mentry.projection?
|
|
@@ -78,7 +78,7 @@ module Textus
|
|
|
78
78
|
else
|
|
79
79
|
{ "entries" => [], "count" => 0, "generated_at" => Time.now.utc.iso8601 }
|
|
80
80
|
end
|
|
81
|
-
data = data.merge("
|
|
81
|
+
data = data.merge("boot" => inject_boot.call) if mentry.inject_boot && inject_boot
|
|
82
82
|
|
|
83
83
|
# 2. Render
|
|
84
84
|
klass = renderers[mentry.format] or
|
|
@@ -9,6 +9,7 @@ module Textus
|
|
|
9
9
|
option :role_filter, "--role=ROLE"
|
|
10
10
|
option :verb_filter, "--verb=V"
|
|
11
11
|
option :since, "--since=ISO8601|RELATIVE"
|
|
12
|
+
option :seq_since, "--seq-since=N"
|
|
12
13
|
option :correlation_id, "--correlation-id=ID"
|
|
13
14
|
option :limit, "--limit=N"
|
|
14
15
|
|
|
@@ -21,6 +22,7 @@ module Textus
|
|
|
21
22
|
role: role_filter,
|
|
22
23
|
verb: verb_filter,
|
|
23
24
|
since: since_time,
|
|
25
|
+
seq_since: seq_since&.to_i,
|
|
24
26
|
correlation_id: correlation_id,
|
|
25
27
|
limit: limit&.to_i,
|
|
26
28
|
)
|
|
@@ -8,7 +8,8 @@ module Textus
|
|
|
8
8
|
|
|
9
9
|
def call(store)
|
|
10
10
|
Textus::Infra::BuildLock.with(root: store.root) do
|
|
11
|
-
|
|
11
|
+
role = store.manifest.roles_with_kind(:generator).first || "builder"
|
|
12
|
+
ops = Textus::Operations.for(store, role: role)
|
|
12
13
|
result = ops.publish(prefix: prefix)
|
|
13
14
|
emit(result)
|
|
14
15
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class CLI
|
|
3
|
+
class Verb
|
|
4
|
+
class Pulse < Verb
|
|
5
|
+
command_name "pulse"
|
|
6
|
+
|
|
7
|
+
option :since, "--since=N"
|
|
8
|
+
|
|
9
|
+
def call(store)
|
|
10
|
+
ops = operations_for(store)
|
|
11
|
+
since_n = (since || "0").to_i
|
|
12
|
+
emit(ops.pulse(since: since_n))
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/textus/cli.rb
CHANGED
|
@@ -99,7 +99,7 @@ module Textus
|
|
|
99
99
|
textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
|
|
100
100
|
textus blame KEY [--limit=N]
|
|
101
101
|
textus doctor
|
|
102
|
-
textus
|
|
102
|
+
textus boot
|
|
103
103
|
|
|
104
104
|
textus key {mv,uid,normalize}
|
|
105
105
|
textus rule {list,explain}
|
|
@@ -44,15 +44,14 @@ module Textus
|
|
|
44
44
|
end
|
|
45
45
|
|
|
46
46
|
def issue(abs_path, stem)
|
|
47
|
-
proposed = Textus::Application::Tools::MigrateKeys.normalize(stem)
|
|
48
47
|
{
|
|
49
48
|
"code" => "key.illegal",
|
|
50
49
|
"level" => "error",
|
|
51
50
|
"subject" => abs_path,
|
|
52
51
|
"path" => abs_path,
|
|
53
|
-
"proposed_key" => proposed,
|
|
54
52
|
"message" => "illegal key segment '#{stem}' at #{abs_path}",
|
|
55
|
-
"fix" => "
|
|
53
|
+
"fix" => "rename the file/directory so each segment matches [a-z0-9][a-z0-9-]* " \
|
|
54
|
+
"(lowercase, digits, hyphens)",
|
|
56
55
|
}
|
|
57
56
|
end
|
|
58
57
|
|
|
@@ -2,14 +2,16 @@ module Textus
|
|
|
2
2
|
module Domain
|
|
3
3
|
module Policy
|
|
4
4
|
class Promote
|
|
5
|
-
KNOWN = %i[schema_valid
|
|
5
|
+
KNOWN = %i[schema_valid accept_authority_signed].freeze
|
|
6
6
|
attr_reader :requires
|
|
7
7
|
|
|
8
8
|
def initialize(requires:)
|
|
9
9
|
syms = Array(requires).map { |r| r.to_s.to_sym }
|
|
10
10
|
unknown = syms - KNOWN
|
|
11
11
|
unless unknown.empty?
|
|
12
|
-
raise Textus::UsageError.new(
|
|
12
|
+
raise Textus::UsageError.new(
|
|
13
|
+
"unknown promote requirement: #{unknown.first.inspect} (known: #{KNOWN.join(", ")})",
|
|
14
|
+
)
|
|
13
15
|
end
|
|
14
16
|
|
|
15
17
|
@requires = syms
|
|
@@ -2,6 +2,8 @@ module Textus
|
|
|
2
2
|
module Domain
|
|
3
3
|
module Policy
|
|
4
4
|
class Refresh
|
|
5
|
+
ALLOWED_ON_STALE = %i[warn sync timed_sync].freeze
|
|
6
|
+
|
|
5
7
|
attr_reader :ttl, :on_stale, :sync_budget_ms, :fetch_timeout_seconds
|
|
6
8
|
|
|
7
9
|
def initialize(ttl:, on_stale:, sync_budget_ms:, fetch_timeout_seconds: nil)
|
data/lib/textus/errors.rb
CHANGED
|
@@ -215,4 +215,20 @@ module Textus
|
|
|
215
215
|
)
|
|
216
216
|
end
|
|
217
217
|
end
|
|
218
|
+
|
|
219
|
+
class CursorExpired < Error
|
|
220
|
+
attr_reader :requested, :min_available
|
|
221
|
+
|
|
222
|
+
def initialize(requested:, min_available:)
|
|
223
|
+
@requested = requested
|
|
224
|
+
@min_available = min_available
|
|
225
|
+
super(
|
|
226
|
+
"cursor_expired",
|
|
227
|
+
"audit cursor expired: requested seq=#{requested} but oldest available is #{min_available}; " \
|
|
228
|
+
"call `textus boot` to re-orient and resume from latest_seq",
|
|
229
|
+
details: { "requested" => requested, "min_available" => min_available },
|
|
230
|
+
hint: "call `textus boot` to get the current latest_seq and resume from there",
|
|
231
|
+
)
|
|
232
|
+
end
|
|
233
|
+
end
|
|
218
234
|
end
|