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
|
@@ -13,6 +13,11 @@ module Textus
|
|
|
13
13
|
"proposal.rejected" => %w[materialize],
|
|
14
14
|
}.freeze
|
|
15
15
|
|
|
16
|
+
ENTRY_LEVEL_TRIGGERS = %w[
|
|
17
|
+
entry.written entry.deleted entry.moved
|
|
18
|
+
proposal.accepted proposal.rejected
|
|
19
|
+
].freeze
|
|
20
|
+
|
|
16
21
|
SCOPE_RESOLVERS = {
|
|
17
22
|
"materialize" => :producible_keys,
|
|
18
23
|
"sweep" => :lane_keys,
|
|
@@ -37,21 +42,21 @@ module Textus
|
|
|
37
42
|
end
|
|
38
43
|
|
|
39
44
|
def plan(trigger:, role:)
|
|
40
|
-
type
|
|
41
|
-
trigger["target"] || trigger[:target]
|
|
45
|
+
type = trigger["type"] || trigger[:type]
|
|
46
|
+
target = trigger["target"] || trigger[:target]
|
|
42
47
|
return [] if type.nil?
|
|
43
48
|
|
|
44
49
|
blocks_with_react = @manifest.rules.blocks.select(&:react)
|
|
45
50
|
if blocks_with_react.any?
|
|
46
|
-
plan_from_rules(blocks_with_react, type, role)
|
|
51
|
+
plan_from_rules(blocks_with_react, type, role, target: target)
|
|
47
52
|
else
|
|
48
|
-
plan_from_defaults(type, role)
|
|
53
|
+
plan_from_defaults(type, role, target: target)
|
|
49
54
|
end
|
|
50
55
|
end
|
|
51
56
|
|
|
52
57
|
private
|
|
53
58
|
|
|
54
|
-
def plan_from_rules(blocks, type, role)
|
|
59
|
+
def plan_from_rules(blocks, type, role, target: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
55
60
|
jobs = []
|
|
56
61
|
blocks
|
|
57
62
|
.select { |b| matches_trigger?(b.react, type) }
|
|
@@ -70,10 +75,13 @@ module Textus
|
|
|
70
75
|
jobs
|
|
71
76
|
end
|
|
72
77
|
|
|
73
|
-
def plan_from_defaults(type, role)
|
|
78
|
+
def plan_from_defaults(type, role, target: nil)
|
|
74
79
|
actions = ACTIONS_BY_TRIGGER.fetch(type, [])
|
|
75
80
|
jobs = []
|
|
76
|
-
|
|
81
|
+
if actions.include?("materialize")
|
|
82
|
+
keys = target && ENTRY_LEVEL_TRIGGERS.include?(type) ? dependent_keys(target) : producible_keys(nil)
|
|
83
|
+
keys.each { |k| jobs << job("materialize", k, role) }
|
|
84
|
+
end
|
|
77
85
|
GLOBAL_ACTIONS.each do |action, args|
|
|
78
86
|
jobs << Textus::Store::Jobs::Queue::Job.new(type: action, args: args, role: role) if actions.include?(action)
|
|
79
87
|
end
|
|
@@ -98,6 +106,18 @@ module Textus
|
|
|
98
106
|
def lane_keys(_target)
|
|
99
107
|
@manifest.data.entries.map(&:key)
|
|
100
108
|
end
|
|
109
|
+
|
|
110
|
+
def dependent_keys(target_key)
|
|
111
|
+
@manifest.data.entries
|
|
112
|
+
.select(&:external?)
|
|
113
|
+
.select do |e|
|
|
114
|
+
Array(e.source&.sources).compact.any? do |s|
|
|
115
|
+
target_key == s || target_key.start_with?("#{s}.")
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
.select { |e| !e.publish_tree.nil? || !e.publish_to.empty? }
|
|
119
|
+
.map(&:key)
|
|
120
|
+
end
|
|
101
121
|
end
|
|
102
122
|
end
|
|
103
123
|
end
|
|
@@ -44,11 +44,19 @@ module Textus
|
|
|
44
44
|
|
|
45
45
|
def enqueue(job)
|
|
46
46
|
now = iso_now
|
|
47
|
-
@store.execute(
|
|
47
|
+
inserted = @store.execute(
|
|
48
48
|
"INSERT OR IGNORE INTO jobs (id, type, args, state, role, attempts, max_attempts, errors, lease, created_at, updated_at)
|
|
49
49
|
VALUES (?, ?, ?, 'ready', ?, ?, ?, ?, NULL, ?, ?)",
|
|
50
50
|
[job.id, job.type, JSON.dump(job.args), job.role, job.attempts, job.max_attempts, JSON.dump(job.errors), now, now],
|
|
51
51
|
)
|
|
52
|
+
return inserted if @store.query_value("SELECT changes()")&.to_i&.positive?
|
|
53
|
+
|
|
54
|
+
@store.execute(
|
|
55
|
+
"UPDATE jobs
|
|
56
|
+
SET state = 'ready', role = ?, args = ?, attempts = 0, errors = ?, lease = NULL, max_attempts = ?, updated_at = ?
|
|
57
|
+
WHERE id = ? AND state IN ('done', 'failed')",
|
|
58
|
+
[job.role, JSON.dump(job.args), JSON.dump([]), job.max_attempts, now, job.id],
|
|
59
|
+
)
|
|
52
60
|
end
|
|
53
61
|
|
|
54
62
|
def ready_ids
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
module Jobs
|
|
6
|
+
module Retention
|
|
7
|
+
class Base
|
|
8
|
+
def initialize(container:, call:)
|
|
9
|
+
@container = container
|
|
10
|
+
@call = call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(rows)
|
|
14
|
+
out = { dropped: [], archived: [], failed: [] }
|
|
15
|
+
rows.each do |row|
|
|
16
|
+
key = row["key"]
|
|
17
|
+
begin
|
|
18
|
+
case row["action"]
|
|
19
|
+
when "drop"
|
|
20
|
+
delete(key)
|
|
21
|
+
out[:dropped] << key
|
|
22
|
+
when "archive"
|
|
23
|
+
archive_leaf(row)
|
|
24
|
+
delete(key)
|
|
25
|
+
out[:archived] << key
|
|
26
|
+
end
|
|
27
|
+
rescue Textus::Error => e
|
|
28
|
+
out[:failed] << { "key" => key, "error" => e.message }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
out
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def archive_leaf(row)
|
|
37
|
+
src = row["path"]
|
|
38
|
+
dest = @container.layout.archive_path(src)
|
|
39
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
40
|
+
FileUtils.cp(src, dest)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def delete(key)
|
|
44
|
+
mentry = @container.manifest.resolver.resolve(key).entry
|
|
45
|
+
writer = Textus::Store::Entry::Writer.from(container: @container, call: @call)
|
|
46
|
+
writer.delete(key, mentry: mentry)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require "time"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
module Jobs
|
|
6
|
+
module Retention
|
|
7
|
+
class Sweep
|
|
8
|
+
def self.expired?(ttl_seconds:, mtime:, now:)
|
|
9
|
+
return false if ttl_seconds.nil? || mtime.nil?
|
|
10
|
+
|
|
11
|
+
(now - mtime).to_i > ttl_seconds
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def initialize(manifest:, file_stat:, clock:)
|
|
15
|
+
@manifest = manifest
|
|
16
|
+
@file_stat = file_stat
|
|
17
|
+
@clock = clock
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def call(prefix: nil, lane: nil)
|
|
21
|
+
@manifest.data.entries
|
|
22
|
+
.select { |m| matches?(m, prefix: prefix, lane: lane) }
|
|
23
|
+
.flat_map { |m| rows_for(m) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def matches?(mentry, prefix:, lane:)
|
|
29
|
+
return false if lane && mentry.lane != lane
|
|
30
|
+
return false if prefix && !Textus::Key::Matching.matches_prefix?(
|
|
31
|
+
mentry.key, prefix, nested: mentry.is_a?(Textus::Manifest::Entry::Nested)
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def rows_for(mentry)
|
|
38
|
+
policy = @manifest.rules.for(mentry.key).retention
|
|
39
|
+
return [] if policy.nil?
|
|
40
|
+
|
|
41
|
+
@manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
|
|
42
|
+
path = row[:path]
|
|
43
|
+
next unless @file_stat.exists?(path)
|
|
44
|
+
next unless self.class.expired?(
|
|
45
|
+
ttl_seconds: policy.ttl_seconds, mtime: @file_stat.mtime(path), now: @clock.now,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
{ "key" => row[:key], "path" => path, "action" => policy.action.to_s }
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -1,49 +1,7 @@
|
|
|
1
|
-
require "fileutils"
|
|
2
|
-
|
|
3
1
|
module Textus
|
|
4
2
|
class Store
|
|
5
3
|
module Jobs
|
|
6
|
-
|
|
7
|
-
def initialize(container:, call:)
|
|
8
|
-
@container = container
|
|
9
|
-
@call = call
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def call(rows)
|
|
13
|
-
out = { dropped: [], archived: [], failed: [] }
|
|
14
|
-
rows.each do |row|
|
|
15
|
-
key = row["key"]
|
|
16
|
-
begin
|
|
17
|
-
case row["action"]
|
|
18
|
-
when "drop"
|
|
19
|
-
delete(key)
|
|
20
|
-
out[:dropped] << key
|
|
21
|
-
when "archive"
|
|
22
|
-
archive_leaf(row)
|
|
23
|
-
delete(key)
|
|
24
|
-
out[:archived] << key
|
|
25
|
-
end
|
|
26
|
-
rescue Textus::Error => e
|
|
27
|
-
out[:failed] << { "key" => key, "error" => e.message }
|
|
28
|
-
end
|
|
29
|
-
end
|
|
30
|
-
out
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
private
|
|
34
|
-
|
|
35
|
-
def archive_leaf(row)
|
|
36
|
-
src = row["path"]
|
|
37
|
-
root = @container.root.to_s
|
|
38
|
-
rel = src.delete_prefix("#{root}/")
|
|
39
|
-
dest = File.join(root, "archive", rel)
|
|
40
|
-
FileUtils.mkdir_p(File.dirname(dest))
|
|
41
|
-
FileUtils.cp(src, dest)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def delete(key)
|
|
45
|
-
Textus::Action::KeyDelete.call(container: @container, call: @call, key: key)
|
|
46
|
-
end
|
|
4
|
+
module Retention
|
|
47
5
|
end
|
|
48
6
|
end
|
|
49
7
|
end
|
|
@@ -8,12 +8,12 @@ module Textus
|
|
|
8
8
|
def self.call(container:, call:, scope: {}, key: nil)
|
|
9
9
|
prefix = key || (scope.is_a?(Hash) ? scope["prefix"] : nil)
|
|
10
10
|
lane = scope.is_a?(Hash) ? scope["lane"] : nil
|
|
11
|
-
rows =
|
|
11
|
+
rows = Retention::Sweep.new(
|
|
12
12
|
manifest: container.manifest,
|
|
13
13
|
file_stat: Textus::Port::Storage::FileStat.new,
|
|
14
14
|
clock: Textus::Port::Clock.new,
|
|
15
15
|
).call(prefix: prefix, lane: lane)
|
|
16
|
-
|
|
16
|
+
Retention::Base.new(container: container, call: call).call(rows)
|
|
17
17
|
end
|
|
18
18
|
end
|
|
19
19
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Store
|
|
3
|
-
class
|
|
3
|
+
class Layout
|
|
4
4
|
RUN = ".state"
|
|
5
5
|
DATA = "data"
|
|
6
6
|
ASSETS = "assets"
|
|
@@ -32,12 +32,28 @@ module Textus
|
|
|
32
32
|
def lock_path(name) = File.join(run_root, "ephemeral", "locks", "#{name}.lock")
|
|
33
33
|
def audit_dir_path = File.join(run_root, "audit")
|
|
34
34
|
def audit_log_path = File.join(audit_dir_path, "audit.log")
|
|
35
|
+
def audit_rotated_log_path(n) = File.join(audit_dir_path, "audit.log.#{n}")
|
|
36
|
+
def audit_rotated_meta_path(n) = File.join(audit_dir_path, "audit.log.#{n}.meta.json")
|
|
37
|
+
def audit_log_glob = File.join(audit_dir_path, "audit.log.*")
|
|
35
38
|
def sentinels_root = File.join(run_root, "tracking", "sentinels")
|
|
36
39
|
def store_db_path = File.join(run_root, "store.db")
|
|
37
40
|
|
|
38
41
|
# -- asset paths --
|
|
39
|
-
def
|
|
40
|
-
File.join(@root, ASSETS,
|
|
42
|
+
def asset_raw_dir(date_path, zone)
|
|
43
|
+
File.join(@root, ASSETS, "raw", date_path, zone.to_s)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def asset_sentinel_path
|
|
47
|
+
File.join(@root, ASSETS, ".gitignore")
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def asset_resolve(rel_path)
|
|
51
|
+
File.join(@root, ASSETS, rel_path)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def archive_path(source_path)
|
|
55
|
+
rel = source_path.delete_prefix("#{@root}/")
|
|
56
|
+
File.join(@root, "archive", rel)
|
|
41
57
|
end
|
|
42
58
|
|
|
43
59
|
# -- config paths --
|
data/lib/textus/store.rb
CHANGED
|
@@ -2,11 +2,8 @@ require "fileutils"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
class Store
|
|
5
|
-
attr_reader :container
|
|
5
|
+
attr_reader :container, :role, :correlation_id, :cursor, :propose_lane, :contract_etag
|
|
6
6
|
|
|
7
|
-
# Readers are derived from the Container's schema, so the field set lives
|
|
8
|
-
# in exactly one place (Container). A new capability added there is
|
|
9
|
-
# automatically exposed on the Store.
|
|
10
7
|
Textus::Store::Container.attribute_names.each do |field|
|
|
11
8
|
define_method(field) { @container.public_send(field) }
|
|
12
9
|
end
|
|
@@ -42,58 +39,84 @@ module Textus
|
|
|
42
39
|
File.directory?(dir) && File.exist?(File.join(dir, "manifest.yaml"))
|
|
43
40
|
end
|
|
44
41
|
|
|
45
|
-
def initialize(root)
|
|
46
|
-
@
|
|
42
|
+
def initialize(root, role: Value::Role::DEFAULT, correlation_id: nil, dry_run: false, container: nil)
|
|
43
|
+
@root = File.expand_path(root)
|
|
44
|
+
@container = container || build_container(@root)
|
|
45
|
+
@role = role.to_s
|
|
46
|
+
@correlation_id = correlation_id || SecureRandom.uuid
|
|
47
|
+
@dry_run = dry_run
|
|
48
|
+
build_session!
|
|
47
49
|
end
|
|
48
50
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def
|
|
52
|
-
|
|
53
|
-
role: role.to_s,
|
|
54
|
-
cursor: audit_log.latest_seq,
|
|
55
|
-
propose_lane: manifest.policy.propose_lane_for(role),
|
|
56
|
-
contract_etag: Textus::Value::Etag.for_contract(root),
|
|
57
|
-
)
|
|
51
|
+
def dry_run? = @dry_run
|
|
52
|
+
|
|
53
|
+
def with_role(new_role)
|
|
54
|
+
_rebuild(role: new_role)
|
|
58
55
|
end
|
|
59
56
|
|
|
60
|
-
def
|
|
61
|
-
|
|
57
|
+
def with_correlation_id(cid)
|
|
58
|
+
_rebuild(correlation_id: cid)
|
|
62
59
|
end
|
|
63
60
|
|
|
64
|
-
def
|
|
65
|
-
|
|
61
|
+
def advance_cursor(new_cursor)
|
|
62
|
+
dup.tap do |s|
|
|
63
|
+
s.instance_variable_set(:@cursor, new_cursor)
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def check_etag!(observed_etag)
|
|
68
|
+
return if observed_etag == @contract_etag
|
|
69
|
+
|
|
70
|
+
raise Textus::ContractDrift.new(
|
|
71
|
+
"contract changed (manifest/hooks/schemas were #{short_etag(@contract_etag)}, " \
|
|
72
|
+
"now #{short_etag(observed_etag)}); re-run boot",
|
|
73
|
+
)
|
|
66
74
|
end
|
|
67
75
|
|
|
68
76
|
private
|
|
69
77
|
|
|
78
|
+
def _rebuild(role: @role, correlation_id: @correlation_id, dry_run: @dry_run)
|
|
79
|
+
self.class.allocate.tap do |s|
|
|
80
|
+
s.instance_variable_set(:@root, @root)
|
|
81
|
+
s.instance_variable_set(:@container, @container)
|
|
82
|
+
s.instance_variable_set(:@role, role.to_s)
|
|
83
|
+
s.instance_variable_set(:@correlation_id, correlation_id || SecureRandom.uuid)
|
|
84
|
+
s.instance_variable_set(:@dry_run, dry_run)
|
|
85
|
+
s.send(:build_session!)
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def build_session!
|
|
90
|
+
@cursor = @container.audit_log.latest_seq
|
|
91
|
+
@propose_lane = @container.manifest.policy.propose_lane_for(@role)
|
|
92
|
+
@contract_etag = Value::Etag.for_contract(@root)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
|
|
96
|
+
|
|
70
97
|
def build_container(root)
|
|
71
98
|
manifest = Manifest.load(root)
|
|
72
99
|
job_store = Port::Store.new(root: root).setup!
|
|
73
|
-
|
|
100
|
+
layout = Store::Layout.new(root)
|
|
74
101
|
infra = Container::Infrastructure.new(
|
|
75
102
|
file_store: Port::Storage::FileStore.new,
|
|
76
|
-
schemas:
|
|
103
|
+
schemas: Schema::Registry.new(layout.schemas_dir),
|
|
77
104
|
audit_log: Port::AuditLog.new(
|
|
78
|
-
|
|
105
|
+
layout: layout,
|
|
79
106
|
max_size: manifest.data.audit_config[:max_size],
|
|
80
107
|
keep: manifest.data.audit_config[:keep],
|
|
81
108
|
),
|
|
82
109
|
job_store:,
|
|
83
|
-
|
|
110
|
+
layout:,
|
|
84
111
|
)
|
|
85
112
|
|
|
86
|
-
|
|
113
|
+
coord_seed = Container::Coordination.new(
|
|
87
114
|
manifest:,
|
|
88
115
|
workflows: Workflow::Loader.load_all(root),
|
|
89
|
-
|
|
90
|
-
compositor: nil,
|
|
116
|
+
pipeline: nil,
|
|
91
117
|
)
|
|
92
118
|
|
|
93
|
-
|
|
94
|
-
compositor = Store::Compositor.new(container)
|
|
95
|
-
gate = Textus::Gate.new(container)
|
|
96
|
-
container.wire_gate!(gate, compositor)
|
|
119
|
+
Container.build(infra, coord_seed)
|
|
97
120
|
end
|
|
98
121
|
end
|
|
99
122
|
end
|
|
@@ -50,7 +50,7 @@ module Textus
|
|
|
50
50
|
module_function
|
|
51
51
|
|
|
52
52
|
def dispatch(verb_instance, store, spec)
|
|
53
|
-
inputs = Textus::
|
|
53
|
+
inputs = Textus::Dispatch::Binder.inputs_from_ordered(
|
|
54
54
|
spec, verb_instance.positional, verb_instance.flag_values(spec)
|
|
55
55
|
)
|
|
56
56
|
inputs = inputs.merge(Surface::CLI::Sources.from_stdin(spec, verb_instance.stdin)) if spec.cli_stdin
|
|
@@ -58,9 +58,11 @@ module Textus
|
|
|
58
58
|
inputs = apply_cli_defaults(spec, inputs)
|
|
59
59
|
role = verb_instance.resolved_role(store)
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
s = store.with_role(role)
|
|
62
|
+
result = s.public_send(spec.verb, **inputs)
|
|
63
|
+
result = spec.view(:cli).call(result, inputs) if spec.view(:cli)
|
|
62
64
|
verb_instance.emit(result)
|
|
63
|
-
rescue Textus::
|
|
65
|
+
rescue Textus::Dispatch::MissingArgs => e
|
|
64
66
|
raise UsageError.new("#{spec.cli_path} requires #{e.missing.first.wire}")
|
|
65
67
|
end
|
|
66
68
|
|
|
@@ -71,7 +73,7 @@ module Textus
|
|
|
71
73
|
# contract, not hidden in a hand class.
|
|
72
74
|
def apply_cli_defaults(spec, inputs)
|
|
73
75
|
spec.args.each_with_object(inputs.dup) do |a, h|
|
|
74
|
-
next if a.cli_default
|
|
76
|
+
next if a.cli_default.nil? || h.key?(a.name)
|
|
75
77
|
|
|
76
78
|
h[a.name] = a.cli_default
|
|
77
79
|
end
|
|
@@ -83,7 +85,7 @@ module Textus
|
|
|
83
85
|
# polarity so a verb that applies-by-default on the CLI but plans-by-default
|
|
84
86
|
# for agents (migrate, data_mv) gets a `--dry-run` flag, not `--no-dry-run`.
|
|
85
87
|
def effective_default(arg)
|
|
86
|
-
arg.cli_default
|
|
88
|
+
arg.cli_default.nil? ? arg.default : arg.cli_default
|
|
87
89
|
end
|
|
88
90
|
|
|
89
91
|
def flagspec_for(arg)
|
|
@@ -144,10 +146,7 @@ module Textus
|
|
|
144
146
|
|
|
145
147
|
def install!
|
|
146
148
|
@installed ||= {}
|
|
147
|
-
Textus::
|
|
148
|
-
next unless action_class.respond_to?(:contract?) && action_class.contract?
|
|
149
|
-
|
|
150
|
-
spec = action_class.contract
|
|
149
|
+
Textus::VerbRegistry.registered.each do |spec|
|
|
151
150
|
next unless spec.cli?
|
|
152
151
|
next if hand_authored?(spec.verb)
|
|
153
152
|
next if @installed[spec.verb]
|
|
@@ -7,9 +7,10 @@ module Textus
|
|
|
7
7
|
option :checks, "--check=NAME"
|
|
8
8
|
|
|
9
9
|
def call(store)
|
|
10
|
-
|
|
10
|
+
Textus::VerbRegistry.for(:doctor)
|
|
11
11
|
inputs = { checks: checks&.split(",")&.map(&:strip) }
|
|
12
|
-
|
|
12
|
+
s = store.with_role(resolved_role(store))
|
|
13
|
+
res = s.doctor(**inputs)
|
|
13
14
|
emit(res, exit_code: res["ok"] ? 0 : 1)
|
|
14
15
|
end
|
|
15
16
|
end
|
|
@@ -3,13 +3,15 @@ module Textus
|
|
|
3
3
|
class CLI
|
|
4
4
|
class Verb
|
|
5
5
|
class Get < Runner::Base
|
|
6
|
-
self.spec = Textus::
|
|
6
|
+
self.spec = Textus::VerbRegistry.for(:get)
|
|
7
7
|
option :as_flag, "--as=ROLE"
|
|
8
8
|
|
|
9
9
|
def invoke(store)
|
|
10
10
|
key = positional.shift or raise UsageError.new("get requires a key")
|
|
11
|
-
spec = Textus::
|
|
12
|
-
|
|
11
|
+
spec = Textus::VerbRegistry.for(:get)
|
|
12
|
+
s = store.with_role(resolved_role(store))
|
|
13
|
+
result = s.get(key: key)
|
|
14
|
+
result = spec.view(:cli).call(result, { key: key }) if spec.view(:cli)
|
|
13
15
|
raise Textus::UnknownKey.new(key, suggestions: store.manifest.resolver.suggestions_for(key)) if result.nil?
|
|
14
16
|
|
|
15
17
|
emit(result)
|
|
@@ -3,7 +3,7 @@ module Textus
|
|
|
3
3
|
class CLI
|
|
4
4
|
class Verb
|
|
5
5
|
class Put < Runner::Base
|
|
6
|
-
self.spec = Textus::
|
|
6
|
+
self.spec = Textus::VerbRegistry.for(:put)
|
|
7
7
|
option :as_flag, "--as=ROLE"
|
|
8
8
|
option :use_stdin, "--stdin"
|
|
9
9
|
|
|
@@ -12,10 +12,12 @@ module Textus
|
|
|
12
12
|
raise UsageError.new("put requires --stdin in v1") unless use_stdin
|
|
13
13
|
|
|
14
14
|
payload = JSON.parse(@stdin.read)
|
|
15
|
-
spec = Textus::
|
|
15
|
+
spec = Textus::VerbRegistry.for(:put)
|
|
16
16
|
inputs = { key: key, meta: payload["_meta"] || {}, body: payload["body"] || "",
|
|
17
17
|
content: nil, if_etag: payload["if_etag"] }
|
|
18
|
-
|
|
18
|
+
s = store.with_role(resolved_role(store))
|
|
19
|
+
result = s.put(**inputs)
|
|
20
|
+
result = spec.view(:cli).call(result, inputs) if spec.view(:cli)
|
|
19
21
|
emit(result)
|
|
20
22
|
end
|
|
21
23
|
end
|
|
@@ -1,89 +1,53 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Surface
|
|
3
3
|
module MCP
|
|
4
|
-
# Derives the entire MCP tool surface from the per-verb contracts (ADR 0039).
|
|
5
|
-
# `build_tools` builds MCP::Tool instances for the SDK; `call` is the generic
|
|
6
|
-
# dispatch: map JSON args -> (positional, keyword) per the contract, invoke
|
|
7
|
-
# the verb through the role scope, then shape the return value. No per-tool code.
|
|
8
4
|
module Catalog
|
|
9
|
-
PROJECTOR = Projector.new(view_key: :default
|
|
5
|
+
PROJECTOR = Projector.new(view_key: :default).freeze
|
|
10
6
|
|
|
11
7
|
module_function
|
|
12
8
|
|
|
13
|
-
WRITE_VERBS = %i[
|
|
14
|
-
put propose key_delete key_mv accept reject enqueue
|
|
15
|
-
].freeze
|
|
16
|
-
|
|
17
|
-
MAINTENANCE_VERBS = %i[
|
|
18
|
-
data_mv key_mv_prefix key_delete_prefix drain rule_lint
|
|
19
|
-
].freeze
|
|
20
|
-
|
|
21
|
-
# Contracts of every MCP-surfaced verb, in Dispatcher order.
|
|
22
9
|
def specs
|
|
23
|
-
|
|
24
|
-
.select { |k| mcp_surfaced?(k) }
|
|
25
|
-
.map(&:contract)
|
|
10
|
+
VerbRegistry.registered.select(&:mcp?)
|
|
26
11
|
end
|
|
27
12
|
|
|
28
|
-
# Builds MCP::Tool instances for the SDK, bound to mcp_server.dispatch.
|
|
29
13
|
def build_tools(mcp_server)
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
.
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
) do |server_context:, **args|
|
|
40
|
-
mcp_server.dispatch(name, args, server_context)
|
|
41
|
-
end
|
|
14
|
+
specs.map do |spec|
|
|
15
|
+
schema = spec.input_schema
|
|
16
|
+
schema = schema.reject { |k, v| k == :required && Array(v).empty? }
|
|
17
|
+
::MCP::Tool.define(
|
|
18
|
+
name: spec.verb.to_s,
|
|
19
|
+
description: spec.summary,
|
|
20
|
+
input_schema: schema,
|
|
21
|
+
) do |server_context:, **args|
|
|
22
|
+
mcp_server.dispatch(spec.verb, args, server_context)
|
|
42
23
|
end
|
|
24
|
+
end
|
|
43
25
|
end
|
|
44
26
|
|
|
45
27
|
def names
|
|
46
|
-
|
|
47
|
-
Textus::Action::VERBS.select { |_, klass| mcp_surfaced?(klass) },
|
|
48
|
-
)
|
|
28
|
+
specs.map(&:verb).map(&:to_s)
|
|
49
29
|
end
|
|
50
30
|
|
|
51
|
-
# MCP-surfaced read verbs, by Dispatcher class namespace — the agent's
|
|
52
|
-
# real read/discovery surface. `boot.agent_quickstart.read_verbs` derives
|
|
53
|
-
# from this so it can never advertise a verb the agent cannot call, nor
|
|
54
|
-
# omit one it can (ADR 0056). Excludes write/maintenance verbs by verb
|
|
55
|
-
# identity (routing may be legacy UseCases or Dispatch::Actions).
|
|
56
31
|
def read_verbs
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
.keys.map(&:to_s)
|
|
32
|
+
VerbRegistry.registered
|
|
33
|
+
.select { |s| s.read? && s.mcp? }
|
|
34
|
+
.map { |s| s.verb.to_s }
|
|
61
35
|
end
|
|
62
36
|
|
|
63
|
-
# MCP-surfaced write verbs, by Dispatcher class namespace — the mirror of
|
|
64
|
-
# read_verbs for the write side. `boot.agent_quickstart.write_verbs` derives
|
|
65
|
-
# from this so it advertises bare verb names the agent can call (no `--as`/
|
|
66
|
-
# `--stdin` CLI framing), finishing the de-CLI-ing of the agent surface
|
|
67
|
-
# (ADR 0056, ADR 0057).
|
|
68
37
|
def write_verbs
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
def mcp_surfaced?(klass)
|
|
75
|
-
klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
|
|
38
|
+
VerbRegistry.registered
|
|
39
|
+
.select { |s| s.write? && s.mcp? }
|
|
40
|
+
.map { |s| s.verb.to_s }
|
|
76
41
|
end
|
|
77
42
|
|
|
78
|
-
def call(name,
|
|
79
|
-
|
|
80
|
-
raise ToolError.new("unknown tool: #{name}") unless
|
|
43
|
+
def call(name, store:, args:)
|
|
44
|
+
spec = VerbRegistry.for(name.to_sym)
|
|
45
|
+
raise ToolError.new("unknown tool: #{name}") unless spec&.mcp?
|
|
81
46
|
|
|
82
|
-
PROJECTOR.dispatch(name, inputs: args, store
|
|
83
|
-
rescue Textus::
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
rescue Textus::ContractDrift, CursorExpired
|
|
47
|
+
PROJECTOR.dispatch(name, inputs: args, store:)
|
|
48
|
+
rescue Textus::Dispatch::MissingArgs => e
|
|
49
|
+
raise ToolError.new("#{name}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
|
|
50
|
+
rescue Textus::ContractDrift, Textus::CursorExpired
|
|
87
51
|
raise
|
|
88
52
|
rescue Textus::Error => e
|
|
89
53
|
raise ToolError.new("#{name}: #{e.message}")
|