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
|
@@ -2,22 +2,28 @@ require "time"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Read
|
|
5
|
-
# Per-entry
|
|
6
|
-
# the manifest
|
|
7
|
-
#
|
|
8
|
-
# :
|
|
5
|
+
# Per-entry staleness scan (ADR 0079, 0085, 0093). Walks every entry declared
|
|
6
|
+
# in the manifest and reports a staleness verdict sourced from the two new
|
|
7
|
+
# policy slots (ADR 0093):
|
|
8
|
+
# - intake entries: `entry.source.ttl_seconds` is the re-pull cadence;
|
|
9
|
+
# basis = `_meta.last_fetched_at` (else file mtime). Past ttl ⇒ :expired.
|
|
10
|
+
# - entries matched by a `retention:` rule: `retention.ttl_seconds` is the
|
|
11
|
+
# GC age; basis = file mtime. Past ttl ⇒ :expired (:action = drop/archive).
|
|
12
|
+
# Intake cadence wins when both apply (freshness is content currency; GC dueness
|
|
13
|
+
# shows via `drain --dry-run`).
|
|
14
|
+
# Status is one of :fresh, :expired, or :no_policy; the row also carries
|
|
15
|
+
# :action (:refresh for intake, :drop/:archive for retention).
|
|
9
16
|
#
|
|
10
17
|
# ADR 0085 removed the public `freshness` verb: there is no `:cli`/`:mcp`
|
|
11
|
-
# surface. This is now a Ruby-only internal scan
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
#
|
|
15
|
-
# (the ttl / on_expire policy) instead of a dedicated verb.
|
|
18
|
+
# surface. This is now a Ruby-only internal scan consumed by `pulse` (which
|
|
19
|
+
# derives `stale` + `next_due_at` from it) and the hook `Context`. Humans drill
|
|
20
|
+
# into per-entry staleness detail via `get` (last_fetched_at) + `rule_explain`
|
|
21
|
+
# (the ttl / action policy) instead of a dedicated verb.
|
|
16
22
|
class Freshness
|
|
17
23
|
extend Textus::Contract::DSL
|
|
18
24
|
|
|
19
25
|
verb :freshness
|
|
20
|
-
summary "Internal per-entry lifecycle scan (status, age, ttl,
|
|
26
|
+
summary "Internal per-entry lifecycle scan (status, age, ttl, action); backs pulse + hook context. No public surface (ADR 0085)."
|
|
21
27
|
arg :prefix, String, required: false, description: "filter to keys with this prefix"
|
|
22
28
|
arg :zone, String, required: false, description: "filter to entries in this zone"
|
|
23
29
|
|
|
@@ -54,29 +60,59 @@ module Textus
|
|
|
54
60
|
private
|
|
55
61
|
|
|
56
62
|
def row_for(mentry)
|
|
57
|
-
policy = lifecycle_for(mentry.key)
|
|
58
63
|
envelope = safe_get(mentry.key)
|
|
59
64
|
last = envelope&.meta&.dig("last_fetched_at")
|
|
65
|
+
ttl, action = policy_for(mentry)
|
|
66
|
+
return base_row(mentry, last).merge(status: :no_policy) if ttl.nil?
|
|
60
67
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
expired, reason = Textus::Domain::Lifecycle.verdict(
|
|
64
|
-
policy: policy,
|
|
65
|
-
last_fetched_at: last,
|
|
66
|
-
mtime: mtime_for(mentry.key),
|
|
67
|
-
now: @call.now,
|
|
68
|
-
)
|
|
68
|
+
basis = basis_for(mentry)
|
|
69
|
+
expired = expired?(mentry, basis, ttl)
|
|
69
70
|
base_row(mentry, last).merge(
|
|
70
|
-
ttl_seconds:
|
|
71
|
-
action:
|
|
71
|
+
ttl_seconds: ttl,
|
|
72
|
+
action: action,
|
|
72
73
|
status: expired ? :expired : :fresh,
|
|
73
|
-
|
|
74
|
-
next_due_at: next_due(last, policy.ttl_seconds),
|
|
74
|
+
next_due_at: basis.nil? ? nil : (basis + ttl).utc.iso8601,
|
|
75
75
|
)
|
|
76
76
|
end
|
|
77
77
|
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
# ADR 0093: staleness comes from the intake re-pull cadence (source.ttl)
|
|
79
|
+
# or a retention GC rule (retention.ttl). Intake cadence wins when an entry
|
|
80
|
+
# has both (freshness is about content currency; GC dueness still shows via
|
|
81
|
+
# `drain --dry-run`). Returns [ttl_seconds, action] or [nil, nil].
|
|
82
|
+
def policy_for(mentry)
|
|
83
|
+
if mentry.intake?
|
|
84
|
+
ttl = mentry.source.ttl_seconds
|
|
85
|
+
return [ttl, :refresh] unless ttl.nil?
|
|
86
|
+
end
|
|
87
|
+
ret = @manifest.rules.for(mentry.key).retention
|
|
88
|
+
return [ret.ttl_seconds, ret.action] unless ret.nil?
|
|
89
|
+
|
|
90
|
+
[nil, nil]
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Intake currency basis comes from the evaluator (single definition);
|
|
94
|
+
# retention dueness is keyed off file mtime.
|
|
95
|
+
def basis_for(mentry)
|
|
96
|
+
return evaluator.intake_basis(mentry) if mentry.intake? && mentry.source.ttl_seconds
|
|
97
|
+
|
|
98
|
+
mtime_for(mentry.key)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def expired?(mentry, basis, ttl)
|
|
102
|
+
if mentry.intake? && mentry.source.ttl_seconds
|
|
103
|
+
evaluator.verdict(mentry).stale
|
|
104
|
+
else
|
|
105
|
+
# Preserve pre-0099 pulse semantics: a never-recorded retention entry
|
|
106
|
+
# (no file => nil basis) is past due. Retention::Sweep.expired? alone
|
|
107
|
+
# returns false on nil mtime (it runs post-exists? in the sweep).
|
|
108
|
+
basis.nil? || Textus::Domain::Retention::Sweep.expired?(ttl_seconds: ttl, mtime: basis, now: @call.now)
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
def evaluator
|
|
113
|
+
@evaluator ||= Textus::Domain::Freshness::Evaluator.new(
|
|
114
|
+
manifest: @manifest, file_stat: Textus::Ports::Storage::FileStat.new, clock: @call,
|
|
115
|
+
)
|
|
80
116
|
end
|
|
81
117
|
|
|
82
118
|
def mtime_for(key)
|
|
@@ -111,12 +147,6 @@ module Textus
|
|
|
111
147
|
rescue Textus::Error
|
|
112
148
|
nil
|
|
113
149
|
end
|
|
114
|
-
|
|
115
|
-
def next_due(last, ttl)
|
|
116
|
-
return nil if last.nil? || ttl.nil?
|
|
117
|
-
|
|
118
|
-
(Time.parse(last) + ttl).utc.iso8601
|
|
119
|
-
end
|
|
120
150
|
end
|
|
121
151
|
end
|
|
122
152
|
end
|
data/lib/textus/read/get.rb
CHANGED
|
@@ -1,119 +1,59 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
|
-
# The one read path
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# hands off to the fetch orchestrator. A read NEVER performs a
|
|
10
|
-
# destructive action (drop/archive) — those belong to the `tend` sweep
|
|
11
|
-
# (ADR 0079).
|
|
12
|
-
#
|
|
13
|
-
# Lifecycle policy comes from the unified `lifecycle:` rule slot (ADR 0079).
|
|
3
|
+
# The one read path — a pure read (ADR 0089, 0093): the on-disk envelope
|
|
4
|
+
# annotated with a freshness annotation. It NEVER mutates and NEVER ingests.
|
|
5
|
+
# Quarantine freshness is system-pushed via `drain` (scheduled sweep) and
|
|
6
|
+
# `hook run` (event push). Lifecycle is removed from the get path (ADR 0093):
|
|
7
|
+
# intake cadence lives in `source.ttl`; GC lives in `retention:` rules; both
|
|
8
|
+
# are evaluated exclusively by the `drain` sweep, not by a read.
|
|
14
9
|
class Get
|
|
15
10
|
extend Textus::Contract::DSL
|
|
16
11
|
|
|
17
12
|
verb :get
|
|
18
|
-
summary "Read one entry
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"_meta, body, freshness)."
|
|
13
|
+
summary "Read one entry — a pure on-disk read annotated with a freshness " \
|
|
14
|
+
"verdict; never ingests (quarantine freshness is drain + hook " \
|
|
15
|
+
"only, ADR 0089). Returns the envelope (uid, etag, _meta, body, " \
|
|
16
|
+
"freshness)."
|
|
23
17
|
surfaces :cli, :mcp
|
|
24
18
|
arg :key, String, required: true, positional: true,
|
|
25
19
|
description: "dotted entry key to read, e.g. 'knowledge.project'"
|
|
26
|
-
arg :fetch, :boolean, default: true,
|
|
27
|
-
description: "read-through (refresh on stale per the " \
|
|
28
|
-
"entry's lifecycle rule) when true, the default; " \
|
|
29
|
-
"false returns the on-disk envelope without ever fetching"
|
|
30
20
|
view { |v, _i| v.to_h_for_wire }
|
|
31
21
|
|
|
32
|
-
def initialize(container:, call:,
|
|
22
|
+
def initialize(container:, call:, file_stat: Textus::Ports::Storage::FileStat.new)
|
|
33
23
|
@container = container
|
|
34
24
|
@call = call
|
|
35
25
|
@manifest = container.manifest
|
|
36
26
|
@file_store = container.file_store
|
|
37
27
|
@file_stat = file_stat
|
|
38
|
-
@orchestrator = orchestrator # nil → built lazily on first fetch only
|
|
39
28
|
end
|
|
40
29
|
|
|
41
|
-
def call(key
|
|
42
|
-
|
|
43
|
-
return envelope if envelope.nil?
|
|
44
|
-
return envelope unless fetch && envelope.freshness&.stale
|
|
45
|
-
|
|
46
|
-
policy = lifecycle_for(key)
|
|
47
|
-
return envelope unless policy&.on_expire == :refresh # only refresh acts on a read
|
|
48
|
-
|
|
49
|
-
verdict = Textus::Domain::Freshness::Verdict.stale(envelope.freshness.reason)
|
|
50
|
-
outcome = orchestrator.execute(refresh_policy(policy).decide(verdict), key: key)
|
|
51
|
-
resolve(outcome, envelope)
|
|
30
|
+
def call(key)
|
|
31
|
+
annotated_envelope(key)
|
|
52
32
|
end
|
|
53
33
|
|
|
54
34
|
# Strict variant: raises UnknownKey when the entry is missing.
|
|
55
35
|
# Used by consumers (e.g. uid, Validator) that distinguish absence.
|
|
56
|
-
def get(key
|
|
57
|
-
call(key
|
|
36
|
+
def get(key)
|
|
37
|
+
call(key) ||
|
|
58
38
|
raise(UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)))
|
|
59
39
|
end
|
|
60
40
|
|
|
61
41
|
private
|
|
62
42
|
|
|
63
|
-
# Pure read + unified lifecycle verdict; no orchestrator dependency.
|
|
64
43
|
def annotated_envelope(key)
|
|
65
44
|
envelope = read_raw_envelope(key)
|
|
66
45
|
return nil if envelope.nil?
|
|
67
46
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
expired, reason = Textus::Domain::Lifecycle.verdict(
|
|
72
|
-
policy: policy,
|
|
73
|
-
last_fetched_at: envelope.meta&.dig("last_fetched_at"),
|
|
74
|
-
mtime: mtime_for(key),
|
|
75
|
-
now: @call.now,
|
|
76
|
-
)
|
|
77
|
-
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
78
|
-
stale: expired, reason: reason, fetching: false,
|
|
79
|
-
))
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
def lifecycle_for(key)
|
|
83
|
-
@manifest.rules.for(key).lifecycle
|
|
47
|
+
entry = @manifest.resolver.resolve(key).entry
|
|
48
|
+
envelope.with(freshness: evaluator.verdict(entry))
|
|
84
49
|
end
|
|
85
50
|
|
|
86
|
-
def
|
|
87
|
-
Textus::Domain::Freshness::
|
|
88
|
-
|
|
89
|
-
on_stale: policy.budget_ms ? :timed_sync : :sync,
|
|
90
|
-
sync_budget_ms: policy.budget_ms,
|
|
51
|
+
def evaluator
|
|
52
|
+
@evaluator ||= Textus::Domain::Freshness::Evaluator.new(
|
|
53
|
+
manifest: @manifest, file_stat: @file_stat, clock: @call,
|
|
91
54
|
)
|
|
92
55
|
end
|
|
93
56
|
|
|
94
|
-
def mtime_for(key)
|
|
95
|
-
path = @manifest.resolver.resolve(key).path
|
|
96
|
-
@file_stat.exists?(path) ? @file_stat.mtime(path) : nil
|
|
97
|
-
rescue Textus::Error
|
|
98
|
-
nil
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
def resolve(outcome, envelope)
|
|
102
|
-
case outcome
|
|
103
|
-
when Textus::Domain::Outcome::Skipped
|
|
104
|
-
envelope
|
|
105
|
-
when Textus::Domain::Outcome::Fetched
|
|
106
|
-
outcome.envelope.with(
|
|
107
|
-
freshness: Textus::Domain::Freshness.build(stale: false, reason: nil, fetching: false),
|
|
108
|
-
)
|
|
109
|
-
when Textus::Domain::Outcome::Detached
|
|
110
|
-
envelope.with(freshness: envelope.freshness.with(fetching: true))
|
|
111
|
-
when Textus::Domain::Outcome::Failed
|
|
112
|
-
envelope.with(freshness: envelope.freshness.with(fetch_error: outcome.error.message))
|
|
113
|
-
else raise "unexpected fetch outcome: #{outcome.class}"
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
57
|
def read_raw_envelope(key)
|
|
118
58
|
res = @manifest.resolver.resolve(key)
|
|
119
59
|
mentry = res.entry
|
|
@@ -128,28 +68,6 @@ module Textus
|
|
|
128
68
|
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
129
69
|
)
|
|
130
70
|
end
|
|
131
|
-
|
|
132
|
-
def annotate_fresh(envelope)
|
|
133
|
-
envelope.with(freshness: Textus::Domain::Freshness.build(
|
|
134
|
-
stale: false, reason: nil, fetching: false,
|
|
135
|
-
))
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def orchestrator
|
|
139
|
-
@orchestrator ||= build_orchestrator
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def build_orchestrator
|
|
143
|
-
worker = Textus::Write::FetchWorker.new(container: @container, call: @call)
|
|
144
|
-
Textus::Write::FetchOrchestrator.new(
|
|
145
|
-
worker: worker, store_root: @container.root, events: @container.events,
|
|
146
|
-
hook_context: hook_context
|
|
147
|
-
)
|
|
148
|
-
end
|
|
149
|
-
|
|
150
|
-
def hook_context
|
|
151
|
-
@hook_context ||= Textus::Hooks::Context.for(container: @container, call: @call)
|
|
152
|
-
end
|
|
153
71
|
end
|
|
154
72
|
end
|
|
155
73
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
# Inspect and operate the job queue: list ids by state, retry a dead-lettered
|
|
4
|
+
# job, or purge a state. The agent's window into deferred convergence work.
|
|
5
|
+
class Jobs
|
|
6
|
+
extend Textus::Contract::DSL
|
|
7
|
+
|
|
8
|
+
verb :jobs
|
|
9
|
+
summary "List queued jobs by state; retry a dead-lettered job or purge a state."
|
|
10
|
+
surfaces :cli, :mcp
|
|
11
|
+
cli "jobs"
|
|
12
|
+
arg :state, String, default: "ready", description: "ready|leased|done|failed"
|
|
13
|
+
arg :action, String, default: nil, description: "retry|purge (optional)"
|
|
14
|
+
arg :job_id, String, default: nil, description: "job id (required for action=retry)"
|
|
15
|
+
|
|
16
|
+
def initialize(container:, call:)
|
|
17
|
+
@container = container
|
|
18
|
+
@call = call
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def call(state: "ready", action: nil, job_id: nil)
|
|
22
|
+
queue = Textus::Ports::Queue.new(root: @container.root)
|
|
23
|
+
case action
|
|
24
|
+
when "retry" then queue.retry_failed(job_id)
|
|
25
|
+
when "purge" then queue.purge(state)
|
|
26
|
+
end
|
|
27
|
+
{ "protocol" => Textus::PROTOCOL, "ok" => true, "state" => state, "jobs" => queue.list(state) }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/textus/read/rdeps.rb
CHANGED
|
@@ -21,12 +21,12 @@ module Textus
|
|
|
21
21
|
|
|
22
22
|
def dependents_of(key)
|
|
23
23
|
@manifest.data.entries.each_with_object([]) do |e, acc|
|
|
24
|
-
next unless e.
|
|
24
|
+
next unless e.derived?
|
|
25
25
|
|
|
26
26
|
src = e.source
|
|
27
|
-
sources = if src.
|
|
27
|
+
sources = if src.projection?
|
|
28
28
|
Array(src.select).compact
|
|
29
|
-
elsif src.
|
|
29
|
+
elsif src.external?
|
|
30
30
|
Array(src.sources).compact
|
|
31
31
|
else
|
|
32
32
|
[]
|
|
@@ -14,9 +14,9 @@ module Textus
|
|
|
14
14
|
surfaces :cli, :mcp
|
|
15
15
|
cli "rule explain"
|
|
16
16
|
arg :key, String, required: true, positional: true,
|
|
17
|
-
description: "dotted key whose effective rules you want (
|
|
17
|
+
description: "dotted key whose effective rules you want (lifecycle ttl/action, write guard, ...)"
|
|
18
18
|
arg :detail, :boolean,
|
|
19
|
-
description: "defaults false: lean {
|
|
19
|
+
description: "defaults false: lean {lifecycle, guard}. detail: true adds matched blocks + guard predicates per transition."
|
|
20
20
|
view(:cli) { |r| { "verb" => "rule_explain" }.merge(r.transform_keys(&:to_s)) }
|
|
21
21
|
|
|
22
22
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
@@ -24,6 +24,16 @@ module Textus
|
|
|
24
24
|
@schemas = container.schemas
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
REGISTRY = Textus::Manifest::Schema::FIELD_REGISTRY
|
|
28
|
+
# Field membership is registry-driven (WS3). Lean shows the fields tagged
|
|
29
|
+
# for :lean; detail's matched_blocks flag every :detail field. The
|
|
30
|
+
# `effective` value-block shows the instantiated-policy fields (those with
|
|
31
|
+
# a policy_class) — guard, being a raw deferred field, is surfaced through
|
|
32
|
+
# the dedicated `guards:` predicate section instead.
|
|
33
|
+
LEAN_FIELDS = REGISTRY.select { |_, m| m[:in_rule_explain].include?(:lean) }.keys.freeze
|
|
34
|
+
DETAIL_FIELDS = REGISTRY.select { |_, m| m[:in_rule_explain].include?(:detail) }.keys.freeze
|
|
35
|
+
EFFECTIVE_FIELDS = DETAIL_FIELDS.select { |f| REGISTRY[f][:policy_class] }.freeze
|
|
36
|
+
|
|
27
37
|
def call(key, detail: false)
|
|
28
38
|
detail ? explain(key) : effective(key)
|
|
29
39
|
end
|
|
@@ -33,14 +43,17 @@ module Textus
|
|
|
33
43
|
# Lean: the effective winners only (formerly Read::Rules / the `rules` verb).
|
|
34
44
|
def effective(key)
|
|
35
45
|
set = @manifest.rules.for(key)
|
|
36
|
-
{
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
46
|
+
LEAN_FIELDS.each_with_object({}) do |field, out|
|
|
47
|
+
value = set.public_send(field)
|
|
48
|
+
out[field.to_s] = lean_value(field, value) unless value.nil?
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def lean_value(field, value)
|
|
53
|
+
case field
|
|
54
|
+
when :retention then retention_hash(value, string_keys: true)
|
|
55
|
+
else value
|
|
56
|
+
end
|
|
44
57
|
end
|
|
45
58
|
|
|
46
59
|
# Verbose: every matching block, per-slot effective value, and the
|
|
@@ -54,25 +67,30 @@ module Textus
|
|
|
54
67
|
{
|
|
55
68
|
key: key,
|
|
56
69
|
matched_blocks: matching.map do |b|
|
|
57
|
-
{
|
|
58
|
-
match: b.match,
|
|
59
|
-
lifecycle: !b.lifecycle.nil?,
|
|
60
|
-
handler_allowlist: !b.handler_allowlist.nil?,
|
|
61
|
-
guard: !b.guard.nil?,
|
|
62
|
-
}
|
|
70
|
+
{ match: b.match }.merge(DETAIL_FIELDS.to_h { |f| [f, !b.public_send(f).nil?] })
|
|
63
71
|
end,
|
|
64
|
-
effective: {
|
|
65
|
-
lifecycle: winners.lifecycle && {
|
|
66
|
-
ttl_seconds: winners.lifecycle.ttl_seconds,
|
|
67
|
-
on_expire: winners.lifecycle.on_expire,
|
|
68
|
-
},
|
|
69
|
-
handler_allowlist: winners.handler_allowlist&.handlers,
|
|
70
|
-
},
|
|
72
|
+
effective: EFFECTIVE_FIELDS.to_h { |f| [f, effective_value(f, winners.public_send(f))] },
|
|
71
73
|
guards: Textus::Domain::Policy::BaseGuards::BASE.keys.to_h do |transition|
|
|
72
74
|
[transition, factory.for(transition, key).predicates.map(&:name)]
|
|
73
75
|
end,
|
|
74
76
|
}
|
|
75
77
|
end
|
|
78
|
+
|
|
79
|
+
def effective_value(field, value)
|
|
80
|
+
return nil if value.nil?
|
|
81
|
+
|
|
82
|
+
case field
|
|
83
|
+
when :retention then retention_hash(value, string_keys: false)
|
|
84
|
+
when :handler_allowlist then value.handlers
|
|
85
|
+
else value
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# ADR 0093: retention is a flat GC policy (ttl + drop/archive action).
|
|
90
|
+
def retention_hash(retention, string_keys:)
|
|
91
|
+
h = { ttl_seconds: retention.ttl_seconds, action: retention.action }
|
|
92
|
+
string_keys ? h.transform_keys(&:to_s) : h
|
|
93
|
+
end
|
|
76
94
|
end
|
|
77
95
|
end
|
|
78
96
|
end
|
|
@@ -17,21 +17,38 @@ module Textus
|
|
|
17
17
|
@manifest = container.manifest
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Fields shown here are driven by FIELD_REGISTRY (in_rule_list); only the
|
|
21
|
+
# per-field serialization below is field-specific.
|
|
22
|
+
LIST_FIELDS = Textus::Manifest::Schema::FIELD_REGISTRY.select { |_, m| m[:in_rule_list] }.keys.freeze
|
|
23
|
+
|
|
20
24
|
def call
|
|
21
25
|
@manifest.rules.blocks.map do |b|
|
|
22
26
|
row = { "match" => b.match }
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"on_expire" => b.lifecycle.on_expire,
|
|
27
|
-
"budget_ms" => b.lifecycle.budget_ms,
|
|
28
|
-
}
|
|
27
|
+
LIST_FIELDS.each do |field|
|
|
28
|
+
value = b.public_send(field)
|
|
29
|
+
row[field.to_s] = serialize(field, value) unless value.nil?
|
|
29
30
|
end
|
|
30
|
-
row["handler_allowlist"] = b.handler_allowlist.handlers if b.handler_allowlist
|
|
31
|
-
row["guard"] = b.guard if b.guard
|
|
32
31
|
row
|
|
33
32
|
end
|
|
34
33
|
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def serialize(field, value)
|
|
38
|
+
case field
|
|
39
|
+
when :retention
|
|
40
|
+
serialize_retention(value)
|
|
41
|
+
when :handler_allowlist
|
|
42
|
+
value.handlers
|
|
43
|
+
else
|
|
44
|
+
value
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# ADR 0093: retention is a flat GC policy.
|
|
49
|
+
def serialize_retention(retention)
|
|
50
|
+
{ "ttl_seconds" => retention.ttl_seconds, "action" => retention.action.to_s }
|
|
51
|
+
end
|
|
35
52
|
end
|
|
36
53
|
end
|
|
37
54
|
end
|
|
@@ -1,6 +1,20 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
|
+
# Store-wide schema + role-authority validation: walks every entry and runs
|
|
4
|
+
# the Validator over it. Consumed internally by `doctor`'s schema_violations
|
|
5
|
+
# check and exposed as a Ruby store method (`store.validate_all`).
|
|
6
|
+
#
|
|
7
|
+
# Ruby-only, like `Read::Freshness`: it declares a contract (so it round-trips
|
|
8
|
+
# through the routing<->contract bijection, ADR 0105) but omits `surfaces`, so
|
|
9
|
+
# it gets no CLI or MCP projection. The public `validate-all` CLI verb was
|
|
10
|
+
# removed in v0.5 (`doctor --check=schema_violations` replaces it).
|
|
3
11
|
class ValidateAll
|
|
12
|
+
extend Textus::Contract::DSL
|
|
13
|
+
|
|
14
|
+
verb :validate_all
|
|
15
|
+
summary "Internal store-wide schema + role-authority validation; backs " \
|
|
16
|
+
"doctor's schema_violations check. No public surface (ADR 0105)."
|
|
17
|
+
|
|
4
18
|
def initialize(container:, call:)
|
|
5
19
|
@container = container
|
|
6
20
|
@call = call
|
data/lib/textus/role.rb
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Role
|
|
3
3
|
# The three role archetypes, each string sourced exactly once: human curates
|
|
4
|
-
# canon, agent proposes, automation
|
|
4
|
+
# canon, agent proposes, automation converges the machine-maintained lanes
|
|
5
|
+
# (refresh + materialize) (explanation/concepts.md).
|
|
5
6
|
# Reference these constants instead of bare literals (ADR 0044).
|
|
6
7
|
HUMAN = "human".freeze
|
|
7
8
|
AGENT = "agent".freeze
|
data/lib/textus/schemas.rb
CHANGED
|
@@ -24,6 +24,14 @@ module Textus
|
|
|
24
24
|
@schemas.values
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
+
# Name-keyed view: { canonical_name => Schema }. The key is the schema's
|
|
28
|
+
# file stem, which is authoritative even when a schema file carries no
|
|
29
|
+
# top-level `name:` (Schema#name reads the body and may be nil). Symmetric
|
|
30
|
+
# with #all (values); use this when you need the names too.
|
|
31
|
+
def by_name
|
|
32
|
+
@schemas.dup
|
|
33
|
+
end
|
|
34
|
+
|
|
27
35
|
private
|
|
28
36
|
|
|
29
37
|
def load_all
|
data/lib/textus/store.rb
CHANGED
|
@@ -90,6 +90,7 @@ module Textus
|
|
|
90
90
|
|
|
91
91
|
def bootstrap_hooks
|
|
92
92
|
Ports::AuditSubscriber.new(audit_log).attach(events)
|
|
93
|
+
Ports::ProduceOnWriteSubscriber.new(container).attach(events)
|
|
93
94
|
Hooks::Builtin.register_all(events: events, rpc: rpc)
|
|
94
95
|
Hooks::Loader.new(events: events, rpc: rpc).load_dir(File.join(root, "hooks"))
|
|
95
96
|
end
|
data/lib/textus/version.rb
CHANGED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Write
|
|
3
|
+
# Push a job of a REGISTERED type onto the convergence queue, to be run by
|
|
4
|
+
# drain/serve. The closed allow-list (Jobs::Handlers.registry) is the safety
|
|
5
|
+
# boundary: an unregistered type is refused, so the general runner can never
|
|
6
|
+
# execute arbitrary code. Authority is checked here (the caller must hold the
|
|
7
|
+
# type's required_role, if any) and frozen onto the job's `enqueued_by` — the
|
|
8
|
+
# worker runs it as exactly this role, no escalation via the queue.
|
|
9
|
+
class Enqueue
|
|
10
|
+
extend Textus::Contract::DSL
|
|
11
|
+
|
|
12
|
+
verb :enqueue
|
|
13
|
+
summary "Push a registered job type onto the convergence queue, to be run by drain/serve."
|
|
14
|
+
surfaces :cli, :mcp
|
|
15
|
+
cli "enqueue"
|
|
16
|
+
arg :type, String, required: true, positional: true, description: "registered job type (e.g. materialize, re-pull, sweep)"
|
|
17
|
+
arg :args, Hash, positional: true, default: {}, description: "type-specific arguments (e.g. { key: ... } or { scope: ... })"
|
|
18
|
+
|
|
19
|
+
def initialize(container:, call:)
|
|
20
|
+
@container = container
|
|
21
|
+
@call = call
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call(type, args = {})
|
|
25
|
+
entry = Textus::Jobs::Handlers.registry.lookup(type) # raises UsageError for unregistered types
|
|
26
|
+
authorize!(entry)
|
|
27
|
+
|
|
28
|
+
job = Textus::Domain::Jobs::Job.new(
|
|
29
|
+
type: type, args: args, enqueued_by: @call.role, max_attempts: entry.max_attempts,
|
|
30
|
+
)
|
|
31
|
+
Textus::Ports::Queue.new(root: @container.root).enqueue(job)
|
|
32
|
+
{ "protocol" => Textus::PROTOCOL, "ok" => true, "id" => job.id }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def authorize!(entry)
|
|
38
|
+
required = entry.required_role
|
|
39
|
+
return if required.nil? || @call.role == required
|
|
40
|
+
|
|
41
|
+
raise Textus::Error.new(
|
|
42
|
+
"forbidden",
|
|
43
|
+
"role '#{@call.role}' is not authorized to enqueue this job type (requires '#{required}')",
|
|
44
|
+
details: { "role" => @call.role, "required_role" => required },
|
|
45
|
+
exit_code: 77,
|
|
46
|
+
)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|