textus 0.29.0 → 0.35.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/ARCHITECTURE.md +2 -235
- data/CHANGELOG.md +169 -0
- data/README.md +85 -64
- data/SPEC.md +366 -201
- data/docs/conventions.md +42 -37
- data/lib/textus/boot.rb +93 -76
- data/lib/textus/cli/group/{refresh.rb → fetch.rb} +4 -4
- data/lib/textus/cli/verb/build.rb +1 -1
- data/lib/textus/cli/verb/fetch.rb +14 -0
- data/lib/textus/cli/verb/{refresh_stale.rb → fetch_stale.rb} +3 -3
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +2 -6
- data/lib/textus/cli/verb/hooks.rb +1 -1
- data/lib/textus/cli/verb/put.rb +5 -14
- data/lib/textus/cli/verb/retain.rb +19 -0
- data/lib/textus/cli/verb/rule_list.rb +8 -8
- data/lib/textus/cli.rb +21 -18
- data/lib/textus/container.rb +1 -2
- data/lib/textus/dispatcher.rb +11 -3
- data/lib/textus/doctor/check/{refresh_locks.rb → fetch_locks.rb} +7 -7
- data/lib/textus/doctor/check/proposal_targets.rb +45 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +3 -3
- data/lib/textus/doctor/check.rb +8 -5
- data/lib/textus/doctor.rb +2 -1
- data/lib/textus/domain/action.rb +3 -3
- data/lib/textus/domain/duration.rb +22 -0
- data/lib/textus/domain/freshness/evaluator.rb +3 -3
- data/lib/textus/domain/freshness/policy.rb +2 -2
- data/lib/textus/domain/freshness.rb +7 -7
- data/lib/textus/domain/outcome.rb +2 -2
- data/lib/textus/domain/permission.rb +2 -10
- data/lib/textus/domain/policy/base_guards.rb +25 -0
- data/lib/textus/domain/policy/evaluation.rb +18 -0
- data/lib/textus/domain/policy/{refresh.rb → fetch.rb} +2 -16
- data/lib/textus/domain/policy/guard.rb +35 -0
- data/lib/textus/domain/policy/guard_factory.rb +40 -0
- data/lib/textus/domain/policy/predicates/author_held.rb +33 -0
- data/lib/textus/domain/policy/predicates/etag_match.rb +32 -0
- data/lib/textus/domain/policy/predicates/fresh_within.rb +58 -0
- data/lib/textus/domain/policy/predicates/registry.rb +39 -0
- data/lib/textus/domain/policy/predicates/schema_valid.rb +30 -19
- data/lib/textus/domain/policy/predicates/target_is_canon.rb +33 -0
- data/lib/textus/domain/policy/predicates/zone_writable_by.rb +39 -0
- data/lib/textus/domain/policy/retention.rb +26 -0
- data/lib/textus/domain/retention.rb +44 -0
- data/lib/textus/domain/staleness/intake_check.rb +6 -6
- data/lib/textus/envelope/io/reader.rb +4 -0
- data/lib/textus/envelope/io/writer.rb +8 -0
- data/lib/textus/envelope.rb +2 -2
- data/lib/textus/errors.rb +25 -28
- data/lib/textus/hooks/event_bus.rb +12 -24
- data/lib/textus/hooks/rpc_registry.rb +9 -35
- data/lib/textus/hooks/signature.rb +31 -0
- data/lib/textus/init.rb +24 -18
- data/lib/textus/maintenance/zone_mv.rb +1 -1
- data/lib/textus/manifest/capabilities.rb +29 -0
- data/lib/textus/manifest/data.rb +16 -8
- data/lib/textus/manifest/entry/base.rb +2 -2
- data/lib/textus/manifest/policy.rb +62 -19
- data/lib/textus/manifest/rules.rb +25 -14
- data/lib/textus/manifest/schema.rb +78 -38
- data/lib/textus/manifest.rb +6 -5
- data/lib/textus/mcp/server.rb +2 -10
- data/lib/textus/mcp/session.rb +7 -23
- data/lib/textus/mcp/tool_schemas.rb +3 -3
- data/lib/textus/mcp/tools.rb +7 -7
- data/lib/textus/ports/audit_subscriber.rb +1 -1
- data/lib/textus/ports/{refresh → fetch}/detached.rb +4 -4
- data/lib/textus/ports/{refresh → fetch}/lock.rb +1 -1
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/read/freshness.rb +9 -9
- data/lib/textus/read/get.rb +8 -8
- data/lib/textus/read/{get_or_refresh.rb → get_or_fetch.rb} +11 -11
- data/lib/textus/read/policy_explain.rb +19 -10
- data/lib/textus/read/pulse.rb +5 -4
- data/lib/textus/read/retainable.rb +17 -0
- data/lib/textus/read/validator.rb +1 -1
- data/lib/textus/role_scope.rb +3 -2
- data/lib/textus/schema/tools.rb +5 -5
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +19 -55
- data/lib/textus/write/delete.rb +15 -17
- data/lib/textus/write/{refresh_all.rb → fetch_all.rb} +6 -6
- data/lib/textus/write/{refresh_orchestrator.rb → fetch_orchestrator.rb} +14 -14
- data/lib/textus/write/{refresh_worker.rb → fetch_worker.rb} +23 -30
- data/lib/textus/write/intake_fetch.rb +23 -0
- data/lib/textus/write/mv.rb +17 -15
- data/lib/textus/write/put.rb +15 -17
- data/lib/textus/write/reject.rb +11 -5
- data/lib/textus/write/retention_sweep.rb +55 -0
- metadata +32 -18
- data/lib/textus/cli/verb/refresh.rb +0 -14
- data/lib/textus/domain/authorizer.rb +0 -37
- data/lib/textus/domain/policy/predicates/accept_authority_signed.rb +0 -33
- data/lib/textus/domain/policy/promote.rb +0 -26
- data/lib/textus/domain/policy/promotion.rb +0 -57
- data/lib/textus/manifest/role_kinds.rb +0 -21
- data/lib/textus/write/authority_gate.rb +0 -24
|
@@ -1,48 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
1
3
|
module Textus
|
|
2
4
|
module Domain
|
|
3
5
|
module Policy
|
|
4
6
|
module Predicates
|
|
7
|
+
# Predicate: the entry's effective frontmatter satisfies the schema
|
|
8
|
+
# bound to the target key. For accept, the frontmatter lives under
|
|
9
|
+
# envelope.meta["frontmatter"]; for a direct put it is envelope.meta.
|
|
5
10
|
class SchemaValid
|
|
6
11
|
attr_reader :reason
|
|
7
12
|
|
|
8
|
-
def
|
|
9
|
-
|
|
13
|
+
def initialize(schemas:)
|
|
14
|
+
@schemas = schemas
|
|
10
15
|
end
|
|
11
16
|
|
|
12
|
-
def
|
|
13
|
-
|
|
17
|
+
def name = "schema_valid"
|
|
18
|
+
|
|
19
|
+
def call(eval)
|
|
20
|
+
manifest = eval.manifest
|
|
21
|
+
return true if eval.envelope.nil? || manifest.nil? || @schemas.nil?
|
|
14
22
|
|
|
15
|
-
target_key =
|
|
23
|
+
target_key = eval.target
|
|
16
24
|
return true unless target_key
|
|
17
25
|
|
|
18
26
|
mentry = manifest.resolver.resolve(target_key).entry
|
|
19
27
|
schema_ref = mentry&.schema
|
|
20
28
|
return true unless schema_ref
|
|
21
29
|
|
|
22
|
-
schema = schemas.fetch_or_nil(schema_ref)
|
|
30
|
+
schema = @schemas.fetch_or_nil(schema_ref)
|
|
23
31
|
return true unless schema
|
|
24
32
|
|
|
25
|
-
frontmatter =
|
|
33
|
+
frontmatter =
|
|
34
|
+
eval.envelope.meta&.dig("frontmatter") || eval.envelope.meta || {}
|
|
26
35
|
begin
|
|
27
36
|
schema.validate!(frontmatter)
|
|
37
|
+
true
|
|
28
38
|
rescue Textus::SchemaViolation => e
|
|
29
|
-
@reason = e
|
|
30
|
-
|
|
31
|
-
if d.is_a?(Hash)
|
|
32
|
-
if d["missing"]
|
|
33
|
-
@reason = "missing required fields: #{Array(d["missing"]).join(", ")}"
|
|
34
|
-
elsif d["field"]
|
|
35
|
-
@reason = "field '#{d["field"]}': #{d["reason"]}"
|
|
36
|
-
end
|
|
37
|
-
end
|
|
38
|
-
return false
|
|
39
|
+
@reason = humanize(e)
|
|
40
|
+
false
|
|
39
41
|
end
|
|
40
|
-
|
|
41
|
-
true
|
|
42
42
|
rescue StandardError => e
|
|
43
43
|
@reason = "schema validation error: #{e.message}"
|
|
44
44
|
false
|
|
45
45
|
end
|
|
46
|
+
|
|
47
|
+
private
|
|
48
|
+
|
|
49
|
+
def humanize(err)
|
|
50
|
+
d = err.details
|
|
51
|
+
return err.message.dup unless d.is_a?(Hash)
|
|
52
|
+
return "missing required fields: #{Array(d["missing"]).join(", ")}" if d["missing"]
|
|
53
|
+
return "field '#{d["field"]}': #{d["reason"]}" if d["field"]
|
|
54
|
+
|
|
55
|
+
err.message.dup
|
|
56
|
+
end
|
|
46
57
|
end
|
|
47
58
|
end
|
|
48
59
|
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Domain
|
|
5
|
+
module Policy
|
|
6
|
+
module Predicates
|
|
7
|
+
# Predicate: a proposal may only target a `canon` zone (ADR 0035). Runs
|
|
8
|
+
# on the `accept` floor, where Evaluation#target is the proposal's
|
|
9
|
+
# resolved target_key. Refuses promotion into workspace/derived/
|
|
10
|
+
# quarantine/queue — the queue→canon path is the only coherent one.
|
|
11
|
+
# No bespoke #error; failures accumulate into GuardFailed (ADR 0031).
|
|
12
|
+
class TargetIsCanon
|
|
13
|
+
attr_reader :reason
|
|
14
|
+
|
|
15
|
+
def name = "target_is_canon"
|
|
16
|
+
|
|
17
|
+
def call(eval)
|
|
18
|
+
zone = eval.manifest.resolver.resolve(eval.target).entry.zone
|
|
19
|
+
kind = eval.manifest.policy.declared_kind(zone.to_s)
|
|
20
|
+
return true if kind == :canon
|
|
21
|
+
|
|
22
|
+
@reason = "proposal target '#{eval.target}' is in zone '#{zone}' " \
|
|
23
|
+
"(kind: #{kind || "none"}); proposals may only target a canon zone"
|
|
24
|
+
false
|
|
25
|
+
rescue Textus::UnknownKey
|
|
26
|
+
@reason = "proposal target '#{eval.target}' resolves to no declared entry"
|
|
27
|
+
false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Domain
|
|
5
|
+
module Policy
|
|
6
|
+
module Predicates
|
|
7
|
+
# Predicate #0 of every write guard. Wraps the post-0.31.0 capability
|
|
8
|
+
# topology gate (role.can ⊇ verb_for(zone.kind)). On failure, #error
|
|
9
|
+
# raises the capability-shaped WriteForbidden so the topology refusal
|
|
10
|
+
# — textus's signature product feature — is unchanged.
|
|
11
|
+
class ZoneWritableBy
|
|
12
|
+
attr_reader :reason
|
|
13
|
+
|
|
14
|
+
def name = "zone_writable_by"
|
|
15
|
+
|
|
16
|
+
def call(eval)
|
|
17
|
+
manifest = eval.manifest
|
|
18
|
+
@mentry = manifest.resolver.resolve(eval.target).entry
|
|
19
|
+
return true if manifest.policy.permission_for(@mentry.zone.to_s).allows_write?(eval.actor)
|
|
20
|
+
|
|
21
|
+
@verb = manifest.policy.verb_for_zone(@mentry.zone) # capability the kind requires
|
|
22
|
+
@holders = manifest.policy.roles_with_capability(@verb)
|
|
23
|
+
@reason = "zone '#{@mentry.zone}' needs capability '#{@verb}'; '#{eval.actor}' lacks it"
|
|
24
|
+
false
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Matches the capability-shaped WriteForbidden landed by ADR 0030
|
|
28
|
+
# Task 3:
|
|
29
|
+
# WriteForbidden.new(key, zone, verb:, holders:)
|
|
30
|
+
# → "writing '<k>' (zone '<z>') needs capability '<verb>'",
|
|
31
|
+
# hint: "held by: <holders>; pass --as=<role>".
|
|
32
|
+
def error(_eval)
|
|
33
|
+
Textus::WriteForbidden.new(@mentry.key, @mentry.zone, verb: @verb, holders: @holders)
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
module Policy
|
|
4
|
+
# Lifetime policy for queue/quarantine leaves. Both windows are optional
|
|
5
|
+
# durations (see Domain::Duration). `expire_after` deletes; `archive_after`
|
|
6
|
+
# moves the leaf aside. When both are set, expire wins once its (longer)
|
|
7
|
+
# window is exceeded.
|
|
8
|
+
class Retention
|
|
9
|
+
attr_reader :expire_after, :archive_after
|
|
10
|
+
|
|
11
|
+
def initialize(expire_after: nil, archive_after: nil)
|
|
12
|
+
@expire_after = Textus::Domain::Duration.seconds(expire_after)
|
|
13
|
+
@archive_after = Textus::Domain::Duration.seconds(archive_after)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# :expire | :archive | nil for a leaf of the given age (seconds).
|
|
17
|
+
def action_for(age_seconds)
|
|
18
|
+
return :expire if @expire_after && age_seconds > @expire_after
|
|
19
|
+
return :archive if @archive_after && age_seconds > @archive_after
|
|
20
|
+
|
|
21
|
+
nil
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Domain
|
|
3
|
+
# Reports leaves whose age (now - file mtime) exceeds a retention window.
|
|
4
|
+
# Each row is { "key", "path", "action" => "expire"|"archive", "age_seconds" }.
|
|
5
|
+
class Retention
|
|
6
|
+
def initialize(manifest:, file_stat:, clock:)
|
|
7
|
+
@manifest = manifest
|
|
8
|
+
@file_stat = file_stat
|
|
9
|
+
@clock = clock
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(prefix: nil, zone: nil)
|
|
13
|
+
@manifest.data.entries
|
|
14
|
+
.select { |m| entry_matches?(m, prefix: prefix, zone: zone) }
|
|
15
|
+
.flat_map { |m| rows_for(m) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def rows_for(mentry)
|
|
21
|
+
policy = @manifest.rules.for(mentry.key).retention
|
|
22
|
+
return [] if policy.nil?
|
|
23
|
+
|
|
24
|
+
@manifest.resolver.enumerate(prefix: mentry.key).filter_map do |row|
|
|
25
|
+
path = row[:path]
|
|
26
|
+
next unless @file_stat.exists?(path)
|
|
27
|
+
|
|
28
|
+
age = (@clock.now - @file_stat.mtime(path)).to_i
|
|
29
|
+
action = policy.action_for(age)
|
|
30
|
+
next if action.nil?
|
|
31
|
+
|
|
32
|
+
{ "key" => row[:key], "path" => path, "action" => action.to_s, "age_seconds" => age }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def entry_matches?(mentry, prefix:, zone:)
|
|
37
|
+
return false if zone && mentry.zone != zone
|
|
38
|
+
return false if prefix && !(mentry.key == prefix || mentry.key.start_with?("#{prefix}."))
|
|
39
|
+
|
|
40
|
+
true
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -15,7 +15,7 @@ module Textus
|
|
|
15
15
|
def rows_for(mentry)
|
|
16
16
|
return [] unless mentry.is_a?(Textus::Manifest::Entry::Intake)
|
|
17
17
|
|
|
18
|
-
ttl = @manifest.rules.for(mentry.key).
|
|
18
|
+
ttl = @manifest.rules.for(mentry.key).fetch&.ttl_seconds
|
|
19
19
|
return [] unless ttl
|
|
20
20
|
|
|
21
21
|
path = Textus::Key::Path.resolve(@manifest.data, mentry)
|
|
@@ -26,17 +26,17 @@ module Textus
|
|
|
26
26
|
private
|
|
27
27
|
|
|
28
28
|
def ttl_reason(mentry, path, ttl)
|
|
29
|
-
return "never
|
|
29
|
+
return "never fetched" unless @file_stat.exists?(path)
|
|
30
30
|
|
|
31
|
-
last_str =
|
|
32
|
-
return "never
|
|
31
|
+
last_str = last_fetched_of(mentry, path)
|
|
32
|
+
return "never fetched (no last_fetched_at)" if last_str.nil?
|
|
33
33
|
|
|
34
34
|
last = parse_time(last_str)
|
|
35
35
|
"ttl exceeded (#{ttl}s)" if last.nil? || (@clock.now - last) > ttl
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
def
|
|
39
|
-
Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["
|
|
38
|
+
def last_fetched_of(mentry, path)
|
|
39
|
+
Entry.for_format(mentry.format).parse(@file_stat.read(path), path: path)["_meta"]["last_fetched_at"]
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def parse_time(str)
|
|
@@ -8,6 +8,10 @@ module Textus
|
|
|
8
8
|
#
|
|
9
9
|
# No audit, no events, no permission checks — those live one layer up.
|
|
10
10
|
class Reader
|
|
11
|
+
def self.from(container:)
|
|
12
|
+
new(file_store: container.file_store, manifest: container.manifest)
|
|
13
|
+
end
|
|
14
|
+
|
|
11
15
|
def initialize(file_store:, manifest:)
|
|
12
16
|
@file_store = file_store
|
|
13
17
|
@manifest = manifest
|
|
@@ -14,6 +14,14 @@ module Textus
|
|
|
14
14
|
class Writer
|
|
15
15
|
Payload = Data.define(:meta, :body, :content)
|
|
16
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
|
+
|
|
17
25
|
def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
|
|
18
26
|
@file_store = file_store
|
|
19
27
|
@manifest = manifest
|
data/lib/textus/envelope.rb
CHANGED
data/lib/textus/errors.rb
CHANGED
|
@@ -90,39 +90,21 @@ module Textus
|
|
|
90
90
|
end
|
|
91
91
|
|
|
92
92
|
class WriteForbidden < Error
|
|
93
|
-
def initialize(k, z,
|
|
94
|
-
|
|
95
|
-
if
|
|
96
|
-
|
|
93
|
+
def initialize(k, z, verb: nil, holders: nil)
|
|
94
|
+
holders_str =
|
|
95
|
+
if holders && !holders.empty?
|
|
96
|
+
holders.join(", ")
|
|
97
97
|
else
|
|
98
|
-
"
|
|
98
|
+
"no declared role"
|
|
99
99
|
end
|
|
100
100
|
details = { "key" => k, "zone" => z }
|
|
101
|
-
details["
|
|
101
|
+
details["verb"] = verb if verb
|
|
102
|
+
details["holders"] = holders if holders
|
|
102
103
|
super(
|
|
103
104
|
"write_forbidden",
|
|
104
|
-
"zone '#{z}'
|
|
105
|
+
"writing '#{k}' (zone '#{z}') needs capability '#{verb}'",
|
|
105
106
|
details: details,
|
|
106
|
-
hint: "
|
|
107
|
-
)
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
|
|
111
|
-
class ReadForbidden < Error
|
|
112
|
-
def initialize(k, z, readers: nil)
|
|
113
|
-
readers_str =
|
|
114
|
-
if readers && !readers.empty?
|
|
115
|
-
readers.join(", ")
|
|
116
|
-
else
|
|
117
|
-
"the role(s) listed in the manifest 'read_policy:'"
|
|
118
|
-
end
|
|
119
|
-
details = { "key" => k, "zone" => z }
|
|
120
|
-
details["readers"] = readers if readers
|
|
121
|
-
super(
|
|
122
|
-
"read_forbidden",
|
|
123
|
-
"zone '#{z}' is not readable by role for key '#{k}'",
|
|
124
|
-
details: details,
|
|
125
|
-
hint: "this zone is readable by #{readers_str}; pass --as=<role>",
|
|
107
|
+
hint: "held by: #{holders_str}; pass --as=<role>",
|
|
126
108
|
)
|
|
127
109
|
end
|
|
128
110
|
end
|
|
@@ -163,7 +145,7 @@ module Textus
|
|
|
163
145
|
"invalid_role",
|
|
164
146
|
message || "role '#{r}' is not declared in any zone",
|
|
165
147
|
details: { "role" => r },
|
|
166
|
-
hint: message ? nil : "valid roles are declared in .textus/manifest.yaml under
|
|
148
|
+
hint: message ? nil : "valid roles are declared in .textus/manifest.yaml under roles: (each with a can: list)",
|
|
167
149
|
)
|
|
168
150
|
end
|
|
169
151
|
end
|
|
@@ -204,6 +186,21 @@ module Textus
|
|
|
204
186
|
def initialize(m) = super("proposal_error", m)
|
|
205
187
|
end
|
|
206
188
|
|
|
189
|
+
class GuardFailed < Error
|
|
190
|
+
def initialize(failed)
|
|
191
|
+
# failed: [[predicate_name, reason], ...]
|
|
192
|
+
rows = failed.map { |name, reason| { "predicate" => name, "reason" => reason } }
|
|
193
|
+
names = failed.map(&:first)
|
|
194
|
+
super(
|
|
195
|
+
"guard_failed",
|
|
196
|
+
"guard refused crossing: #{failed.map { |n, r| "#{n} (#{r})" }.join("; ")}",
|
|
197
|
+
details: { "failed" => rows },
|
|
198
|
+
hint: "run 'textus policy explain <key> --output=json' to see the full guard; " \
|
|
199
|
+
"unmet: #{names.join(", ")}",
|
|
200
|
+
)
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
207
204
|
class FlagRenamed < Error
|
|
208
205
|
def initialize(old_flag, new_flag)
|
|
209
206
|
super(
|
|
@@ -10,16 +10,16 @@ module Textus
|
|
|
10
10
|
EVENTS = {
|
|
11
11
|
entry_put: %i[ctx key envelope],
|
|
12
12
|
entry_deleted: %i[ctx key],
|
|
13
|
-
|
|
13
|
+
entry_fetched: %i[ctx key envelope change],
|
|
14
14
|
entry_renamed: %i[ctx key from_key to_key envelope],
|
|
15
15
|
build_completed: %i[ctx key envelope sources],
|
|
16
16
|
proposal_accepted: %i[ctx key target_key],
|
|
17
17
|
proposal_rejected: %i[ctx key target_key],
|
|
18
18
|
file_published: %i[ctx key envelope source target],
|
|
19
19
|
store_loaded: %i[ctx],
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
fetch_started: %i[ctx key mode],
|
|
21
|
+
fetch_failed: %i[ctx key error_class error_message],
|
|
22
|
+
fetch_backgrounded: %i[ctx key started_at budget_ms],
|
|
23
23
|
}.freeze
|
|
24
24
|
|
|
25
25
|
RPC_EVENTS = %i[resolve_intake transform_rows validate].freeze
|
|
@@ -39,7 +39,12 @@ module Textus
|
|
|
39
39
|
raise UsageError.new("#{event_sym} is an RPC event; register on RpcRegistry") if RPC_EVENTS.include?(event_sym)
|
|
40
40
|
|
|
41
41
|
required = EVENTS[event_sym] or raise UsageError.new("unknown event: #{event}")
|
|
42
|
-
|
|
42
|
+
sig = Signature.new(blk)
|
|
43
|
+
missing = sig.missing(required)
|
|
44
|
+
if missing.any?
|
|
45
|
+
raise UsageError.new("#{event_sym} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
|
|
46
|
+
end
|
|
47
|
+
|
|
43
48
|
name = name.to_sym
|
|
44
49
|
raise UsageError.new("#{event_sym} hook '#{name}' already registered") if @pubsub[event_sym].any? { |h| h[:name] == name }
|
|
45
50
|
|
|
@@ -79,8 +84,9 @@ module Textus
|
|
|
79
84
|
private
|
|
80
85
|
|
|
81
86
|
def invoke(event, sub, key, kwargs)
|
|
82
|
-
accepted =
|
|
87
|
+
accepted = Signature.new(sub[:callable]).filter(kwargs)
|
|
83
88
|
error = nil
|
|
89
|
+
# Thread#kill is unsafe in general but bounded here: post-commit, isolated, only a runaway user hook is affected.
|
|
84
90
|
thread = Thread.new do
|
|
85
91
|
sub[:callable].call(**accepted)
|
|
86
92
|
rescue StandardError => e
|
|
@@ -115,24 +121,6 @@ module Textus
|
|
|
115
121
|
end
|
|
116
122
|
end
|
|
117
123
|
|
|
118
|
-
def filter_kwargs(callable, kwargs)
|
|
119
|
-
params = callable.parameters
|
|
120
|
-
return kwargs if params.any? { |type, _| type == :keyrest }
|
|
121
|
-
|
|
122
|
-
accepted = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
|
|
123
|
-
kwargs.slice(*accepted)
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
def shape_check!(event, required, blk)
|
|
127
|
-
provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
|
|
128
|
-
return if provided.any? { |t, _| t == :keyrest }
|
|
129
|
-
|
|
130
|
-
missing = required - provided.map { |_, n| n }
|
|
131
|
-
return if missing.empty?
|
|
132
|
-
|
|
133
|
-
raise UsageError.new("#{event} hooks must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
|
|
134
|
-
end
|
|
135
|
-
|
|
136
124
|
def match?(globs, key)
|
|
137
125
|
return true if globs.nil?
|
|
138
126
|
|
|
@@ -20,7 +20,10 @@ module Textus
|
|
|
20
20
|
raise UsageError.new("#{event_sym} is a pubsub event; register on EventBus") if PUBSUB_EVENTS.include?(event_sym)
|
|
21
21
|
|
|
22
22
|
required = EVENTS[event_sym] or raise UsageError.new("unknown RPC event: #{event}")
|
|
23
|
-
|
|
23
|
+
sig = Signature.new(blk)
|
|
24
|
+
missing = sig.missing(required)
|
|
25
|
+
raise UsageError.new("#{event_sym} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})") if missing.any?
|
|
26
|
+
|
|
24
27
|
name = name.to_sym
|
|
25
28
|
raise UsageError.new("#{event_sym} '#{name}' already registered") if @table[event_sym].key?(name)
|
|
26
29
|
|
|
@@ -33,45 +36,16 @@ module Textus
|
|
|
33
36
|
@table[event.to_sym][name.to_sym] or raise UsageError.new("unknown #{event}: #{name}")
|
|
34
37
|
end
|
|
35
38
|
|
|
36
|
-
# Invoke a registered callable, injecting `caps:`
|
|
37
|
-
#
|
|
39
|
+
# Invoke a registered callable, injecting `caps:` only if the callable
|
|
40
|
+
# declares it (or accepts keyrest). Mis-named kwargs (e.g. the legacy
|
|
41
|
+
# `caps:`-alternative) are rejected at registration time, not here.
|
|
38
42
|
def invoke(event, name, caps:, **other)
|
|
39
43
|
blk = callable(event, name)
|
|
40
|
-
|
|
41
|
-
accepts_keyrest = params.any? { |t, _| t == :keyrest }
|
|
42
|
-
declared = params.each_with_object([]) { |(t, n), acc| acc << n if %i[key keyreq].include?(t) }
|
|
43
|
-
|
|
44
|
-
if declared.include?(:store)
|
|
45
|
-
raise UsageError.new(
|
|
46
|
-
"RPC callable for #{event} '#{name}' declares legacy `store:`; rename to `caps:` " \
|
|
47
|
-
"(Textus::Container)",
|
|
48
|
-
)
|
|
49
|
-
end
|
|
50
|
-
|
|
44
|
+
sig = Signature.new(blk)
|
|
51
45
|
kwargs = other.dup
|
|
52
|
-
kwargs[:caps] = caps if accepts_keyrest ||
|
|
46
|
+
kwargs[:caps] = caps if sig.accepts_keyrest? || sig.declared_keys.include?(:caps)
|
|
53
47
|
blk.call(**kwargs)
|
|
54
48
|
end
|
|
55
|
-
|
|
56
|
-
private
|
|
57
|
-
|
|
58
|
-
def shape_check!(event, required, blk)
|
|
59
|
-
provided = blk.parameters.select { |t, _| %i[keyreq key keyrest].include?(t) } # rubocop:disable Style/HashSlice
|
|
60
|
-
return if provided.any? { |t, _| t == :keyrest }
|
|
61
|
-
|
|
62
|
-
param_names = provided.map { |_, n| n }
|
|
63
|
-
# Allow `store:` as a stand-in for `caps:` so registration succeeds;
|
|
64
|
-
# invoke will raise UsageError when the callable is actually called.
|
|
65
|
-
effective_required = if param_names.include?(:store)
|
|
66
|
-
required.map { |r| r == :caps ? :store : r }
|
|
67
|
-
else
|
|
68
|
-
required
|
|
69
|
-
end
|
|
70
|
-
missing = effective_required - param_names
|
|
71
|
-
return if missing.empty?
|
|
72
|
-
|
|
73
|
-
raise UsageError.new("#{event} RPC must accept kwargs: #{required.join(", ")} (missing: #{missing.join(", ")})")
|
|
74
|
-
end
|
|
75
49
|
end
|
|
76
50
|
end
|
|
77
51
|
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Hooks
|
|
5
|
+
class Signature
|
|
6
|
+
def initialize(callable)
|
|
7
|
+
@params = callable.parameters
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def accepts_keyrest?
|
|
11
|
+
@params.any? { |type, _| type == :keyrest }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def declared_keys
|
|
15
|
+
@params.each_with_object([]) { |(t, n), acc| acc << n if %i[keyreq key].include?(t) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def missing(required)
|
|
19
|
+
return [] if accepts_keyrest?
|
|
20
|
+
|
|
21
|
+
required - declared_keys
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def filter(kwargs)
|
|
25
|
+
return kwargs if accepts_keyrest?
|
|
26
|
+
|
|
27
|
+
kwargs.slice(*declared_keys)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
data/lib/textus/init.rb
CHANGED
|
@@ -2,19 +2,25 @@ require "fileutils"
|
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Init
|
|
5
|
-
ZONES = %w[
|
|
5
|
+
ZONES = %w[knowledge notebook feeds proposals artifacts].freeze
|
|
6
6
|
|
|
7
7
|
DEFAULT_MANIFEST = <<~YAML
|
|
8
8
|
version: textus/3
|
|
9
|
+
roles:
|
|
10
|
+
- { name: human, can: [author, propose] }
|
|
11
|
+
- { name: agent, can: [propose, keep] }
|
|
12
|
+
- { name: automation, can: [fetch, build] }
|
|
9
13
|
zones:
|
|
10
|
-
- { name:
|
|
11
|
-
- { name:
|
|
12
|
-
- { name:
|
|
13
|
-
- { name:
|
|
14
|
-
- { name:
|
|
14
|
+
- { name: knowledge, kind: canon, desc: "the maintained source of truth (identity.* lives here)" }
|
|
15
|
+
- { name: notebook, kind: workspace, owner: agent, desc: "the agent's own durable working notes" }
|
|
16
|
+
- { name: feeds, kind: quarantine, desc: "external inputs pulled in" }
|
|
17
|
+
- { name: proposals, kind: queue, desc: "changes awaiting your accept" }
|
|
18
|
+
- { name: artifacts, kind: derived, desc: "computed, shippable outputs" }
|
|
15
19
|
entries:
|
|
16
|
-
- { key: identity
|
|
17
|
-
- { key:
|
|
20
|
+
- { key: knowledge.identity, path: knowledge/identity.md, zone: knowledge, schema: null, owner: human:self, kind: leaf }
|
|
21
|
+
- { key: knowledge.notes, path: knowledge/notes, zone: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
|
|
22
|
+
- { key: notebook.notes, path: notebook/notes, zone: notebook, schema: null, owner: agent:self, nested: true, kind: nested }
|
|
23
|
+
- { key: proposals.notes, path: proposals/notes, zone: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
|
|
18
24
|
YAML
|
|
19
25
|
|
|
20
26
|
HOOKS_README = <<~MD
|
|
@@ -30,12 +36,12 @@ module Textus
|
|
|
30
36
|
```ruby
|
|
31
37
|
Textus.hook do |reg|
|
|
32
38
|
reg.on(:resolve_intake, :my_source) do |config:, args:, **|
|
|
33
|
-
{ _meta: { "
|
|
39
|
+
{ _meta: { "last_fetched_at" => Time.now.utc.iso8601 }, body: "…" }
|
|
34
40
|
end
|
|
35
41
|
|
|
36
42
|
reg.on(:transform_rows, :my_source) { |rows:, **| rows.map { |r| r.merge(processed: true) } }
|
|
37
|
-
reg.on(:validate, :my_check) { |
|
|
38
|
-
reg.on(:entry_put, :my_listener, keys: ["
|
|
43
|
+
reg.on(:validate, :my_check) { |caps:, **| [] }
|
|
44
|
+
reg.on(:entry_put, :my_listener, keys: ["knowledge.*"]) { |key:, envelope:, **| }
|
|
39
45
|
|
|
40
46
|
# Run a side-effect every time textus writes a file to your repo:
|
|
41
47
|
reg.on(:file_published, :notify) do |key:, target:, **|
|
|
@@ -50,25 +56,25 @@ module Textus
|
|
|
50
56
|
|
|
51
57
|
```yaml
|
|
52
58
|
entries:
|
|
53
|
-
- key:
|
|
59
|
+
- key: feeds.foo
|
|
54
60
|
kind: intake
|
|
55
|
-
path:
|
|
56
|
-
zone:
|
|
61
|
+
path: feeds/foo.md
|
|
62
|
+
zone: feeds
|
|
57
63
|
intake:
|
|
58
64
|
handler: my_source
|
|
59
65
|
|
|
60
66
|
rules:
|
|
61
|
-
- match:
|
|
62
|
-
|
|
67
|
+
- match: feeds.foo
|
|
68
|
+
fetch:
|
|
63
69
|
ttl: 10m
|
|
64
70
|
on_stale: timed_sync # warn | sync | timed_sync (default: warn)
|
|
65
71
|
```
|
|
66
72
|
|
|
67
73
|
Events: :resolve_intake, :transform_rows, :validate (rpc — return value used)
|
|
68
|
-
:entry_put, :entry_deleted, :
|
|
74
|
+
:entry_put, :entry_deleted, :entry_fetched, :entry_renamed,
|
|
69
75
|
:build_completed, :proposal_accepted, :proposal_rejected,
|
|
70
76
|
:file_published, :store_loaded,
|
|
71
|
-
:
|
|
77
|
+
:fetch_started, :fetch_failed, :fetch_backgrounded (pub-sub — return discarded)
|
|
72
78
|
|
|
73
79
|
See SPEC.md §5.10 for the full table.
|
|
74
80
|
MD
|
|
@@ -15,7 +15,7 @@ module Textus
|
|
|
15
15
|
|
|
16
16
|
def call(from:, to:, dry_run: false)
|
|
17
17
|
raise UsageError.new("from and to required") if from.nil? || to.nil? || from.empty? || to.empty?
|
|
18
|
-
raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.
|
|
18
|
+
raise UsageError.new("zone '#{from}' not declared") unless @manifest.data.declared_zone_kinds.key?(from)
|
|
19
19
|
|
|
20
20
|
dest_dir = File.join(@root, "zones", to)
|
|
21
21
|
raise UsageError.new("destination 'zones/#{to}' already exists") if File.exist?(dest_dir)
|