textus 0.50.0 → 0.51.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 (117) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +26 -0
  3. data/README.md +41 -43
  4. data/SPEC.md +174 -176
  5. data/docs/architecture/README.md +46 -42
  6. data/docs/reference/conventions.md +31 -26
  7. data/lib/textus/boot.rb +13 -17
  8. data/lib/textus/call.rb +1 -1
  9. data/lib/textus/cli/runner.rb +15 -10
  10. data/lib/textus/cli/verb/get.rb +1 -3
  11. data/lib/textus/cli/verb/hook_run.rb +1 -1
  12. data/lib/textus/cli/verb/put.rb +4 -20
  13. data/lib/textus/cli.rb +1 -3
  14. data/lib/textus/dispatcher.rb +1 -3
  15. data/lib/textus/doctor/check/generator_drift.rb +4 -3
  16. data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
  17. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  18. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  19. data/lib/textus/doctor/check/sentinels.rb +2 -2
  20. data/lib/textus/doctor/check/templates.rb +13 -11
  21. data/lib/textus/doctor.rb +0 -2
  22. data/lib/textus/domain/freshness/evaluator.rb +150 -14
  23. data/lib/textus/domain/freshness/verdict.rb +28 -6
  24. data/lib/textus/domain/freshness.rb +4 -33
  25. data/lib/textus/domain/policy/base_guards.rb +1 -1
  26. data/lib/textus/domain/policy/predicates/fresh_within.rb +1 -1
  27. data/lib/textus/domain/policy/publish_target.rb +34 -0
  28. data/lib/textus/domain/policy/retention.rb +29 -0
  29. data/lib/textus/domain/policy/source.rb +79 -0
  30. data/lib/textus/domain/retention/sweep.rb +57 -0
  31. data/lib/textus/domain/retention.rb +11 -0
  32. data/lib/textus/errors.rb +4 -4
  33. data/lib/textus/hooks/builtin.rb +5 -5
  34. data/lib/textus/hooks/catalog.rb +8 -7
  35. data/lib/textus/hooks/context.rb +5 -10
  36. data/lib/textus/init/templates/machine_intake.rb +4 -4
  37. data/lib/textus/init.rb +47 -47
  38. data/lib/textus/key/matching.rb +24 -0
  39. data/lib/textus/maintenance/reconcile.rb +160 -0
  40. data/lib/textus/manifest/capabilities.rb +1 -1
  41. data/lib/textus/manifest/data.rb +2 -2
  42. data/lib/textus/manifest/entry/base.rb +28 -9
  43. data/lib/textus/manifest/entry/nested.rb +3 -4
  44. data/lib/textus/manifest/entry/parser.rb +25 -21
  45. data/lib/textus/manifest/entry/produced.rb +56 -0
  46. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +7 -6
  47. data/lib/textus/manifest/entry/publish/to_paths.rb +62 -11
  48. data/lib/textus/manifest/entry/validators/format_matrix.rb +3 -11
  49. data/lib/textus/manifest/entry/validators/publish.rb +3 -1
  50. data/lib/textus/manifest/entry/validators.rb +0 -1
  51. data/lib/textus/manifest/policy.rb +16 -4
  52. data/lib/textus/manifest/resolver.rb +10 -4
  53. data/lib/textus/manifest/rules.rb +37 -36
  54. data/lib/textus/manifest/schema/keys.rb +98 -0
  55. data/lib/textus/manifest/schema/validator.rb +324 -0
  56. data/lib/textus/manifest/schema/vocabulary.rb +24 -0
  57. data/lib/textus/manifest/schema.rb +27 -247
  58. data/lib/textus/manifest.rb +5 -3
  59. data/lib/textus/mcp/server.rb +1 -1
  60. data/lib/textus/ports/audit_log.rb +6 -0
  61. data/lib/textus/ports/build_lock.rb +6 -0
  62. data/lib/textus/ports/clock.rb +4 -3
  63. data/lib/textus/ports/produce_on_write_subscriber.rb +69 -0
  64. data/lib/textus/ports/publisher.rb +11 -7
  65. data/lib/textus/produce/acquire/handler.rb +29 -0
  66. data/lib/textus/produce/acquire/intake.rb +130 -0
  67. data/lib/textus/produce/acquire/projection.rb +127 -0
  68. data/lib/textus/produce/acquire/serializer/json.rb +31 -0
  69. data/lib/textus/produce/acquire/serializer/text.rb +16 -0
  70. data/lib/textus/produce/acquire/serializer/yaml.rb +31 -0
  71. data/lib/textus/produce/acquire/serializer.rb +17 -0
  72. data/lib/textus/produce/engine.rb +143 -0
  73. data/lib/textus/produce/events.rb +36 -0
  74. data/lib/textus/produce/render.rb +23 -0
  75. data/lib/textus/projection.rb +17 -6
  76. data/lib/textus/read/deps.rb +3 -3
  77. data/lib/textus/read/freshness.rb +61 -31
  78. data/lib/textus/read/get.rb +20 -102
  79. data/lib/textus/read/rdeps.rb +3 -3
  80. data/lib/textus/read/rule_explain.rb +41 -23
  81. data/lib/textus/read/rule_list.rb +25 -8
  82. data/lib/textus/read/validate_all.rb +14 -0
  83. data/lib/textus/role.rb +2 -1
  84. data/lib/textus/schemas.rb +8 -0
  85. data/lib/textus/store.rb +1 -0
  86. data/lib/textus/version.rb +1 -1
  87. data/lib/textus/write/put.rb +1 -1
  88. metadata +23 -30
  89. data/lib/textus/builder/pipeline.rb +0 -88
  90. data/lib/textus/builder/renderer/json.rb +0 -45
  91. data/lib/textus/builder/renderer/markdown.rb +0 -24
  92. data/lib/textus/builder/renderer/text.rb +0 -14
  93. data/lib/textus/builder/renderer/yaml.rb +0 -45
  94. data/lib/textus/builder/renderer.rb +0 -17
  95. data/lib/textus/cli/verb/boot.rb +0 -14
  96. data/lib/textus/cli/verb/build.rb +0 -15
  97. data/lib/textus/doctor/check/fetch_locks.rb +0 -49
  98. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
  99. data/lib/textus/domain/freshness/policy.rb +0 -18
  100. data/lib/textus/domain/lifecycle.rb +0 -83
  101. data/lib/textus/domain/outcome.rb +0 -10
  102. data/lib/textus/domain/policy/lifecycle.rb +0 -35
  103. data/lib/textus/domain/staleness/generator_check.rb +0 -109
  104. data/lib/textus/domain/staleness.rb +0 -29
  105. data/lib/textus/maintenance/tend.rb +0 -110
  106. data/lib/textus/manifest/entry/derived.rb +0 -67
  107. data/lib/textus/manifest/entry/intake.rb +0 -31
  108. data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
  109. data/lib/textus/mcp/tools.rb +0 -14
  110. data/lib/textus/ports/fetch/detached.rb +0 -52
  111. data/lib/textus/ports/fetch/lock.rb +0 -44
  112. data/lib/textus/write/build.rb +0 -90
  113. data/lib/textus/write/fetch_events.rb +0 -42
  114. data/lib/textus/write/fetch_orchestrator.rb +0 -101
  115. data/lib/textus/write/fetch_worker.rb +0 -127
  116. data/lib/textus/write/intake_fetch.rb +0 -25
  117. data/lib/textus/write/materializer.rb +0 -51
@@ -1,101 +0,0 @@
1
- module Textus
2
- module Write
3
- class FetchOrchestrator
4
- # Collaborator (not a Dispatcher verb): constructed directly by FetchWorker /
5
- # Read::Get, which pass their derived hook_context in. That's why this takes
6
- # hook_context: explicitly while verb use cases derive their own.
7
- def initialize(worker:, store_root:, events:, hook_context: nil, detached_spawner: nil)
8
- @worker = worker
9
- @store_root = store_root
10
- @events = events
11
- @hook_context = hook_context
12
- @detached_spawner = detached_spawner || default_spawner
13
- @fetch_events = Textus::Write::FetchEvents.new(events: @events, hook_context: @hook_context)
14
- end
15
-
16
- def execute(action, key:)
17
- case action
18
- when Textus::Domain::Action::Return then Textus::Domain::Outcome::Skipped.new
19
- when Textus::Domain::Action::FetchSync then run_sync(key)
20
- when Textus::Domain::Action::FetchTimed then run_timed(action.budget_ms, key)
21
- else raise ArgumentError.new("unknown action: #{action.inspect}")
22
- end
23
- end
24
-
25
- private
26
-
27
- def run_sync(key)
28
- envelope = @worker.run(key)
29
- Textus::Domain::Outcome::Fetched.new(envelope: envelope)
30
- rescue Textus::Error => e
31
- Textus::Domain::Outcome::Failed.new(error: e)
32
- end
33
-
34
- def run_timed(budget_ms, key)
35
- return run_timed_with_fork(budget_ms, key) if Textus::Ports::Fetch::Detached.supported?
36
-
37
- run_timed_cooperative(budget_ms, key)
38
- end
39
-
40
- def run_timed_cooperative(budget_ms, key)
41
- result = nil
42
- thread = Thread.new do
43
- result = @worker.run(key)
44
- rescue Textus::Error => e
45
- result = e
46
- end
47
-
48
- thread.join(budget_ms / 1000.0)
49
- if thread.alive?
50
- thread.kill
51
- return Textus::Domain::Outcome::Failed.new(
52
- error: Textus::UsageError.new(
53
- "fetch exceeded budget #{budget_ms}ms (no fork available — cooperative cancel)",
54
- ),
55
- )
56
- end
57
-
58
- if result.is_a?(Textus::Error)
59
- Textus::Domain::Outcome::Failed.new(error: result)
60
- else
61
- Textus::Domain::Outcome::Fetched.new(envelope: result)
62
- end
63
- end
64
-
65
- def run_timed_with_fork(budget_ms, key)
66
- result = nil
67
- thread = Thread.new do
68
- result = @worker.run(key)
69
- rescue Textus::Error => e
70
- result = e
71
- end
72
-
73
- thread.join(budget_ms / 1000.0)
74
-
75
- if thread.alive?
76
- thread.kill
77
-
78
- # Single-flight: if a sibling process / earlier fork holds the
79
- # per-leaf lock, don't fork another worker — they're already
80
- # doing this work.
81
- probe = Textus::Ports::Fetch::Lock.new(root: @store_root, key: key)
82
- return Textus::Domain::Outcome::Detached.new unless probe.try_acquire
83
-
84
- probe.release
85
-
86
- @fetch_events.backgrounded(key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms)
87
- @detached_spawner.call(store_root: @store_root, key: key)
88
- Textus::Domain::Outcome::Detached.new
89
- elsif result.is_a?(Textus::Error)
90
- Textus::Domain::Outcome::Failed.new(error: result)
91
- else
92
- Textus::Domain::Outcome::Fetched.new(envelope: result)
93
- end
94
- end
95
-
96
- def default_spawner
97
- Textus::Ports::Fetch::Detached.method(:spawn)
98
- end
99
- end
100
- end
101
- end
@@ -1,127 +0,0 @@
1
- require "timeout"
2
-
3
- module Textus
4
- module Write
5
- # Internal fetch executor for one quarantine/intake entry. No longer a
6
- # public verb (ADR 0079 collapsed the `fetch` surface): used by `get`'s
7
- # orchestrator (read-through refresh) and by the `tend` sweep.
8
- class FetchWorker
9
- FETCH_TIMEOUT_SECONDS = IntakeFetch::FETCH_TIMEOUT_SECONDS
10
-
11
- def initialize(container:, call:)
12
- @container = container
13
- @call = call
14
- @manifest = container.manifest
15
- @schemas = container.schemas
16
- @rpc = container.rpc
17
- end
18
-
19
- # call(key) is the primary entry; run is kept as an alias for
20
- # Orchestrator and FetchAll which call worker.run(key).
21
- def call(key)
22
- run(key)
23
- end
24
-
25
- def run(key)
26
- res = @manifest.resolver.resolve(key)
27
- mentry = res.entry
28
- path = res.path
29
- remaining = res.remaining
30
- raise UsageError.new("no intake declared for '#{key}'") unless mentry.is_a?(Textus::Manifest::Entry::Intake)
31
-
32
- before_etag = @container.file_store.exists?(path) ? @container.file_store.etag(path) : nil
33
- result = fetch_with_events(key, mentry, remaining)
34
- persist_and_notify(key, mentry, result, before_etag)
35
- end
36
-
37
- def self.normalize_action_result(res, format:)
38
- res = res.transform_keys(&:to_s) if res.is_a?(Hash)
39
- res ||= {}
40
- meta_val = res["_meta"]
41
- body = res["body"]
42
- content = res["content"]
43
-
44
- case format
45
- when "markdown" then { meta: meta_val || {}, body: body.to_s, content: nil }
46
- when "text" then { meta: {}, body: body.to_s, content: nil }
47
- when "json", "yaml"
48
- if !content.nil?
49
- { meta: meta_val || {}, body: nil, content: content }
50
- elsif !body.nil?
51
- { meta: {}, body: body.to_s, content: nil }
52
- else
53
- raise Textus::UsageError.new("intake for #{format} returned neither content nor body")
54
- end
55
- else
56
- raise Textus::UsageError.new("unknown format #{format.inspect}")
57
- end
58
- end
59
-
60
- private
61
-
62
- def fetch_events
63
- @fetch_events ||= FetchEvents.from(container: @container, call: @call)
64
- end
65
-
66
- # ADR 0079: a per-rule fetch_timeout_seconds override was an accepted loss
67
- # in the fetch:/retention: → lifecycle: collapse; the constant ceiling
68
- # applies to every intake.
69
- def fetch_timeout_for(_key)
70
- FETCH_TIMEOUT_SECONDS
71
- end
72
-
73
- def fetch_with_events(key, mentry, remaining)
74
- fetch_events.started(key)
75
- call_intake(key, mentry, remaining)
76
- end
77
-
78
- def call_intake(key, mentry, remaining)
79
- IntakeFetch.invoke(
80
- caps: @container, handler: mentry.handler,
81
- config: mentry.config,
82
- args: { trigger_key: key, leaf_segments: remaining || [] },
83
- label: "intake", timeout: fetch_timeout_for(key)
84
- )
85
- rescue Textus::Error => e
86
- fetch_events.failed(key, e)
87
- raise
88
- rescue StandardError => e
89
- fetch_events.failed(key, e)
90
- raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
91
- end
92
-
93
- def persist_and_notify(key, mentry, result, before_etag)
94
- normalized = self.class.normalize_action_result(result, format: mentry.format)
95
- Textus::Domain::Policy::GuardFactory.new(
96
- manifest: @manifest, schemas: @schemas,
97
- ).for(:fetch, key).check!(
98
- Textus::Domain::Policy::Evaluation.new(
99
- actor: @call.role, transition: :fetch, origin: nil,
100
- target: key, envelope: nil, manifest: @manifest
101
- ),
102
- )
103
- envelope = writer.put(
104
- key,
105
- mentry: mentry,
106
- payload: Textus::Envelope::IO::Writer::Payload.new(
107
- meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
108
- ),
109
- )
110
- change = detect_change(before_etag, envelope)
111
- fetch_events.fetched(key, envelope, change)
112
- envelope
113
- end
114
-
115
- def detect_change(before_etag, envelope)
116
- if before_etag.nil? then :created
117
- elsif envelope.etag == before_etag then :unchanged
118
- else :updated
119
- end
120
- end
121
-
122
- def writer
123
- @writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
124
- end
125
- end
126
- end
127
- end
@@ -1,25 +0,0 @@
1
- require "timeout"
2
-
3
- module Textus
4
- module Write
5
- # Invokes a :resolve_intake hook handler by name under a timeout — the single
6
- # home for "call the intake handler under a deadline" (ADR 0048 D1). Shared by
7
- # FetchWorker (the :fetch verb), `textus put --fetch`, and `textus hook run`.
8
- # Always passes a Container as `caps:` so the hook contract (ADR 0027) is
9
- # uniform across every entry point. Maps Timeout::Error to a UsageError;
10
- # leaves any other error to the caller (call sites differ in how they wrap).
11
- module IntakeFetch
12
- FETCH_TIMEOUT_SECONDS = 30
13
-
14
- module_function
15
-
16
- def invoke(caps:, handler:, config:, args:, label:, timeout: FETCH_TIMEOUT_SECONDS)
17
- Timeout.timeout(timeout) do
18
- caps.rpc.invoke(:resolve_intake, handler, caps: caps, config: config, args: args)
19
- end
20
- rescue Timeout::Error
21
- raise Textus::UsageError.new("#{label} '#{handler}' exceeded #{timeout}s timeout")
22
- end
23
- end
24
- end
25
- end
@@ -1,51 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Write
5
- # Materializes a single projection-derived manifest entry onto disk by
6
- # running the builder pipeline (projection + template render). External
7
- # entries are NOT materialized here — they are generated by an out-of-band
8
- # runner and only staleness-tracked, so Derived#publish_via filters them out
9
- # before reaching this point.
10
- # Extracted from Write::Build so that Publish can reuse
11
- # it without creating a Build dependency.
12
- class Materializer
13
- def initialize(container:, call:)
14
- @container = container
15
- @call = call
16
- @manifest = container.manifest
17
- @file_store = container.file_store
18
- @rpc = container.rpc
19
- @root = container.root
20
- end
21
-
22
- # Runs the builder pipeline for `mentry` and returns the on-disk
23
- # target_path string.
24
- def run(mentry)
25
- reader = Textus::Read::Get.new(container: @container, call: @call)
26
- lister = Textus::Read::List.new(container: @container)
27
- Builder::Pipeline.run(
28
- mentry: mentry,
29
- deps: Builder::Pipeline::Deps.new(
30
- manifest: @manifest,
31
- reader: reader.method(:call),
32
- lister: lister.method(:call),
33
- rpc: @rpc,
34
- template_loader: ->(name) { read_template(name) },
35
- transform_context: @container,
36
- inject_boot: -> { Textus::Boot.build(container: @container) },
37
- ),
38
- )
39
- end
40
-
41
- private
42
-
43
- def read_template(name)
44
- tpl_path = File.join(@root, "templates", name)
45
- raise TemplateError.new("template not found: #{tpl_path}", template_name: name) unless File.exist?(tpl_path)
46
-
47
- File.read(tpl_path)
48
- end
49
- end
50
- end
51
- end