textus 0.8.1 → 0.10.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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +329 -0
  3. data/README.md +50 -22
  4. data/SPEC.md +194 -63
  5. data/docs/architecture.md +22 -4
  6. data/docs/conventions.md +24 -17
  7. data/lib/textus/application/context.rb +44 -0
  8. data/lib/textus/application/reads/audit.rb +69 -0
  9. data/lib/textus/application/reads/blame.rb +79 -0
  10. data/lib/textus/application/reads/freshness.rb +77 -0
  11. data/lib/textus/application/reads/get.rb +62 -0
  12. data/lib/textus/application/reads/policy_explain.rb +39 -0
  13. data/lib/textus/application/refresh/all.rb +41 -0
  14. data/lib/textus/application/refresh/orchestrator.rb +69 -0
  15. data/lib/textus/application/refresh/worker.rb +79 -0
  16. data/lib/textus/application/writes/accept.rb +44 -0
  17. data/lib/textus/application/writes/build.rb +116 -0
  18. data/lib/textus/application/writes/delete.rb +36 -0
  19. data/lib/textus/application/writes/publish.rb +25 -0
  20. data/lib/textus/application/writes/put.rb +43 -0
  21. data/lib/textus/builder/pipeline.rb +1 -1
  22. data/lib/textus/builder/renderer/json.rb +1 -1
  23. data/lib/textus/builder/renderer/markdown.rb +1 -1
  24. data/lib/textus/builder/renderer/text.rb +1 -1
  25. data/lib/textus/builder/renderer/yaml.rb +1 -1
  26. data/lib/textus/builder/renderer.rb +1 -1
  27. data/lib/textus/cli/group/policy.rb +11 -0
  28. data/lib/textus/cli/verb/accept.rb +2 -2
  29. data/lib/textus/cli/verb/audit.rb +30 -0
  30. data/lib/textus/cli/verb/blame.rb +16 -0
  31. data/lib/textus/cli/verb/build.rb +2 -1
  32. data/lib/textus/cli/verb/delete.rb +2 -2
  33. data/lib/textus/cli/verb/freshness.rb +16 -0
  34. data/lib/textus/cli/verb/get.rb +7 -1
  35. data/lib/textus/cli/verb/hook_run.rb +4 -4
  36. data/lib/textus/cli/verb/mv.rb +1 -2
  37. data/lib/textus/cli/verb/policy_explain.rb +14 -0
  38. data/lib/textus/cli/verb/policy_list.rb +25 -0
  39. data/lib/textus/cli/verb/put.rb +10 -8
  40. data/lib/textus/cli/verb/refresh.rb +2 -2
  41. data/lib/textus/cli/verb/refresh_stale.rb +18 -0
  42. data/lib/textus/cli/verb/reject.rb +14 -0
  43. data/lib/textus/cli/verb.rb +14 -0
  44. data/lib/textus/cli.rb +16 -2
  45. data/lib/textus/composition.rb +72 -0
  46. data/lib/textus/doctor/check/handler_allowlist.rb +33 -0
  47. data/lib/textus/doctor/check/intake_registration.rb +46 -0
  48. data/lib/textus/doctor/check/legacy_intake_fields.rb +57 -0
  49. data/lib/textus/doctor/check/policy_ambiguity.rb +49 -0
  50. data/lib/textus/doctor.rb +7 -1
  51. data/lib/textus/domain/action.rb +9 -0
  52. data/lib/textus/domain/freshness/evaluator.rb +30 -0
  53. data/lib/textus/domain/freshness/policy.rb +18 -0
  54. data/lib/textus/domain/freshness/verdict.rb +12 -0
  55. data/lib/textus/domain/outcome.rb +10 -0
  56. data/lib/textus/domain/permission.rb +15 -0
  57. data/lib/textus/domain/policy/handler_allowlist.rb +17 -0
  58. data/lib/textus/domain/policy/matcher.rb +51 -0
  59. data/lib/textus/domain/policy/promote.rb +24 -0
  60. data/lib/textus/domain/policy/refresh.rb +48 -0
  61. data/lib/textus/domain/policy.rb +7 -0
  62. data/lib/textus/hooks/builtin.rb +5 -5
  63. data/lib/textus/hooks/dispatcher.rb +15 -1
  64. data/lib/textus/hooks/dsl.rb +18 -0
  65. data/lib/textus/hooks/registry.rb +12 -5
  66. data/lib/textus/infra/clock.rb +9 -0
  67. data/lib/textus/infra/event_bus.rb +27 -0
  68. data/lib/textus/infra/publisher.rb +73 -0
  69. data/lib/textus/infra/refresh/detached.rb +38 -0
  70. data/lib/textus/infra/refresh/lock.rb +44 -0
  71. data/lib/textus/init.rb +71 -28
  72. data/lib/textus/intro.rb +17 -14
  73. data/lib/textus/manifest/entry.rb +39 -13
  74. data/lib/textus/manifest/policies.rb +83 -0
  75. data/lib/textus/manifest.rb +30 -11
  76. data/lib/textus/projection.rb +1 -1
  77. data/lib/textus/proposal.rb +4 -21
  78. data/lib/textus/refresh.rb +9 -45
  79. data/lib/textus/store/mover.rb +14 -9
  80. data/lib/textus/store/reader.rb +10 -8
  81. data/lib/textus/store/staleness.rb +5 -17
  82. data/lib/textus/store/validator.rb +46 -20
  83. data/lib/textus/store/writer.rb +51 -14
  84. data/lib/textus/store.rb +30 -10
  85. data/lib/textus/version.rb +1 -1
  86. data/lib/textus.rb +1 -0
  87. metadata +46 -5
  88. data/lib/textus/builder.rb +0 -86
  89. data/lib/textus/cli/verb/stale.rb +0 -14
  90. data/lib/textus/publisher.rb +0 -71
  91. data/lib/textus/store/view.rb +0 -29
@@ -0,0 +1,38 @@
1
+ module Textus
2
+ module Infra
3
+ module Refresh
4
+ module Detached
5
+ module_function
6
+
7
+ def supported?
8
+ Process.respond_to?(:fork)
9
+ end
10
+
11
+ def spawn(store_root:, key:)
12
+ return nil unless supported?
13
+
14
+ pid = Process.fork do
15
+ $stdin.close
16
+ $stdout.reopen(File::NULL, "w")
17
+ $stderr.reopen(File::NULL, "w")
18
+
19
+ lock = Textus::Infra::Refresh::Lock.new(root: store_root, key: key)
20
+ exit(0) unless lock.try_acquire
21
+
22
+ begin
23
+ store = Textus::Store.new(store_root)
24
+ Textus::Refresh.call(store, key, as: "script")
25
+ rescue StandardError
26
+ # Already logged via :refresh_failed; exit cleanly.
27
+ ensure
28
+ lock.release
29
+ exit(0)
30
+ end
31
+ end
32
+ Process.detach(pid)
33
+ pid
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,44 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Infra
5
+ module Refresh
6
+ class Lock
7
+ def initialize(root:, key:)
8
+ @root = root
9
+ @key = key
10
+ @path = File.join(root, ".locks", "#{safe_key}.lock")
11
+ @file = nil
12
+ end
13
+
14
+ def try_acquire # rubocop:disable Naming/PredicateMethod
15
+ FileUtils.mkdir_p(File.dirname(@path))
16
+ @file = File.open(@path, File::RDWR | File::CREAT, 0o644)
17
+ acquired = @file.flock(File::LOCK_EX | File::LOCK_NB)
18
+ unless acquired
19
+ @file.close
20
+ @file = nil
21
+ return false
22
+ end
23
+ @file.write(Process.pid.to_s)
24
+ @file.flush
25
+ true
26
+ end
27
+
28
+ def release
29
+ return unless @file
30
+
31
+ @file.flock(File::LOCK_UN)
32
+ @file.close
33
+ @file = nil
34
+ end
35
+
36
+ private
37
+
38
+ def safe_key
39
+ @key.to_s.gsub(/[^a-zA-Z0-9._-]/, "_")
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
data/lib/textus/init.rb CHANGED
@@ -2,21 +2,83 @@ require "fileutils"
2
2
 
3
3
  module Textus
4
4
  module Init
5
- ZONES = %w[canon working intake pending derived].freeze
5
+ ZONES = %w[identity working inbox review output].freeze
6
6
 
7
7
  DEFAULT_MANIFEST = <<~YAML
8
8
  version: textus/2
9
9
  zones:
10
- - { name: canon, writable_by: [human] }
11
- - { name: working, writable_by: [human, ai, script] }
12
- - { name: intake, writable_by: [script] }
13
- - { name: pending, writable_by: [ai, human] }
14
- - { name: derived, writable_by: [build] }
10
+ - { name: identity, writable_by: [human] }
11
+ - { name: working, writable_by: [human, ai, script] }
12
+ - { name: inbox, writable_by: [script] }
13
+ - { name: review, writable_by: [ai, human] }
14
+ - { name: output, writable_by: [build] }
15
15
  entries:
16
- - { key: canon.identity, path: canon/identity.md, zone: canon, schema: null, owner: human:self }
17
- - { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true }
16
+ - { key: identity.self, path: identity/self.md, zone: identity, schema: null, owner: human:self }
17
+ - { key: working.notes, path: working/notes, zone: working, schema: null, owner: human:self, nested: true }
18
18
  YAML
19
19
 
20
+ HOOKS_README = <<~MD
21
+ # Hooks
22
+
23
+ Drop one Ruby file per hook. All hooks register through one DSL.
24
+ Files anywhere under `.textus/hooks/` (including subdirectories) are loaded at
25
+ startup in alphabetical order by full path. Subdirectory names are organizational
26
+ only — the registered event and name come from the DSL call, not the file path.
27
+
28
+ ## Per-event sugar (preferred)
29
+
30
+ ```ruby
31
+ Textus.intake(:my_source) do |config:, args:, **|
32
+ { _meta: { "last_refreshed_at" => Time.now.utc.iso8601 }, body: "…" }
33
+ end
34
+
35
+ Textus.reduce(:my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
36
+ Textus.check(:my_check) { |store:, **| { ok: true } }
37
+ Textus.put(:my_listener, keys: ["working.*"]) { |key:, envelope:, **| }
38
+
39
+ # Run a side-effect every time textus writes a file to your repo:
40
+ Textus.published(:notify) do |key:, target:, **|
41
+ warn "wrote \#{target} (from \#{key})"
42
+ end
43
+ ```
44
+
45
+ The intake handler above is paired with a manifest entry plus a
46
+ top-level `policies:` block for freshness (ttl/on_stale live in
47
+ policies, not in the entry):
48
+
49
+ ```yaml
50
+ entries:
51
+ - key: inbox.foo
52
+ path: inbox/foo.md
53
+ zone: inbox
54
+ intake:
55
+ handler: my_source
56
+
57
+ policies:
58
+ - match: inbox.foo
59
+ refresh:
60
+ ttl: 10m
61
+ on_stale: timed_sync # warn | sync | timed_sync (default: warn)
62
+ ```
63
+
64
+ ## Low-level primitive (always available)
65
+
66
+ ```ruby
67
+ Textus.hook(:intake, :name) { |store:, config:, args:| ... } # bring bytes in
68
+ Textus.hook(:reduce, :name) { |store:, rows:, config:| ... } # transform rows
69
+ Textus.hook(:check, :name) { |store:| ... } # doctor check
70
+ Textus.hook(:put, :name, keys: ["..."]) # lifecycle listener
71
+ { |store:, key:, envelope:| ... }
72
+ ```
73
+
74
+ Events: :intake, :reduce, :check (rpc — return value used)
75
+ :put, :deleted, :refreshed, :built, :accepted, :published,
76
+ :mv, :reject, :loaded,
77
+ :refresh_began, :refresh_failed, :refresh_detached (pub-sub — return discarded)
78
+
79
+ See SPEC.md §5.10 for the full table.
80
+ MD
81
+
20
82
  def self.run(target_root)
21
83
  raise UsageError.new(".textus/ already exists at #{target_root}") if File.directory?(target_root)
22
84
 
@@ -28,26 +90,7 @@ module Textus
28
90
  FileUtils.mkdir_p(dir)
29
91
  File.write(File.join(dir, ".gitkeep"), "")
30
92
  end
31
- File.write(File.join(target_root, "hooks", "README.md"), <<~MD)
32
- # Hooks
33
-
34
- Drop one Ruby file per hook. All hooks register through one DSL.
35
- Every handler receives `store:` as its first kwarg, then event-specific args.
36
-
37
- ```ruby
38
- Textus.hook(:fetch, :name) { |store:, config:, args:| ... } # bring bytes in
39
- Textus.hook(:reduce, :name) { |store:, rows:, config:| ... } # transform rows
40
- Textus.hook(:check, :name) { |store:| ... } # doctor check
41
- Textus.hook(:put, :name, keys: ["..."]) # lifecycle listener
42
- { |store:, key:, envelope:| ... }
43
- ```
44
-
45
- Events: :fetch, :reduce, :check (rpc — return value used)
46
- :put, :delete, :refresh, :build, :accept (pub-sub — return discarded)
47
-
48
- See SPEC.md §5.10 for the full table.
49
- MD
50
-
93
+ File.write(File.join(target_root, "hooks", "README.md"), HOOKS_README)
51
94
  File.write(File.join(target_root, "manifest.yaml"), DEFAULT_MANIFEST)
52
95
  { "protocol" => PROTOCOL, "initialized" => target_root }
53
96
  end
data/lib/textus/intro.rb CHANGED
@@ -11,19 +11,19 @@ module Textus
11
11
  # Conventional zone purposes. Unknown zones (declared in the manifest
12
12
  # but not listed here) get no `purpose` field.
13
13
  ZONE_PURPOSES = {
14
- "canon" => "slow-changing identity; human-only writes",
14
+ "identity" => "slow-changing identity; human-only writes",
15
15
  "working" => "active project state; humans, AI, and scripts share this surface",
16
- "intake" => "declared external inputs; script-refreshed via actions",
17
- "pending" => "AI proposals awaiting human accept",
18
- "derived" => "build-computed outputs; never hand-edited",
16
+ "inbox" => "declared external inputs; script-refreshed via actions",
17
+ "review" => "AI proposals awaiting human accept",
18
+ "output" => "build-computed outputs; never hand-edited",
19
19
  }.freeze
20
20
 
21
21
  WRITE_FLOWS = {
22
- "human" => "edit files in canon/working zones, then 'textus put KEY --as=human'",
23
- "ai" => "propose changes by writing 'pending.*' entries with --as=ai and a 'proposal:' frontmatter block; " \
22
+ "human" => "edit files in identity/working zones, then 'textus put KEY --as=human'",
23
+ "ai" => "propose changes by writing 'review.*' entries with --as=ai and a 'proposal:' frontmatter block; " \
24
24
  "a human runs 'textus accept' to apply",
25
- "script" => "refresh intake entries with 'textus refresh KEY --as=script' (uses the entry's declared action)",
26
- "build" => "'textus build' computes derived entries from projections; derived files are never hand-edited",
25
+ "script" => "refresh inbox entries with 'textus refresh KEY --as=script' (uses the entry's declared action)",
26
+ "build" => "'textus build' computes output entries from projections; output files are never hand-edited",
27
27
  }.freeze
28
28
 
29
29
  # The CLI verb catalog. Truth lives here; do not derive dynamically.
@@ -36,13 +36,16 @@ module Textus
36
36
  { "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
37
37
  { "name" => "schema", "summary" => "field shape for a key family" },
38
38
  { "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
39
- { "name" => "accept", "summary" => "apply a pending.* proposal; --as=human only" },
39
+ { "name" => "accept", "summary" => "apply a review.* proposal; --as=human only" },
40
40
  { "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key migrate'" },
41
41
  { "name" => "delete", "summary" => "delete an entry; --as=<role>" },
42
- { "name" => "build", "summary" => "materialize derived entries; publish_to and publish_each fan out copies" },
43
- { "name" => "refresh", "summary" => "run an action for an intake entry" },
44
- { "name" => "stale", "summary" => "list derived/intake entries past their freshness check" },
45
- { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
42
+ { "name" => "build", "summary" => "materialize output entries; publish_to and publish_each fan out copies" },
43
+ { "name" => "refresh", "summary" => "run an action for an inbox entry" },
44
+ { "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
45
+ { "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
46
+ { "name" => "blame", "summary" => "audit rows for one key joined with git commit metadata" },
47
+ { "name" => "policy", "summary" => "inspect effective policies: 'policy list', 'policy explain KEY'" },
48
+ { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
46
49
  { "name" => "hook",
47
50
  "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
48
51
  ].freeze
@@ -80,7 +83,7 @@ module Textus
80
83
  "owner" => e.owner,
81
84
  "format" => e.format,
82
85
  "derived" => derived,
83
- "intake" => !e.fetch.nil?,
86
+ "intake" => !e.intake_handler.nil?,
84
87
  "publish_to" => Array(e.publish_to),
85
88
  "publish_each" => e.publish_each,
86
89
  }
@@ -5,8 +5,9 @@ module Textus
5
5
  PUBLISH_EACH_VAR_RE = /\{([a-z]+)\}/
6
6
 
7
7
  attr_reader :key, :path, :zone, :schema, :owner, :nested, :generator, :raw, :format,
8
- :projection, :template, :publish_to, :publish_each, :fetch, :fetch_config, :ttl, :events,
9
- :inject_intro
8
+ :projection, :template, :publish_to, :publish_each,
9
+ :intake_handler, :intake_config,
10
+ :events, :inject_intro
10
11
 
11
12
  def initialize(manifest, raw)
12
13
  @manifest = manifest
@@ -27,7 +28,9 @@ module Textus
27
28
  @format = resolve_format!(raw["format"])
28
29
 
29
30
  validate_events!
30
- parse_source!(raw["source"])
31
+ raise UsageError.new("entry '#{@key}': 'source:' key renamed to 'intake:' in 0.9") if raw.key?("source")
32
+
33
+ parse_intake!(raw["intake"])
31
34
  reject_legacy_projection_keys!
32
35
  validate_format_matrix!
33
36
  validate_publish_each!
@@ -53,15 +56,32 @@ module Textus
53
56
  @publish_each.gsub(PUBLISH_EACH_VAR_RE) { vars.fetch(::Regexp.last_match(1)) }
54
57
  end
55
58
 
59
+ # Signal-based zone-kind predicates: derive the "kind" of a zone from its
60
+ # writable_by signals rather than its literal name. This keeps detection
61
+ # working when users rename the default zones (canon/intake/pending/derived
62
+ # → identity/inbox/review/output, etc.).
63
+ def in_generator_zone?
64
+ zone_writers.include?("build")
65
+ end
66
+
67
+ def in_proposal_zone?
68
+ zone_writers.include?("ai")
69
+ end
70
+
71
+ # Legacy alias for in_generator_zone?. Retained because internal validation
72
+ # callers (and external tools) read more naturally as `derived?`.
56
73
  def derived?
57
- writers = @manifest.zone_writers(@zone)
58
- writers.include?("build")
59
- rescue UsageError => e
60
- raise UsageError.new("entry '#{@key}': #{e.message}")
74
+ in_generator_zone?
61
75
  end
62
76
 
63
77
  private
64
78
 
79
+ def zone_writers
80
+ @manifest.zone_writers(@zone)
81
+ rescue UsageError => e
82
+ raise UsageError.new("entry '#{@key}': #{e.message}")
83
+ end
84
+
65
85
  def validate_inject_intro!
66
86
  return unless @inject_intro
67
87
 
@@ -168,13 +188,19 @@ module Textus
168
188
  end
169
189
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
170
190
 
171
- def parse_source!(src)
172
- src ||= {}
173
- raise UsageError.new("entry '#{@key}': source.action renamed to source.fetch in 0.6") if src.key?("action")
191
+ def parse_intake!(src)
192
+ raise UsageError.new("entry '#{@key}': source.fetch renamed to intake.handler in 0.9") if src.is_a?(Hash) && src.key?("fetch")
193
+
194
+ if src.is_a?(Hash) && (src.key?("ttl") || src.key?("on_stale") || src.key?("sync_budget_ms"))
195
+ raise UsageError.new(
196
+ "entry '#{@key}': intake.ttl/intake.on_stale/intake.sync_budget_ms removed in 0.9.2 — " \
197
+ "move into a top-level policies: block (see CHANGELOG migration recipe).",
198
+ )
199
+ end
174
200
 
175
- @fetch = src["fetch"]
176
- @fetch_config = src["config"] || {}
177
- @ttl = src["ttl"]
201
+ src ||= {}
202
+ @intake_handler = src["handler"]
203
+ @intake_config = src["config"] || {}
178
204
  end
179
205
 
180
206
  def reject_legacy_projection_keys!
@@ -0,0 +1,83 @@
1
+ module Textus
2
+ class Manifest
3
+ class Policies
4
+ PolicySet = Data.define(:refresh, :handler_allowlist, :promote, :retention)
5
+ EMPTY_SET = PolicySet.new(refresh: nil, handler_allowlist: nil, promote: nil, retention: nil)
6
+
7
+ def self.parse(raw)
8
+ new(Array(raw).map { |b| Block.new(b) })
9
+ end
10
+
11
+ def initialize(blocks)
12
+ @blocks = blocks
13
+ end
14
+
15
+ attr_reader :blocks
16
+
17
+ def for(key)
18
+ slots = { refresh: [], handler_allowlist: [], promote: [], retention: [] }
19
+ @blocks.each do |b|
20
+ next unless Textus::Domain::Policy::Matcher.matches?(b.match, key)
21
+
22
+ slots.each_key { |slot| slots[slot] << b if b.public_send(slot) }
23
+ end
24
+ PolicySet.new(
25
+ refresh: pick(slots[:refresh], :refresh, key),
26
+ handler_allowlist: pick(slots[:handler_allowlist], :handler_allowlist, key),
27
+ promote: pick(slots[:promote], :promote, key),
28
+ retention: pick(slots[:retention], :retention, key),
29
+ )
30
+ end
31
+
32
+ def explain(key)
33
+ @blocks.select { |b| Textus::Domain::Policy::Matcher.matches?(b.match, key) }
34
+ end
35
+
36
+ private
37
+
38
+ def pick(blocks, slot, key)
39
+ return nil if blocks.empty?
40
+
41
+ globs = blocks.map(&:match)
42
+ winning = Textus::Domain::Policy::Matcher.pick_most_specific(globs, key: key)
43
+ blocks.find { |b| b.match == winning }&.public_send(slot)
44
+ end
45
+
46
+ class Block
47
+ attr_reader :match, :refresh, :handler_allowlist, :promote, :retention
48
+
49
+ def initialize(raw)
50
+ @match = raw["match"] or raise Textus::UsageError.new("policy block missing match:")
51
+ @refresh = parse_refresh(raw["refresh"])
52
+ @handler_allowlist = parse_handler_allowlist(raw["handler_allowlist"])
53
+ @promote = parse_promote(raw["promote_requires"])
54
+ @retention = raw["retention"] # reserved — passthrough only
55
+ end
56
+
57
+ private
58
+
59
+ def parse_refresh(h)
60
+ return nil if h.nil?
61
+
62
+ Textus::Domain::Policy::Refresh.new(
63
+ ttl: h["ttl"],
64
+ on_stale: h["on_stale"] || "warn",
65
+ sync_budget_ms: h["sync_budget_ms"],
66
+ )
67
+ end
68
+
69
+ def parse_handler_allowlist(arr)
70
+ return nil if arr.nil?
71
+
72
+ Textus::Domain::Policy::HandlerAllowlist.new(handlers: arr)
73
+ end
74
+
75
+ def parse_promote(arr)
76
+ return nil if arr.nil?
77
+
78
+ Textus::Domain::Policy::Promote.new(requires: arr)
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end
@@ -20,6 +20,14 @@ module Textus
20
20
  zones[zone_name] or raise UsageError.new("undeclared zone '#{zone_name}'")
21
21
  end
22
22
 
23
+ def permission_for(zone_name)
24
+ Textus::Domain::Permission.new(
25
+ zone: zone_name,
26
+ writable_by: zone_writers(zone_name),
27
+ readable_by: :all,
28
+ )
29
+ end
30
+
23
31
  def self.load(root)
24
32
  manifest_path = File.join(root, "manifest.yaml")
25
33
  raise IoError.new("manifest not found: #{manifest_path}") unless File.exist?(manifest_path)
@@ -42,10 +50,19 @@ module Textus
42
50
  @raw = raw
43
51
  raise BadFrontmatter.new(File.join(root, "manifest.yaml"), "manifest must declare zones:") if Array(raw["zones"]).empty?
44
52
 
53
+ reject_legacy_entry_intake_policy!(Array(raw["entries"]))
45
54
  @entries = Array(raw["entries"]).map { |e| Manifest::Entry.new(self, e) }
46
55
  validate_declared_keys!
47
56
  end
48
57
 
58
+ def policies
59
+ @policies ||= Textus::Manifest::Policies.parse(@raw["policies"] || [])
60
+ end
61
+
62
+ def policies_for(key)
63
+ policies.for(key)
64
+ end
65
+
49
66
  # Returns [Manifest::Entry, resolved_path, remaining_segments]
50
67
  def resolve(key)
51
68
  validate_key!(key)
@@ -119,17 +136,6 @@ module Textus
119
136
  end
120
137
  # rubocop:enable Metrics/AbcSize
121
138
 
122
- # Validates all declared entry keys; raises UsageError listing all offenders.
123
- def validate_keys!
124
- offenders = []
125
- @entries.each do |entry|
126
- validate_key!(entry.key)
127
- rescue UsageError => e
128
- offenders << e.message
129
- end
130
- raise UsageError.new("invalid manifest keys: #{offenders.join("; ")}") unless offenders.empty?
131
- end
132
-
133
139
  def validate_key!(key)
134
140
  raise UsageError.new("empty key") if key.nil? || key.empty?
135
141
 
@@ -149,6 +155,19 @@ module Textus
149
155
  @entries.each { |e| validate_key!(e.key) }
150
156
  end
151
157
 
158
+ def reject_legacy_entry_intake_policy!(raw_entries)
159
+ raw_entries.each do |re|
160
+ intake = re["intake"]
161
+ next unless intake.is_a?(Hash)
162
+ next unless intake.key?("ttl") || intake.key?("on_stale") || intake.key?("sync_budget_ms")
163
+
164
+ raise UsageError.new(
165
+ "entry '#{re["key"]}': intake.ttl/intake.on_stale/intake.sync_budget_ms removed in 0.9.2 — " \
166
+ "move into a top-level policies: block (see CHANGELOG migration recipe).",
167
+ )
168
+ end
169
+ end
170
+
152
171
  def resolve_leaf_path(entry)
153
172
  Textus::Key::Path.resolve(self, entry)
154
173
  end
@@ -40,7 +40,7 @@ module Textus
40
40
  def apply_reducer(rows)
41
41
  name = @spec["reduce"] or return rows
42
42
  callable = @store.registry.rpc_callable(:reduce, name)
43
- view = Store::View.new(@store)
43
+ view = Application::Context.new(store: @store, role: "human")
44
44
  Timeout.timeout(REDUCER_TIMEOUT_SECONDS) do
45
45
  callable.call(store: view, rows: rows, config: @spec["reduce_config"] || {})
46
46
  end
@@ -1,27 +1,10 @@
1
1
  module Textus
2
2
  module Proposal
3
+ # Deprecated as of 0.9.1: use Textus::Application::Writes::Accept (via
4
+ # Textus::Composition.writes_accept).
3
5
  def self.accept(store, pending_key, as:)
4
- raise ProposalError.new("only human role can accept proposals; got '#{as}'") unless as == "human"
5
-
6
- env = store.get(pending_key)
7
- proposal = env["_meta"]["proposal"] or raise ProposalError.new("entry has no proposal block: #{pending_key}")
8
- target = proposal["target_key"] or raise ProposalError.new("proposal missing target_key")
9
- action = proposal["action"] || "put"
10
-
11
- case action
12
- when "put"
13
- target_meta = env["_meta"]["frontmatter"] || {}
14
- target_body = env["body"]
15
- store.put(target, meta: target_meta, body: target_body, as: "human")
16
- when "delete"
17
- store.delete(target, as: "human")
18
- else
19
- raise ProposalError.new("unknown action: #{action}")
20
- end
21
-
22
- store.delete(pending_key, as: "human")
23
- store.fire_event(:accept, key: pending_key, target_key: target)
24
- { "protocol" => PROTOCOL, "accepted" => pending_key, "target_key" => target, "action" => action }
6
+ ctx = Textus::Composition.context(store, role: as)
7
+ Textus::Application::Writes::Accept.new(ctx: ctx, bus: store.bus).call(pending_key)
25
8
  end
26
9
  end
27
10
  end
@@ -1,57 +1,21 @@
1
- require "timeout"
2
-
3
1
  module Textus
4
2
  module Refresh
5
- FETCH_TIMEOUT_SECONDS = 2
6
-
7
3
  def self.call(store, key, as:)
8
- mentry, path, = store.manifest.resolve(key)
9
- raise UsageError.new("no fetch declared for '#{key}'") unless mentry.fetch
10
-
11
- before_etag = File.exist?(path) ? Etag.for_file(path) : nil
12
- callable = store.registry.rpc_callable(:fetch, mentry.fetch)
13
- view = Store::View.new(store, writable: true, as: as)
14
- result =
15
- begin
16
- Timeout.timeout(FETCH_TIMEOUT_SECONDS) do
17
- callable.call(store: view, config: mentry.fetch_config, args: {})
18
- end
19
- rescue Timeout::Error
20
- raise UsageError.new("fetch '#{mentry.fetch}' exceeded #{FETCH_TIMEOUT_SECONDS}s timeout")
21
- rescue Textus::Error
22
- raise
23
- rescue StandardError => e
24
- raise UsageError.new("fetch '#{mentry.fetch}' raised: #{e.class}: #{e.message}")
25
- end
26
-
27
- normalized = normalize_action_result(result, format: mentry.format)
28
- envelope = store.put(
29
- key,
30
- meta: normalized[:meta],
31
- body: normalized[:body],
32
- content: normalized[:content],
33
- as: as,
34
- suppress_events: true,
35
- )
4
+ ctx = Textus::Composition.context(store, role: as)
5
+ Textus::Composition.refresh_worker(ctx).run(key)
6
+ end
36
7
 
37
- change = if before_etag.nil?
38
- :created
39
- elsif envelope["etag"] == before_etag
40
- :unchanged
41
- else
42
- :updated
43
- end
44
- store.fire_event(:refresh, key: key, envelope: envelope, change: change) unless change == :unchanged
45
- envelope
8
+ def self.refresh_stale(store, prefix: nil, zone: nil, as: "script")
9
+ ctx = Textus::Composition.context(store, role: as)
10
+ Textus::Application::Refresh::All.call(ctx, prefix: prefix, zone: zone)
46
11
  end
47
12
 
48
- # Normalize the three accepted fetch return shapes into the store's
13
+ # Normalize the three accepted intake return shapes into the store's
49
14
  # internal {frontmatter, body, content} representation.
50
15
  def self.normalize_action_result(res, format:)
51
16
  res = res.transform_keys(&:to_s) if res.is_a?(Hash)
52
17
  res ||= {}
53
- # Accept both legacy :frontmatter/:_meta key names from fetch hooks.
54
- meta_val = res["_meta"] || res["frontmatter"]
18
+ meta_val = res["_meta"]
55
19
  body = res["body"]
56
20
  content = res["content"]
57
21
 
@@ -66,7 +30,7 @@ module Textus
66
30
  elsif !body.nil?
67
31
  { meta: {}, body: body.to_s, content: nil }
68
32
  else
69
- raise UsageError.new("fetch for #{format} returned neither content nor body")
33
+ raise UsageError.new("intake for #{format} returned neither content nor body")
70
34
  end
71
35
  else
72
36
  raise UsageError.new("unknown format #{format.inspect}")