textus 0.14.0 → 0.14.2
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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +42 -0
- data/lib/textus/application/refresh/worker.rb +9 -3
- data/lib/textus/builder/pipeline.rb +46 -2
- data/lib/textus/cli/verb/rule_list.rb +1 -0
- data/lib/textus/domain/policy/refresh.rb +6 -5
- data/lib/textus/manifest/rules.rb +1 -0
- data/lib/textus/manifest/schema.rb +15 -2
- data/lib/textus/manifest.rb +13 -16
- data/lib/textus/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4a732844e2441328f7519ffd93571c3ff611fd0514c2b1f927b5c4ada499db16
|
|
4
|
+
data.tar.gz: 63a3e530439c37fbbff8c8b3864b1489cd34c88c88bd1c10f03ae980f4a66041
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5470a6aaf29f1b081144b2eca3b1e87e5b1d465977422c967366196d259041cdbb8e0993ce293d87d7903f9446649f80ea99e1e6367b9731f7b839bf18f9d67f
|
|
7
|
+
data.tar.gz: dcd3f57064d2f6753f049daf1315a937566e3b77a9ec130455a54410292f73ec55104bafe1e1b20431dc186a1c13735a7e6c6018ef8f54b77122f0eb2e267e8c
|
data/CHANGELOG.md
CHANGED
|
@@ -9,6 +9,48 @@ The **gem version** (`0.x.y`) is distinct from the **protocol version**
|
|
|
9
9
|
bump is a breaking change that requires a store migration; the gem version
|
|
10
10
|
tracks both additive improvements and breaking protocol bumps independently.
|
|
11
11
|
|
|
12
|
+
## 0.14.2 — 2026-05-26
|
|
13
|
+
|
|
14
|
+
### Added
|
|
15
|
+
|
|
16
|
+
- Per-rule `fetch_timeout_seconds:` under `rules[].refresh` overrides the
|
|
17
|
+
hardcoded 30s worker timeout that applies to intake handlers invoked
|
|
18
|
+
via the refresh pipeline (`textus refresh <key>`, `textus refresh
|
|
19
|
+
stale`, and read-path `on_stale: sync` / `timed_sync`). Default stays
|
|
20
|
+
at 30s — opt-in only. Schema validates a positive integer ≤ 3600.
|
|
21
|
+
Mirrors `sync_budget_ms` plumbing: schema → rules → policy → worker
|
|
22
|
+
(#54).
|
|
23
|
+
|
|
24
|
+
### Notes
|
|
25
|
+
|
|
26
|
+
- `fetch_timeout_seconds` is the worker-side hard cap on the intake
|
|
27
|
+
call; `sync_budget_ms` is the caller-side wait budget for `timed_sync`
|
|
28
|
+
on the read path. Two separate concerns, two separate keys.
|
|
29
|
+
- The CLI verbs `textus put --fetch` and `textus hook run` still use the
|
|
30
|
+
30s constant — only the refresh worker honors the per-rule override in
|
|
31
|
+
this release.
|
|
32
|
+
- Long timeouts pair with `Timeout.timeout`, which raises in the Ruby
|
|
33
|
+
thread but does not kill spawned subprocesses. Intake handlers that
|
|
34
|
+
shell out (e.g. `git clone`) should write to a temp dir and atomically
|
|
35
|
+
rename so mid-flight aborts leave no observable partial state.
|
|
36
|
+
|
|
37
|
+
## 0.14.1 — 2026-05-26
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- Build pipeline now skips rewriting a built artifact when the only
|
|
42
|
+
difference from the existing file on disk would be the freshly-stamped
|
|
43
|
+
`generated_at` (markdown: `generated.at`) timestamp. Stores under git
|
|
44
|
+
versioning no longer churn on every `textus build` (#52).
|
|
45
|
+
- Strict policy: any other byte difference — changed `from`,
|
|
46
|
+
`template`, `reduce`, body content — still triggers a write. Text
|
|
47
|
+
format falls back to plain byte-equality (no timestamp to normalize).
|
|
48
|
+
|
|
49
|
+
### Internal
|
|
50
|
+
|
|
51
|
+
- Extracted `Manifest.check_version!` to dedupe the parse/load version
|
|
52
|
+
guard (#51).
|
|
53
|
+
|
|
12
54
|
## 0.14.0 — 2026-05-26
|
|
13
55
|
|
|
14
56
|
### Breaking (Ruby API only — CLI JSON output unchanged)
|
|
@@ -26,6 +26,11 @@ module Textus
|
|
|
26
26
|
Application::Context.new(store: @ctx.store, role: @ctx.role)
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
def fetch_timeout_for(key)
|
|
30
|
+
rule = @ctx.store.manifest.rules_for(key)
|
|
31
|
+
rule&.refresh&.fetch_timeout_seconds || FETCH_TIMEOUT_SECONDS
|
|
32
|
+
end
|
|
33
|
+
|
|
29
34
|
def fetch_with_bus(key, mentry)
|
|
30
35
|
callable = @ctx.store.registry.rpc_callable(:resolve_intake, mentry.intake_handler)
|
|
31
36
|
@bus.publish(:refresh_started, store: read_view, key: key, mode: :sync,
|
|
@@ -34,14 +39,15 @@ module Textus
|
|
|
34
39
|
end
|
|
35
40
|
|
|
36
41
|
def call_intake(key, mentry, callable)
|
|
37
|
-
|
|
42
|
+
timeout = fetch_timeout_for(key)
|
|
43
|
+
Timeout.timeout(timeout) do
|
|
38
44
|
callable.call(store: @ctx, config: mentry.intake_config, args: {})
|
|
39
45
|
end
|
|
40
46
|
rescue Timeout::Error
|
|
41
47
|
@bus.publish(:refresh_failed, store: read_view, key: key, error_class: "Timeout::Error",
|
|
42
|
-
error_message: "intake '#{mentry.intake_handler}' exceeded #{
|
|
48
|
+
error_message: "intake '#{mentry.intake_handler}' exceeded #{timeout}s",
|
|
43
49
|
correlation_id: @ctx.correlation_id)
|
|
44
|
-
raise UsageError.new("intake '#{mentry.intake_handler}' exceeded #{
|
|
50
|
+
raise UsageError.new("intake '#{mentry.intake_handler}' exceeded #{timeout}s timeout")
|
|
45
51
|
rescue Textus::Error => e
|
|
46
52
|
@bus.publish(:refresh_failed, store: read_view, key: key, error_class: e.class.name,
|
|
47
53
|
error_message: e.message, correlation_id: @ctx.correlation_id)
|
|
@@ -19,6 +19,35 @@ module Textus
|
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
# Replaces the freshly-stamped timestamp inside `new_bytes` with the
|
|
23
|
+
# timestamp pulled from `old_bytes` (same format). Returns the rewritten
|
|
24
|
+
# bytes, or nil if either side lacks a parseable timestamp.
|
|
25
|
+
module IdempotentWrite
|
|
26
|
+
def self.rewrite_with_prior_timestamp(new_bytes:, old_bytes:, format:)
|
|
27
|
+
prior = extract_timestamp(old_bytes, format)
|
|
28
|
+
fresh = extract_timestamp(new_bytes, format)
|
|
29
|
+
return nil unless prior && fresh
|
|
30
|
+
return new_bytes if prior == fresh
|
|
31
|
+
|
|
32
|
+
new_bytes.sub(fresh, prior)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def self.extract_timestamp(bytes, format)
|
|
36
|
+
case format
|
|
37
|
+
when "markdown"
|
|
38
|
+
parsed = Entry.for_format("markdown").parse(bytes)
|
|
39
|
+
parsed.dig("_meta", "generated", "at")
|
|
40
|
+
when "json", "yaml"
|
|
41
|
+
parsed = Entry.for_format(format).parse(bytes)
|
|
42
|
+
parsed.dig("_meta", "generated_at")
|
|
43
|
+
else # rubocop:disable Style/EmptyElse
|
|
44
|
+
nil
|
|
45
|
+
end
|
|
46
|
+
rescue Textus::BadFrontmatter
|
|
47
|
+
nil
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
22
51
|
module Pipeline
|
|
23
52
|
def self.renderers
|
|
24
53
|
@renderers ||= {
|
|
@@ -44,13 +73,28 @@ module Textus
|
|
|
44
73
|
raise UsageError.new("builder: unsupported format #{mentry.format.inspect} for '#{mentry.key}'")
|
|
45
74
|
bytes = klass.new(template_loader: template_loader).call(mentry: mentry, data: data)
|
|
46
75
|
|
|
47
|
-
# 3. Write
|
|
76
|
+
# 3. Write (idempotent: skip if only generated_at would differ)
|
|
48
77
|
target_path = Key::Path.resolve(store.manifest, mentry)
|
|
49
78
|
FileUtils.mkdir_p(File.dirname(target_path))
|
|
50
|
-
|
|
79
|
+
write_if_changed(target_path, bytes, mentry.format)
|
|
51
80
|
|
|
52
81
|
target_path
|
|
53
82
|
end
|
|
83
|
+
|
|
84
|
+
def self.write_if_changed(target_path, bytes, format)
|
|
85
|
+
if File.exist?(target_path)
|
|
86
|
+
old_bytes = File.binread(target_path)
|
|
87
|
+
if format == "text"
|
|
88
|
+
return if old_bytes == bytes
|
|
89
|
+
else
|
|
90
|
+
rewritten = IdempotentWrite.rewrite_with_prior_timestamp(
|
|
91
|
+
new_bytes: bytes, old_bytes: old_bytes, format: format,
|
|
92
|
+
)
|
|
93
|
+
return if rewritten && rewritten == old_bytes
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
File.binwrite(target_path, bytes)
|
|
97
|
+
end
|
|
54
98
|
end
|
|
55
99
|
end
|
|
56
100
|
end
|
|
@@ -10,6 +10,7 @@ module Textus
|
|
|
10
10
|
"ttl_seconds" => b.refresh.ttl_seconds,
|
|
11
11
|
"on_stale" => b.refresh.on_stale,
|
|
12
12
|
"sync_budget_ms" => b.refresh.sync_budget_ms,
|
|
13
|
+
"fetch_timeout_seconds" => b.refresh.fetch_timeout_seconds,
|
|
13
14
|
}
|
|
14
15
|
end
|
|
15
16
|
row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
|
|
@@ -2,9 +2,9 @@ module Textus
|
|
|
2
2
|
module Domain
|
|
3
3
|
module Policy
|
|
4
4
|
class Refresh
|
|
5
|
-
attr_reader :ttl, :on_stale, :sync_budget_ms
|
|
5
|
+
attr_reader :ttl, :on_stale, :sync_budget_ms, :fetch_timeout_seconds
|
|
6
6
|
|
|
7
|
-
def initialize(ttl:, on_stale:, sync_budget_ms:)
|
|
7
|
+
def initialize(ttl:, on_stale:, sync_budget_ms:, fetch_timeout_seconds: nil)
|
|
8
8
|
on_stale_sym = on_stale.is_a?(Symbol) ? on_stale : on_stale.to_s.to_sym
|
|
9
9
|
unless ALLOWED_ON_STALE.include?(on_stale_sym)
|
|
10
10
|
raise Textus::UsageError.new(
|
|
@@ -12,9 +12,10 @@ module Textus
|
|
|
12
12
|
)
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
@ttl
|
|
16
|
-
@on_stale
|
|
17
|
-
@sync_budget_ms
|
|
15
|
+
@ttl = ttl
|
|
16
|
+
@on_stale = on_stale_sym
|
|
17
|
+
@sync_budget_ms = sync_budget_ms
|
|
18
|
+
@fetch_timeout_seconds = fetch_timeout_seconds
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
def ttl_seconds
|
|
@@ -11,7 +11,8 @@ module Textus
|
|
|
11
11
|
COMPUTE_KEYS = %w[kind select pluck sort_by limit transform command sources].freeze
|
|
12
12
|
INTAKE_KEYS = %w[handler config].freeze
|
|
13
13
|
RULE_KEYS = %w[match refresh intake_handler_allowlist promotion retention].freeze
|
|
14
|
-
REFRESH_KEYS = %w[ttl on_stale sync_budget_ms].freeze
|
|
14
|
+
REFRESH_KEYS = %w[ttl on_stale sync_budget_ms fetch_timeout_seconds].freeze
|
|
15
|
+
FETCH_TIMEOUT_SECONDS_CEILING = 3600
|
|
15
16
|
PROMOTION_KEYS = %w[requires].freeze
|
|
16
17
|
|
|
17
18
|
def self.validate!(raw)
|
|
@@ -30,11 +31,23 @@ module Textus
|
|
|
30
31
|
Array(raw["rules"]).each_with_index do |r, i|
|
|
31
32
|
path = "$.rules[#{i}]"
|
|
32
33
|
walk(r, RULE_KEYS, path)
|
|
33
|
-
|
|
34
|
+
if r["refresh"].is_a?(Hash)
|
|
35
|
+
walk(r["refresh"], REFRESH_KEYS, "#{path}.refresh")
|
|
36
|
+
validate_fetch_timeout!(r["refresh"]["fetch_timeout_seconds"], "#{path}.refresh.fetch_timeout_seconds")
|
|
37
|
+
end
|
|
34
38
|
walk(r["promotion"], PROMOTION_KEYS, "#{path}.promotion") if r["promotion"].is_a?(Hash)
|
|
35
39
|
end
|
|
36
40
|
end
|
|
37
41
|
|
|
42
|
+
def self.validate_fetch_timeout!(value, path)
|
|
43
|
+
return if value.nil?
|
|
44
|
+
return if value.is_a?(Integer) && value.positive? && value <= FETCH_TIMEOUT_SECONDS_CEILING
|
|
45
|
+
|
|
46
|
+
raise BadManifest.new(
|
|
47
|
+
"fetch_timeout_seconds at '#{path}' must be a positive integer ≤ #{FETCH_TIMEOUT_SECONDS_CEILING} (got #{value.inspect})",
|
|
48
|
+
)
|
|
49
|
+
end
|
|
50
|
+
|
|
38
51
|
def self.walk(hash, allowed, path)
|
|
39
52
|
return unless hash.is_a?(Hash)
|
|
40
53
|
|
data/lib/textus/manifest.rb
CHANGED
|
@@ -39,14 +39,7 @@ module Textus
|
|
|
39
39
|
|
|
40
40
|
def self.parse(yaml_text, root: ".")
|
|
41
41
|
raw = YAML.safe_load(yaml_text, aliases: false)
|
|
42
|
-
|
|
43
|
-
raise BadFrontmatter.new(
|
|
44
|
-
"<string>",
|
|
45
|
-
"unsupported manifest version #{raw["version"].inspect}; expected #{PROTOCOL.inspect}",
|
|
46
|
-
hint: version_hint_for(raw["version"]),
|
|
47
|
-
)
|
|
48
|
-
end
|
|
49
|
-
|
|
42
|
+
check_version!(raw, "<string>")
|
|
50
43
|
new(root, raw)
|
|
51
44
|
end
|
|
52
45
|
|
|
@@ -55,17 +48,21 @@ module Textus
|
|
|
55
48
|
raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)
|
|
56
49
|
|
|
57
50
|
raw = YAML.safe_load_file(manifest_path, aliases: false)
|
|
58
|
-
|
|
59
|
-
raise BadFrontmatter.new(
|
|
60
|
-
manifest_path,
|
|
61
|
-
"unsupported manifest version #{raw["version"].inspect}; expected #{PROTOCOL.inspect}",
|
|
62
|
-
hint: version_hint_for(raw["version"]),
|
|
63
|
-
)
|
|
64
|
-
end
|
|
65
|
-
|
|
51
|
+
check_version!(raw, manifest_path)
|
|
66
52
|
new(root, raw)
|
|
67
53
|
end
|
|
68
54
|
|
|
55
|
+
def self.check_version!(raw, source)
|
|
56
|
+
return if raw["version"] == PROTOCOL
|
|
57
|
+
|
|
58
|
+
raise BadFrontmatter.new(
|
|
59
|
+
source,
|
|
60
|
+
"unsupported manifest version #{raw["version"].inspect}; expected #{PROTOCOL.inspect}",
|
|
61
|
+
hint: version_hint_for(raw["version"]),
|
|
62
|
+
)
|
|
63
|
+
end
|
|
64
|
+
private_class_method :check_version!
|
|
65
|
+
|
|
69
66
|
def initialize(root, raw)
|
|
70
67
|
@root = root
|
|
71
68
|
@raw = raw
|
data/lib/textus/version.rb
CHANGED