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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +38 -0
- data/README.md +41 -43
- data/SPEC.md +176 -176
- data/docs/architecture/README.md +46 -42
- data/docs/reference/conventions.md +31 -26
- data/lib/textus/boot.rb +15 -17
- data/lib/textus/call.rb +1 -1
- data/lib/textus/cli/runner.rb +15 -10
- data/lib/textus/cli/verb/get.rb +1 -3
- data/lib/textus/cli/verb/hook_run.rb +1 -1
- data/lib/textus/cli/verb/put.rb +4 -20
- data/lib/textus/cli/verb/serve.rb +19 -0
- data/lib/textus/cli.rb +1 -3
- data/lib/textus/dispatcher.rb +3 -3
- data/lib/textus/doctor/check/generator_drift.rb +4 -3
- data/lib/textus/doctor/check/handler_allowlist.rb +1 -1
- data/lib/textus/doctor/check/intake_registration.rb +5 -5
- data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +13 -11
- data/lib/textus/doctor.rb +0 -2
- data/lib/textus/domain/freshness/evaluator.rb +150 -14
- data/lib/textus/domain/freshness/verdict.rb +28 -6
- data/lib/textus/domain/freshness.rb +4 -33
- data/lib/textus/domain/jobs/job.rb +58 -0
- data/lib/textus/domain/jobs/registry.rb +37 -0
- data/lib/textus/domain/policy/base_guards.rb +1 -1
- data/lib/textus/domain/policy/predicates/fresh_within.rb +1 -1
- data/lib/textus/domain/policy/publish_target.rb +34 -0
- data/lib/textus/domain/policy/retention.rb +29 -0
- data/lib/textus/domain/policy/source.rb +73 -0
- data/lib/textus/domain/retention/sweep.rb +57 -0
- data/lib/textus/domain/retention.rb +11 -0
- data/lib/textus/errors.rb +4 -4
- data/lib/textus/hooks/builtin.rb +5 -5
- data/lib/textus/hooks/catalog.rb +7 -7
- data/lib/textus/hooks/context.rb +5 -10
- data/lib/textus/init/templates/machine_intake.rb +4 -4
- data/lib/textus/init.rb +47 -47
- data/lib/textus/jobs/handlers.rb +62 -0
- data/lib/textus/jobs/scheduler.rb +36 -0
- data/lib/textus/jobs/seeder.rb +57 -0
- data/lib/textus/key/matching.rb +24 -0
- data/lib/textus/layout.rb +8 -0
- data/lib/textus/maintenance/drain.rb +42 -0
- data/lib/textus/maintenance/retention/apply.rb +52 -0
- data/lib/textus/maintenance/serve.rb +30 -0
- data/lib/textus/maintenance/worker.rb +74 -0
- data/lib/textus/manifest/capabilities.rb +1 -1
- data/lib/textus/manifest/data.rb +18 -3
- data/lib/textus/manifest/entry/base.rb +28 -9
- data/lib/textus/manifest/entry/nested.rb +3 -4
- data/lib/textus/manifest/entry/parser.rb +25 -21
- data/lib/textus/manifest/entry/produced.rb +56 -0
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +7 -6
- data/lib/textus/manifest/entry/publish/to_paths.rb +62 -11
- data/lib/textus/manifest/entry/validators/format_matrix.rb +3 -11
- data/lib/textus/manifest/entry/validators/publish.rb +3 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/manifest/policy.rb +16 -4
- data/lib/textus/manifest/resolver.rb +10 -4
- data/lib/textus/manifest/rules.rb +37 -36
- data/lib/textus/manifest/schema/keys.rb +98 -0
- data/lib/textus/manifest/schema/validator.rb +324 -0
- data/lib/textus/manifest/schema/vocabulary.rb +24 -0
- data/lib/textus/manifest/schema.rb +27 -247
- data/lib/textus/manifest.rb +5 -3
- data/lib/textus/mcp/server.rb +1 -1
- data/lib/textus/ports/audit_log.rb +6 -0
- data/lib/textus/ports/build_lock.rb +6 -0
- data/lib/textus/ports/clock.rb +4 -3
- data/lib/textus/ports/produce_on_write_subscriber.rb +73 -0
- data/lib/textus/ports/publisher.rb +11 -7
- data/lib/textus/ports/queue.rb +130 -0
- data/lib/textus/produce/acquire/handler.rb +29 -0
- data/lib/textus/produce/acquire/intake.rb +130 -0
- data/lib/textus/produce/acquire/projection.rb +127 -0
- data/lib/textus/produce/acquire/serializer/json.rb +31 -0
- data/lib/textus/produce/acquire/serializer/text.rb +16 -0
- data/lib/textus/produce/acquire/serializer/yaml.rb +31 -0
- data/lib/textus/produce/acquire/serializer.rb +17 -0
- data/lib/textus/produce/engine.rb +95 -0
- data/lib/textus/produce/events.rb +36 -0
- data/lib/textus/produce/render.rb +23 -0
- data/lib/textus/projection.rb +17 -6
- data/lib/textus/read/deps.rb +3 -3
- data/lib/textus/read/freshness.rb +61 -31
- data/lib/textus/read/get.rb +20 -102
- data/lib/textus/read/jobs.rb +31 -0
- data/lib/textus/read/rdeps.rb +3 -3
- data/lib/textus/read/rule_explain.rb +41 -23
- data/lib/textus/read/rule_list.rb +25 -8
- data/lib/textus/read/validate_all.rb +14 -0
- data/lib/textus/role.rb +2 -1
- data/lib/textus/schemas.rb +8 -0
- data/lib/textus/store.rb +1 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/enqueue.rb +50 -0
- data/lib/textus/write/put.rb +1 -1
- metadata +35 -30
- data/lib/textus/builder/pipeline.rb +0 -88
- data/lib/textus/builder/renderer/json.rb +0 -45
- data/lib/textus/builder/renderer/markdown.rb +0 -24
- data/lib/textus/builder/renderer/text.rb +0 -14
- data/lib/textus/builder/renderer/yaml.rb +0 -45
- data/lib/textus/builder/renderer.rb +0 -17
- data/lib/textus/cli/verb/boot.rb +0 -14
- data/lib/textus/cli/verb/build.rb +0 -15
- data/lib/textus/doctor/check/fetch_locks.rb +0 -49
- data/lib/textus/doctor/check/lifecycle_action_invalid.rb +0 -39
- data/lib/textus/domain/freshness/policy.rb +0 -18
- data/lib/textus/domain/lifecycle.rb +0 -83
- data/lib/textus/domain/outcome.rb +0 -10
- data/lib/textus/domain/policy/lifecycle.rb +0 -35
- data/lib/textus/domain/staleness/generator_check.rb +0 -109
- data/lib/textus/domain/staleness.rb +0 -29
- data/lib/textus/maintenance/tend.rb +0 -110
- data/lib/textus/manifest/entry/derived.rb +0 -67
- data/lib/textus/manifest/entry/intake.rb +0 -31
- data/lib/textus/manifest/entry/validators/inject_boot.rb +0 -21
- data/lib/textus/mcp/tools.rb +0 -14
- data/lib/textus/ports/fetch/detached.rb +0 -52
- data/lib/textus/ports/fetch/lock.rb +0 -44
- data/lib/textus/write/build.rb +0 -90
- data/lib/textus/write/fetch_events.rb +0 -42
- data/lib/textus/write/fetch_orchestrator.rb +0 -101
- data/lib/textus/write/fetch_worker.rb +0 -127
- data/lib/textus/write/intake_fetch.rb +0 -25
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
data/lib/textus/hooks/builtin.rb
CHANGED
|
@@ -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(:
|
|
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(:
|
|
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(:
|
|
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(:
|
|
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(:
|
|
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)
|
data/lib/textus/hooks/catalog.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
+
entry_published: %i[ctx key envelope source target],
|
|
22
23
|
store_loaded: %i[ctx],
|
|
23
24
|
session_opened: %i[ctx role cursor],
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
31
|
+
resolve_handler: %i[caps config args],
|
|
32
32
|
transform_rows: %i[caps rows config],
|
|
33
33
|
validate: %i[caps],
|
|
34
34
|
}.freeze
|
data/lib/textus/hooks/context.rb
CHANGED
|
@@ -28,16 +28,11 @@ module Textus
|
|
|
28
28
|
@scope
|
|
29
29
|
end
|
|
30
30
|
|
|
31
|
-
# read — a
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
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
|
|
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(:
|
|
10
|
+
reg.on(:resolve_handler, :machines) do |config:, args:, **|
|
|
11
11
|
machine = args[:leaf_segments].first or
|
|
12
|
-
raise "
|
|
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
|
|
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: [
|
|
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:
|
|
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,
|
|
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:
|
|
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:
|
|
37
|
-
|
|
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(:
|
|
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(:
|
|
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(:
|
|
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
|
|
75
|
-
|
|
76
|
-
|
|
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:
|
|
82
|
-
path: feeds/foo.md
|
|
83
|
-
zone:
|
|
84
|
-
|
|
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
|
-
|
|
90
|
-
ttl:
|
|
91
|
-
|
|
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: :
|
|
95
|
-
:
|
|
96
|
-
:
|
|
97
|
-
:
|
|
98
|
-
:
|
|
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
|
|
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.
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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:
|
|
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
|