textus 0.55.1 → 0.55.2
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 +1 -1
- data/README.md +9 -9
- data/SPEC.md +14 -13
- data/docs/architecture/README.md +3 -3
- data/docs/reference/conventions.md +5 -2
- data/lib/textus/boot.rb +64 -85
- data/lib/textus/{gate → dispatch}/binder.rb +8 -10
- data/lib/textus/dispatch/contracts.rb +63 -0
- data/lib/textus/dispatch/handler_registry.rb +21 -0
- data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
- data/lib/textus/dispatch/middleware/auth.rb +40 -0
- data/lib/textus/dispatch/middleware/base.rb +26 -0
- data/lib/textus/dispatch/middleware/binder.rb +20 -0
- data/lib/textus/dispatch/middleware/cascade.rb +53 -0
- data/lib/textus/dispatch/pipeline.rb +35 -0
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/generator_drift.rb +2 -2
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
- data/lib/textus/doctor/check/sentinels.rb +1 -1
- data/lib/textus/doctor/check.rb +8 -6
- data/lib/textus/doctor.rb +1 -1
- data/lib/textus/errors.rb +2 -0
- data/lib/textus/format/base.rb +36 -8
- data/lib/textus/format/json.rb +0 -21
- data/lib/textus/format/markdown.rb +0 -21
- data/lib/textus/format/yaml.rb +0 -21
- data/lib/textus/format.rb +16 -1
- data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
- data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
- data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
- data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
- data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
- data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
- data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
- data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
- data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
- data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
- data/lib/textus/handlers/read/audit_entries.rb +48 -0
- data/lib/textus/handlers/read/blame_entry.rb +71 -0
- data/lib/textus/handlers/read/deps_entry.rb +17 -0
- data/lib/textus/handlers/read/get_entry.rb +68 -0
- data/lib/textus/handlers/read/list_keys.rb +36 -0
- data/lib/textus/handlers/read/pulse_entries.rb +66 -0
- data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
- data/lib/textus/handlers/read/uid_entry.rb +18 -0
- data/lib/textus/handlers/read/where_entry.rb +18 -0
- data/lib/textus/handlers/write/accept_proposal.rb +39 -0
- data/lib/textus/handlers/write/data_mv.rb +55 -0
- data/lib/textus/handlers/write/delete_key.rb +17 -0
- data/lib/textus/handlers/write/enqueue_job.rb +27 -0
- data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
- data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
- data/lib/textus/handlers/write/move_key.rb +80 -0
- data/lib/textus/handlers/write/propose_entry.rb +29 -0
- data/lib/textus/handlers/write/put_entry.rb +29 -0
- data/lib/textus/handlers/write/reject_proposal.rb +29 -0
- data/lib/textus/init.rb +5 -5
- data/lib/textus/manifest/capabilities.rb +1 -1
- data/lib/textus/manifest/entry/base.rb +3 -3
- data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
- data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
- data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
- data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
- data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
- data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
- data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
- data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
- data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
- data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
- data/lib/textus/manifest/policy/predicates.rb +54 -0
- data/lib/textus/manifest/policy/retention.rb +1 -1
- data/lib/textus/orchestration.rb +55 -0
- data/lib/textus/port/audit_log.rb +6 -6
- data/lib/textus/port/build_lock.rb +1 -1
- data/lib/textus/{core → port}/sentinel.rb +1 -6
- data/lib/textus/port/sentinel_store.rb +3 -3
- data/lib/textus/port/storage/file_store.rb +23 -0
- data/lib/textus/port/storage/interface.rb +17 -0
- data/lib/textus/port/store.rb +58 -2
- data/lib/textus/port/watcher_lock.rb +2 -2
- data/lib/textus/produce/engine.rb +1 -11
- data/lib/textus/produce/publisher.rb +21 -0
- data/lib/textus/schema/registry.rb +42 -0
- data/lib/textus/schema/tools.rb +3 -10
- data/lib/textus/store/container.rb +140 -10
- data/lib/textus/store/cursor.rb +1 -1
- data/lib/textus/store/{envelope → entry}/reader.rb +8 -4
- data/lib/textus/store/{envelope → entry}/writer.rb +53 -29
- data/lib/textus/store/envelope/meta.rb +61 -0
- data/lib/textus/store/freshness/drift_detector.rb +93 -0
- data/lib/textus/store/freshness/evaluator.rb +20 -0
- data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
- data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
- data/lib/textus/store/freshness.rb +8 -0
- data/lib/textus/store/index/builder.rb +5 -3
- data/lib/textus/store/jobs/planner.rb +27 -7
- data/lib/textus/store/jobs/queue.rb +9 -1
- data/lib/textus/store/jobs/retention/base.rb +52 -0
- data/lib/textus/store/jobs/retention/sweep.rb +55 -0
- data/lib/textus/store/jobs/retention.rb +1 -43
- data/lib/textus/store/jobs/sweep.rb +2 -2
- data/lib/textus/store/{geometry.rb → layout.rb} +19 -3
- data/lib/textus/store.rb +53 -30
- data/lib/textus/surface/cli/runner.rb +8 -9
- data/lib/textus/surface/cli/verb/doctor.rb +3 -2
- data/lib/textus/surface/cli/verb/get.rb +5 -3
- data/lib/textus/surface/cli/verb/put.rb +5 -3
- data/lib/textus/surface/mcp/catalog.rb +26 -62
- data/lib/textus/surface/mcp/errors.rb +0 -10
- data/lib/textus/surface/mcp/projector.rb +20 -0
- data/lib/textus/surface/mcp/server.rb +20 -31
- data/lib/textus/{core → value}/duration.rb +1 -4
- data/lib/textus/value/envelope.rb +5 -4
- data/lib/textus/value/etag.rb +1 -1
- data/lib/textus/value/payload.rb +7 -0
- data/lib/textus/value/result.rb +36 -16
- data/lib/textus/verb_registry.rb +417 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/loader.rb +1 -1
- data/lib/textus/workflow/runner.rb +10 -18
- data/lib/textus.rb +0 -64
- metadata +70 -70
- data/lib/textus/action/accept.rb +0 -46
- data/lib/textus/action/audit.rb +0 -94
- data/lib/textus/action/base.rb +0 -42
- data/lib/textus/action/blame.rb +0 -79
- data/lib/textus/action/boot.rb +0 -15
- data/lib/textus/action/data_mv.rb +0 -58
- data/lib/textus/action/deps.rb +0 -19
- data/lib/textus/action/doctor.rb +0 -17
- data/lib/textus/action/drain.rb +0 -31
- data/lib/textus/action/enqueue.rb +0 -37
- data/lib/textus/action/get.rb +0 -34
- data/lib/textus/action/ingest.rb +0 -199
- data/lib/textus/action/jobs.rb +0 -27
- data/lib/textus/action/key_delete.rb +0 -26
- data/lib/textus/action/key_delete_prefix.rb +0 -35
- data/lib/textus/action/key_mv.rb +0 -122
- data/lib/textus/action/key_mv_prefix.rb +0 -48
- data/lib/textus/action/list.rb +0 -28
- data/lib/textus/action/propose.rb +0 -42
- data/lib/textus/action/published.rb +0 -22
- data/lib/textus/action/pulse.rb +0 -49
- data/lib/textus/action/put.rb +0 -38
- data/lib/textus/action/rdeps.rb +0 -24
- data/lib/textus/action/reject.rb +0 -28
- data/lib/textus/action/rule_explain.rb +0 -81
- data/lib/textus/action/rule_lint.rb +0 -62
- data/lib/textus/action/rule_list.rb +0 -38
- data/lib/textus/action/schema_envelope.rb +0 -22
- data/lib/textus/action/uid.rb +0 -19
- data/lib/textus/action/where.rb +0 -21
- data/lib/textus/contract/arg.rb +0 -10
- data/lib/textus/contract/dsl.rb +0 -88
- data/lib/textus/contract/spec.rb +0 -25
- data/lib/textus/contract.rb +0 -12
- data/lib/textus/core/freshness/evaluator.rb +0 -150
- data/lib/textus/core/freshness.rb +0 -11
- data/lib/textus/core/retention/sweep.rb +0 -57
- data/lib/textus/core/retention.rb +0 -11
- data/lib/textus/format/shared.rb +0 -17
- data/lib/textus/gate/auth.rb +0 -212
- data/lib/textus/gate.rb +0 -92
- data/lib/textus/meta.rb +0 -54
- data/lib/textus/schemas.rb +0 -54
- data/lib/textus/store/compositor.rb +0 -34
- data/lib/textus/store/session.rb +0 -37
- data/lib/textus/surface/projector.rb +0 -27
- data/lib/textus/surface/role_scope.rb +0 -34
|
@@ -0,0 +1,417 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module VerbRegistry
|
|
3
|
+
ArgSpec = Data.define(
|
|
4
|
+
:name, :type, :required, :positional, :session_default,
|
|
5
|
+
:description, :wire_name, :default, :source, :coerce, :cli_default
|
|
6
|
+
) do
|
|
7
|
+
def wire = wire_name || name
|
|
8
|
+
|
|
9
|
+
# rubocop:disable Metrics/ParameterLists
|
|
10
|
+
def self.arg(name:, type: String, required: false, positional: false,
|
|
11
|
+
session_default: nil, description: nil, wire_name: nil,
|
|
12
|
+
default: nil, source: nil, coerce: nil, cli_default: nil)
|
|
13
|
+
new(name:, type:, required:, positional:, session_default:,
|
|
14
|
+
description:, wire_name:, default:, source:, coerce:, cli_default:)
|
|
15
|
+
end
|
|
16
|
+
# rubocop:enable Metrics/ParameterLists
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
TYPE_MAP = {
|
|
20
|
+
String => "string", Integer => "integer", Hash => "object",
|
|
21
|
+
Array => "array", :boolean => "boolean"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
VerbSpec = Data.define(:verb, :summary, :args, :surfaces, :views, :cli, :cli_stdin, :category) do
|
|
25
|
+
def mcp? = surfaces.include?(:mcp)
|
|
26
|
+
def cli? = surfaces.include?(:cli)
|
|
27
|
+
def view(surface = :default) = views[surface] || views.fetch(:default)
|
|
28
|
+
def cli_path = cli || verb.to_s
|
|
29
|
+
def cli_words = cli_path.split
|
|
30
|
+
def cli_group = cli_words.size > 1 ? cli_words.first : nil
|
|
31
|
+
def cli_leaf = cli_words.last
|
|
32
|
+
def required_args = args.select(&:required)
|
|
33
|
+
def read? = category == :read
|
|
34
|
+
def write? = category == :write
|
|
35
|
+
def maintenance? = category == :maintenance
|
|
36
|
+
|
|
37
|
+
def input_schema
|
|
38
|
+
props = args.to_h do |a|
|
|
39
|
+
json_type = VerbRegistry::TYPE_MAP[a.type] || "string"
|
|
40
|
+
h = { "type" => json_type }
|
|
41
|
+
h["description"] = a.description if a.description
|
|
42
|
+
[a.wire.to_s, h]
|
|
43
|
+
end
|
|
44
|
+
{ type: "object", properties: props, required: required_args.map { |a| a.wire.to_s } }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
VERBS = {}
|
|
49
|
+
POSITIONAL = {}
|
|
50
|
+
|
|
51
|
+
def self.register(spec)
|
|
52
|
+
VERBS[spec.verb] = spec
|
|
53
|
+
POSITIONAL[spec.verb] = spec.args.select(&:positional).map(&:name)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def self.for(verb) = VERBS[verb]
|
|
57
|
+
def self.positional_for(verb) = POSITIONAL[verb] || []
|
|
58
|
+
def self.summary_for(verb) = VERBS[verb]&.summary
|
|
59
|
+
def self.registered = VERBS.values
|
|
60
|
+
def self.contract_class_for(verb) = VERB_TO_CONTRACT[verb]
|
|
61
|
+
|
|
62
|
+
VERB_TO_CONTRACT = {
|
|
63
|
+
get: Dispatch::Contracts::GetEntry,
|
|
64
|
+
put: Dispatch::Contracts::PutEntry,
|
|
65
|
+
list: Dispatch::Contracts::ListKeys,
|
|
66
|
+
key_delete: Dispatch::Contracts::DeleteKey,
|
|
67
|
+
key_mv: Dispatch::Contracts::MoveKey,
|
|
68
|
+
propose: Dispatch::Contracts::ProposeEntry,
|
|
69
|
+
accept: Dispatch::Contracts::AcceptProposal,
|
|
70
|
+
reject: Dispatch::Contracts::RejectProposal,
|
|
71
|
+
enqueue: Dispatch::Contracts::EnqueueJob,
|
|
72
|
+
audit: Dispatch::Contracts::AuditEntries,
|
|
73
|
+
pulse: Dispatch::Contracts::PulseEntries,
|
|
74
|
+
blame: Dispatch::Contracts::BlameEntry,
|
|
75
|
+
where: Dispatch::Contracts::WhereEntry,
|
|
76
|
+
uid: Dispatch::Contracts::UidEntry,
|
|
77
|
+
deps: Dispatch::Contracts::DepsEntry,
|
|
78
|
+
rdeps: Dispatch::Contracts::RdepsEntry,
|
|
79
|
+
boot: Dispatch::Contracts::BootStore,
|
|
80
|
+
doctor: Dispatch::Contracts::DoctorStore,
|
|
81
|
+
published: Dispatch::Contracts::PublishedEntries,
|
|
82
|
+
rule_explain: Dispatch::Contracts::RuleExplain,
|
|
83
|
+
rule_list: Dispatch::Contracts::RuleList,
|
|
84
|
+
schema_show: Dispatch::Contracts::SchemaEnvelope,
|
|
85
|
+
drain: Dispatch::Contracts::DrainStore,
|
|
86
|
+
ingest: Dispatch::Contracts::IngestEntry,
|
|
87
|
+
jobs: Dispatch::Contracts::JobsAction,
|
|
88
|
+
rule_lint: Dispatch::Contracts::RuleLint,
|
|
89
|
+
data_mv: Dispatch::Contracts::DataMv,
|
|
90
|
+
key_mv_prefix: Dispatch::Contracts::KeyMvPrefix,
|
|
91
|
+
key_delete_prefix: Dispatch::Contracts::KeyDeletePrefix,
|
|
92
|
+
}.freeze
|
|
93
|
+
|
|
94
|
+
CONTRACT_TO_VERB = VERB_TO_CONTRACT.invert.freeze
|
|
95
|
+
|
|
96
|
+
private_constant :VERB_TO_CONTRACT, :CONTRACT_TO_VERB
|
|
97
|
+
|
|
98
|
+
def self.contract_to_verb(klass)
|
|
99
|
+
CONTRACT_TO_VERB[klass] || klass.name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def self.contract_to_verb!(klass)
|
|
103
|
+
CONTRACT_TO_VERB.fetch(klass) { raise "unknown contract class: #{klass}" }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
identity = ->(v, _) { v }
|
|
107
|
+
|
|
108
|
+
# ── get ──────────────────────────────────────────────
|
|
109
|
+
register VerbSpec.new(
|
|
110
|
+
:get, "Read one entry — on-disk read with freshness verdict.",
|
|
111
|
+
[ArgSpec.arg(name: :key, required: true, positional: true,
|
|
112
|
+
description: "dotted entry key to read, e.g. 'knowledge.project'")],
|
|
113
|
+
%i[cli mcp], { default: ->(v, _i) { v&.to_h_for_wire } }, nil, nil, :read
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# ── put ──────────────────────────────────────────────
|
|
117
|
+
register VerbSpec.new(
|
|
118
|
+
:put, "Create or update an entry. Schema-validated. Returns {uid, etag}.",
|
|
119
|
+
[ArgSpec.arg(name: :key, required: true, positional: true,
|
|
120
|
+
description: "dotted entry key, e.g. 'knowledge.project'; must resolve to a zone the role may write"),
|
|
121
|
+
ArgSpec.arg(name: :meta, type: Hash, wire_name: :_meta,
|
|
122
|
+
description: "frontmatter; reads back as `_meta`. Schema-validated — call `schema KEY` first"),
|
|
123
|
+
ArgSpec.arg(name: :body,
|
|
124
|
+
description: "markdown/text payload for md entries; use `content` for json/yaml"),
|
|
125
|
+
ArgSpec.arg(name: :content, type: Hash,
|
|
126
|
+
description: "structured payload for json/yaml-format entries; omit (use `body`) for markdown entries"),
|
|
127
|
+
ArgSpec.arg(name: :if_etag,
|
|
128
|
+
description: "optimistic-concurrency guard; write rejected if entry changed since")],
|
|
129
|
+
%i[cli mcp], { default: ->(env, _) { { "uid" => env.uid, "etag" => env.etag } } }, nil, nil, :write
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# ── list ─────────────────────────────────────────────
|
|
133
|
+
register VerbSpec.new(
|
|
134
|
+
:list, "List keys filtered by lane and/or prefix.",
|
|
135
|
+
[ArgSpec.arg(name: :prefix,
|
|
136
|
+
description: "restrict to keys starting with this dotted prefix, e.g. 'knowledge.runbooks'"),
|
|
137
|
+
ArgSpec.arg(name: :lane,
|
|
138
|
+
description: "restrict to one lane by name (see `boot` lanes)"),
|
|
139
|
+
ArgSpec.arg(name: :q,
|
|
140
|
+
description: "full-text search query over entry content (FTS5)"),
|
|
141
|
+
ArgSpec.arg(name: :schema,
|
|
142
|
+
description: "filter to entries whose schema matches this name")],
|
|
143
|
+
%i[cli mcp], { cli: ->(rows, _) { { "entries" => rows } }, default: identity }, nil, nil, :read
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
# ── delete ───────────────────────────────────────────
|
|
147
|
+
register VerbSpec.new(
|
|
148
|
+
:key_delete, "Delete one entry by key. Returns {ok, key, deleted}.",
|
|
149
|
+
[ArgSpec.arg(name: :key, required: true, positional: true,
|
|
150
|
+
description: "dotted entry key to delete"),
|
|
151
|
+
ArgSpec.arg(name: :if_etag,
|
|
152
|
+
description: "optimistic-concurrency guard: etag you last read")],
|
|
153
|
+
%i[cli mcp], { default: identity }, "key delete", nil, :write
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
# ── move ─────────────────────────────────────────────
|
|
157
|
+
register VerbSpec.new(
|
|
158
|
+
:key_mv, "Rename one entry (same zone + format). Refuses if target exists.",
|
|
159
|
+
[ArgSpec.arg(name: :old_key, required: true, positional: true, description: "current dotted key"),
|
|
160
|
+
ArgSpec.arg(name: :new_key, required: true, positional: true,
|
|
161
|
+
description: "new dotted key (same zone and format)"),
|
|
162
|
+
ArgSpec.arg(name: :dry_run, type: :boolean,
|
|
163
|
+
description: "when true, returns planned move without applying")],
|
|
164
|
+
%i[cli mcp], { default: identity }, "key mv", nil, :write
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
# ── propose ──────────────────────────────────────────
|
|
168
|
+
register VerbSpec.new(
|
|
169
|
+
:propose, "Write a proposal to the role's propose_lane. Auto-prefixes the key.",
|
|
170
|
+
[ArgSpec.arg(name: :key, required: true, positional: true,
|
|
171
|
+
description: "key relative to propose_lane, e.g. 'decisions.feature-x'"),
|
|
172
|
+
ArgSpec.arg(name: :meta, type: Hash, wire_name: :_meta,
|
|
173
|
+
description: "frontmatter. Include a 'proposal:' block naming the target_key"),
|
|
174
|
+
ArgSpec.arg(name: :body,
|
|
175
|
+
description: "markdown/text payload for markdown-format entries"),
|
|
176
|
+
ArgSpec.arg(name: :content, type: Hash,
|
|
177
|
+
description: "structured payload for json/yaml-format entries")],
|
|
178
|
+
%i[cli mcp], { default: ->(env, _) { env.to_h_for_wire } }, nil, :json, :write
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
# ── accept ───────────────────────────────────────────
|
|
182
|
+
register VerbSpec.new(
|
|
183
|
+
:accept, "Apply a queued proposal to its target zone; requires author.",
|
|
184
|
+
[ArgSpec.arg(name: :pending_key, required: true, positional: true,
|
|
185
|
+
description: "the queued proposal's key")],
|
|
186
|
+
%i[cli mcp], { default: identity }, "accept", nil, :write
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
# ── reject ───────────────────────────────────────────
|
|
190
|
+
register VerbSpec.new(
|
|
191
|
+
:reject, "Discard a queued proposal without applying it.",
|
|
192
|
+
[ArgSpec.arg(name: :pending_key, required: true, positional: true,
|
|
193
|
+
description: "the queued proposal's key")],
|
|
194
|
+
%i[cli mcp], { default: identity }, "reject", nil, :write
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# ── enqueue ──────────────────────────────────────────
|
|
198
|
+
register VerbSpec.new(
|
|
199
|
+
:enqueue, "Push a registered job type onto the convergence queue.",
|
|
200
|
+
[ArgSpec.arg(name: :type, required: true, positional: true,
|
|
201
|
+
description: "registered job type (e.g. materialize, re-pull, sweep)"),
|
|
202
|
+
ArgSpec.arg(name: :args, type: Hash, default: {},
|
|
203
|
+
description: "type-specific arguments (e.g. { key: ... })")],
|
|
204
|
+
%i[cli mcp], { default: identity }, "enqueue", nil, :write
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# ── ingest ───────────────────────────────────────────
|
|
208
|
+
register VerbSpec.new(
|
|
209
|
+
:ingest, "Capture external source material into the raw lane. Write-once.",
|
|
210
|
+
[ArgSpec.arg(name: :kind, required: true, positional: true,
|
|
211
|
+
description: "source kind: url | file | asset"),
|
|
212
|
+
ArgSpec.arg(name: :slug, required: true,
|
|
213
|
+
description: "human slug for the key suffix (kebab-case)"),
|
|
214
|
+
ArgSpec.arg(name: :url, description: "remote URL (required when kind=url)"),
|
|
215
|
+
ArgSpec.arg(name: :path,
|
|
216
|
+
description: "local file path (required when kind=file or kind=asset)"),
|
|
217
|
+
ArgSpec.arg(name: :lane,
|
|
218
|
+
description: "asset group subdirectory (required when kind=asset)"),
|
|
219
|
+
ArgSpec.arg(name: :label, description: "human label stored in source.label")],
|
|
220
|
+
%i[cli mcp], { default: ->(env, _) { { "key" => env.key, "uid" => env.uid, "etag" => env.etag } } }, nil, nil, :write
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
# ── where ────────────────────────────────────────────
|
|
224
|
+
register VerbSpec.new(
|
|
225
|
+
:where, "Resolve a key to its zone, owner, and path without reading the body.",
|
|
226
|
+
[ArgSpec.arg(name: :key, required: true, positional: true, description: "dotted key to locate")],
|
|
227
|
+
%i[cli mcp], { default: identity }, nil, nil, :read
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
# ── uid ──────────────────────────────────────────────
|
|
231
|
+
register VerbSpec.new(
|
|
232
|
+
:uid, "Return the stable UID of an entry without reading its body.",
|
|
233
|
+
[ArgSpec.arg(name: :key, required: true, positional: true, description: "entry key")],
|
|
234
|
+
[:cli], { cli: ->(uid, inputs) { { "key" => inputs[:key], "uid" => uid } }, default: identity }, "key uid", nil, :read
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# ── blame ────────────────────────────────────────────
|
|
238
|
+
register VerbSpec.new(
|
|
239
|
+
:blame, "Annotate audit rows with the git commit that introduced each file state.",
|
|
240
|
+
[ArgSpec.arg(name: :key, required: true, positional: true, description: "entry key to blame"),
|
|
241
|
+
ArgSpec.arg(name: :limit, type: Integer,
|
|
242
|
+
description: "maximum number of audit rows to return")],
|
|
243
|
+
[:cli], {
|
|
244
|
+
cli: ->(rows, inputs) { { "verb" => "blame", "key" => inputs[:key], "rows" => rows } },
|
|
245
|
+
default: identity,
|
|
246
|
+
}, "blame", nil, :read
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# ── audit ────────────────────────────────────────────
|
|
250
|
+
register VerbSpec.new(
|
|
251
|
+
:audit, "Query the audit log with optional filters.",
|
|
252
|
+
[ArgSpec.arg(name: :key, description: "filter to rows for this key"),
|
|
253
|
+
ArgSpec.arg(name: :lane, description: "filter to keys in this lane"),
|
|
254
|
+
ArgSpec.arg(name: :role, description: "filter to rows written under this role"),
|
|
255
|
+
ArgSpec.arg(name: :verb, description: "filter to rows for this verb"),
|
|
256
|
+
ArgSpec.arg(name: :since,
|
|
257
|
+
description: "ISO-8601 timestamp or relative offset (e.g. 1h, 30m)"),
|
|
258
|
+
ArgSpec.arg(name: :seq_since, type: Integer,
|
|
259
|
+
description: "return rows with seq > this cursor value"),
|
|
260
|
+
ArgSpec.arg(name: :correlation_id,
|
|
261
|
+
description: "filter to rows with this correlation_id"),
|
|
262
|
+
ArgSpec.arg(name: :limit, type: Integer,
|
|
263
|
+
description: "maximum number of rows to return")],
|
|
264
|
+
[:cli], { cli: ->(rows, _) { { "verb" => "audit", "rows" => rows } }, default: identity }, "audit", nil, :read
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# ── deps ─────────────────────────────────────────────
|
|
268
|
+
register VerbSpec.new(
|
|
269
|
+
:deps, "List the keys a derived entry depends on.",
|
|
270
|
+
[ArgSpec.arg(name: :key, required: true, positional: true,
|
|
271
|
+
description: "dotted key of the derived entry whose source keys you want")],
|
|
272
|
+
%i[cli mcp], { default: identity }, nil, nil, :read
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
# ── rdeps ────────────────────────────────────────────
|
|
276
|
+
register VerbSpec.new(
|
|
277
|
+
:rdeps, "List the derived entries that depend on a key (reverse deps).",
|
|
278
|
+
[ArgSpec.arg(name: :key, required: true, positional: true,
|
|
279
|
+
description: "dotted key whose dependents you want")],
|
|
280
|
+
%i[cli mcp], { default: identity }, nil, nil, :read
|
|
281
|
+
)
|
|
282
|
+
|
|
283
|
+
# ── pulse ────────────────────────────────────────────
|
|
284
|
+
register VerbSpec.new(
|
|
285
|
+
:pulse, "Delta since cursor — changed entries, pending proposals, index freshness.",
|
|
286
|
+
[ArgSpec.arg(name: :since, type: Integer, session_default: :cursor,
|
|
287
|
+
description: "audit seq to diff from; defaults to the session cursor")],
|
|
288
|
+
%i[cli mcp], { default: identity }, nil, nil, :read
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# ── rule_explain ─────────────────────────────────────
|
|
292
|
+
register VerbSpec.new(
|
|
293
|
+
:rule_explain, "Effective rules for a key. Lean by default; detail: true adds matched blocks.",
|
|
294
|
+
[ArgSpec.arg(name: :key, required: true, positional: true,
|
|
295
|
+
description: "dotted key whose effective rules you want"),
|
|
296
|
+
ArgSpec.arg(name: :detail, type: :boolean,
|
|
297
|
+
description: "detail: true adds matched blocks + guard predicates")],
|
|
298
|
+
%i[cli mcp], {
|
|
299
|
+
cli: ->(r, _) { { "verb" => "rule_explain" }.merge(r.transform_keys(&:to_s)) },
|
|
300
|
+
default: identity,
|
|
301
|
+
}, "rule explain", nil, :read
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# ── rule_list ────────────────────────────────────────
|
|
305
|
+
register VerbSpec.new(
|
|
306
|
+
:rule_list, "List every rule block in the manifest.",
|
|
307
|
+
[], [:cli], { cli: ->(p, _) { { "verb" => "rule_list", "policies" => p } }, default: identity }, "rule list", nil, :read
|
|
308
|
+
)
|
|
309
|
+
|
|
310
|
+
# ── published ────────────────────────────────────────
|
|
311
|
+
register VerbSpec.new(
|
|
312
|
+
:published, "List all entries that declare a publish target.",
|
|
313
|
+
[], [:cli], { default: identity }, "published", nil, :read
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# ── schema_show ──────────────────────────────────────
|
|
317
|
+
register VerbSpec.new(
|
|
318
|
+
:schema_show, "Return the schema (field shape) for an entry's family.",
|
|
319
|
+
[ArgSpec.arg(name: :key, required: true, positional: true,
|
|
320
|
+
description: "any key in the family whose schema you want")],
|
|
321
|
+
%i[cli mcp], { default: identity }, "schema show", nil, :read
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# ── doctor ───────────────────────────────────────────
|
|
325
|
+
register VerbSpec.new(
|
|
326
|
+
:doctor, "Run health checks on the textus store.",
|
|
327
|
+
[ArgSpec.arg(name: :checks, type: Array,
|
|
328
|
+
description: "subset of check names to run (default: all)")],
|
|
329
|
+
[:cli], { default: identity }, "doctor", nil, :read
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
# ── boot ─────────────────────────────────────────────
|
|
333
|
+
register VerbSpec.new(
|
|
334
|
+
:boot, "Return the orientation contract: lanes, agent_quickstart, agent_protocol.",
|
|
335
|
+
[], %i[cli mcp], { default: identity }, nil, nil, :read
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
# ── jobs ─────────────────────────────────────────────
|
|
339
|
+
register VerbSpec.new(
|
|
340
|
+
:jobs, "List queued jobs by state; retry a dead-lettered job or purge.",
|
|
341
|
+
[ArgSpec.arg(name: :state, default: "ready",
|
|
342
|
+
description: "ready|leased|done|failed"),
|
|
343
|
+
ArgSpec.arg(name: :action, description: "retry|purge (optional)"),
|
|
344
|
+
ArgSpec.arg(name: :job_id, description: "job id (required for action=retry)")],
|
|
345
|
+
%i[cli mcp], { default: identity }, "jobs", nil, :maintenance
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
# ── data_mv ──────────────────────────────────────────
|
|
349
|
+
register VerbSpec.new(
|
|
350
|
+
:data_mv, "Rename a data lane — manifest + files. Refuses if destination exists.",
|
|
351
|
+
[ArgSpec.arg(name: :from, required: true, positional: true, description: "current data lane name"),
|
|
352
|
+
ArgSpec.arg(name: :to, required: true, positional: true, description: "new data lane name"),
|
|
353
|
+
ArgSpec.arg(name: :dry_run, type: :boolean, default: false,
|
|
354
|
+
description: "when true, returns planned zone move without applying")],
|
|
355
|
+
%i[cli mcp], { default: ->(v, _) { v.to_h } }, "data mv", nil, :write
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
# ── key_mv_prefix ────────────────────────────────────
|
|
359
|
+
register VerbSpec.new(
|
|
360
|
+
:key_mv_prefix, "Bulk-rename every leaf key under from_prefix to to_prefix.",
|
|
361
|
+
[ArgSpec.arg(name: :from_prefix, required: true, positional: true,
|
|
362
|
+
description: "dotted prefix whose leaf keys are renamed"),
|
|
363
|
+
ArgSpec.arg(name: :to_prefix, required: true, positional: true,
|
|
364
|
+
description: "dotted prefix the keys are renamed to"),
|
|
365
|
+
ArgSpec.arg(name: :dry_run, type: :boolean, default: false,
|
|
366
|
+
description: "when true, returns planned moves without applying")],
|
|
367
|
+
%i[cli mcp], { default: ->(v, _) { v.to_h } }, "key mv-prefix", nil, :write
|
|
368
|
+
)
|
|
369
|
+
|
|
370
|
+
# ── key_delete_prefix ────────────────────────────────
|
|
371
|
+
register VerbSpec.new(
|
|
372
|
+
:key_delete_prefix, "Bulk-delete every leaf key under prefix.",
|
|
373
|
+
[ArgSpec.arg(name: :prefix, required: true, positional: true,
|
|
374
|
+
description: "every leaf key under this dotted prefix is deleted"),
|
|
375
|
+
ArgSpec.arg(name: :dry_run, type: :boolean, default: false,
|
|
376
|
+
description: "when true, returns keys that would be deleted without deleting")],
|
|
377
|
+
%i[cli mcp], { default: ->(v, _) { v.to_h } }, "key delete-prefix", nil, :write
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
# ── drain ────────────────────────────────────────────
|
|
381
|
+
register VerbSpec.new(
|
|
382
|
+
:drain, "Seed materialize + sweep jobs then drain the queue to empty.",
|
|
383
|
+
[ArgSpec.arg(name: :prefix, description: "restrict to keys under this dotted prefix"),
|
|
384
|
+
ArgSpec.arg(name: :lane, description: "restrict to entries in this lane")],
|
|
385
|
+
%i[cli mcp], { default: identity }, nil, nil, :maintenance
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
# ── rule_lint ────────────────────────────────────────
|
|
389
|
+
register VerbSpec.new(
|
|
390
|
+
:rule_lint, "Diff candidate manifest rules against the live manifest.",
|
|
391
|
+
[ArgSpec.arg(name: :candidate_yaml, required: true,
|
|
392
|
+
wire_name: :against, source: :file,
|
|
393
|
+
description: "path to candidate manifest YAML")],
|
|
394
|
+
%i[cli mcp], { default: ->(v, _) { v.to_h } }, "rule lint", nil, :maintenance
|
|
395
|
+
)
|
|
396
|
+
end
|
|
397
|
+
end
|
|
398
|
+
|
|
399
|
+
# Generate explicit methods on Store for each registered verb so the API
|
|
400
|
+
# is statically discoverable by IDEs and documentation tools.
|
|
401
|
+
Textus::Store.class_eval do
|
|
402
|
+
Textus::VerbRegistry::VERBS.each do |verb, spec|
|
|
403
|
+
positional_names = Textus::VerbRegistry::POSITIONAL[verb] || []
|
|
404
|
+
define_method(verb) do |*args, **kwargs|
|
|
405
|
+
if args.size > positional_names.size
|
|
406
|
+
raise ArgumentError.new("#{verb} accepts #{positional_names.size} positional argument(s) (got #{args.size})")
|
|
407
|
+
end
|
|
408
|
+
|
|
409
|
+
positional_inputs = positional_names.zip(args).to_h.compact
|
|
410
|
+
inputs = positional_inputs.merge(kwargs)
|
|
411
|
+
pending = Textus::Dispatch::Binder.command(spec, inputs)
|
|
412
|
+
call = Textus::Value::Call.build(role: @role, correlation_id: @correlation_id)
|
|
413
|
+
result = @container.pipeline.dispatch(pending, call: call)
|
|
414
|
+
Textus::Value::Result.extract(result)
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
end
|
data/lib/textus/version.rb
CHANGED
|
@@ -58,27 +58,19 @@ module Textus
|
|
|
58
58
|
|
|
59
59
|
def built_in_publish(key, data, ctx)
|
|
60
60
|
normalized = Textus::Format.data_to_payload(data, ctx.entry.format)
|
|
61
|
-
|
|
62
|
-
|
|
61
|
+
guard_map = @container.manifest.rules.for(key).guard
|
|
62
|
+
rule_preds = guard_map ? Array(guard_map["converge"]) : []
|
|
63
|
+
Textus::Manifest::Policy::Predicates.evaluate(
|
|
64
|
+
manifest: @container.manifest, schemas: @container.schemas,
|
|
65
|
+
action: :converge, actor: @call.role, key: key,
|
|
66
|
+
rule_predicates: rule_preds
|
|
67
|
+
)
|
|
68
|
+
Textus::Store::Entry::Writer.from(container: @container, call: @call).put(
|
|
63
69
|
key,
|
|
64
70
|
mentry: ctx.entry,
|
|
65
|
-
payload: Textus::
|
|
66
|
-
)
|
|
67
|
-
publish_external(key, ctx)
|
|
68
|
-
end
|
|
69
|
-
|
|
70
|
-
def publish_external(key, ctx)
|
|
71
|
-
entry = ctx.entry
|
|
72
|
-
return unless entry.publish_tree || !Array(entry.publish_to).empty?
|
|
73
|
-
|
|
74
|
-
entry_path = @container.manifest.resolver.resolve(key).path
|
|
75
|
-
return unless entry.publish_tree || File.exist?(entry_path)
|
|
76
|
-
|
|
77
|
-
reader = Textus::Store::Envelope::Reader.from(container: @container)
|
|
78
|
-
pctx = Textus::Manifest::Entry::Base::PublishContext.new(
|
|
79
|
-
container: @container, call: @call, reader: reader.method(:read),
|
|
71
|
+
payload: Textus::Value::Payload.new(**normalized),
|
|
80
72
|
)
|
|
81
|
-
|
|
73
|
+
Textus::Produce::Publisher.call(container: @container, call: @call, key: key)
|
|
82
74
|
end
|
|
83
75
|
end
|
|
84
76
|
end
|
data/lib/textus.rb
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
require "zeitwerk"
|
|
2
|
-
require "dry-monads"
|
|
3
2
|
require_relative "textus/version"
|
|
4
3
|
require_relative "textus/errors"
|
|
5
4
|
require_relative "textus/surface/mcp"
|
|
@@ -18,76 +17,13 @@ loader.ignore(File.expand_path("textus/errors.rb", __dir__))
|
|
|
18
17
|
loader.ignore(File.expand_path("textus/surface/mcp.rb", __dir__))
|
|
19
18
|
loader.ignore(File.expand_path("textus/surface/mcp/errors.rb", __dir__))
|
|
20
19
|
loader.ignore(File.expand_path("textus/workflow/errors.rb", __dir__))
|
|
21
|
-
# Scaffold sources copied verbatim into user stores by `textus init`. They are
|
|
22
|
-
# templates for user-owned step classes, not gem constants — Zeitwerk must not
|
|
23
|
-
# manage or eager-load them.
|
|
24
20
|
loader.ignore(File.expand_path("textus/init/templates", __dir__))
|
|
25
21
|
loader.ignore(File.expand_path("textus/produce/acquire", __dir__))
|
|
26
22
|
loader.setup
|
|
27
23
|
loader.eager_load
|
|
28
24
|
|
|
29
|
-
# Verb symbol → Action class mapping. Replaces Textus::Dispatcher::VERBS.
|
|
30
|
-
Textus::Action::VERBS = {
|
|
31
|
-
put: Textus::Action::Put,
|
|
32
|
-
propose: Textus::Action::Propose,
|
|
33
|
-
key_delete: Textus::Action::KeyDelete,
|
|
34
|
-
key_mv: Textus::Action::KeyMv,
|
|
35
|
-
accept: Textus::Action::Accept,
|
|
36
|
-
reject: Textus::Action::Reject,
|
|
37
|
-
enqueue: Textus::Action::Enqueue,
|
|
38
|
-
get: Textus::Action::Get,
|
|
39
|
-
ingest: Textus::Action::Ingest,
|
|
40
|
-
list: Textus::Action::List,
|
|
41
|
-
where: Textus::Action::Where,
|
|
42
|
-
uid: Textus::Action::Uid,
|
|
43
|
-
blame: Textus::Action::Blame,
|
|
44
|
-
audit: Textus::Action::Audit,
|
|
45
|
-
# materialize, refresh, sweep are Worker-only — not in VERBS
|
|
46
|
-
deps: Textus::Action::Deps,
|
|
47
|
-
rdeps: Textus::Action::Rdeps,
|
|
48
|
-
pulse: Textus::Action::Pulse,
|
|
49
|
-
rule_explain: Textus::Action::RuleExplain,
|
|
50
|
-
rule_list: Textus::Action::RuleList,
|
|
51
|
-
published: Textus::Action::Published,
|
|
52
|
-
schema_show: Textus::Action::SchemaEnvelope,
|
|
53
|
-
doctor: Textus::Action::Doctor,
|
|
54
|
-
boot: Textus::Action::Boot,
|
|
55
|
-
jobs: Textus::Action::Jobs,
|
|
56
|
-
data_mv: Textus::Action::DataMv,
|
|
57
|
-
key_mv_prefix: Textus::Action::KeyMvPrefix,
|
|
58
|
-
key_delete_prefix: Textus::Action::KeyDeletePrefix,
|
|
59
|
-
drain: Textus::Action::Drain,
|
|
60
|
-
rule_lint: Textus::Action::RuleLint,
|
|
61
|
-
}.freeze
|
|
62
|
-
|
|
63
|
-
# Derive CLI_VERBS after VERBS is available.
|
|
64
25
|
Textus::Boot::CLI_VERBS = Textus::Boot.build_cli_verbs.freeze
|
|
65
26
|
|
|
66
|
-
# Dynamic verb methods on Store (deferred after VERBS is defined).
|
|
67
|
-
Textus::Action::VERBS.each_key do |verb|
|
|
68
|
-
Textus::Store.define_method(verb) do |*args, role: Textus::Value::Role::DEFAULT, **kwargs|
|
|
69
|
-
as(role).public_send(verb, *args, **kwargs)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
Textus::Surface::RoleScope.define_method(verb) do |*args, **kwargs|
|
|
73
|
-
klass = Textus::Action::VERBS[verb]
|
|
74
|
-
inputs = if klass.respond_to?(:contract?) && klass.contract?
|
|
75
|
-
Textus::Gate::Binder.inputs_from_ordered(klass.contract, args, kwargs)
|
|
76
|
-
else
|
|
77
|
-
kwargs.transform_keys(&:to_sym)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
role_value = if klass.respond_to?(:contract?) && klass.contract? &&
|
|
81
|
-
klass.contract.args.any? { |a| a.name == :role }
|
|
82
|
-
inputs[:role]
|
|
83
|
-
else
|
|
84
|
-
@role
|
|
85
|
-
end
|
|
86
|
-
|
|
87
|
-
@container.gate.dispatch(spec: klass.contract, inputs: inputs, role: role_value, correlation_id: @correlation_id)
|
|
88
|
-
end
|
|
89
|
-
end
|
|
90
|
-
|
|
91
27
|
module Textus
|
|
92
28
|
def self.workflow(name, &)
|
|
93
29
|
collector = Workflow::Collector.current
|