textus 0.50.0 → 0.52.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 (130) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +41 -43
  4. data/SPEC.md +176 -176
  5. data/docs/architecture/README.md +46 -42
  6. data/docs/reference/conventions.md +31 -26
  7. data/lib/textus/boot.rb +15 -17
  8. data/lib/textus/call.rb +1 -1
  9. data/lib/textus/cli/runner.rb +15 -10
  10. data/lib/textus/cli/verb/get.rb +1 -3
  11. data/lib/textus/cli/verb/hook_run.rb +1 -1
  12. data/lib/textus/cli/verb/put.rb +4 -20
  13. data/lib/textus/cli/verb/serve.rb +19 -0
  14. data/lib/textus/cli.rb +1 -3
  15. data/lib/textus/dispatcher.rb +3 -3
  16. data/lib/textus/doctor/check/generator_drift.rb +4 -3
  17. data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
  18. data/lib/textus/doctor/check/intake_registration.rb +5 -5
  19. data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
  20. data/lib/textus/doctor/check/sentinels.rb +2 -2
  21. data/lib/textus/doctor/check/templates.rb +13 -11
  22. data/lib/textus/doctor.rb +0 -2
  23. data/lib/textus/domain/freshness/evaluator.rb +150 -14
  24. data/lib/textus/domain/freshness/verdict.rb +28 -6
  25. data/lib/textus/domain/freshness.rb +4 -33
  26. data/lib/textus/domain/jobs/job.rb +58 -0
  27. data/lib/textus/domain/jobs/registry.rb +37 -0
  28. data/lib/textus/domain/policy/base_guards.rb +1 -1
  29. data/lib/textus/domain/policy/predicates/fresh_within.rb +1 -1
  30. data/lib/textus/domain/policy/publish_target.rb +34 -0
  31. data/lib/textus/domain/policy/retention.rb +29 -0
  32. data/lib/textus/domain/policy/source.rb +73 -0
  33. data/lib/textus/domain/retention/sweep.rb +57 -0
  34. data/lib/textus/domain/retention.rb +11 -0
  35. data/lib/textus/errors.rb +4 -4
  36. data/lib/textus/hooks/builtin.rb +5 -5
  37. data/lib/textus/hooks/catalog.rb +7 -7
  38. data/lib/textus/hooks/context.rb +5 -10
  39. data/lib/textus/init/templates/machine_intake.rb +4 -4
  40. data/lib/textus/init.rb +47 -47
  41. data/lib/textus/jobs/handlers.rb +62 -0
  42. data/lib/textus/jobs/scheduler.rb +36 -0
  43. data/lib/textus/jobs/seeder.rb +57 -0
  44. data/lib/textus/key/matching.rb +24 -0
  45. data/lib/textus/layout.rb +8 -0
  46. data/lib/textus/maintenance/drain.rb +42 -0
  47. data/lib/textus/maintenance/retention/apply.rb +52 -0
  48. data/lib/textus/maintenance/serve.rb +30 -0
  49. data/lib/textus/maintenance/worker.rb +74 -0
  50. data/lib/textus/manifest/capabilities.rb +1 -1
  51. data/lib/textus/manifest/data.rb +18 -3
  52. data/lib/textus/manifest/entry/base.rb +28 -9
  53. data/lib/textus/manifest/entry/nested.rb +3 -4
  54. data/lib/textus/manifest/entry/parser.rb +25 -21
  55. data/lib/textus/manifest/entry/produced.rb +56 -0
  56. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +7 -6
  57. data/lib/textus/manifest/entry/publish/to_paths.rb +62 -11
  58. data/lib/textus/manifest/entry/validators/format_matrix.rb +3 -11
  59. data/lib/textus/manifest/entry/validators/publish.rb +3 -1
  60. data/lib/textus/manifest/entry/validators.rb +0 -1
  61. data/lib/textus/manifest/policy.rb +16 -4
  62. data/lib/textus/manifest/resolver.rb +10 -4
  63. data/lib/textus/manifest/rules.rb +37 -36
  64. data/lib/textus/manifest/schema/keys.rb +98 -0
  65. data/lib/textus/manifest/schema/validator.rb +324 -0
  66. data/lib/textus/manifest/schema/vocabulary.rb +24 -0
  67. data/lib/textus/manifest/schema.rb +27 -247
  68. data/lib/textus/manifest.rb +5 -3
  69. data/lib/textus/mcp/server.rb +1 -1
  70. data/lib/textus/ports/audit_log.rb +6 -0
  71. data/lib/textus/ports/build_lock.rb +6 -0
  72. data/lib/textus/ports/clock.rb +4 -3
  73. data/lib/textus/ports/produce_on_write_subscriber.rb +73 -0
  74. data/lib/textus/ports/publisher.rb +11 -7
  75. data/lib/textus/ports/queue.rb +130 -0
  76. data/lib/textus/produce/acquire/handler.rb +29 -0
  77. data/lib/textus/produce/acquire/intake.rb +130 -0
  78. data/lib/textus/produce/acquire/projection.rb +127 -0
  79. data/lib/textus/produce/acquire/serializer/json.rb +31 -0
  80. data/lib/textus/produce/acquire/serializer/text.rb +16 -0
  81. data/lib/textus/produce/acquire/serializer/yaml.rb +31 -0
  82. data/lib/textus/produce/acquire/serializer.rb +17 -0
  83. data/lib/textus/produce/engine.rb +95 -0
  84. data/lib/textus/produce/events.rb +36 -0
  85. data/lib/textus/produce/render.rb +23 -0
  86. data/lib/textus/projection.rb +17 -6
  87. data/lib/textus/read/deps.rb +3 -3
  88. data/lib/textus/read/freshness.rb +61 -31
  89. data/lib/textus/read/get.rb +20 -102
  90. data/lib/textus/read/jobs.rb +31 -0
  91. data/lib/textus/read/rdeps.rb +3 -3
  92. data/lib/textus/read/rule_explain.rb +41 -23
  93. data/lib/textus/read/rule_list.rb +25 -8
  94. data/lib/textus/read/validate_all.rb +14 -0
  95. data/lib/textus/role.rb +2 -1
  96. data/lib/textus/schemas.rb +8 -0
  97. data/lib/textus/store.rb +1 -0
  98. data/lib/textus/version.rb +1 -1
  99. data/lib/textus/write/enqueue.rb +50 -0
  100. data/lib/textus/write/put.rb +1 -1
  101. metadata +35 -30
  102. data/lib/textus/builder/pipeline.rb +0 -88
  103. data/lib/textus/builder/renderer/json.rb +0 -45
  104. data/lib/textus/builder/renderer/markdown.rb +0 -24
  105. data/lib/textus/builder/renderer/text.rb +0 -14
  106. data/lib/textus/builder/renderer/yaml.rb +0 -45
  107. data/lib/textus/builder/renderer.rb +0 -17
  108. data/lib/textus/cli/verb/boot.rb +0 -14
  109. data/lib/textus/cli/verb/build.rb +0 -15
  110. data/lib/textus/doctor/check/fetch_locks.rb +0 -49
  111. data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
  112. data/lib/textus/domain/freshness/policy.rb +0 -18
  113. data/lib/textus/domain/lifecycle.rb +0 -83
  114. data/lib/textus/domain/outcome.rb +0 -10
  115. data/lib/textus/domain/policy/lifecycle.rb +0 -35
  116. data/lib/textus/domain/staleness/generator_check.rb +0 -109
  117. data/lib/textus/domain/staleness.rb +0 -29
  118. data/lib/textus/maintenance/tend.rb +0 -110
  119. data/lib/textus/manifest/entry/derived.rb +0 -67
  120. data/lib/textus/manifest/entry/intake.rb +0 -31
  121. data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
  122. data/lib/textus/mcp/tools.rb +0 -14
  123. data/lib/textus/ports/fetch/detached.rb +0 -52
  124. data/lib/textus/ports/fetch/lock.rb +0 -44
  125. data/lib/textus/write/build.rb +0 -90
  126. data/lib/textus/write/fetch_events.rb +0 -42
  127. data/lib/textus/write/fetch_orchestrator.rb +0 -101
  128. data/lib/textus/write/fetch_worker.rb +0 -127
  129. data/lib/textus/write/intake_fetch.rb +0 -25
  130. data/lib/textus/write/materializer.rb +0 -51
data/lib/textus/errors.rb CHANGED
@@ -127,10 +127,10 @@ module Textus
127
127
  def initialize(holder)
128
128
  super(
129
129
  "build_in_progress",
130
- "textus build already running (#{holder})",
130
+ "a textus maintenance pass is already running (#{holder})",
131
131
  details: { "holder" => holder },
132
132
  exit_code: 75,
133
- hint: "wait for the running build to finish, or check for a recursive hook trigger"
133
+ hint: "wait for the running maintenance pass to finish, or check for a recursive hook trigger"
134
134
  )
135
135
  end
136
136
  end
@@ -166,9 +166,9 @@ module Textus
166
166
  def initialize(m, format: nil)
167
167
  hint =
168
168
  if format
169
- "the template rendered invalid #{format}; try rendering with mock data and parsing the output before re-running build"
169
+ "the template rendered invalid #{format}; try rendering with mock data and parsing the output before re-running drain"
170
170
  else
171
- "the template rendered invalid content; try rendering with mock data and parsing the output before re-running build"
171
+ "the template rendered invalid content; try rendering with mock data and parsing the output before re-running drain"
172
172
  end
173
173
  super("bad_render", m, hint: hint)
174
174
  end
@@ -8,21 +8,21 @@ module Textus
8
8
  module Builtin
9
9
  # rubocop:disable Metrics/AbcSize, Metrics/MethodLength
10
10
  def self.register_all(events:, rpc:) # rubocop:disable Lint/UnusedMethodArgument
11
- rpc.register(:resolve_intake, :json) do |caps:, config:, args:|
11
+ rpc.register(:resolve_handler, :json) do |caps:, config:, args:|
12
12
  _ = caps
13
13
  _ = args
14
14
  data = JSON.parse(config["bytes"].to_s)
15
15
  { _meta: {}, body: YAML.dump(data) }
16
16
  end
17
17
 
18
- rpc.register(:resolve_intake, :csv) do |caps:, config:, args:|
18
+ rpc.register(:resolve_handler, :csv) do |caps:, config:, args:|
19
19
  _ = caps
20
20
  _ = args
21
21
  rows = CSV.parse(config["bytes"].to_s, headers: true).map(&:to_h)
22
22
  { _meta: {}, body: YAML.dump(rows) }
23
23
  end
24
24
 
25
- rpc.register(:resolve_intake, :"markdown-links") do |caps:, config:, args:|
25
+ rpc.register(:resolve_handler, :"markdown-links") do |caps:, config:, args:|
26
26
  _ = caps
27
27
  _ = args
28
28
  links = config["bytes"].to_s.scan(%r{\[([^\]]+)\]\((https?://[^)\s]+)\)}).map do |text, href|
@@ -31,7 +31,7 @@ module Textus
31
31
  { _meta: {}, body: YAML.dump(links) }
32
32
  end
33
33
 
34
- rpc.register(:resolve_intake, :"ical-events") do |caps:, config:, args:|
34
+ rpc.register(:resolve_handler, :"ical-events") do |caps:, config:, args:|
35
35
  _ = caps
36
36
  _ = args
37
37
  events_list = []
@@ -50,7 +50,7 @@ module Textus
50
50
  { _meta: {}, body: YAML.dump(events_list) }
51
51
  end
52
52
 
53
- rpc.register(:resolve_intake, :rss) do |caps:, config:, args:|
53
+ rpc.register(:resolve_handler, :rss) do |caps:, config:, args:|
54
54
  _ = caps
55
55
  _ = args
56
56
  doc = REXML::Document.new(config["bytes"].to_s)
@@ -11,24 +11,24 @@ module Textus
11
11
  module Catalog
12
12
  # Pub-sub events: 0..N handlers, fire-and-forget, receive `ctx:`.
13
13
  PUBSUB = {
14
- entry_put: %i[ctx key envelope],
14
+ entry_written: %i[ctx key envelope],
15
15
  entry_deleted: %i[ctx key],
16
16
  entry_fetched: %i[ctx key envelope change],
17
17
  entry_renamed: %i[ctx key from_key to_key envelope],
18
- build_completed: %i[ctx key envelope sources],
18
+ entry_produced: %i[ctx key envelope sources],
19
+ produce_failed: %i[ctx keys error],
19
20
  proposal_accepted: %i[ctx key target_key],
20
21
  proposal_rejected: %i[ctx key target_key],
21
- file_published: %i[ctx key envelope source target],
22
+ entry_published: %i[ctx key envelope source target],
22
23
  store_loaded: %i[ctx],
23
24
  session_opened: %i[ctx role cursor],
24
- fetch_started: %i[ctx key mode],
25
- fetch_failed: %i[ctx key error_class error_message],
26
- fetch_backgrounded: %i[ctx key started_at budget_ms],
25
+ entry_fetch_started: %i[ctx key mode],
26
+ entry_fetch_failed: %i[ctx key error_class error_message],
27
27
  }.freeze
28
28
 
29
29
  # RPC events: single handler, return value matters, receive `caps:`.
30
30
  RPC = {
31
- resolve_intake: %i[caps config args],
31
+ resolve_handler: %i[caps config args],
32
32
  transform_rows: %i[caps rows config],
33
33
  validate: %i[caps],
34
34
  }.freeze
@@ -28,16 +28,11 @@ module Textus
28
28
  @scope
29
29
  end
30
30
 
31
- # read — a deliberately pure-observation surface: NOTHING here fetches
32
- # (`list`/`deps`/`freshness` don't either). The invariant is that a hook
33
- # observes current state and never triggers an I/O cascade. `get` bypasses
34
- # the read-through behavior (ADR 0062) and reads with fetch:false directly,
35
- # because read-through inside a hook would: (1) fire fetch events → hooks →
36
- # unbounded reentrancy; (2) spawn the orchestrator's threads/fork from
37
- # inside a hook callback; (3) probe the single-flight fetch lock its own
38
- # enclosing fetch may hold (deadlock); (4) inject network latency into
39
- # every hook read. With the merged Read::Get class, `fetch:false` (the
40
- # method default) guarantees no orchestrator is built.
31
+ # read — a pure-observation surface: nothing here ingests. Since ADR 0089
32
+ # `get` itself is a pure read (the read-through that once forced this
33
+ # surface to opt out is gone, so the old re-entrancy/deadlock guard is no
34
+ # longer needed); `list`/`deps`/`freshness` are reads too. A hook observes
35
+ # current state and never triggers an I/O cascade.
41
36
  def get(key) = pure_reader.call(key)
42
37
  def list(**) = @scope.list(**)
43
38
  def deps(key) = @scope.deps(key)
@@ -1,15 +1,15 @@
1
1
  # .textus/hooks/machine_intake.rb
2
2
  # Scaffolded by `textus init` — CUSTOMIZE FREELY, or delete the feeds.machines
3
3
  # entry from manifest.yaml if you don't want it.
4
- # Feeds a per-host SNAPSHOT into feeds.machines.<host> on `textus fetch` (never
5
- # on the per-turn boot/pulse path). It is NESTED so it grows to a fleet: the
4
+ # Feeds a per-host SNAPSHOT into feeds.machines.<host> on `textus drain`
5
+ # (never on the per-turn boot/pulse path). It is NESTED so it grows to a fleet: the
6
6
  # `local` leaf scans THIS host; add ssh hosts with the cookbook recipe
7
7
  # (docs/cookbook/environment-scan.md). tracked:false → gitignored. Keep this an
8
8
  # ALLOWLIST of versions and counts — NEVER secrets, raw `env`, or package lists.
9
9
  Textus.hook do |reg|
10
- reg.on(:resolve_intake, :machines) do |config:, args:, **|
10
+ reg.on(:resolve_handler, :machines) do |config:, args:, **|
11
11
  machine = args[:leaf_segments].first or
12
- raise "fetch a host leaf, e.g. `textus fetch feeds.machines.local`"
12
+ raise "machines intake needs a host leaf, e.g. the 'local' in feeds.machines.local"
13
13
  spec = (config["machines"] || {}).fetch(machine) { raise "unknown machine: #{machine}" }
14
14
  unless (spec["via"] || "local").to_s == "local"
15
15
  raise "machine #{machine}: only `via: local` is scaffolded — see " \
data/lib/textus/init.rb CHANGED
@@ -3,45 +3,44 @@ require "pathname"
3
3
 
4
4
  module Textus
5
5
  module Init
6
- ZONES = %w[knowledge notebook feeds proposals artifacts].freeze
6
+ ZONES = %w[knowledge notebook proposals artifacts].freeze
7
7
 
8
8
  DEFAULT_MANIFEST = <<~YAML
9
9
  version: textus/3
10
10
  roles:
11
11
  - { name: human, can: [author, propose] }
12
12
  - { name: agent, can: [propose, keep] }
13
- - { name: automation, can: [fetch, build] }
13
+ - { name: automation, can: [converge] }
14
14
  zones:
15
15
  - { name: knowledge, kind: canon, desc: "the maintained source of truth (identity.* lives here)" }
16
16
  - { name: notebook, kind: workspace, owner: agent, desc: "the agent's own durable working notes" }
17
- - { name: feeds, kind: quarantine, desc: "external inputs pulled in" }
18
17
  - { name: proposals, kind: queue, desc: "changes awaiting your accept" }
19
- - { name: artifacts, kind: derived, desc: "computed, shippable outputs" }
18
+ - { name: artifacts, kind: machine, desc: "machine-maintained: external inputs (artifacts.feeds.*) + computed outputs (artifacts.derived.*)" }
20
19
  entries:
21
20
  - { key: knowledge.identity, path: knowledge/identity.md, zone: knowledge, schema: null, owner: human:self, kind: leaf }
22
21
  - { key: knowledge.notes, path: knowledge/notes, zone: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
23
22
  - { key: notebook.notes, path: notebook/notes, zone: notebook, schema: null, owner: agent:self, nested: true, kind: nested }
24
23
  - { key: proposals.notes, path: proposals/notes, zone: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
25
- # A per-host snapshot, pulled by `textus fetch feeds.machines.local --as=automation`.
26
- # Nested so it grows to a fleet — add feeds.machines.<host> leaves over SSH
24
+ # A per-host snapshot, refreshed from its declared intake by `textus drain` (scheduled, or on demand).
25
+ # Nested so it grows to a fleet — add artifacts.feeds.machines.<host> leaves over SSH
27
26
  # (see docs/cookbook/environment-scan.md) without renaming. tracked:false →
28
27
  # gitignored (machine info can be sensitive/noisy) but still protocol-readable
29
- # via `textus get feeds.machines.local`. Delete to opt out. (ADR 0043)
30
- - key: feeds.machines
31
- path: feeds/machines
32
- zone: feeds
28
+ # via `textus get artifacts.feeds.machines.local`. Delete to opt out. (ADR 0043)
29
+ - key: artifacts.feeds.machines
30
+ path: artifacts/feeds/machines
31
+ zone: artifacts
33
32
  format: yaml
34
33
  nested: true
35
34
  tracked: false
36
- kind: intake
37
- intake:
35
+ kind: produced
36
+ source:
37
+ from: handler
38
38
  handler: machines
39
+ ttl: 1h # cadence on a long-running server
39
40
  config:
40
41
  machines:
41
42
  local: { via: local }
42
- rules:
43
- - match: feeds.machines.**
44
- lifecycle: { ttl: 1h, on_expire: warn } # meaningful on a long-running server
43
+ rules: []
45
44
  YAML
46
45
 
47
46
  HOOKS_README = <<~MD
@@ -56,71 +55,72 @@ module Textus
56
55
 
57
56
  ```ruby
58
57
  Textus.hook do |reg|
59
- reg.on(:resolve_intake, :my_source) do |config:, args:, **|
58
+ reg.on(:resolve_handler, :my_source) do |config:, args:, **|
60
59
  { _meta: { "last_fetched_at" => Time.now.utc.iso8601 }, body: "…" }
61
60
  end
62
61
 
63
62
  reg.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
64
63
  reg.on(:validate, :my_check) { |caps:, **| [] }
65
- reg.on(:entry_put, :my_listener, keys: ["knowledge.*"]) { |key:, envelope:, **| }
64
+ reg.on(:entry_written, :my_listener, keys: ["knowledge.*"]) { |key:, envelope:, **| }
66
65
 
67
66
  # Run a side-effect every time textus writes a file to your repo:
68
- reg.on(:file_published, :notify) do |key:, target:, **|
67
+ reg.on(:entry_published, :notify) do |key:, target:, **|
69
68
  warn "wrote \#{target} (from \#{key})"
70
69
  end
71
70
  end
72
71
  ```
73
72
 
74
- The intake handler above is paired with a manifest entry plus a
75
- top-level `rules:` block for lifecycle (ttl/on_expire live in
76
- rules, not in the entry):
73
+ The intake handler above is paired with a manifest entry whose
74
+ `source:` block declares the handler and its refresh cadence
75
+ (`ttl`). Age GC (drop/archive) lives in a top-level `retention:`
76
+ rule, not on the entry:
77
77
 
78
78
  ```yaml
79
79
  entries:
80
- - key: feeds.foo
81
- kind: intake
82
- path: feeds/foo.md
83
- zone: feeds
84
- intake:
80
+ - key: artifacts.feeds.foo
81
+ kind: produced
82
+ path: artifacts/feeds/foo.md
83
+ zone: artifacts
84
+ source:
85
+ from: handler
85
86
  handler: my_source
87
+ ttl: 10m # refresh cadence for this intake
86
88
 
87
89
  rules:
88
- - match: feeds.foo
89
- lifecycle:
90
- ttl: 10m
91
- on_expire: refresh # refresh | warn (intake); drop | archive (stored)
90
+ - match: artifacts.feeds.foo
91
+ retention:
92
+ ttl: 30d
93
+ action: archive # drop | archive (age GC of stored rows)
92
94
  ```
93
95
 
94
- Events: :resolve_intake, :transform_rows, :validate (rpc — return value used)
95
- :entry_put, :entry_deleted, :entry_fetched, :entry_renamed,
96
- :build_completed, :proposal_accepted, :proposal_rejected,
97
- :file_published, :store_loaded, :session_opened,
98
- :fetch_started, :fetch_failed, :fetch_backgrounded (pub-sub — return discarded)
96
+ Events: :resolve_handler, :transform_rows, :validate (rpc — return value used)
97
+ :entry_written, :entry_deleted, :entry_fetched, :entry_renamed,
98
+ :entry_produced, :produce_failed,
99
+ :proposal_accepted, :proposal_rejected,
100
+ :entry_published, :store_loaded, :session_opened,
101
+ :entry_fetch_started, :entry_fetch_failed (pub-sub — return discarded)
99
102
 
100
103
  See SPEC.md §5.10 for the full table.
101
104
  MD
102
105
 
103
106
  AGENT_ENTRIES = <<~YAML.gsub(/^/, " ")
104
107
  # --with-agent profile: project facts + runbooks feed the orientation
105
- # projection below, which `textus build` renders to CLAUDE.md/AGENTS.md.
108
+ # projection below, which `textus drain` renders to CLAUDE.md/AGENTS.md.
106
109
  - { key: knowledge.project, path: knowledge/project.md, zone: knowledge, schema: project, owner: human:self, kind: leaf }
107
110
  - { key: knowledge.runbooks, path: knowledge/runbooks, zone: knowledge, schema: runbook, owner: human:self, nested: true, kind: nested }
108
- - key: artifacts.orientation
109
- path: artifacts/orientation.md
111
+ - key: artifacts.derived.orientation
112
+ path: artifacts/derived/orientation.json
110
113
  zone: artifacts
111
- template: orientation.mustache
112
- inject_boot: true
113
114
  publish:
114
- to:
115
- - CLAUDE.md
116
- - AGENTS.md
117
- compute:
118
- kind: projection
115
+ - { to: CLAUDE.md, template: orientation.mustache, inject_boot: true }
116
+ - { to: AGENTS.md, template: orientation.mustache, inject_boot: true }
117
+ source:
118
+ from: project
119
119
  select:
120
120
  - knowledge.project
121
121
  - knowledge.runbooks
122
122
  transform: orientation_reducer
123
- kind: derived
123
+ kind: produced
124
124
  YAML
125
125
 
126
126
  def self.run(target_root, with_agent: false)
@@ -193,7 +193,7 @@ module Textus
193
193
  manifest = Textus::Manifest.load(target_root)
194
194
  root = Pathname.new(target_root)
195
195
  untracked = manifest.data.entries.reject(&:tracked?).map do |e|
196
- if e.nested? # a whole subtree of leaf files (feeds.machines.* → zones/feeds/machines/)
196
+ if e.nested? # a whole subtree of leaf files (artifacts.feeds.machines.* → zones/artifacts/feeds/machines/)
197
197
  "#{File.join("zones", e.path)}/"
198
198
  else
199
199
  Pathname.new(Textus::Key::Path.resolve(manifest.data, e)).relative_path_from(root).to_s
@@ -0,0 +1,62 @@
1
+ module Textus
2
+ module Jobs
3
+ # Wires the closed allow-list of convergence job types to the existing
4
+ # convergence code. Authority is read from the job's frozen `enqueued_by`
5
+ # and turned into the Call the handler runs under: produce self-elevates
6
+ # inside Produce::Engine regardless; destructive sweep runs AS the caller.
7
+ module Handlers
8
+ module_function
9
+
10
+ def registry
11
+ reg = Textus::Domain::Jobs::Registry.new
12
+ # produce is pure (self-elevates) — any caller may request a rematerialize.
13
+ reg.register("materialize", handler: method(:produce))
14
+ reg.register("re-pull", handler: method(:produce))
15
+ # sweep is destructive — gate ad-hoc enqueue to the automation authority.
16
+ reg.register("sweep", handler: method(:sweep), required_role: Textus::Role::AUTOMATION)
17
+ reg
18
+ end
19
+
20
+ # produce: render derived / re-pull intake for a single key. Engine
21
+ # self-elevates to the build actor internally; the passed call carries
22
+ # only correlation/dry_run plus the stamped role for audit. Engine#call
23
+ # isolates per-key produce errors into its result hash rather than raising,
24
+ # so surface them as :produce_failed events (the converge result hash used
25
+ # to carry them; the worker drops the return, so re-publish here).
26
+ def produce(job:, container:)
27
+ call = call_for(job)
28
+ result = Textus::Produce::Engine.converge(container: container, call: call, keys: [job.args["key"]])
29
+ return unless result.is_a?(Hash)
30
+
31
+ Array(result[:failed]).each do |failure|
32
+ container.events.publish(
33
+ :produce_failed,
34
+ ctx: Textus::Hooks::Context.for(container: container, call: call),
35
+ keys: [failure["key"]], error: failure["error"]
36
+ )
37
+ end
38
+ end
39
+
40
+ # sweep: compute retention rows for the scope, then apply destructively AS
41
+ # the job's role (no self-elevation).
42
+ def sweep(job:, container:)
43
+ call = call_for(job)
44
+ scope = job.args["scope"]
45
+ rows = Textus::Domain::Retention::Sweep.new(
46
+ manifest: container.manifest,
47
+ file_stat: Textus::Ports::Storage::FileStat.new,
48
+ clock: Textus::Ports::Clock.new,
49
+ ).call(prefix: scope_prefix(scope), zone: scope_zone(scope))
50
+ Textus::Maintenance::Retention::Apply.new(container: container, call: call).call(rows)
51
+ end
52
+
53
+ def call_for(job)
54
+ Textus::Call.build(role: job.enqueued_by || Textus::Role::AUTOMATION)
55
+ end
56
+
57
+ # A scope is `{ "prefix" => ..., "zone" => ... }` or nil (whole store).
58
+ def scope_prefix(scope) = scope.is_a?(Hash) ? scope["prefix"] : nil
59
+ def scope_zone(scope) = scope.is_a?(Hash) ? scope["zone"] : nil
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,36 @@
1
+ module Textus
2
+ module Jobs
3
+ # Time-based seeding for the daemon: at each tick, enqueue a re-pull job for
4
+ # every intake key past its source.ttl and a sweep job to GC entries past
5
+ # retention.ttl. Dedup means a job already queued from a prior tick is a
6
+ # no-op. Both are stamped automation (the daemon's own authority); the sweep
7
+ # handler runs retention as that role.
8
+ class Scheduler
9
+ def initialize(container:, queue:)
10
+ @container = container
11
+ @queue = queue
12
+ end
13
+
14
+ def run_once
15
+ stale_intake.each do |key|
16
+ @queue.enqueue(job("re-pull", { "key" => key }))
17
+ end
18
+ @queue.enqueue(job("sweep", { "scope" => { "prefix" => nil, "zone" => nil } }))
19
+ end
20
+
21
+ private
22
+
23
+ def stale_intake
24
+ Textus::Domain::Freshness::Evaluator.new(
25
+ manifest: @container.manifest,
26
+ file_stat: Textus::Ports::Storage::FileStat.new,
27
+ clock: Textus::Ports::Clock.new,
28
+ ).stale_intake_keys(prefix: nil, zone: nil)
29
+ end
30
+
31
+ def job(type, args)
32
+ Textus::Domain::Jobs::Job.new(type: type, args: args, enqueued_by: Textus::Role::AUTOMATION)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,57 @@
1
+ module Textus
2
+ module Jobs
3
+ # Enqueues the full convergence set for a scope: a produce job per derived /
4
+ # publish_tree / publish.to entry, a re-pull job per stale intake key, and a
5
+ # single sweep job for the scope. The scope logic mirrors
6
+ # the converge scope (Produce::Engine) so `drain` and `serve` converge identically.
7
+ # Produce jobs self-elevate (stamped automation); the sweep job carries the
8
+ # caller's role (destructive runs as caller).
9
+ class Seeder
10
+ def initialize(container:, queue:, call:)
11
+ @container = container
12
+ @queue = queue
13
+ @call = call
14
+ @manifest = container.manifest
15
+ end
16
+
17
+ def seed(prefix:, zone:)
18
+ file_stat = Textus::Ports::Storage::FileStat.new
19
+
20
+ producible_keys(prefix, zone).each do |key|
21
+ @queue.enqueue(job("materialize", { "key" => key }, Textus::Role::AUTOMATION))
22
+ end
23
+ stale_intake_keys(prefix, zone, file_stat).each do |key|
24
+ @queue.enqueue(job("re-pull", { "key" => key }, Textus::Role::AUTOMATION))
25
+ end
26
+ @queue.enqueue(job("sweep", { "scope" => { "prefix" => prefix, "zone" => zone } }, @call.role))
27
+ end
28
+
29
+ private
30
+
31
+ def job(type, args, role)
32
+ Textus::Domain::Jobs::Job.new(type: type, args: args, enqueued_by: role)
33
+ end
34
+
35
+ # Mirrors the converge scope (the publishable arm).
36
+ def producible_keys(prefix, zone)
37
+ @manifest.data.entries
38
+ .select { |e| e.derived? || !e.publish_tree.nil? || !e.publish_to.empty? }
39
+ .select { |e| in_scope?(e, prefix, zone) }
40
+ .map(&:key)
41
+ end
42
+
43
+ def stale_intake_keys(prefix, zone, file_stat)
44
+ Textus::Domain::Freshness::Evaluator.new(
45
+ manifest: @manifest, file_stat: file_stat, clock: Textus::Ports::Clock.new,
46
+ ).stale_intake_keys(prefix: prefix, zone: zone)
47
+ end
48
+
49
+ def in_scope?(entry, prefix, zone)
50
+ return false if zone && entry.zone != zone
51
+ return false if prefix && !entry.key.start_with?(prefix)
52
+
53
+ true
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,24 @@
1
+ module Textus
2
+ module Key
3
+ # Dotted-key scope matching, shared by all prefix-scoped sweeps
4
+ # (WS4 / ADR 0089-era cleanup). Canonicalised here so every consumer
5
+ # uses a consistent dotted-boundary check with proper Nested ancestor
6
+ # handling. ADR 0093: Produce is the sole engine calling this.
7
+ module Matching
8
+ module_function
9
+
10
+ # Is `key` within the `prefix` scope?
11
+ # - exact match, or a dotted descendant (the `prefix.` boundary, so
12
+ # prefix "art" does NOT match key "artifacts"), and
13
+ # - for a nested entry, also when `prefix` descends INTO it — the nested
14
+ # parent owns the leaf the prefix names (e.g. prefix
15
+ # "feeds.machines.host1" still selects the nested entry
16
+ # "feeds.machines").
17
+ def matches_prefix?(key, prefix, nested: false)
18
+ return true if key == prefix || key.start_with?("#{prefix}.")
19
+
20
+ nested && prefix.start_with?("#{key}.")
21
+ end
22
+ end
23
+ end
24
+ end
data/lib/textus/layout.rb CHANGED
@@ -25,6 +25,14 @@ module Textus
25
25
  File.join(run(root), "build.lock")
26
26
  end
27
27
 
28
+ def self.queue(root)
29
+ File.join(run(root), "queue")
30
+ end
31
+
32
+ def self.queue_state(root, state)
33
+ File.join(queue(root), state.to_s)
34
+ end
35
+
28
36
  def self.audit_dir(root)
29
37
  File.join(run(root), "audit")
30
38
  end
@@ -0,0 +1,42 @@
1
+ module Textus
2
+ module Maintenance
3
+ # Converge-and-exit: seed the full convergence set for the scope, run the
4
+ # worker until the queue is empty, return a health summary. Exits not-ok if
5
+ # any job dead-lettered. This is the converge entry point and what CI
6
+ # runs. Single-pass (serial) on purpose: each produce job self-locks via
7
+ # Produce::Engine.converge, so running them in turn keeps the build lock
8
+ # uncontended; a concurrent pool would make all-but-one produce job hit
9
+ # BuildInProgress and skip.
10
+ class Drain
11
+ extend Textus::Contract::DSL
12
+
13
+ verb :drain
14
+ summary "Converge everything now: seed produce + retention jobs and drain the queue to empty."
15
+ surfaces :cli, :mcp
16
+ cli "drain"
17
+ arg :prefix, String, description: "restrict convergence to keys under this dotted prefix"
18
+ arg :zone, String, description: "restrict convergence to entries in this zone"
19
+
20
+ def initialize(container:, call:)
21
+ @container = container
22
+ @call = call
23
+ end
24
+
25
+ def call(prefix: nil, zone: nil)
26
+ queue = Textus::Ports::Queue.new(root: @container.root)
27
+ Textus::Jobs::Seeder.new(container: @container, queue: queue, call: @call).seed(prefix: prefix, zone: zone)
28
+
29
+ summary = Worker.for(container: @container, queue: queue).drain
30
+ health = Read::Doctor.new(container: @container, call: @call).call
31
+
32
+ {
33
+ "protocol" => Textus::PROTOCOL,
34
+ "ok" => summary.failed.zero?,
35
+ "completed" => summary.completed,
36
+ "failed" => summary.failed,
37
+ "health" => health,
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,52 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Maintenance
5
+ module Retention
6
+ # The destructive half of convergence: apply retention rows (drop/archive).
7
+ # Lifted verbatim from the legacy reconcile apply/archive_leaf so drain/serve and
8
+ # the `sweep` job handler share one path. Runs as the caller's role — never
9
+ # self-elevates (ADR 0079/0093: destructiveness decides authority).
10
+ class Apply
11
+ def initialize(container:, call:)
12
+ @container = container
13
+ @call = call
14
+ end
15
+
16
+ def call(rows)
17
+ out = { dropped: [], archived: [], failed: [] }
18
+ delete = Write::KeyDelete.new(container: @container, call: @call)
19
+ rows.each do |row|
20
+ key = row["key"]
21
+ begin
22
+ case row["action"]
23
+ when "drop"
24
+ delete.call(key)
25
+ out[:dropped] << key
26
+ when "archive"
27
+ archive_leaf(row)
28
+ delete.call(key)
29
+ out[:archived] << key
30
+ end
31
+ rescue Textus::Error => e
32
+ out[:failed] << { "key" => key, "error" => e.message }
33
+ end
34
+ end
35
+ out
36
+ end
37
+
38
+ private
39
+
40
+ # Copy the leaf into <store>/archive/<relative-path> before deletion.
41
+ def archive_leaf(row)
42
+ src = row["path"]
43
+ root = @container.root.to_s
44
+ rel = src.delete_prefix("#{root}/")
45
+ dest = File.join(root, "archive", rel)
46
+ FileUtils.mkdir_p(File.dirname(dest))
47
+ FileUtils.cp(src, dest)
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,30 @@
1
+ module Textus
2
+ module Maintenance
3
+ # The convergence daemon loop: seed scheduled work (TTL re-pull + sweep),
4
+ # reclaim crashed leases, drain the queue, sleep, repeat. `tick` is one
5
+ # iteration (unit-testable); `run` loops forever. Drains serially for the
6
+ # same reason as Drain — each produce job self-locks, so running them in turn
7
+ # keeps the build lock uncontended.
8
+ class Serve
9
+ def initialize(container:, call:)
10
+ @container = container
11
+ @call = call
12
+ @queue = Textus::Ports::Queue.new(root: container.root)
13
+ end
14
+
15
+ def tick
16
+ Textus::Jobs::Scheduler.new(container: @container, queue: @queue).run_once
17
+ @queue.reclaim(now: Textus::Ports::Clock.new.now)
18
+ Worker.for(container: @container, queue: @queue).drain
19
+ end
20
+
21
+ def run(poll: nil)
22
+ interval = poll || @container.manifest.data.worker_config[:poll]
23
+ loop do
24
+ tick
25
+ sleep(interval)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end