textus 0.54.2 → 0.55.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 +37 -0
- data/README.md +8 -1
- data/SPEC.md +27 -0
- data/docs/architecture/README.md +20 -8
- data/docs/reference/conventions.md +1 -1
- data/exe/textus +1 -1
- data/lib/textus/action/accept.rb +23 -21
- data/lib/textus/action/audit.rb +24 -61
- data/lib/textus/action/base.rb +9 -9
- data/lib/textus/action/blame.rb +18 -36
- data/lib/textus/action/boot.rb +2 -4
- data/lib/textus/action/data_mv.rb +20 -31
- data/lib/textus/action/deps.rb +3 -18
- data/lib/textus/action/doctor.rb +2 -9
- data/lib/textus/action/drain.rb +11 -19
- data/lib/textus/action/enqueue.rb +14 -30
- data/lib/textus/action/get.rb +12 -56
- data/lib/textus/action/ingest.rb +74 -78
- data/lib/textus/action/jobs.rb +6 -15
- data/lib/textus/action/key_delete.rb +6 -16
- data/lib/textus/action/key_delete_prefix.rb +8 -17
- data/lib/textus/action/key_mv.rb +54 -61
- data/lib/textus/action/key_mv_prefix.rb +13 -22
- data/lib/textus/action/list.rb +7 -21
- data/lib/textus/action/propose.rb +16 -26
- data/lib/textus/action/published.rb +3 -5
- data/lib/textus/action/pulse.rb +19 -26
- data/lib/textus/action/put.rb +15 -29
- data/lib/textus/action/rdeps.rb +3 -18
- data/lib/textus/action/reject.rb +12 -21
- data/lib/textus/action/rule_explain.rb +12 -22
- data/lib/textus/action/rule_lint.rb +10 -16
- data/lib/textus/action/rule_list.rb +5 -9
- data/lib/textus/action/schema_envelope.rb +3 -10
- data/lib/textus/action/uid.rb +3 -17
- data/lib/textus/action/where.rb +3 -18
- data/lib/textus/boot.rb +7 -15
- data/lib/textus/contract/arg.rb +10 -0
- data/lib/textus/contract/dsl.rb +88 -0
- data/lib/textus/contract/spec.rb +25 -0
- data/lib/textus/contract.rb +0 -162
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/generator_drift.rb +2 -2
- 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/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 +4 -7
- data/lib/textus/doctor.rb +1 -1
- data/lib/textus/errors.rb +6 -0
- data/lib/textus/format/base.rb +0 -4
- data/lib/textus/format/json.rb +5 -6
- data/lib/textus/format/markdown.rb +5 -6
- data/lib/textus/format/shared.rb +17 -0
- data/lib/textus/format/text.rb +5 -4
- data/lib/textus/format/yaml.rb +30 -6
- data/lib/textus/format.rb +6 -0
- data/lib/textus/gate/auth.rb +2 -17
- data/lib/textus/gate/binder.rb +50 -0
- data/lib/textus/gate.rb +64 -88
- data/lib/textus/init.rb +2 -4
- data/lib/textus/jobs.rb +3 -9
- data/lib/textus/manifest/capabilities.rb +3 -3
- data/lib/textus/manifest/entry/base.rb +1 -1
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
- data/lib/textus/manifest/entry/publish/to_paths.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/meta.rb +54 -0
- data/lib/textus/{ports → port}/audit_log.rb +44 -4
- 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/{ports → port}/sentinel_store.rb +3 -3
- data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
- data/lib/textus/{ports → port}/storage/file_store.rb +2 -2
- data/lib/textus/port/store.rb +93 -0
- data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
- data/lib/textus/produce/engine.rb +1 -1
- data/lib/textus/schema/tools.rb +11 -7
- data/lib/textus/store/compositor.rb +34 -0
- data/lib/textus/store/container.rb +43 -0
- data/lib/textus/store/cursor.rb +26 -0
- data/lib/textus/store/envelope/reader.rb +43 -0
- data/lib/textus/store/envelope/writer.rb +195 -0
- data/lib/textus/store/geometry.rb +81 -0
- data/lib/textus/store/index/builder.rb +74 -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 +104 -0
- data/lib/textus/store/jobs/queue.rb +154 -0
- data/lib/textus/store/jobs/registry.rb +19 -0
- data/lib/textus/store/jobs/retention.rb +50 -0
- data/lib/textus/store/jobs/sweep.rb +21 -0
- data/lib/textus/store/jobs/worker.rb +64 -0
- data/lib/textus/store/session.rb +37 -0
- data/lib/textus/store.rb +21 -13
- 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 +10 -63
- data/lib/textus/surface/cli/sources.rb +41 -0
- data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +4 -6
- data/lib/textus/{surfaces → surface}/cli/verb/get.rb +4 -4
- 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/{surfaces → surface}/cli/verb/put.rb +6 -11
- 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/{surfaces → surface}/mcp/catalog.rb +9 -26
- data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -1
- data/lib/textus/{surfaces → surface}/mcp/server.rb +5 -5
- data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
- data/lib/textus/surface/projector.rb +27 -0
- data/lib/textus/{surfaces → surface}/role_scope.rb +1 -1
- 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/value/envelope.rb +89 -0
- data/lib/textus/value/etag.rb +39 -0
- data/lib/textus/value/result.rb +26 -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/version.rb +1 -1
- data/lib/textus/workflow/loader.rb +4 -4
- data/lib/textus/workflow/runner.rb +4 -18
- data/lib/textus.rb +9 -10
- metadata +100 -63
- 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/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/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/role.rb +0 -36
- data/lib/textus/session.rb +0 -35
- data/lib/textus/types.rb +0 -15
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
require "json"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Contract
|
|
5
|
-
# CLI-only input acquisition. Transforms entries of the uniform `inputs`
|
|
6
|
-
# hash that declare a `source:`/`coerce:`, and builds `inputs` from a
|
|
7
|
-
# `cli_stdin` envelope — so put/propose/migrate/rule_lint/audit need no
|
|
8
|
-
# hand-authored CLI class (ADR 0068). MCP receives typed JSON, so these
|
|
9
|
-
# never run there.
|
|
10
|
-
module Sources
|
|
11
|
-
module_function
|
|
12
|
-
|
|
13
|
-
# Apply per-arg :file sources (value is a path -> file contents) and
|
|
14
|
-
# :coerce callables to a by-name inputs hash. Returns a new hash.
|
|
15
|
-
def acquire(spec, inputs)
|
|
16
|
-
spec.args.each_with_object(inputs.dup) do |a, h|
|
|
17
|
-
next unless h.key?(a.name)
|
|
18
|
-
|
|
19
|
-
h[a.name] = File.read(h[a.name]) if a.source == :file
|
|
20
|
-
h[a.name] = a.coerce.call(h[a.name]) if a.coerce
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
# Parse a cli_stdin :json envelope into a by-name inputs hash, mapping
|
|
25
|
-
# envelope keys (wire-names) to arg names.
|
|
26
|
-
def from_stdin(spec, stream)
|
|
27
|
-
return {} unless spec.cli_stdin == :json
|
|
28
|
-
|
|
29
|
-
raw = stream.read.to_s
|
|
30
|
-
return {} if raw.strip.empty? # no envelope piped -> required args surface as missing
|
|
31
|
-
|
|
32
|
-
envelope = JSON.parse(raw)
|
|
33
|
-
spec.args.each_with_object({}) do |a, h|
|
|
34
|
-
h[a.name] = envelope[a.wire.to_s] if envelope.key?(a.wire.to_s)
|
|
35
|
-
end
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
end
|
|
39
|
-
end
|
data/lib/textus/contract/view.rb
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Contract
|
|
3
|
-
# Renders a use-case result for a surface, using the verb's declared view
|
|
4
|
-
# (falling back to the default). The single replacement for the old
|
|
5
|
-
# response/cli_response split and the Proc#arity sniff: views are always
|
|
6
|
-
# called as (result, inputs); a one-parameter view ignores inputs.
|
|
7
|
-
module View
|
|
8
|
-
module_function
|
|
9
|
-
|
|
10
|
-
def render(spec, surface, result, inputs)
|
|
11
|
-
spec.view(surface).call(result, inputs)
|
|
12
|
-
end
|
|
13
|
-
end
|
|
14
|
-
end
|
|
15
|
-
end
|
data/lib/textus/cursor_store.rb
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
require "fileutils"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
# Per-role cursor cache under <root>/.state/cursors/<role>. A convenience so
|
|
5
|
-
# `textus pulse` (no --since) means "since I last looked". Gitignored;
|
|
6
|
-
# losing it just re-emits recent deltas, never corrupts the store. ADR 0036/0038.
|
|
7
|
-
class CursorStore
|
|
8
|
-
def initialize(root:, role:)
|
|
9
|
-
@path = Textus::Layout.cursor(root, role)
|
|
10
|
-
end
|
|
11
|
-
|
|
12
|
-
def read
|
|
13
|
-
Integer(File.read(@path).strip)
|
|
14
|
-
rescue Errno::ENOENT, ArgumentError
|
|
15
|
-
0
|
|
16
|
-
end
|
|
17
|
-
|
|
18
|
-
def write(seq)
|
|
19
|
-
FileUtils.mkdir_p(File.dirname(@path))
|
|
20
|
-
File.write(@path, seq.to_s)
|
|
21
|
-
seq
|
|
22
|
-
end
|
|
23
|
-
end
|
|
24
|
-
end
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
class Envelope
|
|
3
|
-
# Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
|
|
4
|
-
# bytes, parses them via the format strategy, and hands back an
|
|
5
|
-
# Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
|
|
6
|
-
# (existing-uid lookup for the uid-preservation step in #put).
|
|
7
|
-
#
|
|
8
|
-
# No audit, no events, no permission checks — those live one layer up.
|
|
9
|
-
class Reader
|
|
10
|
-
def self.from(container:)
|
|
11
|
-
new(file_store: container.file_store, manifest: container.manifest)
|
|
12
|
-
end
|
|
13
|
-
|
|
14
|
-
def initialize(file_store:, manifest:)
|
|
15
|
-
@file_store = file_store
|
|
16
|
-
@manifest = manifest
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def read(key)
|
|
20
|
-
res = @manifest.resolver.resolve(key)
|
|
21
|
-
path = res.path
|
|
22
|
-
return nil unless @file_store.exists?(path)
|
|
23
|
-
|
|
24
|
-
mentry = res.entry
|
|
25
|
-
raw = @file_store.read(path)
|
|
26
|
-
parsed = Format.for(mentry.format).parse(raw, path: path)
|
|
27
|
-
Textus::Envelope.build(
|
|
28
|
-
key: key, mentry: mentry, path: path,
|
|
29
|
-
meta: parsed["_meta"], body: parsed["body"],
|
|
30
|
-
etag: Etag.for_bytes(raw), content: parsed["content"]
|
|
31
|
-
)
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
def existing_uid(key)
|
|
35
|
-
env = read(key)
|
|
36
|
-
env&.uid
|
|
37
|
-
rescue StandardError
|
|
38
|
-
nil
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def exists?(key)
|
|
42
|
-
@file_store.exists?(@manifest.resolver.resolve(key).path)
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
end
|
|
46
|
-
end
|
|
@@ -1,209 +0,0 @@
|
|
|
1
|
-
require "fileutils"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
class Envelope
|
|
5
|
-
# Owns the write pipeline (validate, serialize, etag-check, write, audit).
|
|
6
|
-
# Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
|
|
7
|
-
# Reader for the existing-uid lookup.
|
|
8
|
-
#
|
|
9
|
-
# Invariant: every public method's final action is @audit_log.append(...).
|
|
10
|
-
#
|
|
11
|
-
# No permission check, no event firing — those belong to the caller
|
|
12
|
-
# (Write::Put / ::Delete / ::Mv).
|
|
13
|
-
class Writer
|
|
14
|
-
Payload = Data.define(:meta, :body, :content)
|
|
15
|
-
|
|
16
|
-
def self.from(container:, call:)
|
|
17
|
-
new(
|
|
18
|
-
file_store: container.file_store, manifest: container.manifest,
|
|
19
|
-
schemas: container.schemas, audit_log: container.audit_log,
|
|
20
|
-
call: call, reader: Reader.from(container: container)
|
|
21
|
-
)
|
|
22
|
-
end
|
|
23
|
-
|
|
24
|
-
def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
|
|
25
|
-
@file_store = file_store
|
|
26
|
-
@manifest = manifest
|
|
27
|
-
@schemas = schemas
|
|
28
|
-
@audit_log = audit_log
|
|
29
|
-
@call = call
|
|
30
|
-
@reader = reader
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def put(key, mentry:, payload:, if_etag: nil)
|
|
34
|
-
path = resolve_path(key)
|
|
35
|
-
meta, content = prepare_uid(mentry, payload, key)
|
|
36
|
-
bytes, eff_meta, eff_body, eff_content = serialize_entry(mentry, path, meta, payload, content)
|
|
37
|
-
enforce_name_match!(path, eff_meta, mentry.format)
|
|
38
|
-
validate_schema(mentry, eff_meta, eff_content)
|
|
39
|
-
etag_before = check_etag!(path, key, if_etag)
|
|
40
|
-
write_bytes(path, bytes)
|
|
41
|
-
envelope = build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
|
|
42
|
-
audit_put(key, etag_before, envelope.etag)
|
|
43
|
-
envelope
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
47
|
-
# `mentry:` is accepted for symmetry with `put` / `move` and to
|
|
48
|
-
# leave room for future format-specific delete hooks; no field
|
|
49
|
-
# on it is needed today.
|
|
50
|
-
path = @manifest.resolver.resolve(key).path
|
|
51
|
-
raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
|
|
52
|
-
|
|
53
|
-
etag_before = @file_store.etag(path)
|
|
54
|
-
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
55
|
-
|
|
56
|
-
@file_store.delete(path)
|
|
57
|
-
prune_empty_parents(path)
|
|
58
|
-
@audit_log.append(
|
|
59
|
-
role: @call.role, verb: "key_delete", key: key,
|
|
60
|
-
etag_before: etag_before, etag_after: nil,
|
|
61
|
-
extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
62
|
-
)
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def move(from_key:, to_key:, new_mentry:, if_etag: nil)
|
|
66
|
-
from_path = @manifest.resolver.resolve(from_key).path
|
|
67
|
-
to_path = @manifest.resolver.resolve(to_key).path
|
|
68
|
-
raise UnknownKey.new(from_key, suggestions: @manifest.resolver.suggestions_for(from_key)) unless @file_store.exists?(from_path)
|
|
69
|
-
|
|
70
|
-
etag_before = @file_store.etag(from_path)
|
|
71
|
-
raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before
|
|
72
|
-
|
|
73
|
-
FileUtils.mkdir_p(File.dirname(to_path))
|
|
74
|
-
FileUtils.mv(from_path, to_path)
|
|
75
|
-
prune_empty_parents(from_path)
|
|
76
|
-
basename = to_key.split(".").last
|
|
77
|
-
Format.for(new_mentry.format).rewrite_name(to_path, basename)
|
|
78
|
-
etag_after = Etag.for_file(to_path)
|
|
79
|
-
|
|
80
|
-
raw = @file_store.read(to_path)
|
|
81
|
-
parsed = Format.for(new_mentry.format).parse(raw, path: to_path)
|
|
82
|
-
envelope = Textus::Envelope.build(
|
|
83
|
-
key: to_key, mentry: new_mentry, path: to_path,
|
|
84
|
-
meta: parsed["_meta"], body: parsed["body"],
|
|
85
|
-
etag: etag_after, content: parsed["content"]
|
|
86
|
-
)
|
|
87
|
-
|
|
88
|
-
extras = {
|
|
89
|
-
"from_key" => from_key, "to_key" => to_key,
|
|
90
|
-
"from_path" => from_path, "to_path" => to_path,
|
|
91
|
-
"uid" => envelope.uid
|
|
92
|
-
}
|
|
93
|
-
extras["correlation_id"] = @call.correlation_id if @call.correlation_id
|
|
94
|
-
|
|
95
|
-
@audit_log.append(
|
|
96
|
-
role: @call.role, verb: "key_mv", key: to_key,
|
|
97
|
-
etag_before: etag_before, etag_after: etag_after,
|
|
98
|
-
extras: extras
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
envelope
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
private
|
|
105
|
-
|
|
106
|
-
# After a file leaves a directory (delete or move-source), remove any
|
|
107
|
-
# now-empty parent dirs so bulk move/delete doesn't accrue orphan dirs
|
|
108
|
-
# (F3 of #161). Floored at the entry's *zone directory* — a zone is a
|
|
109
|
-
# declared, first-class container, so its own dir is preserved even when
|
|
110
|
-
# momentarily empty; only the sub-dirs the bulk op carved out are
|
|
111
|
-
# pruned. Stops at the first non-empty ancestor, so a dir holding a
|
|
112
|
-
# `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
|
|
113
|
-
# non-empty dir is silently fine, never fatal to the write.
|
|
114
|
-
def prune_empty_parents(path)
|
|
115
|
-
floor = zone_floor(path)
|
|
116
|
-
return unless floor
|
|
117
|
-
|
|
118
|
-
dir = File.dirname(path)
|
|
119
|
-
while dir.start_with?("#{floor}/") && Dir.empty?(dir)
|
|
120
|
-
Dir.rmdir(dir)
|
|
121
|
-
dir = File.dirname(dir)
|
|
122
|
-
end
|
|
123
|
-
rescue SystemCallError
|
|
124
|
-
nil
|
|
125
|
-
end
|
|
126
|
-
|
|
127
|
-
# The zone directory under which `path` lives (`<root>/zones/<zone>`),
|
|
128
|
-
# or nil if `path` is not under the store's zones tree.
|
|
129
|
-
def zone_floor(path)
|
|
130
|
-
zones_root = File.join(@manifest.data.root, "data")
|
|
131
|
-
prefix = "#{zones_root}/"
|
|
132
|
-
return nil unless path.start_with?(prefix)
|
|
133
|
-
|
|
134
|
-
zone_seg = path.delete_prefix(prefix).split("/").first
|
|
135
|
-
zone_seg && File.join(zones_root, zone_seg)
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
def ensure_uid(format, meta, content, existing_uid)
|
|
139
|
-
Textus::Format.for(format).inject_uid(meta, content, existing_uid)
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
def enforce_name_match!(path, meta, format)
|
|
143
|
-
Textus::Format.for(format).enforce_name_match!(path, meta)
|
|
144
|
-
end
|
|
145
|
-
|
|
146
|
-
def serialize_for_put(mentry:, path:, meta:, body:, content:)
|
|
147
|
-
Textus::Format.for(mentry.format).serialize_for_put(
|
|
148
|
-
meta: meta, body: body, content: content, path: path,
|
|
149
|
-
)
|
|
150
|
-
end
|
|
151
|
-
|
|
152
|
-
def resolve_path(key)
|
|
153
|
-
@manifest.resolver.resolve(key).path
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
def prepare_uid(mentry, payload, key)
|
|
157
|
-
meta = payload.meta || {}
|
|
158
|
-
existing_uid = @reader.existing_uid(key)
|
|
159
|
-
ensure_uid(mentry.format, meta, payload.content, existing_uid)
|
|
160
|
-
end
|
|
161
|
-
|
|
162
|
-
def serialize_entry(mentry, path, meta, payload, content)
|
|
163
|
-
serialize_for_put(
|
|
164
|
-
mentry: mentry, path: path,
|
|
165
|
-
meta: meta, body: payload.body, content: content
|
|
166
|
-
)
|
|
167
|
-
end
|
|
168
|
-
|
|
169
|
-
def validate_schema(mentry, eff_meta, eff_content)
|
|
170
|
-
schema = @schemas.fetch_or_nil(mentry.schema)
|
|
171
|
-
return unless schema
|
|
172
|
-
|
|
173
|
-
Format.for(mentry.format).validate_against(
|
|
174
|
-
schema,
|
|
175
|
-
{ "_meta" => eff_meta, "content" => eff_content },
|
|
176
|
-
)
|
|
177
|
-
end
|
|
178
|
-
|
|
179
|
-
def check_etag!(path, key, if_etag)
|
|
180
|
-
etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
|
|
181
|
-
raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
|
|
182
|
-
|
|
183
|
-
etag_before
|
|
184
|
-
end
|
|
185
|
-
|
|
186
|
-
def write_bytes(path, bytes)
|
|
187
|
-
@file_store.write(path, bytes)
|
|
188
|
-
end
|
|
189
|
-
|
|
190
|
-
def build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
|
|
191
|
-
Textus::Envelope.build(
|
|
192
|
-
key: key, mentry: mentry, path: path,
|
|
193
|
-
meta: eff_meta, body: eff_body,
|
|
194
|
-
etag: Etag.for_bytes(@file_store.read(path)),
|
|
195
|
-
content: eff_content
|
|
196
|
-
)
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
def audit_put(key, etag_before, etag_after)
|
|
200
|
-
extras = @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
|
|
201
|
-
@audit_log.append(
|
|
202
|
-
role: @call.role, verb: "put", key: key,
|
|
203
|
-
etag_before: etag_before, etag_after: etag_after,
|
|
204
|
-
extras: extras
|
|
205
|
-
)
|
|
206
|
-
end
|
|
207
|
-
end
|
|
208
|
-
end
|
|
209
|
-
end
|
data/lib/textus/envelope.rb
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "dry-struct"
|
|
4
|
-
|
|
5
|
-
module Textus
|
|
6
|
-
class Envelope < Dry::Struct
|
|
7
|
-
attribute :protocol, Types::String
|
|
8
|
-
attribute :key, Types::String
|
|
9
|
-
attribute :lane, Types::String
|
|
10
|
-
attribute :owner, Types::String.optional
|
|
11
|
-
attribute :path, Types::String
|
|
12
|
-
attribute :format, Types::FormatName
|
|
13
|
-
attribute :etag, Types::String
|
|
14
|
-
attribute :uid, Types::String.optional
|
|
15
|
-
attribute :schema_ref, Types::String.optional
|
|
16
|
-
attribute :meta, Types::Hash.default({}.freeze)
|
|
17
|
-
attribute :body, Types::String.optional
|
|
18
|
-
attribute :content, Types::Any.optional
|
|
19
|
-
attribute :freshness, Types::Any.optional
|
|
20
|
-
|
|
21
|
-
# rubocop:disable Metrics/ParameterLists
|
|
22
|
-
def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil, freshness: nil)
|
|
23
|
-
# rubocop:enable Metrics/ParameterLists
|
|
24
|
-
new(
|
|
25
|
-
protocol: Textus::PROTOCOL,
|
|
26
|
-
key: key,
|
|
27
|
-
lane: mentry.lane,
|
|
28
|
-
owner: mentry.owner,
|
|
29
|
-
path: path,
|
|
30
|
-
format: mentry.format,
|
|
31
|
-
uid: extract_uid(meta),
|
|
32
|
-
etag: etag,
|
|
33
|
-
schema_ref: mentry.schema,
|
|
34
|
-
meta: meta,
|
|
35
|
-
body: body,
|
|
36
|
-
content: content,
|
|
37
|
-
freshness: freshness,
|
|
38
|
-
)
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def self.extract_uid(meta)
|
|
42
|
-
v = meta.is_a?(Hash) ? meta["uid"] : nil
|
|
43
|
-
v.is_a?(String) ? v : nil
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
def with(**attrs) = self.class.new(to_h.merge(attrs))
|
|
47
|
-
|
|
48
|
-
def to_h_for_wire
|
|
49
|
-
h = {
|
|
50
|
-
"protocol" => protocol,
|
|
51
|
-
"key" => key,
|
|
52
|
-
"lane" => lane,
|
|
53
|
-
"owner" => owner,
|
|
54
|
-
"path" => path,
|
|
55
|
-
"format" => format,
|
|
56
|
-
"_meta" => meta,
|
|
57
|
-
"body" => body,
|
|
58
|
-
"etag" => etag,
|
|
59
|
-
"schema_ref" => schema_ref,
|
|
60
|
-
"uid" => uid,
|
|
61
|
-
}
|
|
62
|
-
h["content"] = content unless content.nil?
|
|
63
|
-
freshness&.to_h_for_wire&.each { |k, v| h[k] = v }
|
|
64
|
-
h
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def stale?
|
|
68
|
-
return false if freshness.nil?
|
|
69
|
-
|
|
70
|
-
freshness.stale == true
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def fetching?
|
|
74
|
-
return false if freshness.nil?
|
|
75
|
-
|
|
76
|
-
freshness.fetching == true
|
|
77
|
-
end
|
|
78
|
-
end
|
|
79
|
-
end
|
data/lib/textus/etag.rb
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
require "digest"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Etag
|
|
5
|
-
def self.for_bytes(bytes)
|
|
6
|
-
"sha256:#{Digest::SHA256.hexdigest(bytes)}"
|
|
7
|
-
end
|
|
8
|
-
|
|
9
|
-
def self.for_file(path)
|
|
10
|
-
for_bytes(File.binread(path))
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
# The fingerprint of everything an agent's boot orientation depends on:
|
|
14
|
-
# the manifest PLUS the executable contract — hooks and schemas. A
|
|
15
|
-
# mid-session edit to any of these makes the cached orientation stale, so
|
|
16
|
-
# the session must re-boot (ADR 0074). The composite is one digest over the
|
|
17
|
-
# sorted per-file listing, so it is order-stable.
|
|
18
|
-
def self.for_contract(root)
|
|
19
|
-
listing = contract_files(root).map do |path|
|
|
20
|
-
rel = path.delete_prefix(root).delete_prefix("/")
|
|
21
|
-
"#{rel}:#{for_file(path)}"
|
|
22
|
-
end.join("\n")
|
|
23
|
-
for_bytes(listing)
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# manifest.yaml, then every hook and schema file. Dir.glob already returns
|
|
27
|
-
# sorted paths (Ruby 3.0+), keeping the digest independent of FS order.
|
|
28
|
-
def self.contract_files(root)
|
|
29
|
-
[
|
|
30
|
-
File.join(root, "manifest.yaml"),
|
|
31
|
-
*Dir.glob(File.join(root, "hooks", "**", "*.rb")),
|
|
32
|
-
*Dir.glob(File.join(root, "schemas", "**", "*")).select { |f| File.file?(f) },
|
|
33
|
-
]
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
data/lib/textus/jobs/base.rb
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Jobs
|
|
3
|
-
class Base
|
|
4
|
-
def self.inherited(subclass)
|
|
5
|
-
super
|
|
6
|
-
return unless subclass.name
|
|
7
|
-
|
|
8
|
-
TracePoint.new(:end) do |tp|
|
|
9
|
-
if tp.self == subclass
|
|
10
|
-
Textus::Jobs.register(subclass)
|
|
11
|
-
tp.disable
|
|
12
|
-
end
|
|
13
|
-
end.enable
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
def call(**)
|
|
17
|
-
raise NotImplementedError.new("#{self.class}#call")
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
def args = {}
|
|
21
|
-
end
|
|
22
|
-
end
|
|
23
|
-
end
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Jobs
|
|
5
|
-
class Materialize < Base
|
|
6
|
-
TYPE = "materialize"
|
|
7
|
-
|
|
8
|
-
def initialize(key:)
|
|
9
|
-
super()
|
|
10
|
-
@key = key
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def args = { key: @key }
|
|
14
|
-
|
|
15
|
-
def call(container:, call:)
|
|
16
|
-
Textus::Produce::Engine.converge(container: container, call: call, keys: [@key])
|
|
17
|
-
end
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
end
|
data/lib/textus/jobs/plan.rb
DELETED
data/lib/textus/jobs/planner.rb
DELETED
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Jobs
|
|
5
|
-
class Planner
|
|
6
|
-
ACTIONS_BY_TRIGGER = {
|
|
7
|
-
"convergence" => %w[materialize sweep],
|
|
8
|
-
"entry.written" => %w[materialize],
|
|
9
|
-
"entry.deleted" => %w[materialize],
|
|
10
|
-
"entry.moved" => %w[materialize],
|
|
11
|
-
"proposal.accepted" => %w[materialize],
|
|
12
|
-
"proposal.rejected" => %w[materialize],
|
|
13
|
-
}.freeze
|
|
14
|
-
|
|
15
|
-
SCOPE_RESOLVERS = {
|
|
16
|
-
"materialize" => :producible_keys,
|
|
17
|
-
"sweep" => :lane_keys,
|
|
18
|
-
}.freeze
|
|
19
|
-
|
|
20
|
-
def self.seed(container:, queue:, role:)
|
|
21
|
-
jobs = new(container: container).plan(
|
|
22
|
-
trigger: { "type" => "convergence" },
|
|
23
|
-
role: role,
|
|
24
|
-
)
|
|
25
|
-
jobs.each { |j| queue.enqueue(j) }
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def initialize(container:)
|
|
29
|
-
@container = container
|
|
30
|
-
@manifest = container.manifest
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def plan(trigger:, role:)
|
|
34
|
-
type = trigger["type"] || trigger[:type]
|
|
35
|
-
trigger["target"] || trigger[:target]
|
|
36
|
-
return [] if type.nil?
|
|
37
|
-
|
|
38
|
-
blocks_with_react = @manifest.rules.blocks.select(&:react)
|
|
39
|
-
if blocks_with_react.any?
|
|
40
|
-
plan_from_rules(blocks_with_react, type, role)
|
|
41
|
-
else
|
|
42
|
-
plan_from_defaults(type, role)
|
|
43
|
-
end
|
|
44
|
-
end
|
|
45
|
-
|
|
46
|
-
private
|
|
47
|
-
|
|
48
|
-
def plan_from_rules(blocks, type, role)
|
|
49
|
-
jobs = []
|
|
50
|
-
blocks
|
|
51
|
-
.select { |b| matches_trigger?(b.react, type) }
|
|
52
|
-
.each do |block|
|
|
53
|
-
do_action = block.react.raw["do"]
|
|
54
|
-
Array(do_action).each do |action|
|
|
55
|
-
if action == "sweep"
|
|
56
|
-
jobs << Textus::Ports::JobStore::Job.new(
|
|
57
|
-
type: "sweep", args: { "scope" => {} }, enqueued_by: role,
|
|
58
|
-
)
|
|
59
|
-
else
|
|
60
|
-
resolver = SCOPE_RESOLVERS.fetch(action, :producible_keys)
|
|
61
|
-
keys = send(resolver, nil)
|
|
62
|
-
keys.each { |key| jobs << job(action, key, role) }
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
jobs
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def plan_from_defaults(type, role)
|
|
70
|
-
actions = ACTIONS_BY_TRIGGER.fetch(type, [])
|
|
71
|
-
jobs = []
|
|
72
|
-
producible_keys(nil).each { |k| jobs << job("materialize", k, role) } if actions.include?("materialize")
|
|
73
|
-
if actions.include?("sweep")
|
|
74
|
-
jobs << Textus::Ports::JobStore::Job.new(
|
|
75
|
-
type: "sweep", args: { "scope" => {} }, enqueued_by: role,
|
|
76
|
-
)
|
|
77
|
-
end
|
|
78
|
-
jobs
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
def matches_trigger?(react, type)
|
|
82
|
-
on = react.raw["on"]
|
|
83
|
-
Array(on).include?(type)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def job(type, key, enqueued_by)
|
|
87
|
-
Textus::Ports::JobStore::Job.new(type: type, args: { "key" => key }, enqueued_by: enqueued_by)
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def producible_keys(_target)
|
|
91
|
-
@manifest.data.entries
|
|
92
|
-
.select { |e| !e.publish_tree.nil? || !e.publish_to.empty? }
|
|
93
|
-
.map(&:key)
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def lane_keys(_target)
|
|
97
|
-
@manifest.data.entries.map(&:key)
|
|
98
|
-
end
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
end
|
|
@@ -1,48 +0,0 @@
|
|
|
1
|
-
require "fileutils"
|
|
2
|
-
|
|
3
|
-
module Textus
|
|
4
|
-
module Jobs
|
|
5
|
-
class Retention
|
|
6
|
-
def initialize(container:, call:)
|
|
7
|
-
@container = container
|
|
8
|
-
@call = call
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def call(rows)
|
|
12
|
-
out = { dropped: [], archived: [], failed: [] }
|
|
13
|
-
rows.each do |row|
|
|
14
|
-
key = row["key"]
|
|
15
|
-
begin
|
|
16
|
-
case row["action"]
|
|
17
|
-
when "drop"
|
|
18
|
-
delete(key)
|
|
19
|
-
out[:dropped] << key
|
|
20
|
-
when "archive"
|
|
21
|
-
archive_leaf(row)
|
|
22
|
-
delete(key)
|
|
23
|
-
out[:archived] << key
|
|
24
|
-
end
|
|
25
|
-
rescue Textus::Error => e
|
|
26
|
-
out[:failed] << { "key" => key, "error" => e.message }
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
out
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
private
|
|
33
|
-
|
|
34
|
-
def archive_leaf(row)
|
|
35
|
-
src = row["path"]
|
|
36
|
-
root = @container.root.to_s
|
|
37
|
-
rel = src.delete_prefix("#{root}/")
|
|
38
|
-
dest = File.join(root, "archive", rel)
|
|
39
|
-
FileUtils.mkdir_p(File.dirname(dest))
|
|
40
|
-
FileUtils.cp(src, dest)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def delete(key)
|
|
44
|
-
Textus::Action::KeyDelete.new(key: key).call(container: @container, call: @call)
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
data/lib/textus/jobs/sweep.rb
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Jobs
|
|
3
|
-
class Sweep < Base
|
|
4
|
-
REQUIRED_ROLE = Textus::Role::AUTOMATION
|
|
5
|
-
TYPE = "sweep"
|
|
6
|
-
|
|
7
|
-
def initialize(scope: nil, key: nil)
|
|
8
|
-
super()
|
|
9
|
-
@scope = scope || {}
|
|
10
|
-
@key = key
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def args = { scope: @scope, key: @key }.compact
|
|
14
|
-
|
|
15
|
-
def call(container:, call:)
|
|
16
|
-
prefix = @key || (@scope.is_a?(Hash) ? @scope["prefix"] : nil)
|
|
17
|
-
lane = @scope.is_a?(Hash) ? @scope["lane"] : nil
|
|
18
|
-
rows = Textus::Core::Retention::Sweep.new(
|
|
19
|
-
manifest: container.manifest,
|
|
20
|
-
file_stat: Textus::Ports::Storage::FileStat.new,
|
|
21
|
-
clock: Textus::Ports::Clock.new,
|
|
22
|
-
).call(prefix: prefix, lane: lane)
|
|
23
|
-
Textus::Jobs::Retention.new(container: container, call: call).call(rows)
|
|
24
|
-
end
|
|
25
|
-
end
|
|
26
|
-
end
|
|
27
|
-
end
|