textus 0.8.1 → 0.9.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.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +224 -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 +68 -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 +68 -0
  15. data/lib/textus/application/refresh/worker.rb +79 -0
  16. data/lib/textus/application/writes/accept.rb +43 -0
  17. data/lib/textus/application/writes/build.rb +24 -0
  18. data/lib/textus/application/writes/delete.rb +37 -0
  19. data/lib/textus/application/writes/publish.rb +25 -0
  20. data/lib/textus/application/writes/put.rb +44 -0
  21. data/lib/textus/builder.rb +27 -14
  22. data/lib/textus/cli/group/policy.rb +11 -0
  23. data/lib/textus/cli/verb/accept.rb +2 -1
  24. data/lib/textus/cli/verb/audit.rb +31 -0
  25. data/lib/textus/cli/verb/blame.rb +17 -0
  26. data/lib/textus/cli/verb/build.rb +2 -1
  27. data/lib/textus/cli/verb/delete.rb +2 -1
  28. data/lib/textus/cli/verb/freshness.rb +17 -0
  29. data/lib/textus/cli/verb/get.rb +8 -1
  30. data/lib/textus/cli/verb/hook_run.rb +3 -3
  31. data/lib/textus/cli/verb/policy_explain.rb +15 -0
  32. data/lib/textus/cli/verb/policy_list.rb +25 -0
  33. data/lib/textus/cli/verb/put.rb +5 -4
  34. data/lib/textus/cli/verb/refresh.rb +2 -1
  35. data/lib/textus/cli/verb/refresh_stale.rb +19 -0
  36. data/lib/textus/cli/verb/reject.rb +15 -0
  37. data/lib/textus/cli.rb +16 -2
  38. data/lib/textus/composition.rb +71 -0
  39. data/lib/textus/doctor/check/handler_allowlist.rb +33 -0
  40. data/lib/textus/doctor/check/intake_registration.rb +46 -0
  41. data/lib/textus/doctor/check/legacy_intake_fields.rb +57 -0
  42. data/lib/textus/doctor/check/policy_ambiguity.rb +49 -0
  43. data/lib/textus/doctor.rb +4 -0
  44. data/lib/textus/domain/action.rb +9 -0
  45. data/lib/textus/domain/freshness/evaluator.rb +30 -0
  46. data/lib/textus/domain/freshness/policy.rb +18 -0
  47. data/lib/textus/domain/freshness/verdict.rb +12 -0
  48. data/lib/textus/domain/outcome.rb +10 -0
  49. data/lib/textus/domain/permission.rb +15 -0
  50. data/lib/textus/domain/policy/handler_allowlist.rb +17 -0
  51. data/lib/textus/domain/policy/matcher.rb +51 -0
  52. data/lib/textus/domain/policy/promote.rb +24 -0
  53. data/lib/textus/domain/policy/refresh.rb +48 -0
  54. data/lib/textus/domain/policy.rb +7 -0
  55. data/lib/textus/hooks/builtin.rb +5 -5
  56. data/lib/textus/hooks/dispatcher.rb +15 -1
  57. data/lib/textus/hooks/dsl.rb +18 -0
  58. data/lib/textus/hooks/registry.rb +12 -5
  59. data/lib/textus/infra/clock.rb +9 -0
  60. data/lib/textus/infra/event_bus.rb +27 -0
  61. data/lib/textus/infra/publisher.rb +73 -0
  62. data/lib/textus/infra/refresh/detached.rb +38 -0
  63. data/lib/textus/infra/refresh/lock.rb +44 -0
  64. data/lib/textus/init.rb +71 -28
  65. data/lib/textus/intro.rb +19 -11
  66. data/lib/textus/manifest/entry.rb +18 -9
  67. data/lib/textus/manifest/policies.rb +83 -0
  68. data/lib/textus/manifest.rb +30 -0
  69. data/lib/textus/proposal.rb +4 -21
  70. data/lib/textus/publisher.rb +4 -69
  71. data/lib/textus/refresh.rb +9 -44
  72. data/lib/textus/store/mover.rb +14 -9
  73. data/lib/textus/store/reader.rb +10 -8
  74. data/lib/textus/store/staleness.rb +4 -16
  75. data/lib/textus/store/validator.rb +46 -20
  76. data/lib/textus/store/view.rb +8 -19
  77. data/lib/textus/store/writer.rb +51 -14
  78. data/lib/textus/store.rb +29 -9
  79. data/lib/textus/version.rb +1 -1
  80. data/lib/textus.rb +1 -0
  81. metadata +46 -2
  82. data/lib/textus/cli/verb/stale.rb +0 -14
@@ -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,24 @@ 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
+ "inbox" => "declared external inputs; script-refreshed via actions",
17
+ "review" => "AI proposals awaiting human accept",
18
+ "output" => "build-computed outputs; never hand-edited",
19
+ # legacy 0.9.1 zone names — kept so intro still annotates pre-rename stores
20
+ "canon" => "slow-changing identity; human-only writes",
16
21
  "intake" => "declared external inputs; script-refreshed via actions",
17
22
  "pending" => "AI proposals awaiting human accept",
18
23
  "derived" => "build-computed outputs; never hand-edited",
19
24
  }.freeze
20
25
 
21
26
  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; " \
27
+ "human" => "edit files in identity/working zones, then 'textus put KEY --as=human'",
28
+ "ai" => "propose changes by writing 'review.*' entries with --as=ai and a 'proposal:' frontmatter block; " \
24
29
  "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",
30
+ "script" => "refresh inbox entries with 'textus refresh KEY --as=script' (uses the entry's declared action)",
31
+ "build" => "'textus build' computes output entries from projections; output files are never hand-edited",
27
32
  }.freeze
28
33
 
29
34
  # The CLI verb catalog. Truth lives here; do not derive dynamically.
@@ -36,13 +41,16 @@ module Textus
36
41
  { "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
37
42
  { "name" => "schema", "summary" => "field shape for a key family" },
38
43
  { "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
39
- { "name" => "accept", "summary" => "apply a pending.* proposal; --as=human only" },
44
+ { "name" => "accept", "summary" => "apply a review.* proposal; --as=human only" },
40
45
  { "name" => "key", "summary" => "key operations: 'key mv', 'key uid', 'key migrate'" },
41
46
  { "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.)" },
47
+ { "name" => "build", "summary" => "materialize output entries; publish_to and publish_each fan out copies" },
48
+ { "name" => "refresh", "summary" => "run an action for an inbox entry" },
49
+ { "name" => "freshness", "summary" => "per-entry freshness report (status, age, ttl, on_stale)" },
50
+ { "name" => "audit", "summary" => "query .textus/audit.log with filters (key, role, since, correlation-id, ...)" },
51
+ { "name" => "blame", "summary" => "audit rows for one key joined with git commit metadata" },
52
+ { "name" => "policy", "summary" => "inspect effective policies: 'policy list', 'policy explain KEY'" },
53
+ { "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
46
54
  { "name" => "hook",
47
55
  "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
48
56
  ].freeze
@@ -80,7 +88,7 @@ module Textus
80
88
  "owner" => e.owner,
81
89
  "format" => e.format,
82
90
  "derived" => derived,
83
- "intake" => !e.fetch.nil?,
91
+ "intake" => !e.intake_handler.nil?,
84
92
  "publish_to" => Array(e.publish_to),
85
93
  "publish_each" => e.publish_each,
86
94
  }
@@ -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!
@@ -168,13 +171,19 @@ module Textus
168
171
  end
169
172
  # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
170
173
 
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")
174
+ def parse_intake!(src)
175
+ raise UsageError.new("entry '#{@key}': source.fetch renamed to intake.handler in 0.9") if src.is_a?(Hash) && src.key?("fetch")
174
176
 
175
- @fetch = src["fetch"]
176
- @fetch_config = src["config"] || {}
177
- @ttl = src["ttl"]
177
+ if src.is_a?(Hash) && (src.key?("ttl") || src.key?("on_stale") || src.key?("sync_budget_ms"))
178
+ raise UsageError.new(
179
+ "entry '#{@key}': intake.ttl/intake.on_stale/intake.sync_budget_ms removed in 0.9.2 — " \
180
+ "move into a top-level policies: block (see CHANGELOG migration recipe).",
181
+ )
182
+ end
183
+
184
+ src ||= {}
185
+ @intake_handler = src["handler"]
186
+ @intake_config = src["config"] || {}
178
187
  end
179
188
 
180
189
  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)
@@ -149,6 +166,19 @@ module Textus
149
166
  @entries.each { |e| validate_key!(e.key) }
150
167
  end
151
168
 
169
+ def reject_legacy_entry_intake_policy!(raw_entries)
170
+ raw_entries.each do |re|
171
+ intake = re["intake"]
172
+ next unless intake.is_a?(Hash)
173
+ next unless intake.key?("ttl") || intake.key?("on_stale") || intake.key?("sync_budget_ms")
174
+
175
+ raise UsageError.new(
176
+ "entry '#{re["key"]}': intake.ttl/intake.on_stale/intake.sync_budget_ms removed in 0.9.2 — " \
177
+ "move into a top-level policies: block (see CHANGELOG migration recipe).",
178
+ )
179
+ end
180
+ end
181
+
152
182
  def resolve_leaf_path(entry)
153
183
  Textus::Key::Path.resolve(self, entry)
154
184
  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,71 +1,6 @@
1
- require "json"
2
- require "digest"
3
- require "fileutils"
4
-
1
+ # Deprecated as of 0.9.1: use Textus::Infra::Publisher (or
2
+ # Textus::Application::Writes::Publish for the use-case entry point).
3
+ # Slated for removal in 0.10.0.
5
4
  module Textus
6
- # Publishes built artifacts from the store to repo-relative consumer paths.
7
- # Publish = copy + sentinel. The in-store file is already the consumer-shaped
8
- # artifact; no parsing or stripping. Sentinels live under
9
- # `<store_root>/sentinels/` and mirror the target's repo-relative layout so
10
- # consumer directories aren't polluted with `.textus-managed.json` siblings.
11
- module Publisher
12
- SENTINEL_SUFFIX = ".textus-managed.json".freeze
13
- SENTINEL_DIR = "sentinels".freeze
14
-
15
- def self.publish(source:, target:, store_root:)
16
- FileUtils.mkdir_p(File.dirname(target))
17
- refuse_if_unmanaged(target, store_root)
18
- File.delete(target) if File.symlink?(target)
19
- FileUtils.cp(source, target)
20
- write_sentinel(target, store_root: store_root, source: source)
21
- cleanup_legacy_sentinel(target)
22
- end
23
-
24
- def self.refuse_if_unmanaged(target, store_root)
25
- return unless File.exist?(target) || File.symlink?(target)
26
- return if managed?(target, store_root)
27
-
28
- raise PublishError.new("refusing to clobber unmanaged file at #{target}", target: target)
29
- end
30
-
31
- def self.managed?(target, store_root)
32
- File.exist?(sentinel_path(target, store_root)) || File.exist?(legacy_sentinel_path(target))
33
- end
34
-
35
- def self.write_sentinel(target, store_root:, source:)
36
- path = sentinel_path(target, store_root)
37
- FileUtils.mkdir_p(File.dirname(path))
38
- File.write(path, JSON.generate(
39
- "source" => source,
40
- "target" => target,
41
- "sha256" => Digest::SHA256.hexdigest(File.binread(target)),
42
- "mode" => "copy",
43
- ))
44
- end
45
-
46
- # Sentinel layout: <store_root>/sentinels/<target_rel_to_repo>.textus-managed.json
47
- # The full target extension is preserved so a marketplace.json and
48
- # marketplace.yaml don't collide.
49
- def self.sentinel_path(target, store_root)
50
- repo_root = File.dirname(store_root)
51
- rel = relative_to(target, repo_root) || File.basename(target)
52
- File.join(store_root, SENTINEL_DIR, rel + SENTINEL_SUFFIX)
53
- end
54
-
55
- def self.legacy_sentinel_path(target)
56
- target + SENTINEL_SUFFIX
57
- end
58
-
59
- def self.cleanup_legacy_sentinel(target)
60
- FileUtils.rm_f(legacy_sentinel_path(target))
61
- end
62
-
63
- def self.relative_to(path, base)
64
- path = File.expand_path(path)
65
- base = File.expand_path(base)
66
- return nil unless path.start_with?(base + File::SEPARATOR)
67
-
68
- path[(base.length + 1)..]
69
- end
70
- end
5
+ Publisher = Infra::Publisher
71
6
  end
@@ -1,56 +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.
18
+ # Accept both legacy :frontmatter/:_meta key names from intake hooks.
54
19
  meta_val = res["_meta"] || res["frontmatter"]
55
20
  body = res["body"]
56
21
  content = res["content"]
@@ -66,7 +31,7 @@ module Textus
66
31
  elsif !body.nil?
67
32
  { meta: {}, body: body.to_s, content: nil }
68
33
  else
69
- raise UsageError.new("fetch for #{format} returned neither content nor body")
34
+ raise UsageError.new("intake for #{format} returned neither content nor body")
70
35
  end
71
36
  else
72
37
  raise UsageError.new("unknown format #{format.inspect}")
@@ -4,14 +4,15 @@ module Textus
4
4
  class Store
5
5
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
6
6
  class Mover
7
- def initialize(reader:, writer:, manifest:, audit_log:)
7
+ def initialize(store:, reader:, writer:, manifest:, audit_log:)
8
+ @store = store
8
9
  @reader = reader
9
10
  @writer = writer
10
11
  @manifest = manifest
11
12
  @audit_log = audit_log
12
13
  end
13
14
 
14
- def call(old_key, new_key, as: Role::DEFAULT, dry_run: false)
15
+ def call(old_key, new_key, as: Role::DEFAULT, dry_run: false, correlation_id: nil)
15
16
  @manifest.validate_key!(old_key)
16
17
  @manifest.validate_key!(new_key)
17
18
  raise UsageError.new("mv: old and new keys are identical") if old_key == new_key
@@ -69,23 +70,27 @@ module Textus
69
70
  rewrite_name_for_mv!(new_mentry, new_path, new_key)
70
71
  etag_after = Etag.for_file(new_path)
71
72
 
73
+ extras = {
74
+ "from_key" => old_key, "to_key" => new_key,
75
+ "from_path" => old_path, "to_path" => new_path,
76
+ "uid" => current_uid
77
+ }
78
+ extras["correlation_id"] = correlation_id if correlation_id
79
+
72
80
  @audit_log.append(
73
81
  role: as, verb: "mv", key: new_key,
74
82
  etag_before: etag_before, etag_after: etag_after,
75
- extras: {
76
- "from_key" => old_key, "to_key" => new_key,
77
- "from_path" => old_path, "to_path" => new_path,
78
- "uid" => current_uid
79
- }
83
+ extras: extras
80
84
  )
81
85
 
82
- env = @reader.get(new_key)
86
+ new_envelope = @reader.get(new_key)
87
+ @store.fire_event(:mv, key: new_key, from_key: old_key, to_key: new_key, envelope: new_envelope)
83
88
  {
84
89
  "protocol" => PROTOCOL, "ok" => true,
85
90
  "from_key" => old_key, "to_key" => new_key,
86
91
  "from_path" => old_path, "to_path" => new_path,
87
92
  "uid" => current_uid,
88
- "envelope" => env
93
+ "envelope" => new_envelope
89
94
  }
90
95
  end
91
96