textus 0.47.1 → 0.49.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -0
  3. data/README.md +9 -7
  4. data/SPEC.md +40 -46
  5. data/docs/reference/conventions.md +11 -10
  6. data/lib/textus/boot.rb +2 -2
  7. data/lib/textus/cli/runner.rb +5 -4
  8. data/lib/textus/dispatcher.rb +3 -8
  9. data/lib/textus/doctor/check/generator_drift.rb +28 -0
  10. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +39 -0
  11. data/lib/textus/doctor/check/rule_ambiguity.rb +1 -1
  12. data/lib/textus/doctor.rb +2 -0
  13. data/lib/textus/domain/lifecycle.rb +83 -0
  14. data/lib/textus/domain/policy/base_guards.rb +2 -2
  15. data/lib/textus/domain/policy/lifecycle.rb +35 -0
  16. data/lib/textus/domain/staleness.rb +6 -3
  17. data/lib/textus/envelope/io/writer.rb +2 -2
  18. data/lib/textus/hooks/context.rb +1 -1
  19. data/lib/textus/init.rb +4 -4
  20. data/lib/textus/maintenance/key_delete_prefix.rb +1 -1
  21. data/lib/textus/maintenance/key_mv_prefix.rb +2 -2
  22. data/lib/textus/maintenance/tend.rb +110 -0
  23. data/lib/textus/manifest/rules.rb +11 -23
  24. data/lib/textus/manifest/schema.rb +3 -18
  25. data/lib/textus/ports/audit_log.rb +1 -1
  26. data/lib/textus/ports/audit_subscriber.rb +1 -1
  27. data/lib/textus/ports/fetch/detached.rb +5 -1
  28. data/lib/textus/read/freshness.rb +29 -22
  29. data/lib/textus/read/get.rb +47 -32
  30. data/lib/textus/read/pulse.rb +1 -1
  31. data/lib/textus/read/rule_explain.rb +10 -16
  32. data/lib/textus/read/rule_list.rb +5 -7
  33. data/lib/textus/version.rb +1 -1
  34. data/lib/textus/write/accept.rb +1 -1
  35. data/lib/textus/write/fetch_worker.rb +8 -12
  36. data/lib/textus/write/{delete.rb → key_delete.rb} +3 -3
  37. data/lib/textus/write/{mv.rb → key_mv.rb} +4 -4
  38. data/lib/textus/write/reject.rb +1 -1
  39. metadata +8 -15
  40. data/lib/textus/cli/group/fetch.rb +0 -20
  41. data/lib/textus/cli/verb/fetch.rb +0 -14
  42. data/lib/textus/cli/verb/fetch_all.rb +0 -20
  43. data/lib/textus/domain/policy/fetch.rb +0 -37
  44. data/lib/textus/domain/policy/retention.rb +0 -26
  45. data/lib/textus/domain/retention.rb +0 -44
  46. data/lib/textus/domain/staleness/intake_check.rb +0 -54
  47. data/lib/textus/maintenance/migrate.rb +0 -65
  48. data/lib/textus/read/retainable.rb +0 -17
  49. data/lib/textus/read/stale.rb +0 -17
  50. data/lib/textus/write/fetch_all.rb +0 -53
  51. data/lib/textus/write/retention_sweep.rb +0 -64
@@ -1,65 +0,0 @@
1
- require "yaml"
2
-
3
- module Textus
4
- module Maintenance
5
- # Loads a YAML migration plan and dispatches each op to the
6
- # appropriate Maintenance use case. Concatenates resulting Plans.
7
- class Migrate
8
- extend Textus::Contract::DSL
9
-
10
- verb :migrate
11
- summary "Run a YAML migration plan (multi-op)."
12
- surfaces :cli, :mcp
13
- arg :plan_yaml, String, required: true, positional: true, source: :file,
14
- description: "path to the YAML migration plan (zone_mv, key_mv_prefix, key_delete_prefix ops run in order)"
15
- arg :dry_run, :boolean, default: false,
16
- description: "when true, returns the planned ops without applying them; " \
17
- "defaults to false, so omitting it runs the migration immediately"
18
- view { |v, _i| v.to_h }
19
-
20
- def initialize(container:, call:)
21
- @container = container
22
- @call = call
23
- end
24
-
25
- def call(plan_yaml, dry_run: false)
26
- raw = YAML.safe_load(plan_yaml, permitted_classes: [Symbol], aliases: false)
27
- raise UsageError.new("migration plan must be a YAML mapping") unless raw.is_a?(Hash)
28
-
29
- ops = Array(raw["operations"])
30
- all_steps = []
31
- warnings = []
32
-
33
- ops.each do |op_hash|
34
- op_name = op_hash["op"]
35
- sub_plan = invoke_op(op_name, op_hash, dry_run: dry_run)
36
- all_steps.concat(sub_plan.steps)
37
- warnings.concat(sub_plan.warnings)
38
- end
39
-
40
- Plan.new(steps: all_steps, warnings: warnings)
41
- end
42
-
43
- private
44
-
45
- def invoke_op(op_name, op_hash, dry_run:)
46
- klass = op_class(op_name)
47
- inputs = op_hash.except("op").transform_keys(&:to_sym).merge(dry_run: dry_run)
48
- # Each op now carries positional args (from/to, from_prefix/to_prefix,
49
- # prefix); split the YAML fields into (positional, keyword) via the op's
50
- # own contract so we call its #call signature correctly (ADR 0066/0068).
51
- args, kwargs = Textus::Contract::Binder.bind(klass.contract, inputs)
52
- klass.new(container: @container, call: @call).call(*args, **kwargs)
53
- end
54
-
55
- def op_class(op_name)
56
- case op_name
57
- when "key_mv_prefix" then KeyMvPrefix
58
- when "key_delete_prefix" then KeyDeletePrefix
59
- when "zone_mv" then ZoneMv
60
- else raise UsageError.new("unknown op: #{op_name}")
61
- end
62
- end
63
- end
64
- end
65
- end
@@ -1,17 +0,0 @@
1
- module Textus
2
- module Read
3
- class Retainable
4
- def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
- @manifest = container.manifest
6
- end
7
-
8
- def call(prefix: nil, zone: nil)
9
- Textus::Domain::Retention.new(
10
- manifest: @manifest,
11
- file_stat: Textus::Ports::Storage::FileStat.new,
12
- clock: Textus::Ports::Clock,
13
- ).call(prefix: prefix, zone: zone)
14
- end
15
- end
16
- end
17
- end
@@ -1,17 +0,0 @@
1
- module Textus
2
- module Read
3
- class Stale
4
- def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
5
- @manifest = container.manifest
6
- end
7
-
8
- def call(prefix: nil, zone: nil)
9
- Textus::Domain::Staleness.new(
10
- manifest: @manifest,
11
- file_stat: Textus::Ports::Storage::FileStat.new,
12
- clock: Textus::Ports::Clock,
13
- ).call(prefix: prefix, zone: zone)
14
- end
15
- end
16
- end
17
- end
@@ -1,53 +0,0 @@
1
- module Textus
2
- module Write
3
- class FetchAll
4
- extend Textus::Contract::DSL
5
-
6
- verb :fetch_all
7
- summary "Fetch all stale quarantine entries, optionally scoped by zone/prefix."
8
- surfaces :cli, :mcp
9
- cli "fetch all"
10
- arg :prefix, String, description: "only refresh stale entries whose key starts with this dotted prefix"
11
- arg :zone, String, description: "only refresh stale entries in this quarantine zone (see `pulse` stale list)"
12
-
13
- def initialize(container:, call:)
14
- @container = container
15
- @call = call
16
- end
17
-
18
- def call(prefix: nil, zone: nil)
19
- worker = Textus::Write::FetchWorker.new(
20
- container: @container, call: @call,
21
- )
22
-
23
- stale_rows = Textus::Read::Stale.new(container: @container, call: @call).call(prefix: prefix, zone: zone)
24
- fetched = []
25
- failed = []
26
- skipped = []
27
-
28
- stale_rows.each do |row|
29
- key = row["key"] || row[:key]
30
- reason = row["reason"] || row[:reason]
31
- if reason.to_s.match?(/ttl exceeded|never fetched/)
32
- begin
33
- worker.run(key)
34
- fetched << key
35
- rescue Textus::Error => e
36
- failed << { "key" => key, "error" => e.message }
37
- end
38
- else
39
- skipped << { "key" => key, "reason" => reason }
40
- end
41
- end
42
-
43
- {
44
- "protocol" => Textus::PROTOCOL,
45
- "ok" => failed.empty?,
46
- "fetched" => fetched,
47
- "failed" => failed,
48
- "skipped" => skipped,
49
- }
50
- end
51
- end
52
- end
53
- end
@@ -1,64 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Write
5
- # Applies retention actions reported by Read::Retainable. `expire` deletes
6
- # the leaf through the role gate; `archive` copies it to
7
- # <root>/archive/<relative-path> first, then deletes. Rows whose zone the
8
- # caller's role cannot write surface in `failed` rather than aborting.
9
- class RetentionSweep
10
- extend Textus::Contract::DSL
11
-
12
- verb :retain
13
- summary "Apply each entry's retention policy; prune expired versions."
14
- surfaces :cli
15
- cli "retain"
16
- arg :prefix, String, description: "restrict to keys starting with this dotted prefix"
17
- arg :zone, String, description: "restrict to entries in this zone"
18
-
19
- def initialize(container:, call:)
20
- @container = container
21
- @call = call
22
- end
23
-
24
- def call(prefix: nil, zone: nil)
25
- rows = Textus::Read::Retainable.new(container: @container, call: @call)
26
- .call(prefix: prefix, zone: zone)
27
- delete_op = Textus::Write::Delete.new(container: @container, call: @call)
28
- expired = []
29
- archived = []
30
- failed = []
31
-
32
- rows.each do |row|
33
- key = row["key"]
34
- begin
35
- archive_leaf(row) if row["action"] == "archive"
36
- delete_op.call(key)
37
- (row["action"] == "archive" ? archived : expired) << key
38
- rescue Textus::Error => e
39
- failed << { "key" => key, "error" => e.message }
40
- end
41
- end
42
-
43
- {
44
- "protocol" => Textus::PROTOCOL,
45
- "ok" => failed.empty?,
46
- "expired" => expired,
47
- "archived" => archived,
48
- "failed" => failed,
49
- }
50
- end
51
-
52
- private
53
-
54
- def archive_leaf(row)
55
- src = row["path"]
56
- root = @container.root.to_s
57
- rel = src.delete_prefix("#{root}/")
58
- dest = File.join(root, "archive", rel)
59
- FileUtils.mkdir_p(File.dirname(dest))
60
- FileUtils.cp(src, dest)
61
- end
62
- end
63
- end
64
- end