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
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Background
5
- module Job
6
- @registry = {}
7
-
8
- def self.registry = @registry
9
-
10
- def self.register(klass)
11
- @registry[klass::TYPE] = klass if klass.const_defined?(:TYPE, false)
12
- end
13
-
14
- def self.fetch(type)
15
- @registry.fetch(type) { raise Textus::UsageError.new("unknown job type: #{type}") }
16
- end
17
- end
18
- end
19
- end
@@ -1,113 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Background
5
- module Planner
6
- class Plan
7
- ACTIONS_BY_TRIGGER = {
8
- "convergence" => %w[materialize refresh sweep],
9
- "entry.written" => %w[materialize],
10
- "entry.deleted" => %w[materialize],
11
- "entry.moved" => %w[materialize],
12
- "proposal.accepted" => %w[materialize],
13
- "proposal.rejected" => %w[materialize],
14
- }.freeze
15
-
16
- SCOPE_RESOLVERS = {
17
- "materialize" => :producible_keys,
18
- "refresh" => :stale_intake_keys,
19
- "sweep" => :lane_keys,
20
- }.freeze
21
-
22
- def self.seed(container:, queue:, role:)
23
- jobs = new(container: container).plan(
24
- trigger: { "type" => "convergence" },
25
- role: role,
26
- )
27
- jobs.each { |j| queue.enqueue(j) }
28
- end
29
-
30
- def initialize(container:)
31
- @container = container
32
- @manifest = container.manifest
33
- end
34
-
35
- def plan(trigger:, role:)
36
- type = trigger["type"] || trigger[:type]
37
- trigger["target"] || trigger[:target]
38
- return [] if type.nil?
39
-
40
- blocks_with_react = @manifest.rules.blocks.select(&:react)
41
- if blocks_with_react.any?
42
- plan_from_rules(blocks_with_react, type, role)
43
- else
44
- plan_from_defaults(type, role)
45
- end
46
- end
47
-
48
- private
49
-
50
- def plan_from_rules(blocks, type, role)
51
- jobs = []
52
- blocks
53
- .select { |b| matches_trigger?(b.react, type) }
54
- .each do |block|
55
- do_action = block.react.raw["do"]
56
- Array(do_action).each do |action|
57
- if action == "sweep"
58
- jobs << Textus::Ports::Queue::Job.new(
59
- type: "sweep", args: { "scope" => {} }, enqueued_by: role,
60
- )
61
- else
62
- resolver = SCOPE_RESOLVERS.fetch(action, :producible_keys)
63
- keys = send(resolver, nil)
64
- keys.each { |key| jobs << job(action, key, role) }
65
- end
66
- end
67
- end
68
- jobs
69
- end
70
-
71
- def plan_from_defaults(type, role)
72
- actions = ACTIONS_BY_TRIGGER.fetch(type, [])
73
- jobs = []
74
- producible_keys(nil).each { |k| jobs << job("materialize", k, role) } if actions.include?("materialize")
75
- stale_intake_keys(nil).each { |k| jobs << job("refresh", k, role) } if actions.include?("refresh")
76
- if actions.include?("sweep")
77
- jobs << Textus::Ports::Queue::Job.new(
78
- type: "sweep", args: { "scope" => {} }, enqueued_by: role,
79
- )
80
- end
81
- jobs
82
- end
83
-
84
- def matches_trigger?(react, type)
85
- on = react.raw["on"]
86
- Array(on).include?(type)
87
- end
88
-
89
- def job(type, key, enqueued_by)
90
- Textus::Ports::Queue::Job.new(type: type, args: { "key" => key }, enqueued_by: enqueued_by)
91
- end
92
-
93
- def producible_keys(_target)
94
- @manifest.data.entries
95
- .select { |e| e.derived? || !e.publish_tree.nil? || !e.publish_to.empty? }
96
- .map(&:key)
97
- end
98
-
99
- def stale_intake_keys(_target)
100
- Textus::Core::Freshness::Evaluator.new(
101
- manifest: @manifest,
102
- file_stat: Textus::Ports::Storage::FileStat.new,
103
- clock: Textus::Ports::Clock.new,
104
- ).stale_intake_keys(prefix: nil, lane: nil)
105
- end
106
-
107
- def lane_keys(_target)
108
- @manifest.data.entries.map(&:key)
109
- end
110
- end
111
- end
112
- end
113
- end
@@ -1,50 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Background
5
- module Retention
6
- class Apply
7
- def initialize(container:, call:)
8
- @container = container
9
- @call = call
10
- end
11
-
12
- def call(rows)
13
- out = { dropped: [], archived: [], failed: [] }
14
- rows.each do |row|
15
- key = row["key"]
16
- begin
17
- case row["action"]
18
- when "drop"
19
- delete(key)
20
- out[:dropped] << key
21
- when "archive"
22
- archive_leaf(row)
23
- delete(key)
24
- out[:archived] << key
25
- end
26
- rescue Textus::Error => e
27
- out[:failed] << { "key" => key, "error" => e.message }
28
- end
29
- end
30
- out
31
- end
32
-
33
- private
34
-
35
- def archive_leaf(row)
36
- src = row["path"]
37
- root = @container.root.to_s
38
- rel = src.delete_prefix("#{root}/")
39
- dest = File.join(root, "archive", rel)
40
- FileUtils.mkdir_p(File.dirname(dest))
41
- FileUtils.cp(src, dest)
42
- end
43
-
44
- def delete(key)
45
- Textus::Action::KeyDelete.new(key: key).call(container: @container, call: @call)
46
- end
47
- end
48
- end
49
- end
50
- end
@@ -1,34 +0,0 @@
1
- module Textus
2
- module Doctor
3
- class Check
4
- # For every entry with a fetch handler, look up its handler_permit policy
5
- # (if any) and verify the declared handler is permitted. Emits a failure
6
- # when the handler is rejected by policy.
7
- class HandlerPermit < Check
8
- def call
9
- out = []
10
- manifest.data.entries.each do |mentry|
11
- next unless mentry.intake?
12
-
13
- handler = mentry.handler
14
-
15
- permit = manifest.rules.for(mentry.key).handler_permit
16
- next if permit.nil?
17
- next if permit.permits?(handler)
18
-
19
- out << {
20
- "code" => "policy.handler_not_permitted",
21
- "level" => "error",
22
- "subject" => mentry.key,
23
- "message" => "entry '#{mentry.key}' declares source.handler='#{handler}' but " \
24
- "handler_permit allows only: #{permit.handlers.join(", ")}",
25
- "fix" => "change handler to one of [#{permit.handlers.join(", ")}] or " \
26
- "extend handler_permit in .textus/manifest.yaml",
27
- }
28
- end
29
- out
30
- end
31
- end
32
- end
33
- end
34
- end
@@ -1,25 +0,0 @@
1
- module Textus
2
- module Doctor
3
- class Check
4
- class Hooks < Check
5
- def call
6
- out = []
7
- dir = File.join(root, "steps")
8
- return out unless File.directory?(dir)
9
-
10
- Textus::Step::Loader.new(registry: Textus::Step::RegistryStore.new).load_dir(dir)
11
- out
12
- rescue Textus::UsageError => e
13
- out << {
14
- "code" => "step.load_failed",
15
- "level" => "error",
16
- "subject" => "steps",
17
- "message" => e.message,
18
- "fix" => "open the named step file under .textus/steps/ and fix the error",
19
- }
20
- out
21
- end
22
- end
23
- end
24
- end
25
- end
@@ -1,46 +0,0 @@
1
- module Textus
2
- module Doctor
3
- class Check
4
- class IntakeRegistration < Check
5
- BUILTIN = %i[json csv markdown-links ical-events rss].freeze
6
-
7
- def call
8
- declared = collect_declared_handlers
9
- registered = steps.names(:fetch).to_set
10
-
11
- out = (declared - registered).map do |name|
12
- {
13
- "code" => "intake.handler_missing",
14
- "level" => "error",
15
- "subject" => name.to_s,
16
- "message" => "manifest references intake handler '#{name}' but no fetch step for '#{name}' is registered",
17
- "fix" => "create .textus/steps/fetch/#{name}.rb with `class #{name.to_s.split(/[-_]/).map(&:capitalize).join}Fetch < Textus::Step::Fetch`",
18
- }
19
- end
20
-
21
- (registered - declared - BUILTIN.to_set).each do |name|
22
- out << {
23
- "code" => "intake.handler_orphan",
24
- "level" => "warning",
25
- "subject" => name.to_s,
26
- "message" => "fetch step '#{name}' is registered but no manifest entry references it",
27
- "fix" => "remove the unused step, or add an entry with `intake.handler: #{name}`",
28
- }
29
- end
30
-
31
- out
32
- end
33
-
34
- private
35
-
36
- def collect_declared_handlers
37
- set = Set.new
38
- manifest.data.entries.each do |mentry|
39
- set << mentry.handler.to_sym if mentry.intake?
40
- end
41
- set
42
- end
43
- end
44
- end
45
- end
46
- end
@@ -1,48 +0,0 @@
1
- module Textus
2
- class Envelope
3
- module IO
4
- # Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
5
- # bytes, parses them via the format strategy, and hands back an
6
- # Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
7
- # (existing-uid lookup for the uid-preservation step in #put).
8
- #
9
- # No audit, no events, no permission checks — those live one layer up.
10
- class Reader
11
- def self.from(container:)
12
- new(file_store: container.file_store, manifest: container.manifest)
13
- end
14
-
15
- def initialize(file_store:, manifest:)
16
- @file_store = file_store
17
- @manifest = manifest
18
- end
19
-
20
- def read(key)
21
- res = @manifest.resolver.resolve(key)
22
- path = res.path
23
- return nil unless @file_store.exists?(path)
24
-
25
- mentry = res.entry
26
- raw = @file_store.read(path)
27
- parsed = Entry.for_format(mentry.format).parse(raw, path: path)
28
- Textus::Envelope.build(
29
- key: key, mentry: mentry, path: path,
30
- meta: parsed["_meta"], body: parsed["body"],
31
- etag: Etag.for_bytes(raw), content: parsed["content"]
32
- )
33
- end
34
-
35
- def existing_uid(key)
36
- env = read(key)
37
- env&.uid
38
- rescue StandardError
39
- nil
40
- end
41
-
42
- def exists?(key)
43
- @file_store.exists?(@manifest.resolver.resolve(key).path)
44
- end
45
- end
46
- end
47
- end
48
- end
@@ -1,211 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- class Envelope
5
- module IO
6
- # Owns the write pipeline (validate, serialize, etag-check, write, audit).
7
- # Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
8
- # Reader for the existing-uid lookup.
9
- #
10
- # Invariant: every public method's final action is @audit_log.append(...).
11
- #
12
- # No permission check, no event firing — those belong to the caller
13
- # (Write::Put / ::Delete / ::Mv).
14
- class Writer
15
- Payload = Data.define(:meta, :body, :content)
16
-
17
- def self.from(container:, call:)
18
- new(
19
- file_store: container.file_store, manifest: container.manifest,
20
- schemas: container.schemas, audit_log: container.audit_log,
21
- call: call, reader: Reader.from(container: container)
22
- )
23
- end
24
-
25
- def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
26
- @file_store = file_store
27
- @manifest = manifest
28
- @schemas = schemas
29
- @audit_log = audit_log
30
- @call = call
31
- @reader = reader
32
- end
33
-
34
- def put(key, mentry:, payload:, if_etag: nil)
35
- path = resolve_path(key)
36
- meta, content = prepare_uid(mentry, payload, key)
37
- bytes, eff_meta, eff_body, eff_content = serialize_entry(mentry, path, meta, payload, content)
38
- enforce_name_match!(path, eff_meta, mentry.format)
39
- validate_schema(mentry, eff_meta, eff_content)
40
- etag_before = check_etag!(path, key, if_etag)
41
- write_bytes(path, bytes)
42
- envelope = build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
43
- audit_put(key, etag_before, envelope.etag)
44
- envelope
45
- end
46
-
47
- def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
48
- # `mentry:` is accepted for symmetry with `put` / `move` and to
49
- # leave room for future format-specific delete hooks; no field
50
- # on it is needed today.
51
- path = @manifest.resolver.resolve(key).path
52
- raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
53
-
54
- etag_before = @file_store.etag(path)
55
- raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
56
-
57
- @file_store.delete(path)
58
- prune_empty_parents(path)
59
- @audit_log.append(
60
- role: @call.role, verb: "key_delete", key: key,
61
- etag_before: etag_before, etag_after: nil,
62
- extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
63
- )
64
- end
65
-
66
- def move(from_key:, to_key:, new_mentry:, if_etag: nil)
67
- from_path = @manifest.resolver.resolve(from_key).path
68
- to_path = @manifest.resolver.resolve(to_key).path
69
- raise UnknownKey.new(from_key, suggestions: @manifest.resolver.suggestions_for(from_key)) unless @file_store.exists?(from_path)
70
-
71
- etag_before = @file_store.etag(from_path)
72
- raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before
73
-
74
- FileUtils.mkdir_p(File.dirname(to_path))
75
- FileUtils.mv(from_path, to_path)
76
- prune_empty_parents(from_path)
77
- basename = to_key.split(".").last
78
- Entry.for_format(new_mentry.format).rewrite_name(to_path, basename)
79
- etag_after = Etag.for_file(to_path)
80
-
81
- raw = @file_store.read(to_path)
82
- parsed = Entry.for_format(new_mentry.format).parse(raw, path: to_path)
83
- envelope = Textus::Envelope.build(
84
- key: to_key, mentry: new_mentry, path: to_path,
85
- meta: parsed["_meta"], body: parsed["body"],
86
- etag: etag_after, content: parsed["content"]
87
- )
88
-
89
- extras = {
90
- "from_key" => from_key, "to_key" => to_key,
91
- "from_path" => from_path, "to_path" => to_path,
92
- "uid" => envelope.uid
93
- }
94
- extras["correlation_id"] = @call.correlation_id if @call.correlation_id
95
-
96
- @audit_log.append(
97
- role: @call.role, verb: "key_mv", key: to_key,
98
- etag_before: etag_before, etag_after: etag_after,
99
- extras: extras
100
- )
101
-
102
- envelope
103
- end
104
-
105
- private
106
-
107
- # After a file leaves a directory (delete or move-source), remove any
108
- # now-empty parent dirs so bulk move/delete doesn't accrue orphan dirs
109
- # (F3 of #161). Floored at the entry's *zone directory* — a zone is a
110
- # declared, first-class container, so its own dir is preserved even when
111
- # momentarily empty; only the sub-dirs the bulk op carved out are
112
- # pruned. Stops at the first non-empty ancestor, so a dir holding a
113
- # `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
114
- # non-empty dir is silently fine, never fatal to the write.
115
- def prune_empty_parents(path)
116
- floor = zone_floor(path)
117
- return unless floor
118
-
119
- dir = File.dirname(path)
120
- while dir.start_with?("#{floor}/") && Dir.empty?(dir)
121
- Dir.rmdir(dir)
122
- dir = File.dirname(dir)
123
- end
124
- rescue SystemCallError
125
- nil
126
- end
127
-
128
- # The zone directory under which `path` lives (`<root>/zones/<zone>`),
129
- # or nil if `path` is not under the store's zones tree.
130
- def zone_floor(path)
131
- zones_root = File.join(@manifest.data.root, "data")
132
- prefix = "#{zones_root}/"
133
- return nil unless path.start_with?(prefix)
134
-
135
- zone_seg = path.delete_prefix(prefix).split("/").first
136
- zone_seg && File.join(zones_root, zone_seg)
137
- end
138
-
139
- def ensure_uid(format, meta, content, existing_uid)
140
- Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
141
- end
142
-
143
- def enforce_name_match!(path, meta, format)
144
- Textus::Entry.for_format(format).enforce_name_match!(path, meta)
145
- end
146
-
147
- def serialize_for_put(mentry:, path:, meta:, body:, content:)
148
- Textus::Entry.for_format(mentry.format).serialize_for_put(
149
- meta: meta, body: body, content: content, path: path,
150
- )
151
- end
152
-
153
- def resolve_path(key)
154
- @manifest.resolver.resolve(key).path
155
- end
156
-
157
- def prepare_uid(mentry, payload, key)
158
- meta = payload.meta || {}
159
- existing_uid = @reader.existing_uid(key)
160
- ensure_uid(mentry.format, meta, payload.content, existing_uid)
161
- end
162
-
163
- def serialize_entry(mentry, path, meta, payload, content)
164
- serialize_for_put(
165
- mentry: mentry, path: path,
166
- meta: meta, body: payload.body, content: content
167
- )
168
- end
169
-
170
- def validate_schema(mentry, eff_meta, eff_content)
171
- schema = @schemas.fetch_or_nil(mentry.schema)
172
- return unless schema
173
-
174
- Entry.for_format(mentry.format).validate_against(
175
- schema,
176
- { "_meta" => eff_meta, "content" => eff_content },
177
- )
178
- end
179
-
180
- def check_etag!(path, key, if_etag)
181
- etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
182
- raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
183
-
184
- etag_before
185
- end
186
-
187
- def write_bytes(path, bytes)
188
- @file_store.write(path, bytes)
189
- end
190
-
191
- def build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
192
- Textus::Envelope.build(
193
- key: key, mentry: mentry, path: path,
194
- meta: eff_meta, body: eff_body,
195
- etag: Etag.for_bytes(@file_store.read(path)),
196
- content: eff_content
197
- )
198
- end
199
-
200
- def audit_put(key, etag_before, etag_after)
201
- extras = @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
202
- @audit_log.append(
203
- role: @call.role, verb: "put", key: key,
204
- etag_before: etag_before, etag_after: etag_after,
205
- extras: extras
206
- )
207
- end
208
- end
209
- end
210
- end
211
- end
data/lib/textus/events.rb DELETED
@@ -1,21 +0,0 @@
1
- module Textus
2
- module Events
3
- ENTRY_WRITTEN = "entry.written"
4
- ENTRY_DELETED = "entry.deleted"
5
- ENTRY_RENAMED = "entry.renamed"
6
- ENTRY_FETCHED = "entry.fetched"
7
- ENTRY_DERIVED = "entry.derived"
8
- ENTRY_VALIDATED = "entry.validated"
9
- ENTRY_PUBLISHED = "entry.published"
10
- PIPELINE_FAILED = "pipeline.failed"
11
-
12
- STEP_FETCH_COMPLETE = "step.fetch.complete"
13
- STEP_TRANSFORM_COMPLETE = "step.transform.complete"
14
- STEP_VALIDATE_PASSED = "step.validate.passed"
15
- STEP_VALIDATE_FAILED = "step.validate.failed"
16
-
17
- STORE_LOADED = "store.loaded"
18
- SESSION_OPENED = "session.opened"
19
- SESSION_CLOSED = "session.closed"
20
- end
21
- end
@@ -1,49 +0,0 @@
1
- # .textus/steps/fetch/machine_intake.rb
2
- # Scaffolded by `textus init` — CUSTOMIZE FREELY, or delete the feeds.machines
3
- # entry from manifest.yaml if you don't want it.
4
- # Feeds a per-host SNAPSHOT into feeds.machines.<host> on `textus drain`
5
- # (never on the per-turn boot/pulse path). It is NESTED so it grows to a fleet: the
6
- # `local` leaf scans THIS host; add ssh hosts with the cookbook recipe
7
- # (docs/cookbook/environment-scan.md). tracked:false → gitignored. Keep this an
8
- # ALLOWLIST of versions and counts — NEVER secrets, raw `env`, or package lists.
9
- module Textus
10
- module Step
11
- class MachineIntakeFetch < Fetch
12
- def call(config:, args:, caps:, **)
13
- machine = args[:leaf_segments].first or
14
- raise "machines intake needs a host leaf, e.g. the 'local' in feeds.machines.local"
15
- spec = (config["machines"] || {}).fetch(machine) { raise "unknown machine: #{machine}" }
16
- unless (spec["via"] || "local").to_s == "local"
17
- raise "machine #{machine}: only `via: local` is scaffolded — see " \
18
- "docs/cookbook/environment-scan.md for the SSH (remote) fan-out"
19
- end
20
-
21
- sh = ->(cmd) { `#{cmd}`.strip } # local shell-out, no network
22
- ver = ->(cmd) { o = `#{cmd} 2>/dev/null`.strip; o.empty? ? nil : o } # nil if tool absent
23
- count = ->(cmd) { n = `#{cmd} 2>/dev/null`.strip.lines.size; n.zero? ? nil : n }
24
- { content: {
25
- # git_* describe THIS repo on the control host — only meaningful for `local`.
26
- "git_head" => sh.call("git rev-parse --short HEAD 2>/dev/null"),
27
- "git_branch" => sh.call("git rev-parse --abbrev-ref HEAD 2>/dev/null"),
28
- "git_dirty" => !sh.call("git status --porcelain 2>/dev/null").empty?,
29
- "repo_root" => sh.call("git rev-parse --show-toplevel 2>/dev/null"),
30
- "captured_at" => Time.now.utc.iso8601,
31
- "os" => RbConfig::CONFIG["host_os"],
32
- "arch" => RbConfig::CONFIG["host_cpu"],
33
- "ruby_version" => RUBY_VERSION,
34
- "runtimes" => { # versions only; nil when not installed
35
- "node" => ver.call("node --version"),
36
- "python" => ver.call("python3 --version"),
37
- "go" => ver.call("go version"),
38
- },
39
- "packages" => { # COUNTS only — never the list (size/secrets)
40
- "brew" => count.call("brew list --formula"), # ~1-3s on macOS; runs only on fetch, amortized by the ttl rule
41
- "apt" => count.call("dpkg-query -f '.\n' -W"),
42
- },
43
- "textus_version" => Textus::VERSION,
44
- "protocol" => Textus::PROTOCOL,
45
- } }
46
- end
47
- end
48
- end
49
- end
@@ -1,21 +0,0 @@
1
- # Reducer that reshapes the raw projection rows into the keys the
2
- # orientation.mustache template references. Without this, the template
3
- # would only have access to the flat rows list.
4
- module Textus
5
- module Step
6
- class OrientationTransform < Transform
7
- def call(rows:, config:, **)
8
- project_row = rows.find { |r| r["_key"] == "knowledge.project" } || {}
9
- runbook_rows = rows.select { |r| r["_key"]&.start_with?("knowledge.runbooks.") }
10
-
11
- {
12
- "project" => {
13
- "name" => project_row["name"],
14
- "description" => project_row["description"]
15
- },
16
- "runbooks" => runbook_rows.map { |r| { "name" => r["name"], "description" => r["description"] } }
17
- }
18
- end
19
- end
20
- end
21
- end
@@ -1,22 +0,0 @@
1
- module Textus
2
- class Manifest
3
- class Entry
4
- module Validators
5
- module Events
6
- def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
7
- pubsub_events = Textus::Step::Catalog::PUBSUB.keys
8
- events = entry.events
9
- events.each_key do |evt|
10
- next if pubsub_events.include?(evt.to_sym)
11
-
12
- raise UsageError.new(
13
- "entry '#{entry.key}': unknown event '#{evt}' in events: block. " \
14
- "Known events: #{pubsub_events.join(", ")}.",
15
- )
16
- end
17
- end
18
- end
19
- end
20
- end
21
- end
22
- end
@@ -1,17 +0,0 @@
1
- module Textus
2
- class Manifest
3
- class Policy
4
- class HandlerPermit
5
- attr_reader :handlers
6
-
7
- def initialize(handlers:)
8
- @handlers = Array(handlers).map(&:to_s).freeze
9
- end
10
-
11
- def permits?(handler)
12
- @handlers.include?(handler.to_s)
13
- end
14
- end
15
- end
16
- end
17
- end