textus 0.50.0 → 0.51.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 +26 -0
- data/README.md +41 -43
- data/SPEC.md +174 -176
- data/docs/architecture/README.md +46 -42
- data/docs/reference/conventions.md +31 -26
- data/lib/textus/boot.rb +13 -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.rb +1 -3
- data/lib/textus/dispatcher.rb +1 -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/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 +79 -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 +8 -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/key/matching.rb +24 -0
- data/lib/textus/maintenance/reconcile.rb +160 -0
- data/lib/textus/manifest/capabilities.rb +1 -1
- data/lib/textus/manifest/data.rb +2 -2
- 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 +69 -0
- data/lib/textus/ports/publisher.rb +11 -7
- 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 +143 -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/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/put.rb +1 -1
- metadata +23 -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/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 reconcile`
|
|
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: [reconcile] }
|
|
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 reconcile` (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, :reconcile_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 reconcile` 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,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
|
|
@@ -0,0 +1,160 @@
|
|
|
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
|
|
@@ -13,7 +13,7 @@ module Textus
|
|
|
13
13
|
DEFAULT_MAPPING = {
|
|
14
14
|
Textus::Role::HUMAN => %w[author propose].freeze,
|
|
15
15
|
Textus::Role::AGENT => %w[propose].freeze,
|
|
16
|
-
Textus::Role::AUTOMATION => %w[
|
|
16
|
+
Textus::Role::AUTOMATION => %w[reconcile].freeze,
|
|
17
17
|
}.freeze
|
|
18
18
|
|
|
19
19
|
# Returns { role_name => [verbs] }. When `roles:` is declared we use
|
data/lib/textus/manifest/data.rb
CHANGED
|
@@ -49,8 +49,8 @@ module Textus
|
|
|
49
49
|
@audit_config = build_audit_config(raw)
|
|
50
50
|
@role_caps = Capabilities.resolve(raw["roles"])
|
|
51
51
|
# Policy is constructed before entries because Entry validators
|
|
52
|
-
#
|
|
53
|
-
#
|
|
52
|
+
# use the entry's own `derived?` and similar helpers that call into
|
|
53
|
+
# Policy; Policy must exist before entries are built.
|
|
54
54
|
@policy = Policy.new(self)
|
|
55
55
|
@entries = build_entries(raw)
|
|
56
56
|
validate_declared_keys!
|
|
@@ -2,10 +2,10 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
class Base < Entry
|
|
5
|
-
attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :
|
|
5
|
+
attr_reader :raw, :key, :path, :zone, :schema, :owner, :format, :publish_targets
|
|
6
6
|
|
|
7
7
|
# rubocop:disable Metrics/ParameterLists, Lint/MissingSuper
|
|
8
|
-
def initialize(raw:, key:, path:, zone:, schema:, owner:, format:,
|
|
8
|
+
def initialize(raw:, key:, path:, zone:, schema:, owner:, format:, publish_targets: [])
|
|
9
9
|
@raw = raw
|
|
10
10
|
@key = key
|
|
11
11
|
@path = path
|
|
@@ -13,7 +13,7 @@ module Textus
|
|
|
13
13
|
@schema = schema
|
|
14
14
|
@owner = owner
|
|
15
15
|
@format = format
|
|
16
|
-
@
|
|
16
|
+
@publish_targets = Array(publish_targets)
|
|
17
17
|
end
|
|
18
18
|
# rubocop:enable Metrics/ParameterLists, Lint/MissingSuper
|
|
19
19
|
|
|
@@ -23,28 +23,34 @@ module Textus
|
|
|
23
23
|
raise UsageError.new("entry '#{@key}': #{e.message}")
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def
|
|
27
|
-
def in_proposal_zone?(policy) = policy.queue_zone?(@zone)
|
|
26
|
+
def in_proposal_zone?(policy) = policy.queue_zone?(@zone)
|
|
28
27
|
|
|
29
28
|
def nested? = false
|
|
30
29
|
def derived? = false
|
|
31
30
|
def intake? = false
|
|
32
31
|
def leaf? = false
|
|
33
32
|
|
|
33
|
+
# Production traits. Default false on Base (a leaf/intake entry is neither
|
|
34
|
+
# an out-of-band command nor a projection); Produced overrides both from
|
|
35
|
+
# its source. Lets publish modes call these without a `respond_to?` guard.
|
|
36
|
+
def external? = false
|
|
37
|
+
def projection? = false
|
|
38
|
+
|
|
34
39
|
# Whether git should track this entry's file. Default true; an entry
|
|
35
40
|
# marked `tracked: false` in the manifest stays protocol-readable but is
|
|
36
41
|
# listed in the generated `.gitignore` (ADR 0043). Cross-cutting, so it
|
|
37
42
|
# reads from raw here rather than threading through every constructor.
|
|
38
43
|
def tracked? = @raw["tracked"] != false
|
|
39
44
|
|
|
45
|
+
# Single source of truth is @publish_targets (ADR 0094). These
|
|
46
|
+
# derive the ADR-0049/0052 views the publish modes consume.
|
|
47
|
+
def publish_to = @publish_targets.select(&:to_target?).map(&:to)
|
|
48
|
+
def publish_tree = @publish_targets.find(&:tree_target?)&.tree
|
|
49
|
+
|
|
40
50
|
# Nil stubs for cross-cutting optional attrs. Subclasses override the
|
|
41
51
|
# ones they own. Validators and serializers can call these directly
|
|
42
52
|
# without `respond_to?` guards.
|
|
43
|
-
def template = nil
|
|
44
|
-
def inject_boot = false # rubocop:disable Naming/PredicateMethod
|
|
45
|
-
def provenance = true # rubocop:disable Naming/PredicateMethod
|
|
46
53
|
def events = {}
|
|
47
|
-
def publish_tree = nil
|
|
48
54
|
def ignore = []
|
|
49
55
|
|
|
50
56
|
# Per-entry ignore (ADR 0042). Base entries enumerate no tree, so
|
|
@@ -69,6 +75,19 @@ module Textus
|
|
|
69
75
|
events.publish(event, ctx: hook_context, **payload)
|
|
70
76
|
end
|
|
71
77
|
|
|
78
|
+
# Read a named template from the store's templates/ directory.
|
|
79
|
+
# Raises TemplateError when the file doesn't exist.
|
|
80
|
+
def read_template(name)
|
|
81
|
+
path = File.join(container.root.to_s, "templates", name)
|
|
82
|
+
unless File.exist?(path)
|
|
83
|
+
raise Textus::TemplateError.new(
|
|
84
|
+
"template '#{name}' not found",
|
|
85
|
+
template_name: name,
|
|
86
|
+
)
|
|
87
|
+
end
|
|
88
|
+
File.read(path)
|
|
89
|
+
end
|
|
90
|
+
|
|
72
91
|
private
|
|
73
92
|
|
|
74
93
|
def scope_for_hooks
|
|
@@ -6,11 +6,10 @@ module Textus
|
|
|
6
6
|
# Entry::Publish::* — Nested is just the value (attributes + ignore
|
|
7
7
|
# predicate) those modes read.
|
|
8
8
|
class Nested < Base
|
|
9
|
-
attr_reader :
|
|
9
|
+
attr_reader :ignore
|
|
10
10
|
|
|
11
|
-
def initialize(
|
|
11
|
+
def initialize(ignore: nil, **rest)
|
|
12
12
|
super(**rest)
|
|
13
|
-
@publish_tree = publish_tree
|
|
14
13
|
@ignore = Array(ignore)
|
|
15
14
|
end
|
|
16
15
|
|
|
@@ -24,8 +23,8 @@ module Textus
|
|
|
24
23
|
KIND = :nested
|
|
25
24
|
|
|
26
25
|
def self.from_raw(common, raw)
|
|
26
|
+
# publish_tree is derived from publish_targets (ADR 0094) via Base#publish_tree
|
|
27
27
|
new(
|
|
28
|
-
publish_tree: raw.dig("publish", "tree"), # ADR 0052: typed publish block
|
|
29
28
|
ignore: raw["ignore"],
|
|
30
29
|
**common,
|
|
31
30
|
)
|
|
@@ -2,8 +2,6 @@ module Textus
|
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
4
|
module Parser
|
|
5
|
-
COMPUTE_KINDS = %w[projection external].freeze
|
|
6
|
-
|
|
7
5
|
def self.call(raw)
|
|
8
6
|
key = raw["key"] or raise UsageError.new("manifest entry missing key")
|
|
9
7
|
path = raw["path"] or raise UsageError.new("manifest entry '#{key}' missing path")
|
|
@@ -11,6 +9,12 @@ module Textus
|
|
|
11
9
|
|
|
12
10
|
raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (#{Entry::REGISTRY.keys.join("|")})")
|
|
13
11
|
kind = raw_kind.to_sym
|
|
12
|
+
if %i[derived intake].include?(kind)
|
|
13
|
+
raise BadManifest.new(
|
|
14
|
+
"entry '#{key}': kind: #{kind} was collapsed into `kind: produced` (ADR 0095) — " \
|
|
15
|
+
"the produce method is `source.from` (#{kind == :intake ? "handler" : "project|command"})",
|
|
16
|
+
)
|
|
17
|
+
end
|
|
14
18
|
format = resolve_format(raw, path)
|
|
15
19
|
|
|
16
20
|
common = {
|
|
@@ -18,10 +22,7 @@ module Textus
|
|
|
18
22
|
key: key, path: path, zone: zone,
|
|
19
23
|
schema: raw["schema"], owner: raw["owner"],
|
|
20
24
|
format: format,
|
|
21
|
-
|
|
22
|
-
# publish_to/publish_tree readers (the ADR 0049 modes) are sourced
|
|
23
|
-
# from it (publish_to <- publish.to, publish_tree <- publish.tree).
|
|
24
|
-
publish_to: raw.dig("publish", "to")
|
|
25
|
+
publish_targets: publish_targets(raw)
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
klass = Entry::REGISTRY[kind] or
|
|
@@ -29,26 +30,29 @@ module Textus
|
|
|
29
30
|
klass.from_raw(common, raw)
|
|
30
31
|
end
|
|
31
32
|
|
|
33
|
+
# ADR 0093: an entry's production block is the unified `source:`. Returns a
|
|
34
|
+
# Domain::Policy::Source; kind (intake/derived) is read from source.from.
|
|
32
35
|
def self.parse_source(raw, key)
|
|
33
|
-
|
|
34
|
-
|
|
36
|
+
block = raw["source"] or
|
|
37
|
+
raise BadManifest.new("entry '#{key}' requires a source: { from: project|handler|command, ... }")
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
"entry '#{key}': compute.kind must be one of #{COMPUTE_KINDS.join(", ")} (got #{compute["kind"].inspect})",
|
|
39
|
-
)
|
|
40
|
-
end
|
|
39
|
+
Textus::Domain::Policy::Source.new(block)
|
|
40
|
+
end
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
42
|
+
# ADR 0094: `publish:` is a LIST of target objects — to-targets
|
|
43
|
+
# [{to, template?, inject_boot?}] and/or a tree-target [{tree}]. The
|
|
44
|
+
# ADR-0052 map forms ({to: […]} / {tree: …}) are retired.
|
|
45
|
+
def self.publish_targets(raw)
|
|
46
|
+
block = raw["publish"]
|
|
47
|
+
return [] if block.nil?
|
|
48
|
+
|
|
49
|
+
unless block.is_a?(Array)
|
|
50
|
+
raise BadManifest.new(
|
|
51
|
+
"entry '#{raw["key"]}': `publish:` must be a list of targets " \
|
|
52
|
+
"[{to:, template:?} | {tree:}] (ADR 0094); the `publish: { … }` map form was retired",
|
|
48
53
|
)
|
|
49
|
-
else
|
|
50
|
-
Entry::Derived::External.new(sources: compute["sources"], command: compute["command"])
|
|
51
54
|
end
|
|
55
|
+
block.map { |t| Textus::Domain::Policy::PublishTarget.new(t) }
|
|
52
56
|
end
|
|
53
57
|
|
|
54
58
|
def self.resolve_format(raw, path)
|