textus 0.51.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +19 -19
  4. data/SPEC.md +41 -39
  5. data/docs/architecture/README.md +9 -9
  6. data/docs/reference/conventions.md +8 -8
  7. data/lib/textus/boot.rb +7 -5
  8. data/lib/textus/cli/runner.rb +2 -2
  9. data/lib/textus/cli/verb/put.rb +1 -1
  10. data/lib/textus/cli/verb/serve.rb +19 -0
  11. data/lib/textus/dispatcher.rb +3 -1
  12. data/lib/textus/doctor/check/generator_drift.rb +1 -1
  13. data/lib/textus/doctor/check/sentinels.rb +2 -2
  14. data/lib/textus/domain/freshness/evaluator.rb +2 -2
  15. data/lib/textus/domain/jobs/job.rb +58 -0
  16. data/lib/textus/domain/jobs/registry.rb +37 -0
  17. data/lib/textus/domain/policy/base_guards.rb +1 -1
  18. data/lib/textus/domain/policy/retention.rb +1 -1
  19. data/lib/textus/domain/policy/source.rb +4 -10
  20. data/lib/textus/errors.rb +2 -2
  21. data/lib/textus/hooks/catalog.rb +0 -1
  22. data/lib/textus/init/templates/machine_intake.rb +1 -1
  23. data/lib/textus/init.rb +4 -4
  24. data/lib/textus/jobs/handlers.rb +62 -0
  25. data/lib/textus/jobs/scheduler.rb +36 -0
  26. data/lib/textus/jobs/seeder.rb +57 -0
  27. data/lib/textus/layout.rb +8 -0
  28. data/lib/textus/maintenance/drain.rb +42 -0
  29. data/lib/textus/maintenance/retention/apply.rb +52 -0
  30. data/lib/textus/maintenance/serve.rb +30 -0
  31. data/lib/textus/maintenance/worker.rb +74 -0
  32. data/lib/textus/manifest/capabilities.rb +1 -1
  33. data/lib/textus/manifest/data.rb +16 -1
  34. data/lib/textus/manifest/schema/keys.rb +1 -1
  35. data/lib/textus/manifest/schema/validator.rb +3 -3
  36. data/lib/textus/manifest/schema/vocabulary.rb +2 -2
  37. data/lib/textus/mcp/server.rb +1 -1
  38. data/lib/textus/ports/build_lock.rb +1 -1
  39. data/lib/textus/ports/produce_on_write_subscriber.rb +28 -24
  40. data/lib/textus/ports/queue.rb +130 -0
  41. data/lib/textus/produce/acquire/handler.rb +1 -1
  42. data/lib/textus/produce/acquire/intake.rb +3 -3
  43. data/lib/textus/produce/engine.rb +10 -58
  44. data/lib/textus/produce/events.rb +1 -1
  45. data/lib/textus/read/freshness.rb +2 -2
  46. data/lib/textus/read/get.rb +3 -3
  47. data/lib/textus/read/jobs.rb +31 -0
  48. data/lib/textus/role.rb +1 -1
  49. data/lib/textus/version.rb +1 -1
  50. data/lib/textus/write/enqueue.rb +50 -0
  51. metadata +14 -2
  52. data/lib/textus/maintenance/reconcile.rb +0 -160
@@ -1,7 +1,7 @@
1
1
  module Textus
2
2
  module Produce
3
3
  # Single home for the fetch lifecycle event vocabulary (ADR 0048 D5).
4
- # Produce::Acquire::Intake (the ingest executor driven by reconcile + hook) emits through
4
+ # Produce::Acquire::Intake (the ingest executor driven by converge + hook) emits through
5
5
  # this seam so the event names and payload shapes live in one place with one
6
6
  # derived hook context.
7
7
  class Events
@@ -10,7 +10,7 @@ module Textus
10
10
  # - entries matched by a `retention:` rule: `retention.ttl_seconds` is the
11
11
  # GC age; basis = file mtime. Past ttl ⇒ :expired (:action = drop/archive).
12
12
  # Intake cadence wins when both apply (freshness is content currency; GC dueness
13
- # shows via `reconcile --dry-run`).
13
+ # shows via `drain --dry-run`).
14
14
  # Status is one of :fresh, :expired, or :no_policy; the row also carries
15
15
  # :action (:refresh for intake, :drop/:archive for retention).
16
16
  #
@@ -78,7 +78,7 @@ module Textus
78
78
  # ADR 0093: staleness comes from the intake re-pull cadence (source.ttl)
79
79
  # or a retention GC rule (retention.ttl). Intake cadence wins when an entry
80
80
  # has both (freshness is about content currency; GC dueness still shows via
81
- # `reconcile --dry-run`). Returns [ttl_seconds, action] or [nil, nil].
81
+ # `drain --dry-run`). Returns [ttl_seconds, action] or [nil, nil].
82
82
  def policy_for(mentry)
83
83
  if mentry.intake?
84
84
  ttl = mentry.source.ttl_seconds
@@ -2,16 +2,16 @@ module Textus
2
2
  module Read
3
3
  # The one read path — a pure read (ADR 0089, 0093): the on-disk envelope
4
4
  # annotated with a freshness annotation. It NEVER mutates and NEVER ingests.
5
- # Quarantine freshness is system-pushed via `reconcile` (scheduled sweep) and
5
+ # Quarantine freshness is system-pushed via `drain` (scheduled sweep) and
6
6
  # `hook run` (event push). Lifecycle is removed from the get path (ADR 0093):
7
7
  # intake cadence lives in `source.ttl`; GC lives in `retention:` rules; both
8
- # are evaluated exclusively by the `reconcile` sweep, not by a read.
8
+ # are evaluated exclusively by the `drain` sweep, not by a read.
9
9
  class Get
10
10
  extend Textus::Contract::DSL
11
11
 
12
12
  verb :get
13
13
  summary "Read one entry — a pure on-disk read annotated with a freshness " \
14
- "verdict; never ingests (quarantine freshness is reconcile + hook " \
14
+ "verdict; never ingests (quarantine freshness is drain + hook " \
15
15
  "only, ADR 0089). Returns the envelope (uid, etag, _meta, body, " \
16
16
  "freshness)."
17
17
  surfaces :cli, :mcp
@@ -0,0 +1,31 @@
1
+ module Textus
2
+ module Read
3
+ # Inspect and operate the job queue: list ids by state, retry a dead-lettered
4
+ # job, or purge a state. The agent's window into deferred convergence work.
5
+ class Jobs
6
+ extend Textus::Contract::DSL
7
+
8
+ verb :jobs
9
+ summary "List queued jobs by state; retry a dead-lettered job or purge a state."
10
+ surfaces :cli, :mcp
11
+ cli "jobs"
12
+ arg :state, String, default: "ready", description: "ready|leased|done|failed"
13
+ arg :action, String, default: nil, description: "retry|purge (optional)"
14
+ arg :job_id, String, default: nil, description: "job id (required for action=retry)"
15
+
16
+ def initialize(container:, call:)
17
+ @container = container
18
+ @call = call
19
+ end
20
+
21
+ def call(state: "ready", action: nil, job_id: nil)
22
+ queue = Textus::Ports::Queue.new(root: @container.root)
23
+ case action
24
+ when "retry" then queue.retry_failed(job_id)
25
+ when "purge" then queue.purge(state)
26
+ end
27
+ { "protocol" => Textus::PROTOCOL, "ok" => true, "state" => state, "jobs" => queue.list(state) }
28
+ end
29
+ end
30
+ end
31
+ end
data/lib/textus/role.rb CHANGED
@@ -1,7 +1,7 @@
1
1
  module Textus
2
2
  module Role
3
3
  # The three role archetypes, each string sourced exactly once: human curates
4
- # canon, agent proposes, automation reconciles the machine-maintained lanes
4
+ # canon, agent proposes, automation converges the machine-maintained lanes
5
5
  # (refresh + materialize) (explanation/concepts.md).
6
6
  # Reference these constants instead of bare literals (ADR 0044).
7
7
  HUMAN = "human".freeze
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.51.0"
2
+ VERSION = "0.52.0"
3
3
  PROTOCOL = "textus/3"
4
4
  end
@@ -0,0 +1,50 @@
1
+ module Textus
2
+ module Write
3
+ # Push a job of a REGISTERED type onto the convergence queue, to be run by
4
+ # drain/serve. The closed allow-list (Jobs::Handlers.registry) is the safety
5
+ # boundary: an unregistered type is refused, so the general runner can never
6
+ # execute arbitrary code. Authority is checked here (the caller must hold the
7
+ # type's required_role, if any) and frozen onto the job's `enqueued_by` — the
8
+ # worker runs it as exactly this role, no escalation via the queue.
9
+ class Enqueue
10
+ extend Textus::Contract::DSL
11
+
12
+ verb :enqueue
13
+ summary "Push a registered job type onto the convergence queue, to be run by drain/serve."
14
+ surfaces :cli, :mcp
15
+ cli "enqueue"
16
+ arg :type, String, required: true, positional: true, description: "registered job type (e.g. materialize, re-pull, sweep)"
17
+ arg :args, Hash, positional: true, default: {}, description: "type-specific arguments (e.g. { key: ... } or { scope: ... })"
18
+
19
+ def initialize(container:, call:)
20
+ @container = container
21
+ @call = call
22
+ end
23
+
24
+ def call(type, args = {})
25
+ entry = Textus::Jobs::Handlers.registry.lookup(type) # raises UsageError for unregistered types
26
+ authorize!(entry)
27
+
28
+ job = Textus::Domain::Jobs::Job.new(
29
+ type: type, args: args, enqueued_by: @call.role, max_attempts: entry.max_attempts,
30
+ )
31
+ Textus::Ports::Queue.new(root: @container.root).enqueue(job)
32
+ { "protocol" => Textus::PROTOCOL, "ok" => true, "id" => job.id }
33
+ end
34
+
35
+ private
36
+
37
+ def authorize!(entry)
38
+ required = entry.required_role
39
+ return if required.nil? || @call.role == required
40
+
41
+ raise Textus::Error.new(
42
+ "forbidden",
43
+ "role '#{@call.role}' is not authorized to enqueue this job type (requires '#{required}')",
44
+ details: { "role" => @call.role, "required_role" => required },
45
+ exit_code: 77,
46
+ )
47
+ end
48
+ end
49
+ end
50
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: textus
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.51.0
4
+ version: 0.52.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Patrick
@@ -132,6 +132,7 @@ files:
132
132
  - lib/textus/cli/verb/schema_diff.rb
133
133
  - lib/textus/cli/verb/schema_init.rb
134
134
  - lib/textus/cli/verb/schema_migrate.rb
135
+ - lib/textus/cli/verb/serve.rb
135
136
  - lib/textus/container.rb
136
137
  - lib/textus/contract.rb
137
138
  - lib/textus/contract/around.rb
@@ -167,6 +168,8 @@ files:
167
168
  - lib/textus/domain/freshness.rb
168
169
  - lib/textus/domain/freshness/evaluator.rb
169
170
  - lib/textus/domain/freshness/verdict.rb
171
+ - lib/textus/domain/jobs/job.rb
172
+ - lib/textus/domain/jobs/registry.rb
170
173
  - lib/textus/domain/permission.rb
171
174
  - lib/textus/domain/policy/base_guards.rb
172
175
  - lib/textus/domain/policy/evaluation.rb
@@ -210,16 +213,22 @@ files:
210
213
  - lib/textus/init.rb
211
214
  - lib/textus/init/templates/machine_intake.rb
212
215
  - lib/textus/init/templates/orientation_reducer.rb
216
+ - lib/textus/jobs/handlers.rb
217
+ - lib/textus/jobs/scheduler.rb
218
+ - lib/textus/jobs/seeder.rb
213
219
  - lib/textus/key/distance.rb
214
220
  - lib/textus/key/grammar.rb
215
221
  - lib/textus/key/matching.rb
216
222
  - lib/textus/key/path.rb
217
223
  - lib/textus/layout.rb
218
224
  - lib/textus/maintenance.rb
225
+ - lib/textus/maintenance/drain.rb
219
226
  - lib/textus/maintenance/key_delete_prefix.rb
220
227
  - lib/textus/maintenance/key_mv_prefix.rb
221
- - lib/textus/maintenance/reconcile.rb
228
+ - lib/textus/maintenance/retention/apply.rb
222
229
  - lib/textus/maintenance/rule_lint.rb
230
+ - lib/textus/maintenance/serve.rb
231
+ - lib/textus/maintenance/worker.rb
223
232
  - lib/textus/maintenance/zone_mv.rb
224
233
  - lib/textus/manifest.rb
225
234
  - lib/textus/manifest/capabilities.rb
@@ -263,6 +272,7 @@ files:
263
272
  - lib/textus/ports/clock.rb
264
273
  - lib/textus/ports/produce_on_write_subscriber.rb
265
274
  - lib/textus/ports/publisher.rb
275
+ - lib/textus/ports/queue.rb
266
276
  - lib/textus/ports/sentinel_store.rb
267
277
  - lib/textus/ports/storage/file_stat.rb
268
278
  - lib/textus/ports/storage/file_store.rb
@@ -285,6 +295,7 @@ files:
285
295
  - lib/textus/read/doctor.rb
286
296
  - lib/textus/read/freshness.rb
287
297
  - lib/textus/read/get.rb
298
+ - lib/textus/read/jobs.rb
288
299
  - lib/textus/read/list.rb
289
300
  - lib/textus/read/published.rb
290
301
  - lib/textus/read/pulse.rb
@@ -306,6 +317,7 @@ files:
306
317
  - lib/textus/uid.rb
307
318
  - lib/textus/version.rb
308
319
  - lib/textus/write/accept.rb
320
+ - lib/textus/write/enqueue.rb
309
321
  - lib/textus/write/key_delete.rb
310
322
  - lib/textus/write/key_mv.rb
311
323
  - lib/textus/write/propose.rb
@@ -1,160 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Maintenance
5
- # Two-phase convergence pass (ADR 0093). Replaces the old Lifecycle-reporter
6
- # sweep.
7
- #
8
- # Phase 1 — Produce (non-destructive): re-render ALL derived entries (cheap,
9
- # idempotent) plus every intake entry past its source.ttl (stale-only, so
10
- # external sources are not hammered). Driven by Produce::Engine.
11
- #
12
- # Phase 2 — Retention sweep (destructive): drop or archive entries past their
13
- # retention ttl. Driven by Domain::Retention::Sweep. The old refresh/warn
14
- # actions are gone — intake re-pull is now Produce's responsibility.
15
- class Reconcile
16
- extend Textus::Contract::DSL
17
-
18
- verb :reconcile
19
- summary "Run the convergence pass: produce derived + stale intake, then drop/archive aged entries; report health."
20
- surfaces :cli, :mcp
21
- cli "reconcile"
22
- arg :prefix, String, description: "restrict the sweep to keys under this dotted prefix"
23
- arg :zone, String, description: "restrict the sweep to entries in this zone"
24
- arg :dry_run, :boolean, default: false,
25
- description: "when true, report what the pass WOULD do without applying; " \
26
- "defaults to false, so omitting it produces + drops/archives immediately"
27
-
28
- def initialize(container:, call:)
29
- @container = container
30
- @call = call
31
- end
32
-
33
- def call(prefix: nil, zone: nil, dry_run: false)
34
- file_stat = Textus::Ports::Storage::FileStat.new
35
- retention_rows = Textus::Domain::Retention::Sweep.new(
36
- manifest: @container.manifest, file_stat: file_stat, clock: Textus::Ports::Clock.new,
37
- ).call(prefix: prefix, zone: zone)
38
-
39
- produce_keys = produce_scope(prefix, zone, file_stat)
40
- health = Read::Doctor.new(container: @container, call: @call).call
41
- return dry_run_result(produce_keys, retention_rows, health) if dry_run
42
-
43
- # reconcile is the authoritative "make everything current now" pass, so
44
- # it subsumes any in-flight reactive produce: drain pending async
45
- # produce-on-write threads first, both to fold their work in and to free
46
- # the shared maintenance lock (BuildLock is non-blocking — a thread still
47
- # holding it would make the acquire below raise BuildInProgress). ADR 0093.
48
- Textus::Produce::Engine::AsyncRunner.drain
49
-
50
- Textus::Ports::BuildLock.with(root: @container.root) do
51
- produced = Textus::Produce::Engine.new(container: @container, call: @call).call(keys: produce_keys)
52
- swept = apply(retention_rows)
53
- publish_failed(swept[:failed]) unless swept[:failed].empty?
54
- apply_result(produced, swept, health)
55
- end
56
- end
57
-
58
- private
59
-
60
- # The full produce scope (ADR 0093): every derived entry (always
61
- # re-render — cheap, idempotent), every entry that mirrors a publish_tree
62
- # (the nested-subtree publishers, ADR 0047 — mirrored each pass so a
63
- # removed source leaf is swept from the published tree), every authored
64
- # leaf with a `publish.to` target (the single-file canon publishers —
65
- # docs/README.md, the architecture index, the root README; ADR 0103 —
66
- # converged each pass so a stale published copy is rewritten and the
67
- # `reconcile`-is-a-no-op check guards them), plus every intake entry past
68
- # its source.ttl (re-pull only when due, so external sources aren't
69
- # hammered). Ttl-less intake entries (:no_policy) are skipped — they have
70
- # no freshness contract and are never auto-re-pulled (ADR 0099). All are
71
- # idempotent: publish writes only when the target's content changed.
72
- def produce_scope(prefix, zone, file_stat)
73
- publishable = @container.manifest.data.entries
74
- .select { |e| e.derived? || !e.publish_tree.nil? || !e.publish_to.empty? }
75
- .select { |e| in_scope?(e, prefix, zone) }.map(&:key)
76
- stale_intake = Textus::Domain::Freshness::Evaluator.new(
77
- manifest: @container.manifest, file_stat: file_stat, clock: Textus::Ports::Clock.new,
78
- ).stale_intake_keys(prefix: prefix, zone: zone)
79
- (publishable + stale_intake).uniq
80
- end
81
-
82
- def in_scope?(entry, prefix, zone)
83
- return false if zone && entry.zone != zone
84
- return false if prefix && !entry.key.start_with?(prefix)
85
-
86
- true
87
- end
88
-
89
- def dry_run_result(produce_keys, rows, health)
90
- {
91
- "protocol" => Textus::PROTOCOL, "ok" => true, "dry_run" => true,
92
- "would_produce" => produce_keys,
93
- "would_drop" => action_keys(rows, "drop"),
94
- "would_archive" => action_keys(rows, "archive"),
95
- "health" => health
96
- }
97
- end
98
-
99
- def apply_result(produced, swept, health)
100
- {
101
- "protocol" => Textus::PROTOCOL,
102
- "ok" => produced[:failed].empty? && swept[:failed].empty?,
103
- "dry_run" => false,
104
- "produced" => produced[:produced],
105
- "produce_failed" => produced[:failed],
106
- "dropped" => swept[:dropped], "archived" => swept[:archived],
107
- "failed" => swept[:failed],
108
- "health" => health
109
- }
110
- end
111
-
112
- def action_keys(rows, action)
113
- rows.select { |r| r["action"] == action }.map { |r| r["key"] }
114
- end
115
-
116
- def publish_failed(failed)
117
- @container.events.publish(
118
- :reconcile_failed,
119
- ctx: Textus::Hooks::Context.for(container: @container, call: @call),
120
- failed: failed,
121
- )
122
- end
123
-
124
- # Phase 2: destructive retention only (drop/archive). No refresh — intake
125
- # re-pull is Produce's job (Phase 1). ADR 0093.
126
- def apply(rows)
127
- out = { dropped: [], archived: [], failed: [] }
128
- delete = Write::KeyDelete.new(container: @container, call: @call)
129
- rows.each do |row|
130
- key = row["key"]
131
- begin
132
- case row["action"]
133
- when "drop"
134
- delete.call(key)
135
- out[:dropped] << key
136
- when "archive"
137
- archive_leaf(row)
138
- delete.call(key)
139
- out[:archived] << key
140
- end
141
- rescue Textus::Error => e
142
- out[:failed] << { "key" => key, "error" => e.message }
143
- end
144
- end
145
- out
146
- end
147
-
148
- # Copy the leaf into <store>/archive/<relative-path> before deletion.
149
- # (Lifted from the retired RetentionSweep#archive_leaf.)
150
- def archive_leaf(row)
151
- src = row["path"]
152
- root = @container.root.to_s
153
- rel = src.delete_prefix("#{root}/")
154
- dest = File.join(root, "archive", rel)
155
- FileUtils.mkdir_p(File.dirname(dest))
156
- FileUtils.cp(src, dest)
157
- end
158
- end
159
- end
160
- end