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,21 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Store
|
|
3
|
+
module Jobs
|
|
4
|
+
class Sweep < Base
|
|
5
|
+
REQUIRED_ROLE = Textus::Value::Role::AUTOMATION
|
|
6
|
+
TYPE = "sweep"
|
|
7
|
+
|
|
8
|
+
def self.call(container:, call:, scope: {}, key: nil)
|
|
9
|
+
prefix = key || (scope.is_a?(Hash) ? scope["prefix"] : nil)
|
|
10
|
+
lane = scope.is_a?(Hash) ? scope["lane"] : nil
|
|
11
|
+
rows = Retention::Sweep.new(
|
|
12
|
+
manifest: container.manifest,
|
|
13
|
+
file_stat: Textus::Port::Storage::FileStat.new,
|
|
14
|
+
clock: Textus::Port::Clock.new,
|
|
15
|
+
).call(prefix: prefix, lane: lane)
|
|
16
|
+
Retention::Base.new(container: container, call: call).call(rows)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Store
|
|
3
|
+
module Jobs
|
|
4
|
+
class Worker
|
|
5
|
+
Summary = Struct.new(:completed, :failed, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
def self.for(container:, queue:)
|
|
8
|
+
new(queue: queue, container: container,
|
|
9
|
+
lease_ttl: container.manifest.data.worker_config[:lease_ttl])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(queue:, container:, lease_ttl: 60)
|
|
13
|
+
@queue = queue
|
|
14
|
+
@container = container
|
|
15
|
+
@lease_ttl = lease_ttl
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def drain(worker_id: "drain-#{Process.pid}")
|
|
19
|
+
completed = 0
|
|
20
|
+
failed = 0
|
|
21
|
+
loop do
|
|
22
|
+
leased = @queue.lease(worker_id: worker_id, lease_ttl: @lease_ttl)
|
|
23
|
+
break unless leased
|
|
24
|
+
|
|
25
|
+
case run_one(leased)
|
|
26
|
+
when :completed then completed += 1
|
|
27
|
+
when :dead_lettered then failed += 1
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
Summary.new(completed: completed, failed: failed)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def drain_pool(pool: 4)
|
|
34
|
+
summaries = []
|
|
35
|
+
mutex = Mutex.new
|
|
36
|
+
threads = Array.new(pool) do |i|
|
|
37
|
+
Thread.new do
|
|
38
|
+
s = drain(worker_id: "pool-#{Process.pid}-#{i}")
|
|
39
|
+
mutex.synchronize { summaries << s }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
threads.each(&:join)
|
|
43
|
+
Summary.new(completed: summaries.sum(&:completed), failed: summaries.sum(&:failed))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def run_one(leased)
|
|
49
|
+
job = leased.job
|
|
50
|
+
klass = Textus::Jobs.fetch(job.type)
|
|
51
|
+
call = Textus::Value::Call.build(
|
|
52
|
+
role: job.role || Textus::Value::Role::AUTOMATION,
|
|
53
|
+
correlation_id: SecureRandom.uuid,
|
|
54
|
+
)
|
|
55
|
+
klass.call(container: @container, call: call, **job.args.transform_keys(&:to_sym))
|
|
56
|
+
@queue.ack(leased)
|
|
57
|
+
:completed
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
@queue.fail(leased, error: e.message)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Store
|
|
3
|
+
class Layout
|
|
4
|
+
RUN = ".state"
|
|
5
|
+
DATA = "data"
|
|
6
|
+
ASSETS = "assets"
|
|
7
|
+
|
|
8
|
+
def initialize(root)
|
|
9
|
+
@root = root
|
|
10
|
+
freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_reader :root
|
|
14
|
+
|
|
15
|
+
# -- data paths --
|
|
16
|
+
def data_root = File.join(@root, DATA)
|
|
17
|
+
def lane_path(lane_name) = File.join(data_root, lane_name.to_s)
|
|
18
|
+
|
|
19
|
+
def entry_path(mentry)
|
|
20
|
+
primary_ext = Format.for(mentry.format).extensions.first
|
|
21
|
+
rel = normalize_relative_path(mentry.path)
|
|
22
|
+
if File.extname(mentry.path) == ""
|
|
23
|
+
File.join(@root, rel + primary_ext)
|
|
24
|
+
else
|
|
25
|
+
File.join(@root, rel)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# -- runtime paths --
|
|
30
|
+
def run_root = File.join(@root, RUN)
|
|
31
|
+
def cursor_path(role) = File.join(run_root, "ephemeral", "cursors", role.to_s)
|
|
32
|
+
def lock_path(name) = File.join(run_root, "ephemeral", "locks", "#{name}.lock")
|
|
33
|
+
def audit_dir_path = File.join(run_root, "audit")
|
|
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.*")
|
|
38
|
+
def sentinels_root = File.join(run_root, "tracking", "sentinels")
|
|
39
|
+
def store_db_path = File.join(run_root, "store.db")
|
|
40
|
+
|
|
41
|
+
# -- asset paths --
|
|
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)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# -- config paths --
|
|
60
|
+
def manifest_path = File.join(@root, "manifest.yaml")
|
|
61
|
+
def schemas_dir = File.join(@root, "schemas")
|
|
62
|
+
def schema_path(name) = File.join(schemas_dir, "#{name}.yaml")
|
|
63
|
+
def template_path(name) = File.join(@root, "templates", name)
|
|
64
|
+
def workflow_dir = File.join(@root, "workflows")
|
|
65
|
+
def hooks_dir = File.join(@root, "hooks")
|
|
66
|
+
def schemas_glob = File.join(schemas_dir, "**", "*")
|
|
67
|
+
|
|
68
|
+
# -- gitignore --
|
|
69
|
+
def gitignore_body(untracked_entries: [])
|
|
70
|
+
lines = ["# textus runtime artifacts — safe to delete, never commit",
|
|
71
|
+
"#{RUN}/"]
|
|
72
|
+
unless untracked_entries.empty?
|
|
73
|
+
lines << "# tracked:false entries — protocol-readable, not committed"
|
|
74
|
+
lines.concat(untracked_entries)
|
|
75
|
+
end
|
|
76
|
+
"#{lines.join("\n")}\n"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# -- lane boundary (replaces Writer#zone_floor) --
|
|
80
|
+
def lane_floor(path)
|
|
81
|
+
prefix = "#{data_root}/"
|
|
82
|
+
return nil unless path.start_with?(prefix)
|
|
83
|
+
|
|
84
|
+
seg = path.delete_prefix(prefix).split("/").first
|
|
85
|
+
seg && File.join(data_root, seg)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def normalize_relative_path(path)
|
|
91
|
+
return path if path.start_with?("data/")
|
|
92
|
+
|
|
93
|
+
File.join("data", path)
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
data/lib/textus/store.rb
CHANGED
|
@@ -2,12 +2,9 @@ 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
|
-
|
|
8
|
-
# in exactly one place (Container). A new capability added there is
|
|
9
|
-
# automatically exposed on the Store.
|
|
10
|
-
Textus::Container.attribute_names.each do |field|
|
|
7
|
+
Textus::Store::Container.attribute_names.each do |field|
|
|
11
8
|
define_method(field) { @container.public_send(field) }
|
|
12
9
|
end
|
|
13
10
|
|
|
@@ -42,50 +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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
)
|
|
51
|
+
def dry_run? = @dry_run
|
|
52
|
+
|
|
53
|
+
def with_role(new_role)
|
|
54
|
+
_rebuild(role: new_role)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def with_correlation_id(cid)
|
|
58
|
+
_rebuild(correlation_id: cid)
|
|
58
59
|
end
|
|
59
60
|
|
|
60
|
-
def
|
|
61
|
-
|
|
61
|
+
def advance_cursor(new_cursor)
|
|
62
|
+
dup.tap do |s|
|
|
63
|
+
s.instance_variable_set(:@cursor, new_cursor)
|
|
64
|
+
end
|
|
62
65
|
end
|
|
63
66
|
|
|
64
|
-
def
|
|
65
|
-
|
|
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
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
audit_log:
|
|
78
|
-
|
|
99
|
+
job_store = Port::Store.new(root: root).setup!
|
|
100
|
+
layout = Store::Layout.new(root)
|
|
101
|
+
infra = Container::Infrastructure.new(
|
|
102
|
+
file_store: Port::Storage::FileStore.new,
|
|
103
|
+
schemas: Schema::Registry.new(layout.schemas_dir),
|
|
104
|
+
audit_log: Port::AuditLog.new(
|
|
105
|
+
layout: layout,
|
|
79
106
|
max_size: manifest.data.audit_config[:max_size],
|
|
80
107
|
keep: manifest.data.audit_config[:keep],
|
|
81
108
|
),
|
|
109
|
+
job_store:,
|
|
110
|
+
layout:,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
coord_seed = Container::Coordination.new(
|
|
114
|
+
manifest:,
|
|
82
115
|
workflows: Workflow::Loader.load_all(root),
|
|
83
|
-
|
|
116
|
+
pipeline: nil,
|
|
84
117
|
)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
gate.instance_variable_set(:@container, container)
|
|
88
|
-
container
|
|
118
|
+
|
|
119
|
+
Container.build(infra, coord_seed)
|
|
89
120
|
end
|
|
90
121
|
end
|
|
91
122
|
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Surface
|
|
3
3
|
class CLI
|
|
4
4
|
class Group < Verb
|
|
5
5
|
class << self
|
|
@@ -7,7 +7,7 @@ module Textus
|
|
|
7
7
|
# `parent_group` is this group counts as a subcommand. Sorted
|
|
8
8
|
# alphabetically by command_name for stable help output.
|
|
9
9
|
def subcommands
|
|
10
|
-
Textus::
|
|
10
|
+
Textus::Surface::CLI::Runner.install!
|
|
11
11
|
Verb.descendants
|
|
12
12
|
.select { |k| k.parent_group == self && k.command_name }
|
|
13
13
|
.sort_by(&:command_name)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Surface
|
|
3
3
|
class CLI
|
|
4
4
|
# Generates CLI::Verb (and CLI::Group) subclasses from per-verb contracts,
|
|
5
5
|
# so the CLI surface is a projection of the contract — the operator-facing
|
|
@@ -49,63 +49,23 @@ module Textus
|
|
|
49
49
|
|
|
50
50
|
module_function
|
|
51
51
|
|
|
52
|
-
# Build a Command from the spec + parsed inputs, dispatch through Gate.
|
|
53
52
|
def dispatch(verb_instance, store, spec)
|
|
54
|
-
inputs = Textus::
|
|
53
|
+
inputs = Textus::Dispatch::Binder.inputs_from_ordered(
|
|
55
54
|
spec, verb_instance.positional, verb_instance.flag_values(spec)
|
|
56
55
|
)
|
|
57
|
-
inputs = inputs.merge(
|
|
58
|
-
inputs =
|
|
56
|
+
inputs = inputs.merge(Surface::CLI::Sources.from_stdin(spec, verb_instance.stdin)) if spec.cli_stdin
|
|
57
|
+
inputs = Surface::CLI::Sources.acquire(spec, inputs)
|
|
59
58
|
inputs = apply_cli_defaults(spec, inputs)
|
|
60
59
|
role = verb_instance.resolved_role(store)
|
|
61
60
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
result = if spec.around
|
|
68
|
-
scope = store.as(role)
|
|
69
|
-
Textus::Contract::Around.with(spec.around, scope: scope, inputs: inputs, session: nil, &invoke)
|
|
70
|
-
else
|
|
71
|
-
invoke.call(inputs)
|
|
72
|
-
end
|
|
73
|
-
verb_instance.emit(shape(spec, result, inputs))
|
|
74
|
-
rescue Textus::Contract::MissingArgs => e
|
|
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)
|
|
64
|
+
verb_instance.emit(result)
|
|
65
|
+
rescue Textus::Dispatch::MissingArgs => e
|
|
75
66
|
raise UsageError.new("#{spec.cli_path} requires #{e.missing.first.wire}")
|
|
76
67
|
end
|
|
77
68
|
|
|
78
|
-
def build_command(spec, inputs, role)
|
|
79
|
-
cmd_class = Textus::Gate::VERB_COMMAND.fetch(spec.verb) do
|
|
80
|
-
raise Textus::UsageError.new("no Command for verb: #{spec.verb}")
|
|
81
|
-
end
|
|
82
|
-
defaults = {}
|
|
83
|
-
spec.args.each do |a|
|
|
84
|
-
next if a.default == :__unset || inputs.key?(a.name)
|
|
85
|
-
next if a.default.nil? && a.required
|
|
86
|
-
|
|
87
|
-
defaults[a.name] = a.default
|
|
88
|
-
end
|
|
89
|
-
kwargs = defaults.merge(inputs)
|
|
90
|
-
kwargs[:role] = role if cmd_class.members.include?(:role) && !inputs.key?(:role) && spec.verb != :audit
|
|
91
|
-
check_missing_args!(spec, cmd_class, kwargs)
|
|
92
|
-
|
|
93
|
-
cmd_class.new(**kwargs.slice(*cmd_class.members))
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def check_missing_args!(spec, cmd_class, kwargs)
|
|
97
|
-
params = cmd_class.instance_method(:initialize).parameters
|
|
98
|
-
required = if params == [[:rest]]
|
|
99
|
-
cmd_class.members
|
|
100
|
-
else
|
|
101
|
-
params.select { |t,| t == :keyreq }.map(&:last)
|
|
102
|
-
end
|
|
103
|
-
missing = required - kwargs.keys
|
|
104
|
-
return if missing.empty?
|
|
105
|
-
|
|
106
|
-
raise Textus::Contract::MissingArgs.new(spec, missing.map { |m| Struct.new(:wire, :name).new(m.to_s, m) })
|
|
107
|
-
end
|
|
108
|
-
|
|
109
69
|
# Fill CLI-specific defaults (cli_default:) for args the operator did not
|
|
110
70
|
# pass, where the CLI default diverges from the contract default the agent
|
|
111
71
|
# surfaces use — e.g. migrate/data_mv apply by default on the CLI but plan
|
|
@@ -113,27 +73,19 @@ module Textus
|
|
|
113
73
|
# contract, not hidden in a hand class.
|
|
114
74
|
def apply_cli_defaults(spec, inputs)
|
|
115
75
|
spec.args.each_with_object(inputs.dup) do |a, h|
|
|
116
|
-
next if a.cli_default
|
|
76
|
+
next if a.cli_default.nil? || h.key?(a.name)
|
|
117
77
|
|
|
118
78
|
h[a.name] = a.cli_default
|
|
119
79
|
end
|
|
120
80
|
end
|
|
121
81
|
|
|
122
|
-
# Shape the use-case result for the CLI wire via the verb's :cli view
|
|
123
|
-
# (falling back to the default view). The view is called uniformly as
|
|
124
|
-
# (result, inputs); an inputs-aware view echoes an input such as the key
|
|
125
|
-
# (ADR 0067).
|
|
126
|
-
def shape(spec, result, inputs)
|
|
127
|
-
Textus::Contract::View.render(spec, :cli, result, inputs)
|
|
128
|
-
end
|
|
129
|
-
|
|
130
82
|
# The default the CLI flag is generated against — `cli_default:` when the
|
|
131
83
|
# operator-facing default diverges from the contract default the agent
|
|
132
84
|
# surfaces use, else the contract `default`. This drives boolean flag
|
|
133
85
|
# polarity so a verb that applies-by-default on the CLI but plans-by-default
|
|
134
86
|
# for agents (migrate, data_mv) gets a `--dry-run` flag, not `--no-dry-run`.
|
|
135
87
|
def effective_default(arg)
|
|
136
|
-
arg.cli_default
|
|
88
|
+
arg.cli_default.nil? ? arg.default : arg.cli_default
|
|
137
89
|
end
|
|
138
90
|
|
|
139
91
|
def flagspec_for(arg)
|
|
@@ -151,6 +103,7 @@ module Textus
|
|
|
151
103
|
def coerce(arg, raw)
|
|
152
104
|
return effective_default(arg) != true if arg.type == :boolean
|
|
153
105
|
return Integer(raw) if arg.type == Integer
|
|
106
|
+
return JSON.parse(raw) if arg.type == Hash
|
|
154
107
|
|
|
155
108
|
raw
|
|
156
109
|
end
|
|
@@ -193,14 +146,7 @@ module Textus
|
|
|
193
146
|
|
|
194
147
|
def install!
|
|
195
148
|
@installed ||= {}
|
|
196
|
-
Textus::
|
|
197
|
-
verb = Textus::Gate::VERB_COMMAND.key(cmd_class)
|
|
198
|
-
next unless verb
|
|
199
|
-
|
|
200
|
-
action_class = Textus::Gate::ROUTES[cmd_class].first
|
|
201
|
-
next unless action_class.respond_to?(:contract?) && action_class.contract?
|
|
202
|
-
|
|
203
|
-
spec = action_class.contract
|
|
149
|
+
Textus::VerbRegistry.registered.each do |spec|
|
|
204
150
|
next unless spec.cli?
|
|
205
151
|
next if hand_authored?(spec.verb)
|
|
206
152
|
next if @installed[spec.verb]
|
|
@@ -224,7 +170,7 @@ module Textus
|
|
|
224
170
|
non_positional.each { |a| klass.option a.name, Runner.flagspec_for(a) }
|
|
225
171
|
|
|
226
172
|
# Anchor the anonymous class to a constant so descendants discovery is
|
|
227
|
-
# stable. Name it
|
|
173
|
+
# stable. Name it under a Generated namespace.
|
|
228
174
|
const_name = spec.verb.to_s.split("_").map(&:capitalize).join
|
|
229
175
|
gen = "Gen#{const_name}"
|
|
230
176
|
Verb.const_set(gen, klass) unless Verb.const_defined?(gen, false)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Surface
|
|
5
|
+
class CLI
|
|
6
|
+
# CLI-only input acquisition. Transforms entries of the uniform `inputs`
|
|
7
|
+
# hash that declare a `source:`/`coerce:`, and builds `inputs` from a
|
|
8
|
+
# `cli_stdin` envelope — so put/propose/migrate/rule_lint/audit need no
|
|
9
|
+
# hand-authored CLI class (ADR 0068). MCP receives typed JSON, so these
|
|
10
|
+
# never run there.
|
|
11
|
+
module Sources
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Apply per-arg :file sources (value is a path -> file contents) and
|
|
15
|
+
# :coerce callables to a by-name inputs hash. Returns a new hash.
|
|
16
|
+
def acquire(spec, inputs)
|
|
17
|
+
spec.args.each_with_object(inputs.dup) do |a, h|
|
|
18
|
+
next unless h.key?(a.name)
|
|
19
|
+
|
|
20
|
+
h[a.name] = File.read(h[a.name]) if a.source == :file
|
|
21
|
+
h[a.name] = a.coerce.call(h[a.name]) if a.coerce
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Parse a cli_stdin :json envelope into a by-name inputs hash, mapping
|
|
26
|
+
# envelope keys (wire-names) to arg names.
|
|
27
|
+
def from_stdin(spec, stream)
|
|
28
|
+
return {} unless spec.cli_stdin == :json
|
|
29
|
+
|
|
30
|
+
raw = stream.read.to_s
|
|
31
|
+
return {} if raw.strip.empty? # no envelope piped -> required args surface as missing
|
|
32
|
+
|
|
33
|
+
envelope = JSON.parse(raw)
|
|
34
|
+
spec.args.each_with_object({}) do |a, h|
|
|
35
|
+
h[a.name] = envelope[a.wire.to_s] if envelope.key?(a.wire.to_s)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Surface
|
|
3
3
|
class CLI
|
|
4
4
|
class Verb
|
|
5
5
|
class Doctor < Verb
|
|
@@ -7,11 +7,10 @@ module Textus
|
|
|
7
7
|
option :checks, "--check=NAME"
|
|
8
8
|
|
|
9
9
|
def call(store)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
)
|
|
14
|
-
res = store.gate.dispatch(cmd)
|
|
10
|
+
Textus::VerbRegistry.for(:doctor)
|
|
11
|
+
inputs = { checks: checks&.split(",")&.map(&:strip) }
|
|
12
|
+
s = store.with_role(resolved_role(store))
|
|
13
|
+
res = s.doctor(**inputs)
|
|
15
14
|
emit(res, exit_code: res["ok"] ? 0 : 1)
|
|
16
15
|
end
|
|
17
16
|
end
|
|
@@ -1,18 +1,20 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Surface
|
|
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
|
-
|
|
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
|
-
emit(result
|
|
17
|
+
emit(result)
|
|
16
18
|
end
|
|
17
19
|
end
|
|
18
20
|
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Surface
|
|
3
3
|
class CLI
|
|
4
4
|
class Verb
|
|
5
5
|
# Launches the MCP stdio server in the current process. Blocks on stdin;
|
|
@@ -13,8 +13,8 @@ module Textus
|
|
|
13
13
|
option :as_flag, "--as=ROLE"
|
|
14
14
|
|
|
15
15
|
def call(store)
|
|
16
|
-
role = resolved_role(store, default: Textus::Role::AGENT)
|
|
17
|
-
Textus::
|
|
16
|
+
role = resolved_role(store, default: Textus::Value::Role::AGENT)
|
|
17
|
+
Textus::Surface::MCP::Server.new(store: store, stdin: @stdin, stdout: @stdout, role: role).run
|
|
18
18
|
0
|
|
19
19
|
end
|
|
20
20
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Surface
|
|
3
|
+
class CLI
|
|
4
|
+
class Verb
|
|
5
|
+
class Put < Runner::Base
|
|
6
|
+
self.spec = Textus::VerbRegistry.for(:put)
|
|
7
|
+
option :as_flag, "--as=ROLE"
|
|
8
|
+
option :use_stdin, "--stdin"
|
|
9
|
+
|
|
10
|
+
def invoke(store)
|
|
11
|
+
key = positional.shift or raise UsageError.new("put requires a key")
|
|
12
|
+
raise UsageError.new("put requires --stdin in v1") unless use_stdin
|
|
13
|
+
|
|
14
|
+
payload = JSON.parse(@stdin.read)
|
|
15
|
+
spec = Textus::VerbRegistry.for(:put)
|
|
16
|
+
inputs = { key: key, meta: payload["_meta"] || {}, body: payload["body"] || "",
|
|
17
|
+
content: nil, if_etag: payload["if_etag"] }
|
|
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)
|
|
21
|
+
emit(result)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|