textus 0.54.2 → 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 +38 -1
- data/README.md +17 -10
- data/SPEC.md +39 -11
- data/docs/architecture/README.md +23 -11
- data/docs/reference/conventions.md +5 -2
- data/exe/textus +1 -1
- data/lib/textus/boot.rb +64 -93
- data/lib/textus/dispatch/binder.rb +48 -0
- 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 +2 -2
- data/lib/textus/doctor/check/generator_drift.rb +4 -4
- 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/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
- 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 +9 -10
- data/lib/textus/doctor.rb +2 -2
- data/lib/textus/errors.rb +8 -0
- data/lib/textus/format/base.rb +34 -10
- data/lib/textus/format/json.rb +5 -27
- data/lib/textus/format/markdown.rb +5 -27
- data/lib/textus/format/text.rb +5 -4
- data/lib/textus/format/yaml.rb +30 -27
- data/lib/textus/format.rb +22 -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 -7
- data/lib/textus/jobs.rb +3 -9
- data/lib/textus/manifest/capabilities.rb +4 -4
- data/lib/textus/manifest/entry/base.rb +3 -3
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
- data/lib/textus/manifest/entry/publish/to_paths.rb +2 -2
- 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/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/orchestration.rb +55 -0
- data/lib/textus/{ports → port}/audit_log.rb +46 -6
- 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/{core → port}/sentinel.rb +1 -6
- data/lib/textus/{ports → port}/sentinel_store.rb +4 -4
- data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
- data/lib/textus/port/storage/file_store.rb +49 -0
- data/lib/textus/port/storage/interface.rb +17 -0
- data/lib/textus/port/store.rb +149 -0
- data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
- 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 +7 -10
- data/lib/textus/store/container.rb +173 -0
- data/lib/textus/store/cursor.rb +26 -0
- data/lib/textus/store/entry/reader.rb +47 -0
- data/lib/textus/store/entry/writer.rb +219 -0
- 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 +76 -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 +124 -0
- data/lib/textus/store/jobs/queue.rb +162 -0
- data/lib/textus/store/jobs/registry.rb +19 -0
- 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 +8 -0
- data/lib/textus/store/jobs/sweep.rb +21 -0
- data/lib/textus/store/jobs/worker.rb +64 -0
- data/lib/textus/store/layout.rb +97 -0
- data/lib/textus/store.rb +63 -32
- 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 +14 -68
- data/lib/textus/surface/cli/sources.rb +41 -0
- data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +5 -6
- data/lib/textus/{surfaces → surface}/cli/verb/get.rb +7 -5
- 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/surface/cli/verb/put.rb +27 -0
- 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/surface/mcp/catalog.rb +58 -0
- data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -11
- data/lib/textus/surface/mcp/projector.rb +20 -0
- data/lib/textus/{surfaces → surface}/mcp/server.rb +24 -35
- data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
- 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/{core → value}/duration.rb +1 -4
- data/lib/textus/value/envelope.rb +90 -0
- data/lib/textus/value/etag.rb +39 -0
- data/lib/textus/value/payload.rb +7 -0
- data/lib/textus/value/result.rb +46 -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/verb_registry.rb +417 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/loader.rb +4 -4
- data/lib/textus/workflow/runner.rb +11 -33
- data/lib/textus.rb +4 -69
- metadata +142 -105
- data/lib/textus/action/accept.rb +0 -44
- data/lib/textus/action/audit.rb +0 -131
- data/lib/textus/action/base.rb +0 -42
- data/lib/textus/action/blame.rb +0 -97
- data/lib/textus/action/boot.rb +0 -17
- data/lib/textus/action/data_mv.rb +0 -69
- data/lib/textus/action/deps.rb +0 -34
- data/lib/textus/action/doctor.rb +0 -24
- data/lib/textus/action/drain.rb +0 -39
- data/lib/textus/action/enqueue.rb +0 -53
- data/lib/textus/action/get.rb +0 -78
- data/lib/textus/action/ingest.rb +0 -203
- data/lib/textus/action/jobs.rb +0 -36
- data/lib/textus/action/key_delete.rb +0 -36
- data/lib/textus/action/key_delete_prefix.rb +0 -44
- data/lib/textus/action/key_mv.rb +0 -129
- data/lib/textus/action/key_mv_prefix.rb +0 -57
- data/lib/textus/action/list.rb +0 -42
- data/lib/textus/action/propose.rb +0 -52
- data/lib/textus/action/published.rb +0 -24
- data/lib/textus/action/pulse.rb +0 -56
- data/lib/textus/action/put.rb +0 -52
- data/lib/textus/action/rdeps.rb +0 -39
- data/lib/textus/action/reject.rb +0 -37
- data/lib/textus/action/rule_explain.rb +0 -91
- data/lib/textus/action/rule_lint.rb +0 -68
- data/lib/textus/action/rule_list.rb +0 -42
- data/lib/textus/action/schema_envelope.rb +0 -29
- data/lib/textus/action/uid.rb +0 -33
- data/lib/textus/action/where.rb +0 -36
- 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/contract.rb +0 -174
- 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/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/gate/auth.rb +0 -227
- data/lib/textus/gate.rb +0 -116
- 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/ports/storage/file_store.rb +0 -26
- data/lib/textus/role.rb +0 -36
- data/lib/textus/schemas.rb +0 -54
- data/lib/textus/session.rb +0 -35
- data/lib/textus/surfaces/cli/verb/put.rb +0 -30
- data/lib/textus/surfaces/mcp/catalog.rb +0 -111
- data/lib/textus/surfaces/role_scope.rb +0 -34
- data/lib/textus/types.rb +0 -15
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require "yaml"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Handlers
|
|
5
|
+
module Write
|
|
6
|
+
class DataMv
|
|
7
|
+
def initialize(container:)
|
|
8
|
+
@container = container
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(command, _call)
|
|
12
|
+
manifest = @container.manifest
|
|
13
|
+
geom = @container.layout
|
|
14
|
+
|
|
15
|
+
return Value::Result.failure(:usage_error, "from and to required") if command.from.nil? || command.to.nil?
|
|
16
|
+
unless manifest.data.declared_lane_kinds.key?(command.from)
|
|
17
|
+
return Value::Result.failure(:usage_error,
|
|
18
|
+
"data lane '#{command.from}' not declared")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
dest_dir = geom.lane_path(command.to)
|
|
22
|
+
return Value::Result.failure(:usage_error, "destination 'data/#{command.to}' already exists") if File.exist?(dest_dir)
|
|
23
|
+
|
|
24
|
+
affected_keys = manifest.data.entries.select { |entry| entry.lane == command.from }.map(&:key)
|
|
25
|
+
|
|
26
|
+
steps = [{ "op" => "rename_zone", "from" => command.from, "to" => command.to }]
|
|
27
|
+
steps += affected_keys.map do |key|
|
|
28
|
+
{ "op" => "mv", "from" => key, "to" => "#{command.to}#{key[command.from.length..]}" }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
plan = Textus::Store::Jobs::Plan.new(steps: steps, warnings: [])
|
|
32
|
+
return Value::Result.success(plan) if command.dry_run
|
|
33
|
+
|
|
34
|
+
rewrite_manifest!(geom, from: command.from, to: command.to)
|
|
35
|
+
FileUtils.mv(geom.lane_path(command.from), dest_dir)
|
|
36
|
+
Value::Result.success(plan)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
private
|
|
40
|
+
|
|
41
|
+
def rewrite_manifest!(geom, from:, to:)
|
|
42
|
+
path = geom.manifest_path
|
|
43
|
+
raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
|
|
44
|
+
raw["lanes"].each { |lane| lane["name"] = to if lane["name"] == from }
|
|
45
|
+
raw["entries"].each do |entry|
|
|
46
|
+
entry["lane"] = to if entry["lane"] == from
|
|
47
|
+
entry["key"] = entry["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
|
|
48
|
+
entry["path"] = entry["path"].sub(%r{\A(data/)?#{Regexp.escape(from)}(/|\z)}, "\\1#{to}\\2")
|
|
49
|
+
end
|
|
50
|
+
File.write(path, YAML.dump(raw))
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Write
|
|
4
|
+
class DeleteKey
|
|
5
|
+
def initialize(container:)
|
|
6
|
+
@container = container
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, call)
|
|
10
|
+
writer = Store::Entry::Writer.from(container: @container, call: call)
|
|
11
|
+
writer.delete(command.key, if_etag: command.if_etag)
|
|
12
|
+
Value::Result.success("protocol" => Textus::PROTOCOL, "ok" => true, "key" => command.key, "deleted" => true)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Write
|
|
4
|
+
class EnqueueJob
|
|
5
|
+
def initialize(job_store:)
|
|
6
|
+
@job_store = job_store
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, call)
|
|
10
|
+
action_class = Textus::Jobs.fetch(command.type.to_s)
|
|
11
|
+
|
|
12
|
+
if action_class.const_defined?(:REQUIRED_ROLE) && call.role != action_class::REQUIRED_ROLE
|
|
13
|
+
return Value::Result.failure(:forbidden,
|
|
14
|
+
"role '#{call.role}' is not authorized to enqueue this job type",
|
|
15
|
+
details: { "role" => call.role, "required_role" => action_class::REQUIRED_ROLE })
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
job = Textus::Store::Jobs::Queue::Job.new(type: command.type, args: command.args, role: call.role, max_attempts: 3)
|
|
19
|
+
Textus::Store::Jobs::Queue.new(store: @job_store).enqueue(job)
|
|
20
|
+
Value::Result.success("protocol" => Textus::PROTOCOL, "ok" => true, "id" => job.id)
|
|
21
|
+
rescue Textus::UsageError
|
|
22
|
+
Value::Result.failure(:usage_error, "unregistered job type '#{command.type}'")
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Write
|
|
4
|
+
class KeyDeletePrefix
|
|
5
|
+
def initialize(orchestration:)
|
|
6
|
+
@orchestration = orchestration
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, call)
|
|
10
|
+
return Value::Result.failure(:usage_error, "prefix required") if command.prefix.nil? || command.prefix.empty?
|
|
11
|
+
|
|
12
|
+
list = @orchestration.list_keys(prefix: command.prefix, lane: nil, call: call)
|
|
13
|
+
return list if list.failure?
|
|
14
|
+
|
|
15
|
+
leaves = list.value.fetch("rows")
|
|
16
|
+
|
|
17
|
+
warnings = leaves.empty? ? ["no keys under #{command.prefix}"] : []
|
|
18
|
+
steps = leaves.map { |row| { "op" => "delete", "key" => row["key"] } }
|
|
19
|
+
|
|
20
|
+
plan = Textus::Store::Jobs::Plan.new(steps: steps, warnings: warnings)
|
|
21
|
+
return Value::Result.success(plan) if command.dry_run
|
|
22
|
+
|
|
23
|
+
steps.each do |step|
|
|
24
|
+
delete = @orchestration.delete_key(key: step["key"], call: call)
|
|
25
|
+
return delete if delete.failure?
|
|
26
|
+
end
|
|
27
|
+
Value::Result.success(plan)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Write
|
|
4
|
+
class KeyMvPrefix
|
|
5
|
+
def initialize(orchestration:)
|
|
6
|
+
@orchestration = orchestration
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, call)
|
|
10
|
+
if command.from_prefix.nil? || command.to_prefix.nil?
|
|
11
|
+
return Value::Result.failure(:usage_error,
|
|
12
|
+
"from_prefix and to_prefix required")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
list = @orchestration.list_keys(prefix: command.from_prefix, lane: nil, call: call)
|
|
16
|
+
return list if list.failure?
|
|
17
|
+
|
|
18
|
+
leaves = list.value.fetch("rows")
|
|
19
|
+
|
|
20
|
+
if leaves.any? { |r| r["key"] == command.from_prefix }
|
|
21
|
+
return Value::Result.failure(:usage_error,
|
|
22
|
+
"from_prefix '#{command.from_prefix}' is itself a leaf — use `mv` to rename a single key")
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
warnings = leaves.empty? ? ["no keys under #{command.from_prefix}"] : []
|
|
26
|
+
steps = leaves.map do |row|
|
|
27
|
+
old_key = row["key"]
|
|
28
|
+
tail = old_key.delete_prefix("#{command.from_prefix}.")
|
|
29
|
+
new_key = "#{command.to_prefix}.#{tail}"
|
|
30
|
+
{ "op" => "mv", "from" => old_key, "to" => new_key }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
plan = Textus::Store::Jobs::Plan.new(steps: steps, warnings: warnings)
|
|
34
|
+
return Value::Result.success(plan) if command.dry_run
|
|
35
|
+
|
|
36
|
+
steps.each do |step|
|
|
37
|
+
move = @orchestration.move_key(old_key: step["from"], new_key: step["to"], call: call)
|
|
38
|
+
return move if move.failure?
|
|
39
|
+
end
|
|
40
|
+
Value::Result.success(plan)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Write
|
|
4
|
+
class MoveKey
|
|
5
|
+
def initialize(container:, manifest:)
|
|
6
|
+
@container = container
|
|
7
|
+
@manifest = manifest
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def call(command, call)
|
|
11
|
+
Textus::Manifest::Data.validate_key!(command.old_key)
|
|
12
|
+
Textus::Manifest::Data.validate_key!(command.new_key)
|
|
13
|
+
|
|
14
|
+
return Value::Result.failure(:usage_error, "mv: old and new keys are identical") if command.old_key == command.new_key
|
|
15
|
+
|
|
16
|
+
old_res = @manifest.resolver.resolve(command.old_key)
|
|
17
|
+
new_res = @manifest.resolver.resolve(command.new_key)
|
|
18
|
+
|
|
19
|
+
reader = Store::Entry::Reader.from(container: @container)
|
|
20
|
+
|
|
21
|
+
unless reader.exists?(command.old_key)
|
|
22
|
+
return Value::Result.failure(:not_found,
|
|
23
|
+
"source key '#{command.old_key}' not found")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
zone_check = validate_zone(old_res.entry, new_res.entry)
|
|
27
|
+
return zone_check if zone_check
|
|
28
|
+
|
|
29
|
+
if reader.exists?(command.new_key)
|
|
30
|
+
return Value::Result.failure(:usage_error, "mv: target '#{command.new_key}' already exists at #{new_res.path}")
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
pre_env = reader.read(command.old_key)
|
|
34
|
+
writer = Store::Entry::Writer.from(container: @container, call: call)
|
|
35
|
+
unless pre_env.uid
|
|
36
|
+
writer.put(
|
|
37
|
+
command.old_key, mentry: old_res.entry,
|
|
38
|
+
payload: Textus::Value::Payload.new(meta: pre_env.meta, body: pre_env.body, content: pre_env.content)
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
if command.dry_run
|
|
43
|
+
return Value::Result.success({
|
|
44
|
+
"protocol" => Textus::PROTOCOL, "ok" => true, "dry_run" => true,
|
|
45
|
+
"from_key" => command.old_key, "to_key" => command.new_key,
|
|
46
|
+
"from_path" => old_res.path, "to_path" => new_res.path,
|
|
47
|
+
"uid" => pre_env.uid
|
|
48
|
+
})
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
envelope = writer.move(
|
|
52
|
+
from_key: command.old_key, to_key: command.new_key,
|
|
53
|
+
new_mentry: new_res.entry
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
Value::Result.success({
|
|
57
|
+
"protocol" => Textus::PROTOCOL, "ok" => true,
|
|
58
|
+
"from_key" => command.old_key, "to_key" => command.new_key,
|
|
59
|
+
"from_path" => old_res.path, "to_path" => new_res.path,
|
|
60
|
+
"uid" => envelope.uid, "envelope" => envelope.to_h_for_wire
|
|
61
|
+
})
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
private
|
|
65
|
+
|
|
66
|
+
def validate_zone(old_mentry, new_mentry)
|
|
67
|
+
if old_mentry.lane != new_mentry.lane
|
|
68
|
+
return Value::Result.failure(:usage_error,
|
|
69
|
+
"mv: cross-zone refused (#{old_mentry.lane} -> #{new_mentry.lane}). Use put+delete.")
|
|
70
|
+
end
|
|
71
|
+
if old_mentry.format != new_mentry.format
|
|
72
|
+
return Value::Result.failure(:usage_error,
|
|
73
|
+
"mv: format mismatch (#{old_mentry.format} -> #{new_mentry.format}); refusing.")
|
|
74
|
+
end
|
|
75
|
+
nil
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Write
|
|
4
|
+
class ProposeEntry
|
|
5
|
+
def initialize(container:)
|
|
6
|
+
@container = container
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, call)
|
|
10
|
+
zone = @container.manifest.policy.propose_lane_for(call.role)
|
|
11
|
+
unless zone
|
|
12
|
+
return Value::Result.failure(:propose_forbidden,
|
|
13
|
+
"role '#{call.role}' has no writable propose_lane",
|
|
14
|
+
details: { "role" => call.role })
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
key = "#{zone}.#{command.key}"
|
|
18
|
+
mentry = @container.manifest.resolver.resolve(key).entry
|
|
19
|
+
writer = Store::Entry::Writer.from(container: @container, call: call)
|
|
20
|
+
envelope = writer.put(
|
|
21
|
+
key, mentry: mentry,
|
|
22
|
+
payload: Textus::Value::Payload.new(meta: command.meta || {}, body: command.body, content: command.content)
|
|
23
|
+
)
|
|
24
|
+
Value::Result.success(envelope)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Write
|
|
4
|
+
class PutEntry
|
|
5
|
+
def initialize(container:)
|
|
6
|
+
@container = container
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, call)
|
|
10
|
+
Textus::Manifest::Data.validate_key!(command.key)
|
|
11
|
+
mentry = @container.manifest.resolver.resolve(command.key).entry
|
|
12
|
+
|
|
13
|
+
writer = Store::Entry::Writer.from(container: @container, call: call)
|
|
14
|
+
envelope = writer.put(
|
|
15
|
+
command.key,
|
|
16
|
+
mentry: mentry,
|
|
17
|
+
payload: Textus::Value::Payload.new(
|
|
18
|
+
meta: command.meta,
|
|
19
|
+
body: command.body,
|
|
20
|
+
content: command.content,
|
|
21
|
+
),
|
|
22
|
+
if_etag: command.if_etag,
|
|
23
|
+
)
|
|
24
|
+
Value::Result.success(envelope)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Write
|
|
4
|
+
class RejectProposal
|
|
5
|
+
def initialize(container:)
|
|
6
|
+
@container = container
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, call)
|
|
10
|
+
mentry = @container.manifest.resolver.resolve(command.pending_key).entry
|
|
11
|
+
unless mentry.in_proposal_lane?(@container.manifest.policy)
|
|
12
|
+
return Value::Result.failure(:proposal_error,
|
|
13
|
+
"reject: '#{command.pending_key}' is not in a proposal zone (zone=#{mentry.lane})")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
reader = Store::Entry::Reader.from(container: @container)
|
|
17
|
+
env = reader.read(command.pending_key)
|
|
18
|
+
proposal = env&.meta&.dig("proposal") or
|
|
19
|
+
return Value::Result.failure(:proposal_error, "entry has no proposal block: #{command.pending_key}")
|
|
20
|
+
target_key = proposal["target_key"]
|
|
21
|
+
|
|
22
|
+
writer = Store::Entry::Writer.from(container: @container, call: call)
|
|
23
|
+
writer.delete(command.pending_key, mentry: mentry)
|
|
24
|
+
Value::Result.success("protocol" => Textus::PROTOCOL, "rejected" => command.pending_key, "target_key" => target_key)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
data/lib/textus/init.rb
CHANGED
|
@@ -3,7 +3,7 @@ require "pathname"
|
|
|
3
3
|
|
|
4
4
|
module Textus
|
|
5
5
|
module Init
|
|
6
|
-
ZONES = %w[knowledge
|
|
6
|
+
ZONES = %w[knowledge scratchpad proposals artifacts raw].freeze
|
|
7
7
|
|
|
8
8
|
DEFAULT_MANIFEST = <<~YAML
|
|
9
9
|
version: textus/4
|
|
@@ -13,13 +13,13 @@ module Textus
|
|
|
13
13
|
- { name: automation, can: [converge] }
|
|
14
14
|
lanes:
|
|
15
15
|
- { name: knowledge, kind: canon, desc: "the maintained source of truth (identity.* lives here)" }
|
|
16
|
-
- { name:
|
|
16
|
+
- { name: scratchpad, kind: workspace, owner: agent, desc: "the agent's own durable working notes" }
|
|
17
17
|
- { name: proposals, kind: queue, desc: "changes awaiting your accept" }
|
|
18
18
|
- { name: artifacts, kind: machine, desc: "machine-maintained: external inputs (artifacts.feeds.*) + computed outputs (artifacts.derived.*)" }
|
|
19
19
|
entries:
|
|
20
20
|
- { key: knowledge.identity, path: data/knowledge/identity.md, lane: knowledge, schema: null, owner: human:self, kind: leaf }
|
|
21
21
|
- { key: knowledge.notes, path: data/knowledge/notes, lane: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
|
|
22
|
-
- { key:
|
|
22
|
+
- { key: scratchpad.notes, path: data/scratchpad/notes, lane: scratchpad, schema: null, owner: agent:self, nested: true, kind: nested }
|
|
23
23
|
- { key: proposals.notes, path: data/proposals/notes, lane: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
|
|
24
24
|
# A per-host snapshot, populated by a registered workflow. Nested so it
|
|
25
25
|
# grows to a fleet — add leaves over SSH without renaming. tracked:false →
|
|
@@ -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::Layout.
|
|
96
|
-
FileUtils.mkdir_p(Textus::Layout.cursors(target_root))
|
|
97
|
-
FileUtils.mkdir_p(Textus::Layout.locks(target_root))
|
|
95
|
+
FileUtils.mkdir_p(Textus::Store::Layout.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::Layout.gitignore_body(
|
|
154
|
+
Textus::Store::Layout.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
|
|
@@ -7,13 +7,13 @@ module Textus
|
|
|
7
7
|
# Fallback role set for a manifest that omits `roles:` entirely. Agent
|
|
8
8
|
# is intentionally minimal here (`propose` only) — narrower than the
|
|
9
9
|
# `textus init` scaffold, which declares `agent: [propose, keep]` so the
|
|
10
|
-
# default `
|
|
10
|
+
# default `scratchpad` workspace is writable. A roles-less manifest that
|
|
11
11
|
# declares a `kind: workspace` zone is therefore rejected at load (no
|
|
12
12
|
# `keep`-holder); declare `roles:` to opt into a workspace lane (ADR 0033).
|
|
13
13
|
DEFAULT_MAPPING = {
|
|
14
|
-
Textus::Role::HUMAN => %w[author propose].freeze,
|
|
15
|
-
Textus::Role::AGENT => %w[propose].freeze,
|
|
16
|
-
Textus::Role::AUTOMATION => %w[converge].freeze,
|
|
14
|
+
Textus::Value::Role::HUMAN => %w[author propose].freeze,
|
|
15
|
+
Textus::Value::Role::AGENT => %w[propose].freeze,
|
|
16
|
+
Textus::Value::Role::AUTOMATION => %w[converge].freeze,
|
|
17
17
|
}.freeze
|
|
18
18
|
|
|
19
19
|
# Returns { role_name => [verbs] }. When `roles:` is declared we use
|
|
@@ -4,7 +4,7 @@ module Textus
|
|
|
4
4
|
class Base < Entry
|
|
5
5
|
attr_reader :raw, :key, :path, :lane, :schema, :owner, :format, :publish_targets
|
|
6
6
|
|
|
7
|
-
# rubocop:disable
|
|
7
|
+
# rubocop:disable Lint/MissingSuper
|
|
8
8
|
def initialize(raw:, key:, path:, lane:, schema:, owner:, format:, publish_targets: [])
|
|
9
9
|
@raw = raw
|
|
10
10
|
@key = key
|
|
@@ -15,7 +15,7 @@ module Textus
|
|
|
15
15
|
@format = format
|
|
16
16
|
@publish_targets = Array(publish_targets)
|
|
17
17
|
end
|
|
18
|
-
# rubocop:enable
|
|
18
|
+
# rubocop:enable Lint/MissingSuper
|
|
19
19
|
|
|
20
20
|
def lane_writers(policy)
|
|
21
21
|
verb = policy.verb_for_lane(@lane)
|
|
@@ -75,7 +75,7 @@ module Textus
|
|
|
75
75
|
# Read a named template from the store's templates/ directory.
|
|
76
76
|
# Raises TemplateError when the file doesn't exist.
|
|
77
77
|
def read_template(name)
|
|
78
|
-
path =
|
|
78
|
+
path = container.layout.template_path(name)
|
|
79
79
|
unless File.exist?(path)
|
|
80
80
|
raise Textus::TemplateError.new(
|
|
81
81
|
"template '#{name}' not found",
|
|
@@ -9,7 +9,7 @@ module Textus
|
|
|
9
9
|
# shared shape — Tree always walks at `base` and honors `ignore` in the
|
|
10
10
|
# prune (ADR 0047 D4, so a derived index in the mirrored dir survives).
|
|
11
11
|
class SubtreeMirror
|
|
12
|
-
def initialize(entry, pctx, publisher: Textus::
|
|
12
|
+
def initialize(entry, pctx, publisher: Textus::Port::Publisher.new)
|
|
13
13
|
@entry = entry
|
|
14
14
|
@pctx = pctx
|
|
15
15
|
@publisher = publisher
|
|
@@ -52,7 +52,7 @@ module Textus
|
|
|
52
52
|
# targets_under can't reach another leaf's sentinels.
|
|
53
53
|
def prune(target_dir, written, honor_ignore)
|
|
54
54
|
kept = written.map { |w| File.expand_path(w["target"]) }
|
|
55
|
-
store = Textus::
|
|
55
|
+
store = Textus::Port::SentinelStore.new
|
|
56
56
|
store.targets_under(target_dir, @pctx.root).filter_map do |managed|
|
|
57
57
|
abs = File.expand_path(managed)
|
|
58
58
|
next nil if kept.include?(abs)
|
|
@@ -9,12 +9,12 @@ module Textus
|
|
|
9
9
|
# ADR 0094: iterates publish_targets (to-targets), rendering through a
|
|
10
10
|
# template when the target declares one, or copying verbatim otherwise.
|
|
11
11
|
class ToPaths < Mode
|
|
12
|
-
def initialize(entry, publisher: Textus::
|
|
12
|
+
def initialize(entry, publisher: Textus::Port::Publisher.new)
|
|
13
13
|
super(entry)
|
|
14
14
|
@publisher = publisher
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
17
|
+
def publish(pctx, prefix: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
18
18
|
targets = entry.publish_targets.select(&:to_target?)
|
|
19
19
|
|
|
20
20
|
return nil if targets.empty?
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
class AuthorHeld
|
|
6
|
+
def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
|
|
7
|
+
holders = manifest.policy.roles_with_capability("author")
|
|
8
|
+
pass = holders.include?(actor.to_s)
|
|
9
|
+
reason = if pass
|
|
10
|
+
nil
|
|
11
|
+
elsif holders.empty?
|
|
12
|
+
"no role holds the 'author' capability; #{action} is disabled"
|
|
13
|
+
else
|
|
14
|
+
"role '#{actor}' lacks the 'author' capability (held by: #{holders.join(", ")})"
|
|
15
|
+
end
|
|
16
|
+
{ pass:, reason: }
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
class EtagMatch
|
|
6
|
+
def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
|
|
7
|
+
if_etag = extra[:if_etag]
|
|
8
|
+
return { pass: true } if if_etag.nil?
|
|
9
|
+
|
|
10
|
+
current = envelope&.etag
|
|
11
|
+
pass = current.nil? || current == if_etag
|
|
12
|
+
{ pass:, error: pass ? nil : Textus::EtagMismatch.new(key, if_etag, current) }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
class LaneDeletableBy
|
|
6
|
+
def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
|
|
7
|
+
return { pass: true } if key.nil?
|
|
8
|
+
|
|
9
|
+
mentry = manifest.resolver.resolve(key).entry
|
|
10
|
+
is_raw = manifest.policy.declared_kind(mentry.lane.to_s) == :raw
|
|
11
|
+
lane_verb = manifest.policy.verb_for_lane(mentry.lane.to_s)
|
|
12
|
+
caps = Set.new(manifest.data.role_caps.fetch(actor.to_s, []))
|
|
13
|
+
|
|
14
|
+
pass = if is_raw
|
|
15
|
+
caps.include?("author")
|
|
16
|
+
else
|
|
17
|
+
caps.include?(lane_verb.to_s) || caps.include?("author")
|
|
18
|
+
end
|
|
19
|
+
return { pass: true } if pass
|
|
20
|
+
|
|
21
|
+
extra_holders = is_raw ? ["author"] : [lane_verb.to_s, "author"]
|
|
22
|
+
holders = extra_holders.flat_map { |v| manifest.policy.roles_with_capability(v) }.uniq
|
|
23
|
+
{ pass: false, error: Textus::WriteForbidden.new(mentry.key, mentry.lane, verb: lane_verb, holders:) }
|
|
24
|
+
rescue Textus::UnknownKey
|
|
25
|
+
{ pass: true }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
class LaneWritableBy
|
|
6
|
+
def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
|
|
7
|
+
return { pass: true } if key.nil?
|
|
8
|
+
|
|
9
|
+
mentry = manifest.resolver.resolve(key).entry
|
|
10
|
+
lane_verb = manifest.policy.verb_for_lane(mentry.lane.to_s)
|
|
11
|
+
caps = Set.new(manifest.data.role_caps.fetch(actor.to_s, []))
|
|
12
|
+
return { pass: true } if caps.include?(lane_verb.to_s)
|
|
13
|
+
|
|
14
|
+
holders = manifest.policy.roles_with_capability(lane_verb.to_s)
|
|
15
|
+
{ pass: false, error: Textus::WriteForbidden.new(mentry.key, mentry.lane, verb: lane_verb, holders:) }
|
|
16
|
+
rescue Textus::UnknownKey
|
|
17
|
+
{ pass: true }
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
class RawLaneIngestOnly
|
|
6
|
+
def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
|
|
7
|
+
return { pass: true } if key.nil?
|
|
8
|
+
|
|
9
|
+
mentry = manifest.resolver.resolve(key).entry
|
|
10
|
+
return { pass: true } unless manifest.policy.declared_kind(mentry.lane.to_s) == :raw
|
|
11
|
+
return { pass: true } if action == :ingest
|
|
12
|
+
|
|
13
|
+
{ pass: false, error: Textus::Error.new(
|
|
14
|
+
:raw_lane_ingest_only,
|
|
15
|
+
"raw lane '#{mentry.lane}' only accepts `textus ingest` — " \
|
|
16
|
+
"use that verb instead of '#{action}'",
|
|
17
|
+
) }
|
|
18
|
+
rescue Textus::UnknownKey
|
|
19
|
+
{ pass: true }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Manifest
|
|
3
|
+
class Policy
|
|
4
|
+
module Predicates
|
|
5
|
+
class RawWriteOnce
|
|
6
|
+
def self.call(manifest:, actor:, action:, key:, schemas: nil, envelope: nil, extra: {})
|
|
7
|
+
return { pass: true } if key.nil?
|
|
8
|
+
|
|
9
|
+
path = manifest.resolver.resolve(key).path
|
|
10
|
+
return { pass: true } unless File.exist?(path)
|
|
11
|
+
|
|
12
|
+
{ pass: false, error: Textus::Error.new(
|
|
13
|
+
:raw_write_once,
|
|
14
|
+
"raw entry '#{key}' already exists; " \
|
|
15
|
+
"delete it first (`textus key-delete #{key}`), then re-ingest",
|
|
16
|
+
) }
|
|
17
|
+
rescue Textus::UnknownKey
|
|
18
|
+
{ pass: true }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|