textus 0.53.0 → 0.54.1

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 (160) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/README.md +53 -73
  4. data/SPEC.md +2 -5
  5. data/docs/architecture/README.md +149 -159
  6. data/lib/textus/action/accept.rb +0 -9
  7. data/lib/textus/action/audit.rb +0 -2
  8. data/lib/textus/action/blame.rb +0 -2
  9. data/lib/textus/action/boot.rb +2 -11
  10. data/lib/textus/action/data_mv.rb +1 -3
  11. data/lib/textus/action/deps.rb +1 -15
  12. data/lib/textus/action/doctor.rb +0 -2
  13. data/lib/textus/action/drain.rb +4 -6
  14. data/lib/textus/action/enqueue.rb +3 -5
  15. data/lib/textus/action/get.rb +1 -3
  16. data/lib/textus/action/ingest.rb +135 -0
  17. data/lib/textus/action/jobs.rb +1 -3
  18. data/lib/textus/action/key_delete.rb +0 -10
  19. data/lib/textus/action/key_delete_prefix.rb +1 -3
  20. data/lib/textus/action/key_mv.rb +1 -15
  21. data/lib/textus/action/key_mv_prefix.rb +1 -3
  22. data/lib/textus/action/list.rb +0 -2
  23. data/lib/textus/action/propose.rb +0 -2
  24. data/lib/textus/action/published.rb +0 -2
  25. data/lib/textus/action/pulse.rb +7 -38
  26. data/lib/textus/action/put.rb +1 -12
  27. data/lib/textus/action/rdeps.rb +2 -12
  28. data/lib/textus/action/reject.rb +0 -12
  29. data/lib/textus/action/rule_explain.rb +0 -4
  30. data/lib/textus/action/rule_lint.rb +1 -3
  31. data/lib/textus/action/rule_list.rb +0 -4
  32. data/lib/textus/action/schema_envelope.rb +0 -2
  33. data/lib/textus/action/uid.rb +0 -2
  34. data/lib/textus/action/where.rb +0 -2
  35. data/lib/textus/action/write_verb.rb +4 -18
  36. data/lib/textus/boot.rb +40 -109
  37. data/lib/textus/command.rb +6 -1
  38. data/lib/textus/container.rb +21 -5
  39. data/lib/textus/core/freshness/evaluator.rb +18 -34
  40. data/lib/textus/core/freshness/verdict.rb +1 -1
  41. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  42. data/lib/textus/doctor/check/protocol_version.rb +6 -6
  43. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  44. data/lib/textus/doctor/check/stale_reviewed_stamp.rb +54 -0
  45. data/lib/textus/doctor/check.rb +0 -1
  46. data/lib/textus/doctor.rb +3 -42
  47. data/lib/textus/envelope/reader.rb +46 -0
  48. data/lib/textus/envelope/writer.rb +209 -0
  49. data/lib/textus/envelope.rb +19 -6
  50. data/lib/textus/errors.rb +9 -5
  51. data/lib/textus/{entry → format}/base.rb +1 -6
  52. data/lib/textus/{entry → format}/json.rb +1 -4
  53. data/lib/textus/{entry → format}/markdown.rb +2 -6
  54. data/lib/textus/{entry → format}/text.rb +2 -6
  55. data/lib/textus/{entry → format}/yaml.rb +1 -4
  56. data/lib/textus/{entry.rb → format.rb} +9 -10
  57. data/lib/textus/gate/auth.rb +58 -12
  58. data/lib/textus/gate.rb +2 -0
  59. data/lib/textus/init.rb +16 -79
  60. data/lib/textus/jobs/base.rb +23 -0
  61. data/lib/textus/jobs/materialize.rb +20 -0
  62. data/lib/textus/{background → jobs}/plan.rb +1 -1
  63. data/lib/textus/jobs/planner.rb +101 -0
  64. data/lib/textus/jobs/retention.rb +48 -0
  65. data/lib/textus/jobs/sweep.rb +27 -0
  66. data/lib/textus/{background → jobs}/worker.rb +2 -2
  67. data/lib/textus/jobs.rb +15 -0
  68. data/lib/textus/key/path.rb +1 -1
  69. data/lib/textus/manifest/entry/base.rb +4 -19
  70. data/lib/textus/manifest/entry/parser.rb +27 -10
  71. data/lib/textus/manifest/entry/produced.rb +13 -34
  72. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
  73. data/lib/textus/manifest/entry/publish.rb +0 -12
  74. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  75. data/lib/textus/manifest/entry/validators.rb +0 -1
  76. data/lib/textus/manifest/policy/source.rb +14 -62
  77. data/lib/textus/manifest/policy.rb +2 -5
  78. data/lib/textus/manifest/resolver.rb +2 -2
  79. data/lib/textus/manifest/schema/contract.rb +61 -0
  80. data/lib/textus/manifest/schema/keys.rb +6 -20
  81. data/lib/textus/manifest/schema/semantics.rb +232 -0
  82. data/lib/textus/manifest/schema/validator.rb +24 -302
  83. data/lib/textus/manifest/schema/vocabulary.rb +1 -0
  84. data/lib/textus/manifest/schema.rb +1 -4
  85. data/lib/textus/manifest.rb +1 -3
  86. data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
  87. data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
  88. data/lib/textus/produce/engine.rb +50 -0
  89. data/lib/textus/produce/render.rb +20 -0
  90. data/lib/textus/session.rb +15 -8
  91. data/lib/textus/store.rb +5 -14
  92. data/lib/textus/surfaces/mcp/catalog.rb +23 -11
  93. data/lib/textus/surfaces/mcp/errors.rb +0 -9
  94. data/lib/textus/surfaces/mcp/server.rb +87 -110
  95. data/lib/textus/surfaces/role_scope.rb +0 -4
  96. data/lib/textus/surfaces/watcher.rb +3 -3
  97. data/lib/textus/types.rb +15 -0
  98. data/lib/textus/version.rb +2 -2
  99. data/lib/textus/workflow/collector.rb +27 -0
  100. data/lib/textus/workflow/context.rb +5 -0
  101. data/lib/textus/workflow/dsl.rb +44 -0
  102. data/lib/textus/workflow/loader.rb +17 -0
  103. data/lib/textus/workflow/pattern.rb +18 -0
  104. data/lib/textus/workflow/registry.rb +21 -0
  105. data/lib/textus/workflow/runner.rb +99 -0
  106. data/lib/textus/workflow.rb +28 -0
  107. data/lib/textus.rb +12 -1
  108. metadata +67 -66
  109. data/lib/textus/action/pulse/scanner.rb +0 -118
  110. data/lib/textus/background/job/base.rb +0 -27
  111. data/lib/textus/background/job/materialize.rb +0 -31
  112. data/lib/textus/background/job/refresh.rb +0 -22
  113. data/lib/textus/background/job/sweep.rb +0 -31
  114. data/lib/textus/background/job.rb +0 -19
  115. data/lib/textus/background/planner/plan.rb +0 -113
  116. data/lib/textus/background/retention/apply.rb +0 -50
  117. data/lib/textus/doctor/check/handler_permit.rb +0 -34
  118. data/lib/textus/doctor/check/hooks.rb +0 -25
  119. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  120. data/lib/textus/envelope/io/reader.rb +0 -48
  121. data/lib/textus/envelope/io/writer.rb +0 -211
  122. data/lib/textus/events.rb +0 -21
  123. data/lib/textus/init/templates/machine_intake.rb +0 -49
  124. data/lib/textus/init/templates/orientation_reducer.rb +0 -21
  125. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  126. data/lib/textus/manifest/policy/handler_permit.rb +0 -17
  127. data/lib/textus/pipeline/acquire/handler.rb +0 -29
  128. data/lib/textus/pipeline/acquire/intake.rb +0 -132
  129. data/lib/textus/pipeline/acquire/projection.rb +0 -129
  130. data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
  131. data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
  132. data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
  133. data/lib/textus/pipeline/acquire/serializer.rb +0 -17
  134. data/lib/textus/pipeline/engine.rb +0 -97
  135. data/lib/textus/pipeline/render.rb +0 -25
  136. data/lib/textus/ports/audit_subscriber.rb +0 -42
  137. data/lib/textus/projection.rb +0 -103
  138. data/lib/textus/step/base.rb +0 -35
  139. data/lib/textus/step/builtin/csv_fetch.rb +0 -19
  140. data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
  141. data/lib/textus/step/builtin/json_fetch.rb +0 -18
  142. data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
  143. data/lib/textus/step/builtin/rss_fetch.rb +0 -26
  144. data/lib/textus/step/builtin.rb +0 -22
  145. data/lib/textus/step/catalog.rb +0 -37
  146. data/lib/textus/step/context.rb +0 -74
  147. data/lib/textus/step/discovery.rb +0 -24
  148. data/lib/textus/step/error_log.rb +0 -32
  149. data/lib/textus/step/event_bus.rb +0 -113
  150. data/lib/textus/step/fetch.rb +0 -13
  151. data/lib/textus/step/fire_report.rb +0 -23
  152. data/lib/textus/step/loader.rb +0 -108
  153. data/lib/textus/step/observe.rb +0 -31
  154. data/lib/textus/step/registry_store.rb +0 -66
  155. data/lib/textus/step/signature.rb +0 -31
  156. data/lib/textus/step/transform.rb +0 -12
  157. data/lib/textus/step/validate.rb +0 -11
  158. data/lib/textus/step.rb +0 -10
  159. data/lib/textus/surfaces/mcp/session.rb +0 -9
  160. data/lib/textus/surfaces/mcp/tool_schemas.rb +0 -17
data/lib/textus/init.rb CHANGED
@@ -3,10 +3,10 @@ require "pathname"
3
3
 
4
4
  module Textus
5
5
  module Init
6
- ZONES = %w[knowledge notebook proposals artifacts].freeze
6
+ ZONES = %w[knowledge notebook proposals artifacts raw].freeze
7
7
 
8
8
  DEFAULT_MANIFEST = <<~YAML
9
- version: textus/3
9
+ version: textus/4
10
10
  roles:
11
11
  - { name: human, can: [author, propose] }
12
12
  - { name: agent, can: [propose, keep] }
@@ -21,9 +21,8 @@ module Textus
21
21
  - { key: knowledge.notes, path: data/knowledge/notes, lane: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
22
22
  - { key: notebook.notes, path: data/notebook/notes, lane: notebook, schema: null, owner: agent:self, nested: true, kind: nested }
23
23
  - { key: proposals.notes, path: data/proposals/notes, lane: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
24
- # A per-host snapshot, refreshed from its declared intake by `textus drain` (scheduled, or on demand).
25
- # Nested so it grows to a fleet — add artifacts.feeds.machines.<host> leaves over SSH
26
- # (see docs/cookbook/environment-scan.md) without renaming. tracked:false →
24
+ # A per-host snapshot, populated by a registered workflow. Nested so it
25
+ # grows to a fleet — add leaves over SSH without renaming. tracked:false →
27
26
  # gitignored (machine info can be sensitive/noisy) but still protocol-readable
28
27
  # via `textus get artifacts.feeds.machines.local`. Delete to opt out. (ADR 0043)
29
28
  - key: artifacts.feeds.machines
@@ -32,60 +31,10 @@ module Textus
32
31
  format: yaml
33
32
  nested: true
34
33
  tracked: false
35
- kind: produced
36
- source:
37
- from: fetch
38
- handler: machine-intake
39
- ttl: 1h # cadence on a long-running server
40
- config:
41
- machines:
42
- local: { via: local }
34
+ kind: nested
43
35
  rules: []
44
36
  YAML
45
37
 
46
- STEPS_README = <<~MD
47
- # Steps
48
-
49
- Drop one Ruby file per step. Steps are discovered by convention.
50
- Files under `.textus/steps/<kind>/<name>.rb` are loaded at
51
- startup and registered.
52
-
53
- ## Conventions
54
-
55
- The directory name (`<kind>`) must be one of:
56
- - `fetch`: Acquires data from outside the store.
57
- - `transform`: Reshapes projection rows.
58
- - `validate`: Validates data before writing.
59
- - `observe`: Listens to store events.
60
-
61
- The filename (`<name>.rb`) defines the step name. The class defined
62
- in the file must be a subclass of `Textus::Step::<Kind>` (e.g.
63
- `Textus::Step::Fetch`) and be wrapped in the `Textus::Step` module.
64
-
65
- ## Example
66
-
67
- ```ruby
68
- module Textus
69
- module Step
70
- class MyFetch < Fetch
71
- def call(config:, args:, caps:, **)
72
- { content: { "foo" => "bar" } }
73
- end
74
- end
75
- end
76
- end
77
- ```
78
-
79
- Events: :fetch, :transform, :validate (rpc — return value used)
80
- :entry_written, :entry_deleted, :entry_fetched, :entry_renamed,
81
- :entry_produced, :produce_failed,
82
- :proposal_accepted, :proposal_rejected,
83
- :entry_published, :store_loaded, :session_opened,
84
- :entry_fetch_started, :entry_fetch_failed (pub-sub — return discarded)
85
-
86
- See SPEC.md §5.10 for the full table.
87
- MD
88
-
89
38
  AGENT_ENTRIES = <<~YAML.gsub(/^/, " ")
90
39
  - { key: knowledge.project, path: data/knowledge/project.md, lane: knowledge, schema: project, owner: human:self, kind: leaf }
91
40
  - { key: knowledge.runbooks, path: data/knowledge/runbooks, lane: knowledge, schema: runbook, owner: human:self, nested: true, kind: nested }
@@ -93,14 +42,8 @@ module Textus
93
42
  path: data/artifacts/derived/orientation.json
94
43
  lane: artifacts
95
44
  publish:
96
- - { to: CLAUDE.md, template: orientation.mustache, inject_boot: true }
97
- - { to: AGENTS.md, template: orientation.mustache, inject_boot: true }
98
- source:
99
- from: derive
100
- select:
101
- - knowledge.project
102
- - knowledge.runbooks
103
- transform: orientation
45
+ - { to: CLAUDE.md, template: orientation.erb, inject_boot: true }
46
+ - { to: AGENTS.md, template: orientation.erb, inject_boot: true }
104
47
  kind: produced
105
48
  YAML
106
49
 
@@ -108,7 +51,6 @@ module Textus
108
51
  check_target!(target_root)
109
52
  scaffold_dir = File.expand_path("init/templates", __dir__)
110
53
  create_directories(target_root)
111
- write_steps_readme(target_root, scaffold_dir)
112
54
  write_manifest(target_root, with_agent:)
113
55
  mcp_status = scaffold_agent(target_root, scaffold_dir, with_agent:)
114
56
  setup_state_dirs(target_root)
@@ -120,26 +62,22 @@ module Textus
120
62
  raise UsageError.new(".textus/ already exists at #{target_root}") if File.directory?(target_root)
121
63
  end
122
64
 
65
+ ASSET_ZONES = %w[raw].freeze
66
+
123
67
  def self.create_directories(target_root)
124
68
  FileUtils.mkdir_p(File.join(target_root, "schemas"))
125
69
  FileUtils.mkdir_p(File.join(target_root, "templates"))
126
- FileUtils.mkdir_p(File.join(target_root, "steps/fetch"))
127
- FileUtils.mkdir_p(File.join(target_root, "steps/transform"))
128
- FileUtils.mkdir_p(File.join(target_root, "steps/validate"))
129
- FileUtils.mkdir_p(File.join(target_root, "steps/observe"))
70
+ FileUtils.mkdir_p(File.join(target_root, "workflows"))
130
71
  ZONES.each do |z|
131
72
  dir = File.join(target_root, "data", z)
132
73
  FileUtils.mkdir_p(dir)
133
74
  File.write(File.join(dir, ".gitkeep"), "")
134
75
  end
135
- end
136
-
137
- def self.write_steps_readme(target_root, scaffold_dir)
138
- File.write(File.join(target_root, "steps/README.md"), STEPS_README)
139
- File.write(
140
- File.join(target_root, "steps/fetch/machine-intake.rb"),
141
- File.read(File.join(scaffold_dir, "machine_intake.rb")),
142
- )
76
+ ASSET_ZONES.each do |z|
77
+ dir = File.join(target_root, "assets", z)
78
+ FileUtils.mkdir_p(dir)
79
+ File.write(File.join(dir, ".gitkeep"), "")
80
+ end
143
81
  end
144
82
 
145
83
  def self.write_manifest(target_root, with_agent:)
@@ -183,8 +121,7 @@ module Textus
183
121
  {
184
122
  "project.schema.yaml" => File.join("schemas", "project.yaml"),
185
123
  "runbook.schema.yaml" => File.join("schemas", "runbook.yaml"),
186
- "orientation.mustache" => File.join("templates", "orientation.mustache"),
187
- "orientation_reducer.rb" => File.join("steps/transform", "orientation.rb"),
124
+ "orientation.erb" => File.join("templates", "orientation.erb"),
188
125
  }.each do |src, dest|
189
126
  File.write(File.join(target_root, dest), File.read(File.join(scaffold_dir, src)))
190
127
  end
@@ -0,0 +1,23 @@
1
+ module Textus
2
+ module Jobs
3
+ class Base
4
+ def self.inherited(subclass)
5
+ super
6
+ return unless subclass.name
7
+
8
+ TracePoint.new(:end) do |tp|
9
+ if tp.self == subclass
10
+ Textus::Jobs.register(subclass)
11
+ tp.disable
12
+ end
13
+ end.enable
14
+ end
15
+
16
+ def call(**)
17
+ raise NotImplementedError.new("#{self.class}#call")
18
+ end
19
+
20
+ def args = {}
21
+ end
22
+ end
23
+ end
@@ -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)