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.
Files changed (149) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/SPEC.md +2 -5
  4. data/docs/architecture/README.md +149 -159
  5. data/lib/textus/action/accept.rb +0 -9
  6. data/lib/textus/action/audit.rb +0 -2
  7. data/lib/textus/action/blame.rb +0 -2
  8. data/lib/textus/action/boot.rb +2 -11
  9. data/lib/textus/action/data_mv.rb +1 -3
  10. data/lib/textus/action/deps.rb +1 -15
  11. data/lib/textus/action/doctor.rb +0 -2
  12. data/lib/textus/action/drain.rb +4 -6
  13. data/lib/textus/action/enqueue.rb +3 -5
  14. data/lib/textus/action/get.rb +1 -3
  15. data/lib/textus/action/ingest.rb +135 -0
  16. data/lib/textus/action/jobs.rb +1 -3
  17. data/lib/textus/action/key_delete.rb +0 -10
  18. data/lib/textus/action/key_delete_prefix.rb +1 -3
  19. data/lib/textus/action/key_mv.rb +1 -15
  20. data/lib/textus/action/key_mv_prefix.rb +1 -3
  21. data/lib/textus/action/list.rb +0 -2
  22. data/lib/textus/action/propose.rb +0 -2
  23. data/lib/textus/action/published.rb +0 -2
  24. data/lib/textus/action/pulse.rb +7 -38
  25. data/lib/textus/action/put.rb +1 -12
  26. data/lib/textus/action/rdeps.rb +2 -12
  27. data/lib/textus/action/reject.rb +0 -12
  28. data/lib/textus/action/rule_explain.rb +0 -4
  29. data/lib/textus/action/rule_lint.rb +1 -3
  30. data/lib/textus/action/rule_list.rb +0 -4
  31. data/lib/textus/action/schema_envelope.rb +0 -2
  32. data/lib/textus/action/uid.rb +0 -2
  33. data/lib/textus/action/where.rb +0 -2
  34. data/lib/textus/action/write_verb.rb +4 -18
  35. data/lib/textus/boot.rb +33 -109
  36. data/lib/textus/command.rb +6 -1
  37. data/lib/textus/container.rb +1 -1
  38. data/lib/textus/core/freshness/evaluator.rb +18 -34
  39. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  40. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  41. data/lib/textus/doctor/check.rb +0 -1
  42. data/lib/textus/doctor.rb +2 -42
  43. data/lib/textus/envelope/reader.rb +46 -0
  44. data/lib/textus/envelope/writer.rb +209 -0
  45. data/lib/textus/errors.rb +8 -4
  46. data/lib/textus/{entry → format}/base.rb +1 -6
  47. data/lib/textus/{entry → format}/json.rb +1 -4
  48. data/lib/textus/{entry → format}/markdown.rb +2 -6
  49. data/lib/textus/{entry → format}/text.rb +2 -6
  50. data/lib/textus/{entry → format}/yaml.rb +1 -4
  51. data/lib/textus/{entry.rb → format.rb} +9 -10
  52. data/lib/textus/gate/auth.rb +58 -12
  53. data/lib/textus/gate.rb +2 -0
  54. data/lib/textus/init.rb +12 -75
  55. data/lib/textus/jobs/base.rb +23 -0
  56. data/lib/textus/jobs/materialize.rb +20 -0
  57. data/lib/textus/{background → jobs}/plan.rb +1 -1
  58. data/lib/textus/jobs/planner.rb +101 -0
  59. data/lib/textus/jobs/retention.rb +48 -0
  60. data/lib/textus/jobs/sweep.rb +27 -0
  61. data/lib/textus/{background → jobs}/worker.rb +2 -2
  62. data/lib/textus/jobs.rb +15 -0
  63. data/lib/textus/key/path.rb +1 -1
  64. data/lib/textus/manifest/entry/base.rb +4 -19
  65. data/lib/textus/manifest/entry/parser.rb +27 -10
  66. data/lib/textus/manifest/entry/produced.rb +13 -34
  67. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
  68. data/lib/textus/manifest/entry/publish.rb +0 -12
  69. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  70. data/lib/textus/manifest/entry/validators.rb +0 -1
  71. data/lib/textus/manifest/policy/source.rb +14 -62
  72. data/lib/textus/manifest/policy.rb +2 -5
  73. data/lib/textus/manifest/resolver.rb +2 -2
  74. data/lib/textus/manifest/schema/keys.rb +6 -20
  75. data/lib/textus/manifest/schema/validator.rb +3 -11
  76. data/lib/textus/manifest/schema/vocabulary.rb +1 -0
  77. data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
  78. data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
  79. data/lib/textus/produce/engine.rb +50 -0
  80. data/lib/textus/produce/render.rb +18 -0
  81. data/lib/textus/session.rb +1 -1
  82. data/lib/textus/store.rb +1 -10
  83. data/lib/textus/surfaces/mcp/catalog.rb +1 -1
  84. data/lib/textus/surfaces/mcp/errors.rb +0 -9
  85. data/lib/textus/surfaces/mcp/routing.rb +51 -0
  86. data/lib/textus/surfaces/mcp/server.rb +101 -62
  87. data/lib/textus/surfaces/role_scope.rb +0 -4
  88. data/lib/textus/surfaces/watcher.rb +3 -3
  89. data/lib/textus/version.rb +1 -1
  90. data/lib/textus/workflow/collector.rb +27 -0
  91. data/lib/textus/workflow/context.rb +5 -0
  92. data/lib/textus/workflow/dsl.rb +44 -0
  93. data/lib/textus/workflow/loader.rb +17 -0
  94. data/lib/textus/workflow/pattern.rb +18 -0
  95. data/lib/textus/workflow/registry.rb +21 -0
  96. data/lib/textus/workflow/runner.rb +84 -0
  97. data/lib/textus/workflow.rb +28 -0
  98. data/lib/textus.rb +12 -1
  99. metadata +33 -61
  100. data/lib/textus/action/pulse/scanner.rb +0 -118
  101. data/lib/textus/background/job/base.rb +0 -27
  102. data/lib/textus/background/job/materialize.rb +0 -31
  103. data/lib/textus/background/job/refresh.rb +0 -22
  104. data/lib/textus/background/job/sweep.rb +0 -31
  105. data/lib/textus/background/job.rb +0 -19
  106. data/lib/textus/background/planner/plan.rb +0 -113
  107. data/lib/textus/background/retention/apply.rb +0 -50
  108. data/lib/textus/doctor/check/handler_permit.rb +0 -34
  109. data/lib/textus/doctor/check/hooks.rb +0 -25
  110. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  111. data/lib/textus/envelope/io/reader.rb +0 -48
  112. data/lib/textus/envelope/io/writer.rb +0 -211
  113. data/lib/textus/events.rb +0 -21
  114. data/lib/textus/init/templates/machine_intake.rb +0 -49
  115. data/lib/textus/init/templates/orientation_reducer.rb +0 -21
  116. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  117. data/lib/textus/manifest/policy/handler_permit.rb +0 -17
  118. data/lib/textus/pipeline/acquire/handler.rb +0 -29
  119. data/lib/textus/pipeline/acquire/intake.rb +0 -132
  120. data/lib/textus/pipeline/acquire/projection.rb +0 -129
  121. data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
  122. data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
  123. data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
  124. data/lib/textus/pipeline/acquire/serializer.rb +0 -17
  125. data/lib/textus/pipeline/engine.rb +0 -97
  126. data/lib/textus/pipeline/render.rb +0 -25
  127. data/lib/textus/ports/audit_subscriber.rb +0 -42
  128. data/lib/textus/projection.rb +0 -103
  129. data/lib/textus/step/base.rb +0 -35
  130. data/lib/textus/step/builtin/csv_fetch.rb +0 -19
  131. data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
  132. data/lib/textus/step/builtin/json_fetch.rb +0 -18
  133. data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
  134. data/lib/textus/step/builtin/rss_fetch.rb +0 -26
  135. data/lib/textus/step/builtin.rb +0 -22
  136. data/lib/textus/step/catalog.rb +0 -37
  137. data/lib/textus/step/context.rb +0 -74
  138. data/lib/textus/step/discovery.rb +0 -24
  139. data/lib/textus/step/error_log.rb +0 -32
  140. data/lib/textus/step/event_bus.rb +0 -113
  141. data/lib/textus/step/fetch.rb +0 -13
  142. data/lib/textus/step/fire_report.rb +0 -23
  143. data/lib/textus/step/loader.rb +0 -108
  144. data/lib/textus/step/observe.rb +0 -31
  145. data/lib/textus/step/registry_store.rb +0 -66
  146. data/lib/textus/step/signature.rb +0 -31
  147. data/lib/textus/step/transform.rb +0 -12
  148. data/lib/textus/step/validate.rb +0 -11
  149. 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
@@ -1,5 +1,5 @@
1
1
  module Textus
2
- module Background
2
+ module Jobs
3
3
  Plan = Data.define(:steps, :warnings) do
4
4
  def to_h
5
5
  { "steps" => steps, "warnings" => warnings }
@@ -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 Background
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::Background::Job.fetch(job.type)
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
@@ -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
@@ -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 = Entry.for_format(mentry.format).extensions.first
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. Data.define
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
- def hook_context
75
- Textus::Step::Context.new(scope: scope_for_hooks)
76
- end
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
- format = resolve_format(raw, path)
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
- # ADR 0093: an entry's production block is the unified `source:`. Returns a
34
- # Manifest::Policy::Source; kind (intake/derived) is read from source.from.
35
- def self.parse_source(raw, key)
36
- block = raw["source"] or
37
- raise BadManifest.new("entry '#{key}' requires a source: { from: derive|fetch|external, ... }")
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
- ext = File.extname(path)
61
- inferred = Textus::Entry.infer_from_extension(ext)
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::Entry.formats.include?(declared)
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 (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.
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, :events
9
+ attr_reader :source
9
10
 
10
- def initialize(source:, events: {}, **rest)
11
+ def initialize(source:, **rest)
11
12
  super(**rest)
12
13
  @source = source
13
- @events = events || {}
14
14
  end
15
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 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
- # ADR 0094/0095: projection (from: project) sources build their DATA
29
- # artifact here, then publish via the ONE shared mode (Publish::ToPaths).
30
- # Intake bytes come from Produce::Acquire::Intake and command (external) bytes from the
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
- built = false
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]), events: raw["events"] || {}, **common)
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::Pipeline::Render.new(template_loader: ->(n) { pctx.read_template(n) })
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::Entry.for_format(entry.format).parse(File.read(data_path), path: data_path)["content"]
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::Entry.for_format(entry.format).parse(File.read(data_path), path: data_path)["content"]
33
- bytes = Textus::Entry.for_format(entry.format).serialize(meta: {}, body: "", content: content)
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::Entry.for_format(entry.format).validate_path_extension(entry.path, entry.nested?)
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
@@ -3,7 +3,6 @@ module Textus
3
3
  class Entry
4
4
  module Validators
5
5
  REGISTERED = [
6
- Events,
7
6
  Publish,
8
7
  Ignore,
9
8
  FormatMatrix,
@@ -1,77 +1,29 @@
1
1
  module Textus
2
2
  class Manifest
3
3
  class Policy
4
- # An entry's data-acquisition declaration (ADR 0094). `source:` says HOW the
5
- # entry's data is acquired; rendering is a publish concern, so there are no
6
- # template/render fields here. `from` is the acquire + staleness axis:
7
- # from: derive -> derived (internal projection; observable -> rdeps staleness)
8
- # from: fetch -> intake (external fetch; unobservable -> ttl staleness)
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
- FROMS = %w[fetch derive external].freeze
15
-
16
- attr_reader :from, :handler, :config, :command, :sources
10
+ attr_reader :command, :sources
17
11
 
18
12
  def initialize(raw)
19
- @from = raw["from"].to_s
20
- unless FROMS.include?(@from)
21
- raise Textus::BadManifest.new("source.from must be one of #{FROMS.join("|")}, got #{raw["from"].inspect}")
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?(key)
66
- return false if @data.entries.nil?
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::Entry.for_format(entry.format).extensions.first
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::Entry.for_format(format).nested_glob
114
+ Textus::Format.for(format).nested_glob
115
115
  end
116
116
  end
117
117
  end