textus 0.40.0 → 0.41.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 (32) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +13 -1
  3. data/SPEC.md +16 -2
  4. data/docs/architecture/README.md +256 -0
  5. data/docs/reference/conventions.md +148 -0
  6. data/lib/textus/cli/verb/hook_run.rb +3 -1
  7. data/lib/textus/cli/verb/put.rb +1 -1
  8. data/lib/textus/doctor/check/publish_tree_index_overlap.rb +48 -0
  9. data/lib/textus/doctor.rb +1 -0
  10. data/lib/textus/manifest/entry/base.rb +12 -20
  11. data/lib/textus/manifest/entry/nested.rb +8 -89
  12. data/lib/textus/manifest/entry/publish/each.rb +83 -0
  13. data/lib/textus/manifest/entry/publish/each_dir.rb +74 -0
  14. data/lib/textus/manifest/entry/publish/each_file.rb +29 -0
  15. data/lib/textus/manifest/entry/publish/mode.rb +39 -0
  16. data/lib/textus/manifest/entry/publish/none.rb +14 -0
  17. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +72 -0
  18. data/lib/textus/manifest/entry/publish/template.rb +22 -0
  19. data/lib/textus/manifest/entry/publish/to_paths.rb +27 -0
  20. data/lib/textus/manifest/entry/publish/tree.rb +54 -0
  21. data/lib/textus/manifest/entry/publish.rb +45 -0
  22. data/lib/textus/manifest/entry/validators/publish.rb +26 -0
  23. data/lib/textus/manifest/entry/validators.rb +1 -1
  24. data/lib/textus/manifest/schema.rb +1 -1
  25. data/lib/textus/ports/publisher.rb +14 -2
  26. data/lib/textus/version.rb +1 -1
  27. data/lib/textus/write/fetch_events.rb +42 -0
  28. data/lib/textus/write/fetch_orchestrator.rb +2 -3
  29. data/lib/textus/write/fetch_worker.rb +13 -22
  30. data/lib/textus/write/intake_fetch.rb +8 -6
  31. metadata +16 -2
  32. data/lib/textus/manifest/entry/validators/publish_each.rb +0 -79
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.40.0"
2
+ VERSION = "0.41.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -0,0 +1,42 @@
1
+ module Textus
2
+ module Write
3
+ # Single home for the fetch lifecycle event vocabulary (ADR 0048 D5). Both
4
+ # FetchWorker (synchronous semantics) and FetchOrchestrator (async policy)
5
+ # emit through this seam so the event names and payload shapes live in one
6
+ # place with one derived hook context.
7
+ class FetchEvents
8
+ def self.from(container:, call:)
9
+ new(
10
+ events: container.events,
11
+ hook_context: Textus::Hooks::Context.for(container: container, call: call),
12
+ )
13
+ end
14
+
15
+ def initialize(events:, hook_context:)
16
+ @events = events
17
+ @hook_context = hook_context
18
+ end
19
+
20
+ def started(key, mode: :sync)
21
+ @events.publish(:fetch_started, ctx: @hook_context, key: key, mode: mode)
22
+ end
23
+
24
+ def failed(key, error)
25
+ @events.publish(:fetch_failed, ctx: @hook_context, key: key,
26
+ error_class: error.class.name, error_message: error.message)
27
+ end
28
+
29
+ def fetched(key, envelope, change)
30
+ return if change == :unchanged
31
+
32
+ @events.publish(:entry_fetched, ctx: @hook_context, key: key, envelope: envelope, change: change)
33
+ end
34
+
35
+ def backgrounded(key, started_at:, budget_ms:)
36
+ payload = { key: key, started_at: started_at, budget_ms: budget_ms }
37
+ payload[:ctx] = @hook_context if @hook_context
38
+ @events.publish(:fetch_backgrounded, **payload)
39
+ end
40
+ end
41
+ end
42
+ end
@@ -10,6 +10,7 @@ module Textus
10
10
  @events = events
11
11
  @hook_context = hook_context
12
12
  @detached_spawner = detached_spawner || default_spawner
13
+ @fetch_events = Textus::Write::FetchEvents.new(events: @events, hook_context: @hook_context)
13
14
  end
14
15
 
15
16
  def execute(action, key:)
@@ -82,9 +83,7 @@ module Textus
82
83
 
83
84
  probe.release
84
85
 
85
- payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
86
- payload[:ctx] = @hook_context if @hook_context
87
- @events.publish(:fetch_backgrounded, **payload)
86
+ @fetch_events.backgrounded(key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms)
88
87
  @detached_spawner.call(store_root: @store_root, key: key)
89
88
  Textus::Domain::Outcome::Detached.new
90
89
  elsif result.is_a?(Textus::Error)
@@ -18,7 +18,6 @@ module Textus
18
18
  @call = call
19
19
  @manifest = container.manifest
20
20
  @schemas = container.schemas
21
- @events = container.events
22
21
  @rpc = container.rpc
23
22
  end
24
23
 
@@ -35,7 +34,7 @@ module Textus
35
34
  remaining = res.remaining
36
35
  raise UsageError.new("no intake declared for '#{key}'") unless mentry.is_a?(Textus::Manifest::Entry::Intake)
37
36
 
38
- before_etag = File.exist?(path) ? Etag.for_file(path) : nil
37
+ before_etag = @container.file_store.exists?(path) ? @container.file_store.etag(path) : nil
39
38
  result = fetch_with_events(key, mentry, remaining)
40
39
  persist_and_notify(key, mentry, result, before_etag)
41
40
  end
@@ -65,8 +64,8 @@ module Textus
65
64
 
66
65
  private
67
66
 
68
- def hook_context
69
- @hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
67
+ def fetch_events
68
+ @fetch_events ||= FetchEvents.from(container: @container, call: @call)
70
69
  end
71
70
 
72
71
  def fetch_timeout_for(key)
@@ -75,30 +74,22 @@ module Textus
75
74
  end
76
75
 
77
76
  def fetch_with_events(key, mentry, remaining)
78
- @events.publish(:fetch_started, ctx: hook_context, key: key, mode: :sync)
77
+ fetch_events.started(key)
79
78
  call_intake(key, mentry, remaining)
80
79
  end
81
80
 
82
81
  def call_intake(key, mentry, remaining)
83
- timeout = fetch_timeout_for(key)
84
- Timeout.timeout(timeout) do
85
- @rpc.invoke(:resolve_intake, mentry.handler,
86
- caps: @container,
87
- config: mentry.config,
88
- args: { trigger_key: key, leaf_segments: remaining || [] })
89
- end
90
- rescue Timeout::Error
91
- @events.publish(:fetch_failed, ctx: hook_context, key: key,
92
- error_class: "Timeout::Error",
93
- error_message: "intake '#{mentry.handler}' exceeded #{timeout}s")
94
- raise UsageError.new("intake '#{mentry.handler}' exceeded #{timeout}s timeout")
82
+ IntakeFetch.invoke(
83
+ caps: @container, handler: mentry.handler,
84
+ config: mentry.config,
85
+ args: { trigger_key: key, leaf_segments: remaining || [] },
86
+ label: "intake", timeout: fetch_timeout_for(key)
87
+ )
95
88
  rescue Textus::Error => e
96
- @events.publish(:fetch_failed, ctx: hook_context, key: key, error_class: e.class.name,
97
- error_message: e.message)
89
+ fetch_events.failed(key, e)
98
90
  raise
99
91
  rescue StandardError => e
100
- @events.publish(:fetch_failed, ctx: hook_context, key: key, error_class: e.class.name,
101
- error_message: e.message)
92
+ fetch_events.failed(key, e)
102
93
  raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
103
94
  end
104
95
 
@@ -120,7 +111,7 @@ module Textus
120
111
  ),
121
112
  )
122
113
  change = detect_change(before_etag, envelope)
123
- @events.publish(:entry_fetched, ctx: hook_context, key: key, envelope: envelope, change: change) unless change == :unchanged
114
+ fetch_events.fetched(key, envelope, change)
124
115
  envelope
125
116
  end
126
117
 
@@ -2,18 +2,20 @@ require "timeout"
2
2
 
3
3
  module Textus
4
4
  module Write
5
- # Invokes a :resolve_intake hook handler by name under a timeout.
6
- # The transport-side fetch kernel shared by `textus put --fetch` and
7
- # `textus hook run`. Maps Timeout::Error to a UsageError; leaves any
8
- # other error to the caller (call sites differ in how they wrap those).
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).
9
11
  module IntakeFetch
10
12
  FETCH_TIMEOUT_SECONDS = 30
11
13
 
12
14
  module_function
13
15
 
14
- def invoke(rpc:, handler:, config:, args:, label:, timeout: FETCH_TIMEOUT_SECONDS)
16
+ def invoke(caps:, handler:, config:, args:, label:, timeout: FETCH_TIMEOUT_SECONDS)
15
17
  Timeout.timeout(timeout) do
16
- rpc.invoke(:resolve_intake, handler, caps: nil, config: config, args: args)
18
+ caps.rpc.invoke(:resolve_intake, handler, caps: caps, config: config, args: args)
17
19
  end
18
20
  rescue Timeout::Error
19
21
  raise Textus::UsageError.new("#{label} '#{handler}' exceeded #{timeout}s timeout")
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.40.0
4
+ version: 0.41.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -106,6 +106,8 @@ files:
106
106
  - CHANGELOG.md
107
107
  - README.md
108
108
  - SPEC.md
109
+ - docs/architecture/README.md
110
+ - docs/reference/conventions.md
109
111
  - exe/textus
110
112
  - lib/textus.rb
111
113
  - lib/textus/boot.rb
@@ -179,6 +181,7 @@ files:
179
181
  - lib/textus/doctor/check/orphaned_publish_targets.rb
180
182
  - lib/textus/doctor/check/proposal_targets.rb
181
183
  - lib/textus/doctor/check/protocol_version.rb
184
+ - lib/textus/doctor/check/publish_tree_index_overlap.rb
182
185
  - lib/textus/doctor/check/rule_ambiguity.rb
183
186
  - lib/textus/doctor/check/schema_parse_error.rb
184
187
  - lib/textus/doctor/check/schema_violations.rb
@@ -257,13 +260,23 @@ files:
257
260
  - lib/textus/manifest/entry/leaf.rb
258
261
  - lib/textus/manifest/entry/nested.rb
259
262
  - lib/textus/manifest/entry/parser.rb
263
+ - lib/textus/manifest/entry/publish.rb
264
+ - lib/textus/manifest/entry/publish/each.rb
265
+ - lib/textus/manifest/entry/publish/each_dir.rb
266
+ - lib/textus/manifest/entry/publish/each_file.rb
267
+ - lib/textus/manifest/entry/publish/mode.rb
268
+ - lib/textus/manifest/entry/publish/none.rb
269
+ - lib/textus/manifest/entry/publish/subtree_mirror.rb
270
+ - lib/textus/manifest/entry/publish/template.rb
271
+ - lib/textus/manifest/entry/publish/to_paths.rb
272
+ - lib/textus/manifest/entry/publish/tree.rb
260
273
  - lib/textus/manifest/entry/validators.rb
261
274
  - lib/textus/manifest/entry/validators/events.rb
262
275
  - lib/textus/manifest/entry/validators/format_matrix.rb
263
276
  - lib/textus/manifest/entry/validators/ignore.rb
264
277
  - lib/textus/manifest/entry/validators/index_filename.rb
265
278
  - lib/textus/manifest/entry/validators/inject_boot.rb
266
- - lib/textus/manifest/entry/validators/publish_each.rb
279
+ - lib/textus/manifest/entry/validators/publish.rb
267
280
  - lib/textus/manifest/policy.rb
268
281
  - lib/textus/manifest/resolver.rb
269
282
  - lib/textus/manifest/rules.rb
@@ -320,6 +333,7 @@ files:
320
333
  - lib/textus/write/accept.rb
321
334
  - lib/textus/write/delete.rb
322
335
  - lib/textus/write/fetch_all.rb
336
+ - lib/textus/write/fetch_events.rb
323
337
  - lib/textus/write/fetch_orchestrator.rb
324
338
  - lib/textus/write/fetch_worker.rb
325
339
  - lib/textus/write/intake_fetch.rb
@@ -1,79 +0,0 @@
1
- module Textus
2
- class Manifest
3
- class Entry
4
- module Validators
5
- module PublishEach
6
- KNOWN_VARS = %w[leaf basename key ext].freeze
7
- VAR_RE = /\{([a-z]+)\}/
8
- REQUIRED_DISCRIMINATOR_VARS = %w[leaf basename key].freeze
9
-
10
- def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
11
- # Use raw to detect misuse on non-nested entries (typed attr stubs nil on Base).
12
- publish_each = entry.nested? ? entry.publish_each : entry.raw["publish_each"]
13
- return if publish_each.nil?
14
-
15
- raise UsageError.new("entry '#{entry.key}': publish_each requires nested: true") unless entry.nested?
16
-
17
- publish_to = entry.publish_to
18
- raise UsageError.new("entry '#{entry.key}': publish_to and publish_each are mutually exclusive") unless publish_to.empty?
19
- raise UsageError.new("entry '#{entry.key}': publish_each must be a string") unless publish_each.is_a?(String)
20
-
21
- used_vars = publish_each.scan(VAR_RE).flatten
22
- unknown = used_vars - KNOWN_VARS
23
- unless unknown.empty?
24
- raise UsageError.new(
25
- "entry '#{entry.key}': publish_each uses unknown template variable(s) " \
26
- "#{unknown.map { |v| "{#{v}}" }.join(", ")}. Known: #{KNOWN_VARS.map { |v| "{#{v}}" }.join(", ")}.",
27
- )
28
- end
29
-
30
- validate_discriminator(entry, used_vars, publish_each)
31
- end
32
-
33
- def self.validate_discriminator(entry, used_vars, publish_each)
34
- if entry.index_filename
35
- forbidden = used_vars & %w[basename ext]
36
- unless forbidden.empty?
37
- raise UsageError.new(
38
- "entry '#{entry.key}': publish_each names a directory " \
39
- "(index_filename: '#{entry.index_filename}'); {basename}/{ext} are file-only — " \
40
- "use {leaf} or {key}.",
41
- )
42
- end
43
- last_segment = publish_each.sub(%r{/\z}, "").split("/").last
44
- if last_segment == entry.index_filename
45
- raise UsageError.new(
46
- "entry '#{entry.key}': directory-leaf publish_each must name the target DIRECTORY, " \
47
- "not the index file — drop the trailing '/#{entry.index_filename}' " \
48
- "(the whole leaf subtree is copied into the named directory).",
49
- )
50
- end
51
- ext = File.extname(last_segment)
52
- unless ext.empty?
53
- raise UsageError.new(
54
- "entry '#{entry.key}': directory-leaf publish_each names a DIRECTORY target, but its " \
55
- "final segment '#{last_segment}' looks like a file (extension '#{ext}') — " \
56
- "drop the extension (the whole leaf subtree is copied into the named directory).",
57
- )
58
- end
59
- return if used_vars.intersect?(%w[leaf key])
60
-
61
- raise UsageError.new(
62
- "entry '#{entry.key}': directory-leaf publish_each must reference {leaf} or {key} " \
63
- "(else every leaf would clobber the same directory).",
64
- )
65
- end
66
-
67
- return if used_vars.intersect?(REQUIRED_DISCRIMINATOR_VARS)
68
-
69
- raise UsageError.new(
70
- "entry '#{entry.key}': publish_each must reference at least one of {leaf}, {basename}, or {key} " \
71
- "(else every leaf would clobber the same target).",
72
- )
73
- end
74
- private_class_method :validate_discriminator
75
- end
76
- end
77
- end
78
- end
79
- end