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
@@ -1,129 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Pipeline
5
- module Acquire
6
- # Builds an entry's DATA artifact (ADR 0094) by running the projection
7
- # pipeline; rendering is a publish concern. External entries are NOT built
8
- # here — they are generated by an out-of-band runner; Derived#publish_via
9
- # filters them out before reaching this point.
10
- #
11
- # Merges the former Write::DataBuilder wrapper and Builder::Pipeline module
12
- # into one class (ADR 0100 produce/ topology refactor).
13
- class Projection
14
- # Injects provenance metadata as the first key in the serialized output.
15
- # Carries only deterministic provenance (`from`/`reduce`) — the volatile
16
- # `generated_at` is deliberately NOT stamped, so the built artifact is
17
- # content-addressed and a rebuild is a byte-for-byte no-op (ADR 0070).
18
- # Build time lives out of the tracked artifact.
19
- module InjectMeta
20
- def self.call(content_hash, mentry)
21
- meta = {}
22
- if mentry.derived?
23
- src = mentry.source
24
- if src.projection?
25
- from = Array(src.select).compact
26
- meta["from"] = from unless from.empty?
27
- meta["reduce"] = src.transform if src.transform
28
- end
29
- end
30
-
31
- out = { "_meta" => meta }
32
- content_hash.each { |k, v| out[k] = v unless k == "_meta" }
33
- out
34
- end
35
- end
36
-
37
- Deps = Data.define(:manifest, :reader, :lister, :steps, :transform_context)
38
-
39
- def self.renderers
40
- @renderers ||= {
41
- "text" => Textus::Pipeline::Acquire::Serializer::Text,
42
- "json" => Textus::Pipeline::Acquire::Serializer::Json,
43
- "yaml" => Textus::Pipeline::Acquire::Serializer::Yaml,
44
- }
45
- end
46
-
47
- def initialize(container:, call:)
48
- @container = container
49
- @call = call
50
- @manifest = container.manifest
51
- @file_store = container.file_store
52
- @steps = container.steps
53
- @root = container.root
54
- end
55
-
56
- # Runs the projection pipeline for `mentry` and returns the on-disk
57
- # target_path string.
58
- def run(mentry)
59
- reader = lambda do |key|
60
- Textus::Action::Get.new(key: key).call(container: @container, call: @call)
61
- end
62
- # Projections must be able to read source data from any nested entry,
63
- # including keyless (publish_tree) ones like knowledge.decisions.
64
- # The `include_keyless: true` option makes the resolver walk those dirs
65
- # without exposing them on the public `list` / CLI surface (ADR 0047).
66
- resolver = @manifest.resolver
67
- lister = lambda do |prefix:|
68
- resolver.enumerate(prefix: prefix, include_keyless: true)
69
- .map { |row| { "key" => row[:key], "lane" => row[:manifest_entry].lane, "path" => row[:path] } }
70
- end
71
- self.class.pipeline_run(
72
- mentry: mentry,
73
- deps: Deps.new(
74
- manifest: @manifest,
75
- reader: reader,
76
- lister: lister,
77
- steps: @steps,
78
- transform_context: @container,
79
- ),
80
- )
81
- end
82
-
83
- def self.pipeline_run(mentry:, deps:)
84
- # 1. Load sources + project + reduce. Only projection-derived entries are
85
- # buildable in-process; External entries are generated out-of-band and are
86
- # filtered out upstream (Derived#publish_via), so reaching here with a
87
- # non-projection source is a wiring bug — fail loudly rather than emit an
88
- # empty payload (and never re-stamp the volatile generated_at, ADR 0070).
89
- unless mentry.projection?
90
- raise UsageError.new(
91
- "builder: '#{mentry.key}' is not a projection-derived entry; only projections are buildable",
92
- )
93
- end
94
-
95
- data =
96
- Textus::Projection.new(
97
- reader: deps.reader,
98
- spec: mentry.source.projection_spec,
99
- lister: deps.lister,
100
- steps: deps.steps,
101
- transform_context: deps.transform_context,
102
- ).run
103
-
104
- # 2. Serialize as DATA. Rendering through a template is a publish concern
105
- # (ADR 0094) — the build never consults a template.
106
- klass = renderers[mentry.format] or
107
- raise UsageError.new("builder: unsupported data format #{mentry.format.inspect} for '#{mentry.key}'")
108
- bytes = klass.new.call(mentry: mentry, data: data)
109
-
110
- # 3. Write (idempotent: skip if only generated_at would differ)
111
- target_path = Key::Path.resolve(deps.manifest.data, mentry)
112
- FileUtils.mkdir_p(File.dirname(target_path))
113
- write_if_changed(target_path, bytes, mentry.format)
114
-
115
- target_path
116
- end
117
-
118
- # Built artifacts are content-addressed (no volatile timestamp, ADR 0070),
119
- # so identity is plain byte-equality: skip the write when nothing changed.
120
- # `format` is retained for signature stability across renderers.
121
- def self.write_if_changed(target_path, bytes, _format)
122
- return if File.exist?(target_path) && File.binread(target_path) == bytes
123
-
124
- File.binwrite(target_path, bytes)
125
- end
126
- end
127
- end
128
- end
129
- end
@@ -1,31 +0,0 @@
1
- require "json"
2
-
3
- module Textus
4
- module Pipeline
5
- module Acquire
6
- class Serializer
7
- class Json < Serializer
8
- def call(mentry:, data:)
9
- content = default_shape(mentry, data)
10
- final = Textus::Pipeline::Acquire::Projection::InjectMeta.call(content, mentry)
11
- Entry.for_format("json").serialize(meta: {}, body: "", content: final)
12
- end
13
-
14
- private
15
-
16
- def default_shape(mentry, data)
17
- has_transform = mentry.projection? &&
18
- mentry.source.transform
19
- if has_transform && data.is_a?(Hash) && !data.key?("entries")
20
- data
21
- elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
22
- { "entries" => data["entries"] }
23
- else
24
- data.is_a?(Hash) ? data : { "entries" => Array(data) }
25
- end
26
- end
27
- end
28
- end
29
- end
30
- end
31
- end
@@ -1,16 +0,0 @@
1
- module Textus
2
- module Pipeline
3
- module Acquire
4
- class Serializer
5
- class Text < Serializer
6
- def call(mentry:, data:) # rubocop:disable Lint/UnusedMethodArgument
7
- # Text format serializes data as plain-text. Rendering through a
8
- # template is a publish concern (ADR 0094) — build emits data only.
9
- body = data.is_a?(Hash) ? data.to_s : data.inspect
10
- Entry.for_format("text").serialize(meta: {}, body: body)
11
- end
12
- end
13
- end
14
- end
15
- end
16
- end
@@ -1,31 +0,0 @@
1
- require "yaml"
2
-
3
- module Textus
4
- module Pipeline
5
- module Acquire
6
- class Serializer
7
- class Yaml < Serializer
8
- def call(mentry:, data:)
9
- content = default_shape(mentry, data)
10
- final = Textus::Pipeline::Acquire::Projection::InjectMeta.call(content, mentry)
11
- Entry.for_format("yaml").serialize(meta: {}, body: "", content: final)
12
- end
13
-
14
- private
15
-
16
- def default_shape(mentry, data)
17
- has_transform = mentry.projection? &&
18
- mentry.source.transform
19
- if has_transform && data.is_a?(Hash) && !data.key?("entries")
20
- data
21
- elsif data.is_a?(Hash) && data["entries"].is_a?(Array)
22
- { "entries" => data["entries"] }
23
- else
24
- data.is_a?(Hash) ? data : { "entries" => Array(data) }
25
- end
26
- end
27
- end
28
- end
29
- end
30
- end
31
- end
@@ -1,17 +0,0 @@
1
- module Textus
2
- module Pipeline
3
- module Acquire
4
- # Abstract base for output serializers. Each concrete serializer owns
5
- # producing the bytes for one manifest format (json/yaml/text).
6
- # Rendering through a template is a publish concern (ADR 0094) — serializers
7
- # here only serialize data; they take no arguments.
8
- class Serializer
9
- def call(mentry:, data:)
10
- _ = mentry
11
- _ = data
12
- raise NotImplementedError.new("#{self.class.name}#call not implemented")
13
- end
14
- end
15
- end
16
- end
17
- end
@@ -1,97 +0,0 @@
1
- module Textus
2
- module Pipeline
3
- # The single convergence engine (ADR 0093/0094). "Make these machine entries
4
- # current from upstream." Acquire is per-`from`; publish is one uniform
5
- # `publish_via` entry point for all kinds (ADR 0094):
6
- # intake (from: handler) -> re-pull (Produce::Acquire::Intake), then publish_via
7
- # derived (from: project) -> build data + publish_via (ToPaths or None)
8
- # derived (from: command) -> skip the build; publish_via publishes
9
- # existing store bytes via mode resolution
10
- # (None when no targets -> skipped)
11
- # Runs as the converge build actor (self-elevating); the passed `call`
12
- # supplies only correlation_id/dry_run. Callers choose the key set: the
13
- # write subscriber passes rdeps ∩ derived; the converge pass passes
14
- # all-derived + stale-intake.
15
- class Engine
16
- # Locked + failure-isolated convergence — the entry point worker handlers
17
- # call to materialize a key set (ADR 0093 / job-queue model). A held lock
18
- # is a soft miss (an in-flight build/converge already produces fresh
19
- # output); any other error is republished as :produce_failed and never
20
- # raised at the caller (ADR 0087 §5 failure isolation, preserved).
21
- def self.converge(container:, call:, keys:)
22
- Textus::Ports::BuildLock.with(root: container.root) do
23
- new(container: container, call: call).call(keys: keys)
24
- end
25
- rescue Textus::BuildInProgress
26
- nil
27
- rescue Textus::Error => e
28
- container.steps.publish(
29
- :produce_failed,
30
- ctx: Textus::Step::Context.for(container: container, call: call),
31
- keys: keys, error: e.message
32
- )
33
- end
34
-
35
- def initialize(container:, call:)
36
- @container = container
37
- @call = call
38
- @manifest = container.manifest
39
- end
40
-
41
- # keys: the machine entry keys to converge. Returns
42
- # { produced: [k...], skipped: [k...], failed: [{ "key"=>, "error"=> }...] }
43
- def call(keys:)
44
- build_call = build_actor_call
45
- context = build_context(build_call)
46
- out = { produced: [], skipped: [], failed: [] }
47
-
48
- keys.each do |key|
49
- produce_one(key, build_call, context, out)
50
- rescue Textus::Error => e
51
- out[:failed] << { "key" => key, "error" => e.message }
52
- end
53
- out
54
- end
55
-
56
- private
57
-
58
- # Acquire is per-`from`; publish is one uniform entry point (publish_via)
59
- # for every kind. The command emit-vs-skip falls out of publish-mode
60
- # resolution (Publish::None when no targets), so there is no command branch.
61
- def produce_one(key, build_call, context, out)
62
- entry = @manifest.resolver.resolve(key).entry
63
-
64
- if entry.intake?
65
- Textus::Pipeline::Acquire::Intake.new(container: @container, call: build_call).run(key) # acquire: re-pull
66
- entry.publish_via(context) # emit any targets
67
- out[:produced] << key # a fetch is production
68
- else
69
- result = entry.publish_via(context) # derived builds inside; command publishes-or-None
70
- result.nil? ? (out[:skipped] << key) : (out[:produced] << key)
71
- end
72
- end
73
-
74
- def build_actor_call
75
- build_role = @manifest.policy.actor_for("converge") or
76
- raise Textus::UsageError.new(
77
- "no role holds the 'converge' capability",
78
- hint: "declare a role with `can: [converge]` in .textus/manifest.yaml",
79
- )
80
- Textus::Call.build(
81
- role: build_role,
82
- correlation_id: @call.correlation_id,
83
- dry_run: @call.dry_run,
84
- )
85
- end
86
-
87
- def build_context(call)
88
- Textus::Manifest::Entry::Base::PublishContext.new(
89
- container: @container, call: call,
90
- reader: lambda { |key|
91
- Textus::Action::Get.new(key: key).call(container: @container, call: call)
92
- }
93
- )
94
- end
95
- end
96
- end
97
- end
@@ -1,25 +0,0 @@
1
- require "mustache"
2
-
3
- module Textus
4
- module Pipeline
5
- # Renders an entry's stored DATA into the bytes for one publish target
6
- # (ADR 0094). Relocates the Mustache logic that used to live in the
7
- # build-time Markdown renderer. Provenance is NOT added here — it lives in
8
- # the data's `_meta`; a template surfaces it if the output should show it.
9
- # A verbatim target (no template) is the caller's job to copy.
10
- class Render
11
- def initialize(template_loader:)
12
- @template_loader = template_loader
13
- end
14
-
15
- # target: a rendering Policy::PublishTarget. data: parsed entry data.
16
- # boot: boot context hash or nil. Returns the rendered String.
17
- def bytes_for(target:, data:, boot:)
18
- raise ArgumentError.new("Produce::Render called for a verbatim target #{target.to.inspect}") unless target.renders?
19
-
20
- ctx = target.inject_boot ? data.merge("boot" => boot) : data
21
- Mustache.render(@template_loader.call(target.template), ctx)
22
- end
23
- end
24
- end
25
- end
@@ -1,42 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Ports
5
- # Writes an "event_error" audit row when a user hook raises during
6
- # Step::EventBus publish. Attached at Store boot.
7
- #
8
- # Integration: uses Step::EventBus#on_error callback (chosen over a
9
- # synthetic :hook_error event because the bus already owns the
10
- # rescue and the failure is a bus-internal concern, not a domain
11
- # event subscribers should be able to filter by key glob).
12
- #
13
- # Lifecycle audit rows for verb: "put" / "key_delete" / "key_mv" are written
14
- # by Envelope::IO::Writer directly (it owns the
15
- # audit-append-as-final-step invariant); this subscriber covers the
16
- # hook-failure case the writer never sees.
17
- class AuditSubscriber
18
- def initialize(audit_log)
19
- @audit_log = audit_log
20
- end
21
-
22
- def attach(registry)
23
- registry.on_error do |event:, hook:, key:, kwargs:, error:|
24
- record_error(event: event, hook: hook, key: key, kwargs: kwargs, error: error)
25
- end
26
- self
27
- end
28
-
29
- private
30
-
31
- def record_error(event:, hook:, key:, kwargs:, error:)
32
- extras = { "event" => event.to_s, "hook" => hook.to_s, "error" => "#{error.class}: #{error.message}" }
33
- extras["target_key"] = kwargs[:target_key] if kwargs.key?(:target_key)
34
- extras["pending_key"] = kwargs[:pending_key] if kwargs.key?(:pending_key)
35
- @audit_log.append(
36
- role: Textus::Role::AUTOMATION, verb: "event_error", key: key,
37
- etag_before: nil, etag_after: nil, extras: extras
38
- )
39
- end
40
- end
41
- end
42
- end
@@ -1,103 +0,0 @@
1
- require "time"
2
- require "timeout"
3
-
4
- module Textus
5
- class Projection
6
- MAX_LIMIT = 1000
7
- REDUCER_TIMEOUT_SECONDS = 2
8
-
9
- # `reader` — a callable `->(key) { envelope_or_nil }`. `Read::Get` is a pure
10
- # read on every path (ADR 0089): it annotates freshness but never ingests,
11
- # so materialization and any other reader share the same side-effect-free read.
12
- # `lister` — a callable `->(prefix:) { [ { "key" => ... }, ... ] }`.
13
- # `rpc` — a `Step::RpcRegistry` used to dispatch `transform_rows` callables.
14
- # `transform_context` — capability object handed to transform reducers as `caps:`.
15
- def initialize(reader:, spec:, lister:, steps:, transform_context:)
16
- @reader = reader
17
- @spec = spec || {}
18
- @lister = lister
19
- @steps = steps
20
- @transform_context = transform_context
21
- @limit = (@spec["limit"] || MAX_LIMIT).to_i
22
- raise InvalidProjection.new("limit #{@limit} exceeds max #{MAX_LIMIT}") if @limit > MAX_LIMIT
23
- end
24
-
25
- def run
26
- keys = collect_keys
27
- explicit_pluck = !@spec["pluck"].nil? && @spec["pluck"] != "*"
28
- pluck_key = explicit_pluck && Array(@spec["pluck"]).include?("_key")
29
- rows = keys.map do |key|
30
- env = @reader.call(key)
31
- row = pluck(env.meta, env.body)
32
- if explicit_pluck
33
- pluck_key ? row.merge("_key" => key) : row
34
- else
35
- row.merge("_key" => key)
36
- end
37
- end
38
- reduced = apply_reducer(rows)
39
- # Reducers may return either an Array of rows (legacy / templated builds)
40
- # or a Hash that becomes the structured-format payload base. In the Hash
41
- # case, downstream sort/limit/position markers don't apply.
42
- return reduced if reduced.is_a?(Hash)
43
-
44
- rows = reduced
45
- rows = sort(rows)
46
- rows = rows.first(@limit)
47
- mark_positions(rows)
48
- # No `generated_at` in the payload — the built artifact is content-addressed
49
- # (ADR 0070); volatile build time is kept out of the tracked output.
50
- { "entries" => rows, "count" => rows.length }
51
- end
52
-
53
- private
54
-
55
- def apply_reducer(rows)
56
- name = @spec["transform"] or return rows
57
- Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
58
- @steps.invoke(:transform, name,
59
- caps: @transform_context,
60
- rows: rows,
61
- config: @spec["transform_config"] || {})
62
- end
63
- rescue Timeout::Error
64
- raise UsageError.new("transform '#{name}' exceeded #{REDUCER_TIMEOUT_SECONDS}s timeout")
65
- end
66
-
67
- def collect_keys
68
- prefixes = Array(@spec["select"])
69
- prefixes.flat_map { |p| @lister.call(prefix: p).map { |row| row["key"] } }.uniq
70
- end
71
-
72
- def pluck(frontmatter, body)
73
- fields = @spec["pluck"]
74
- if fields.nil? || fields == "*"
75
- frontmatter
76
- else
77
- Array(fields).each_with_object({}) do |f, h|
78
- if f == "body"
79
- h["body"] = body
80
- elsif frontmatter.key?(f)
81
- h[f] = frontmatter[f]
82
- end
83
- end
84
- end
85
- end
86
-
87
- # Adds `_first`, `_last`, and `_index` markers so templates can emit
88
- # delimiters (e.g. JSON commas) via {{^_last}},{{/_last}}.
89
- def mark_positions(rows)
90
- last_idx = rows.length - 1
91
- rows.each_with_index do |row, i|
92
- row["_index"] = i
93
- row["_first"] = i.zero?
94
- row["_last"] = (i == last_idx)
95
- end
96
- end
97
-
98
- def sort(rows)
99
- sb = @spec["sort_by"] or return rows
100
- rows.sort_by { |r| r[sb].to_s }
101
- end
102
- end
103
- end
@@ -1,35 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Step
5
- class Base
6
- class << self
7
- # The step kind is derived from class hierarchy.
8
- def kind
9
- return :fetch if defined?(Step::Fetch) && self <= Step::Fetch
10
- return :transform if defined?(Step::Transform) && self <= Step::Transform
11
- return :validate if defined?(Step::Validate) && self <= Step::Validate
12
- return :observe if defined?(Step::Observe) && self <= Step::Observe
13
-
14
- raise NotImplementedError.new("#{self} is not a known step kind")
15
- end
16
-
17
- # Required #call kwargs the loader validates against the subclass.
18
- def required_kwargs = []
19
-
20
- # Built-ins (and only built-ins) override the registered name when the
21
- # Ruby class name can't carry it (e.g. "markdown-links").
22
- def step_name(value = :__read__)
23
- if value == :__read__
24
- @step_name
25
- else
26
- @step_name = value.to_s
27
- end
28
- end
29
- end
30
-
31
- # Assigned by the loader/registry at registration time.
32
- attr_accessor :name
33
- end
34
- end
35
- end
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "csv"
4
- require "yaml"
5
-
6
- module Textus
7
- module Step
8
- module Builtin
9
- class CsvFetch < Step::Fetch
10
- step_name "csv"
11
- def call(config:, args:, **)
12
- _ = args
13
- rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
14
- { _meta: {}, body: YAML.dump(rows) }
15
- end
16
- end
17
- end
18
- end
19
- end
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
-
5
- module Textus
6
- module Step
7
- module Builtin
8
- class IcalEventsFetch < Step::Fetch
9
- step_name "ical-events"
10
- def call(config:, args:, **)
11
- _ = args
12
- events_list = []
13
- current = nil
14
- config["bytes"].to_s.each_line do |line|
15
- line = line.strip
16
- case line
17
- when "BEGIN:VEVENT" then current = {}
18
- when "END:VEVENT"
19
- events_list << current if current
20
- current = nil
21
- when /\A(SUMMARY|DTSTART|DTEND|UID|LOCATION|DESCRIPTION):(.*)\z/
22
- current[Regexp.last_match(1).downcase] = Regexp.last_match(2) if current
23
- end
24
- end
25
- { _meta: {}, body: YAML.dump(events_list) }
26
- end
27
- end
28
- end
29
- end
30
- end
@@ -1,18 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "json"
4
- require "yaml"
5
-
6
- module Textus
7
- module Step
8
- module Builtin
9
- class JsonFetch < Step::Fetch
10
- step_name "json"
11
- def call(config:, args:, **)
12
- _ = args
13
- { _meta: {}, body: YAML.dump(JSON.parse(config["bytes"].to_s)) }
14
- end
15
- end
16
- end
17
- end
18
- end
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
-
5
- module Textus
6
- module Step
7
- module Builtin
8
- class MarkdownLinksFetch < Step::Fetch
9
- step_name "markdown-links"
10
- def call(config:, args:, **)
11
- _ = args
12
- links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
13
- { "text" => text, "href" => href }
14
- end
15
- { _meta: {}, body: YAML.dump(links) }
16
- end
17
- end
18
- end
19
- end
20
- end
@@ -1,26 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "rexml/document"
4
- require "yaml"
5
-
6
- module Textus
7
- module Step
8
- module Builtin
9
- class RssFetch < Step::Fetch
10
- step_name "rss"
11
- def call(config:, args:, **)
12
- _ = args
13
- doc = REXML::Document.new(config["bytes"].to_s)
14
- items = doc.elements.to_a("//item").map do |item|
15
- {
16
- "title" => item.elements["title"]&.text,
17
- "link" => item.elements["link"]&.text,
18
- "pubDate" => item.elements["pubDate"]&.text,
19
- }
20
- end
21
- { _meta: {}, body: YAML.dump(items) }
22
- end
23
- end
24
- end
25
- end
26
- end
@@ -1,22 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Step
5
- # Registers the framework-provided fetch steps (json/csv/markdown-links/
6
- # ical-events/rss) into a registry at Store construction. The successor to
7
- # Step::Builtin.register_all.
8
- module Builtin
9
- STEPS = [
10
- JsonFetch, CsvFetch, MarkdownLinksFetch, IcalEventsFetch, RssFetch
11
- ].freeze
12
-
13
- def self.register_all(registry)
14
- STEPS.each do |klass|
15
- step = klass.new
16
- step.name = klass.step_name
17
- registry.register(step)
18
- end
19
- end
20
- end
21
- end
22
- end