textus 0.50.0 → 0.52.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 +38 -0
- data/README.md +41 -43
- data/SPEC.md +176 -176
- data/docs/architecture/README.md +46 -42
- data/docs/reference/conventions.md +31 -26
- data/lib/textus/boot.rb +15 -17
- data/lib/textus/call.rb +1 -1
- data/lib/textus/cli/runner.rb +15 -10
- data/lib/textus/cli/verb/get.rb +1 -3
- data/lib/textus/cli/verb/hook_run.rb +1 -1
- data/lib/textus/cli/verb/put.rb +4 -20
- data/lib/textus/cli/verb/serve.rb +19 -0
- data/lib/textus/cli.rb +1 -3
- data/lib/textus/dispatcher.rb +3 -3
- data/lib/textus/doctor/check/generator_drift.rb +4 -3
- data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
- data/lib/textus/doctor/check/intake_registration.rb +5 -5
- data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +13 -11
- data/lib/textus/doctor.rb +0 -2
- data/lib/textus/domain/freshness/evaluator.rb +150 -14
- data/lib/textus/domain/freshness/verdict.rb +28 -6
- data/lib/textus/domain/freshness.rb +4 -33
- data/lib/textus/domain/jobs/job.rb +58 -0
- data/lib/textus/domain/jobs/registry.rb +37 -0
- data/lib/textus/domain/policy/base_guards.rb +1 -1
- data/lib/textus/domain/policy/predicates/fresh_within.rb +1 -1
- data/lib/textus/domain/policy/publish_target.rb +34 -0
- data/lib/textus/domain/policy/retention.rb +29 -0
- data/lib/textus/domain/policy/source.rb +73 -0
- data/lib/textus/domain/retention/sweep.rb +57 -0
- data/lib/textus/domain/retention.rb +11 -0
- data/lib/textus/errors.rb +4 -4
- data/lib/textus/hooks/builtin.rb +5 -5
- data/lib/textus/hooks/catalog.rb +7 -7
- data/lib/textus/hooks/context.rb +5 -10
- data/lib/textus/init/templates/machine_intake.rb +4 -4
- data/lib/textus/init.rb +47 -47
- data/lib/textus/jobs/handlers.rb +62 -0
- data/lib/textus/jobs/scheduler.rb +36 -0
- data/lib/textus/jobs/seeder.rb +57 -0
- data/lib/textus/key/matching.rb +24 -0
- data/lib/textus/layout.rb +8 -0
- data/lib/textus/maintenance/drain.rb +42 -0
- data/lib/textus/maintenance/retention/apply.rb +52 -0
- data/lib/textus/maintenance/serve.rb +30 -0
- data/lib/textus/maintenance/worker.rb +74 -0
- data/lib/textus/manifest/capabilities.rb +1 -1
- data/lib/textus/manifest/data.rb +18 -3
- data/lib/textus/manifest/entry/base.rb +28 -9
- data/lib/textus/manifest/entry/nested.rb +3 -4
- data/lib/textus/manifest/entry/parser.rb +25 -21
- data/lib/textus/manifest/entry/produced.rb +56 -0
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +7 -6
- data/lib/textus/manifest/entry/publish/to_paths.rb +62 -11
- data/lib/textus/manifest/entry/validators/format_matrix.rb +3 -11
- data/lib/textus/manifest/entry/validators/publish.rb +3 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/manifest/policy.rb +16 -4
- data/lib/textus/manifest/resolver.rb +10 -4
- data/lib/textus/manifest/rules.rb +37 -36
- data/lib/textus/manifest/schema/keys.rb +98 -0
- data/lib/textus/manifest/schema/validator.rb +324 -0
- data/lib/textus/manifest/schema/vocabulary.rb +24 -0
- data/lib/textus/manifest/schema.rb +27 -247
- data/lib/textus/manifest.rb +5 -3
- data/lib/textus/mcp/server.rb +1 -1
- data/lib/textus/ports/audit_log.rb +6 -0
- data/lib/textus/ports/build_lock.rb +6 -0
- data/lib/textus/ports/clock.rb +4 -3
- data/lib/textus/ports/produce_on_write_subscriber.rb +73 -0
- data/lib/textus/ports/publisher.rb +11 -7
- data/lib/textus/ports/queue.rb +130 -0
- data/lib/textus/produce/acquire/handler.rb +29 -0
- data/lib/textus/produce/acquire/intake.rb +130 -0
- data/lib/textus/produce/acquire/projection.rb +127 -0
- data/lib/textus/produce/acquire/serializer/json.rb +31 -0
- data/lib/textus/produce/acquire/serializer/text.rb +16 -0
- data/lib/textus/produce/acquire/serializer/yaml.rb +31 -0
- data/lib/textus/produce/acquire/serializer.rb +17 -0
- data/lib/textus/produce/engine.rb +95 -0
- data/lib/textus/produce/events.rb +36 -0
- data/lib/textus/produce/render.rb +23 -0
- data/lib/textus/projection.rb +17 -6
- data/lib/textus/read/deps.rb +3 -3
- data/lib/textus/read/freshness.rb +61 -31
- data/lib/textus/read/get.rb +20 -102
- data/lib/textus/read/jobs.rb +31 -0
- data/lib/textus/read/rdeps.rb +3 -3
- data/lib/textus/read/rule_explain.rb +41 -23
- data/lib/textus/read/rule_list.rb +25 -8
- data/lib/textus/read/validate_all.rb +14 -0
- data/lib/textus/role.rb +2 -1
- data/lib/textus/schemas.rb +8 -0
- data/lib/textus/store.rb +1 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/enqueue.rb +50 -0
- data/lib/textus/write/put.rb +1 -1
- metadata +35 -30
- data/lib/textus/builder/pipeline.rb +0 -88
- data/lib/textus/builder/renderer/json.rb +0 -45
- data/lib/textus/builder/renderer/markdown.rb +0 -24
- data/lib/textus/builder/renderer/text.rb +0 -14
- data/lib/textus/builder/renderer/yaml.rb +0 -45
- data/lib/textus/builder/renderer.rb +0 -17
- data/lib/textus/cli/verb/boot.rb +0 -14
- data/lib/textus/cli/verb/build.rb +0 -15
- data/lib/textus/doctor/check/fetch_locks.rb +0 -49
- data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
- data/lib/textus/domain/freshness/policy.rb +0 -18
- data/lib/textus/domain/lifecycle.rb +0 -83
- data/lib/textus/domain/outcome.rb +0 -10
- data/lib/textus/domain/policy/lifecycle.rb +0 -35
- data/lib/textus/domain/staleness/generator_check.rb +0 -109
- data/lib/textus/domain/staleness.rb +0 -29
- data/lib/textus/maintenance/tend.rb +0 -110
- data/lib/textus/manifest/entry/derived.rb +0 -67
- data/lib/textus/manifest/entry/intake.rb +0 -31
- data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
- data/lib/textus/mcp/tools.rb +0 -14
- data/lib/textus/ports/fetch/detached.rb +0 -52
- data/lib/textus/ports/fetch/lock.rb +0 -44
- data/lib/textus/write/build.rb +0 -90
- data/lib/textus/write/fetch_events.rb +0 -42
- data/lib/textus/write/fetch_orchestrator.rb +0 -101
- data/lib/textus/write/fetch_worker.rb +0 -127
- data/lib/textus/write/intake_fetch.rb +0 -25
- data/lib/textus/write/materializer.rb +0 -51
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Maintenance
|
|
3
|
+
# Drains the job queue: lease a job, look up its handler in the registry, run
|
|
4
|
+
# it (as the job's stamped authority — wired in a later phase), then ack on
|
|
5
|
+
# success or fail (requeue/dead-letter) on a raise. `drain` runs until the
|
|
6
|
+
# queue is empty and returns a summary. Delivery is at-least-once.
|
|
7
|
+
class Worker
|
|
8
|
+
Summary = Struct.new(:completed, :failed, keyword_init: true)
|
|
9
|
+
|
|
10
|
+
# The standard convergence worker: the closed handler allow-list plus the
|
|
11
|
+
# lease TTL from worker_config. Both `drain` and `serve` build it this way.
|
|
12
|
+
def self.for(container:, queue:)
|
|
13
|
+
new(
|
|
14
|
+
queue: queue, registry: Textus::Jobs::Handlers.registry,
|
|
15
|
+
container: container, lease_ttl: container.manifest.data.worker_config[:lease_ttl]
|
|
16
|
+
)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(queue:, registry:, container:, lease_ttl: 60)
|
|
20
|
+
@queue = queue
|
|
21
|
+
@registry = registry
|
|
22
|
+
@container = container
|
|
23
|
+
@lease_ttl = lease_ttl
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def drain(worker_id: "drain-#{Process.pid}")
|
|
27
|
+
completed = 0
|
|
28
|
+
failed = 0
|
|
29
|
+
loop do
|
|
30
|
+
leased = @queue.lease(worker_id: worker_id, lease_ttl: @lease_ttl)
|
|
31
|
+
break unless leased
|
|
32
|
+
|
|
33
|
+
case run_one(leased)
|
|
34
|
+
when :completed then completed += 1
|
|
35
|
+
when :dead_lettered then failed += 1
|
|
36
|
+
# :requeued -> a transient failure; it re-leases on a later iteration
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
Summary.new(completed: completed, failed: failed)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def drain_pool(pool: 4)
|
|
43
|
+
summaries = []
|
|
44
|
+
mutex = Mutex.new
|
|
45
|
+
threads = Array.new(pool) do |i|
|
|
46
|
+
Thread.new do
|
|
47
|
+
s = drain(worker_id: "pool-#{Process.pid}-#{i}")
|
|
48
|
+
mutex.synchronize { summaries << s }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
threads.each(&:join)
|
|
52
|
+
Summary.new(
|
|
53
|
+
completed: summaries.sum(&:completed),
|
|
54
|
+
failed: summaries.sum(&:failed),
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
# Returns :completed on ack, or the queue's failure verdict (:requeued |
|
|
61
|
+
# :dead_lettered) on a raise. A requeued job re-leases on the next loop
|
|
62
|
+
# iteration, so a transient failure still drains; only a dead-letter is a
|
|
63
|
+
# terminal failure that counts toward the summary.
|
|
64
|
+
def run_one(leased)
|
|
65
|
+
entry = @registry.lookup(leased.job.type)
|
|
66
|
+
entry.handler.call(job: leased.job, container: @container)
|
|
67
|
+
@queue.ack(leased)
|
|
68
|
+
:completed
|
|
69
|
+
rescue StandardError => e
|
|
70
|
+
@queue.fail(leased, error: e.message)
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -13,7 +13,7 @@ module Textus
|
|
|
13
13
|
DEFAULT_MAPPING = {
|
|
14
14
|
Textus::Role::HUMAN => %w[author propose].freeze,
|
|
15
15
|
Textus::Role::AGENT => %w[propose].freeze,
|
|
16
|
-
Textus::Role::AUTOMATION => %w[
|
|
16
|
+
Textus::Role::AUTOMATION => %w[converge].freeze,
|
|
17
17
|
}.freeze
|
|
18
18
|
|
|
19
19
|
# Returns { role_name => [verbs] }. When `roles:` is declared we use
|
data/lib/textus/manifest/data.rb
CHANGED
|
@@ -10,10 +10,11 @@ module Textus
|
|
|
10
10
|
# resolution, rules) lives on Manifest::Policy / Resolver / Rules.
|
|
11
11
|
class Data
|
|
12
12
|
AUDIT_DEFAULTS = { max_size: 10_485_760, keep: 5 }.freeze
|
|
13
|
+
WORKER_DEFAULTS = { pool: 4, poll: 5, lease_ttl: 60, max_attempts: 3 }.freeze
|
|
13
14
|
|
|
14
15
|
attr_reader :raw, :root, :entries, :declared_zone_kinds,
|
|
15
16
|
:zone_descs, :zone_owners,
|
|
16
|
-
:audit_config, :role_caps, :policy
|
|
17
|
+
:audit_config, :worker_config, :role_caps, :policy
|
|
17
18
|
|
|
18
19
|
def self.validate_key!(key)
|
|
19
20
|
raise UsageError.new("empty key") if key.nil? || key.empty?
|
|
@@ -47,10 +48,11 @@ module Textus
|
|
|
47
48
|
# future `zone_owners.key?(name)` means "owner declared", not "zone exists".
|
|
48
49
|
@zone_owners = Array(raw["zones"]).to_h { |z| [z["name"], z["owner"]] }.compact
|
|
49
50
|
@audit_config = build_audit_config(raw)
|
|
51
|
+
@worker_config = build_worker_config(raw)
|
|
50
52
|
@role_caps = Capabilities.resolve(raw["roles"])
|
|
51
53
|
# Policy is constructed before entries because Entry validators
|
|
52
|
-
#
|
|
53
|
-
#
|
|
54
|
+
# use the entry's own `derived?` and similar helpers that call into
|
|
55
|
+
# Policy; Policy must exist before entries are built.
|
|
54
56
|
@policy = Policy.new(self)
|
|
55
57
|
@entries = build_entries(raw)
|
|
56
58
|
validate_declared_keys!
|
|
@@ -67,6 +69,19 @@ module Textus
|
|
|
67
69
|
}.freeze
|
|
68
70
|
end
|
|
69
71
|
|
|
72
|
+
# Worker/queue tunables (ADR: job-queue execution model). All optional;
|
|
73
|
+
# the daemon (serve) and batch drain read these, falling back to defaults
|
|
74
|
+
# so a manifest with no `worker:` block runs the queue out of the box.
|
|
75
|
+
def build_worker_config(raw)
|
|
76
|
+
w = raw["worker"] || {}
|
|
77
|
+
{
|
|
78
|
+
pool: w["pool"] || WORKER_DEFAULTS[:pool],
|
|
79
|
+
poll: w["poll"] || WORKER_DEFAULTS[:poll],
|
|
80
|
+
lease_ttl: w["lease_ttl"] || WORKER_DEFAULTS[:lease_ttl],
|
|
81
|
+
max_attempts: w["max_attempts"] || WORKER_DEFAULTS[:max_attempts],
|
|
82
|
+
}.freeze
|
|
83
|
+
end
|
|
84
|
+
|
|
70
85
|
def build_entries(raw)
|
|
71
86
|
Array(raw["entries"]).map do |e|
|
|
72
87
|
entry = Manifest::Entry::Parser.call(e)
|
|
@@ -2,10 +2,10 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
class Base < Entry
|
|
5
|
-
attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :
|
|
5
|
+
attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :publish_targets
|
|
6
6
|
|
|
7
7
|
# rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
|
|
8
|
-
def initialize(raw:, key:, path:, zone:, schema:, owner:, format:,
|
|
8
|
+
def initialize(raw:, key:, path:, zone:, schema:, owner:, format:, publish_targets: [])
|
|
9
9
|
@raw = raw
|
|
10
10
|
@key = key
|
|
11
11
|
@path = path
|
|
@@ -13,7 +13,7 @@ module Textus
|
|
|
13
13
|
@schema = schema
|
|
14
14
|
@owner = owner
|
|
15
15
|
@format = format
|
|
16
|
-
@
|
|
16
|
+
@publish_targets = Array(publish_targets)
|
|
17
17
|
end
|
|
18
18
|
# rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
|
|
19
19
|
|
|
@@ -23,28 +23,34 @@ module Textus
|
|
|
23
23
|
raise UsageError.new("entry '#{@key}': #{e.message}")
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def
|
|
27
|
-
def in_proposal_zone?(policy) = policy.queue_zone?(@zone)
|
|
26
|
+
def in_proposal_zone?(policy) = policy.queue_zone?(@zone)
|
|
28
27
|
|
|
29
28
|
def nested? = false
|
|
30
29
|
def derived? = false
|
|
31
30
|
def intake? = false
|
|
32
31
|
def leaf? = false
|
|
33
32
|
|
|
33
|
+
# Production traits. Default false on Base (a leaf/intake entry is neither
|
|
34
|
+
# an out-of-band command nor a projection); Produced overrides both from
|
|
35
|
+
# its source. Lets publish modes call these without a `respond_to?` guard.
|
|
36
|
+
def external? = false
|
|
37
|
+
def projection? = false
|
|
38
|
+
|
|
34
39
|
# Whether git should track this entry's file. Default true; an entry
|
|
35
40
|
# marked `tracked: false` in the manifest stays protocol-readable but is
|
|
36
41
|
# listed in the generated `.gitignore` (ADR 0043). Cross-cutting, so it
|
|
37
42
|
# reads from raw here rather than threading through every constructor.
|
|
38
43
|
def tracked? = @raw["tracked"] != false
|
|
39
44
|
|
|
45
|
+
# Single source of truth is @publish_targets (ADR 0094). These
|
|
46
|
+
# derive the ADR-0049/0052 views the publish modes consume.
|
|
47
|
+
def publish_to = @publish_targets.select(&:to_target?).map(&:to)
|
|
48
|
+
def publish_tree = @publish_targets.find(&:tree_target?)&.tree
|
|
49
|
+
|
|
40
50
|
# Nil stubs for cross-cutting optional attrs. Subclasses override the
|
|
41
51
|
# ones they own. Validators and serializers can call these directly
|
|
42
52
|
# without `respond_to?` guards.
|
|
43
|
-
def template = nil
|
|
44
|
-
def inject_boot = false # rubocop:disable Naming/PredicateMethod
|
|
45
|
-
def provenance = true # rubocop:disable Naming/PredicateMethod
|
|
46
53
|
def events = {}
|
|
47
|
-
def publish_tree = nil
|
|
48
54
|
def ignore = []
|
|
49
55
|
|
|
50
56
|
# Per-entry ignore (ADR 0042). Base entries enumerate no tree, so
|
|
@@ -69,6 +75,19 @@ module Textus
|
|
|
69
75
|
events.publish(event, ctx: hook_context, **payload)
|
|
70
76
|
end
|
|
71
77
|
|
|
78
|
+
# Read a named template from the store's templates/ directory.
|
|
79
|
+
# Raises TemplateError when the file doesn't exist.
|
|
80
|
+
def read_template(name)
|
|
81
|
+
path = File.join(container.root.to_s, "templates", name)
|
|
82
|
+
unless File.exist?(path)
|
|
83
|
+
raise Textus::TemplateError.new(
|
|
84
|
+
"template '#{name}' not found",
|
|
85
|
+
template_name: name,
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
File.read(path)
|
|
89
|
+
end
|
|
90
|
+
|
|
72
91
|
private
|
|
73
92
|
|
|
74
93
|
def scope_for_hooks
|
|
@@ -6,11 +6,10 @@ module Textus
|
|
|
6
6
|
# Entry::Publish::* — Nested is just the value (attributes + ignore
|
|
7
7
|
# predicate) those modes read.
|
|
8
8
|
class Nested < Base
|
|
9
|
-
attr_reader :
|
|
9
|
+
attr_reader :ignore
|
|
10
10
|
|
|
11
|
-
def initialize(
|
|
11
|
+
def initialize(ignore: nil, **rest)
|
|
12
12
|
super(**rest)
|
|
13
|
-
@publish_tree = publish_tree
|
|
14
13
|
@ignore = Array(ignore)
|
|
15
14
|
end
|
|
16
15
|
|
|
@@ -24,8 +23,8 @@ module Textus
|
|
|
24
23
|
KIND = :nested
|
|
25
24
|
|
|
26
25
|
def self.from_raw(common, raw)
|
|
26
|
+
# publish_tree is derived from publish_targets (ADR 0094) via Base#publish_tree
|
|
27
27
|
new(
|
|
28
|
-
publish_tree: raw.dig("publish", "tree"), # ADR 0052: typed publish block
|
|
29
28
|
ignore: raw["ignore"],
|
|
30
29
|
**common,
|
|
31
30
|
)
|
|
@@ -2,8 +2,6 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
module Parser
|
|
5
|
-
COMPUTE_KINDS = %w[projection external].freeze
|
|
6
|
-
|
|
7
5
|
def self.call(raw)
|
|
8
6
|
key = raw["key"] or raise UsageError.new("manifest entry missing key")
|
|
9
7
|
path = raw["path"] or raise UsageError.new("manifest entry '#{key}' missing path")
|
|
@@ -11,6 +9,12 @@ module Textus
|
|
|
11
9
|
|
|
12
10
|
raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (#{Entry::REGISTRY.keys.join("|")})")
|
|
13
11
|
kind = raw_kind.to_sym
|
|
12
|
+
if %i[derived intake].include?(kind)
|
|
13
|
+
raise BadManifest.new(
|
|
14
|
+
"entry '#{key}': kind: #{kind} was collapsed into `kind: produced` (ADR 0095) — " \
|
|
15
|
+
"the produce method is `source.from` (#{kind == :intake ? "handler" : "project|command"})",
|
|
16
|
+
)
|
|
17
|
+
end
|
|
14
18
|
format = resolve_format(raw, path)
|
|
15
19
|
|
|
16
20
|
common = {
|
|
@@ -18,10 +22,7 @@ module Textus
|
|
|
18
22
|
key: key, path: path, zone: zone,
|
|
19
23
|
schema: raw["schema"], owner: raw["owner"],
|
|
20
24
|
format: format,
|
|
21
|
-
|
|
22
|
-
# publish_to/publish_tree readers (the ADR 0049 modes) are sourced
|
|
23
|
-
# from it (publish_to <- publish.to, publish_tree <- publish.tree).
|
|
24
|
-
publish_to: raw.dig("publish", "to")
|
|
25
|
+
publish_targets: publish_targets(raw)
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
klass = Entry::REGISTRY[kind] or
|
|
@@ -29,26 +30,29 @@ module Textus
|
|
|
29
30
|
klass.from_raw(common, raw)
|
|
30
31
|
end
|
|
31
32
|
|
|
33
|
+
# ADR 0093: an entry's production block is the unified `source:`. Returns a
|
|
34
|
+
# Domain::Policy::Source; kind (intake/derived) is read from source.from.
|
|
32
35
|
def self.parse_source(raw, key)
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
block = raw["source"] or
|
|
37
|
+
raise BadManifest.new("entry '#{key}' requires a source: { from: project|handler|command, ... }")
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"entry '#{key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{compute["kind"].inspect})",
|
|
39
|
-
)
|
|
40
|
-
end
|
|
39
|
+
Textus::Domain::Policy::Source.new(block)
|
|
40
|
+
end
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
# ADR 0094: `publish:` is a LIST of target objects — to-targets
|
|
43
|
+
# [{to, template?, inject_boot?}] and/or a tree-target [{tree}]. The
|
|
44
|
+
# ADR-0052 map forms ({to: […]} / {tree: …}) are retired.
|
|
45
|
+
def self.publish_targets(raw)
|
|
46
|
+
block = raw["publish"]
|
|
47
|
+
return [] if block.nil?
|
|
48
|
+
|
|
49
|
+
unless block.is_a?(Array)
|
|
50
|
+
raise BadManifest.new(
|
|
51
|
+
"entry '#{raw["key"]}': `publish:` must be a list of targets " \
|
|
52
|
+
"[{to:, template:?} | {tree:}] (ADR 0094); the `publish: { … }` map form was retired",
|
|
48
53
|
)
|
|
49
|
-
else
|
|
50
|
-
Entry::Derived::External.new(sources: compute["sources"], command: compute["command"])
|
|
51
54
|
end
|
|
55
|
+
block.map { |t| Textus::Domain::Policy::PublishTarget.new(t) }
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
def self.resolve_format(raw, path)
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Entry
|
|
4
|
+
# A produced entry (ADR 0095) — anything with a `source:`. The produce
|
|
5
|
+
# method (intake/derived/external) is read from source.from; there is no
|
|
6
|
+
# separate kind for it. Merges the former Derived + Intake classes.
|
|
7
|
+
class Produced < Base
|
|
8
|
+
attr_reader :source, :events
|
|
9
|
+
|
|
10
|
+
def initialize(source:, events: {}, **rest)
|
|
11
|
+
super(**rest)
|
|
12
|
+
@source = source
|
|
13
|
+
@events = events || {}
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def intake? = @source.kind == :intake
|
|
17
|
+
def derived? = @source.kind == :derived
|
|
18
|
+
def external? = @source.external?
|
|
19
|
+
def projection? = @source.projection?
|
|
20
|
+
def nested? = !!@raw["nested"]
|
|
21
|
+
def handler = @source.handler
|
|
22
|
+
def config = @source.config
|
|
23
|
+
|
|
24
|
+
KIND = :produced
|
|
25
|
+
|
|
26
|
+
# ADR 0094/0095: projection (from: project) sources build their DATA
|
|
27
|
+
# artifact here, then publish via the ONE shared mode (Publish::ToPaths).
|
|
28
|
+
# Intake bytes come from Produce::Acquire::Intake and command (external) bytes from the
|
|
29
|
+
# out-of-band runner — neither builds, but both still publish their
|
|
30
|
+
# existing store bytes through the same mode. A projection entry with no
|
|
31
|
+
# targets is a terminal data node: it produced data, so report :built
|
|
32
|
+
# even though nothing was emitted.
|
|
33
|
+
def publish_via(pctx, prefix: nil)
|
|
34
|
+
built = false
|
|
35
|
+
if projection?
|
|
36
|
+
Textus::Produce::Acquire::Projection.new(container: pctx.container, call: pctx.call).run(self)
|
|
37
|
+
built = true
|
|
38
|
+
pctx.emit(:entry_produced, key: @key, envelope: pctx.reader.call(@key), sources: Array(@source.select).compact)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
emitted = publish_mode.publish(pctx, prefix: prefix)
|
|
42
|
+
return emitted if emitted
|
|
43
|
+
return nil unless built
|
|
44
|
+
|
|
45
|
+
{ kind: :built, value: { "key" => @key, "path" => Key::Path.resolve(pctx.manifest.data, self), "published_to" => [] } }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.from_raw(common, raw)
|
|
49
|
+
new(source: Parser.parse_source(raw, common[:key]), events: raw["events"] || {}, **common)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
Entry::REGISTRY[KIND] = self
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -9,9 +9,10 @@ module Textus
|
|
|
9
9
|
# shared shape — Tree always walks at `base` and honors `ignore` in the
|
|
10
10
|
# prune (ADR 0047 D4, so a derived index in the mirrored dir survives).
|
|
11
11
|
class SubtreeMirror
|
|
12
|
-
def initialize(entry, pctx)
|
|
13
|
-
@entry
|
|
14
|
-
@pctx
|
|
12
|
+
def initialize(entry, pctx, publisher: Textus::Ports::Publisher.new)
|
|
13
|
+
@entry = entry
|
|
14
|
+
@pctx = pctx
|
|
15
|
+
@publisher = publisher
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
# base: store dir the entry owns — the root `ignored?` globs are
|
|
@@ -40,8 +41,8 @@ module Textus
|
|
|
40
41
|
next nil if @entry.ignored?(relative(src, base))
|
|
41
42
|
|
|
42
43
|
dst = File.join(target_dir, relative(src, walk_root))
|
|
43
|
-
|
|
44
|
-
@pctx.emit(:
|
|
44
|
+
@publisher.publish(source: src, target: dst, store_root: @pctx.root)
|
|
45
|
+
@pctx.emit(:entry_published, key: key, envelope: envelope, source: src, target: dst)
|
|
45
46
|
{ "key" => key, "source" => src, "target" => dst }
|
|
46
47
|
end
|
|
47
48
|
end
|
|
@@ -57,7 +58,7 @@ module Textus
|
|
|
57
58
|
next nil if kept.include?(abs)
|
|
58
59
|
next nil if honor_ignore && @entry.ignored?(relative(abs, target_dir))
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
@publisher.unpublish(target: managed, store_root: @pctx.root)
|
|
61
62
|
managed
|
|
62
63
|
end
|
|
63
64
|
end
|
|
@@ -1,24 +1,75 @@
|
|
|
1
|
+
require "tempfile"
|
|
2
|
+
|
|
1
3
|
module Textus
|
|
2
4
|
class Manifest
|
|
3
5
|
class Entry
|
|
4
6
|
module Publish
|
|
5
|
-
# publish.to: copy the entry's
|
|
6
|
-
# The behaviour of any entry that declares `publish: { to:
|
|
7
|
+
# publish.to: render or copy the entry's stored data to each fixed repo path.
|
|
8
|
+
# The behaviour of any entry that declares `publish: [{ to: ... }, ...]`.
|
|
9
|
+
# ADR 0094: iterates publish_targets (to-targets), rendering through a
|
|
10
|
+
# template when the target declares one, or copying verbatim otherwise.
|
|
7
11
|
class ToPaths < Mode
|
|
8
|
-
def
|
|
9
|
-
|
|
12
|
+
def initialize(entry, publisher: Textus::Ports::Publisher.new)
|
|
13
|
+
super(entry)
|
|
14
|
+
@publisher = publisher
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument,Metrics/AbcSize
|
|
18
|
+
targets = entry.publish_targets.select(&:to_target?)
|
|
10
19
|
return nil if targets.empty?
|
|
11
20
|
|
|
12
|
-
|
|
13
|
-
envelope
|
|
21
|
+
data_path = pctx.manifest.resolver.resolve(entry.key).path
|
|
22
|
+
envelope = pctx.reader.call(entry.key)
|
|
23
|
+
renderer = Textus::Produce::Render.new(template_loader: ->(n) { pctx.read_template(n) })
|
|
24
|
+
content = nil # parsed lazily; the data's `content` (always _meta-free)
|
|
14
25
|
|
|
15
|
-
targets.each do |
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
26
|
+
targets.each do |t|
|
|
27
|
+
if t.renders?
|
|
28
|
+
content ||= Textus::Entry.for_format(entry.format).parse(File.read(data_path), path: data_path)["content"]
|
|
29
|
+
publish_bytes(render_bytes(t, content, renderer, pctx), entry.key, t, pctx, data_path, envelope)
|
|
30
|
+
elsif strip_meta?(entry)
|
|
31
|
+
content ||= Textus::Entry.for_format(entry.format).parse(File.read(data_path), path: data_path)["content"]
|
|
32
|
+
bytes = Textus::Entry.for_format(entry.format).serialize(meta: {}, body: "", content: content)
|
|
33
|
+
publish_bytes(bytes, entry.key, t, pctx, data_path, envelope)
|
|
34
|
+
else
|
|
35
|
+
# opaque / command / non-structured — publish the stored file as-is
|
|
36
|
+
target_abs = File.join(pctx.repo_root, t.to)
|
|
37
|
+
@publisher.publish(source: data_path, target: target_abs, store_root: pctx.root)
|
|
38
|
+
pctx.emit(:entry_published, key: entry.key, envelope: envelope, source: data_path, target: target_abs)
|
|
39
|
+
end
|
|
19
40
|
end
|
|
20
41
|
|
|
21
|
-
{ kind: :built, value: { "key" => entry.key, "path" =>
|
|
42
|
+
{ kind: :built, value: { "key" => entry.key, "path" => data_path, "published_to" => targets.map(&:to) } }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
private
|
|
46
|
+
|
|
47
|
+
# A structured-data entry that textus owns: its `_meta` stays in the
|
|
48
|
+
# store, so the published file is the re-serialized meta-free content.
|
|
49
|
+
# An external (command) entry is opaque — never parse/re-serialize it.
|
|
50
|
+
def strip_meta?(entry)
|
|
51
|
+
!entry.external? && %w[json yaml].include?(entry.format.to_s)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def render_bytes(target, content, renderer, pctx)
|
|
55
|
+
boot = target.inject_boot ? Textus::Boot.build(container: pctx.container) : nil
|
|
56
|
+
renderer.bytes_for(target: target, data: content, boot: boot)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Write bytes to a system temp, publish (recording the persistent data
|
|
60
|
+
# file as the sentinel source), then remove the temp — the store is
|
|
61
|
+
# never polluted with render artifacts.
|
|
62
|
+
def publish_bytes(bytes, key, target, pctx, data_path, envelope)
|
|
63
|
+
target_abs = File.join(pctx.repo_root, target.to)
|
|
64
|
+
Tempfile.create(["textus-publish", File.extname(target.to)]) do |f|
|
|
65
|
+
f.binmode
|
|
66
|
+
f.write(bytes)
|
|
67
|
+
f.flush
|
|
68
|
+
@publisher.publish(
|
|
69
|
+
source: f.path, target: target_abs, store_root: pctx.root, provenance_source: data_path,
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
pctx.emit(:entry_published, key: key, envelope: envelope, source: data_path, target: target_abs)
|
|
22
73
|
end
|
|
23
74
|
end
|
|
24
75
|
end
|
|
@@ -3,24 +3,16 @@ module Textus
|
|
|
3
3
|
class Entry
|
|
4
4
|
module Validators
|
|
5
5
|
module FormatMatrix
|
|
6
|
-
def self.call(entry, policy:)
|
|
6
|
+
def self.call(entry, policy:) # rubocop:disable Lint/UnusedMethodArgument
|
|
7
7
|
begin
|
|
8
8
|
Textus::Entry.for_format(entry.format).validate_path_extension(entry.path, entry.nested?)
|
|
9
9
|
rescue UsageError => e
|
|
10
10
|
raise UsageError.new("entry '#{entry.key}': #{e.message}")
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
has_template = !entry.template.nil?
|
|
18
|
-
is_external = entry.derived? && entry.external?
|
|
19
|
-
is_intake = entry.intake?
|
|
20
|
-
return unless entry.in_generator_zone?(policy) && !has_template && !is_external && !is_intake &&
|
|
21
|
-
%w[markdown text].include?(entry.format) && !entry.nested?
|
|
13
|
+
return unless entry.format == "text" && !entry.schema.nil?
|
|
22
14
|
|
|
23
|
-
raise UsageError.new("entry '#{entry.key}':
|
|
15
|
+
raise UsageError.new("entry '#{entry.key}': text format must not declare a schema")
|
|
24
16
|
end
|
|
25
17
|
end
|
|
26
18
|
end
|
|
@@ -14,7 +14,9 @@ module Textus
|
|
|
14
14
|
module Publish
|
|
15
15
|
def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
16
16
|
unless entry.nested?
|
|
17
|
-
|
|
17
|
+
# ADR 0094: publish: is now a list; use publish_tree (derived reader)
|
|
18
|
+
# rather than raw.dig("publish", "tree") which breaks on an Array.
|
|
19
|
+
raise UsageError.new("entry '#{entry.key}': publish.tree requires nested: true") if entry.publish_tree
|
|
18
20
|
|
|
19
21
|
return
|
|
20
22
|
end
|
|
@@ -7,7 +7,7 @@ module Textus
|
|
|
7
7
|
# (Schema::KIND_REQUIRES_VERB) and a role may write a zone iff its caps
|
|
8
8
|
# include that verb (verb_for_zone, roles_with_capability). Derived /
|
|
9
9
|
# proposal-queue status is authoritative via the declared-kind family
|
|
10
|
-
# (declared_kind,
|
|
10
|
+
# (declared_kind, derived_entry?, queue_zone?, queue_zone).
|
|
11
11
|
class Policy
|
|
12
12
|
def initialize(data)
|
|
13
13
|
@data = data
|
|
@@ -72,9 +72,21 @@ module Textus
|
|
|
72
72
|
@data.declared_zone_kinds.key(:queue)
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
#
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
# ADR 0091: derived-ness is a property of the ENTRY, not its zone (one
|
|
76
|
+
# machine zone holds both intake and derived entries). Resolve the entry
|
|
77
|
+
# and ask it directly. Returns false if entries are not yet built
|
|
78
|
+
# (validator phase during Data#initialize) — validators must not rely on
|
|
79
|
+
# cross-entry state during construction.
|
|
80
|
+
def derived_entry?(key)
|
|
81
|
+
return false if @data.entries.nil?
|
|
82
|
+
|
|
83
|
+
entry = @data.entries.find { |e| e.key == key } or return false
|
|
84
|
+
entry.derived?
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# The single zone declaring kind: machine, or nil.
|
|
88
|
+
def machine_zone
|
|
89
|
+
@data.declared_zone_kinds.key(:machine)
|
|
78
90
|
end
|
|
79
91
|
|
|
80
92
|
# A zone is a proposal queue iff it declares kind: queue.
|
|
@@ -30,8 +30,10 @@ module Textus
|
|
|
30
30
|
[]
|
|
31
31
|
end
|
|
32
32
|
|
|
33
|
-
def enumerate(prefix: nil)
|
|
34
|
-
out = @data.entries.flat_map
|
|
33
|
+
def enumerate(prefix: nil, include_keyless: false)
|
|
34
|
+
out = @data.entries.flat_map do |entry|
|
|
35
|
+
nested_entry?(entry) ? enumerate_nested(entry, include_keyless: include_keyless) : enumerate_leaf(entry)
|
|
36
|
+
end
|
|
35
37
|
out.select! { |row| row[:key] == prefix || row[:key].start_with?("#{prefix}.") } if prefix
|
|
36
38
|
out.sort_by { |row| row[:key] }
|
|
37
39
|
end
|
|
@@ -62,10 +64,14 @@ module Textus
|
|
|
62
64
|
File.exist?(fp) ? [{ key: entry.key, path: fp, manifest_entry: entry }] : []
|
|
63
65
|
end
|
|
64
66
|
|
|
65
|
-
def enumerate_nested(entry)
|
|
67
|
+
def enumerate_nested(entry, include_keyless: false)
|
|
66
68
|
# publish_tree mirrors opaque payload by path — its files are never
|
|
67
69
|
# enumerated as keys (ADR 0047). Ask the resolved mode, not the path.
|
|
68
|
-
|
|
70
|
+
# The `include_keyless:` override is used only by the projection lister
|
|
71
|
+
# so that `from: project` selects can read source data from keyless
|
|
72
|
+
# nested entries (e.g. knowledge.decisions) without exposing them as
|
|
73
|
+
# addressable store keys in the public `list` surface.
|
|
74
|
+
return [] if entry.publish_mode.keyless? && !include_keyless
|
|
69
75
|
|
|
70
76
|
base = File.join(@data.root, "zones", entry.path)
|
|
71
77
|
return [] unless File.directory?(base)
|