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,66 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class PulseEntries
|
|
5
|
+
def initialize(manifest:, audit_log:, file_store:, orchestration:, job_store: nil)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
@audit_log = audit_log
|
|
8
|
+
@file_store = file_store
|
|
9
|
+
@orchestration = orchestration
|
|
10
|
+
@job_store = job_store
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(command, call)
|
|
14
|
+
root = @manifest.data.root
|
|
15
|
+
since = command.since || Textus::Store::Cursor.new(root: root, role: call.role).read
|
|
16
|
+
|
|
17
|
+
changed = changed_since(since, call)
|
|
18
|
+
|
|
19
|
+
result = {
|
|
20
|
+
"cursor" => @audit_log.latest_seq,
|
|
21
|
+
"changed" => changed,
|
|
22
|
+
"pending_review" => review_keys(call),
|
|
23
|
+
"contract_etag" => Textus::Value::Etag.for_contract(root),
|
|
24
|
+
"index_etag" => index_etag,
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Textus::Store::Cursor.new(root: root, role: call.role).write(result["cursor"])
|
|
28
|
+
Value::Result.success(result)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def changed_since(since, call)
|
|
34
|
+
if @job_store
|
|
35
|
+
sqlite_rows = @job_store.audit_events_since(seq: since)
|
|
36
|
+
return sqlite_rows.map { |r| { "key" => r["key"], "verb" => r["verb"], "seq" => r["seq"] } } if sqlite_rows.any?
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Fall back to flat-log scan when SQLite index is empty for this window
|
|
40
|
+
# (writes that bypassed dispatch, fresh stores, or pre-migration entries).
|
|
41
|
+
audit = @orchestration.audit_entries(seq_since: since, call: call)
|
|
42
|
+
return [] if audit.failure?
|
|
43
|
+
|
|
44
|
+
audit.value.fetch("rows") || []
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def review_keys(call)
|
|
48
|
+
queue = @manifest.policy.queue_lane
|
|
49
|
+
return [] unless queue
|
|
50
|
+
|
|
51
|
+
result = @orchestration.list_keys(prefix: nil, lane: queue, call: call)
|
|
52
|
+
return [] unless result.success?
|
|
53
|
+
|
|
54
|
+
result.value.fetch("rows").map { |r| r["key"] }
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def index_etag
|
|
58
|
+
path = @manifest.resolver.resolve("artifacts.system.index").path
|
|
59
|
+
File.exist?(path) ? @file_store.etag(path) : nil
|
|
60
|
+
rescue Textus::Error
|
|
61
|
+
nil
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class RdepsEntry
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, _call)
|
|
10
|
+
rdeps = @manifest.data.entries.each_with_object([]) do |entry, acc|
|
|
11
|
+
next unless entry.external?
|
|
12
|
+
|
|
13
|
+
sources = Array(entry.source&.sources).compact
|
|
14
|
+
acc << entry.key if sources.any? { |source| source == command.key || command.key.start_with?("#{source}.") }
|
|
15
|
+
end
|
|
16
|
+
Value::Result.success("key" => command.key, "rdeps" => rdeps)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class UidEntry
|
|
5
|
+
def initialize(container:)
|
|
6
|
+
@container = container
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, _call)
|
|
10
|
+
envelope = Store::Entry::Reader.from(container: @container).read(command.key)
|
|
11
|
+
return Value::Result.failure(:not_found, "no entry at #{command.key}") unless envelope
|
|
12
|
+
|
|
13
|
+
Value::Result.success(envelope.uid)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Read
|
|
4
|
+
class WhereEntry
|
|
5
|
+
def initialize(manifest:)
|
|
6
|
+
@manifest = manifest
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, _call)
|
|
10
|
+
res = @manifest.resolver.resolve(command.key)
|
|
11
|
+
mentry = res.entry
|
|
12
|
+
Value::Result.success("protocol" => Textus::PROTOCOL, "key" => command.key,
|
|
13
|
+
"lane" => mentry.lane, "owner" => mentry.owner, "path" => res.path)
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Handlers
|
|
3
|
+
module Write
|
|
4
|
+
class AcceptProposal
|
|
5
|
+
def initialize(container:)
|
|
6
|
+
@container = container
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def call(command, call)
|
|
10
|
+
reader = Store::Entry::Reader.from(container: @container)
|
|
11
|
+
env = reader.read(command.pending_key)
|
|
12
|
+
proposal = env&.meta&.dig("proposal") or
|
|
13
|
+
return Value::Result.failure(:proposal_error, "entry has no proposal block: #{command.pending_key}")
|
|
14
|
+
target = proposal["target_key"] or
|
|
15
|
+
return Value::Result.failure(:proposal_error, "proposal missing target_key")
|
|
16
|
+
action = proposal["action"] || "put"
|
|
17
|
+
|
|
18
|
+
writer = Store::Entry::Writer.from(container: @container, call: call)
|
|
19
|
+
case action
|
|
20
|
+
when "put"
|
|
21
|
+
mentry = @container.manifest.resolver.resolve(target).entry
|
|
22
|
+
writer.put(
|
|
23
|
+
target, mentry: mentry,
|
|
24
|
+
payload: Textus::Value::Payload.new(meta: env.meta["_meta"] || {}, body: env.body, content: nil)
|
|
25
|
+
)
|
|
26
|
+
when "delete"
|
|
27
|
+
writer.delete(target)
|
|
28
|
+
else
|
|
29
|
+
return Value::Result.failure(:proposal_error, "unknown action: #{action}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
writer.delete(command.pending_key)
|
|
33
|
+
Value::Result.success("protocol" => Textus::PROTOCOL, "accepted" => command.pending_key,
|
|
34
|
+
"target_key" => target, "action" => action, "cascade_key" => target)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -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,7 +92,7 @@ module Textus
|
|
|
92
92
|
end
|
|
93
93
|
|
|
94
94
|
def self.setup_state_dirs(target_root)
|
|
95
|
-
FileUtils.mkdir_p(Textus::Store::
|
|
95
|
+
FileUtils.mkdir_p(Textus::Store::Layout.new(target_root).audit_dir_path)
|
|
96
96
|
end
|
|
97
97
|
|
|
98
98
|
def self.write_gitignore(target_root)
|
|
@@ -151,7 +151,7 @@ module Textus
|
|
|
151
151
|
Pathname.new(Textus::Key::Path.resolve(manifest.data, e)).relative_path_from(root).to_s
|
|
152
152
|
end
|
|
153
153
|
end
|
|
154
|
-
Textus::Store::
|
|
154
|
+
Textus::Store::Layout.new(target_root).gitignore_body(untracked_entries: untracked)
|
|
155
155
|
end
|
|
156
156
|
end
|
|
157
157
|
end
|
|
@@ -7,7 +7,7 @@ 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 = {
|
|
@@ -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 = container.
|
|
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",
|
|
@@ -14,7 +14,7 @@ module Textus
|
|
|
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
|