textus 0.53.0 → 0.54.1
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 +52 -0
- data/README.md +53 -73
- data/SPEC.md +2 -5
- data/docs/architecture/README.md +149 -159
- data/lib/textus/action/accept.rb +0 -9
- data/lib/textus/action/audit.rb +0 -2
- data/lib/textus/action/blame.rb +0 -2
- data/lib/textus/action/boot.rb +2 -11
- data/lib/textus/action/data_mv.rb +1 -3
- data/lib/textus/action/deps.rb +1 -15
- data/lib/textus/action/doctor.rb +0 -2
- data/lib/textus/action/drain.rb +4 -6
- data/lib/textus/action/enqueue.rb +3 -5
- data/lib/textus/action/get.rb +1 -3
- data/lib/textus/action/ingest.rb +135 -0
- data/lib/textus/action/jobs.rb +1 -3
- data/lib/textus/action/key_delete.rb +0 -10
- data/lib/textus/action/key_delete_prefix.rb +1 -3
- data/lib/textus/action/key_mv.rb +1 -15
- data/lib/textus/action/key_mv_prefix.rb +1 -3
- data/lib/textus/action/list.rb +0 -2
- data/lib/textus/action/propose.rb +0 -2
- data/lib/textus/action/published.rb +0 -2
- data/lib/textus/action/pulse.rb +7 -38
- data/lib/textus/action/put.rb +1 -12
- data/lib/textus/action/rdeps.rb +2 -12
- data/lib/textus/action/reject.rb +0 -12
- data/lib/textus/action/rule_explain.rb +0 -4
- data/lib/textus/action/rule_lint.rb +1 -3
- data/lib/textus/action/rule_list.rb +0 -4
- data/lib/textus/action/schema_envelope.rb +0 -2
- data/lib/textus/action/uid.rb +0 -2
- data/lib/textus/action/where.rb +0 -2
- data/lib/textus/action/write_verb.rb +4 -18
- data/lib/textus/boot.rb +40 -109
- data/lib/textus/command.rb +6 -1
- data/lib/textus/container.rb +21 -5
- data/lib/textus/core/freshness/evaluator.rb +18 -34
- data/lib/textus/core/freshness/verdict.rb +1 -1
- data/lib/textus/doctor/check/notebook_sources.rb +50 -0
- data/lib/textus/doctor/check/protocol_version.rb +6 -6
- data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
- data/lib/textus/doctor/check/stale_reviewed_stamp.rb +54 -0
- data/lib/textus/doctor/check.rb +0 -1
- data/lib/textus/doctor.rb +3 -42
- data/lib/textus/envelope/reader.rb +46 -0
- data/lib/textus/envelope/writer.rb +209 -0
- data/lib/textus/envelope.rb +19 -6
- data/lib/textus/errors.rb +9 -5
- data/lib/textus/{entry → format}/base.rb +1 -6
- data/lib/textus/{entry → format}/json.rb +1 -4
- data/lib/textus/{entry → format}/markdown.rb +2 -6
- data/lib/textus/{entry → format}/text.rb +2 -6
- data/lib/textus/{entry → format}/yaml.rb +1 -4
- data/lib/textus/{entry.rb → format.rb} +9 -10
- data/lib/textus/gate/auth.rb +58 -12
- data/lib/textus/gate.rb +2 -0
- data/lib/textus/init.rb +16 -79
- data/lib/textus/jobs/base.rb +23 -0
- data/lib/textus/jobs/materialize.rb +20 -0
- data/lib/textus/{background → jobs}/plan.rb +1 -1
- data/lib/textus/jobs/planner.rb +101 -0
- data/lib/textus/jobs/retention.rb +48 -0
- data/lib/textus/jobs/sweep.rb +27 -0
- data/lib/textus/{background → jobs}/worker.rb +2 -2
- data/lib/textus/jobs.rb +15 -0
- data/lib/textus/key/path.rb +1 -1
- data/lib/textus/manifest/entry/base.rb +4 -19
- data/lib/textus/manifest/entry/parser.rb +27 -10
- data/lib/textus/manifest/entry/produced.rb +13 -34
- data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
- data/lib/textus/manifest/entry/publish.rb +0 -12
- data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/manifest/policy/source.rb +14 -62
- data/lib/textus/manifest/policy.rb +2 -5
- data/lib/textus/manifest/resolver.rb +2 -2
- data/lib/textus/manifest/schema/contract.rb +61 -0
- data/lib/textus/manifest/schema/keys.rb +6 -20
- data/lib/textus/manifest/schema/semantics.rb +232 -0
- data/lib/textus/manifest/schema/validator.rb +24 -302
- data/lib/textus/manifest/schema/vocabulary.rb +1 -0
- data/lib/textus/manifest/schema.rb +1 -4
- data/lib/textus/manifest.rb +1 -3
- data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
- data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
- data/lib/textus/produce/engine.rb +50 -0
- data/lib/textus/produce/render.rb +20 -0
- data/lib/textus/session.rb +15 -8
- data/lib/textus/store.rb +5 -14
- data/lib/textus/surfaces/mcp/catalog.rb +23 -11
- data/lib/textus/surfaces/mcp/errors.rb +0 -9
- data/lib/textus/surfaces/mcp/server.rb +87 -110
- data/lib/textus/surfaces/role_scope.rb +0 -4
- data/lib/textus/surfaces/watcher.rb +3 -3
- data/lib/textus/types.rb +15 -0
- data/lib/textus/version.rb +2 -2
- data/lib/textus/workflow/collector.rb +27 -0
- data/lib/textus/workflow/context.rb +5 -0
- data/lib/textus/workflow/dsl.rb +44 -0
- data/lib/textus/workflow/loader.rb +17 -0
- data/lib/textus/workflow/pattern.rb +18 -0
- data/lib/textus/workflow/registry.rb +21 -0
- data/lib/textus/workflow/runner.rb +99 -0
- data/lib/textus/workflow.rb +28 -0
- data/lib/textus.rb +12 -1
- metadata +67 -66
- data/lib/textus/action/pulse/scanner.rb +0 -118
- data/lib/textus/background/job/base.rb +0 -27
- data/lib/textus/background/job/materialize.rb +0 -31
- data/lib/textus/background/job/refresh.rb +0 -22
- data/lib/textus/background/job/sweep.rb +0 -31
- data/lib/textus/background/job.rb +0 -19
- data/lib/textus/background/planner/plan.rb +0 -113
- data/lib/textus/background/retention/apply.rb +0 -50
- data/lib/textus/doctor/check/handler_permit.rb +0 -34
- data/lib/textus/doctor/check/hooks.rb +0 -25
- data/lib/textus/doctor/check/intake_registration.rb +0 -46
- data/lib/textus/envelope/io/reader.rb +0 -48
- data/lib/textus/envelope/io/writer.rb +0 -211
- data/lib/textus/events.rb +0 -21
- data/lib/textus/init/templates/machine_intake.rb +0 -49
- data/lib/textus/init/templates/orientation_reducer.rb +0 -21
- data/lib/textus/manifest/entry/validators/events.rb +0 -22
- data/lib/textus/manifest/policy/handler_permit.rb +0 -17
- data/lib/textus/pipeline/acquire/handler.rb +0 -29
- data/lib/textus/pipeline/acquire/intake.rb +0 -132
- data/lib/textus/pipeline/acquire/projection.rb +0 -129
- data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
- data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
- data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
- data/lib/textus/pipeline/acquire/serializer.rb +0 -17
- data/lib/textus/pipeline/engine.rb +0 -97
- data/lib/textus/pipeline/render.rb +0 -25
- data/lib/textus/ports/audit_subscriber.rb +0 -42
- data/lib/textus/projection.rb +0 -103
- data/lib/textus/step/base.rb +0 -35
- data/lib/textus/step/builtin/csv_fetch.rb +0 -19
- data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
- data/lib/textus/step/builtin/json_fetch.rb +0 -18
- data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
- data/lib/textus/step/builtin/rss_fetch.rb +0 -26
- data/lib/textus/step/builtin.rb +0 -22
- data/lib/textus/step/catalog.rb +0 -37
- data/lib/textus/step/context.rb +0 -74
- data/lib/textus/step/discovery.rb +0 -24
- data/lib/textus/step/error_log.rb +0 -32
- data/lib/textus/step/event_bus.rb +0 -113
- data/lib/textus/step/fetch.rb +0 -13
- data/lib/textus/step/fire_report.rb +0 -23
- data/lib/textus/step/loader.rb +0 -108
- data/lib/textus/step/observe.rb +0 -31
- data/lib/textus/step/registry_store.rb +0 -66
- data/lib/textus/step/signature.rb +0 -31
- data/lib/textus/step/transform.rb +0 -12
- data/lib/textus/step/validate.rb +0 -11
- data/lib/textus/step.rb +0 -10
- data/lib/textus/surfaces/mcp/session.rb +0 -9
- data/lib/textus/surfaces/mcp/tool_schemas.rb +0 -17
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Doctor
|
|
3
|
-
class Check
|
|
4
|
-
# For every entry with a fetch handler, look up its handler_permit policy
|
|
5
|
-
# (if any) and verify the declared handler is permitted. Emits a failure
|
|
6
|
-
# when the handler is rejected by policy.
|
|
7
|
-
class HandlerPermit < Check
|
|
8
|
-
def call
|
|
9
|
-
out = []
|
|
10
|
-
manifest.data.entries.each do |mentry|
|
|
11
|
-
next unless mentry.intake?
|
|
12
|
-
|
|
13
|
-
handler = mentry.handler
|
|
14
|
-
|
|
15
|
-
permit = manifest.rules.for(mentry.key).handler_permit
|
|
16
|
-
next if permit.nil?
|
|
17
|
-
next if permit.permits?(handler)
|
|
18
|
-
|
|
19
|
-
out << {
|
|
20
|
-
"code" => "policy.handler_not_permitted",
|
|
21
|
-
"level" => "error",
|
|
22
|
-
"subject" => mentry.key,
|
|
23
|
-
"message" => "entry '#{mentry.key}' declares source.handler='#{handler}' but " \
|
|
24
|
-
"handler_permit allows only: #{permit.handlers.join(", ")}",
|
|
25
|
-
"fix" => "change handler to one of [#{permit.handlers.join(", ")}] or " \
|
|
26
|
-
"extend handler_permit in .textus/manifest.yaml",
|
|
27
|
-
}
|
|
28
|
-
end
|
|
29
|
-
out
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
@@ -1,25 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Doctor
|
|
3
|
-
class Check
|
|
4
|
-
class Hooks < Check
|
|
5
|
-
def call
|
|
6
|
-
out = []
|
|
7
|
-
dir = File.join(root, "steps")
|
|
8
|
-
return out unless File.directory?(dir)
|
|
9
|
-
|
|
10
|
-
Textus::Step::Loader.new(registry: Textus::Step::RegistryStore.new).load_dir(dir)
|
|
11
|
-
out
|
|
12
|
-
rescue Textus::UsageError => e
|
|
13
|
-
out << {
|
|
14
|
-
"code" => "step.load_failed",
|
|
15
|
-
"level" => "error",
|
|
16
|
-
"subject" => "steps",
|
|
17
|
-
"message" => e.message,
|
|
18
|
-
"fix" => "open the named step file under .textus/steps/ and fix the error",
|
|
19
|
-
}
|
|
20
|
-
out
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
25
|
-
end
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Doctor
|
|
3
|
-
class Check
|
|
4
|
-
class IntakeRegistration < Check
|
|
5
|
-
BUILTIN = %i[json csv markdown-links ical-events rss].freeze
|
|
6
|
-
|
|
7
|
-
def call
|
|
8
|
-
declared = collect_declared_handlers
|
|
9
|
-
registered = steps.names(:fetch).to_set
|
|
10
|
-
|
|
11
|
-
out = (declared - registered).map do |name|
|
|
12
|
-
{
|
|
13
|
-
"code" => "intake.handler_missing",
|
|
14
|
-
"level" => "error",
|
|
15
|
-
"subject" => name.to_s,
|
|
16
|
-
"message" => "manifest references intake handler '#{name}' but no fetch step for '#{name}' is registered",
|
|
17
|
-
"fix" => "create .textus/steps/fetch/#{name}.rb with `class #{name.to_s.split(/[-_]/).map(&:capitalize).join}Fetch < Textus::Step::Fetch`",
|
|
18
|
-
}
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
(registered - declared - BUILTIN.to_set).each do |name|
|
|
22
|
-
out << {
|
|
23
|
-
"code" => "intake.handler_orphan",
|
|
24
|
-
"level" => "warning",
|
|
25
|
-
"subject" => name.to_s,
|
|
26
|
-
"message" => "fetch step '#{name}' is registered but no manifest entry references it",
|
|
27
|
-
"fix" => "remove the unused step, or add an entry with `intake.handler: #{name}`",
|
|
28
|
-
}
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
out
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
def collect_declared_handlers
|
|
37
|
-
set = Set.new
|
|
38
|
-
manifest.data.entries.each do |mentry|
|
|
39
|
-
set << mentry.handler.to_sym if mentry.intake?
|
|
40
|
-
end
|
|
41
|
-
set
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class Envelope
|
|
3
|
-
module IO
|
|
4
|
-
# Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
|
|
5
|
-
# bytes, parses them via the format strategy, and hands back an
|
|
6
|
-
# Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
|
|
7
|
-
# (existing-uid lookup for the uid-preservation step in #put).
|
|
8
|
-
#
|
|
9
|
-
# No audit, no events, no permission checks — those live one layer up.
|
|
10
|
-
class Reader
|
|
11
|
-
def self.from(container:)
|
|
12
|
-
new(file_store: container.file_store, manifest: container.manifest)
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def initialize(file_store:, manifest:)
|
|
16
|
-
@file_store = file_store
|
|
17
|
-
@manifest = manifest
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def read(key)
|
|
21
|
-
res = @manifest.resolver.resolve(key)
|
|
22
|
-
path = res.path
|
|
23
|
-
return nil unless @file_store.exists?(path)
|
|
24
|
-
|
|
25
|
-
mentry = res.entry
|
|
26
|
-
raw = @file_store.read(path)
|
|
27
|
-
parsed = Entry.for_format(mentry.format).parse(raw, path: path)
|
|
28
|
-
Textus::Envelope.build(
|
|
29
|
-
key: key, mentry: mentry, path: path,
|
|
30
|
-
meta: parsed["_meta"], body: parsed["body"],
|
|
31
|
-
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
32
|
-
)
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
def existing_uid(key)
|
|
36
|
-
env = read(key)
|
|
37
|
-
env&.uid
|
|
38
|
-
rescue StandardError
|
|
39
|
-
nil
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
def exists?(key)
|
|
43
|
-
@file_store.exists?(@manifest.resolver.resolve(key).path)
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
@@ -1,211 +0,0 @@
|
|
|
1
|
-
require "fileutils"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
class Envelope
|
|
5
|
-
module IO
|
|
6
|
-
# Owns the write pipeline (validate, serialize, etag-check, write, audit).
|
|
7
|
-
# Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
|
|
8
|
-
# Reader for the existing-uid lookup.
|
|
9
|
-
#
|
|
10
|
-
# Invariant: every public method's final action is @audit_log.append(...).
|
|
11
|
-
#
|
|
12
|
-
# No permission check, no event firing — those belong to the caller
|
|
13
|
-
# (Write::Put / ::Delete / ::Mv).
|
|
14
|
-
class Writer
|
|
15
|
-
Payload = Data.define(:meta, :body, :content)
|
|
16
|
-
|
|
17
|
-
def self.from(container:, call:)
|
|
18
|
-
new(
|
|
19
|
-
file_store: container.file_store, manifest: container.manifest,
|
|
20
|
-
schemas: container.schemas, audit_log: container.audit_log,
|
|
21
|
-
call: call, reader: Reader.from(container: container)
|
|
22
|
-
)
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
|
|
26
|
-
@file_store = file_store
|
|
27
|
-
@manifest = manifest
|
|
28
|
-
@schemas = schemas
|
|
29
|
-
@audit_log = audit_log
|
|
30
|
-
@call = call
|
|
31
|
-
@reader = reader
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def put(key, mentry:, payload:, if_etag: nil)
|
|
35
|
-
path = resolve_path(key)
|
|
36
|
-
meta, content = prepare_uid(mentry, payload, key)
|
|
37
|
-
bytes, eff_meta, eff_body, eff_content = serialize_entry(mentry, path, meta, payload, content)
|
|
38
|
-
enforce_name_match!(path, eff_meta, mentry.format)
|
|
39
|
-
validate_schema(mentry, eff_meta, eff_content)
|
|
40
|
-
etag_before = check_etag!(path, key, if_etag)
|
|
41
|
-
write_bytes(path, bytes)
|
|
42
|
-
envelope = build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
|
|
43
|
-
audit_put(key, etag_before, envelope.etag)
|
|
44
|
-
envelope
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
48
|
-
# `mentry:` is accepted for symmetry with `put` / `move` and to
|
|
49
|
-
# leave room for future format-specific delete hooks; no field
|
|
50
|
-
# on it is needed today.
|
|
51
|
-
path = @manifest.resolver.resolve(key).path
|
|
52
|
-
raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
|
|
53
|
-
|
|
54
|
-
etag_before = @file_store.etag(path)
|
|
55
|
-
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
56
|
-
|
|
57
|
-
@file_store.delete(path)
|
|
58
|
-
prune_empty_parents(path)
|
|
59
|
-
@audit_log.append(
|
|
60
|
-
role: @call.role, verb: "key_delete", key: key,
|
|
61
|
-
etag_before: etag_before, etag_after: nil,
|
|
62
|
-
extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
63
|
-
)
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def move(from_key:, to_key:, new_mentry:, if_etag: nil)
|
|
67
|
-
from_path = @manifest.resolver.resolve(from_key).path
|
|
68
|
-
to_path = @manifest.resolver.resolve(to_key).path
|
|
69
|
-
raise UnknownKey.new(from_key, suggestions: @manifest.resolver.suggestions_for(from_key)) unless @file_store.exists?(from_path)
|
|
70
|
-
|
|
71
|
-
etag_before = @file_store.etag(from_path)
|
|
72
|
-
raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
73
|
-
|
|
74
|
-
FileUtils.mkdir_p(File.dirname(to_path))
|
|
75
|
-
FileUtils.mv(from_path, to_path)
|
|
76
|
-
prune_empty_parents(from_path)
|
|
77
|
-
basename = to_key.split(".").last
|
|
78
|
-
Entry.for_format(new_mentry.format).rewrite_name(to_path, basename)
|
|
79
|
-
etag_after = Etag.for_file(to_path)
|
|
80
|
-
|
|
81
|
-
raw = @file_store.read(to_path)
|
|
82
|
-
parsed = Entry.for_format(new_mentry.format).parse(raw, path: to_path)
|
|
83
|
-
envelope = Textus::Envelope.build(
|
|
84
|
-
key: to_key, mentry: new_mentry, path: to_path,
|
|
85
|
-
meta: parsed["_meta"], body: parsed["body"],
|
|
86
|
-
etag: etag_after, content: parsed["content"]
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
extras = {
|
|
90
|
-
"from_key" => from_key, "to_key" => to_key,
|
|
91
|
-
"from_path" => from_path, "to_path" => to_path,
|
|
92
|
-
"uid" => envelope.uid
|
|
93
|
-
}
|
|
94
|
-
extras["correlation_id"] = @call.correlation_id if @call.correlation_id
|
|
95
|
-
|
|
96
|
-
@audit_log.append(
|
|
97
|
-
role: @call.role, verb: "key_mv", key: to_key,
|
|
98
|
-
etag_before: etag_before, etag_after: etag_after,
|
|
99
|
-
extras: extras
|
|
100
|
-
)
|
|
101
|
-
|
|
102
|
-
envelope
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
private
|
|
106
|
-
|
|
107
|
-
# After a file leaves a directory (delete or move-source), remove any
|
|
108
|
-
# now-empty parent dirs so bulk move/delete doesn't accrue orphan dirs
|
|
109
|
-
# (F3 of #161). Floored at the entry's *zone directory* — a zone is a
|
|
110
|
-
# declared, first-class container, so its own dir is preserved even when
|
|
111
|
-
# momentarily empty; only the sub-dirs the bulk op carved out are
|
|
112
|
-
# pruned. Stops at the first non-empty ancestor, so a dir holding a
|
|
113
|
-
# `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
|
|
114
|
-
# non-empty dir is silently fine, never fatal to the write.
|
|
115
|
-
def prune_empty_parents(path)
|
|
116
|
-
floor = zone_floor(path)
|
|
117
|
-
return unless floor
|
|
118
|
-
|
|
119
|
-
dir = File.dirname(path)
|
|
120
|
-
while dir.start_with?("#{floor}/") && Dir.empty?(dir)
|
|
121
|
-
Dir.rmdir(dir)
|
|
122
|
-
dir = File.dirname(dir)
|
|
123
|
-
end
|
|
124
|
-
rescue SystemCallError
|
|
125
|
-
nil
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
# The zone directory under which `path` lives (`<root>/zones/<zone>`),
|
|
129
|
-
# or nil if `path` is not under the store's zones tree.
|
|
130
|
-
def zone_floor(path)
|
|
131
|
-
zones_root = File.join(@manifest.data.root, "data")
|
|
132
|
-
prefix = "#{zones_root}/"
|
|
133
|
-
return nil unless path.start_with?(prefix)
|
|
134
|
-
|
|
135
|
-
zone_seg = path.delete_prefix(prefix).split("/").first
|
|
136
|
-
zone_seg && File.join(zones_root, zone_seg)
|
|
137
|
-
end
|
|
138
|
-
|
|
139
|
-
def ensure_uid(format, meta, content, existing_uid)
|
|
140
|
-
Textus::Entry.for_format(format).inject_uid(meta, content, existing_uid)
|
|
141
|
-
end
|
|
142
|
-
|
|
143
|
-
def enforce_name_match!(path, meta, format)
|
|
144
|
-
Textus::Entry.for_format(format).enforce_name_match!(path, meta)
|
|
145
|
-
end
|
|
146
|
-
|
|
147
|
-
def serialize_for_put(mentry:, path:, meta:, body:, content:)
|
|
148
|
-
Textus::Entry.for_format(mentry.format).serialize_for_put(
|
|
149
|
-
meta: meta, body: body, content: content, path: path,
|
|
150
|
-
)
|
|
151
|
-
end
|
|
152
|
-
|
|
153
|
-
def resolve_path(key)
|
|
154
|
-
@manifest.resolver.resolve(key).path
|
|
155
|
-
end
|
|
156
|
-
|
|
157
|
-
def prepare_uid(mentry, payload, key)
|
|
158
|
-
meta = payload.meta || {}
|
|
159
|
-
existing_uid = @reader.existing_uid(key)
|
|
160
|
-
ensure_uid(mentry.format, meta, payload.content, existing_uid)
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
def serialize_entry(mentry, path, meta, payload, content)
|
|
164
|
-
serialize_for_put(
|
|
165
|
-
mentry: mentry, path: path,
|
|
166
|
-
meta: meta, body: payload.body, content: content
|
|
167
|
-
)
|
|
168
|
-
end
|
|
169
|
-
|
|
170
|
-
def validate_schema(mentry, eff_meta, eff_content)
|
|
171
|
-
schema = @schemas.fetch_or_nil(mentry.schema)
|
|
172
|
-
return unless schema
|
|
173
|
-
|
|
174
|
-
Entry.for_format(mentry.format).validate_against(
|
|
175
|
-
schema,
|
|
176
|
-
{ "_meta" => eff_meta, "content" => eff_content },
|
|
177
|
-
)
|
|
178
|
-
end
|
|
179
|
-
|
|
180
|
-
def check_etag!(path, key, if_etag)
|
|
181
|
-
etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
|
|
182
|
-
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
|
|
183
|
-
|
|
184
|
-
etag_before
|
|
185
|
-
end
|
|
186
|
-
|
|
187
|
-
def write_bytes(path, bytes)
|
|
188
|
-
@file_store.write(path, bytes)
|
|
189
|
-
end
|
|
190
|
-
|
|
191
|
-
def build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
|
|
192
|
-
Textus::Envelope.build(
|
|
193
|
-
key: key, mentry: mentry, path: path,
|
|
194
|
-
meta: eff_meta, body: eff_body,
|
|
195
|
-
etag: Etag.for_bytes(@file_store.read(path)),
|
|
196
|
-
content: eff_content
|
|
197
|
-
)
|
|
198
|
-
end
|
|
199
|
-
|
|
200
|
-
def audit_put(key, etag_before, etag_after)
|
|
201
|
-
extras = @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
202
|
-
@audit_log.append(
|
|
203
|
-
role: @call.role, verb: "put", key: key,
|
|
204
|
-
etag_before: etag_before, etag_after: etag_after,
|
|
205
|
-
extras: extras
|
|
206
|
-
)
|
|
207
|
-
end
|
|
208
|
-
end
|
|
209
|
-
end
|
|
210
|
-
end
|
|
211
|
-
end
|
data/lib/textus/events.rb
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Events
|
|
3
|
-
ENTRY_WRITTEN = "entry.written"
|
|
4
|
-
ENTRY_DELETED = "entry.deleted"
|
|
5
|
-
ENTRY_RENAMED = "entry.renamed"
|
|
6
|
-
ENTRY_FETCHED = "entry.fetched"
|
|
7
|
-
ENTRY_DERIVED = "entry.derived"
|
|
8
|
-
ENTRY_VALIDATED = "entry.validated"
|
|
9
|
-
ENTRY_PUBLISHED = "entry.published"
|
|
10
|
-
PIPELINE_FAILED = "pipeline.failed"
|
|
11
|
-
|
|
12
|
-
STEP_FETCH_COMPLETE = "step.fetch.complete"
|
|
13
|
-
STEP_TRANSFORM_COMPLETE = "step.transform.complete"
|
|
14
|
-
STEP_VALIDATE_PASSED = "step.validate.passed"
|
|
15
|
-
STEP_VALIDATE_FAILED = "step.validate.failed"
|
|
16
|
-
|
|
17
|
-
STORE_LOADED = "store.loaded"
|
|
18
|
-
SESSION_OPENED = "session.opened"
|
|
19
|
-
SESSION_CLOSED = "session.closed"
|
|
20
|
-
end
|
|
21
|
-
end
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
# .textus/steps/fetch/machine_intake.rb
|
|
2
|
-
# Scaffolded by `textus init` — CUSTOMIZE FREELY, or delete the feeds.machines
|
|
3
|
-
# entry from manifest.yaml if you don't want it.
|
|
4
|
-
# Feeds a per-host SNAPSHOT into feeds.machines.<host> on `textus drain`
|
|
5
|
-
# (never on the per-turn boot/pulse path). It is NESTED so it grows to a fleet: the
|
|
6
|
-
# `local` leaf scans THIS host; add ssh hosts with the cookbook recipe
|
|
7
|
-
# (docs/cookbook/environment-scan.md). tracked:false → gitignored. Keep this an
|
|
8
|
-
# ALLOWLIST of versions and counts — NEVER secrets, raw `env`, or package lists.
|
|
9
|
-
module Textus
|
|
10
|
-
module Step
|
|
11
|
-
class MachineIntakeFetch < Fetch
|
|
12
|
-
def call(config:, args:, caps:, **)
|
|
13
|
-
machine = args[:leaf_segments].first or
|
|
14
|
-
raise "machines intake needs a host leaf, e.g. the 'local' in feeds.machines.local"
|
|
15
|
-
spec = (config["machines"] || {}).fetch(machine) { raise "unknown machine: #{machine}" }
|
|
16
|
-
unless (spec["via"] || "local").to_s == "local"
|
|
17
|
-
raise "machine #{machine}: only `via: local` is scaffolded — see " \
|
|
18
|
-
"docs/cookbook/environment-scan.md for the SSH (remote) fan-out"
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
sh = ->(cmd) { `#{cmd}`.strip } # local shell-out, no network
|
|
22
|
-
ver = ->(cmd) { o = `#{cmd} 2>/dev/null`.strip; o.empty? ? nil : o } # nil if tool absent
|
|
23
|
-
count = ->(cmd) { n = `#{cmd} 2>/dev/null`.strip.lines.size; n.zero? ? nil : n }
|
|
24
|
-
{ content: {
|
|
25
|
-
# git_* describe THIS repo on the control host — only meaningful for `local`.
|
|
26
|
-
"git_head" => sh.call("git rev-parse --short HEAD 2>/dev/null"),
|
|
27
|
-
"git_branch" => sh.call("git rev-parse --abbrev-ref HEAD 2>/dev/null"),
|
|
28
|
-
"git_dirty" => !sh.call("git status --porcelain 2>/dev/null").empty?,
|
|
29
|
-
"repo_root" => sh.call("git rev-parse --show-toplevel 2>/dev/null"),
|
|
30
|
-
"captured_at" => Time.now.utc.iso8601,
|
|
31
|
-
"os" => RbConfig::CONFIG["host_os"],
|
|
32
|
-
"arch" => RbConfig::CONFIG["host_cpu"],
|
|
33
|
-
"ruby_version" => RUBY_VERSION,
|
|
34
|
-
"runtimes" => { # versions only; nil when not installed
|
|
35
|
-
"node" => ver.call("node --version"),
|
|
36
|
-
"python" => ver.call("python3 --version"),
|
|
37
|
-
"go" => ver.call("go version"),
|
|
38
|
-
},
|
|
39
|
-
"packages" => { # COUNTS only — never the list (size/secrets)
|
|
40
|
-
"brew" => count.call("brew list --formula"), # ~1-3s on macOS; runs only on fetch, amortized by the ttl rule
|
|
41
|
-
"apt" => count.call("dpkg-query -f '.\n' -W"),
|
|
42
|
-
},
|
|
43
|
-
"textus_version" => Textus::VERSION,
|
|
44
|
-
"protocol" => Textus::PROTOCOL,
|
|
45
|
-
} }
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# Reducer that reshapes the raw projection rows into the keys the
|
|
2
|
-
# orientation.mustache template references. Without this, the template
|
|
3
|
-
# would only have access to the flat rows list.
|
|
4
|
-
module Textus
|
|
5
|
-
module Step
|
|
6
|
-
class OrientationTransform < Transform
|
|
7
|
-
def call(rows:, config:, **)
|
|
8
|
-
project_row = rows.find { |r| r["_key"] == "knowledge.project" } || {}
|
|
9
|
-
runbook_rows = rows.select { |r| r["_key"]&.start_with?("knowledge.runbooks.") }
|
|
10
|
-
|
|
11
|
-
{
|
|
12
|
-
"project" => {
|
|
13
|
-
"name" => project_row["name"],
|
|
14
|
-
"description" => project_row["description"]
|
|
15
|
-
},
|
|
16
|
-
"runbooks" => runbook_rows.map { |r| { "name" => r["name"], "description" => r["description"] } }
|
|
17
|
-
}
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class Manifest
|
|
3
|
-
class Entry
|
|
4
|
-
module Validators
|
|
5
|
-
module Events
|
|
6
|
-
def self.call(entry, policy: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
7
|
-
pubsub_events = Textus::Step::Catalog::PUBSUB.keys
|
|
8
|
-
events = entry.events
|
|
9
|
-
events.each_key do |evt|
|
|
10
|
-
next if pubsub_events.include?(evt.to_sym)
|
|
11
|
-
|
|
12
|
-
raise UsageError.new(
|
|
13
|
-
"entry '#{entry.key}': unknown event '#{evt}' in events: block. " \
|
|
14
|
-
"Known events: #{pubsub_events.join(", ")}.",
|
|
15
|
-
)
|
|
16
|
-
end
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
end
|
|
@@ -1,17 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class Manifest
|
|
3
|
-
class Policy
|
|
4
|
-
class HandlerPermit
|
|
5
|
-
attr_reader :handlers
|
|
6
|
-
|
|
7
|
-
def initialize(handlers:)
|
|
8
|
-
@handlers = Array(handlers).map(&:to_s).freeze
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def permits?(handler)
|
|
12
|
-
@handlers.include?(handler.to_s)
|
|
13
|
-
end
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
end
|
|
17
|
-
end
|
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
require "timeout"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Pipeline
|
|
5
|
-
module Acquire
|
|
6
|
-
# Invokes a :resolve_handler hook handler by name under a timeout — the single
|
|
7
|
-
# home for "call the intake handler under a deadline" (ADR 0048 D1). Shared by
|
|
8
|
-
# Produce::Acquire::Intake (the internal ingest mechanism — no public verb since ADR 0079)
|
|
9
|
-
# as driven by the converge sweep (drain/serve) and `textus hook run` (ADR 0089 made
|
|
10
|
-
# ingest system-pushed; there is no read or put trigger).
|
|
11
|
-
# Always passes a Container as `caps:` so the hook contract (ADR 0027) is
|
|
12
|
-
# uniform across every entry point. Maps Timeout::Error to a UsageError;
|
|
13
|
-
# leaves any other error to the caller (call sites differ in how they wrap).
|
|
14
|
-
module Handler
|
|
15
|
-
FETCH_TIMEOUT_SECONDS = 30
|
|
16
|
-
|
|
17
|
-
module_function
|
|
18
|
-
|
|
19
|
-
def invoke(caps:, handler:, config:, args:, label:, timeout: FETCH_TIMEOUT_SECONDS)
|
|
20
|
-
Timeout.timeout(timeout) do
|
|
21
|
-
caps.steps.invoke(:fetch, handler, caps: caps, config: config, args: args)
|
|
22
|
-
end
|
|
23
|
-
rescue Timeout::Error
|
|
24
|
-
raise Textus::UsageError.new("#{label} '#{handler}' exceeded #{timeout}s timeout")
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
end
|
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
require "timeout"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Pipeline
|
|
5
|
-
module Acquire
|
|
6
|
-
# Internal ingest executor for one machine-zone intake entry. No longer a
|
|
7
|
-
# public verb (ADR 0079 collapsed the `fetch` surface): used by the
|
|
8
|
-
# converge sweep (drain/serve) and `textus hook run` only — ingest is system-pushed
|
|
9
|
-
# (ADR 0089 removed the read-through that once also drove it).
|
|
10
|
-
class Intake
|
|
11
|
-
FETCH_TIMEOUT_SECONDS = Textus::Pipeline::Acquire::Handler::FETCH_TIMEOUT_SECONDS
|
|
12
|
-
|
|
13
|
-
def initialize(container:, call:)
|
|
14
|
-
@container = container
|
|
15
|
-
@call = call
|
|
16
|
-
@manifest = container.manifest
|
|
17
|
-
@schemas = container.schemas
|
|
18
|
-
@steps = container.steps
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
# call(key) is the primary entry; run is kept as an alias for
|
|
22
|
-
# Orchestrator and FetchAll which call worker.run(key).
|
|
23
|
-
def call(key)
|
|
24
|
-
run(key)
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def run(key)
|
|
28
|
-
res = @manifest.resolver.resolve(key)
|
|
29
|
-
mentry = res.entry
|
|
30
|
-
path = res.path
|
|
31
|
-
remaining = res.remaining
|
|
32
|
-
raise UsageError.new("no intake declared for '#{key}'") unless mentry.intake?
|
|
33
|
-
|
|
34
|
-
before_etag = @container.file_store.exists?(path) ? @container.file_store.etag(path) : nil
|
|
35
|
-
result = fetch_with_events(key, mentry, remaining)
|
|
36
|
-
persist_and_notify(key, mentry, result, before_etag)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def self.normalize_action_result(res, format:)
|
|
40
|
-
res = res.transform_keys(&:to_s) if res.is_a?(Hash)
|
|
41
|
-
res ||= {}
|
|
42
|
-
meta_val = res["_meta"]
|
|
43
|
-
body = res["body"]
|
|
44
|
-
content = res["content"]
|
|
45
|
-
|
|
46
|
-
case format
|
|
47
|
-
when "markdown" then { meta: meta_val || {}, body: body.to_s, content: nil }
|
|
48
|
-
when "text" then { meta: {}, body: body.to_s, content: nil }
|
|
49
|
-
when "json", "yaml"
|
|
50
|
-
if !content.nil?
|
|
51
|
-
{ meta: meta_val || {}, body: nil, content: content }
|
|
52
|
-
elsif !body.nil?
|
|
53
|
-
{ meta: {}, body: body.to_s, content: nil }
|
|
54
|
-
else
|
|
55
|
-
raise Textus::UsageError.new("intake for #{format} returned neither content nor body")
|
|
56
|
-
end
|
|
57
|
-
else
|
|
58
|
-
raise Textus::UsageError.new("unknown format #{format.inspect}")
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
private
|
|
63
|
-
|
|
64
|
-
# ADR 0079: a per-rule fetch_timeout_seconds override was an accepted loss
|
|
65
|
-
# in the fetch:/retention: → lifecycle: collapse; the constant ceiling
|
|
66
|
-
# applies to every intake.
|
|
67
|
-
def fetch_timeout_for(_key)
|
|
68
|
-
FETCH_TIMEOUT_SECONDS
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
def steps_ctx
|
|
72
|
-
@steps_ctx ||= Textus::Step::Context.for(container: @container, call: @call)
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def fetch_with_events(key, mentry, remaining)
|
|
76
|
-
@steps.publish(:entry_fetch_started, ctx: steps_ctx, key: key, mode: :sync)
|
|
77
|
-
call_intake(key, mentry, remaining)
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def call_intake(key, mentry, remaining)
|
|
81
|
-
Textus::Pipeline::Acquire::Handler.invoke(
|
|
82
|
-
caps: @container, handler: mentry.handler,
|
|
83
|
-
config: mentry.config,
|
|
84
|
-
args: { trigger_key: key, leaf_segments: remaining || [] },
|
|
85
|
-
label: "intake", timeout: fetch_timeout_for(key)
|
|
86
|
-
)
|
|
87
|
-
rescue Textus::Error => e
|
|
88
|
-
@steps.publish(:entry_fetch_failed, ctx: steps_ctx, key: key,
|
|
89
|
-
error_class: e.class.name, error_message: e.message)
|
|
90
|
-
raise
|
|
91
|
-
rescue StandardError => e
|
|
92
|
-
@steps.publish(:entry_fetch_failed, ctx: steps_ctx, key: key,
|
|
93
|
-
error_class: e.class.name, error_message: e.message)
|
|
94
|
-
raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
def persist_and_notify(key, mentry, result, before_etag)
|
|
98
|
-
normalized = self.class.normalize_action_result(result, format: mentry.format)
|
|
99
|
-
auth.check_action!(action: :converge, actor: @call.role, key: key)
|
|
100
|
-
envelope = writer.put(
|
|
101
|
-
key,
|
|
102
|
-
mentry: mentry,
|
|
103
|
-
payload: Textus::Envelope::IO::Writer::Payload.new(
|
|
104
|
-
meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
|
|
105
|
-
),
|
|
106
|
-
)
|
|
107
|
-
change = detect_change(before_etag, envelope)
|
|
108
|
-
unless change == :unchanged
|
|
109
|
-
@steps.publish(:entry_fetched, ctx: steps_ctx, key: key,
|
|
110
|
-
envelope: envelope, change: change)
|
|
111
|
-
end
|
|
112
|
-
envelope
|
|
113
|
-
end
|
|
114
|
-
|
|
115
|
-
def detect_change(before_etag, envelope)
|
|
116
|
-
if before_etag.nil? then :created
|
|
117
|
-
elsif envelope.etag == before_etag then :unchanged
|
|
118
|
-
else :updated
|
|
119
|
-
end
|
|
120
|
-
end
|
|
121
|
-
|
|
122
|
-
def writer
|
|
123
|
-
@writer ||= Textus::Envelope::IO::Writer.from(container: @container, call: @call)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def auth
|
|
127
|
-
@auth ||= Textus::Gate::Auth.new(@container)
|
|
128
|
-
end
|
|
129
|
-
end
|
|
130
|
-
end
|
|
131
|
-
end
|
|
132
|
-
end
|