textus 0.39.1 → 0.41.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 +36 -1
- data/SPEC.md +31 -7
- data/docs/architecture/README.md +256 -0
- data/docs/reference/conventions.md +148 -0
- data/lib/textus/boot.rb +2 -2
- data/lib/textus/cli/verb/build.rb +5 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -1
- data/lib/textus/cli/verb/hooks.rb +3 -3
- data/lib/textus/cli/verb/put.rb +1 -1
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +35 -0
- data/lib/textus/doctor/check/publish_tree_index_overlap.rb +48 -0
- data/lib/textus/doctor.rb +2 -0
- data/lib/textus/hooks/catalog.rb +36 -0
- data/lib/textus/hooks/event_bus.rb +2 -19
- data/lib/textus/hooks/loader.rb +1 -1
- data/lib/textus/hooks/rpc_registry.rb +3 -11
- data/lib/textus/manifest/capabilities.rb +3 -3
- data/lib/textus/manifest/entry/base.rb +12 -20
- data/lib/textus/manifest/entry/nested.rb +8 -49
- data/lib/textus/manifest/entry/publish/each.rb +83 -0
- data/lib/textus/manifest/entry/publish/each_dir.rb +74 -0
- data/lib/textus/manifest/entry/publish/each_file.rb +29 -0
- data/lib/textus/manifest/entry/publish/mode.rb +39 -0
- data/lib/textus/manifest/entry/publish/none.rb +14 -0
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +72 -0
- data/lib/textus/manifest/entry/publish/template.rb +22 -0
- data/lib/textus/manifest/entry/publish/to_paths.rb +27 -0
- data/lib/textus/manifest/entry/publish/tree.rb +54 -0
- data/lib/textus/manifest/entry/publish.rb +45 -0
- data/lib/textus/manifest/entry/validators/events.rb +1 -1
- data/lib/textus/manifest/entry/validators/publish.rb +26 -0
- data/lib/textus/manifest/entry/validators.rb +1 -1
- data/lib/textus/manifest/policy.rb +7 -0
- data/lib/textus/manifest/resolver.rb +15 -2
- data/lib/textus/manifest/schema.rb +56 -1
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/fetch/detached.rb +11 -1
- data/lib/textus/ports/publisher.rb +24 -2
- data/lib/textus/ports/sentinel_store.rb +15 -0
- data/lib/textus/role.rb +18 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/fetch_events.rb +42 -0
- data/lib/textus/write/fetch_orchestrator.rb +2 -3
- data/lib/textus/write/fetch_worker.rb +13 -22
- data/lib/textus/write/intake_fetch.rb +8 -6
- data/lib/textus/write/publish.rb +6 -3
- metadata +18 -2
- data/lib/textus/manifest/entry/validators/publish_each.rb +0 -41
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# ADR 0049: the one walk->publish->prune pipeline shared by EachDir
|
|
6
|
+
# (per-leaf subtree, ADR 0046) and Tree (whole-entry mirror, ADR 0047).
|
|
7
|
+
# The two used to be near-duplicate methods (publish_subtree +
|
|
8
|
+
# publish_tree_via, prune_orphans + prune_tree); their only real
|
|
9
|
+
# difference — whether the prune honors the entry's `ignore` — is now the
|
|
10
|
+
# explicit `prune_honors_ignore:` parameter.
|
|
11
|
+
class SubtreeMirror
|
|
12
|
+
def initialize(entry, pctx)
|
|
13
|
+
@entry = entry
|
|
14
|
+
@pctx = pctx
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
# base: store dir the entry owns — the root `ignored?` globs are
|
|
18
|
+
# relative to (ADR 0042).
|
|
19
|
+
# walk_root: dir the glob is rooted at (a single leaf dir for EachDir,
|
|
20
|
+
# == base for Tree). dst paths mirror rel-to-walk_root.
|
|
21
|
+
# target_dir: repo-side destination root.
|
|
22
|
+
# key/envelope: emitted per file; envelope is nil for the keyless Tree.
|
|
23
|
+
# prune_honors_ignore: when true a managed file the entry `ignore`s
|
|
24
|
+
# survives the prune (ADR 0047 D4 — lets a derived index live in the
|
|
25
|
+
# mirrored dir); when false every unwritten managed file is pruned.
|
|
26
|
+
def mirror(base:, walk_root:, target_dir:, key:, envelope:, prune_honors_ignore:)
|
|
27
|
+
return { written: [], pruned: [] } unless File.directory?(walk_root)
|
|
28
|
+
|
|
29
|
+
written = publish_files(base: base, walk_root: walk_root, target_dir: target_dir, key: key, envelope: envelope)
|
|
30
|
+
{ written: written, pruned: prune(target_dir, written, prune_honors_ignore) }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def publish_files(base:, walk_root:, target_dir:, key:, envelope:)
|
|
36
|
+
# FNM_DOTMATCH includes dotfiles; File.file? below skips dirs (and
|
|
37
|
+
# symlinks-to-dirs). Trees are authored content, not symlink graphs.
|
|
38
|
+
Dir.glob(File.join(walk_root, "**", "*"), File::FNM_DOTMATCH).sort.filter_map do |src|
|
|
39
|
+
next nil unless File.file?(src)
|
|
40
|
+
next nil if @entry.ignored?(relative(src, base))
|
|
41
|
+
|
|
42
|
+
dst = File.join(target_dir, relative(src, walk_root))
|
|
43
|
+
Textus::Ports::Publisher.publish(source: src, target: dst, store_root: @pctx.root)
|
|
44
|
+
@pctx.emit(:file_published, key: key, envelope: envelope, source: src, target: dst)
|
|
45
|
+
{ "key" => key, "source" => src, "target" => dst }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# Scoped to target_dir. Safe across leaves because ADR 0046 D5
|
|
50
|
+
# (shallowest-index-wins) keeps leaf target dirs non-nesting, so
|
|
51
|
+
# targets_under can't reach another leaf's sentinels.
|
|
52
|
+
def prune(target_dir, written, honor_ignore)
|
|
53
|
+
kept = written.map { |w| File.expand_path(w["target"]) }
|
|
54
|
+
store = Textus::Ports::SentinelStore.new
|
|
55
|
+
store.targets_under(target_dir, @pctx.root).filter_map do |managed|
|
|
56
|
+
abs = File.expand_path(managed)
|
|
57
|
+
next nil if kept.include?(abs)
|
|
58
|
+
next nil if honor_ignore && @entry.ignored?(relative(abs, target_dir))
|
|
59
|
+
|
|
60
|
+
Textus::Ports::Publisher.unpublish(target: managed, store_root: @pctx.root)
|
|
61
|
+
managed
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def relative(path, root)
|
|
66
|
+
path.sub(%r{\A#{Regexp.escape(root)}/}, "")
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# The publish_each template vocabulary. A publish_each value is a path
|
|
6
|
+
# with `{leaf}`, `{basename}`, `{key}`, `{ext}` placeholders; a
|
|
7
|
+
# publish_tree value must be a plain path (any var is an error), so the
|
|
8
|
+
# modes reuse VAR_RE to detect stray vars there too.
|
|
9
|
+
module Template
|
|
10
|
+
KNOWN_VARS = %w[leaf basename key ext].freeze
|
|
11
|
+
VAR_RE = /\{([a-z]+)\}/
|
|
12
|
+
REQUIRED_DISCRIMINATOR_VARS = %w[leaf basename key].freeze
|
|
13
|
+
|
|
14
|
+
# Substitute `{var}` placeholders from a string-keyed hash.
|
|
15
|
+
def self.expand(template, vars)
|
|
16
|
+
template.gsub(VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# publish_to: copy the entry's one stored file to each fixed repo path.
|
|
6
|
+
# The default behaviour of any entry that declares `publish_to:`.
|
|
7
|
+
class ToPaths < Mode
|
|
8
|
+
def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
9
|
+
targets = Array(entry.publish_to)
|
|
10
|
+
return nil if targets.empty?
|
|
11
|
+
|
|
12
|
+
source_path = pctx.manifest.resolver.resolve(entry.key).path
|
|
13
|
+
envelope = pctx.reader.call(entry.key)
|
|
14
|
+
|
|
15
|
+
targets.each do |rel|
|
|
16
|
+
target_abs = File.join(pctx.repo_root, rel)
|
|
17
|
+
Textus::Ports::Publisher.publish(source: source_path, target: target_abs, store_root: pctx.root)
|
|
18
|
+
pctx.emit(:file_published, key: entry.key, envelope: envelope, source: source_path, target: target_abs)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
{ kind: :built, value: { "key" => entry.key, "path" => source_path, "published_to" => targets } }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Publish
|
|
5
|
+
# publish_tree (ADR 0047): mirror this entry's whole subtree to one
|
|
6
|
+
# target dir by real path. No resolver, no keys — files are opaque
|
|
7
|
+
# payload (envelope nil). The prune honors `ignore` so a derived index
|
|
8
|
+
# (e.g. a SKILL.md written by a separate entry into the same dir)
|
|
9
|
+
# survives the whole-target prune (ADR 0047 D4).
|
|
10
|
+
class Tree < Mode
|
|
11
|
+
def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
12
|
+
target_rel = entry.publish_tree
|
|
13
|
+
target_dir = repo_abs(pctx, target_rel)
|
|
14
|
+
unless inside_repo?(pctx, target_dir)
|
|
15
|
+
raise Textus::PublishError.new(
|
|
16
|
+
"entry '#{entry.key}': publish_tree target '#{target_rel}' escapes repo root",
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
result = SubtreeMirror.new(entry, pctx).mirror(
|
|
21
|
+
base: store_base(pctx),
|
|
22
|
+
walk_root: store_base(pctx),
|
|
23
|
+
target_dir: target_dir,
|
|
24
|
+
key: entry.key,
|
|
25
|
+
envelope: nil,
|
|
26
|
+
prune_honors_ignore: true,
|
|
27
|
+
)
|
|
28
|
+
{ kind: :leaves, value: result[:written], pruned: result[:pruned] }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def validate!
|
|
32
|
+
publish_tree = entry.publish_tree
|
|
33
|
+
raise UsageError.new("entry '#{entry.key}': publish_tree must be a string") unless publish_tree.is_a?(String)
|
|
34
|
+
|
|
35
|
+
unless entry.index_filename.nil?
|
|
36
|
+
raise UsageError.new(
|
|
37
|
+
"entry '#{entry.key}': index_filename and publish_tree are mutually exclusive — " \
|
|
38
|
+
"publish_tree mirrors a whole subtree by path and never enumerates an index.",
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
used_vars = publish_tree.scan(Template::VAR_RE).flatten
|
|
43
|
+
return if used_vars.empty?
|
|
44
|
+
|
|
45
|
+
raise UsageError.new(
|
|
46
|
+
"entry '#{entry.key}': publish_tree names a single directory and takes no template variable(s) " \
|
|
47
|
+
"#{used_vars.map { |v| "{#{v}}" }.join(", ")} — it mirrors the whole subtree to one target dir.",
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
# ADR 0049: the publish design is a three-key concept (ADR 0047 table)
|
|
5
|
+
# realized as one resolved sum type. Each directory entry resolves, once,
|
|
6
|
+
# to one Publish::* mode that owns its publish algorithm — no nil-cascade,
|
|
7
|
+
# no pairwise exclusivity guards, one shared subtree mirror.
|
|
8
|
+
#
|
|
9
|
+
# None — nothing to publish
|
|
10
|
+
# ToPaths — publish_to: 1 stored file -> N fixed repo paths
|
|
11
|
+
# EachFile — publish_each (file leaves): 1 leaf file -> 1 templated path
|
|
12
|
+
# EachDir — publish_each + index_filename: 1 leaf subtree -> 1 templated dir
|
|
13
|
+
# Tree — publish_tree: whole entry subtree -> 1 dir, no keys
|
|
14
|
+
module Publish
|
|
15
|
+
# Resolve an entry to its single publish mode. Raises one UsageError if
|
|
16
|
+
# more than one of {publish_to, publish_each, publish_tree} is set —
|
|
17
|
+
# exclusivity is structural here, not four scattered pairwise guards.
|
|
18
|
+
def self.resolve(entry)
|
|
19
|
+
set = []
|
|
20
|
+
set << "publish_to" unless Array(entry.publish_to).empty?
|
|
21
|
+
set << "publish_each" unless entry.publish_each.nil?
|
|
22
|
+
set << "publish_tree" unless entry.publish_tree.nil?
|
|
23
|
+
|
|
24
|
+
if set.length > 1
|
|
25
|
+
raise Textus::UsageError.new(
|
|
26
|
+
"entry '#{entry.key}': #{set.join(", ")} are mutually exclusive — an entry publishes exactly one way",
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
mode_for(entry, set.first)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def self.mode_for(entry, key)
|
|
34
|
+
case key
|
|
35
|
+
when "publish_to" then ToPaths.new(entry)
|
|
36
|
+
when "publish_tree" then Tree.new(entry)
|
|
37
|
+
when "publish_each" then entry.index_filename ? EachDir.new(entry) : EachFile.new(entry)
|
|
38
|
+
else None.new(entry)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
private_class_method :mode_for
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -4,7 +4,7 @@ module Textus
|
|
|
4
4
|
module Validators
|
|
5
5
|
module Events
|
|
6
6
|
def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
7
|
-
pubsub_events = Textus::Hooks::
|
|
7
|
+
pubsub_events = Textus::Hooks::Catalog::PUBSUB.keys
|
|
8
8
|
events = entry.events
|
|
9
9
|
events.each_key do |evt|
|
|
10
10
|
next if pubsub_events.include?(evt.to_sym)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
module Validators
|
|
5
|
+
# ADR 0049: one publish validator. Exclusivity among the publish keys is
|
|
6
|
+
# enforced structurally by Publish.resolve (reached via #publish_mode),
|
|
7
|
+
# and each mode's shape rules run *because that mode resolved* — replacing
|
|
8
|
+
# the four scattered pairwise "not-both" guards of the old PublishEach +
|
|
9
|
+
# PublishTree validators. Misuse on a non-nested entry is still caught
|
|
10
|
+
# here from raw, since the typed attrs stub nil on Base.
|
|
11
|
+
module Publish
|
|
12
|
+
def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
13
|
+
unless entry.nested?
|
|
14
|
+
%w[publish_each publish_tree].each do |key|
|
|
15
|
+
raise UsageError.new("entry '#{entry.key}': #{key} requires nested: true") if entry.raw[key]
|
|
16
|
+
end
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
entry.publish_mode.validate!
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -34,6 +34,13 @@ module Textus
|
|
|
34
34
|
(proposers - roles_with_capability("author")).first || proposers.first
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# The role textus acts AS for a system-initiated operation requiring
|
|
38
|
+
# `verb` (no human passed --as). Capability-derived — a role name that
|
|
39
|
+
# exists in the manifest, or nil. Never a hardcoded literal (ADR 0044).
|
|
40
|
+
def actor_for(verb)
|
|
41
|
+
roles_with_capability(verb).first
|
|
42
|
+
end
|
|
43
|
+
|
|
37
44
|
# The roles authorized to write `zone_name`: those holding the verb its
|
|
38
45
|
# kind requires. Raises on an undeclared zone.
|
|
39
46
|
def zone_writers(zone_name)
|
|
@@ -72,8 +72,21 @@ module Textus
|
|
|
72
72
|
return [] unless File.directory?(base)
|
|
73
73
|
|
|
74
74
|
entry_index_filename = entry.index_filename
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
unless entry_index_filename
|
|
76
|
+
return Dir.glob(File.join(base, nested_glob(entry.format)))
|
|
77
|
+
.filter_map { |path| nested_row_for(entry, base, path) }
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
claimed = []
|
|
81
|
+
Dir.glob(File.join(base, "**", entry_index_filename))
|
|
82
|
+
.sort_by { |path| path.count("/") }
|
|
83
|
+
.filter_map do |path|
|
|
84
|
+
leaf_dir = File.dirname(path)
|
|
85
|
+
next nil if claimed.any? { |d| leaf_dir.start_with?("#{d}/") }
|
|
86
|
+
|
|
87
|
+
claimed << leaf_dir
|
|
88
|
+
nested_row_for(entry, base, path)
|
|
89
|
+
end
|
|
77
90
|
end
|
|
78
91
|
|
|
79
92
|
def nested_row_for(entry, base, path)
|
|
@@ -24,7 +24,7 @@ module Textus
|
|
|
24
24
|
KIND_REQUIRES_VERB = LANES
|
|
25
25
|
ENTRY_KEYS = %w[
|
|
26
26
|
key path zone kind schema owner nested format
|
|
27
|
-
compute template publish_to publish_each
|
|
27
|
+
compute template publish_to publish_each publish_tree
|
|
28
28
|
intake events inject_boot index_filename ignore tracked
|
|
29
29
|
].freeze
|
|
30
30
|
COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
|
|
@@ -35,6 +35,13 @@ module Textus
|
|
|
35
35
|
RETENTION_KEYS = %w[expire_after archive_after].freeze
|
|
36
36
|
AUDIT_KEYS = %w[max_size keep].freeze
|
|
37
37
|
|
|
38
|
+
# Syntactic shape of an `owner:` subject token (the `patrick` in
|
|
39
|
+
# `human:patrick`) — the subject half of the owner-validation rule below.
|
|
40
|
+
# Role supplies the archetype set (Role::NAMES); this pattern is the
|
|
41
|
+
# owner-specific part, so it lives with the rule that composes them
|
|
42
|
+
# (ADR 0045 D1). Acting-role *names* are gated by Role::NAMES, not a regex.
|
|
43
|
+
OWNER_SUBJECT_PATTERN = /\A[a-z][a-z0-9_-]*\z/
|
|
44
|
+
|
|
38
45
|
def self.validate!(raw)
|
|
39
46
|
raise BadManifest.new("manifest must be a hash") unless raw.is_a?(Hash)
|
|
40
47
|
|
|
@@ -42,6 +49,7 @@ module Textus
|
|
|
42
49
|
validate_roles!(raw["roles"])
|
|
43
50
|
validate_zones!(raw["zones"])
|
|
44
51
|
validate_entries!(raw["entries"])
|
|
52
|
+
validate_owners!(raw["zones"], raw["entries"])
|
|
45
53
|
validate_rules!(raw["rules"])
|
|
46
54
|
walk(raw["audit"], AUDIT_KEYS, "$.audit") if raw["audit"].is_a?(Hash)
|
|
47
55
|
validate_single_queue!(raw)
|
|
@@ -91,6 +99,12 @@ module Textus
|
|
|
91
99
|
path = "$.roles[#{i}]"
|
|
92
100
|
walk(r, ROLE_KEYS, path)
|
|
93
101
|
name = r["name"] or raise BadManifest.new("role at '#{path}' missing name")
|
|
102
|
+
unless Textus::Role::NAMES.include?(name)
|
|
103
|
+
raise BadManifest.new(
|
|
104
|
+
"unknown role name '#{name}' at '#{path}' " \
|
|
105
|
+
"(allowed: #{Textus::Role::NAMES.join(", ")})",
|
|
106
|
+
)
|
|
107
|
+
end
|
|
94
108
|
Array(r["can"]).each do |verb|
|
|
95
109
|
next if CAPABILITIES.include?(verb)
|
|
96
110
|
|
|
@@ -109,6 +123,47 @@ module Textus
|
|
|
109
123
|
)
|
|
110
124
|
end
|
|
111
125
|
|
|
126
|
+
# Owners are validated against the SAME closed archetype set as role names
|
|
127
|
+
# (ADR 0045 D1) so attribution can't bypass the closed-name guarantee.
|
|
128
|
+
# Applies to both zone owners and entry owners; owner is optional, so a
|
|
129
|
+
# nil owner is not an error.
|
|
130
|
+
def self.validate_owners!(zones, entries)
|
|
131
|
+
Array(zones).each_with_index do |z, i|
|
|
132
|
+
check_owner!(z["owner"], "$.zones[#{i}]")
|
|
133
|
+
end
|
|
134
|
+
Array(entries).each_with_index do |e, i|
|
|
135
|
+
check_owner!(e["owner"], "$.entries[#{i}]")
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def self.check_owner!(owner, path)
|
|
140
|
+
return if owner.nil?
|
|
141
|
+
return if valid_owner?(owner)
|
|
142
|
+
|
|
143
|
+
raise BadManifest.new(
|
|
144
|
+
"invalid owner '#{owner}' at '#{path}' " \
|
|
145
|
+
"(expected <archetype> or <archetype>:<subject>, " \
|
|
146
|
+
"archetype one of: #{Textus::Role::NAMES.join(", ")})",
|
|
147
|
+
)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# The owner-validation rule: an `owner:` token is either a bare archetype
|
|
151
|
+
# (`agent`) or `<archetype>:<subject>` (`human:patrick`). The archetype is
|
|
152
|
+
# gated against the closed Role::NAMES set (so attribution can't smuggle in
|
|
153
|
+
# a name the role side rejects, ADR 0045 D1); the subject is the free-form
|
|
154
|
+
# principal, validated by OWNER_SUBJECT_PATTERN. Split on the FIRST ':'
|
|
155
|
+
# only — a subject may not itself contain ':' (the pattern excludes it), so
|
|
156
|
+
# `human:a:b` is rejected.
|
|
157
|
+
def self.valid_owner?(token)
|
|
158
|
+
return false unless token.is_a?(String) && !token.empty?
|
|
159
|
+
|
|
160
|
+
archetype, subject = token.split(":", 2)
|
|
161
|
+
return false unless Textus::Role::NAMES.include?(archetype)
|
|
162
|
+
return true if subject.nil?
|
|
163
|
+
|
|
164
|
+
OWNER_SUBJECT_PATTERN.match?(subject)
|
|
165
|
+
end
|
|
166
|
+
|
|
112
167
|
def self.validate_fetch_timeout!(value, path)
|
|
113
168
|
return if value.nil?
|
|
114
169
|
return if value.is_a?(Integer) && value.positive? && value <= FETCH_TIMEOUT_SECONDS_CEILING
|
|
@@ -33,7 +33,7 @@ module Textus
|
|
|
33
33
|
extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
|
|
34
34
|
extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
|
|
35
35
|
@audit_log.append(
|
|
36
|
-
role:
|
|
36
|
+
role: Textus::Role::AUTOMATION, verb: "event_error", key: key,
|
|
37
37
|
etag_before: nil, etag_after: nil, extras: extras
|
|
38
38
|
)
|
|
39
39
|
end
|
|
@@ -8,6 +8,10 @@ module Textus
|
|
|
8
8
|
Process.respond_to?(:fork)
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
+
def acting_role(store)
|
|
12
|
+
store.manifest.policy.actor_for("fetch")
|
|
13
|
+
end
|
|
14
|
+
|
|
11
15
|
def spawn(store_root:, key:)
|
|
12
16
|
return nil unless supported?
|
|
13
17
|
|
|
@@ -21,7 +25,13 @@ module Textus
|
|
|
21
25
|
|
|
22
26
|
begin
|
|
23
27
|
store = Textus::Store.new(store_root)
|
|
24
|
-
|
|
28
|
+
# No fetch-holder configured — exit the child cleanly. In practice
|
|
29
|
+
# this is unreachable: the background fork only happens after a
|
|
30
|
+
# foreground fetch was already authorized (so a fetch-holder
|
|
31
|
+
# exists). Config-time detection is doctor's job (ADR 0044 Q2).
|
|
32
|
+
role = acting_role(store)
|
|
33
|
+
exit(0) unless role
|
|
34
|
+
store.as(role).fetch(key)
|
|
25
35
|
rescue StandardError
|
|
26
36
|
# Already logged via :fetch_failed; exit cleanly.
|
|
27
37
|
ensure
|
|
@@ -12,19 +12,41 @@ module Textus
|
|
|
12
12
|
module Publisher
|
|
13
13
|
def self.publish(source:, target:, store_root:)
|
|
14
14
|
FileUtils.mkdir_p(File.dirname(target))
|
|
15
|
-
|
|
15
|
+
guard_clobber(source, target, store_root)
|
|
16
16
|
File.delete(target) if File.symlink?(target)
|
|
17
17
|
FileUtils.cp(source, target)
|
|
18
18
|
Textus::Ports::SentinelStore.new.write!(target: target, source: source, store_root: store_root)
|
|
19
19
|
end
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
# Removes a previously-published file and its sentinel. No-op unless the
|
|
22
|
+
# target is textus-managed — never deletes an unmanaged file.
|
|
23
|
+
def self.unpublish(target:, store_root:)
|
|
24
|
+
return unless managed?(target, store_root)
|
|
25
|
+
|
|
26
|
+
FileUtils.rm_f(target)
|
|
27
|
+
sentinel = Textus::Ports::SentinelStore.new.sentinel_path(target, store_root)
|
|
28
|
+
FileUtils.rm_f(sentinel)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Refuse to clobber an unmanaged target — EXCEPT adopt one whose bytes
|
|
32
|
+
# already equal the source (ADR 0050: a migration copies files into the
|
|
33
|
+
# store and publishes them back to where they already live, so the target
|
|
34
|
+
# is byte-identical — nothing is at risk). Adoption writes no sentinel
|
|
35
|
+
# here; the normal publish path below does, and the cp is a content no-op.
|
|
36
|
+
# An unmanaged target whose content DIFFERS, or any unmanaged symlink, is
|
|
37
|
+
# still refused — that is the guard's real job.
|
|
38
|
+
def self.guard_clobber(source, target, store_root)
|
|
22
39
|
return unless File.exist?(target) || File.symlink?(target)
|
|
23
40
|
return if managed?(target, store_root)
|
|
41
|
+
return if adoptable?(source, target)
|
|
24
42
|
|
|
25
43
|
raise PublishError.new("refusing to clobber unmanaged file at #{target}", target: target)
|
|
26
44
|
end
|
|
27
45
|
|
|
46
|
+
def self.adoptable?(source, target)
|
|
47
|
+
!File.symlink?(target) && File.file?(target) && FileUtils.identical?(source, target)
|
|
48
|
+
end
|
|
49
|
+
|
|
28
50
|
def self.managed?(target, store_root)
|
|
29
51
|
File.exist?(Textus::Ports::SentinelStore.new.sentinel_path(target, store_root))
|
|
30
52
|
end
|
|
@@ -42,6 +42,21 @@ module Textus
|
|
|
42
42
|
File.join(store_root, DIR, rel + SUFFIX)
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# Absolute target paths of every sentinel recorded under `target_dir`.
|
|
46
|
+
def targets_under(target_dir, store_root)
|
|
47
|
+
repo_root = File.dirname(store_root)
|
|
48
|
+
rel = relative_to(target_dir, repo_root) or return []
|
|
49
|
+
sdir = File.join(store_root, DIR, rel)
|
|
50
|
+
return [] unless File.directory?(sdir)
|
|
51
|
+
|
|
52
|
+
prefix = File.join(store_root, DIR) + "/"
|
|
53
|
+
Dir.glob(File.join(sdir, "**", "*#{SUFFIX}")).map do |spath|
|
|
54
|
+
# strip the sentinel-store prefix and the .textus-managed.json suffix to recover the repo-relative target path
|
|
55
|
+
trel = spath.delete_prefix(prefix).delete_suffix(SUFFIX)
|
|
56
|
+
File.join(repo_root, trel)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
45
60
|
private
|
|
46
61
|
|
|
47
62
|
def rel_or_abs(path, repo_root)
|
data/lib/textus/role.rb
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Role
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
#
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
# The three role archetypes, each string sourced exactly once: human curates
|
|
4
|
+
# canon, agent proposes, automation fetches/builds (explanation/concepts.md).
|
|
5
|
+
# Reference these constants instead of bare literals (ADR 0044).
|
|
6
|
+
HUMAN = "human".freeze
|
|
7
|
+
AGENT = "agent".freeze
|
|
8
|
+
AUTOMATION = "automation".freeze
|
|
9
|
+
|
|
10
|
+
# The closed set of legal role names (ADR 0045), built FROM the archetypes
|
|
11
|
+
# above so it stays the single source of truth — a manifest declaring any
|
|
12
|
+
# other name is rejected at load, and DEFAULT ∈ NAMES holds structurally.
|
|
13
|
+
# Capabilities (`can:`) remain freely tunable per role.
|
|
14
|
+
NAMES = [HUMAN, AGENT, AUTOMATION].freeze
|
|
15
|
+
|
|
16
|
+
# Default acting identity (ADR 0040): a *choice* over the vocabulary, not a
|
|
17
|
+
# new name. CLI callers act as the human; an agent over stdio proposes and
|
|
18
|
+
# does not inherit the human's authority (it defaults to AGENT per transport).
|
|
19
|
+
DEFAULT = HUMAN
|
|
9
20
|
|
|
10
21
|
def self.resolve(root:, flag: nil, env: ENV, default: DEFAULT)
|
|
11
22
|
candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || default
|
|
12
|
-
raise InvalidRole.new(candidate) unless
|
|
23
|
+
raise InvalidRole.new(candidate) unless NAMES.include?(candidate)
|
|
13
24
|
|
|
14
25
|
candidate
|
|
15
26
|
end
|
data/lib/textus/version.rb
CHANGED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Write
|
|
3
|
+
# Single home for the fetch lifecycle event vocabulary (ADR 0048 D5). Both
|
|
4
|
+
# FetchWorker (synchronous semantics) and FetchOrchestrator (async policy)
|
|
5
|
+
# emit through this seam so the event names and payload shapes live in one
|
|
6
|
+
# place with one derived hook context.
|
|
7
|
+
class FetchEvents
|
|
8
|
+
def self.from(container:, call:)
|
|
9
|
+
new(
|
|
10
|
+
events: container.events,
|
|
11
|
+
hook_context: Textus::Hooks::Context.for(container: container, call: call),
|
|
12
|
+
)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def initialize(events:, hook_context:)
|
|
16
|
+
@events = events
|
|
17
|
+
@hook_context = hook_context
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def started(key, mode: :sync)
|
|
21
|
+
@events.publish(:fetch_started, ctx: @hook_context, key: key, mode: mode)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def failed(key, error)
|
|
25
|
+
@events.publish(:fetch_failed, ctx: @hook_context, key: key,
|
|
26
|
+
error_class: error.class.name, error_message: error.message)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def fetched(key, envelope, change)
|
|
30
|
+
return if change == :unchanged
|
|
31
|
+
|
|
32
|
+
@events.publish(:entry_fetched, ctx: @hook_context, key: key, envelope: envelope, change: change)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def backgrounded(key, started_at:, budget_ms:)
|
|
36
|
+
payload = { key: key, started_at: started_at, budget_ms: budget_ms }
|
|
37
|
+
payload[:ctx] = @hook_context if @hook_context
|
|
38
|
+
@events.publish(:fetch_backgrounded, **payload)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -10,6 +10,7 @@ module Textus
|
|
|
10
10
|
@events = events
|
|
11
11
|
@hook_context = hook_context
|
|
12
12
|
@detached_spawner = detached_spawner || default_spawner
|
|
13
|
+
@fetch_events = Textus::Write::FetchEvents.new(events: @events, hook_context: @hook_context)
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def execute(action, key:)
|
|
@@ -82,9 +83,7 @@ module Textus
|
|
|
82
83
|
|
|
83
84
|
probe.release
|
|
84
85
|
|
|
85
|
-
|
|
86
|
-
payload[:ctx] = @hook_context if @hook_context
|
|
87
|
-
@events.publish(:fetch_backgrounded, **payload)
|
|
86
|
+
@fetch_events.backgrounded(key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms)
|
|
88
87
|
@detached_spawner.call(store_root: @store_root, key: key)
|
|
89
88
|
Textus::Domain::Outcome::Detached.new
|
|
90
89
|
elsif result.is_a?(Textus::Error)
|