textus 0.20.2 → 0.26.0
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 +148 -45
- data/CHANGELOG.md +194 -0
- data/README.md +8 -5
- data/SPEC.md +54 -15
- data/docs/conventions.md +10 -0
- data/lib/textus/application/caps.rb +49 -0
- data/lib/textus/application/context.rb +2 -2
- data/lib/textus/application/envelope/reader.rb +44 -0
- data/lib/textus/application/{writes/envelope_io.rb → envelope/writer.rb} +24 -50
- data/lib/textus/application/maintenance/key_delete_prefix.rb +44 -0
- data/lib/textus/application/maintenance/key_mv_prefix.rb +57 -0
- data/lib/textus/application/maintenance/migrate.rb +59 -0
- data/lib/textus/application/maintenance/rule_lint.rb +65 -0
- data/lib/textus/application/maintenance/zone_mv.rb +60 -0
- data/lib/textus/application/maintenance.rb +17 -0
- data/lib/textus/application/projection.rb +12 -10
- data/lib/textus/application/read/audit.rb +106 -0
- data/lib/textus/application/read/blame.rb +91 -0
- data/lib/textus/application/read/deps.rb +34 -0
- data/lib/textus/application/read/freshness.rb +110 -0
- data/lib/textus/application/read/get.rb +75 -0
- data/lib/textus/application/read/get_or_refresh.rb +63 -0
- data/lib/textus/application/read/list.rb +25 -0
- data/lib/textus/application/read/policy_explain.rb +47 -0
- data/lib/textus/application/read/published.rb +25 -0
- data/lib/textus/application/read/pulse.rb +101 -0
- data/lib/textus/application/read/rdeps.rb +35 -0
- data/lib/textus/application/read/schema_envelope.rb +26 -0
- data/lib/textus/application/read/stale.rb +23 -0
- data/lib/textus/application/read/uid.rb +30 -0
- data/lib/textus/application/read/validate_all.rb +32 -0
- data/lib/textus/application/{reads → read}/validator.rb +2 -2
- data/lib/textus/application/read/where.rb +26 -0
- data/lib/textus/application/use_case.rb +22 -0
- data/lib/textus/application/write/accept.rb +102 -0
- data/lib/textus/application/{writes → write}/authority_gate.rb +3 -3
- data/lib/textus/application/write/delete.rb +45 -0
- data/lib/textus/application/{writes → write}/materializer.rb +14 -15
- data/lib/textus/application/write/mv.rb +118 -0
- data/lib/textus/application/write/publish.rb +96 -0
- data/lib/textus/application/write/put.rb +49 -0
- data/lib/textus/application/write/refresh_all.rb +63 -0
- data/lib/textus/application/{refresh/orchestrator.rb → write/refresh_orchestrator.rb} +32 -8
- data/lib/textus/application/write/refresh_worker.rb +134 -0
- data/lib/textus/application/write/reject.rb +62 -0
- data/lib/textus/{intro.rb → boot.rb} +49 -29
- data/lib/textus/builder/pipeline.rb +5 -5
- data/lib/textus/cli/group/mcp.rb +9 -0
- data/lib/textus/cli/group/zone.rb +9 -0
- data/lib/textus/cli/verb/accept.rb +1 -1
- data/lib/textus/cli/verb/audit.rb +4 -2
- data/lib/textus/cli/verb/blame.rb +1 -1
- data/lib/textus/cli/verb/boot.rb +13 -0
- data/lib/textus/cli/verb/build.rb +2 -2
- data/lib/textus/cli/verb/delete.rb +1 -1
- data/lib/textus/cli/verb/deps.rb +1 -1
- data/lib/textus/cli/verb/doctor.rb +1 -1
- data/lib/textus/cli/verb/freshness.rb +1 -1
- data/lib/textus/cli/verb/get.rb +1 -1
- data/lib/textus/cli/verb/hook_run.rb +3 -4
- data/lib/textus/cli/verb/hooks.rb +11 -14
- data/lib/textus/cli/verb/key_delete.rb +24 -0
- data/lib/textus/cli/verb/list.rb +1 -1
- data/lib/textus/cli/verb/mcp_serve.rb +17 -0
- data/lib/textus/cli/verb/migrate.rb +18 -0
- data/lib/textus/cli/verb/mv.rb +11 -3
- data/lib/textus/cli/verb/published.rb +1 -1
- data/lib/textus/cli/verb/pulse.rb +17 -0
- data/lib/textus/cli/verb/put.rb +8 -6
- data/lib/textus/cli/verb/rdeps.rb +1 -1
- data/lib/textus/cli/verb/refresh.rb +1 -1
- data/lib/textus/cli/verb/refresh_stale.rb +1 -1
- data/lib/textus/cli/verb/reject.rb +1 -1
- data/lib/textus/cli/verb/rule_explain.rb +1 -1
- data/lib/textus/cli/verb/rule_lint.rb +18 -0
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb/uid.rb +1 -1
- data/lib/textus/cli/verb/where.rb +1 -1
- data/lib/textus/cli/verb/zone_mv.rb +19 -0
- data/lib/textus/cli/verb.rb +4 -4
- data/lib/textus/cli.rb +1 -1
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/handler_allowlist.rb +2 -2
- data/lib/textus/doctor/check/hooks.rb +4 -3
- data/lib/textus/doctor/check/illegal_keys.rb +2 -2
- data/lib/textus/doctor/check/intake_registration.rb +2 -2
- data/lib/textus/doctor/check/manifest_files.rb +2 -2
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/refresh_locks.rb +2 -2
- data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +1 -1
- data/lib/textus/doctor/check/schemas.rb +2 -2
- data/lib/textus/doctor/check/sentinels.rb +2 -2
- data/lib/textus/doctor/check/templates.rb +2 -2
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +5 -3
- data/lib/textus/doctor.rb +24 -27
- data/lib/textus/domain/authorizer.rb +4 -4
- data/lib/textus/{application → domain}/policy/predicates/accept_authority_signed.rb +2 -2
- data/lib/textus/{application → domain}/policy/predicates/schema_valid.rb +1 -1
- data/lib/textus/{application → domain}/policy/promotion.rb +1 -1
- data/lib/textus/domain/staleness/generator_check.rb +2 -2
- data/lib/textus/domain/staleness/intake_check.rb +2 -2
- data/lib/textus/domain/staleness.rb +1 -1
- data/lib/textus/errors.rb +16 -0
- data/lib/textus/hooks/builtin.rb +14 -14
- data/lib/textus/hooks/context.rb +13 -13
- data/lib/textus/hooks/error_log.rb +32 -0
- data/lib/textus/hooks/{bus.rb → event_bus.rb} +41 -53
- data/lib/textus/hooks/loader.rb +29 -3
- data/lib/textus/hooks/rpc_registry.rb +77 -0
- data/lib/textus/infra/audit_log.rb +126 -16
- data/lib/textus/infra/audit_subscriber.rb +6 -7
- data/lib/textus/infra/refresh/detached.rb +1 -1
- data/lib/textus/key/path.rb +7 -3
- data/lib/textus/manifest/data.rb +78 -0
- data/lib/textus/manifest/entry/base.rb +44 -7
- data/lib/textus/manifest/entry/derived.rb +41 -6
- data/lib/textus/manifest/entry/intake.rb +15 -3
- data/lib/textus/manifest/entry/leaf.rb +6 -5
- data/lib/textus/manifest/entry/nested.rb +42 -3
- data/lib/textus/manifest/entry/parser.rb +8 -44
- data/lib/textus/manifest/entry/validators/events.rb +2 -2
- data/lib/textus/manifest/entry/validators/format_matrix.rb +5 -4
- data/lib/textus/manifest/entry/validators/index_filename.rb +2 -1
- data/lib/textus/manifest/entry/validators/inject_boot.rb +19 -0
- data/lib/textus/manifest/entry/validators/publish_each.rb +4 -3
- data/lib/textus/manifest/entry/validators.rb +1 -1
- data/lib/textus/manifest/entry.rb +3 -0
- data/lib/textus/manifest/policy.rb +48 -0
- data/lib/textus/manifest/resolver.rb +18 -18
- data/lib/textus/manifest/rules.rb +1 -1
- data/lib/textus/manifest/schema.rb +20 -6
- data/lib/textus/manifest.rb +53 -101
- data/lib/textus/mcp/errors.rb +32 -0
- data/lib/textus/mcp/server.rb +127 -0
- data/lib/textus/mcp/session.rb +31 -0
- data/lib/textus/mcp/tool_schemas.rb +71 -0
- data/lib/textus/mcp/tools.rb +129 -0
- data/lib/textus/mcp.rb +6 -0
- data/lib/textus/schema/tools.rb +14 -10
- data/lib/textus/session.rb +84 -0
- data/lib/textus/store.rb +17 -8
- data/lib/textus/version.rb +1 -1
- data/lib/textus.rb +8 -1
- metadata +65 -38
- data/lib/textus/application/reads/audit.rb +0 -69
- data/lib/textus/application/reads/blame.rb +0 -82
- data/lib/textus/application/reads/deps.rb +0 -26
- data/lib/textus/application/reads/freshness.rb +0 -88
- data/lib/textus/application/reads/get.rb +0 -67
- data/lib/textus/application/reads/get_or_refresh.rb +0 -51
- data/lib/textus/application/reads/list.rb +0 -17
- data/lib/textus/application/reads/policy_explain.rb +0 -39
- data/lib/textus/application/reads/published.rb +0 -17
- data/lib/textus/application/reads/rdeps.rb +0 -27
- data/lib/textus/application/reads/schema_envelope.rb +0 -18
- data/lib/textus/application/reads/stale.rb +0 -15
- data/lib/textus/application/reads/uid.rb +0 -23
- data/lib/textus/application/reads/validate_all.rb +0 -24
- data/lib/textus/application/reads/where.rb +0 -18
- data/lib/textus/application/refresh/all.rb +0 -52
- data/lib/textus/application/refresh/worker.rb +0 -116
- data/lib/textus/application/writes/accept.rb +0 -89
- data/lib/textus/application/writes/delete.rb +0 -33
- data/lib/textus/application/writes/mv.rb +0 -105
- data/lib/textus/application/writes/publish.rb +0 -162
- data/lib/textus/application/writes/put.rb +0 -37
- data/lib/textus/application/writes/reject.rb +0 -50
- data/lib/textus/cli/verb/intro.rb +0 -13
- data/lib/textus/infra/event_bus.rb +0 -27
- data/lib/textus/manifest/entry/validators/inject_intro.rb +0 -21
- data/lib/textus/operations.rb +0 -169
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Write
|
|
4
|
+
module Put
|
|
5
|
+
def self.call(*, session:, ctx:, caps:, **)
|
|
6
|
+
Impl.new(
|
|
7
|
+
ctx: ctx, caps: caps,
|
|
8
|
+
writer: session.envelope_writer,
|
|
9
|
+
hook_context: session.hook_context
|
|
10
|
+
).call(*, **)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class Impl
|
|
14
|
+
def initialize(ctx:, caps:, writer:, hook_context:)
|
|
15
|
+
@ctx = ctx
|
|
16
|
+
@manifest = caps.manifest
|
|
17
|
+
@events = caps.events
|
|
18
|
+
@authorizer = caps.authorizer
|
|
19
|
+
@writer = writer
|
|
20
|
+
@hook_context = hook_context
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(key, meta: nil, body: nil, content: nil, if_etag: nil)
|
|
24
|
+
Textus::Manifest::Data.validate_key!(key)
|
|
25
|
+
mentry = @manifest.resolver.resolve(key).entry
|
|
26
|
+
|
|
27
|
+
@authorizer.authorize_write!(mentry, role: @ctx.role)
|
|
28
|
+
|
|
29
|
+
envelope = @writer.put(
|
|
30
|
+
key,
|
|
31
|
+
mentry: mentry,
|
|
32
|
+
payload: Textus::Application::Envelope::Writer::Payload.new(meta: meta, body: body, content: content),
|
|
33
|
+
if_etag: if_etag,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
@events.publish(:entry_put,
|
|
37
|
+
ctx: @hook_context,
|
|
38
|
+
key: key,
|
|
39
|
+
envelope: envelope)
|
|
40
|
+
|
|
41
|
+
envelope
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
Textus::Application::UseCase.register(:put, Textus::Application::Write::Put, caps: :write)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Application
|
|
3
|
+
module Write
|
|
4
|
+
module RefreshAll
|
|
5
|
+
def self.call(*, session:, ctx:, caps:, **)
|
|
6
|
+
Impl.new(
|
|
7
|
+
ctx: ctx, caps: caps,
|
|
8
|
+
rpc: session.rpc,
|
|
9
|
+
writer: session.envelope_writer,
|
|
10
|
+
hook_context: session.hook_context
|
|
11
|
+
).call(*, **)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class Impl
|
|
15
|
+
def initialize(ctx:, caps:, rpc:, writer:, hook_context:)
|
|
16
|
+
@ctx = ctx
|
|
17
|
+
@caps = caps
|
|
18
|
+
@rpc = rpc
|
|
19
|
+
@writer = writer
|
|
20
|
+
@hook_context = hook_context
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(prefix: nil, zone: nil)
|
|
24
|
+
worker = Textus::Application::Write::RefreshWorker::Impl.new(
|
|
25
|
+
ctx: @ctx, caps: @caps, rpc: @rpc, writer: @writer,
|
|
26
|
+
hook_context: @hook_context
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
stale_rows = Textus::Application::Read::Stale::Impl.new(caps: @caps).call(prefix: prefix, zone: zone)
|
|
30
|
+
refreshed = []
|
|
31
|
+
failed = []
|
|
32
|
+
skipped = []
|
|
33
|
+
|
|
34
|
+
stale_rows.each do |row|
|
|
35
|
+
key = row["key"] || row[:key]
|
|
36
|
+
reason = row["reason"] || row[:reason]
|
|
37
|
+
if reason.to_s.match?(/ttl exceeded|never refreshed/)
|
|
38
|
+
begin
|
|
39
|
+
worker.run(key)
|
|
40
|
+
refreshed << key
|
|
41
|
+
rescue Textus::Error => e
|
|
42
|
+
failed << { "key" => key, "error" => e.message }
|
|
43
|
+
end
|
|
44
|
+
else
|
|
45
|
+
skipped << { "key" => key, "reason" => reason }
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
{
|
|
50
|
+
"protocol" => Textus::PROTOCOL,
|
|
51
|
+
"ok" => failed.empty?,
|
|
52
|
+
"refreshed" => refreshed,
|
|
53
|
+
"failed" => failed,
|
|
54
|
+
"skipped" => skipped,
|
|
55
|
+
}
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
Textus::Application::UseCase.register(:refresh_all, Textus::Application::Write::RefreshAll, caps: :write)
|
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Application
|
|
3
|
-
module
|
|
4
|
-
class
|
|
5
|
-
def initialize(worker:, store_root:,
|
|
3
|
+
module Write
|
|
4
|
+
class RefreshOrchestrator
|
|
5
|
+
def initialize(worker:, store_root:, events:, ctx: nil, hook_context: nil, detached_spawner: nil)
|
|
6
6
|
@worker = worker
|
|
7
7
|
@store_root = store_root
|
|
8
|
-
@
|
|
9
|
-
@store = store
|
|
8
|
+
@events = events
|
|
10
9
|
@ctx = ctx
|
|
11
10
|
@hook_context = hook_context
|
|
12
11
|
@detached_spawner = detached_spawner || default_spawner
|
|
@@ -31,12 +30,37 @@ module Textus
|
|
|
31
30
|
end
|
|
32
31
|
|
|
33
32
|
def run_timed(budget_ms, key)
|
|
34
|
-
|
|
33
|
+
return run_timed_with_fork(budget_ms, key) if Textus::Infra::Refresh::Detached.supported?
|
|
34
|
+
|
|
35
|
+
run_timed_cooperative(budget_ms, key)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def run_timed_cooperative(budget_ms, key)
|
|
39
|
+
result = nil
|
|
40
|
+
thread = Thread.new do
|
|
41
|
+
result = @worker.run(key)
|
|
42
|
+
rescue Textus::Error => e
|
|
43
|
+
result = e
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
thread.join(budget_ms / 1000.0)
|
|
47
|
+
if thread.alive?
|
|
48
|
+
thread.kill
|
|
35
49
|
return Textus::Domain::Outcome::Failed.new(
|
|
36
|
-
error: Textus::UsageError.new(
|
|
50
|
+
error: Textus::UsageError.new(
|
|
51
|
+
"refresh exceeded budget #{budget_ms}ms (no fork available — cooperative cancel)",
|
|
52
|
+
),
|
|
37
53
|
)
|
|
38
54
|
end
|
|
39
55
|
|
|
56
|
+
if result.is_a?(Textus::Error)
|
|
57
|
+
Textus::Domain::Outcome::Failed.new(error: result)
|
|
58
|
+
else
|
|
59
|
+
Textus::Domain::Outcome::Refreshed.new(envelope: result)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def run_timed_with_fork(budget_ms, key)
|
|
40
64
|
result = nil
|
|
41
65
|
thread = Thread.new do
|
|
42
66
|
result = @worker.run(key)
|
|
@@ -59,7 +83,7 @@ module Textus
|
|
|
59
83
|
|
|
60
84
|
payload = { key: key, started_at: Time.now.utc.iso8601, budget_ms: budget_ms }
|
|
61
85
|
payload[:ctx] = @hook_context if @hook_context
|
|
62
|
-
@
|
|
86
|
+
@events.publish(:refresh_backgrounded, **payload)
|
|
63
87
|
@detached_spawner.call(store_root: @store_root, key: key)
|
|
64
88
|
Textus::Domain::Outcome::Detached.new
|
|
65
89
|
elsif result.is_a?(Textus::Error)
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
require "timeout"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Application
|
|
5
|
+
module Write
|
|
6
|
+
module RefreshWorker
|
|
7
|
+
FETCH_TIMEOUT_SECONDS = 30
|
|
8
|
+
|
|
9
|
+
def self.call(*, session:, ctx:, caps:, **)
|
|
10
|
+
Impl.new(
|
|
11
|
+
ctx: ctx, caps: caps,
|
|
12
|
+
rpc: session.rpc,
|
|
13
|
+
writer: session.envelope_writer,
|
|
14
|
+
hook_context: session.hook_context
|
|
15
|
+
).call(*, **)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
class Impl
|
|
19
|
+
def initialize(ctx:, caps:, rpc:, writer:, hook_context:)
|
|
20
|
+
@ctx = ctx
|
|
21
|
+
@caps = caps
|
|
22
|
+
@manifest = caps.manifest
|
|
23
|
+
@writer = writer
|
|
24
|
+
@events = caps.events
|
|
25
|
+
@rpc = rpc
|
|
26
|
+
@authorizer = caps.authorizer
|
|
27
|
+
@hook_context = hook_context
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# call(key) is the primary entry; run is kept as an alias for
|
|
31
|
+
# Orchestrator and RefreshAll which call worker.run(key).
|
|
32
|
+
def call(key)
|
|
33
|
+
run(key)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run(key)
|
|
37
|
+
res = @manifest.resolver.resolve(key)
|
|
38
|
+
mentry = res.entry
|
|
39
|
+
path = res.path
|
|
40
|
+
remaining = res.remaining
|
|
41
|
+
raise UsageError.new("no intake declared for '#{key}'") unless mentry.is_a?(Textus::Manifest::Entry::Intake)
|
|
42
|
+
|
|
43
|
+
before_etag = File.exist?(path) ? Etag.for_file(path) : nil
|
|
44
|
+
result = fetch_with_events(key, mentry, remaining)
|
|
45
|
+
persist_and_notify(key, mentry, result, before_etag)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def fetch_timeout_for(key)
|
|
51
|
+
rule = @manifest.rules.for(key)
|
|
52
|
+
rule&.refresh&.fetch_timeout_seconds || FETCH_TIMEOUT_SECONDS
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def fetch_with_events(key, mentry, remaining)
|
|
56
|
+
@events.publish(:refresh_started, ctx: @hook_context, key: key, mode: :sync)
|
|
57
|
+
call_intake(key, mentry, remaining)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def call_intake(key, mentry, remaining)
|
|
61
|
+
timeout = fetch_timeout_for(key)
|
|
62
|
+
Timeout.timeout(timeout) do
|
|
63
|
+
@rpc.invoke(:resolve_intake, mentry.handler,
|
|
64
|
+
caps: @caps,
|
|
65
|
+
config: mentry.config,
|
|
66
|
+
args: { trigger_key: key, leaf_segments: remaining || [] })
|
|
67
|
+
end
|
|
68
|
+
rescue Timeout::Error
|
|
69
|
+
@events.publish(:refresh_failed, ctx: @hook_context, key: key,
|
|
70
|
+
error_class: "Timeout::Error",
|
|
71
|
+
error_message: "intake '#{mentry.handler}' exceeded #{timeout}s")
|
|
72
|
+
raise UsageError.new("intake '#{mentry.handler}' exceeded #{timeout}s timeout")
|
|
73
|
+
rescue Textus::Error => e
|
|
74
|
+
@events.publish(:refresh_failed, ctx: @hook_context, key: key, error_class: e.class.name,
|
|
75
|
+
error_message: e.message)
|
|
76
|
+
raise
|
|
77
|
+
rescue StandardError => e
|
|
78
|
+
@events.publish(:refresh_failed, ctx: @hook_context, key: key, error_class: e.class.name,
|
|
79
|
+
error_message: e.message)
|
|
80
|
+
raise UsageError.new("intake '#{mentry.handler}' raised: #{e.class}: #{e.message}")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def persist_and_notify(key, mentry, result, before_etag)
|
|
84
|
+
normalized = RefreshWorker.send(:normalize_action_result, result, format: mentry.format)
|
|
85
|
+
@authorizer.authorize_write!(mentry, role: @ctx.role)
|
|
86
|
+
envelope = @writer.put(
|
|
87
|
+
key,
|
|
88
|
+
mentry: mentry,
|
|
89
|
+
payload: Textus::Application::Envelope::Writer::Payload.new(
|
|
90
|
+
meta: normalized[:meta], body: normalized[:body], content: normalized[:content],
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
change = detect_change(before_etag, envelope)
|
|
94
|
+
@events.publish(:entry_refreshed, ctx: @hook_context, key: key, envelope: envelope, change: change) unless change == :unchanged
|
|
95
|
+
envelope
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def detect_change(before_etag, envelope)
|
|
99
|
+
if before_etag.nil? then :created
|
|
100
|
+
elsif envelope.etag == before_etag then :unchanged
|
|
101
|
+
else :updated
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def self.normalize_action_result(res, format:)
|
|
107
|
+
res = res.transform_keys(&:to_s) if res.is_a?(Hash)
|
|
108
|
+
res ||= {}
|
|
109
|
+
meta_val = res["_meta"]
|
|
110
|
+
body = res["body"]
|
|
111
|
+
content = res["content"]
|
|
112
|
+
|
|
113
|
+
case format
|
|
114
|
+
when "markdown" then { meta: meta_val || {}, body: body.to_s, content: nil }
|
|
115
|
+
when "text" then { meta: {}, body: body.to_s, content: nil }
|
|
116
|
+
when "json", "yaml"
|
|
117
|
+
if !content.nil?
|
|
118
|
+
{ meta: meta_val || {}, body: nil, content: content }
|
|
119
|
+
elsif !body.nil?
|
|
120
|
+
{ meta: {}, body: body.to_s, content: nil }
|
|
121
|
+
else
|
|
122
|
+
raise Textus::UsageError.new("intake for #{format} returned neither content nor body")
|
|
123
|
+
end
|
|
124
|
+
else
|
|
125
|
+
raise Textus::UsageError.new("unknown format #{format.inspect}")
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
private_class_method :normalize_action_result
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
Textus::Application::UseCase.register(:refresh, Textus::Application::Write::RefreshWorker, caps: :write)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
require_relative "authority_gate"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Application
|
|
5
|
+
module Write
|
|
6
|
+
module Reject
|
|
7
|
+
def self.call(*, session:, ctx:, caps:, **)
|
|
8
|
+
Impl.new(
|
|
9
|
+
ctx: ctx, caps: caps,
|
|
10
|
+
writer: session.envelope_writer,
|
|
11
|
+
hook_context: session.hook_context
|
|
12
|
+
).call(*, **)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
class Impl
|
|
16
|
+
include AuthorityGate
|
|
17
|
+
|
|
18
|
+
def initialize(ctx:, caps:, writer:, hook_context:)
|
|
19
|
+
@ctx = ctx
|
|
20
|
+
@caps = caps
|
|
21
|
+
@manifest = caps.manifest
|
|
22
|
+
@writer = writer
|
|
23
|
+
@events = caps.events
|
|
24
|
+
@authorizer = caps.authorizer
|
|
25
|
+
@hook_context = hook_context
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(pending_key)
|
|
29
|
+
assert_accept_authority!("reject")
|
|
30
|
+
|
|
31
|
+
mentry = @manifest.resolver.resolve(pending_key).entry
|
|
32
|
+
unless mentry.in_proposal_zone?
|
|
33
|
+
raise ProposalError.new("reject: '#{pending_key}' is not in a proposal zone (zone=#{mentry.zone})")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
env = Textus::Application::Read::Get::Impl.new(
|
|
37
|
+
ctx: @ctx, caps: @caps,
|
|
38
|
+
).call(pending_key)
|
|
39
|
+
proposal = env.meta&.dig("proposal") or
|
|
40
|
+
raise ProposalError.new("entry has no proposal block: #{pending_key}")
|
|
41
|
+
target_key = proposal["target_key"] or
|
|
42
|
+
raise ProposalError.new("proposal missing target_key")
|
|
43
|
+
|
|
44
|
+
Textus::Application::Write::Delete::Impl.new(
|
|
45
|
+
ctx: @ctx, caps: @caps, writer: @writer,
|
|
46
|
+
hook_context: @hook_context
|
|
47
|
+
).call(pending_key, suppress_events: true)
|
|
48
|
+
|
|
49
|
+
@events.publish(:proposal_rejected,
|
|
50
|
+
ctx: @hook_context,
|
|
51
|
+
key: pending_key,
|
|
52
|
+
target_key: target_key)
|
|
53
|
+
|
|
54
|
+
{ "protocol" => PROTOCOL, "rejected" => pending_key, "target_key" => target_key }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
Textus::Application::UseCase.register(:reject, Textus::Application::Write::Reject, caps: :write)
|
|
@@ -4,8 +4,8 @@ module Textus
|
|
|
4
4
|
# project: zones and their write authority, entries and their flags,
|
|
5
5
|
# registered hooks, write flows, and the CLI verb catalog.
|
|
6
6
|
#
|
|
7
|
-
#
|
|
8
|
-
module
|
|
7
|
+
# Boot is side-effect-free.
|
|
8
|
+
module Boot
|
|
9
9
|
PROTOCOL_ID = PROTOCOL
|
|
10
10
|
|
|
11
11
|
# Conventional zone purposes. Unknown zones (declared in the manifest
|
|
@@ -26,7 +26,7 @@ module Textus
|
|
|
26
26
|
"edit files in identity/working zones, then 'textus put KEY --as=#{name}'"
|
|
27
27
|
end,
|
|
28
28
|
proposer: lambda do |name, manifest|
|
|
29
|
-
authority = manifest.roles_with_kind(:accept_authority).first || "accept_authority"
|
|
29
|
+
authority = manifest.policy.roles_with_kind(:accept_authority).first || "accept_authority"
|
|
30
30
|
"propose changes by writing review.* entries with --as=#{name} and a 'proposal:' frontmatter block; " \
|
|
31
31
|
"the #{authority} role runs 'textus accept' to apply"
|
|
32
32
|
end,
|
|
@@ -39,7 +39,7 @@ module Textus
|
|
|
39
39
|
}.freeze
|
|
40
40
|
|
|
41
41
|
def self.write_flows_for(manifest)
|
|
42
|
-
manifest.role_mapping.each_with_object({}) do |(name, kind), acc|
|
|
42
|
+
manifest.policy.role_mapping.each_with_object({}) do |(name, kind), acc|
|
|
43
43
|
tmpl = WRITE_FLOW_TEMPLATES[kind]
|
|
44
44
|
acc[name] = tmpl.call(name, manifest) if tmpl
|
|
45
45
|
end
|
|
@@ -95,10 +95,10 @@ module Textus
|
|
|
95
95
|
}.freeze
|
|
96
96
|
|
|
97
97
|
# The CLI verb catalog. Truth lives here; do not derive dynamically.
|
|
98
|
-
# Agents that read
|
|
98
|
+
# Agents that read boot should see a stable shape regardless of how
|
|
99
99
|
# verb implementations evolve.
|
|
100
100
|
CLI_VERBS = [
|
|
101
|
-
{ "name" => "
|
|
101
|
+
{ "name" => "boot", "summary" => "this output — orientation for agents and tools" },
|
|
102
102
|
{ "name" => "list", "summary" => "enumerate keys (optional --prefix)" },
|
|
103
103
|
{ "name" => "get", "summary" => "read an entry; envelope with _meta, body, uid, etag" },
|
|
104
104
|
{ "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
|
|
@@ -116,35 +116,58 @@ module Textus
|
|
|
116
116
|
{ "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
|
|
117
117
|
{ "name" => "hook",
|
|
118
118
|
"summary" => "list and run registered hooks: 'hook list', 'hook run NAME'" },
|
|
119
|
+
{ "name" => "pulse",
|
|
120
|
+
"summary" => "delta since cursor — changed entries, stale, pending review, doctor summary" },
|
|
119
121
|
].freeze
|
|
120
122
|
|
|
123
|
+
def self.agent_quickstart(manifest, session)
|
|
124
|
+
proposer_roles = manifest.policy.roles_with_kind(:proposer)
|
|
125
|
+
agent_role = proposer_roles.first
|
|
126
|
+
|
|
127
|
+
writable_zones = manifest.data.zones.each_with_object([]) do |(zname, writers), acc|
|
|
128
|
+
acc << zname if agent_role && writers.include?(agent_role)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
propose_zone = writable_zones.find { |z| z.include?("review") } || writable_zones.first
|
|
132
|
+
|
|
133
|
+
{
|
|
134
|
+
"read_verbs" => %w[boot get list audit pulse freshness doctor],
|
|
135
|
+
"write_verbs" => agent_role ? ["put KEY --as=#{agent_role} --stdin"] : [],
|
|
136
|
+
"writable_zones" => writable_zones,
|
|
137
|
+
"propose_zone" => propose_zone,
|
|
138
|
+
"latest_seq" => session.write_caps.audit_log.latest_seq,
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
121
142
|
def self.agent_protocol(manifest)
|
|
122
143
|
AGENT_PROTOCOL_TEMPLATE.merge(
|
|
123
144
|
"role_resolution" => {
|
|
124
145
|
"summary" => "write role is resolved in order: --as flag, TEXTUS_ROLE env var, .textus/role file, " \
|
|
125
146
|
"default 'human'",
|
|
126
|
-
"roles" => manifest.role_mapping.keys,
|
|
147
|
+
"roles" => manifest.policy.role_mapping.keys,
|
|
127
148
|
"ref" => "SPEC.md §5",
|
|
128
149
|
},
|
|
129
150
|
)
|
|
130
151
|
end
|
|
131
152
|
|
|
132
|
-
def self.run(
|
|
153
|
+
def self.run(session)
|
|
154
|
+
manifest = session.read_caps.manifest
|
|
133
155
|
{
|
|
134
156
|
"protocol" => PROTOCOL_ID,
|
|
135
|
-
"store_root" =>
|
|
136
|
-
"zones" => zones_for(
|
|
137
|
-
"entries" => entries_for(
|
|
138
|
-
"hooks" => hooks_for(
|
|
139
|
-
"write_flows" => write_flows_for(
|
|
157
|
+
"store_root" => session.read_caps.root,
|
|
158
|
+
"zones" => zones_for(manifest),
|
|
159
|
+
"entries" => entries_for(manifest),
|
|
160
|
+
"hooks" => hooks_for(session),
|
|
161
|
+
"write_flows" => write_flows_for(manifest),
|
|
140
162
|
"cli_verbs" => CLI_VERBS.map(&:dup),
|
|
141
|
-
"agent_protocol" => agent_protocol(
|
|
163
|
+
"agent_protocol" => agent_protocol(manifest),
|
|
164
|
+
"agent_quickstart" => agent_quickstart(manifest, session),
|
|
142
165
|
"docs" => { "spec" => "SPEC.md", "example" => "examples/claude-plugin/" },
|
|
143
166
|
}
|
|
144
167
|
end
|
|
145
168
|
|
|
146
|
-
def self.zones_for(
|
|
147
|
-
|
|
169
|
+
def self.zones_for(manifest)
|
|
170
|
+
manifest.data.zones.map do |name, writers|
|
|
148
171
|
row = { "name" => name, "writers" => Array(writers) }
|
|
149
172
|
purpose = ZONE_PURPOSES[name]
|
|
150
173
|
row["purpose"] = purpose if purpose
|
|
@@ -152,9 +175,9 @@ module Textus
|
|
|
152
175
|
end
|
|
153
176
|
end
|
|
154
177
|
|
|
155
|
-
def self.entries_for(
|
|
156
|
-
|
|
157
|
-
derived =
|
|
178
|
+
def self.entries_for(manifest)
|
|
179
|
+
manifest.data.entries.map do |e|
|
|
180
|
+
derived = manifest.policy.zone_kinds(e.zone).include?(:generator)
|
|
158
181
|
{
|
|
159
182
|
"key" => e.key,
|
|
160
183
|
"zone" => e.zone,
|
|
@@ -165,21 +188,18 @@ module Textus
|
|
|
165
188
|
"derived" => derived,
|
|
166
189
|
"intake" => e.is_a?(Textus::Manifest::Entry::Intake),
|
|
167
190
|
"publish_to" => Array(e.publish_to),
|
|
168
|
-
"publish_each" => e.
|
|
191
|
+
"publish_each" => e.publish_each,
|
|
169
192
|
}
|
|
170
193
|
end
|
|
171
194
|
end
|
|
172
195
|
|
|
173
|
-
def self.hooks_for(
|
|
174
|
-
bus = store.bus
|
|
196
|
+
def self.hooks_for(session)
|
|
175
197
|
sections = {}
|
|
176
|
-
Hooks::
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
sections[event.to_s] = bus.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
|
|
182
|
-
end
|
|
198
|
+
Hooks::RpcRegistry::EVENTS.each_key do |event|
|
|
199
|
+
sections[event.to_s] = session.rpc.names(event).map(&:to_s).sort
|
|
200
|
+
end
|
|
201
|
+
Hooks::EventBus::EVENTS.each_key do |event|
|
|
202
|
+
sections[event.to_s] = session.events.pubsub_handlers(event).map { |h| h[:name].to_s }.sort
|
|
183
203
|
end
|
|
184
204
|
sections
|
|
185
205
|
end
|
|
@@ -63,8 +63,8 @@ module Textus
|
|
|
63
63
|
end
|
|
64
64
|
|
|
65
65
|
# rubocop:disable Metrics/ParameterLists
|
|
66
|
-
def self.run(mentry:, manifest:, reader:, lister:,
|
|
67
|
-
transform_context: nil,
|
|
66
|
+
def self.run(mentry:, manifest:, reader:, lister:, rpc:, template_loader:,
|
|
67
|
+
transform_context: nil, inject_boot: nil)
|
|
68
68
|
# 1. Load sources + project + reduce
|
|
69
69
|
data =
|
|
70
70
|
if mentry.is_a?(Textus::Manifest::Entry::Derived) && mentry.projection?
|
|
@@ -72,13 +72,13 @@ module Textus
|
|
|
72
72
|
reader: reader,
|
|
73
73
|
spec: mentry.source.to_h.transform_keys(&:to_s),
|
|
74
74
|
lister: lister,
|
|
75
|
-
|
|
75
|
+
rpc: rpc,
|
|
76
76
|
transform_context: transform_context,
|
|
77
77
|
).run
|
|
78
78
|
else
|
|
79
79
|
{ "entries" => [], "count" => 0, "generated_at" => Time.now.utc.iso8601 }
|
|
80
80
|
end
|
|
81
|
-
data = data.merge("
|
|
81
|
+
data = data.merge("boot" => inject_boot.call) if mentry.inject_boot && inject_boot
|
|
82
82
|
|
|
83
83
|
# 2. Render
|
|
84
84
|
klass = renderers[mentry.format] or
|
|
@@ -86,7 +86,7 @@ module Textus
|
|
|
86
86
|
bytes = klass.new(template_loader: template_loader).call(mentry: mentry, data: data)
|
|
87
87
|
|
|
88
88
|
# 3. Write (idempotent: skip if only generated_at would differ)
|
|
89
|
-
target_path = Key::Path.resolve(manifest, mentry)
|
|
89
|
+
target_path = Key::Path.resolve(manifest.data, mentry)
|
|
90
90
|
FileUtils.mkdir_p(File.dirname(target_path))
|
|
91
91
|
write_if_changed(target_path, bytes, mentry.format)
|
|
92
92
|
|
|
@@ -9,18 +9,20 @@ module Textus
|
|
|
9
9
|
option :role_filter, "--role=ROLE"
|
|
10
10
|
option :verb_filter, "--verb=V"
|
|
11
11
|
option :since, "--since=ISO8601|RELATIVE"
|
|
12
|
+
option :seq_since, "--seq-since=N"
|
|
12
13
|
option :correlation_id, "--correlation-id=ID"
|
|
13
14
|
option :limit, "--limit=N"
|
|
14
15
|
|
|
15
16
|
def call(store)
|
|
16
|
-
ops =
|
|
17
|
-
since_time = since && Textus::Application::
|
|
17
|
+
ops = session_for(store)
|
|
18
|
+
since_time = since && Textus::Application::Read::Audit.parse_since(since, now: ops.ctx.now)
|
|
18
19
|
rows = ops.audit(
|
|
19
20
|
key: key_filter,
|
|
20
21
|
zone: zone,
|
|
21
22
|
role: role_filter,
|
|
22
23
|
verb: verb_filter,
|
|
23
24
|
since: since_time,
|
|
25
|
+
seq_since: seq_since&.to_i,
|
|
24
26
|
correlation_id: correlation_id,
|
|
25
27
|
limit: limit&.to_i,
|
|
26
28
|
)
|
|
@@ -8,7 +8,7 @@ module Textus
|
|
|
8
8
|
|
|
9
9
|
def call(store)
|
|
10
10
|
key = positional.shift or raise UsageError.new("blame requires a key")
|
|
11
|
-
rows =
|
|
11
|
+
rows = session_for(store).blame(key: key, limit: limit&.to_i)
|
|
12
12
|
emit({ "verb" => "blame", "key" => key, "rows" => rows })
|
|
13
13
|
end
|
|
14
14
|
end
|
|
@@ -8,8 +8,8 @@ module Textus
|
|
|
8
8
|
|
|
9
9
|
def call(store)
|
|
10
10
|
Textus::Infra::BuildLock.with(root: store.root) do
|
|
11
|
-
role = store.manifest.roles_with_kind(:generator).first || "builder"
|
|
12
|
-
ops =
|
|
11
|
+
role = store.manifest.policy.roles_with_kind(:generator).first || "builder"
|
|
12
|
+
ops = store.session(role: role)
|
|
13
13
|
result = ops.publish(prefix: prefix)
|
|
14
14
|
emit(result)
|
|
15
15
|
end
|
data/lib/textus/cli/verb/deps.rb
CHANGED