textus 0.47.1 → 0.50.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 +51 -0
- data/README.md +9 -7
- data/SPEC.md +44 -69
- data/docs/reference/conventions.md +13 -12
- data/lib/textus/boot.rb +47 -32
- data/lib/textus/builder/renderer/json.rb +1 -1
- data/lib/textus/cli/runner.rb +5 -4
- data/lib/textus/cli/verb/boot.rb +2 -1
- data/lib/textus/cli.rb +0 -1
- data/lib/textus/dispatcher.rb +3 -8
- data/lib/textus/doctor/check/generator_drift.rb +28 -0
- data/lib/textus/doctor/check/lifecycle_action_invalid.rb +39 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +1 -1
- data/lib/textus/doctor.rb +2 -0
- data/lib/textus/domain/lifecycle.rb +83 -0
- data/lib/textus/domain/policy/base_guards.rb +2 -2
- data/lib/textus/domain/policy/lifecycle.rb +35 -0
- data/lib/textus/domain/staleness.rb +6 -3
- data/lib/textus/envelope/io/writer.rb +2 -2
- data/lib/textus/hooks/context.rb +1 -1
- data/lib/textus/init.rb +4 -4
- data/lib/textus/maintenance/key_delete_prefix.rb +1 -1
- data/lib/textus/maintenance/key_mv_prefix.rb +2 -2
- data/lib/textus/maintenance/tend.rb +110 -0
- data/lib/textus/manifest/entry/base.rb +1 -0
- data/lib/textus/manifest/entry/derived.rb +4 -2
- data/lib/textus/manifest/rules.rb +11 -23
- data/lib/textus/manifest/schema.rb +4 -19
- data/lib/textus/mcp/server.rb +9 -2
- data/lib/textus/ports/audit_log.rb +1 -1
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/fetch/detached.rb +5 -1
- data/lib/textus/read/boot.rb +4 -2
- data/lib/textus/read/freshness.rb +37 -26
- data/lib/textus/read/get.rb +47 -32
- data/lib/textus/read/pulse.rb +1 -1
- data/lib/textus/read/rule_explain.rb +10 -16
- data/lib/textus/read/rule_list.rb +5 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +1 -1
- data/lib/textus/write/fetch_worker.rb +8 -12
- data/lib/textus/write/{delete.rb → key_delete.rb} +3 -3
- data/lib/textus/write/{mv.rb → key_mv.rb} +4 -4
- data/lib/textus/write/reject.rb +1 -1
- metadata +8 -15
- data/lib/textus/cli/group/fetch.rb +0 -20
- data/lib/textus/cli/verb/fetch.rb +0 -14
- data/lib/textus/cli/verb/fetch_all.rb +0 -20
- data/lib/textus/domain/policy/fetch.rb +0 -37
- data/lib/textus/domain/policy/retention.rb +0 -26
- data/lib/textus/domain/retention.rb +0 -44
- data/lib/textus/domain/staleness/intake_check.rb +0 -54
- data/lib/textus/maintenance/migrate.rb +0 -65
- data/lib/textus/read/retainable.rb +0 -17
- data/lib/textus/read/stale.rb +0 -17
- data/lib/textus/write/fetch_all.rb +0 -53
- data/lib/textus/write/retention_sweep.rb +0 -64
data/lib/textus/boot.rb
CHANGED
|
@@ -71,49 +71,50 @@ module Textus
|
|
|
71
71
|
},
|
|
72
72
|
}.freeze
|
|
73
73
|
|
|
74
|
-
# Curated agent-facing verb catalog.
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
#
|
|
74
|
+
# Curated agent-facing verb catalog. This declares which verbs the operator
|
|
75
|
+
# CLI surfaces and in what order — the editorial presentation. The summary of
|
|
76
|
+
# each verb is a fact, not presentation: it is derived from `contract.summary`
|
|
77
|
+
# at load time (ADR 0039). A literal "summary" survives here only for grouped
|
|
78
|
+
# CLI tokens (schema/key/rule/hook) that aggregate several sub-contracts and so
|
|
79
|
+
# have no single contract to derive from. CLI_VERBS itself is assigned in
|
|
80
|
+
# textus.rb after Zeitwerk eager_load so all contract files are present.
|
|
79
81
|
CURATED_CLI_VERBS = [
|
|
80
82
|
{ "name" => "boot" },
|
|
81
83
|
{ "name" => "list" },
|
|
82
84
|
{ "name" => "get" },
|
|
83
|
-
{ "name" => "where"
|
|
85
|
+
{ "name" => "where" },
|
|
84
86
|
{ "name" => "schema", "summary" => "schema operations: 'schema show KEY', 'schema diff', 'schema init', 'schema migrate'" },
|
|
85
87
|
{ "name" => "put" },
|
|
86
88
|
{ "name" => "propose" },
|
|
87
|
-
{ "name" => "accept"
|
|
88
|
-
{ "name" => "key",
|
|
89
|
-
{ "name" => "build"
|
|
90
|
-
{ "name" => "
|
|
91
|
-
{ "name" => "
|
|
92
|
-
{ "name" => "
|
|
93
|
-
{ "name" => "
|
|
94
|
-
{ "name" => "
|
|
95
|
-
{ "name" => "
|
|
96
|
-
{ "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
89
|
+
{ "name" => "accept" },
|
|
90
|
+
{ "name" => "key", "summary" => "key operations: 'key delete', 'key mv', 'key uid'" },
|
|
91
|
+
{ "name" => "build" },
|
|
92
|
+
{ "name" => "tend" },
|
|
93
|
+
{ "name" => "audit" },
|
|
94
|
+
{ "name" => "blame" },
|
|
95
|
+
{ "name" => "rule", "summary" => "inspect effective rules: 'rule list', 'rule explain KEY'" },
|
|
96
|
+
{ "name" => "doctor" },
|
|
97
|
+
{ "name" => "hook", "summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
97
98
|
{ "name" => "pulse" },
|
|
98
99
|
{ "name" => "capabilities" },
|
|
99
100
|
].freeze
|
|
100
101
|
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
.to_h { |k| [k.contract.verb.to_s, k.contract.summary] }
|
|
102
|
+
# verb token => contract.summary, for every Dispatcher verb that carries a
|
|
103
|
+
# contract. The single source for a verb's one-line summary (ADR 0039).
|
|
104
|
+
def self.contract_summaries
|
|
105
|
+
Dispatcher::VERBS.values
|
|
106
|
+
.select { |k| k.respond_to?(:contract?) && k.contract? }
|
|
107
|
+
.to_h { |k| [k.contract.verb.to_s, k.contract.summary] }
|
|
108
|
+
end
|
|
109
109
|
|
|
110
|
+
# Build the CLI verb catalog: each summary is derived from its contract when
|
|
111
|
+
# one exists, falling back to the curated editorial string for grouped tokens
|
|
112
|
+
# (schema/key/rule/hook). Called once from textus.rb after eager_load.
|
|
113
|
+
def self.build_cli_verbs
|
|
114
|
+
summaries = contract_summaries
|
|
110
115
|
CURATED_CLI_VERBS.map do |entry|
|
|
111
|
-
derived =
|
|
112
|
-
|
|
113
|
-
entry.merge("summary" => derived)
|
|
114
|
-
else
|
|
115
|
-
entry
|
|
116
|
-
end
|
|
116
|
+
derived = summaries[entry["name"]]
|
|
117
|
+
derived ? entry.merge("summary" => derived) : entry
|
|
117
118
|
end
|
|
118
119
|
end
|
|
119
120
|
|
|
@@ -130,7 +131,8 @@ module Textus
|
|
|
130
131
|
# Both verb lists derive from the MCP catalog (ADR 0056, ADR 0057): the
|
|
131
132
|
# agent's real read and write surface, named as verbs the agent calls —
|
|
132
133
|
# not CLI strings. read_verbs can neither advertise a verb the agent
|
|
133
|
-
# cannot call (audit/
|
|
134
|
+
# cannot call (audit/doctor are CLI-only; freshness is a Ruby-only
|
|
135
|
+
# internal scan, ADR 0085) nor omit one it can
|
|
134
136
|
# (schema_show/rules); write_verbs drops the old `put KEY --as=… --stdin` CLI
|
|
135
137
|
# framing (role is connection-resolved over MCP; there is no stdin).
|
|
136
138
|
# writable_zones / propose_zone below carry the agent's write authority.
|
|
@@ -196,8 +198,20 @@ module Textus
|
|
|
196
198
|
)
|
|
197
199
|
end
|
|
198
200
|
|
|
199
|
-
def self.build(container:)
|
|
201
|
+
def self.build(container:, lean: false)
|
|
200
202
|
manifest = container.manifest
|
|
203
|
+
etag = Textus::Etag.for_contract(container.root)
|
|
204
|
+
|
|
205
|
+
if lean
|
|
206
|
+
return {
|
|
207
|
+
"protocol" => PROTOCOL_ID,
|
|
208
|
+
"store_root" => container.root,
|
|
209
|
+
"zones" => zones_for(manifest),
|
|
210
|
+
"agent_quickstart" => agent_quickstart(manifest, container.audit_log),
|
|
211
|
+
"contract_etag" => etag,
|
|
212
|
+
}
|
|
213
|
+
end
|
|
214
|
+
|
|
201
215
|
{
|
|
202
216
|
"protocol" => PROTOCOL_ID,
|
|
203
217
|
"store_root" => container.root,
|
|
@@ -208,6 +222,7 @@ module Textus
|
|
|
208
222
|
"cli_verbs" => CLI_VERBS.map(&:dup),
|
|
209
223
|
"agent_protocol" => agent_protocol(manifest),
|
|
210
224
|
"agent_quickstart" => agent_quickstart(manifest, container.audit_log),
|
|
225
|
+
"contract_etag" => etag,
|
|
211
226
|
"docs" => { "spec" => "SPEC.md", "example" => "examples/project/" },
|
|
212
227
|
}
|
|
213
228
|
end
|
|
@@ -6,7 +6,7 @@ module Textus
|
|
|
6
6
|
class Json < Renderer
|
|
7
7
|
def call(mentry:, data:)
|
|
8
8
|
content = mentry.template ? parse_rendered_template!(mentry, data) : default_shape(mentry, data)
|
|
9
|
-
final = InjectMeta.call(content, mentry)
|
|
9
|
+
final = mentry.provenance ? InjectMeta.call(content, mentry) : content
|
|
10
10
|
Entry.for_format("json").serialize(meta: {}, body: "", content: final)
|
|
11
11
|
end
|
|
12
12
|
|
data/lib/textus/cli/runner.rb
CHANGED
|
@@ -137,10 +137,11 @@ module Textus
|
|
|
137
137
|
BEHAVIORAL_HATCHES = %i[get put build].freeze
|
|
138
138
|
|
|
139
139
|
# Contract verbs whose CLI is a plain `< Verb` command, not a projection at
|
|
140
|
-
# all —
|
|
141
|
-
#
|
|
142
|
-
#
|
|
143
|
-
|
|
140
|
+
# all — composite reports assembled outside the contract:
|
|
141
|
+
# boot, doctor — composite reports
|
|
142
|
+
# (fetch/fetch_all were removed in ADR 0079: FetchWorker is now internal,
|
|
143
|
+
# driven by get's read-through orchestrator and the tend sweep.)
|
|
144
|
+
NON_PROJECTED_CLI = %i[boot doctor].freeze
|
|
144
145
|
|
|
145
146
|
# The installer skips generation for either category.
|
|
146
147
|
HAND_AUTHORED_VERBS = (BEHAVIORAL_HATCHES + NON_PROJECTED_CLI).freeze
|
data/lib/textus/cli/verb/boot.rb
CHANGED
data/lib/textus/cli.rb
CHANGED
|
@@ -123,7 +123,6 @@ module Textus
|
|
|
123
123
|
textus where KEY
|
|
124
124
|
textus get KEY
|
|
125
125
|
textus put KEY --stdin [--fetch=NAME] --as=ROLE
|
|
126
|
-
textus freshness [--prefix=KEY] [--zone=Z]
|
|
127
126
|
textus fetch KEY
|
|
128
127
|
textus fetch all [--prefix=KEY] [--zone=Z]
|
|
129
128
|
textus audit [--key=K] [--zone=Z] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
|
data/lib/textus/dispatcher.rb
CHANGED
|
@@ -7,14 +7,11 @@ module Textus
|
|
|
7
7
|
# Write
|
|
8
8
|
put: Textus::Write::Put,
|
|
9
9
|
propose: Textus::Write::Propose,
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
key_delete: Textus::Write::KeyDelete,
|
|
11
|
+
key_mv: Textus::Write::KeyMv,
|
|
12
12
|
accept: Textus::Write::Accept,
|
|
13
13
|
reject: Textus::Write::Reject,
|
|
14
14
|
build: Textus::Write::Build,
|
|
15
|
-
fetch: Textus::Write::FetchWorker,
|
|
16
|
-
fetch_all: Textus::Write::FetchAll,
|
|
17
|
-
retain: Textus::Write::RetentionSweep,
|
|
18
15
|
|
|
19
16
|
# Read
|
|
20
17
|
get: Textus::Read::Get,
|
|
@@ -24,7 +21,6 @@ module Textus
|
|
|
24
21
|
blame: Textus::Read::Blame,
|
|
25
22
|
audit: Textus::Read::Audit,
|
|
26
23
|
freshness: Textus::Read::Freshness,
|
|
27
|
-
stale: Textus::Read::Stale,
|
|
28
24
|
deps: Textus::Read::Deps,
|
|
29
25
|
rdeps: Textus::Read::Rdeps,
|
|
30
26
|
pulse: Textus::Read::Pulse,
|
|
@@ -35,14 +31,13 @@ module Textus
|
|
|
35
31
|
validate_all: Textus::Read::ValidateAll,
|
|
36
32
|
doctor: Textus::Read::Doctor,
|
|
37
33
|
boot: Textus::Read::Boot,
|
|
38
|
-
retainable: Textus::Read::Retainable,
|
|
39
34
|
capabilities: Textus::Read::Capabilities,
|
|
40
35
|
|
|
41
36
|
# Maintenance
|
|
42
|
-
migrate: Textus::Maintenance::Migrate,
|
|
43
37
|
zone_mv: Textus::Maintenance::ZoneMv,
|
|
44
38
|
key_mv_prefix: Textus::Maintenance::KeyMvPrefix,
|
|
45
39
|
key_delete_prefix: Textus::Maintenance::KeyDeletePrefix,
|
|
40
|
+
tend: Textus::Maintenance::Tend,
|
|
46
41
|
rule_lint: Textus::Maintenance::RuleLint,
|
|
47
42
|
}.freeze
|
|
48
43
|
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Doctor
|
|
3
|
+
class Check
|
|
4
|
+
# ADR 0079: generator/build drift — a derived/external entry whose sources
|
|
5
|
+
# changed since its generated.at. Dependency-based (not age-based), so it
|
|
6
|
+
# stays OUT of the lifecycle/freshness unification and lives here as a
|
|
7
|
+
# health signal. This is the surviving home for what the removed `stale`
|
|
8
|
+
# verb reported.
|
|
9
|
+
class GeneratorDrift < Check
|
|
10
|
+
def call
|
|
11
|
+
gen = Textus::Domain::Staleness::GeneratorCheck.new(
|
|
12
|
+
manifest: manifest,
|
|
13
|
+
file_stat: Textus::Ports::Storage::FileStat.new,
|
|
14
|
+
)
|
|
15
|
+
manifest.data.entries.flat_map { |m| gen.rows_for(m) }.map do |row|
|
|
16
|
+
{
|
|
17
|
+
"code" => "generator_drift",
|
|
18
|
+
"level" => "warning",
|
|
19
|
+
"subject" => row["key"],
|
|
20
|
+
"message" => row["reason"],
|
|
21
|
+
"fix" => "rebuild the entry: `textus build`",
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Doctor
|
|
3
|
+
class Check
|
|
4
|
+
# ADR 0079: refresh is valid only for intake entries; drop/archive are
|
|
5
|
+
# invalid for intake entries (they would re-fetch, not prune).
|
|
6
|
+
class LifecycleActionInvalid < Check
|
|
7
|
+
def call
|
|
8
|
+
manifest.data.entries.filter_map do |mentry|
|
|
9
|
+
policy = manifest.rules.for(mentry.key).lifecycle
|
|
10
|
+
next if policy.nil?
|
|
11
|
+
|
|
12
|
+
intake = mentry.is_a?(Textus::Manifest::Entry::Intake)
|
|
13
|
+
bad = (policy.on_expire == :refresh && !intake) || (policy.destructive? && intake)
|
|
14
|
+
next unless bad
|
|
15
|
+
|
|
16
|
+
issue_for(mentry, policy, intake)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def issue_for(mentry, policy, intake)
|
|
23
|
+
{
|
|
24
|
+
"code" => "lifecycle.action_invalid",
|
|
25
|
+
"level" => "error",
|
|
26
|
+
"subject" => mentry.key,
|
|
27
|
+
"message" => "on_expire: #{policy.on_expire} is not valid for a " \
|
|
28
|
+
"#{intake ? "intake" : "stored"} entry",
|
|
29
|
+
"fix" => if intake
|
|
30
|
+
"use on_expire: refresh|warn for intake entries"
|
|
31
|
+
else
|
|
32
|
+
"use on_expire: drop|archive|warn for stored entries"
|
|
33
|
+
end,
|
|
34
|
+
}
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -6,7 +6,7 @@ module Textus
|
|
|
6
6
|
# guard). Ties are non-deterministic in the parser's pick step, so
|
|
7
7
|
# they're a configuration smell — surface them.
|
|
8
8
|
class RuleAmbiguity < Check
|
|
9
|
-
SLOTS = %i[
|
|
9
|
+
SLOTS = %i[lifecycle handler_allowlist guard].freeze
|
|
10
10
|
|
|
11
11
|
def call
|
|
12
12
|
out = []
|
data/lib/textus/doctor.rb
CHANGED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Domain
|
|
5
|
+
# Unified lifecycle reporter (ADR 0079): which entries are past their ttl,
|
|
6
|
+
# and the on_expire action that applies. Replaces both Staleness::IntakeCheck
|
|
7
|
+
# and Retention. Age basis: _meta.last_fetched_at (intake) when present, else
|
|
8
|
+
# file mtime (stored). `self.verdict` is the pure per-entry decision that BOTH
|
|
9
|
+
# this reporter and `Read::Get` (Plan 2) call, so the basis logic lives once.
|
|
10
|
+
class Lifecycle
|
|
11
|
+
# Pure: is the entry past its ttl? -> [expired(bool), reason(String|nil)].
|
|
12
|
+
def self.verdict(policy:, last_fetched_at:, mtime:, now:)
|
|
13
|
+
ttl = policy.ttl_seconds
|
|
14
|
+
return [false, nil] if ttl.nil?
|
|
15
|
+
|
|
16
|
+
basis = parse_time(last_fetched_at) || mtime
|
|
17
|
+
return [true, "never recorded"] if basis.nil?
|
|
18
|
+
|
|
19
|
+
age = (now - basis).to_i
|
|
20
|
+
age > ttl ? [true, "ttl exceeded (age=#{age}s, ttl=#{ttl}s)"] : [false, nil]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.parse_time(str)
|
|
24
|
+
return nil if str.nil?
|
|
25
|
+
|
|
26
|
+
Time.parse(str.to_s)
|
|
27
|
+
rescue ArgumentError, TypeError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def initialize(manifest:, file_stat:, clock:)
|
|
32
|
+
@manifest = manifest
|
|
33
|
+
@file_stat = file_stat
|
|
34
|
+
@clock = clock
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def call(prefix: nil, zone: nil)
|
|
38
|
+
@manifest.data.entries
|
|
39
|
+
.select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
|
|
40
|
+
.flat_map { |m| rows_for(m) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def entry_matches?(mentry, prefix:, zone:)
|
|
46
|
+
return false if zone && mentry.zone != zone
|
|
47
|
+
return false if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
|
|
48
|
+
|
|
49
|
+
true
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def rows_for(mentry)
|
|
53
|
+
policy = @manifest.rules.for(mentry.key).lifecycle
|
|
54
|
+
return [] if policy.nil?
|
|
55
|
+
|
|
56
|
+
@manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
|
|
57
|
+
path = row[:path]
|
|
58
|
+
next unless @file_stat.exists?(path)
|
|
59
|
+
|
|
60
|
+
expired, _reason = self.class.verdict(
|
|
61
|
+
policy: policy,
|
|
62
|
+
last_fetched_at: last_fetched_at_of(mentry, path),
|
|
63
|
+
mtime: @file_stat.mtime(path),
|
|
64
|
+
now: @clock.now,
|
|
65
|
+
)
|
|
66
|
+
next unless expired
|
|
67
|
+
|
|
68
|
+
{
|
|
69
|
+
"key" => row[:key], "path" => path,
|
|
70
|
+
"action" => policy.on_expire.to_s, "expired" => true
|
|
71
|
+
}
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Reads _meta.last_fetched_at from the on-disk envelope (intake basis).
|
|
76
|
+
def last_fetched_at_of(mentry, path)
|
|
77
|
+
Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["last_fetched_at"]
|
|
78
|
+
rescue StandardError
|
|
79
|
+
nil
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
@@ -11,8 +11,8 @@ module Textus
|
|
|
11
11
|
# composable-only, added per-key via rules[].guard (ADR 0031).
|
|
12
12
|
BASE = {
|
|
13
13
|
put: %w[zone_writable_by],
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
key_delete: %w[zone_writable_by],
|
|
15
|
+
key_mv: %w[zone_writable_by],
|
|
16
16
|
accept: %w[author_held target_is_canon],
|
|
17
17
|
reject: %w[author_held],
|
|
18
18
|
fetch: %w[zone_writable_by],
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
module Policy
|
|
4
|
+
# Unified per-entry lifecycle policy (ADR 0079): one ttl + one action.
|
|
5
|
+
# Replaces the separate Fetch (ttl/on_stale) and Retention
|
|
6
|
+
# (expire_after/archive_after) policies. The action's destructiveness
|
|
7
|
+
# decides WHERE it runs: lazy actions (refresh/warn) on get/list reads;
|
|
8
|
+
# destructive actions (drop/archive) only on the tend sweep.
|
|
9
|
+
class Lifecycle
|
|
10
|
+
LAZY = %i[refresh warn].freeze
|
|
11
|
+
DESTRUCTIVE = %i[drop archive].freeze
|
|
12
|
+
ALLOWED = (LAZY + DESTRUCTIVE).freeze
|
|
13
|
+
|
|
14
|
+
attr_reader :on_expire, :budget_ms
|
|
15
|
+
|
|
16
|
+
def initialize(ttl:, on_expire:, budget_ms: nil)
|
|
17
|
+
action = on_expire.is_a?(Symbol) ? on_expire : on_expire.to_s.to_sym
|
|
18
|
+
unless ALLOWED.include?(action)
|
|
19
|
+
raise Textus::UsageError.new(
|
|
20
|
+
"lifecycle on_expire must be one of #{ALLOWED.join("|")}, got #{on_expire.inspect}",
|
|
21
|
+
)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
@ttl = ttl
|
|
25
|
+
@on_expire = action
|
|
26
|
+
@budget_ms = budget_ms
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def ttl_seconds = Textus::Domain::Duration.seconds(@ttl)
|
|
30
|
+
def destructive? = DESTRUCTIVE.include?(@on_expire)
|
|
31
|
+
def lazy? = LAZY.include?(@on_expire)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Domain
|
|
3
3
|
class Staleness
|
|
4
|
-
|
|
4
|
+
# ADR 0079: intake (age-based) staleness moved to the unified lifecycle
|
|
5
|
+
# path (Domain::Lifecycle / freshness); only generator/build drift —
|
|
6
|
+
# dependency-based, surfaced by the doctor `generator_drift` check —
|
|
7
|
+
# remains here.
|
|
8
|
+
def initialize(manifest:, file_stat:, clock: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
9
|
@manifest = manifest
|
|
6
10
|
@generator_check = GeneratorCheck.new(manifest: manifest, file_stat: file_stat)
|
|
7
|
-
@intake_check = IntakeCheck.new(manifest: manifest, file_stat: file_stat, clock: clock)
|
|
8
11
|
end
|
|
9
12
|
|
|
10
13
|
def call(prefix: nil, zone: nil)
|
|
11
14
|
@manifest.data.entries
|
|
12
15
|
.select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
|
|
13
|
-
.flat_map { |m| @generator_check.rows_for(m)
|
|
16
|
+
.flat_map { |m| @generator_check.rows_for(m) }
|
|
14
17
|
end
|
|
15
18
|
|
|
16
19
|
private
|
|
@@ -84,7 +84,7 @@ module Textus
|
|
|
84
84
|
@file_store.delete(path)
|
|
85
85
|
prune_empty_parents(path)
|
|
86
86
|
@audit_log.append(
|
|
87
|
-
role: @call.role, verb: "
|
|
87
|
+
role: @call.role, verb: "key_delete", key: key,
|
|
88
88
|
etag_before: etag_before, etag_after: nil,
|
|
89
89
|
extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
90
90
|
)
|
|
@@ -121,7 +121,7 @@ module Textus
|
|
|
121
121
|
extras["correlation_id"] = @call.correlation_id if @call.correlation_id
|
|
122
122
|
|
|
123
123
|
@audit_log.append(
|
|
124
|
-
role: @call.role, verb: "
|
|
124
|
+
role: @call.role, verb: "key_mv", key: to_key,
|
|
125
125
|
etag_before: etag_before, etag_after: etag_after,
|
|
126
126
|
extras: extras
|
|
127
127
|
)
|
data/lib/textus/hooks/context.rb
CHANGED
|
@@ -45,7 +45,7 @@ module Textus
|
|
|
45
45
|
|
|
46
46
|
# write (authorized + audited)
|
|
47
47
|
def put(key, **) = @scope.put(key, **)
|
|
48
|
-
def delete(key, **) = @scope.
|
|
48
|
+
def delete(key, **) = @scope.key_delete(key, **)
|
|
49
49
|
|
|
50
50
|
def audit(verb, key:, **)
|
|
51
51
|
@scope.container.audit_log.append(role: @role, verb: verb, key: key, **)
|
data/lib/textus/init.rb
CHANGED
|
@@ -41,7 +41,7 @@ module Textus
|
|
|
41
41
|
local: { via: local }
|
|
42
42
|
rules:
|
|
43
43
|
- match: feeds.machines.**
|
|
44
|
-
|
|
44
|
+
lifecycle: { ttl: 1h, on_expire: warn } # meaningful on a long-running server
|
|
45
45
|
YAML
|
|
46
46
|
|
|
47
47
|
HOOKS_README = <<~MD
|
|
@@ -72,7 +72,7 @@ module Textus
|
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
The intake handler above is paired with a manifest entry plus a
|
|
75
|
-
top-level `rules:` block for
|
|
75
|
+
top-level `rules:` block for lifecycle (ttl/on_expire live in
|
|
76
76
|
rules, not in the entry):
|
|
77
77
|
|
|
78
78
|
```yaml
|
|
@@ -86,9 +86,9 @@ module Textus
|
|
|
86
86
|
|
|
87
87
|
rules:
|
|
88
88
|
- match: feeds.foo
|
|
89
|
-
|
|
89
|
+
lifecycle:
|
|
90
90
|
ttl: 10m
|
|
91
|
-
|
|
91
|
+
on_expire: refresh # refresh | warn (intake); drop | archive (stored)
|
|
92
92
|
```
|
|
93
93
|
|
|
94
94
|
Events: :resolve_intake, :transform_rows, :validate (rpc — return value used)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Maintenance
|
|
3
3
|
# Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
|
|
4
|
-
# Calls Write::
|
|
4
|
+
# Calls Write::KeyMv directly for each entry — emits one audit row per file moved.
|
|
5
5
|
class KeyMvPrefix
|
|
6
6
|
extend Textus::Contract::DSL
|
|
7
7
|
|
|
@@ -61,7 +61,7 @@ module Textus
|
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
def mv
|
|
64
|
-
Write::
|
|
64
|
+
Write::KeyMv.new(container: @container, call: @call)
|
|
65
65
|
end
|
|
66
66
|
end
|
|
67
67
|
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Maintenance
|
|
5
|
+
# The destructive-only lifecycle sweep (ADR 0079, supersedes the composite
|
|
6
|
+
# 0078 body). Drives off the unified Domain::Lifecycle reporter: it applies
|
|
7
|
+
# destructive actions a read never performs (drop = delete via Write::KeyDelete;
|
|
8
|
+
# archive = copy to <store>/archive/ then delete) and refreshes cold expired
|
|
9
|
+
# intake entries (on_expire: refresh) via Write::FetchWorker. Non-destructive
|
|
10
|
+
# annotation (warn) is left to the lazy `get`/`freshness` path. Adds no new
|
|
11
|
+
# authority — every sub-op runs with the CALLER's own `call` (role), and is
|
|
12
|
+
# gated exactly as on its own.
|
|
13
|
+
class Tend
|
|
14
|
+
extend Textus::Contract::DSL
|
|
15
|
+
|
|
16
|
+
verb :tend
|
|
17
|
+
summary "Run the destructive lifecycle sweep: drop/archive expired entries, refresh cold intake, report health."
|
|
18
|
+
surfaces :cli, :mcp
|
|
19
|
+
cli "tend"
|
|
20
|
+
arg :prefix, String, description: "restrict the sweep to keys under this dotted prefix"
|
|
21
|
+
arg :zone, String, description: "restrict the sweep to entries in this zone"
|
|
22
|
+
arg :dry_run, :boolean, default: false,
|
|
23
|
+
description: "when true, report what the sweep WOULD do without applying; " \
|
|
24
|
+
"defaults to false, so omitting it drops/archives/refreshes immediately"
|
|
25
|
+
|
|
26
|
+
def initialize(container:, call:)
|
|
27
|
+
@container = container
|
|
28
|
+
@call = call
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def call(prefix: nil, zone: nil, dry_run: false)
|
|
32
|
+
rows = Textus::Domain::Lifecycle.new(
|
|
33
|
+
manifest: @container.manifest,
|
|
34
|
+
file_stat: Textus::Ports::Storage::FileStat.new,
|
|
35
|
+
clock: Textus::Ports::Clock,
|
|
36
|
+
).call(prefix: prefix, zone: zone)
|
|
37
|
+
|
|
38
|
+
health = Read::Doctor.new(container: @container, call: @call).call
|
|
39
|
+
return dry_run_result(rows, health) if dry_run
|
|
40
|
+
|
|
41
|
+
apply_result(apply(rows), health)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def dry_run_result(rows, health)
|
|
47
|
+
{
|
|
48
|
+
"protocol" => Textus::PROTOCOL, "ok" => true, "dry_run" => true,
|
|
49
|
+
"would_drop" => action_keys(rows, "drop"),
|
|
50
|
+
"would_archive" => action_keys(rows, "archive"),
|
|
51
|
+
"would_refresh" => action_keys(rows, "refresh"),
|
|
52
|
+
"health" => health
|
|
53
|
+
}
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def apply_result(result, health)
|
|
57
|
+
{
|
|
58
|
+
"protocol" => Textus::PROTOCOL,
|
|
59
|
+
"ok" => result[:failed].empty?,
|
|
60
|
+
"dry_run" => false,
|
|
61
|
+
"dropped" => result[:dropped], "archived" => result[:archived],
|
|
62
|
+
"refreshed" => result[:refreshed], "failed" => result[:failed],
|
|
63
|
+
"health" => health
|
|
64
|
+
}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def action_keys(rows, action)
|
|
68
|
+
rows.select { |r| r["action"] == action }.map { |r| r["key"] }
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def apply(rows)
|
|
72
|
+
out = { dropped: [], archived: [], refreshed: [], failed: [] }
|
|
73
|
+
delete = Write::KeyDelete.new(container: @container, call: @call)
|
|
74
|
+
refresh = Write::FetchWorker.new(container: @container, call: @call)
|
|
75
|
+
|
|
76
|
+
rows.each do |row|
|
|
77
|
+
key = row["key"]
|
|
78
|
+
begin
|
|
79
|
+
case row["action"]
|
|
80
|
+
when "drop"
|
|
81
|
+
delete.call(key)
|
|
82
|
+
out[:dropped] << key
|
|
83
|
+
when "archive"
|
|
84
|
+
archive_leaf(row)
|
|
85
|
+
delete.call(key)
|
|
86
|
+
out[:archived] << key
|
|
87
|
+
when "refresh"
|
|
88
|
+
refresh.run(key)
|
|
89
|
+
out[:refreshed] << key
|
|
90
|
+
end
|
|
91
|
+
rescue Textus::Error => e
|
|
92
|
+
out[:failed] << { "key" => key, "error" => e.message }
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
out
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Copy the leaf into <store>/archive/<relative-path> before deletion.
|
|
99
|
+
# (Lifted from the retired RetentionSweep#archive_leaf.)
|
|
100
|
+
def archive_leaf(row)
|
|
101
|
+
src = row["path"]
|
|
102
|
+
root = @container.root.to_s
|
|
103
|
+
rel = src.delete_prefix("#{root}/")
|
|
104
|
+
dest = File.join(root, "archive", rel)
|
|
105
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
106
|
+
FileUtils.cp(src, dest)
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|