textus 0.8.1 → 0.9.2
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 +224 -0
- data/README.md +50 -22
- data/SPEC.md +194 -63
- data/docs/architecture.md +22 -4
- data/docs/conventions.md +24 -17
- data/lib/textus/application/context.rb +68 -0
- data/lib/textus/application/reads/audit.rb +69 -0
- data/lib/textus/application/reads/blame.rb +79 -0
- data/lib/textus/application/reads/freshness.rb +77 -0
- data/lib/textus/application/reads/get.rb +62 -0
- data/lib/textus/application/reads/policy_explain.rb +39 -0
- data/lib/textus/application/refresh/all.rb +41 -0
- data/lib/textus/application/refresh/orchestrator.rb +68 -0
- data/lib/textus/application/refresh/worker.rb +79 -0
- data/lib/textus/application/writes/accept.rb +43 -0
- data/lib/textus/application/writes/build.rb +24 -0
- data/lib/textus/application/writes/delete.rb +37 -0
- data/lib/textus/application/writes/publish.rb +25 -0
- data/lib/textus/application/writes/put.rb +44 -0
- data/lib/textus/builder.rb +27 -14
- data/lib/textus/cli/group/policy.rb +11 -0
- data/lib/textus/cli/verb/accept.rb +2 -1
- data/lib/textus/cli/verb/audit.rb +31 -0
- data/lib/textus/cli/verb/blame.rb +17 -0
- data/lib/textus/cli/verb/build.rb +2 -1
- data/lib/textus/cli/verb/delete.rb +2 -1
- data/lib/textus/cli/verb/freshness.rb +17 -0
- data/lib/textus/cli/verb/get.rb +8 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -3
- data/lib/textus/cli/verb/policy_explain.rb +15 -0
- data/lib/textus/cli/verb/policy_list.rb +25 -0
- data/lib/textus/cli/verb/put.rb +5 -4
- data/lib/textus/cli/verb/refresh.rb +2 -1
- data/lib/textus/cli/verb/refresh_stale.rb +19 -0
- data/lib/textus/cli/verb/reject.rb +15 -0
- data/lib/textus/cli.rb +16 -2
- data/lib/textus/composition.rb +71 -0
- data/lib/textus/doctor/check/handler_allowlist.rb +33 -0
- data/lib/textus/doctor/check/intake_registration.rb +46 -0
- data/lib/textus/doctor/check/legacy_intake_fields.rb +57 -0
- data/lib/textus/doctor/check/policy_ambiguity.rb +49 -0
- data/lib/textus/doctor.rb +4 -0
- data/lib/textus/domain/action.rb +9 -0
- data/lib/textus/domain/freshness/evaluator.rb +30 -0
- data/lib/textus/domain/freshness/policy.rb +18 -0
- data/lib/textus/domain/freshness/verdict.rb +12 -0
- data/lib/textus/domain/outcome.rb +10 -0
- data/lib/textus/domain/permission.rb +15 -0
- data/lib/textus/domain/policy/handler_allowlist.rb +17 -0
- data/lib/textus/domain/policy/matcher.rb +51 -0
- data/lib/textus/domain/policy/promote.rb +24 -0
- data/lib/textus/domain/policy/refresh.rb +48 -0
- data/lib/textus/domain/policy.rb +7 -0
- data/lib/textus/hooks/builtin.rb +5 -5
- data/lib/textus/hooks/dispatcher.rb +15 -1
- data/lib/textus/hooks/dsl.rb +18 -0
- data/lib/textus/hooks/registry.rb +12 -5
- data/lib/textus/infra/clock.rb +9 -0
- data/lib/textus/infra/event_bus.rb +27 -0
- data/lib/textus/infra/publisher.rb +73 -0
- data/lib/textus/infra/refresh/detached.rb +38 -0
- data/lib/textus/infra/refresh/lock.rb +44 -0
- data/lib/textus/init.rb +71 -28
- data/lib/textus/intro.rb +19 -11
- data/lib/textus/manifest/entry.rb +18 -9
- data/lib/textus/manifest/policies.rb +83 -0
- data/lib/textus/manifest.rb +30 -0
- data/lib/textus/proposal.rb +4 -21
- data/lib/textus/publisher.rb +4 -69
- data/lib/textus/refresh.rb +9 -44
- data/lib/textus/store/mover.rb +14 -9
- data/lib/textus/store/reader.rb +10 -8
- data/lib/textus/store/staleness.rb +4 -16
- data/lib/textus/store/validator.rb +46 -20
- data/lib/textus/store/view.rb +8 -19
- data/lib/textus/store/writer.rb +51 -14
- data/lib/textus/store.rb +29 -9
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +1 -0
- metadata +46 -2
- data/lib/textus/cli/verb/stale.rb +0 -14
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Infra
|
|
5
|
+
module Refresh
|
|
6
|
+
class Lock
|
|
7
|
+
def initialize(root:, key:)
|
|
8
|
+
@root = root
|
|
9
|
+
@key = key
|
|
10
|
+
@path = File.join(root, ".locks", "#{safe_key}.lock")
|
|
11
|
+
@file = nil
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def try_acquire # rubocop:disable Naming/PredicateMethod
|
|
15
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
16
|
+
@file = File.open(@path, File::RDWR | File::CREAT, 0o644)
|
|
17
|
+
acquired = @file.flock(File::LOCK_EX | File::LOCK_NB)
|
|
18
|
+
unless acquired
|
|
19
|
+
@file.close
|
|
20
|
+
@file = nil
|
|
21
|
+
return false
|
|
22
|
+
end
|
|
23
|
+
@file.write(Process.pid.to_s)
|
|
24
|
+
@file.flush
|
|
25
|
+
true
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def release
|
|
29
|
+
return unless @file
|
|
30
|
+
|
|
31
|
+
@file.flock(File::LOCK_UN)
|
|
32
|
+
@file.close
|
|
33
|
+
@file = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def safe_key
|
|
39
|
+
@key.to_s.gsub(/[^a-zA-Z0-9._-]/, "_")
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
data/lib/textus/init.rb
CHANGED
|
@@ -2,21 +2,83 @@ require "fileutils"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Init
|
|
5
|
-
ZONES = %w[
|
|
5
|
+
ZONES = %w[identity working inbox review output].freeze
|
|
6
6
|
|
|
7
7
|
DEFAULT_MANIFEST = <<~YAML
|
|
8
8
|
version: textus/2
|
|
9
9
|
zones:
|
|
10
|
-
- { name:
|
|
11
|
-
- { name: working,
|
|
12
|
-
- { name:
|
|
13
|
-
- { name:
|
|
14
|
-
- { name:
|
|
10
|
+
- { name: identity, writable_by: [human] }
|
|
11
|
+
- { name: working, writable_by: [human, ai, script] }
|
|
12
|
+
- { name: inbox, writable_by: [script] }
|
|
13
|
+
- { name: review, writable_by: [ai, human] }
|
|
14
|
+
- { name: output, writable_by: [build] }
|
|
15
15
|
entries:
|
|
16
|
-
- { key:
|
|
17
|
-
- { key: working.notes,
|
|
16
|
+
- { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self }
|
|
17
|
+
- { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true }
|
|
18
18
|
YAML
|
|
19
19
|
|
|
20
|
+
HOOKS_README = <<~MD
|
|
21
|
+
# Hooks
|
|
22
|
+
|
|
23
|
+
Drop one Ruby file per hook. All hooks register through one DSL.
|
|
24
|
+
Files anywhere under `.textus/hooks/` (including subdirectories) are loaded at
|
|
25
|
+
startup in alphabetical order by full path. Subdirectory names are organizational
|
|
26
|
+
only — the registered event and name come from the DSL call, not the file path.
|
|
27
|
+
|
|
28
|
+
## Per-event sugar (preferred)
|
|
29
|
+
|
|
30
|
+
```ruby
|
|
31
|
+
Textus.intake(:my_source) do |config:, args:, **|
|
|
32
|
+
{ _meta: { "last_refreshed_at" => Time.now.utc.iso8601 }, body: "…" }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
Textus.reduce(:my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
|
|
36
|
+
Textus.check(:my_check) { |store:, **| { ok: true } }
|
|
37
|
+
Textus.put(:my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
|
|
38
|
+
|
|
39
|
+
# Run a side-effect every time textus writes a file to your repo:
|
|
40
|
+
Textus.published(:notify) do |key:, target:, **|
|
|
41
|
+
warn "wrote \#{target} (from \#{key})"
|
|
42
|
+
end
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
The intake handler above is paired with a manifest entry plus a
|
|
46
|
+
top-level `policies:` block for freshness (ttl/on_stale live in
|
|
47
|
+
policies, not in the entry):
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
entries:
|
|
51
|
+
- key: inbox.foo
|
|
52
|
+
path: inbox/foo.md
|
|
53
|
+
zone: inbox
|
|
54
|
+
intake:
|
|
55
|
+
handler: my_source
|
|
56
|
+
|
|
57
|
+
policies:
|
|
58
|
+
- match: inbox.foo
|
|
59
|
+
refresh:
|
|
60
|
+
ttl: 10m
|
|
61
|
+
on_stale: timed_sync # warn | sync | timed_sync (default: warn)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Low-level primitive (always available)
|
|
65
|
+
|
|
66
|
+
```ruby
|
|
67
|
+
Textus.hook(:intake, :name) { |store:, config:, args:| ... } # bring bytes in
|
|
68
|
+
Textus.hook(:reduce, :name) { |store:, rows:, config:| ... } # transform rows
|
|
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)
|
|
78
|
+
|
|
79
|
+
See SPEC.md §5.10 for the full table.
|
|
80
|
+
MD
|
|
81
|
+
|
|
20
82
|
def self.run(target_root)
|
|
21
83
|
raise UsageError.new(".textus/ already exists at #{target_root}") if File.directory?(target_root)
|
|
22
84
|
|
|
@@ -28,26 +90,7 @@ module Textus
|
|
|
28
90
|
FileUtils.mkdir_p(dir)
|
|
29
91
|
File.write(File.join(dir, ".gitkeep"), "")
|
|
30
92
|
end
|
|
31
|
-
File.write(File.join(target_root, "hooks", "README.md"),
|
|
32
|
-
# Hooks
|
|
33
|
-
|
|
34
|
-
Drop one Ruby file per hook. All hooks register through one DSL.
|
|
35
|
-
Every handler receives `store:` as its first kwarg, then event-specific args.
|
|
36
|
-
|
|
37
|
-
```ruby
|
|
38
|
-
Textus.hook(:fetch, :name) { |store:, config:, args:| ... } # bring bytes in
|
|
39
|
-
Textus.hook(:reduce, :name) { |store:, rows:, config:| ... } # transform rows
|
|
40
|
-
Textus.hook(:check, :name) { |store:| ... } # doctor check
|
|
41
|
-
Textus.hook(:put, :name, keys: ["..."]) # lifecycle listener
|
|
42
|
-
{ |store:, key:, envelope:| ... }
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
Events: :fetch, :reduce, :check (rpc — return value used)
|
|
46
|
-
:put, :delete, :refresh, :build, :accept (pub-sub — return discarded)
|
|
47
|
-
|
|
48
|
-
See SPEC.md §5.10 for the full table.
|
|
49
|
-
MD
|
|
50
|
-
|
|
93
|
+
File.write(File.join(target_root, "hooks", "README.md"), HOOKS_README)
|
|
51
94
|
File.write(File.join(target_root, "manifest.yaml"), DEFAULT_MANIFEST)
|
|
52
95
|
{ "protocol" => PROTOCOL, "initialized" => target_root }
|
|
53
96
|
end
|
data/lib/textus/intro.rb
CHANGED
|
@@ -11,19 +11,24 @@ module Textus
|
|
|
11
11
|
# Conventional zone purposes. Unknown zones (declared in the manifest
|
|
12
12
|
# but not listed here) get no `purpose` field.
|
|
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
|
+
"inbox" => "declared external inputs; script-refreshed via actions",
|
|
17
|
+
"review" => "AI proposals awaiting human accept",
|
|
18
|
+
"output" => "build-computed outputs; never hand-edited",
|
|
19
|
+
# legacy 0.9.1 zone names — kept so intro still annotates pre-rename stores
|
|
20
|
+
"canon" => "slow-changing identity; human-only writes",
|
|
16
21
|
"intake" => "declared external inputs; script-refreshed via actions",
|
|
17
22
|
"pending" => "AI proposals awaiting human accept",
|
|
18
23
|
"derived" => "build-computed outputs; never hand-edited",
|
|
19
24
|
}.freeze
|
|
20
25
|
|
|
21
26
|
WRITE_FLOWS = {
|
|
22
|
-
"human" => "edit files in
|
|
23
|
-
"ai" => "propose changes by writing '
|
|
27
|
+
"human" => "edit files in identity/working zones, then 'textus put KEY --as=human'",
|
|
28
|
+
"ai" => "propose changes by writing 'review.*' entries with --as=ai and a 'proposal:' frontmatter block; " \
|
|
24
29
|
"a human runs 'textus accept' to apply",
|
|
25
|
-
"script" => "refresh
|
|
26
|
-
"build" => "'textus build' computes
|
|
30
|
+
"script" => "refresh inbox entries with 'textus refresh KEY --as=script' (uses the entry's declared action)",
|
|
31
|
+
"build" => "'textus build' computes output entries from projections; output files are never hand-edited",
|
|
27
32
|
}.freeze
|
|
28
33
|
|
|
29
34
|
# The CLI verb catalog. Truth lives here; do not derive dynamically.
|
|
@@ -36,13 +41,16 @@ module Textus
|
|
|
36
41
|
{ "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
|
|
37
42
|
{ "name" => "schema", "summary" => "field shape for a key family" },
|
|
38
43
|
{ "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
|
|
39
|
-
{ "name" => "accept", "summary" => "apply a
|
|
44
|
+
{ "name" => "accept", "summary" => "apply a review.* proposal; --as=human only" },
|
|
40
45
|
{ "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key migrate'" },
|
|
41
46
|
{ "name" => "delete", "summary" => "delete an entry; --as=<role>" },
|
|
42
|
-
{ "name" => "build", "summary" => "materialize
|
|
43
|
-
{ "name" => "refresh", "summary" => "run an action for an
|
|
44
|
-
{ "name" => "
|
|
45
|
-
{ "name" => "
|
|
47
|
+
{ "name" => "build", "summary" => "materialize output entries; publish_to and publish_each fan out copies" },
|
|
48
|
+
{ "name" => "refresh", "summary" => "run an action for an inbox entry" },
|
|
49
|
+
{ "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
|
|
50
|
+
{ "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
|
|
51
|
+
{ "name" => "blame", "summary" => "audit rows for one key joined with git commit metadata" },
|
|
52
|
+
{ "name" => "policy", "summary" => "inspect effective policies: 'policy list', 'policy explain KEY'" },
|
|
53
|
+
{ "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
|
|
46
54
|
{ "name" => "hook",
|
|
47
55
|
"summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
48
56
|
].freeze
|
|
@@ -80,7 +88,7 @@ module Textus
|
|
|
80
88
|
"owner" => e.owner,
|
|
81
89
|
"format" => e.format,
|
|
82
90
|
"derived" => derived,
|
|
83
|
-
"intake" => !e.
|
|
91
|
+
"intake" => !e.intake_handler.nil?,
|
|
84
92
|
"publish_to" => Array(e.publish_to),
|
|
85
93
|
"publish_each" => e.publish_each,
|
|
86
94
|
}
|
|
@@ -5,8 +5,9 @@ module Textus
|
|
|
5
5
|
PUBLISH_EACH_VAR_RE = /\{([a-z]+)\}/
|
|
6
6
|
|
|
7
7
|
attr_reader :key, :path, :zone, :schema, :owner, :nested, :generator, :raw, :format,
|
|
8
|
-
:projection, :template, :publish_to, :publish_each,
|
|
9
|
-
:
|
|
8
|
+
:projection, :template, :publish_to, :publish_each,
|
|
9
|
+
:intake_handler, :intake_config,
|
|
10
|
+
:events, :inject_intro
|
|
10
11
|
|
|
11
12
|
def initialize(manifest, raw)
|
|
12
13
|
@manifest = manifest
|
|
@@ -27,7 +28,9 @@ module Textus
|
|
|
27
28
|
@format = resolve_format!(raw["format"])
|
|
28
29
|
|
|
29
30
|
validate_events!
|
|
30
|
-
|
|
31
|
+
raise UsageError.new("entry '#{@key}': 'source:' key renamed to 'intake:' in 0.9") if raw.key?("source")
|
|
32
|
+
|
|
33
|
+
parse_intake!(raw["intake"])
|
|
31
34
|
reject_legacy_projection_keys!
|
|
32
35
|
validate_format_matrix!
|
|
33
36
|
validate_publish_each!
|
|
@@ -168,13 +171,19 @@ module Textus
|
|
|
168
171
|
end
|
|
169
172
|
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
170
173
|
|
|
171
|
-
def
|
|
172
|
-
|
|
173
|
-
raise UsageError.new("entry '#{@key}': source.action renamed to source.fetch in 0.6") if src.key?("action")
|
|
174
|
+
def parse_intake!(src)
|
|
175
|
+
raise UsageError.new("entry '#{@key}': source.fetch renamed to intake.handler in 0.9") if src.is_a?(Hash) && src.key?("fetch")
|
|
174
176
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
if src.is_a?(Hash) && (src.key?("ttl") || src.key?("on_stale") || src.key?("sync_budget_ms"))
|
|
178
|
+
raise UsageError.new(
|
|
179
|
+
"entry '#{@key}': intake.ttl/intake.on_stale/intake.sync_budget_ms removed in 0.9.2 — " \
|
|
180
|
+
"move into a top-level policies: block (see CHANGELOG migration recipe).",
|
|
181
|
+
)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
src ||= {}
|
|
185
|
+
@intake_handler = src["handler"]
|
|
186
|
+
@intake_config = src["config"] || {}
|
|
178
187
|
end
|
|
179
188
|
|
|
180
189
|
def reject_legacy_projection_keys!
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Policies
|
|
4
|
+
PolicySet = Data.define(:refresh, :handler_allowlist, :promote, :retention)
|
|
5
|
+
EMPTY_SET = PolicySet.new(refresh: nil, handler_allowlist: nil, promote: nil, retention: nil)
|
|
6
|
+
|
|
7
|
+
def self.parse(raw)
|
|
8
|
+
new(Array(raw).map { |b| Block.new(b) })
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(blocks)
|
|
12
|
+
@blocks = blocks
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :blocks
|
|
16
|
+
|
|
17
|
+
def for(key)
|
|
18
|
+
slots = { refresh: [], handler_allowlist: [], promote: [], retention: [] }
|
|
19
|
+
@blocks.each do |b|
|
|
20
|
+
next unless Textus::Domain::Policy::Matcher.matches?(b.match, key)
|
|
21
|
+
|
|
22
|
+
slots.each_key { |slot| slots[slot] << b if b.public_send(slot) }
|
|
23
|
+
end
|
|
24
|
+
PolicySet.new(
|
|
25
|
+
refresh: pick(slots[:refresh], :refresh, key),
|
|
26
|
+
handler_allowlist: pick(slots[:handler_allowlist], :handler_allowlist, key),
|
|
27
|
+
promote: pick(slots[:promote], :promote, key),
|
|
28
|
+
retention: pick(slots[:retention], :retention, key),
|
|
29
|
+
)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def explain(key)
|
|
33
|
+
@blocks.select { |b| Textus::Domain::Policy::Matcher.matches?(b.match, key) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def pick(blocks, slot, key)
|
|
39
|
+
return nil if blocks.empty?
|
|
40
|
+
|
|
41
|
+
globs = blocks.map(&:match)
|
|
42
|
+
winning = Textus::Domain::Policy::Matcher.pick_most_specific(globs, key: key)
|
|
43
|
+
blocks.find { |b| b.match == winning }&.public_send(slot)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
class Block
|
|
47
|
+
attr_reader :match, :refresh, :handler_allowlist, :promote, :retention
|
|
48
|
+
|
|
49
|
+
def initialize(raw)
|
|
50
|
+
@match = raw["match"] or raise Textus::UsageError.new("policy block missing match:")
|
|
51
|
+
@refresh = parse_refresh(raw["refresh"])
|
|
52
|
+
@handler_allowlist = parse_handler_allowlist(raw["handler_allowlist"])
|
|
53
|
+
@promote = parse_promote(raw["promote_requires"])
|
|
54
|
+
@retention = raw["retention"] # reserved — passthrough only
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
def parse_refresh(h)
|
|
60
|
+
return nil if h.nil?
|
|
61
|
+
|
|
62
|
+
Textus::Domain::Policy::Refresh.new(
|
|
63
|
+
ttl: h["ttl"],
|
|
64
|
+
on_stale: h["on_stale"] || "warn",
|
|
65
|
+
sync_budget_ms: h["sync_budget_ms"],
|
|
66
|
+
)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def parse_handler_allowlist(arr)
|
|
70
|
+
return nil if arr.nil?
|
|
71
|
+
|
|
72
|
+
Textus::Domain::Policy::HandlerAllowlist.new(handlers: arr)
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def parse_promote(arr)
|
|
76
|
+
return nil if arr.nil?
|
|
77
|
+
|
|
78
|
+
Textus::Domain::Policy::Promote.new(requires: arr)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
data/lib/textus/manifest.rb
CHANGED
|
@@ -20,6 +20,14 @@ module Textus
|
|
|
20
20
|
zones[zone_name] or raise UsageError.new("undeclared zone '#{zone_name}'")
|
|
21
21
|
end
|
|
22
22
|
|
|
23
|
+
def permission_for(zone_name)
|
|
24
|
+
Textus::Domain::Permission.new(
|
|
25
|
+
zone: zone_name,
|
|
26
|
+
writable_by: zone_writers(zone_name),
|
|
27
|
+
readable_by: :all,
|
|
28
|
+
)
|
|
29
|
+
end
|
|
30
|
+
|
|
23
31
|
def self.load(root)
|
|
24
32
|
manifest_path = File.join(root, "manifest.yaml")
|
|
25
33
|
raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)
|
|
@@ -42,10 +50,19 @@ module Textus
|
|
|
42
50
|
@raw = raw
|
|
43
51
|
raise BadFrontmatter.new(File.join(root, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?
|
|
44
52
|
|
|
53
|
+
reject_legacy_entry_intake_policy!(Array(raw["entries"]))
|
|
45
54
|
@entries = Array(raw["entries"]).map { |e| Manifest::Entry.new(self, e) }
|
|
46
55
|
validate_declared_keys!
|
|
47
56
|
end
|
|
48
57
|
|
|
58
|
+
def policies
|
|
59
|
+
@policies ||= Textus::Manifest::Policies.parse(@raw["policies"] || [])
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def policies_for(key)
|
|
63
|
+
policies.for(key)
|
|
64
|
+
end
|
|
65
|
+
|
|
49
66
|
# Returns [Manifest::Entry, resolved_path, remaining_segments]
|
|
50
67
|
def resolve(key)
|
|
51
68
|
validate_key!(key)
|
|
@@ -149,6 +166,19 @@ module Textus
|
|
|
149
166
|
@entries.each { |e| validate_key!(e.key) }
|
|
150
167
|
end
|
|
151
168
|
|
|
169
|
+
def reject_legacy_entry_intake_policy!(raw_entries)
|
|
170
|
+
raw_entries.each do |re|
|
|
171
|
+
intake = re["intake"]
|
|
172
|
+
next unless intake.is_a?(Hash)
|
|
173
|
+
next unless intake.key?("ttl") || intake.key?("on_stale") || intake.key?("sync_budget_ms")
|
|
174
|
+
|
|
175
|
+
raise UsageError.new(
|
|
176
|
+
"entry '#{re["key"]}': intake.ttl/intake.on_stale/intake.sync_budget_ms removed in 0.9.2 — " \
|
|
177
|
+
"move into a top-level policies: block (see CHANGELOG migration recipe).",
|
|
178
|
+
)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
152
182
|
def resolve_leaf_path(entry)
|
|
153
183
|
Textus::Key::Path.resolve(self, entry)
|
|
154
184
|
end
|
data/lib/textus/proposal.rb
CHANGED
|
@@ -1,27 +1,10 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Proposal
|
|
3
|
+
# Deprecated as of 0.9.1: use Textus::Application::Writes::Accept (via
|
|
4
|
+
# Textus::Composition.writes_accept).
|
|
3
5
|
def self.accept(store, pending_key, as:)
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
env = store.get(pending_key)
|
|
7
|
-
proposal = env["_meta"]["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
|
|
8
|
-
target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
|
|
9
|
-
action = proposal["action"] || "put"
|
|
10
|
-
|
|
11
|
-
case action
|
|
12
|
-
when "put"
|
|
13
|
-
target_meta = env["_meta"]["frontmatter"] || {}
|
|
14
|
-
target_body = env["body"]
|
|
15
|
-
store.put(target, meta: target_meta, body: target_body, as: "human")
|
|
16
|
-
when "delete"
|
|
17
|
-
store.delete(target, as: "human")
|
|
18
|
-
else
|
|
19
|
-
raise ProposalError.new("unknown action: #{action}")
|
|
20
|
-
end
|
|
21
|
-
|
|
22
|
-
store.delete(pending_key, as: "human")
|
|
23
|
-
store.fire_event(:accept, key: pending_key, target_key: target)
|
|
24
|
-
{ "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
|
|
6
|
+
ctx = Textus::Composition.context(store, role: as)
|
|
7
|
+
Textus::Application::Writes::Accept.new(ctx: ctx, bus: store.bus).call(pending_key)
|
|
25
8
|
end
|
|
26
9
|
end
|
|
27
10
|
end
|
data/lib/textus/publisher.rb
CHANGED
|
@@ -1,71 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
# Deprecated as of 0.9.1: use Textus::Infra::Publisher (or
|
|
2
|
+
# Textus::Application::Writes::Publish for the use-case entry point).
|
|
3
|
+
# Slated for removal in 0.10.0.
|
|
5
4
|
module Textus
|
|
6
|
-
|
|
7
|
-
# Publish = copy + sentinel. The in-store file is already the consumer-shaped
|
|
8
|
-
# artifact; no parsing or stripping. Sentinels live under
|
|
9
|
-
# `<store_root>/sentinels/` and mirror the target's repo-relative layout so
|
|
10
|
-
# consumer directories aren't polluted with `.textus-managed.json` siblings.
|
|
11
|
-
module Publisher
|
|
12
|
-
SENTINEL_SUFFIX = ".textus-managed.json".freeze
|
|
13
|
-
SENTINEL_DIR = "sentinels".freeze
|
|
14
|
-
|
|
15
|
-
def self.publish(source:, target:, store_root:)
|
|
16
|
-
FileUtils.mkdir_p(File.dirname(target))
|
|
17
|
-
refuse_if_unmanaged(target, store_root)
|
|
18
|
-
File.delete(target) if File.symlink?(target)
|
|
19
|
-
FileUtils.cp(source, target)
|
|
20
|
-
write_sentinel(target, store_root: store_root, source: source)
|
|
21
|
-
cleanup_legacy_sentinel(target)
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def self.refuse_if_unmanaged(target, store_root)
|
|
25
|
-
return unless File.exist?(target) || File.symlink?(target)
|
|
26
|
-
return if managed?(target, store_root)
|
|
27
|
-
|
|
28
|
-
raise PublishError.new("refusing to clobber unmanaged file at #{target}", target: target)
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def self.managed?(target, store_root)
|
|
32
|
-
File.exist?(sentinel_path(target, store_root)) || File.exist?(legacy_sentinel_path(target))
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def self.write_sentinel(target, store_root:, source:)
|
|
36
|
-
path = sentinel_path(target, store_root)
|
|
37
|
-
FileUtils.mkdir_p(File.dirname(path))
|
|
38
|
-
File.write(path, JSON.generate(
|
|
39
|
-
"source" => source,
|
|
40
|
-
"target" => target,
|
|
41
|
-
"sha256" => Digest::SHA256.hexdigest(File.binread(target)),
|
|
42
|
-
"mode" => "copy",
|
|
43
|
-
))
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
# Sentinel layout: <store_root>/sentinels/<target_rel_to_repo>.textus-managed.json
|
|
47
|
-
# The full target extension is preserved so a marketplace.json and
|
|
48
|
-
# marketplace.yaml don't collide.
|
|
49
|
-
def self.sentinel_path(target, store_root)
|
|
50
|
-
repo_root = File.dirname(store_root)
|
|
51
|
-
rel = relative_to(target, repo_root) || File.basename(target)
|
|
52
|
-
File.join(store_root, SENTINEL_DIR, rel + SENTINEL_SUFFIX)
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
def self.legacy_sentinel_path(target)
|
|
56
|
-
target + SENTINEL_SUFFIX
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def self.cleanup_legacy_sentinel(target)
|
|
60
|
-
FileUtils.rm_f(legacy_sentinel_path(target))
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
def self.relative_to(path, base)
|
|
64
|
-
path = File.expand_path(path)
|
|
65
|
-
base = File.expand_path(base)
|
|
66
|
-
return nil unless path.start_with?(base + File::SEPARATOR)
|
|
67
|
-
|
|
68
|
-
path[(base.length + 1)..]
|
|
69
|
-
end
|
|
70
|
-
end
|
|
5
|
+
Publisher = Infra::Publisher
|
|
71
6
|
end
|
data/lib/textus/refresh.rb
CHANGED
|
@@ -1,56 +1,21 @@
|
|
|
1
|
-
require "timeout"
|
|
2
|
-
|
|
3
1
|
module Textus
|
|
4
2
|
module Refresh
|
|
5
|
-
FETCH_TIMEOUT_SECONDS = 2
|
|
6
|
-
|
|
7
3
|
def self.call(store, key, as:)
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
before_etag = File.exist?(path) ? Etag.for_file(path) : nil
|
|
12
|
-
callable = store.registry.rpc_callable(:fetch, mentry.fetch)
|
|
13
|
-
view = Store::View.new(store, writable: true, as: as)
|
|
14
|
-
result =
|
|
15
|
-
begin
|
|
16
|
-
Timeout.timeout(FETCH_TIMEOUT_SECONDS) do
|
|
17
|
-
callable.call(store: view, config: mentry.fetch_config, args: {})
|
|
18
|
-
end
|
|
19
|
-
rescue Timeout::Error
|
|
20
|
-
raise UsageError.new("fetch '#{mentry.fetch}' exceeded #{FETCH_TIMEOUT_SECONDS}s timeout")
|
|
21
|
-
rescue Textus::Error
|
|
22
|
-
raise
|
|
23
|
-
rescue StandardError => e
|
|
24
|
-
raise UsageError.new("fetch '#{mentry.fetch}' raised: #{e.class}: #{e.message}")
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
normalized = normalize_action_result(result, format: mentry.format)
|
|
28
|
-
envelope = store.put(
|
|
29
|
-
key,
|
|
30
|
-
meta: normalized[:meta],
|
|
31
|
-
body: normalized[:body],
|
|
32
|
-
content: normalized[:content],
|
|
33
|
-
as: as,
|
|
34
|
-
suppress_events: true,
|
|
35
|
-
)
|
|
4
|
+
ctx = Textus::Composition.context(store, role: as)
|
|
5
|
+
Textus::Composition.refresh_worker(ctx).run(key)
|
|
6
|
+
end
|
|
36
7
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
:unchanged
|
|
41
|
-
else
|
|
42
|
-
:updated
|
|
43
|
-
end
|
|
44
|
-
store.fire_event(:refresh, key: key, envelope: envelope, change: change) unless change == :unchanged
|
|
45
|
-
envelope
|
|
8
|
+
def self.refresh_stale(store, prefix: nil, zone: nil, as: "script")
|
|
9
|
+
ctx = Textus::Composition.context(store, role: as)
|
|
10
|
+
Textus::Application::Refresh::All.call(ctx, prefix: prefix, zone: zone)
|
|
46
11
|
end
|
|
47
12
|
|
|
48
|
-
# Normalize the three accepted
|
|
13
|
+
# Normalize the three accepted intake return shapes into the store's
|
|
49
14
|
# internal {frontmatter, body, content} representation.
|
|
50
15
|
def self.normalize_action_result(res, format:)
|
|
51
16
|
res = res.transform_keys(&:to_s) if res.is_a?(Hash)
|
|
52
17
|
res ||= {}
|
|
53
|
-
# Accept both legacy :frontmatter/:_meta key names from
|
|
18
|
+
# Accept both legacy :frontmatter/:_meta key names from intake hooks.
|
|
54
19
|
meta_val = res["_meta"] || res["frontmatter"]
|
|
55
20
|
body = res["body"]
|
|
56
21
|
content = res["content"]
|
|
@@ -66,7 +31,7 @@ module Textus
|
|
|
66
31
|
elsif !body.nil?
|
|
67
32
|
{ meta: {}, body: body.to_s, content: nil }
|
|
68
33
|
else
|
|
69
|
-
raise UsageError.new("
|
|
34
|
+
raise UsageError.new("intake for #{format} returned neither content nor body")
|
|
70
35
|
end
|
|
71
36
|
else
|
|
72
37
|
raise UsageError.new("unknown format #{format.inspect}")
|
data/lib/textus/store/mover.rb
CHANGED
|
@@ -4,14 +4,15 @@ module Textus
|
|
|
4
4
|
class Store
|
|
5
5
|
# rubocop:disable Metrics/AbcSize, Metrics/MethodLength
|
|
6
6
|
class Mover
|
|
7
|
-
def initialize(reader:, writer:, manifest:, audit_log:)
|
|
7
|
+
def initialize(store:, reader:, writer:, manifest:, audit_log:)
|
|
8
|
+
@store = store
|
|
8
9
|
@reader = reader
|
|
9
10
|
@writer = writer
|
|
10
11
|
@manifest = manifest
|
|
11
12
|
@audit_log = audit_log
|
|
12
13
|
end
|
|
13
14
|
|
|
14
|
-
def call(old_key, new_key, as: Role::DEFAULT, dry_run: false)
|
|
15
|
+
def call(old_key, new_key, as: Role::DEFAULT, dry_run: false, correlation_id: nil)
|
|
15
16
|
@manifest.validate_key!(old_key)
|
|
16
17
|
@manifest.validate_key!(new_key)
|
|
17
18
|
raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
|
|
@@ -69,23 +70,27 @@ module Textus
|
|
|
69
70
|
rewrite_name_for_mv!(new_mentry, new_path, new_key)
|
|
70
71
|
etag_after = Etag.for_file(new_path)
|
|
71
72
|
|
|
73
|
+
extras = {
|
|
74
|
+
"from_key" => old_key, "to_key" => new_key,
|
|
75
|
+
"from_path" => old_path, "to_path" => new_path,
|
|
76
|
+
"uid" => current_uid
|
|
77
|
+
}
|
|
78
|
+
extras["correlation_id"] = correlation_id if correlation_id
|
|
79
|
+
|
|
72
80
|
@audit_log.append(
|
|
73
81
|
role: as, verb: "mv", key: new_key,
|
|
74
82
|
etag_before: etag_before, etag_after: etag_after,
|
|
75
|
-
extras:
|
|
76
|
-
"from_key" => old_key, "to_key" => new_key,
|
|
77
|
-
"from_path" => old_path, "to_path" => new_path,
|
|
78
|
-
"uid" => current_uid
|
|
79
|
-
}
|
|
83
|
+
extras: extras
|
|
80
84
|
)
|
|
81
85
|
|
|
82
|
-
|
|
86
|
+
new_envelope = @reader.get(new_key)
|
|
87
|
+
@store.fire_event(:mv, key: new_key, from_key: old_key, to_key: new_key, envelope: new_envelope)
|
|
83
88
|
{
|
|
84
89
|
"protocol" => PROTOCOL, "ok" => true,
|
|
85
90
|
"from_key" => old_key, "to_key" => new_key,
|
|
86
91
|
"from_path" => old_path, "to_path" => new_path,
|
|
87
92
|
"uid" => current_uid,
|
|
88
|
-
"envelope" =>
|
|
93
|
+
"envelope" => new_envelope
|
|
89
94
|
}
|
|
90
95
|
end
|
|
91
96
|
|