textus 0.53.0 → 0.54.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 +34 -0
- data/SPEC.md +2 -5
- data/docs/architecture/README.md +149 -159
- data/lib/textus/action/accept.rb +0 -9
- data/lib/textus/action/audit.rb +0 -2
- data/lib/textus/action/blame.rb +0 -2
- data/lib/textus/action/boot.rb +2 -11
- data/lib/textus/action/data_mv.rb +1 -3
- data/lib/textus/action/deps.rb +1 -15
- data/lib/textus/action/doctor.rb +0 -2
- data/lib/textus/action/drain.rb +4 -6
- data/lib/textus/action/enqueue.rb +3 -5
- data/lib/textus/action/get.rb +1 -3
- data/lib/textus/action/ingest.rb +135 -0
- data/lib/textus/action/jobs.rb +1 -3
- data/lib/textus/action/key_delete.rb +0 -10
- data/lib/textus/action/key_delete_prefix.rb +1 -3
- data/lib/textus/action/key_mv.rb +1 -15
- data/lib/textus/action/key_mv_prefix.rb +1 -3
- data/lib/textus/action/list.rb +0 -2
- data/lib/textus/action/propose.rb +0 -2
- data/lib/textus/action/published.rb +0 -2
- data/lib/textus/action/pulse.rb +7 -38
- data/lib/textus/action/put.rb +1 -12
- data/lib/textus/action/rdeps.rb +2 -12
- data/lib/textus/action/reject.rb +0 -12
- data/lib/textus/action/rule_explain.rb +0 -4
- data/lib/textus/action/rule_lint.rb +1 -3
- data/lib/textus/action/rule_list.rb +0 -4
- data/lib/textus/action/schema_envelope.rb +0 -2
- data/lib/textus/action/uid.rb +0 -2
- data/lib/textus/action/where.rb +0 -2
- data/lib/textus/action/write_verb.rb +4 -18
- data/lib/textus/boot.rb +33 -109
- data/lib/textus/command.rb +6 -1
- data/lib/textus/container.rb +1 -1
- data/lib/textus/core/freshness/evaluator.rb +18 -34
- data/lib/textus/doctor/check/notebook_sources.rb +50 -0
- data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
- data/lib/textus/doctor/check.rb +0 -1
- data/lib/textus/doctor.rb +2 -42
- data/lib/textus/envelope/reader.rb +46 -0
- data/lib/textus/envelope/writer.rb +209 -0
- data/lib/textus/errors.rb +8 -4
- data/lib/textus/{entry → format}/base.rb +1 -6
- data/lib/textus/{entry → format}/json.rb +1 -4
- data/lib/textus/{entry → format}/markdown.rb +2 -6
- data/lib/textus/{entry → format}/text.rb +2 -6
- data/lib/textus/{entry → format}/yaml.rb +1 -4
- data/lib/textus/{entry.rb → format.rb} +9 -10
- data/lib/textus/gate/auth.rb +58 -12
- data/lib/textus/gate.rb +2 -0
- data/lib/textus/init.rb +12 -75
- data/lib/textus/jobs/base.rb +23 -0
- data/lib/textus/jobs/materialize.rb +20 -0
- data/lib/textus/{background → jobs}/plan.rb +1 -1
- data/lib/textus/jobs/planner.rb +101 -0
- data/lib/textus/jobs/retention.rb +48 -0
- data/lib/textus/jobs/sweep.rb +27 -0
- data/lib/textus/{background → jobs}/worker.rb +2 -2
- data/lib/textus/jobs.rb +15 -0
- data/lib/textus/key/path.rb +1 -1
- data/lib/textus/manifest/entry/base.rb +4 -19
- data/lib/textus/manifest/entry/parser.rb +27 -10
- data/lib/textus/manifest/entry/produced.rb +13 -34
- data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
- data/lib/textus/manifest/entry/publish.rb +0 -12
- data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/manifest/policy/source.rb +14 -62
- data/lib/textus/manifest/policy.rb +2 -5
- data/lib/textus/manifest/resolver.rb +2 -2
- data/lib/textus/manifest/schema/keys.rb +6 -20
- data/lib/textus/manifest/schema/validator.rb +3 -11
- data/lib/textus/manifest/schema/vocabulary.rb +1 -0
- data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
- data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
- data/lib/textus/produce/engine.rb +50 -0
- data/lib/textus/produce/render.rb +18 -0
- data/lib/textus/session.rb +1 -1
- data/lib/textus/store.rb +1 -10
- data/lib/textus/surfaces/mcp/catalog.rb +1 -1
- data/lib/textus/surfaces/mcp/errors.rb +0 -9
- data/lib/textus/surfaces/mcp/routing.rb +51 -0
- data/lib/textus/surfaces/mcp/server.rb +101 -62
- data/lib/textus/surfaces/role_scope.rb +0 -4
- data/lib/textus/surfaces/watcher.rb +3 -3
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/collector.rb +27 -0
- data/lib/textus/workflow/context.rb +5 -0
- data/lib/textus/workflow/dsl.rb +44 -0
- data/lib/textus/workflow/loader.rb +17 -0
- data/lib/textus/workflow/pattern.rb +18 -0
- data/lib/textus/workflow/registry.rb +21 -0
- data/lib/textus/workflow/runner.rb +84 -0
- data/lib/textus/workflow.rb +28 -0
- data/lib/textus.rb +12 -1
- metadata +33 -61
- data/lib/textus/action/pulse/scanner.rb +0 -118
- data/lib/textus/background/job/base.rb +0 -27
- data/lib/textus/background/job/materialize.rb +0 -31
- data/lib/textus/background/job/refresh.rb +0 -22
- data/lib/textus/background/job/sweep.rb +0 -31
- data/lib/textus/background/job.rb +0 -19
- data/lib/textus/background/planner/plan.rb +0 -113
- data/lib/textus/background/retention/apply.rb +0 -50
- data/lib/textus/doctor/check/handler_permit.rb +0 -34
- data/lib/textus/doctor/check/hooks.rb +0 -25
- data/lib/textus/doctor/check/intake_registration.rb +0 -46
- data/lib/textus/envelope/io/reader.rb +0 -48
- data/lib/textus/envelope/io/writer.rb +0 -211
- data/lib/textus/events.rb +0 -21
- data/lib/textus/init/templates/machine_intake.rb +0 -49
- data/lib/textus/init/templates/orientation_reducer.rb +0 -21
- data/lib/textus/manifest/entry/validators/events.rb +0 -22
- data/lib/textus/manifest/policy/handler_permit.rb +0 -17
- data/lib/textus/pipeline/acquire/handler.rb +0 -29
- data/lib/textus/pipeline/acquire/intake.rb +0 -132
- data/lib/textus/pipeline/acquire/projection.rb +0 -129
- data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
- data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
- data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
- data/lib/textus/pipeline/acquire/serializer.rb +0 -17
- data/lib/textus/pipeline/engine.rb +0 -97
- data/lib/textus/pipeline/render.rb +0 -25
- data/lib/textus/ports/audit_subscriber.rb +0 -42
- data/lib/textus/projection.rb +0 -103
- data/lib/textus/step/base.rb +0 -35
- data/lib/textus/step/builtin/csv_fetch.rb +0 -19
- data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
- data/lib/textus/step/builtin/json_fetch.rb +0 -18
- data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
- data/lib/textus/step/builtin/rss_fetch.rb +0 -26
- data/lib/textus/step/builtin.rb +0 -22
- data/lib/textus/step/catalog.rb +0 -37
- data/lib/textus/step/context.rb +0 -74
- data/lib/textus/step/discovery.rb +0 -24
- data/lib/textus/step/error_log.rb +0 -32
- data/lib/textus/step/event_bus.rb +0 -113
- data/lib/textus/step/fetch.rb +0 -13
- data/lib/textus/step/fire_report.rb +0 -23
- data/lib/textus/step/loader.rb +0 -108
- data/lib/textus/step/observe.rb +0 -31
- data/lib/textus/step/registry_store.rb +0 -66
- data/lib/textus/step/signature.rb +0 -31
- data/lib/textus/step/transform.rb +0 -12
- data/lib/textus/step/validate.rb +0 -11
- data/lib/textus/step.rb +0 -10
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Jobs
|
|
5
|
+
class Materialize < Base
|
|
6
|
+
TYPE = "materialize"
|
|
7
|
+
|
|
8
|
+
def initialize(key:)
|
|
9
|
+
super()
|
|
10
|
+
@key = key
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def args = { key: @key }
|
|
14
|
+
|
|
15
|
+
def call(container:, call:)
|
|
16
|
+
Textus::Produce::Engine.converge(container: container, call: call, keys: [@key])
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Jobs
|
|
5
|
+
class Planner
|
|
6
|
+
ACTIONS_BY_TRIGGER = {
|
|
7
|
+
"convergence" => %w[materialize sweep],
|
|
8
|
+
"entry.written" => %w[materialize],
|
|
9
|
+
"entry.deleted" => %w[materialize],
|
|
10
|
+
"entry.moved" => %w[materialize],
|
|
11
|
+
"proposal.accepted" => %w[materialize],
|
|
12
|
+
"proposal.rejected" => %w[materialize],
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
SCOPE_RESOLVERS = {
|
|
16
|
+
"materialize" => :producible_keys,
|
|
17
|
+
"sweep" => :lane_keys,
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def self.seed(container:, queue:, role:)
|
|
21
|
+
jobs = new(container: container).plan(
|
|
22
|
+
trigger: { "type" => "convergence" },
|
|
23
|
+
role: role,
|
|
24
|
+
)
|
|
25
|
+
jobs.each { |j| queue.enqueue(j) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(container:)
|
|
29
|
+
@container = container
|
|
30
|
+
@manifest = container.manifest
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def plan(trigger:, role:)
|
|
34
|
+
type = trigger["type"] || trigger[:type]
|
|
35
|
+
trigger["target"] || trigger[:target]
|
|
36
|
+
return [] if type.nil?
|
|
37
|
+
|
|
38
|
+
blocks_with_react = @manifest.rules.blocks.select(&:react)
|
|
39
|
+
if blocks_with_react.any?
|
|
40
|
+
plan_from_rules(blocks_with_react, type, role)
|
|
41
|
+
else
|
|
42
|
+
plan_from_defaults(type, role)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def plan_from_rules(blocks, type, role)
|
|
49
|
+
jobs = []
|
|
50
|
+
blocks
|
|
51
|
+
.select { |b| matches_trigger?(b.react, type) }
|
|
52
|
+
.each do |block|
|
|
53
|
+
do_action = block.react.raw["do"]
|
|
54
|
+
Array(do_action).each do |action|
|
|
55
|
+
if action == "sweep"
|
|
56
|
+
jobs << Textus::Ports::JobStore::Job.new(
|
|
57
|
+
type: "sweep", args: { "scope" => {} }, enqueued_by: role,
|
|
58
|
+
)
|
|
59
|
+
else
|
|
60
|
+
resolver = SCOPE_RESOLVERS.fetch(action, :producible_keys)
|
|
61
|
+
keys = send(resolver, nil)
|
|
62
|
+
keys.each { |key| jobs << job(action, key, role) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
jobs
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def plan_from_defaults(type, role)
|
|
70
|
+
actions = ACTIONS_BY_TRIGGER.fetch(type, [])
|
|
71
|
+
jobs = []
|
|
72
|
+
producible_keys(nil).each { |k| jobs << job("materialize", k, role) } if actions.include?("materialize")
|
|
73
|
+
if actions.include?("sweep")
|
|
74
|
+
jobs << Textus::Ports::JobStore::Job.new(
|
|
75
|
+
type: "sweep", args: { "scope" => {} }, enqueued_by: role,
|
|
76
|
+
)
|
|
77
|
+
end
|
|
78
|
+
jobs
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def matches_trigger?(react, type)
|
|
82
|
+
on = react.raw["on"]
|
|
83
|
+
Array(on).include?(type)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def job(type, key, enqueued_by)
|
|
87
|
+
Textus::Ports::JobStore::Job.new(type: type, args: { "key" => key }, enqueued_by: enqueued_by)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def producible_keys(_target)
|
|
91
|
+
@manifest.data.entries
|
|
92
|
+
.select { |e| !e.publish_tree.nil? || !e.publish_to.empty? }
|
|
93
|
+
.map(&:key)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def lane_keys(_target)
|
|
97
|
+
@manifest.data.entries.map(&:key)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Jobs
|
|
5
|
+
class Retention
|
|
6
|
+
def initialize(container:, call:)
|
|
7
|
+
@container = container
|
|
8
|
+
@call = call
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(rows)
|
|
12
|
+
out = { dropped: [], archived: [], failed: [] }
|
|
13
|
+
rows.each do |row|
|
|
14
|
+
key = row["key"]
|
|
15
|
+
begin
|
|
16
|
+
case row["action"]
|
|
17
|
+
when "drop"
|
|
18
|
+
delete(key)
|
|
19
|
+
out[:dropped] << key
|
|
20
|
+
when "archive"
|
|
21
|
+
archive_leaf(row)
|
|
22
|
+
delete(key)
|
|
23
|
+
out[:archived] << key
|
|
24
|
+
end
|
|
25
|
+
rescue Textus::Error => e
|
|
26
|
+
out[:failed] << { "key" => key, "error" => e.message }
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
out
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def archive_leaf(row)
|
|
35
|
+
src = row["path"]
|
|
36
|
+
root = @container.root.to_s
|
|
37
|
+
rel = src.delete_prefix("#{root}/")
|
|
38
|
+
dest = File.join(root, "archive", rel)
|
|
39
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
40
|
+
FileUtils.cp(src, dest)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def delete(key)
|
|
44
|
+
Textus::Action::KeyDelete.new(key: key).call(container: @container, call: @call)
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Jobs
|
|
3
|
+
class Sweep < Base
|
|
4
|
+
REQUIRED_ROLE = Textus::Role::AUTOMATION
|
|
5
|
+
TYPE = "sweep"
|
|
6
|
+
|
|
7
|
+
def initialize(scope: nil, key: nil)
|
|
8
|
+
super()
|
|
9
|
+
@scope = scope || {}
|
|
10
|
+
@key = key
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def args = { scope: @scope, key: @key }.compact
|
|
14
|
+
|
|
15
|
+
def call(container:, call:)
|
|
16
|
+
prefix = @key || (@scope.is_a?(Hash) ? @scope["prefix"] : nil)
|
|
17
|
+
lane = @scope.is_a?(Hash) ? @scope["lane"] : nil
|
|
18
|
+
rows = Textus::Core::Retention::Sweep.new(
|
|
19
|
+
manifest: container.manifest,
|
|
20
|
+
file_stat: Textus::Ports::Storage::FileStat.new,
|
|
21
|
+
clock: Textus::Ports::Clock.new,
|
|
22
|
+
).call(prefix: prefix, lane: lane)
|
|
23
|
+
Textus::Jobs::Retention.new(container: container, call: call).call(rows)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Jobs
|
|
3
3
|
class Worker
|
|
4
4
|
Summary = Struct.new(:completed, :failed, keyword_init: true)
|
|
5
5
|
|
|
@@ -46,7 +46,7 @@ module Textus
|
|
|
46
46
|
|
|
47
47
|
def run_one(leased)
|
|
48
48
|
job = leased.job
|
|
49
|
-
klass = Textus::
|
|
49
|
+
klass = Textus::Jobs.fetch(job.type)
|
|
50
50
|
action = if klass.instance_method(:initialize).parameters.any?
|
|
51
51
|
klass.new(**job.args.transform_keys(&:to_sym))
|
|
52
52
|
else
|
data/lib/textus/jobs.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Jobs
|
|
3
|
+
@registry = {}
|
|
4
|
+
|
|
5
|
+
def self.registry = @registry
|
|
6
|
+
|
|
7
|
+
def self.register(klass)
|
|
8
|
+
@registry[klass::TYPE] = klass if klass.const_defined?(:TYPE, false)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.fetch(type)
|
|
12
|
+
@registry.fetch(type) { raise Textus::UsageError.new("unknown job type: #{type}") }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/textus/key/path.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Textus
|
|
|
9
9
|
# callers historically passed the whole Manifest but should now pass
|
|
10
10
|
# `manifest.data`.
|
|
11
11
|
def self.resolve(data, mentry)
|
|
12
|
-
primary_ext =
|
|
12
|
+
primary_ext = Format.for(mentry.format).extensions.first
|
|
13
13
|
rel_path = normalize_relative_path(mentry.path)
|
|
14
14
|
if File.extname(mentry.path) == ""
|
|
15
15
|
File.join(data.root, rel_path + primary_ext)
|
|
@@ -62,22 +62,15 @@ module Textus
|
|
|
62
62
|
def ignored?(_rel_path) = false
|
|
63
63
|
|
|
64
64
|
# Minimal context object passed into entry `publish_via` hooks.
|
|
65
|
-
# Everything beyond the three primitives is derived.
|
|
66
|
-
# instances are frozen, so we recompute per-call rather than
|
|
67
|
-
# memoizing — RoleScope/Step::Context construction is cheap.
|
|
65
|
+
# Everything beyond the three primitives is derived.
|
|
68
66
|
PublishContext = ::Data.define(:container, :call, :reader) do
|
|
69
67
|
def manifest = container.manifest
|
|
70
68
|
def root = container.root
|
|
71
69
|
def repo_root = File.dirname(container.root)
|
|
72
|
-
def steps = container.steps
|
|
73
70
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def emit(event, **payload)
|
|
79
|
-
steps.publish(event, ctx: hook_context, **payload)
|
|
80
|
-
end
|
|
71
|
+
# No-op: event bus removed in workflow redesign; callers that fire
|
|
72
|
+
# :entry_published / :entry_produced remain unchanged in the source.
|
|
73
|
+
def emit(_event, **_payload) = nil
|
|
81
74
|
|
|
82
75
|
# Read a named template from the store's templates/ directory.
|
|
83
76
|
# Raises TemplateError when the file doesn't exist.
|
|
@@ -91,14 +84,6 @@ module Textus
|
|
|
91
84
|
end
|
|
92
85
|
File.read(path)
|
|
93
86
|
end
|
|
94
|
-
|
|
95
|
-
private
|
|
96
|
-
|
|
97
|
-
def scope_for_hooks
|
|
98
|
-
Textus::Surfaces::RoleScope.new(
|
|
99
|
-
container: container, role: call.role, dry_run: call.dry_run,
|
|
100
|
-
)
|
|
101
|
-
end
|
|
102
87
|
end
|
|
103
88
|
|
|
104
89
|
# ADR 0049: an entry resolves, once, to one Publish::* mode that owns its
|
|
@@ -4,7 +4,6 @@ module Textus
|
|
|
4
4
|
module Parser
|
|
5
5
|
def self.call(raw)
|
|
6
6
|
key = raw["key"] or raise UsageError.new("manifest entry missing key")
|
|
7
|
-
path = raw["path"] or raise UsageError.new("manifest entry '#{key}' missing path")
|
|
8
7
|
lane = raw["lane"] or raise UsageError.new("manifest entry '#{key}' missing lane")
|
|
9
8
|
|
|
10
9
|
raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (#{Entry::REGISTRY.keys.join("|")})")
|
|
@@ -15,7 +14,10 @@ module Textus
|
|
|
15
14
|
"the produce method is `source.from` (#{kind == :intake ? "handler" : "project|command"})",
|
|
16
15
|
)
|
|
17
16
|
end
|
|
18
|
-
|
|
17
|
+
|
|
18
|
+
explicit_path = raw["path"]
|
|
19
|
+
format = resolve_format(raw, explicit_path)
|
|
20
|
+
path = explicit_path || derive_path(key, kind, format)
|
|
19
21
|
|
|
20
22
|
common = {
|
|
21
23
|
raw: raw,
|
|
@@ -30,11 +32,11 @@ module Textus
|
|
|
30
32
|
klass.from_raw(common, raw)
|
|
31
33
|
end
|
|
32
34
|
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
def self.parse_source(raw,
|
|
36
|
-
block = raw["source"]
|
|
37
|
-
|
|
35
|
+
# Parse the optional `source:` block. Returns nil when absent (workflow
|
|
36
|
+
# produced entries register their produce logic in .textus/workflows/).
|
|
37
|
+
def self.parse_source(raw, _key)
|
|
38
|
+
block = raw["source"]
|
|
39
|
+
return nil if block.nil?
|
|
38
40
|
|
|
39
41
|
Textus::Manifest::Policy::Source.new(block)
|
|
40
42
|
end
|
|
@@ -57,8 +59,11 @@ module Textus
|
|
|
57
59
|
|
|
58
60
|
def self.resolve_format(raw, path)
|
|
59
61
|
declared = raw["format"]
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
|
|
63
|
+
return declared || "markdown" if path.nil? || path.empty?
|
|
64
|
+
|
|
65
|
+
ext = File.extname(path)
|
|
66
|
+
inferred = Textus::Format.infer_from_extension(ext)
|
|
62
67
|
|
|
63
68
|
if declared.nil?
|
|
64
69
|
return inferred if inferred
|
|
@@ -66,7 +71,7 @@ module Textus
|
|
|
66
71
|
return "markdown"
|
|
67
72
|
end
|
|
68
73
|
|
|
69
|
-
raise UsageError.new("entry '#{raw["key"]}': unknown format #{declared.inspect}") unless Textus::
|
|
74
|
+
raise UsageError.new("entry '#{raw["key"]}': unknown format #{declared.inspect}") unless Textus::Format.formats.include?(declared)
|
|
70
75
|
|
|
71
76
|
if ext != "" && inferred && inferred != declared
|
|
72
77
|
raise UsageError.new(
|
|
@@ -76,6 +81,18 @@ module Textus
|
|
|
76
81
|
|
|
77
82
|
declared
|
|
78
83
|
end
|
|
84
|
+
|
|
85
|
+
# Derives the manifest-relative path from key + kind + format.
|
|
86
|
+
# Key::Path.normalize_relative_path will prepend data/ at resolution time.
|
|
87
|
+
def self.derive_path(key, kind_sym, format)
|
|
88
|
+
dir_path = key.split(".").join("/")
|
|
89
|
+
return dir_path if kind_sym == :nested
|
|
90
|
+
|
|
91
|
+
ext = Textus::Format.for(format).extensions.first
|
|
92
|
+
"#{dir_path}#{ext}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private_class_method :derive_path
|
|
79
96
|
end
|
|
80
97
|
end
|
|
81
98
|
end
|
|
@@ -1,53 +1,32 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
|
-
# A produced entry
|
|
5
|
-
#
|
|
6
|
-
#
|
|
4
|
+
# A produced entry — `kind: produced` with an optional `source:` block.
|
|
5
|
+
# When `source:` is present it must be `from: external` (out-of-band
|
|
6
|
+
# generator; textus detects drift but never runs it). When absent the
|
|
7
|
+
# entry is produced by a workflow file in .textus/workflows/.
|
|
7
8
|
class Produced < Base
|
|
8
|
-
attr_reader :source
|
|
9
|
+
attr_reader :source
|
|
9
10
|
|
|
10
|
-
def initialize(source:,
|
|
11
|
+
def initialize(source:, **rest)
|
|
11
12
|
super(**rest)
|
|
12
13
|
@source = source
|
|
13
|
-
@events = events || {}
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
def
|
|
17
|
-
def
|
|
18
|
-
def external? = @source.external?
|
|
19
|
-
def projection? = @source.projection?
|
|
20
|
-
def fetch? = @source.fetch?
|
|
21
|
-
def derive? = @source.derive?
|
|
22
|
-
def nested? = !!@raw["nested"]
|
|
23
|
-
def handler = @source.handler
|
|
24
|
-
def config = @source.config
|
|
16
|
+
def external? = @source&.external? || false
|
|
17
|
+
def nested? = !!@raw["nested"]
|
|
25
18
|
|
|
26
19
|
KIND = :produced
|
|
27
20
|
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# out-of-band runner — neither builds, but both still publish their
|
|
32
|
-
# existing store bytes through the same mode. A projection entry with no
|
|
33
|
-
# targets is a terminal data node: it produced data, so report :built
|
|
34
|
-
# even though nothing was emitted.
|
|
21
|
+
# Publish existing store bytes via the shared publish mode (Publish::ToPaths
|
|
22
|
+
# or Publish::None). Workflow runners handle the produce step; this method
|
|
23
|
+
# only publishes whatever bytes are already on disk.
|
|
35
24
|
def publish_via(pctx, prefix: nil)
|
|
36
|
-
|
|
37
|
-
if projection?
|
|
38
|
-
Textus::Pipeline::Acquire::Projection.new(container: pctx.container, call: pctx.call).run(self)
|
|
39
|
-
built = true
|
|
40
|
-
pctx.emit(:entry_produced, key: @key, envelope: pctx.reader.call(@key), sources: Array(@source.select).compact)
|
|
41
|
-
end
|
|
42
|
-
emitted = publish_mode.publish(pctx, prefix: prefix)
|
|
43
|
-
return emitted if emitted
|
|
44
|
-
return nil unless built
|
|
45
|
-
|
|
46
|
-
{ kind: :built, value: { "key" => @key, "path" => Key::Path.resolve(pctx.manifest.data, self), "published_to" => [] } }
|
|
25
|
+
publish_mode.publish(pctx, prefix: prefix)
|
|
47
26
|
end
|
|
48
27
|
|
|
49
28
|
def self.from_raw(common, raw)
|
|
50
|
-
new(source: Parser.parse_source(raw, common[:key]),
|
|
29
|
+
new(source: Parser.parse_source(raw, common[:key]), **common)
|
|
51
30
|
end
|
|
52
31
|
|
|
53
32
|
Entry::REGISTRY[KIND] = self
|
|
@@ -21,16 +21,16 @@ module Textus
|
|
|
21
21
|
|
|
22
22
|
data_path = pctx.manifest.resolver.resolve(entry.key).path
|
|
23
23
|
envelope = pctx.reader.call(entry.key)
|
|
24
|
-
renderer = Textus::
|
|
24
|
+
renderer = Textus::Produce::Render.new(template_loader: ->(n) { pctx.read_template(n) })
|
|
25
25
|
content = nil # parsed lazily; the data's `content` (always _meta-free)
|
|
26
26
|
|
|
27
27
|
targets.each do |t|
|
|
28
28
|
if t.renders?
|
|
29
|
-
content ||= Textus::
|
|
29
|
+
content ||= Textus::Format.for(entry.format).parse(File.read(data_path), path: data_path)["content"]
|
|
30
30
|
publish_bytes(render_bytes(t, content, renderer, pctx), entry.key, t, pctx, data_path, envelope)
|
|
31
31
|
elsif strip_meta?(entry)
|
|
32
|
-
content ||= Textus::
|
|
33
|
-
bytes = Textus::
|
|
32
|
+
content ||= Textus::Format.for(entry.format).parse(File.read(data_path), path: data_path)["content"]
|
|
33
|
+
bytes = Textus::Format.for(entry.format).serialize(meta: {}, body: "", content: content)
|
|
34
34
|
publish_bytes(bytes, entry.key, t, pctx, data_path, envelope)
|
|
35
35
|
else
|
|
36
36
|
# opaque / command / non-structured — publish the stored file as-is
|
|
@@ -18,8 +18,6 @@ module Textus
|
|
|
18
18
|
# the block groups the two but does not make exclusivity structural, so
|
|
19
19
|
# this stays the one enforcement point (ADR 0052 D2).
|
|
20
20
|
def self.resolve(entry)
|
|
21
|
-
reject_removed_publish_each(entry)
|
|
22
|
-
|
|
23
21
|
set = []
|
|
24
22
|
set << "publish.to" unless Array(entry.publish_to).empty?
|
|
25
23
|
set << "publish.tree" unless entry.publish_tree.nil?
|
|
@@ -33,16 +31,6 @@ module Textus
|
|
|
33
31
|
mode_for(entry, set.first)
|
|
34
32
|
end
|
|
35
33
|
|
|
36
|
-
def self.reject_removed_publish_each(entry)
|
|
37
|
-
return unless entry.raw["publish_each"]
|
|
38
|
-
|
|
39
|
-
raise Textus::UsageError.new(
|
|
40
|
-
"entry '#{entry.key}': publish_each was removed in 0.42.0 (ADR 0051) — " \
|
|
41
|
-
"mirror the subtree with `publish: { tree: \"...\" }`.",
|
|
42
|
-
)
|
|
43
|
-
end
|
|
44
|
-
private_class_method :reject_removed_publish_each
|
|
45
|
-
|
|
46
34
|
def self.mode_for(entry, key)
|
|
47
35
|
case key
|
|
48
36
|
when "publish.to" then ToPaths.new(entry)
|
|
@@ -5,7 +5,7 @@ module Textus
|
|
|
5
5
|
module FormatMatrix
|
|
6
6
|
def self.call(entry, policy:) # rubocop:disable Lint/UnusedMethodArgument
|
|
7
7
|
begin
|
|
8
|
-
Textus::
|
|
8
|
+
Textus::Format.for(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
|
|
@@ -1,77 +1,29 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
class Policy
|
|
4
|
-
# An entry's
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# from: external -> external (out-of-band runner; staleness only, textus never runs it)
|
|
10
|
-
# Materialization is async-only (job-queue model): a write enqueues a
|
|
11
|
-
# `materialize` job, converged by a worker. There is no per-entry write
|
|
12
|
-
# trigger knob.
|
|
4
|
+
# An entry's external-generator declaration. `source:` now means ONLY
|
|
5
|
+
# `from: external` — out-of-band runner; textus never invokes it, only
|
|
6
|
+
# detects drift (doctor generator_drift check).
|
|
7
|
+
# `from: fetch` and `from: derive` are removed; those concerns now live
|
|
8
|
+
# in workflow files under .textus/workflows/.
|
|
13
9
|
class Source
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
attr_reader :from, :handler, :config, :command, :sources
|
|
10
|
+
attr_reader :command, :sources
|
|
17
11
|
|
|
18
12
|
def initialize(raw)
|
|
19
|
-
|
|
20
|
-
unless
|
|
21
|
-
raise Textus::BadManifest.new(
|
|
13
|
+
from = raw["from"].to_s
|
|
14
|
+
unless from == "external"
|
|
15
|
+
raise Textus::BadManifest.new(
|
|
16
|
+
"from: #{from} is removed — use a workflow file under .textus/workflows/ instead",
|
|
17
|
+
)
|
|
22
18
|
end
|
|
23
19
|
|
|
24
|
-
@ttl = raw["ttl"]
|
|
25
|
-
@projection = {}
|
|
26
|
-
|
|
27
|
-
case @from
|
|
28
|
-
when "fetch" then init_fetch(raw)
|
|
29
|
-
when "derive" then init_derive(raw)
|
|
30
|
-
when "external" then init_external(raw)
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def kind
|
|
35
|
-
{ "fetch" => :intake, "derive" => :derived, "external" => :external }.fetch(@from)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def fetch? = @from == "fetch"
|
|
39
|
-
def derive? = @from == "derive"
|
|
40
|
-
def external? = @from == "external"
|
|
41
|
-
def projection? = derive?
|
|
42
|
-
def ttl_seconds = @ttl.nil? ? nil : Textus::Core::Duration.seconds(@ttl)
|
|
43
|
-
|
|
44
|
-
# Flattened projection accessors (ADR 0094) — read directly off the source
|
|
45
|
-
# block; nil when absent or not a projection source.
|
|
46
|
-
def select = @projection["select"]
|
|
47
|
-
def pluck = @projection["pluck"]
|
|
48
|
-
def sort_by = @projection["sort_by"]
|
|
49
|
-
def transform = @projection["transform"]
|
|
50
|
-
|
|
51
|
-
# The projection spec hash fed to Textus::Projection (string keys, only the
|
|
52
|
-
# present fields). {} when not a projection.
|
|
53
|
-
def projection_spec = @projection.dup
|
|
54
|
-
|
|
55
|
-
private
|
|
56
|
-
|
|
57
|
-
def init_derive(raw)
|
|
58
|
-
%w[select pluck sort_by transform].each { |f| @projection[f] = raw[f] if raw.key?(f) }
|
|
59
|
-
return unless @projection["select"].nil? && @projection["transform"].nil?
|
|
60
|
-
|
|
61
|
-
raise Textus::BadManifest.new("source (from: derive) requires `select:` and/or `transform:`")
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
def init_fetch(raw)
|
|
65
|
-
@handler = raw["handler"] or
|
|
66
|
-
raise Textus::BadManifest.new("source (from: fetch) requires a `handler:` field")
|
|
67
|
-
@config = raw["config"] || {}
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def init_external(raw)
|
|
71
20
|
@command = raw["command"] or
|
|
72
21
|
raise Textus::BadManifest.new("source (from: external) requires a `command:` field")
|
|
73
22
|
@sources = raw["sources"] || []
|
|
74
23
|
end
|
|
24
|
+
|
|
25
|
+
def external? = true
|
|
26
|
+
def kind = :external
|
|
75
27
|
end
|
|
76
28
|
end
|
|
77
29
|
end
|
|
@@ -62,11 +62,8 @@ module Textus
|
|
|
62
62
|
# and ask it directly. Returns false if entries are not yet built
|
|
63
63
|
# (validator phase during Data#initialize) — validators must not rely on
|
|
64
64
|
# cross-entry state during construction.
|
|
65
|
-
def derived_entry?(
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
entry = @data.entries.find { |e| e.key == key } or return false
|
|
69
|
-
entry.derived?
|
|
65
|
+
def derived_entry?(_key)
|
|
66
|
+
false
|
|
70
67
|
end
|
|
71
68
|
|
|
72
69
|
# The single lane declaring kind: machine, or nil.
|
|
@@ -53,7 +53,7 @@ module Textus
|
|
|
53
53
|
else
|
|
54
54
|
raise UnknownKey.new(key, suggestions: suggestions_for(key)) unless nested_entry?(entry)
|
|
55
55
|
|
|
56
|
-
primary_ext = Textus::
|
|
56
|
+
primary_ext = Textus::Format.for(entry.format).extensions.first
|
|
57
57
|
base = Textus::Key::Path.normalize_relative_path(entry.path)
|
|
58
58
|
path = File.join(@data.root, base, *remaining) + primary_ext
|
|
59
59
|
Resolution.new(entry: entry, path: path, remaining: remaining)
|
|
@@ -111,7 +111,7 @@ module Textus
|
|
|
111
111
|
end
|
|
112
112
|
|
|
113
113
|
def nested_glob(format)
|
|
114
|
-
Textus::
|
|
114
|
+
Textus::Format.for(format).nested_glob
|
|
115
115
|
end
|
|
116
116
|
end
|
|
117
117
|
end
|