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
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "yaml"
|
|
4
|
-
|
|
5
|
-
module Textus
|
|
6
|
-
module Action
|
|
7
|
-
class DataMv < Base
|
|
8
|
-
extend Textus::Contract::DSL
|
|
9
|
-
|
|
10
|
-
verb :data_mv
|
|
11
|
-
summary "Rename a data lane — manifest + files. Refuses if destination exists."
|
|
12
|
-
surfaces :cli, :mcp
|
|
13
|
-
cli "data mv"
|
|
14
|
-
arg :from, String, required: true, positional: true, description: "current data lane name"
|
|
15
|
-
arg :to, String, required: true, positional: true,
|
|
16
|
-
description: "new data lane name; refused if a lane by this name already exists"
|
|
17
|
-
arg :dry_run, :boolean, default: false,
|
|
18
|
-
description: "when true, returns the planned zone move without applying it; " \
|
|
19
|
-
"defaults to false, so omitting it applies the move immediately"
|
|
20
|
-
view { |v, _i| v.to_h }
|
|
21
|
-
|
|
22
|
-
def initialize(from:, to:, dry_run: false)
|
|
23
|
-
super()
|
|
24
|
-
@from = from
|
|
25
|
-
@to = to
|
|
26
|
-
@dry_run = dry_run
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def call(container:, **)
|
|
30
|
-
manifest = container.manifest
|
|
31
|
-
root = container.root
|
|
32
|
-
|
|
33
|
-
raise UsageError.new("from and to required") if @from.nil? || @to.nil? || @from.empty? || @to.empty?
|
|
34
|
-
raise UsageError.new("data lane '#{@from}' not declared") unless manifest.data.declared_lane_kinds.key?(@from)
|
|
35
|
-
|
|
36
|
-
dest_dir = File.join(root, "data", @to)
|
|
37
|
-
raise UsageError.new("destination 'data/#{@to}' already exists") if File.exist?(dest_dir)
|
|
38
|
-
|
|
39
|
-
affected_keys = manifest.data.entries.select { |entry| entry.lane == @from }.map(&:key)
|
|
40
|
-
|
|
41
|
-
steps = [{ "op" => "rename_zone", "from" => @from, "to" => @to }]
|
|
42
|
-
steps += affected_keys.map do |key|
|
|
43
|
-
{ "op" => "mv", "from" => key, "to" => "#{@to}#{key[@from.length..]}" }
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
plan = Textus::Jobs::Plan.new(steps: steps, warnings: [])
|
|
47
|
-
return plan if @dry_run
|
|
48
|
-
|
|
49
|
-
rewrite_manifest!(root)
|
|
50
|
-
FileUtils.mv(File.join(root, "data", @from), dest_dir)
|
|
51
|
-
plan
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
private
|
|
55
|
-
|
|
56
|
-
def rewrite_manifest!(root)
|
|
57
|
-
path = File.join(root, "manifest.yaml")
|
|
58
|
-
raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
|
|
59
|
-
raw["lanes"].each { |lane| lane["name"] = @to if lane["name"] == @from }
|
|
60
|
-
raw["entries"].each do |entry|
|
|
61
|
-
entry["lane"] = @to if entry["lane"] == @from
|
|
62
|
-
entry["key"] = entry["key"].sub(/\A#{Regexp.escape(@from)}(\.|\z)/, "#{@to}\\1")
|
|
63
|
-
entry["path"] = entry["path"].sub(%r{\A(data/)?#{Regexp.escape(@from)}(/|\z)}, "\\1#{@to}\\2")
|
|
64
|
-
end
|
|
65
|
-
File.write(path, YAML.dump(raw))
|
|
66
|
-
end
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
data/lib/textus/action/deps.rb
DELETED
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Action
|
|
5
|
-
class Deps < Base
|
|
6
|
-
extend Textus::Contract::DSL
|
|
7
|
-
|
|
8
|
-
verb :deps
|
|
9
|
-
summary "List the keys a derived entry depends on (its projection/external sources)."
|
|
10
|
-
surfaces :cli, :mcp
|
|
11
|
-
arg :key, String, required: true, positional: true,
|
|
12
|
-
description: "dotted key of the derived entry whose source keys you want"
|
|
13
|
-
|
|
14
|
-
def initialize(key:)
|
|
15
|
-
super()
|
|
16
|
-
@key = key
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def call(container:, **)
|
|
20
|
-
entry = container.manifest.data.entries.find { |e| e.key == @key }
|
|
21
|
-
deps = entry&.external? ? Array(entry.source&.sources).compact : []
|
|
22
|
-
{ "key" => @key, "deps" => deps.uniq }
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def self.new(*args, **kwargs)
|
|
26
|
-
return super(**kwargs) unless args.any?
|
|
27
|
-
|
|
28
|
-
positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
|
|
29
|
-
mapped = positional.zip(args).to_h
|
|
30
|
-
super(**mapped.merge(kwargs))
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
data/lib/textus/action/doctor.rb
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Action
|
|
5
|
-
class Doctor < Base
|
|
6
|
-
extend Textus::Contract::DSL
|
|
7
|
-
|
|
8
|
-
verb :doctor
|
|
9
|
-
summary "Run health checks on the textus store and report any issues."
|
|
10
|
-
surfaces :cli
|
|
11
|
-
cli "doctor"
|
|
12
|
-
arg :checks, Array, required: false, description: "subset of check names to run (default: all)"
|
|
13
|
-
|
|
14
|
-
def initialize(checks: nil)
|
|
15
|
-
super()
|
|
16
|
-
@checks = checks
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def call(container:, call:, **)
|
|
20
|
-
Textus::Doctor.build(container: container, checks: @checks, role: call.role)
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
data/lib/textus/action/drain.rb
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Action
|
|
5
|
-
class Drain < Base
|
|
6
|
-
extend Textus::Contract::DSL
|
|
7
|
-
|
|
8
|
-
verb :drain
|
|
9
|
-
summary "Seed materialize + sweep jobs then drain the queue to empty. " \
|
|
10
|
-
"Identical to one Watcher tick. Use when no watcher is running."
|
|
11
|
-
surfaces :cli, :mcp
|
|
12
|
-
arg :prefix, String, description: "restrict to keys under this dotted prefix"
|
|
13
|
-
arg :lane, String, description: "restrict to entries in this lane"
|
|
14
|
-
|
|
15
|
-
def initialize(prefix: nil, lane: nil)
|
|
16
|
-
super()
|
|
17
|
-
@prefix = prefix
|
|
18
|
-
@lane = lane
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def call(container:, call:)
|
|
22
|
-
queue = Textus::Ports::JobStore.new(root: container.root)
|
|
23
|
-
Textus::Jobs::Planner.seed(
|
|
24
|
-
container: container,
|
|
25
|
-
queue: queue,
|
|
26
|
-
role: call.role,
|
|
27
|
-
)
|
|
28
|
-
queue.reclaim(now: Textus::Ports::Clock.new.now)
|
|
29
|
-
summary = Textus::Jobs::Worker.for(container:, queue:).drain
|
|
30
|
-
{
|
|
31
|
-
"protocol" => Textus::PROTOCOL,
|
|
32
|
-
"ok" => summary.failed.zero?,
|
|
33
|
-
"completed" => summary.completed,
|
|
34
|
-
"failed" => summary.failed,
|
|
35
|
-
}
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Action
|
|
5
|
-
class Enqueue < WriteVerb
|
|
6
|
-
extend Textus::Contract::DSL
|
|
7
|
-
|
|
8
|
-
verb :enqueue
|
|
9
|
-
summary "Push a registered job type onto the convergence queue, to be run by drain/serve."
|
|
10
|
-
surfaces :cli, :mcp
|
|
11
|
-
cli "enqueue"
|
|
12
|
-
arg :type, String, required: true, positional: true,
|
|
13
|
-
description: "registered job type (e.g. materialize, re-pull, sweep)"
|
|
14
|
-
arg :args, Hash, default: {},
|
|
15
|
-
description: "type-specific arguments (e.g. { key: ... } or { scope: ... })"
|
|
16
|
-
|
|
17
|
-
def initialize(type:, args: {})
|
|
18
|
-
super()
|
|
19
|
-
@type = type
|
|
20
|
-
@job_args = args
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def args
|
|
24
|
-
{ type: @type, args: @job_args }
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def call(container:, call:)
|
|
28
|
-
action_class = begin
|
|
29
|
-
Textus::Jobs.fetch(@type.to_s)
|
|
30
|
-
rescue Textus::UsageError
|
|
31
|
-
raise Textus::UsageError.new("unregistered job type '#{@type}'")
|
|
32
|
-
end
|
|
33
|
-
if action_class.const_defined?(:REQUIRED_ROLE) && call.role != action_class::REQUIRED_ROLE
|
|
34
|
-
raise Textus::Error.new(
|
|
35
|
-
"forbidden",
|
|
36
|
-
"role '#{call.role}' is not authorized to enqueue this job type (requires '#{action_class::REQUIRED_ROLE}')",
|
|
37
|
-
details: { "role" => call.role, "required_role" => action_class::REQUIRED_ROLE },
|
|
38
|
-
exit_code: 77,
|
|
39
|
-
)
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
job = Textus::Ports::JobStore::Job.new(
|
|
43
|
-
type: @type,
|
|
44
|
-
args: @job_args,
|
|
45
|
-
enqueued_by: call.role,
|
|
46
|
-
max_attempts: 3,
|
|
47
|
-
)
|
|
48
|
-
Textus::Ports::JobStore.new(root: container.root).enqueue(job)
|
|
49
|
-
{ "protocol" => Textus::PROTOCOL, "ok" => true, "id" => job.id }
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
data/lib/textus/action/get.rb
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Action
|
|
5
|
-
class Get < Base
|
|
6
|
-
extend Textus::Contract::DSL
|
|
7
|
-
|
|
8
|
-
verb :get
|
|
9
|
-
summary "Read one entry - a pure on-disk read annotated with a freshness " \
|
|
10
|
-
"verdict; never ingests (quarantine freshness is drain + hook " \
|
|
11
|
-
"only, ADR 0089). Returns the envelope (uid, etag, _meta, body, " \
|
|
12
|
-
"freshness)."
|
|
13
|
-
surfaces :cli, :mcp
|
|
14
|
-
arg :key, String, required: true, positional: true,
|
|
15
|
-
description: "dotted entry key to read, e.g. 'knowledge.project'"
|
|
16
|
-
view { |v, _i| v.to_h_for_wire }
|
|
17
|
-
|
|
18
|
-
def initialize(key:)
|
|
19
|
-
super()
|
|
20
|
-
@key = key
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def call(container:, call:, file_stat: Textus::Ports::Storage::FileStat.new)
|
|
24
|
-
@container = container
|
|
25
|
-
@call = call
|
|
26
|
-
@manifest = container.manifest
|
|
27
|
-
@file_store = container.file_store
|
|
28
|
-
@file_stat = file_stat
|
|
29
|
-
annotated_envelope(@key)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def self.new(*args, **kwargs)
|
|
33
|
-
return super(**kwargs) unless args.any?
|
|
34
|
-
|
|
35
|
-
positional = instance_method(:initialize).parameters.slice(:keyreq, :key).map(&:last)
|
|
36
|
-
mapped = positional.zip(args).to_h
|
|
37
|
-
super(**mapped.merge(kwargs))
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
private
|
|
41
|
-
|
|
42
|
-
def annotated_envelope(key)
|
|
43
|
-
envelope = read_raw_envelope(key)
|
|
44
|
-
return nil if envelope.nil?
|
|
45
|
-
|
|
46
|
-
entry = @manifest.resolver.resolve(key).entry
|
|
47
|
-
envelope.with(freshness: evaluator.verdict(entry))
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
def evaluator
|
|
51
|
-
@evaluator ||= Textus::Core::Freshness::Evaluator.new(
|
|
52
|
-
manifest: @manifest,
|
|
53
|
-
file_stat: @file_stat,
|
|
54
|
-
clock: @call,
|
|
55
|
-
)
|
|
56
|
-
end
|
|
57
|
-
|
|
58
|
-
def read_raw_envelope(key)
|
|
59
|
-
res = @manifest.resolver.resolve(key)
|
|
60
|
-
mentry = res.entry
|
|
61
|
-
path = res.path
|
|
62
|
-
return nil unless @file_store.exists?(path)
|
|
63
|
-
|
|
64
|
-
raw = @file_store.read(path)
|
|
65
|
-
parsed = Textus::Format.for(mentry.format).parse(raw, path: path)
|
|
66
|
-
Textus::Envelope.build(
|
|
67
|
-
key: key,
|
|
68
|
-
mentry: mentry,
|
|
69
|
-
path: path,
|
|
70
|
-
meta: parsed["_meta"],
|
|
71
|
-
body: parsed["body"],
|
|
72
|
-
etag: Textus::Etag.for_bytes(raw),
|
|
73
|
-
content: parsed["content"],
|
|
74
|
-
)
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
end
|
|
78
|
-
end
|
data/lib/textus/action/ingest.rb
DELETED
|
@@ -1,203 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "date"
|
|
5
|
-
require "digest"
|
|
6
|
-
|
|
7
|
-
module Textus
|
|
8
|
-
module Action
|
|
9
|
-
class Ingest < Base
|
|
10
|
-
extend Textus::Contract::DSL
|
|
11
|
-
|
|
12
|
-
verb :ingest
|
|
13
|
-
summary "Capture external source material into the raw lane. Write-once, agent-owned."
|
|
14
|
-
surfaces :cli, :mcp
|
|
15
|
-
arg :kind, String, required: true, positional: true,
|
|
16
|
-
description: "source kind: url | file | asset"
|
|
17
|
-
arg :slug, String, required: true,
|
|
18
|
-
description: "human slug for the key suffix (kebab-case)"
|
|
19
|
-
arg :url, String, description: "remote URL (required when kind=url)"
|
|
20
|
-
arg :path, String, description: "local file path (required when kind=file or kind=asset)"
|
|
21
|
-
arg :zone, String, description: "asset group subdirectory (required when kind=asset)"
|
|
22
|
-
arg :label, String, description: "human label stored in source.label"
|
|
23
|
-
view { |env| { "key" => env.key, "uid" => env.uid, "etag" => env.etag } }
|
|
24
|
-
|
|
25
|
-
SOURCE_KINDS = %w[url file asset].freeze
|
|
26
|
-
CONTENT_HASH_ALGO = "sha256"
|
|
27
|
-
TOMBSTONE_RETAIN = %w[ingested_at].freeze
|
|
28
|
-
|
|
29
|
-
def initialize(kind:, slug:, url: nil, path: nil, zone: nil, label: nil)
|
|
30
|
-
super()
|
|
31
|
-
@kind = kind
|
|
32
|
-
@slug = slug
|
|
33
|
-
@url = url
|
|
34
|
-
@path = path
|
|
35
|
-
@zone = zone
|
|
36
|
-
@label = label
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def call(container:, call:)
|
|
40
|
-
validate_inputs!
|
|
41
|
-
|
|
42
|
-
now = Time.now.utc
|
|
43
|
-
key = derive_key(now)
|
|
44
|
-
|
|
45
|
-
Textus::Gate::Auth.new(container).check_action!(
|
|
46
|
-
action: :ingest, actor: call.role, key: key,
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
content_hash = compute_content_hash
|
|
50
|
-
writer = Textus::Envelope::Writer.from(container: container, call: call)
|
|
51
|
-
mentry = container.manifest.resolver.resolve(key).entry
|
|
52
|
-
ts = now.iso8601
|
|
53
|
-
structured = build_structured(ts, container, now, content_hash)
|
|
54
|
-
|
|
55
|
-
index = Textus::Ports::RawIndex.new(root: container.root)
|
|
56
|
-
duplicate_key = find_duplicate(index, content_hash)
|
|
57
|
-
|
|
58
|
-
if duplicate_key && duplicate_key != key
|
|
59
|
-
supersede_entry(duplicate_key, key, structured, container, call, index)
|
|
60
|
-
else
|
|
61
|
-
env = write_raw_entry(key, structured, mentry, writer)
|
|
62
|
-
index.upsert(content_hash: content_hash, url: @url, key: key)
|
|
63
|
-
env
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
private
|
|
68
|
-
|
|
69
|
-
def validate_inputs!
|
|
70
|
-
unless SOURCE_KINDS.include?(@kind)
|
|
71
|
-
raise Textus::UsageError.new(
|
|
72
|
-
"ingest kind must be one of #{SOURCE_KINDS.join("|")}, got #{@kind.inspect}",
|
|
73
|
-
)
|
|
74
|
-
end
|
|
75
|
-
case @kind
|
|
76
|
-
when "url"
|
|
77
|
-
raise Textus::UsageError.new("ingest url requires --url") unless @url
|
|
78
|
-
when "file"
|
|
79
|
-
raise Textus::UsageError.new("ingest file requires --path") unless @path
|
|
80
|
-
when "asset"
|
|
81
|
-
raise Textus::UsageError.new("ingest asset requires --path") unless @path
|
|
82
|
-
raise Textus::UsageError.new("ingest asset requires --zone") unless @zone
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def derive_key(now)
|
|
87
|
-
date = now.strftime("%Y.%m.%d")
|
|
88
|
-
"raw.#{date}.#{@kind}-#{@slug}"
|
|
89
|
-
end
|
|
90
|
-
|
|
91
|
-
def compute_content_hash
|
|
92
|
-
digest = Digest::SHA256.new
|
|
93
|
-
case @kind
|
|
94
|
-
when "url"
|
|
95
|
-
digest.update(@url)
|
|
96
|
-
when "file", "asset"
|
|
97
|
-
digest.file(@path)
|
|
98
|
-
end
|
|
99
|
-
"#{CONTENT_HASH_ALGO}:#{digest.hexdigest}"
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
def build_structured(timestamp, container, now, content_hash)
|
|
103
|
-
base = { "ingested_at" => timestamp, "content_hash" => content_hash }
|
|
104
|
-
case @kind
|
|
105
|
-
when "url"
|
|
106
|
-
base.merge("source" => { "kind" => "url", "url" => @url, "label" => @label || @url },
|
|
107
|
-
"body" => nil)
|
|
108
|
-
when "file"
|
|
109
|
-
body_content = File.read(@path)
|
|
110
|
-
base.merge("source" => { "kind" => "file", "path" => @path,
|
|
111
|
-
"label" => @label || File.basename(@path) },
|
|
112
|
-
"body" => body_content)
|
|
113
|
-
when "asset"
|
|
114
|
-
asset_rel = copy_asset_file(container, now)
|
|
115
|
-
base.merge("source" => { "kind" => "asset",
|
|
116
|
-
"label" => @label || File.basename(@path) },
|
|
117
|
-
"asset" => asset_rel,
|
|
118
|
-
"body" => nil)
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def write_raw_entry(key, structured, mentry, writer)
|
|
123
|
-
writer.put(key, mentry: mentry,
|
|
124
|
-
payload: Textus::Envelope::Writer::Payload.new(
|
|
125
|
-
meta: nil, body: nil, content: structured,
|
|
126
|
-
))
|
|
127
|
-
end
|
|
128
|
-
|
|
129
|
-
def find_duplicate(index, content_hash)
|
|
130
|
-
dup = index.find_by_hash(content_hash)
|
|
131
|
-
return dup if dup
|
|
132
|
-
|
|
133
|
-
return unless @kind == "url"
|
|
134
|
-
|
|
135
|
-
index.find_by_url(@url)
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def supersede_entry(old_key, new_key, structured, container, call, index)
|
|
139
|
-
old_mentry = container.manifest.resolver.resolve(old_key).entry
|
|
140
|
-
writer = Textus::Envelope::Writer.from(container: container, call: call)
|
|
141
|
-
|
|
142
|
-
reader = Textus::Envelope::Reader.from(container: container)
|
|
143
|
-
old_env = reader.read(old_key)
|
|
144
|
-
old_content = old_env&.content || {}
|
|
145
|
-
tombstone = {}
|
|
146
|
-
TOMBSTONE_RETAIN.each do |k|
|
|
147
|
-
tombstone[k] = old_content[k] if old_content.key?(k)
|
|
148
|
-
end
|
|
149
|
-
source_kind = old_content.dig("source", "kind")
|
|
150
|
-
tombstone["source"] = { "kind" => source_kind } if source_kind
|
|
151
|
-
tombstone["superseded_by"] = new_key
|
|
152
|
-
|
|
153
|
-
writer.put(old_key, mentry: old_mentry,
|
|
154
|
-
payload: Textus::Envelope::Writer::Payload.new(
|
|
155
|
-
meta: nil, body: nil, content: tombstone,
|
|
156
|
-
))
|
|
157
|
-
|
|
158
|
-
structured["supersedes"] = old_key
|
|
159
|
-
env = write_raw_entry(new_key, structured, container.manifest.resolver.resolve(new_key).entry, writer)
|
|
160
|
-
|
|
161
|
-
move_asset_file(container, old_content["asset"]) if @kind == "asset" && old_content["asset"]
|
|
162
|
-
|
|
163
|
-
index.upsert(content_hash: structured["content_hash"], url: @url, key: new_key)
|
|
164
|
-
env
|
|
165
|
-
end
|
|
166
|
-
|
|
167
|
-
def move_asset_file(container, old_asset_rel)
|
|
168
|
-
old_path = File.join(container.root, "assets", old_asset_rel)
|
|
169
|
-
return unless File.exist?(old_path)
|
|
170
|
-
|
|
171
|
-
now = Time.now.utc
|
|
172
|
-
date_path = now.strftime("%Y/%m/%d")
|
|
173
|
-
filename = File.basename(old_path)
|
|
174
|
-
new_dir = File.join(container.root, "assets", "raw", date_path, @zone)
|
|
175
|
-
new_path = File.join(new_dir, filename)
|
|
176
|
-
|
|
177
|
-
return if old_path == new_path
|
|
178
|
-
|
|
179
|
-
FileUtils.mkdir_p(new_dir)
|
|
180
|
-
FileUtils.mv(old_path, new_path)
|
|
181
|
-
rescue Errno::ENOENT, Errno::EACCES => e
|
|
182
|
-
warn "[textus ingest] could not move asset #{old_asset_rel}: #{e.message}"
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
def copy_asset_file(container, now)
|
|
186
|
-
date_path = now.strftime("%Y/%m/%d")
|
|
187
|
-
filename = File.basename(@path)
|
|
188
|
-
assets_dir = File.join(container.root, "assets", "raw", date_path, @zone)
|
|
189
|
-
FileUtils.mkdir_p(assets_dir)
|
|
190
|
-
FileUtils.cp(@path, File.join(assets_dir, filename))
|
|
191
|
-
create_gitignore_sentinel(container)
|
|
192
|
-
"raw/#{date_path}/#{@zone}/#{filename}"
|
|
193
|
-
end
|
|
194
|
-
|
|
195
|
-
def create_gitignore_sentinel(container)
|
|
196
|
-
assets_root = File.join(container.root, "assets")
|
|
197
|
-
FileUtils.mkdir_p(assets_root)
|
|
198
|
-
sentinel = File.join(assets_root, ".gitignore")
|
|
199
|
-
File.write(sentinel, "*\n") unless File.exist?(sentinel)
|
|
200
|
-
end
|
|
201
|
-
end
|
|
202
|
-
end
|
|
203
|
-
end
|
data/lib/textus/action/jobs.rb
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Action
|
|
5
|
-
class Jobs < Base
|
|
6
|
-
extend Textus::Contract::DSL
|
|
7
|
-
|
|
8
|
-
verb :jobs
|
|
9
|
-
summary "List queued jobs by state; retry a dead-lettered job or purge a state."
|
|
10
|
-
surfaces :cli, :mcp
|
|
11
|
-
cli "jobs"
|
|
12
|
-
arg :state, String, default: "ready", description: "ready|leased|done|failed"
|
|
13
|
-
arg :action, String, default: nil, description: "retry|purge (optional)"
|
|
14
|
-
arg :job_id, String, default: nil, description: "job id (required for action=retry)"
|
|
15
|
-
|
|
16
|
-
def initialize(state: "ready", action: nil, job_id: nil)
|
|
17
|
-
super()
|
|
18
|
-
@state = state
|
|
19
|
-
@action = action
|
|
20
|
-
@job_id = job_id
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
def call(container:, **)
|
|
24
|
-
queue = Textus::Ports::JobStore.new(root: container.root)
|
|
25
|
-
case @action
|
|
26
|
-
when "retry"
|
|
27
|
-
queue.retry_failed(@job_id)
|
|
28
|
-
when "purge"
|
|
29
|
-
queue.purge(@state)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
{ "protocol" => Textus::PROTOCOL, "ok" => true, "state" => @state, "jobs" => queue.list(@state) }
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Action
|
|
5
|
-
class KeyDelete < WriteVerb
|
|
6
|
-
extend Textus::Contract::DSL
|
|
7
|
-
|
|
8
|
-
verb :key_delete
|
|
9
|
-
summary "Delete one entry by key. Single-key, lower blast radius than key_delete_prefix; " \
|
|
10
|
-
"guarded by an optional optimistic-concurrency etag. Returns {ok, key, deleted}."
|
|
11
|
-
surfaces :cli, :mcp
|
|
12
|
-
cli "key delete"
|
|
13
|
-
arg :key, String, required: true, positional: true,
|
|
14
|
-
description: "dotted entry key to delete"
|
|
15
|
-
arg :if_etag, String,
|
|
16
|
-
description: "optimistic-concurrency guard: the etag you last read; the delete is rejected if the entry changed since"
|
|
17
|
-
|
|
18
|
-
def initialize(key:, if_etag: nil)
|
|
19
|
-
super()
|
|
20
|
-
@key = key
|
|
21
|
-
@if_etag = if_etag
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def call(container:, call:)
|
|
25
|
-
run_with_cascade(@key, container:, call:) do
|
|
26
|
-
Textus::Manifest::Data.validate_key!(@key)
|
|
27
|
-
mentry = container.manifest.resolver.resolve(@key).entry
|
|
28
|
-
|
|
29
|
-
writer(container, call).delete(@key, mentry:, if_etag: @if_etag)
|
|
30
|
-
|
|
31
|
-
{ "protocol" => Textus::PROTOCOL, "ok" => true, "key" => @key, "deleted" => true }
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Action
|
|
5
|
-
class KeyDeletePrefix < Base
|
|
6
|
-
extend Textus::Contract::DSL
|
|
7
|
-
|
|
8
|
-
verb :key_delete_prefix
|
|
9
|
-
summary "Bulk-delete every leaf key under prefix."
|
|
10
|
-
surfaces :cli, :mcp
|
|
11
|
-
cli "key delete-prefix"
|
|
12
|
-
arg :prefix, String, required: true, positional: true,
|
|
13
|
-
description: "every leaf key under this dotted prefix is deleted"
|
|
14
|
-
arg :dry_run, :boolean, default: false,
|
|
15
|
-
description: "when true, returns the keys that would be deleted without deleting them; " \
|
|
16
|
-
"defaults to false, so omitting it deletes immediately"
|
|
17
|
-
view { |v, _i| v.to_h }
|
|
18
|
-
|
|
19
|
-
def initialize(prefix:, dry_run: false)
|
|
20
|
-
super()
|
|
21
|
-
@prefix = prefix
|
|
22
|
-
@dry_run = dry_run
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def call(container:, call:)
|
|
26
|
-
raise UsageError.new("prefix required") if @prefix.nil? || @prefix.empty?
|
|
27
|
-
|
|
28
|
-
leaves = Textus::Action::List.new(prefix: @prefix).call(container: container)
|
|
29
|
-
.map { |row| row.is_a?(Hash) ? (row["key"] || row[:key]) : row }
|
|
30
|
-
|
|
31
|
-
warnings = leaves.empty? ? ["no keys under #{@prefix}"] : []
|
|
32
|
-
steps = leaves.map { |key| { "op" => "delete", "key" => key } }
|
|
33
|
-
|
|
34
|
-
plan = Textus::Jobs::Plan.new(steps: steps, warnings: warnings)
|
|
35
|
-
return plan if @dry_run
|
|
36
|
-
|
|
37
|
-
steps.each do |step|
|
|
38
|
-
Textus::Action::KeyDelete.new(key: step["key"]).call(container: container, call: call)
|
|
39
|
-
end
|
|
40
|
-
plan
|
|
41
|
-
end
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|