textus 0.54.2 → 0.55.1
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 +37 -0
- data/README.md +8 -1
- data/SPEC.md +27 -0
- data/docs/architecture/README.md +20 -8
- data/docs/reference/conventions.md +1 -1
- data/exe/textus +1 -1
- data/lib/textus/action/accept.rb +23 -21
- data/lib/textus/action/audit.rb +24 -61
- data/lib/textus/action/base.rb +9 -9
- data/lib/textus/action/blame.rb +18 -36
- data/lib/textus/action/boot.rb +2 -4
- data/lib/textus/action/data_mv.rb +20 -31
- data/lib/textus/action/deps.rb +3 -18
- data/lib/textus/action/doctor.rb +2 -9
- data/lib/textus/action/drain.rb +11 -19
- data/lib/textus/action/enqueue.rb +14 -30
- data/lib/textus/action/get.rb +12 -56
- data/lib/textus/action/ingest.rb +74 -78
- data/lib/textus/action/jobs.rb +6 -15
- data/lib/textus/action/key_delete.rb +6 -16
- data/lib/textus/action/key_delete_prefix.rb +8 -17
- data/lib/textus/action/key_mv.rb +54 -61
- data/lib/textus/action/key_mv_prefix.rb +13 -22
- data/lib/textus/action/list.rb +7 -21
- data/lib/textus/action/propose.rb +16 -26
- data/lib/textus/action/published.rb +3 -5
- data/lib/textus/action/pulse.rb +19 -26
- data/lib/textus/action/put.rb +15 -29
- data/lib/textus/action/rdeps.rb +3 -18
- data/lib/textus/action/reject.rb +12 -21
- data/lib/textus/action/rule_explain.rb +12 -22
- data/lib/textus/action/rule_lint.rb +10 -16
- data/lib/textus/action/rule_list.rb +5 -9
- data/lib/textus/action/schema_envelope.rb +3 -10
- data/lib/textus/action/uid.rb +3 -17
- data/lib/textus/action/where.rb +3 -18
- data/lib/textus/boot.rb +7 -15
- data/lib/textus/contract/arg.rb +10 -0
- data/lib/textus/contract/dsl.rb +88 -0
- data/lib/textus/contract/spec.rb +25 -0
- data/lib/textus/contract.rb +0 -162
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/generator_drift.rb +2 -2
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +2 -2
- data/lib/textus/doctor/check/schemas.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +4 -4
- data/lib/textus/doctor/check/templates.rb +1 -1
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +4 -7
- data/lib/textus/doctor.rb +1 -1
- data/lib/textus/errors.rb +6 -0
- data/lib/textus/format/base.rb +0 -4
- data/lib/textus/format/json.rb +5 -6
- data/lib/textus/format/markdown.rb +5 -6
- data/lib/textus/format/shared.rb +17 -0
- data/lib/textus/format/text.rb +5 -4
- data/lib/textus/format/yaml.rb +30 -6
- data/lib/textus/format.rb +6 -0
- data/lib/textus/gate/auth.rb +2 -17
- data/lib/textus/gate/binder.rb +50 -0
- data/lib/textus/gate.rb +64 -88
- data/lib/textus/init.rb +2 -4
- data/lib/textus/jobs.rb +3 -9
- data/lib/textus/manifest/capabilities.rb +3 -3
- data/lib/textus/manifest/entry/base.rb +1 -1
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
- data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
- data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
- data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
- data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
- data/lib/textus/manifest/schema/semantics.rb +11 -216
- data/lib/textus/meta.rb +54 -0
- data/lib/textus/{ports → port}/audit_log.rb +44 -4
- data/lib/textus/{ports → port}/build_lock.rb +2 -2
- data/lib/textus/{ports → port}/clock.rb +1 -1
- data/lib/textus/{ports → port}/publisher.rb +5 -5
- data/lib/textus/{ports → port}/sentinel_store.rb +3 -3
- data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
- data/lib/textus/{ports → port}/storage/file_store.rb +2 -2
- data/lib/textus/port/store.rb +93 -0
- data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
- data/lib/textus/produce/engine.rb +1 -1
- data/lib/textus/schema/tools.rb +11 -7
- data/lib/textus/store/compositor.rb +34 -0
- data/lib/textus/store/container.rb +43 -0
- data/lib/textus/store/cursor.rb +26 -0
- data/lib/textus/store/envelope/reader.rb +43 -0
- data/lib/textus/store/envelope/writer.rb +195 -0
- data/lib/textus/store/geometry.rb +81 -0
- data/lib/textus/store/index/builder.rb +74 -0
- data/lib/textus/store/index/lookup.rb +60 -0
- data/lib/textus/store/jobs/base.rb +13 -0
- data/lib/textus/store/jobs/index.rb +15 -0
- data/lib/textus/store/jobs/materialize.rb +15 -0
- data/lib/textus/store/jobs/plan.rb +11 -0
- data/lib/textus/store/jobs/planner.rb +104 -0
- data/lib/textus/store/jobs/queue.rb +154 -0
- data/lib/textus/store/jobs/registry.rb +19 -0
- data/lib/textus/store/jobs/retention.rb +50 -0
- data/lib/textus/store/jobs/sweep.rb +21 -0
- data/lib/textus/store/jobs/worker.rb +64 -0
- data/lib/textus/store/session.rb +37 -0
- data/lib/textus/store.rb +21 -13
- data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
- data/lib/textus/{surfaces → surface}/cli/runner.rb +10 -63
- data/lib/textus/surface/cli/sources.rb +41 -0
- data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +4 -6
- data/lib/textus/{surfaces → surface}/cli/verb/get.rb +4 -4
- data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
- data/lib/textus/{surfaces → surface}/cli/verb/put.rb +6 -11
- data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
- data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
- data/lib/textus/{surfaces → surface}/cli.rb +1 -1
- data/lib/textus/{surfaces → surface}/mcp/catalog.rb +9 -26
- data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -1
- data/lib/textus/{surfaces → surface}/mcp/server.rb +5 -5
- data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
- data/lib/textus/surface/projector.rb +27 -0
- data/lib/textus/{surfaces → surface}/role_scope.rb +1 -1
- data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
- data/lib/textus/value/call.rb +30 -0
- data/lib/textus/value/command.rb +16 -0
- data/lib/textus/value/envelope.rb +89 -0
- data/lib/textus/value/etag.rb +39 -0
- data/lib/textus/value/result.rb +26 -0
- data/lib/textus/value/role.rb +38 -0
- data/lib/textus/value/types.rb +13 -0
- data/lib/textus/{uid.rb → value/uid.rb} +9 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/loader.rb +4 -4
- data/lib/textus/workflow/runner.rb +4 -18
- data/lib/textus.rb +9 -10
- metadata +100 -63
- data/lib/textus/action/write_verb.rb +0 -44
- data/lib/textus/call.rb +0 -28
- data/lib/textus/command.rb +0 -41
- data/lib/textus/container.rb +0 -26
- data/lib/textus/contract/around.rb +0 -29
- data/lib/textus/contract/binder.rb +0 -88
- data/lib/textus/contract/resources/build_lock.rb +0 -17
- data/lib/textus/contract/resources/cursor.rb +0 -26
- data/lib/textus/contract/sources.rb +0 -39
- data/lib/textus/contract/view.rb +0 -15
- data/lib/textus/cursor_store.rb +0 -24
- data/lib/textus/envelope/reader.rb +0 -46
- data/lib/textus/envelope/writer.rb +0 -209
- data/lib/textus/envelope.rb +0 -79
- data/lib/textus/etag.rb +0 -36
- data/lib/textus/jobs/base.rb +0 -23
- data/lib/textus/jobs/materialize.rb +0 -20
- data/lib/textus/jobs/plan.rb +0 -9
- data/lib/textus/jobs/planner.rb +0 -101
- data/lib/textus/jobs/retention.rb +0 -48
- data/lib/textus/jobs/sweep.rb +0 -27
- data/lib/textus/jobs/worker.rb +0 -67
- data/lib/textus/layout.rb +0 -91
- data/lib/textus/ports/job_store/job.rb +0 -65
- data/lib/textus/ports/job_store.rb +0 -123
- data/lib/textus/ports/raw_index.rb +0 -61
- data/lib/textus/role.rb +0 -36
- data/lib/textus/session.rb +0 -35
- data/lib/textus/types.rb +0 -15
|
@@ -8,12 +8,12 @@ module Textus
|
|
|
8
8
|
# that drift without making `build` scan globally.
|
|
9
9
|
class OrphanedPublishTargets < Check
|
|
10
10
|
def call
|
|
11
|
-
sdir = Textus::
|
|
11
|
+
sdir = Textus::Store::Geometry.new(root).sentinels_root
|
|
12
12
|
return [] unless File.directory?(sdir)
|
|
13
13
|
|
|
14
14
|
repo_root = File.dirname(root)
|
|
15
|
-
store = Textus::
|
|
16
|
-
glob = File.join(sdir, "**", "*#{Textus::
|
|
15
|
+
store = Textus::Port::SentinelStore.new
|
|
16
|
+
glob = File.join(sdir, "**", "*#{Textus::Port::SentinelStore::SUFFIX}")
|
|
17
17
|
Dir.glob(glob).filter_map do |spath|
|
|
18
18
|
sentinel = store.load(spath, repo_root)
|
|
19
19
|
next nil if sentinel.nil? || sentinel.source.nil?
|
|
@@ -8,7 +8,7 @@ module Textus
|
|
|
8
8
|
class ProtocolVersion < Check
|
|
9
9
|
# Standalone interface: root is the project root (parent of .textus/).
|
|
10
10
|
def self.run(root:)
|
|
11
|
-
path = File.join(root, ".textus
|
|
11
|
+
path = File.join(root, ".textus", "manifest.yaml")
|
|
12
12
|
return [] unless File.exist?(path)
|
|
13
13
|
|
|
14
14
|
doc = YAML.safe_load_file(path, aliases: false) || {}
|
|
@@ -26,7 +26,7 @@ module Textus
|
|
|
26
26
|
# Doctor check interface: root is the .textus/ directory itself,
|
|
27
27
|
# so manifest.yaml lives directly inside it.
|
|
28
28
|
def call
|
|
29
|
-
path =
|
|
29
|
+
path = geometry.manifest_path
|
|
30
30
|
return [] unless File.exist?(path)
|
|
31
31
|
|
|
32
32
|
doc = YAML.safe_load_file(path, aliases: false) || {}
|
|
@@ -7,7 +7,7 @@ module Textus
|
|
|
7
7
|
# leaving the operator with no signal that a schema is broken.
|
|
8
8
|
class SchemaParseError < Check
|
|
9
9
|
def call
|
|
10
|
-
dir =
|
|
10
|
+
dir = geometry.schemas_dir
|
|
11
11
|
return [] unless File.directory?(dir)
|
|
12
12
|
|
|
13
13
|
Dir.glob(File.join(dir, "*.yaml")).each_with_object([]) do |path, out|
|
|
@@ -4,11 +4,11 @@ module Textus
|
|
|
4
4
|
class SchemaViolations < Check
|
|
5
5
|
def call
|
|
6
6
|
result = Textus::Doctor::Validator.new(
|
|
7
|
-
reader: ->(key, ctnr, c) { Textus::Action::Get.
|
|
7
|
+
reader: ->(key, ctnr, c) { Value::Result.unwrap(Textus::Action::Get.call(container: ctnr, call: c, key: key)) },
|
|
8
8
|
manifest: @container.manifest,
|
|
9
9
|
audit_log: @container.audit_log,
|
|
10
10
|
schema_for: ->(name) { @container.schemas.fetch_or_nil(name) },
|
|
11
|
-
).call(container: @container, call: Textus::Call.build(role: Textus::Role::DEFAULT))
|
|
11
|
+
).call(container: @container, call: Textus::Value::Call.build(role: Textus::Value::Role::DEFAULT))
|
|
12
12
|
|
|
13
13
|
result["violations"].map do |v|
|
|
14
14
|
fix = v["expected"] &&
|
|
@@ -3,13 +3,13 @@ module Textus
|
|
|
3
3
|
class Check
|
|
4
4
|
class Sentinels < Check
|
|
5
5
|
def call
|
|
6
|
-
store = Textus::
|
|
7
|
-
file_stat = Textus::
|
|
8
|
-
dir = Textus::
|
|
6
|
+
store = Textus::Port::SentinelStore.new
|
|
7
|
+
file_stat = Textus::Port::Storage::FileStat.new
|
|
8
|
+
dir = Textus::Store::Geometry.new(root).sentinels_root
|
|
9
9
|
return [] unless file_stat.directory?(dir)
|
|
10
10
|
|
|
11
11
|
repo_root = File.dirname(root)
|
|
12
|
-
file_stat.glob(File.join(dir, "**", "*#{Textus::
|
|
12
|
+
file_stat.glob(File.join(dir, "**", "*#{Textus::Port::SentinelStore::SUFFIX}")).flat_map do |sentinel_path|
|
|
13
13
|
inspect_sentinel(sentinel_path, repo_root, store, file_stat)
|
|
14
14
|
end
|
|
15
15
|
end
|
data/lib/textus/doctor/check.rb
CHANGED
|
@@ -14,7 +14,7 @@ module Textus
|
|
|
14
14
|
.downcase
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def initialize(container, role: Textus::Role::DEFAULT)
|
|
17
|
+
def initialize(container, role: Textus::Value::Role::DEFAULT)
|
|
18
18
|
@container = container
|
|
19
19
|
@role = role
|
|
20
20
|
end
|
|
@@ -26,18 +26,15 @@ module Textus
|
|
|
26
26
|
protected
|
|
27
27
|
|
|
28
28
|
def root = @container.root
|
|
29
|
+
def geometry = @container.geometry
|
|
29
30
|
def manifest = @container.manifest
|
|
30
31
|
|
|
31
32
|
# Dispatch a verb through Gate.
|
|
32
33
|
def dispatch(verb, *args, **kwargs)
|
|
33
34
|
klass = Textus::Action::VERBS[verb]
|
|
34
35
|
spec = klass.contract if klass.respond_to?(:contract?) && klass.contract?
|
|
35
|
-
inputs = spec ? Textus::
|
|
36
|
-
|
|
37
|
-
merged = inputs.merge(role: @role)
|
|
38
|
-
filled = cmd_class.members.to_h { |m| [m, merged.key?(m) ? merged[m] : nil] }
|
|
39
|
-
cmd = cmd_class.new(**filled)
|
|
40
|
-
@container.gate.dispatch(cmd)
|
|
36
|
+
inputs = spec ? Textus::Gate::Binder.inputs_from_ordered(spec, args, kwargs) : kwargs
|
|
37
|
+
@container.gate.dispatch(spec:, inputs:, role: @role)
|
|
41
38
|
end
|
|
42
39
|
end
|
|
43
40
|
end
|
data/lib/textus/doctor.rb
CHANGED
|
@@ -30,7 +30,7 @@ module Textus
|
|
|
30
30
|
|
|
31
31
|
module_function
|
|
32
32
|
|
|
33
|
-
def build(container:, checks: nil, role: Textus::Role::DEFAULT)
|
|
33
|
+
def build(container:, checks: nil, role: Textus::Value::Role::DEFAULT)
|
|
34
34
|
selected_keys = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
|
|
35
35
|
unknown = selected_keys - ALL_CHECKS
|
|
36
36
|
unless unknown.empty?
|
data/lib/textus/errors.rb
CHANGED
|
@@ -225,6 +225,12 @@ module Textus
|
|
|
225
225
|
end
|
|
226
226
|
end
|
|
227
227
|
|
|
228
|
+
class ActionError < Error
|
|
229
|
+
def initialize(code, message, details: {})
|
|
230
|
+
super(code, message, details: details, exit_code: 1)
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
228
234
|
class CursorExpired < Error
|
|
229
235
|
attr_reader :requested, :min_available
|
|
230
236
|
|
data/lib/textus/format/base.rb
CHANGED
|
@@ -29,10 +29,6 @@ module Textus
|
|
|
29
29
|
raise NotImplementedError.new("#{name}.validate_path_extension not implemented")
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
def self.inject_uid(_meta, _content, _existing_uid)
|
|
33
|
-
raise NotImplementedError.new("#{name}.inject_uid not implemented")
|
|
34
|
-
end
|
|
35
|
-
|
|
36
32
|
def self.enforce_name_match!(_path, _meta)
|
|
37
33
|
raise NotImplementedError.new("#{name}.enforce_name_match! not implemented")
|
|
38
34
|
end
|
data/lib/textus/format/json.rb
CHANGED
|
@@ -80,12 +80,6 @@ module Textus
|
|
|
80
80
|
raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
|
|
81
81
|
end
|
|
82
82
|
|
|
83
|
-
def self.inject_uid(meta, content, existing_uid)
|
|
84
|
-
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
85
|
-
m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
|
|
86
|
-
[m, content]
|
|
87
|
-
end
|
|
88
|
-
|
|
89
83
|
def self.validate_path_extension(path, nested)
|
|
90
84
|
ext = File.extname(path)
|
|
91
85
|
if nested
|
|
@@ -98,6 +92,11 @@ module Textus
|
|
|
98
92
|
|
|
99
93
|
raise UsageError.new("json format requires '.json' path (got #{ext.inspect})")
|
|
100
94
|
end
|
|
95
|
+
|
|
96
|
+
def self.data_to_payload(data)
|
|
97
|
+
data = data.transform_keys(&:to_s) if data.is_a?(Hash)
|
|
98
|
+
{ meta: data["_meta"] || {}, body: nil, content: data["content"] || data }
|
|
99
|
+
end
|
|
101
100
|
end
|
|
102
101
|
end
|
|
103
102
|
end
|
|
@@ -64,18 +64,17 @@ module Textus
|
|
|
64
64
|
raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
-
def self.inject_uid(meta, content, existing_uid)
|
|
68
|
-
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
69
|
-
m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
|
|
70
|
-
[m, content]
|
|
71
|
-
end
|
|
72
|
-
|
|
73
67
|
def self.validate_path_extension(path, _nested)
|
|
74
68
|
ext = File.extname(path)
|
|
75
69
|
return if ["", ".md"].include?(ext)
|
|
76
70
|
|
|
77
71
|
raise UsageError.new("markdown format requires '.md' path (got #{ext.inspect})")
|
|
78
72
|
end
|
|
73
|
+
|
|
74
|
+
def self.data_to_payload(data)
|
|
75
|
+
data = data.transform_keys(&:to_s) if data.is_a?(Hash)
|
|
76
|
+
{ meta: data["_meta"] || {}, body: (data["body"] || "").to_s, content: nil }
|
|
77
|
+
end
|
|
79
78
|
end
|
|
80
79
|
end
|
|
81
80
|
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Format
|
|
3
|
+
module Shared
|
|
4
|
+
ENFORCE_NAME_RE = /\.(md|json|yaml|yml|txt)\z/i
|
|
5
|
+
|
|
6
|
+
def self.enforce_name_match!(path, meta, extensions)
|
|
7
|
+
return unless meta.is_a?(Hash) && meta["name"]
|
|
8
|
+
|
|
9
|
+
ext = extensions.first
|
|
10
|
+
basename = File.basename(path, ext)
|
|
11
|
+
return if meta["name"] == basename
|
|
12
|
+
|
|
13
|
+
raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
data/lib/textus/format/text.rb
CHANGED
|
@@ -20,10 +20,6 @@ module Textus
|
|
|
20
20
|
|
|
21
21
|
def self.nested_glob = "**/*.txt"
|
|
22
22
|
|
|
23
|
-
def self.inject_uid(meta, content, _existing_uid)
|
|
24
|
-
[meta, content]
|
|
25
|
-
end
|
|
26
|
-
|
|
27
23
|
def self.enforce_name_match!(_path, _meta); end
|
|
28
24
|
|
|
29
25
|
def self.serialize_for_put(meta:, body:, content:, path:)
|
|
@@ -49,6 +45,11 @@ module Textus
|
|
|
49
45
|
|
|
50
46
|
raise UsageError.new("text format requires '.txt' or no extension (got #{ext.inspect})")
|
|
51
47
|
end
|
|
48
|
+
|
|
49
|
+
def self.data_to_payload(data)
|
|
50
|
+
data = data.transform_keys(&:to_s) if data.is_a?(Hash)
|
|
51
|
+
{ meta: data["_meta"] || {}, body: (data["body"] || "").to_s, content: nil }
|
|
52
|
+
end
|
|
52
53
|
end
|
|
53
54
|
end
|
|
54
55
|
end
|
data/lib/textus/format/yaml.rb
CHANGED
|
@@ -37,6 +37,31 @@ module Textus
|
|
|
37
37
|
schema.validate!(parsed["content"] || {})
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
+
RAW_REQUIRED = %w[ingested_at content_hash].freeze
|
|
41
|
+
RAW_SOURCE_KINDS = %w[url file asset].freeze
|
|
42
|
+
|
|
43
|
+
def self.validate_raw_entry!(parsed, lane)
|
|
44
|
+
return unless lane == "raw"
|
|
45
|
+
|
|
46
|
+
content = parsed["content"] || {}
|
|
47
|
+
return if content["superseded_by"]
|
|
48
|
+
|
|
49
|
+
missing = RAW_REQUIRED.reject { |f| content[f] }
|
|
50
|
+
raise Textus::BadContent.new(nil, "raw entry missing required field(s): #{missing.join(", ")}") if missing.any?
|
|
51
|
+
|
|
52
|
+
source = content["source"] || {}
|
|
53
|
+
kind = source["kind"]
|
|
54
|
+
unless RAW_SOURCE_KINDS.include?(kind)
|
|
55
|
+
raise Textus::BadContent.new(
|
|
56
|
+
nil, "raw entry source.kind must be #{RAW_SOURCE_KINDS.join("|")}, got #{kind.inspect}"
|
|
57
|
+
)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
return unless kind == "url" && !source["url"]
|
|
61
|
+
|
|
62
|
+
raise Textus::BadContent.new(nil, "raw entry with source.kind=url must have source.url")
|
|
63
|
+
end
|
|
64
|
+
|
|
40
65
|
def self.extensions = [".yaml", ".yml"]
|
|
41
66
|
|
|
42
67
|
def self.nested_glob = "**/*.{yaml,yml}"
|
|
@@ -78,12 +103,6 @@ module Textus
|
|
|
78
103
|
raise BadFrontmatter.new(path, "name '#{meta["name"]}' does not match basename '#{basename}'")
|
|
79
104
|
end
|
|
80
105
|
|
|
81
|
-
def self.inject_uid(meta, content, existing_uid)
|
|
82
|
-
m = meta.is_a?(Hash) ? meta.dup : {}
|
|
83
|
-
m["uid"] = existing_uid || Textus::Uid.mint unless m["uid"].is_a?(String) && !m["uid"].empty?
|
|
84
|
-
[m, content]
|
|
85
|
-
end
|
|
86
|
-
|
|
87
106
|
def self.validate_path_extension(path, nested)
|
|
88
107
|
ext = File.extname(path)
|
|
89
108
|
if nested
|
|
@@ -96,6 +115,11 @@ module Textus
|
|
|
96
115
|
|
|
97
116
|
raise UsageError.new("yaml format requires '.yaml' or '.yml' path (got #{ext.inspect})")
|
|
98
117
|
end
|
|
118
|
+
|
|
119
|
+
def self.data_to_payload(data)
|
|
120
|
+
data = data.transform_keys(&:to_s) if data.is_a?(Hash)
|
|
121
|
+
{ meta: data["_meta"] || {}, body: nil, content: data["content"] || data }
|
|
122
|
+
end
|
|
99
123
|
end
|
|
100
124
|
end
|
|
101
125
|
end
|
data/lib/textus/format.rb
CHANGED
|
@@ -36,5 +36,11 @@ module Textus
|
|
|
36
36
|
def self.serialize(meta: {}, body: "", content: nil, format: "markdown")
|
|
37
37
|
Format.for(format).serialize(meta: meta, body: body, content: content)
|
|
38
38
|
end
|
|
39
|
+
|
|
40
|
+
def self.data_to_payload(data, format)
|
|
41
|
+
return { meta: {}, body: "", content: nil } if data.nil?
|
|
42
|
+
|
|
43
|
+
Format.for(format).data_to_payload(data)
|
|
44
|
+
end
|
|
39
45
|
end
|
|
40
46
|
end
|
data/lib/textus/gate/auth.rb
CHANGED
|
@@ -27,13 +27,12 @@ module Textus
|
|
|
27
27
|
@schemas = container.schemas
|
|
28
28
|
end
|
|
29
29
|
|
|
30
|
-
# Command-based check (new Gate path).
|
|
31
30
|
def check!(cmd)
|
|
32
31
|
key = extract_key(cmd)
|
|
33
32
|
return unless key
|
|
34
33
|
|
|
35
34
|
evaluate_predicates(
|
|
36
|
-
action:
|
|
35
|
+
action: cmd.verb,
|
|
37
36
|
actor: cmd.role.to_s,
|
|
38
37
|
key: key,
|
|
39
38
|
envelope: nil,
|
|
@@ -52,18 +51,8 @@ module Textus
|
|
|
52
51
|
)
|
|
53
52
|
end
|
|
54
53
|
|
|
55
|
-
def self.command_to_verb
|
|
56
|
-
@command_to_verb ||= Textus::Gate::VERB_COMMAND.invert.freeze
|
|
57
|
-
end
|
|
58
|
-
|
|
59
54
|
private
|
|
60
55
|
|
|
61
|
-
def command_to_action(cmd)
|
|
62
|
-
self.class.command_to_verb.fetch(cmd.class) do
|
|
63
|
-
raise Textus::UsageError.new("unmapped command: #{cmd.class}")
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
56
|
def evaluate_predicates(action:, actor:, key:, envelope:, extra:)
|
|
68
57
|
mentry = @manifest.resolver.resolve(key).entry
|
|
69
58
|
lane_verb = @manifest.policy.verb_for_lane(mentry.lane.to_s)
|
|
@@ -89,11 +78,7 @@ module Textus
|
|
|
89
78
|
end
|
|
90
79
|
|
|
91
80
|
def extract_key(cmd)
|
|
92
|
-
|
|
93
|
-
cmd.pending_key
|
|
94
|
-
elsif cmd.respond_to?(:key)
|
|
95
|
-
cmd.key
|
|
96
|
-
end
|
|
81
|
+
cmd.params.key?(:pending_key) ? cmd.pending_key : cmd.key
|
|
97
82
|
end
|
|
98
83
|
|
|
99
84
|
def rule_declared_predicates(action, key)
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Gate
|
|
3
|
+
# Raised when a required arg is absent from the bound input. Surface
|
|
4
|
+
# adapters translate this to their native error (MCP ToolError, CLI
|
|
5
|
+
# UsageError); a direct Ruby call lets it surface as-is.
|
|
6
|
+
class MissingArgs < Textus::Error
|
|
7
|
+
attr_reader :spec, :missing
|
|
8
|
+
|
|
9
|
+
def initialize(spec, missing)
|
|
10
|
+
@spec = spec
|
|
11
|
+
@missing = missing
|
|
12
|
+
super("missing_args", "#{spec.verb}: missing #{missing.map(&:wire).join(", ")}")
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# Validates and resolves a by-name inputs hash against a contract spec.
|
|
17
|
+
# Returns a flat hash with defaults and session_defaults filled in.
|
|
18
|
+
# Every caller receives the same shape — no positional/kwarg split.
|
|
19
|
+
module Binder
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def bind(spec, inputs, session: nil)
|
|
23
|
+
missing = spec.required_args.reject { |a| inputs.key?(a.name) }
|
|
24
|
+
raise MissingArgs.new(spec, missing) unless missing.empty?
|
|
25
|
+
|
|
26
|
+
spec.args.each_with_object({}) do |a, h|
|
|
27
|
+
if inputs.key?(a.name)
|
|
28
|
+
h[a.name] = inputs[a.name]
|
|
29
|
+
elsif a.session_default && session
|
|
30
|
+
h[a.name] = session.public_send(a.session_default)
|
|
31
|
+
elsif !a.default.nil?
|
|
32
|
+
h[a.name] = a.default
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def inputs_from_ordered(spec, ordered_positionals, by_name_keywords)
|
|
38
|
+
names = spec.args.select(&:positional).map(&:name)
|
|
39
|
+
names.zip(ordered_positionals).to_h.compact.merge(by_name_keywords)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def inputs_from_wire(spec, raw)
|
|
43
|
+
raw ||= {}
|
|
44
|
+
spec.args.each_with_object({}) do |a, h|
|
|
45
|
+
h[a.name] = raw[a.wire.to_s] if raw.key?(a.wire.to_s)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/textus/gate.rb
CHANGED
|
@@ -2,115 +2,91 @@
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
class Gate
|
|
5
|
-
VERB_COMMAND = {
|
|
6
|
-
get: Textus::Command::Get,
|
|
7
|
-
put: Textus::Command::Put,
|
|
8
|
-
propose: Textus::Command::Propose,
|
|
9
|
-
key_delete: Textus::Command::KeyDelete,
|
|
10
|
-
key_mv: Textus::Command::KeyMv,
|
|
11
|
-
accept: Textus::Command::Accept,
|
|
12
|
-
reject: Textus::Command::Reject,
|
|
13
|
-
enqueue: Textus::Command::Enqueue,
|
|
14
|
-
list: Textus::Command::List,
|
|
15
|
-
where: Textus::Command::Where,
|
|
16
|
-
uid: Textus::Command::Uid,
|
|
17
|
-
blame: Textus::Command::Blame,
|
|
18
|
-
audit: Textus::Command::Audit,
|
|
19
|
-
deps: Textus::Command::Deps,
|
|
20
|
-
rdeps: Textus::Command::Rdeps,
|
|
21
|
-
pulse: Textus::Command::Pulse,
|
|
22
|
-
rule_explain: Textus::Command::RuleExplain,
|
|
23
|
-
rule_list: Textus::Command::RuleList,
|
|
24
|
-
rule_lint: Textus::Command::RuleLint,
|
|
25
|
-
published: Textus::Command::Published,
|
|
26
|
-
schema_show: Textus::Command::SchemaShow,
|
|
27
|
-
doctor: Textus::Command::Doctor,
|
|
28
|
-
boot: Textus::Command::Boot,
|
|
29
|
-
jobs: Textus::Command::Jobs,
|
|
30
|
-
data_mv: Textus::Command::DataMv,
|
|
31
|
-
key_mv_prefix: Textus::Command::KeyMvPrefix,
|
|
32
|
-
key_delete_prefix: Textus::Command::KeyDeletePrefix,
|
|
33
|
-
drain: Textus::Command::Drain,
|
|
34
|
-
ingest: Textus::Command::Ingest,
|
|
35
|
-
}.freeze
|
|
36
|
-
|
|
37
|
-
ROUTES = {
|
|
38
|
-
Command::Get => [Textus::Action::Get],
|
|
39
|
-
Command::Put => [Textus::Action::Put],
|
|
40
|
-
Command::Propose => [Textus::Action::Propose],
|
|
41
|
-
Command::KeyDelete => [Textus::Action::KeyDelete],
|
|
42
|
-
Command::KeyMv => [Textus::Action::KeyMv],
|
|
43
|
-
Command::Accept => [Textus::Action::Accept],
|
|
44
|
-
Command::Reject => [Textus::Action::Reject],
|
|
45
|
-
Command::Enqueue => [Textus::Action::Enqueue],
|
|
46
|
-
Command::List => [Textus::Action::List],
|
|
47
|
-
Command::Where => [Textus::Action::Where],
|
|
48
|
-
Command::Uid => [Textus::Action::Uid],
|
|
49
|
-
Command::Blame => [Textus::Action::Blame],
|
|
50
|
-
Command::Audit => [Textus::Action::Audit],
|
|
51
|
-
Command::Deps => [Textus::Action::Deps],
|
|
52
|
-
Command::Rdeps => [Textus::Action::Rdeps],
|
|
53
|
-
Command::Pulse => [Textus::Action::Pulse],
|
|
54
|
-
Command::RuleExplain => [Textus::Action::RuleExplain],
|
|
55
|
-
Command::RuleList => [Textus::Action::RuleList],
|
|
56
|
-
Command::RuleLint => [Textus::Action::RuleLint],
|
|
57
|
-
Command::Published => [Textus::Action::Published],
|
|
58
|
-
Command::SchemaShow => [Textus::Action::SchemaEnvelope],
|
|
59
|
-
Command::Doctor => [Textus::Action::Doctor],
|
|
60
|
-
Command::Boot => [Textus::Action::Boot],
|
|
61
|
-
Command::Jobs => [Textus::Action::Jobs],
|
|
62
|
-
Command::DataMv => [Textus::Action::DataMv],
|
|
63
|
-
Command::KeyMvPrefix => [Textus::Action::KeyMvPrefix],
|
|
64
|
-
Command::KeyDeletePrefix => [Textus::Action::KeyDeletePrefix],
|
|
65
|
-
Command::Drain => [Textus::Action::Drain],
|
|
66
|
-
Command::Ingest => [Textus::Action::Ingest],
|
|
67
|
-
}.freeze
|
|
68
|
-
|
|
69
5
|
def initialize(container)
|
|
70
6
|
@container = container
|
|
71
7
|
end
|
|
72
8
|
|
|
73
|
-
def dispatch(
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
9
|
+
def dispatch(spec:, inputs:, role:, correlation_id: nil, session: nil, surface: nil)
|
|
10
|
+
resolved = Binder.bind(spec, inputs, session: session)
|
|
11
|
+
cmd = Value::Command.new(verb: spec.verb, params: resolved.freeze, role: role)
|
|
12
|
+
|
|
13
|
+
cmd = normalize_propose_key(cmd) if cmd.verb == :propose
|
|
14
|
+
action_class = Textus::Action::VERBS.fetch(cmd.verb) do
|
|
15
|
+
raise Textus::UsageError.new("unknown command verb: #{cmd.verb}")
|
|
77
16
|
end
|
|
78
17
|
|
|
79
|
-
Gate::Auth.new(@container)
|
|
18
|
+
auth = Gate::Auth.new(@container)
|
|
19
|
+
auth.check!(cmd)
|
|
20
|
+
check_dispatch_auth(cmd, resolved, auth)
|
|
80
21
|
call_obj = build_call(cmd, correlation_id: correlation_id)
|
|
81
|
-
|
|
82
|
-
|
|
22
|
+
result = run_action(action_class, resolved, call_obj)
|
|
23
|
+
cascade(cmd, result, call_obj) if CASCADE_VERBS.include?(cmd.verb) && !call_obj.dry_run
|
|
24
|
+
return result unless surface
|
|
25
|
+
|
|
26
|
+
spec.view(surface).call(result, resolved)
|
|
83
27
|
end
|
|
84
28
|
|
|
29
|
+
CASCADE_VERBS = %i[put propose accept reject key_mv key_delete].freeze
|
|
30
|
+
|
|
31
|
+
AUTH_KEYS = {
|
|
32
|
+
key_mv: ->(params) { [params[:old_key], params[:new_key]] },
|
|
33
|
+
ingest: ->(params) { Textus::Action::Ingest.dispatch_key(**params) },
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
85
36
|
private
|
|
86
37
|
|
|
87
|
-
def
|
|
38
|
+
def check_dispatch_auth(cmd, resolved, auth)
|
|
39
|
+
return unless (resolver = AUTH_KEYS[cmd.verb])
|
|
40
|
+
|
|
41
|
+
keys = Array(resolver.call(resolved))
|
|
42
|
+
keys.each { |k| auth.check_action!(action: cmd.verb, actor: cmd.role, key: k) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def normalize_propose_key(cmd)
|
|
88
46
|
return cmd if cmd.pending_key
|
|
89
47
|
|
|
90
|
-
zone = container.manifest.policy.propose_lane_for(cmd.role.to_s)
|
|
91
|
-
|
|
48
|
+
zone = @container.manifest.policy.propose_lane_for(cmd.role.to_s)
|
|
49
|
+
key = zone ? "#{zone}.#{cmd.key}" : nil
|
|
50
|
+
cmd.with(params: cmd.params.merge(pending_key: key))
|
|
92
51
|
end
|
|
93
52
|
|
|
94
|
-
def run_action(klass,
|
|
95
|
-
|
|
96
|
-
|
|
53
|
+
def run_action(klass, params, call_obj)
|
|
54
|
+
result = klass.call(container: @container, call: call_obj, **params)
|
|
55
|
+
Value::Result.unwrap(result)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def build_call(cmd, correlation_id: nil)
|
|
59
|
+
dry_run = cmd.params.key?(:dry_run) ? !cmd.params[:dry_run].nil? : false
|
|
60
|
+
Textus::Value::Call.build(role: cmd.role, dry_run:, correlation_id: correlation_id)
|
|
97
61
|
end
|
|
98
62
|
|
|
99
|
-
def
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
cmd.members.each_with_object({}) do |m, h|
|
|
104
|
-
next unless accepts_keyrest || param_set.include?(m)
|
|
63
|
+
def cascade(cmd, result, call)
|
|
64
|
+
key = result.is_a?(Hash) ? result["cascade_key"] : nil
|
|
65
|
+
key ||= cascade_key_from_params(cmd)
|
|
66
|
+
return unless key
|
|
105
67
|
|
|
106
|
-
|
|
107
|
-
|
|
68
|
+
rdeps_result = Textus::Action::Rdeps.call(container: @container, call: call, key: key)
|
|
69
|
+
rdeps = Value::Result.unwrap(rdeps_result).fetch("rdeps", [])
|
|
70
|
+
producible = rdeps.select { |dep_key| producible?(dep_key) }
|
|
71
|
+
producible.each do |dep_key|
|
|
72
|
+
Textus::Store::Jobs::Materialize.call(container: @container, call: call, key: dep_key)
|
|
108
73
|
end
|
|
109
74
|
end
|
|
110
75
|
|
|
111
|
-
def
|
|
112
|
-
|
|
113
|
-
|
|
76
|
+
def cascade_key_from_params(cmd)
|
|
77
|
+
case cmd.verb
|
|
78
|
+
when :put, :key_delete then cmd.params[:key]
|
|
79
|
+
when :key_mv then cmd.params[:new_key]
|
|
80
|
+
when :propose, :reject then cmd.params[:pending_key]
|
|
81
|
+
when :accept then nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def producible?(key)
|
|
86
|
+
entry = @container.manifest.resolver.resolve(key).entry
|
|
87
|
+
!entry.publish_tree.nil?
|
|
88
|
+
rescue Textus::Error
|
|
89
|
+
false
|
|
114
90
|
end
|
|
115
91
|
end
|
|
116
92
|
end
|
data/lib/textus/init.rb
CHANGED
|
@@ -92,9 +92,7 @@ module Textus
|
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
def self.setup_state_dirs(target_root)
|
|
95
|
-
FileUtils.mkdir_p(Textus::
|
|
96
|
-
FileUtils.mkdir_p(Textus::Layout.cursors(target_root))
|
|
97
|
-
FileUtils.mkdir_p(Textus::Layout.locks(target_root))
|
|
95
|
+
FileUtils.mkdir_p(Textus::Store::Geometry.new(target_root).audit_dir_path)
|
|
98
96
|
end
|
|
99
97
|
|
|
100
98
|
def self.write_gitignore(target_root)
|
|
@@ -153,7 +151,7 @@ module Textus
|
|
|
153
151
|
Pathname.new(Textus::Key::Path.resolve(manifest.data, e)).relative_path_from(root).to_s
|
|
154
152
|
end
|
|
155
153
|
end
|
|
156
|
-
Textus::
|
|
154
|
+
Textus::Store::Geometry.new(target_root).gitignore_body(untracked_entries: untracked)
|
|
157
155
|
end
|
|
158
156
|
end
|
|
159
157
|
end
|
data/lib/textus/jobs.rb
CHANGED
|
@@ -1,15 +1,9 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Jobs
|
|
3
|
-
@registry = {}
|
|
4
|
-
|
|
5
|
-
def self.registry = @registry
|
|
6
|
-
|
|
7
|
-
def self.register(klass)
|
|
8
|
-
@registry[klass::TYPE] = klass if klass.const_defined?(:TYPE, false)
|
|
9
|
-
end
|
|
10
|
-
|
|
11
3
|
def self.fetch(type)
|
|
12
|
-
|
|
4
|
+
Store::Jobs::Registry.fetch(type)
|
|
5
|
+
rescue Store::Jobs::Registry::UnknownJob
|
|
6
|
+
raise Textus::UsageError.new("unknown job type: #{type}")
|
|
13
7
|
end
|
|
14
8
|
end
|
|
15
9
|
end
|