textus 0.35.1 → 0.39.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/CHANGELOG.md +135 -2
- data/README.md +34 -13
- data/SPEC.md +10 -4
- data/lib/textus/boot.rb +41 -21
- data/lib/textus/cli/verb/mcp_serve.rb +8 -3
- data/lib/textus/cli/verb/propose.rb +28 -0
- data/lib/textus/cli/verb/pulse.rb +12 -3
- data/lib/textus/cli/verb/schema.rb +1 -1
- data/lib/textus/cli/verb.rb +3 -2
- data/lib/textus/contract.rb +106 -0
- data/lib/textus/cursor_store.rb +24 -0
- data/lib/textus/dispatcher.rb +3 -1
- data/lib/textus/doctor/check/audit_log.rb +1 -1
- data/lib/textus/doctor/check/fetch_locks.rb +2 -2
- data/lib/textus/doctor/check/illegal_keys.rb +10 -4
- data/lib/textus/domain/policy/evaluation.rb +3 -6
- data/lib/textus/init.rb +4 -0
- data/lib/textus/layout.rb +41 -0
- data/lib/textus/maintenance/key_delete_prefix.rb +9 -0
- data/lib/textus/maintenance/key_mv_prefix.rb +10 -0
- data/lib/textus/maintenance/migrate.rb +9 -0
- data/lib/textus/maintenance/rule_lint.rb +8 -0
- data/lib/textus/maintenance/zone_mv.rb +10 -0
- data/lib/textus/manifest/entry/base.rb +5 -0
- data/lib/textus/manifest/entry/ignore_matcher.rb +46 -0
- data/lib/textus/manifest/entry/nested.rb +9 -2
- data/lib/textus/manifest/entry/validators/ignore.rb +28 -0
- data/lib/textus/manifest/entry/validators.rb +1 -0
- data/lib/textus/manifest/resolver.rb +2 -0
- data/lib/textus/manifest/schema.rb +1 -1
- data/lib/textus/mcp/catalog.rb +72 -0
- data/lib/textus/mcp/server.rb +8 -5
- data/lib/textus/mcp/session.rb +3 -20
- data/lib/textus/mcp/tool_schemas.rb +6 -62
- data/lib/textus/mcp/tools.rb +4 -119
- data/lib/textus/ports/audit_log.rb +17 -15
- data/lib/textus/ports/build_lock.rb +1 -2
- data/lib/textus/ports/fetch/lock.rb +1 -1
- data/lib/textus/read/audit.rb +3 -3
- data/lib/textus/read/boot.rb +6 -0
- data/lib/textus/read/get.rb +8 -0
- data/lib/textus/read/list.rb +8 -0
- data/lib/textus/read/pulse.rb +7 -0
- data/lib/textus/read/rules.rb +24 -0
- data/lib/textus/read/schema_envelope.rb +7 -0
- data/lib/textus/role.rb +6 -2
- data/lib/textus/session.rb +24 -0
- data/lib/textus/store.rb +11 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/write/accept.rb +1 -1
- data/lib/textus/write/delete.rb +1 -1
- data/lib/textus/write/fetch_all.rb +8 -0
- data/lib/textus/write/fetch_worker.rb +9 -1
- data/lib/textus/write/mv.rb +1 -1
- data/lib/textus/write/propose.rb +46 -0
- data/lib/textus/write/put.rb +13 -1
- data/lib/textus/write/reject.rb +1 -1
- data/lib/textus.rb +4 -0
- metadata +15 -5
- data/docs/conventions.md +0 -148
data/lib/textus/mcp/tools.rb
CHANGED
|
@@ -1,129 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module MCP
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
# propagate verbatim so the server can map them to JSON-RPC codes.
|
|
3
|
+
# Thin delegator kept for name stability (ADR 0039). The dispatch table
|
|
4
|
+
# and JSON schemas are now DERIVED from per-verb contracts by MCP::Catalog;
|
|
5
|
+
# this module only forwards.
|
|
7
6
|
module Tools
|
|
8
7
|
module_function
|
|
9
8
|
|
|
10
9
|
def call(name, session:, store:, args:)
|
|
11
|
-
|
|
12
|
-
impl.call(session, store, args || {})
|
|
13
|
-
rescue ContractDrift, CursorExpired
|
|
14
|
-
raise
|
|
15
|
-
rescue Textus::Error => e
|
|
16
|
-
raise ToolError.new("#{name}: #{e.message}")
|
|
10
|
+
Catalog.call(name, session: session, store: store, args: args || {})
|
|
17
11
|
end
|
|
18
|
-
|
|
19
|
-
def ops_for(session, store)
|
|
20
|
-
store.as(session.role)
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
REGISTRY = {
|
|
24
|
-
"boot" => ->(_s, store, _a) { store.boot },
|
|
25
|
-
|
|
26
|
-
"find" => lambda do |s, store, args|
|
|
27
|
-
ops_for(s, store).list(zone: args["zone"], prefix: args["prefix"])
|
|
28
|
-
end,
|
|
29
|
-
|
|
30
|
-
"read" => lambda do |s, store, args|
|
|
31
|
-
key = args.fetch("key") { raise ToolError.new("read: missing key") }
|
|
32
|
-
env = ops_for(s, store).get(key)
|
|
33
|
-
env.to_h_for_wire
|
|
34
|
-
end,
|
|
35
|
-
|
|
36
|
-
"tick" => lambda do |s, store, args|
|
|
37
|
-
since = (args["since"] || s.cursor).to_i
|
|
38
|
-
ops_for(s, store).pulse(since: since)
|
|
39
|
-
end,
|
|
40
|
-
|
|
41
|
-
"write" => lambda do |s, store, args|
|
|
42
|
-
key = args.fetch("key") { raise ToolError.new("write: missing key") }
|
|
43
|
-
env = ops_for(s, store).put(
|
|
44
|
-
key,
|
|
45
|
-
meta: args["meta"] || {},
|
|
46
|
-
body: args["body"],
|
|
47
|
-
content: args["content"],
|
|
48
|
-
if_etag: args["if_etag"],
|
|
49
|
-
)
|
|
50
|
-
{ "uid" => env.uid, "etag" => env.etag }
|
|
51
|
-
end,
|
|
52
|
-
|
|
53
|
-
"propose" => lambda do |s, store, args|
|
|
54
|
-
raise ToolError.new("propose: session has no propose_zone") unless s.propose_zone
|
|
55
|
-
|
|
56
|
-
rel = args.fetch("key") { raise ToolError.new("propose: missing key") }
|
|
57
|
-
target = "#{s.propose_zone}.#{rel}"
|
|
58
|
-
env = ops_for(s, store).put(
|
|
59
|
-
target,
|
|
60
|
-
meta: args["meta"] || {},
|
|
61
|
-
body: args["body"],
|
|
62
|
-
content: args["content"],
|
|
63
|
-
)
|
|
64
|
-
{ "uid" => env.uid, "etag" => env.etag, "key" => target }
|
|
65
|
-
end,
|
|
66
|
-
|
|
67
|
-
"fetch" => lambda do |s, store, args|
|
|
68
|
-
key = args.fetch("key") { raise ToolError.new("fetch: missing key") }
|
|
69
|
-
outcome = ops_for(s, store).fetch(key)
|
|
70
|
-
{ "outcome" => outcome.class.name.split("::").last.downcase }
|
|
71
|
-
end,
|
|
72
|
-
|
|
73
|
-
"fetch_stale" => lambda do |s, store, args|
|
|
74
|
-
ops_for(s, store).fetch_all(zone: args["zone"], prefix: args["prefix"])
|
|
75
|
-
end,
|
|
76
|
-
|
|
77
|
-
"schema" => lambda do |_s, store, args|
|
|
78
|
-
family = args.fetch("family") { raise ToolError.new("schema: missing family") }
|
|
79
|
-
store.schemas.fetch(family)
|
|
80
|
-
end,
|
|
81
|
-
|
|
82
|
-
"rules" => lambda do |_s, store, args|
|
|
83
|
-
key = args.fetch("key") { raise ToolError.new("rules: missing key") }
|
|
84
|
-
set = store.manifest.rules.for(key)
|
|
85
|
-
{
|
|
86
|
-
"fetch" => set.fetch&.to_h,
|
|
87
|
-
"guard" => set.guard,
|
|
88
|
-
}.compact
|
|
89
|
-
end,
|
|
90
|
-
|
|
91
|
-
"key_mv_prefix" => lambda do |s, store, args|
|
|
92
|
-
ops_for(s, store).key_mv_prefix(
|
|
93
|
-
from_prefix: args.fetch("from_prefix") { raise ToolError.new("key_mv_prefix: missing from_prefix") },
|
|
94
|
-
to_prefix: args.fetch("to_prefix") { raise ToolError.new("key_mv_prefix: missing to_prefix") },
|
|
95
|
-
dry_run: args["dry_run"] || false,
|
|
96
|
-
).to_h
|
|
97
|
-
end,
|
|
98
|
-
|
|
99
|
-
"key_delete_prefix" => lambda do |s, store, args|
|
|
100
|
-
ops_for(s, store).key_delete_prefix(
|
|
101
|
-
prefix: args.fetch("prefix") { raise ToolError.new("key_delete_prefix: missing prefix") },
|
|
102
|
-
dry_run: args["dry_run"] || false,
|
|
103
|
-
).to_h
|
|
104
|
-
end,
|
|
105
|
-
|
|
106
|
-
"zone_mv" => lambda do |s, store, args|
|
|
107
|
-
ops_for(s, store).zone_mv(
|
|
108
|
-
from: args.fetch("from") { raise ToolError.new("zone_mv: missing from") },
|
|
109
|
-
to: args.fetch("to") { raise ToolError.new("zone_mv: missing to") },
|
|
110
|
-
dry_run: args["dry_run"] || false,
|
|
111
|
-
).to_h
|
|
112
|
-
end,
|
|
113
|
-
|
|
114
|
-
"rule_lint" => lambda do |s, store, args|
|
|
115
|
-
ops_for(s, store).rule_lint(
|
|
116
|
-
candidate_yaml: args.fetch("candidate_yaml") { raise ToolError.new("rule_lint: missing candidate_yaml") },
|
|
117
|
-
).to_h
|
|
118
|
-
end,
|
|
119
|
-
|
|
120
|
-
"migrate" => lambda do |s, store, args|
|
|
121
|
-
ops_for(s, store).migrate(
|
|
122
|
-
plan_yaml: args.fetch("plan_yaml") { raise ToolError.new("migrate: missing plan_yaml") },
|
|
123
|
-
dry_run: args["dry_run"] || false,
|
|
124
|
-
).to_h
|
|
125
|
-
end,
|
|
126
|
-
}.freeze
|
|
127
12
|
end
|
|
128
13
|
end
|
|
129
14
|
end
|
|
@@ -10,7 +10,7 @@ module Textus
|
|
|
10
10
|
|
|
11
11
|
def initialize(root, max_size: DEFAULT_MAX_SIZE, keep: DEFAULT_KEEP)
|
|
12
12
|
@root = root
|
|
13
|
-
@path =
|
|
13
|
+
@path = Textus::Layout.audit_log(root)
|
|
14
14
|
@max_size = max_size
|
|
15
15
|
@keep = keep
|
|
16
16
|
end
|
|
@@ -54,6 +54,7 @@ module Textus
|
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
def append(role:, verb:, key:, etag_before:, etag_after:, extras: nil)
|
|
57
|
+
FileUtils.mkdir_p(File.dirname(@path))
|
|
57
58
|
File.open(@path, File::WRONLY | File::APPEND | File::CREAT, 0o644) do |f|
|
|
58
59
|
f.flock(File::LOCK_EX)
|
|
59
60
|
next_seq = current_max_seq_unlocked + 1
|
|
@@ -81,6 +82,14 @@ module Textus
|
|
|
81
82
|
|
|
82
83
|
private
|
|
83
84
|
|
|
85
|
+
def rotated(n)
|
|
86
|
+
File.join(Textus::Layout.audit_dir(@root), "audit.log.#{n}")
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def rotated_meta(n)
|
|
90
|
+
File.join(Textus::Layout.audit_dir(@root), "audit.log.#{n}.meta.json")
|
|
91
|
+
end
|
|
92
|
+
|
|
84
93
|
# Caller holds the flock. Returns the highest seq across the active log,
|
|
85
94
|
# OR the most-recent rotated file's max_seq if the active log is empty.
|
|
86
95
|
def current_max_seq_unlocked
|
|
@@ -113,7 +122,7 @@ module Textus
|
|
|
113
122
|
end
|
|
114
123
|
|
|
115
124
|
def read_meta(n)
|
|
116
|
-
path =
|
|
125
|
+
path = rotated_meta(n)
|
|
117
126
|
return nil unless File.exist?(path)
|
|
118
127
|
|
|
119
128
|
JSON.parse(File.read(path))
|
|
@@ -151,25 +160,18 @@ module Textus
|
|
|
151
160
|
meta = { "min_seq" => min_seq, "max_seq" => max_seq, "rotated_at" => Time.now.utc.iso8601 }
|
|
152
161
|
|
|
153
162
|
# Drop the file that would be shifted past @keep.
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
FileUtils.rm_f(oldest)
|
|
157
|
-
FileUtils.rm_f(oldest_meta)
|
|
163
|
+
FileUtils.rm_f(rotated(@keep))
|
|
164
|
+
FileUtils.rm_f(rotated_meta(@keep))
|
|
158
165
|
|
|
159
166
|
# Shift .N → .(N+1) for N = keep-1 down to 1.
|
|
160
167
|
(@keep - 1).downto(1) do |n|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
File.rename(src, dst) if File.exist?(src)
|
|
164
|
-
|
|
165
|
-
src_meta = File.join(@root, "audit.log.#{n}.meta.json")
|
|
166
|
-
dst_meta = File.join(@root, "audit.log.#{n + 1}.meta.json")
|
|
167
|
-
File.rename(src_meta, dst_meta) if File.exist?(src_meta)
|
|
168
|
+
File.rename(rotated(n), rotated(n + 1)) if File.exist?(rotated(n))
|
|
169
|
+
File.rename(rotated_meta(n), rotated_meta(n + 1)) if File.exist?(rotated_meta(n))
|
|
168
170
|
end
|
|
169
171
|
|
|
170
172
|
# Active log → .1
|
|
171
|
-
File.rename(@path,
|
|
172
|
-
File.write(
|
|
173
|
+
File.rename(@path, rotated(1))
|
|
174
|
+
File.write(rotated_meta(1), JSON.generate(meta) + "\n")
|
|
173
175
|
# Next append will create a fresh audit.log via File::CREAT.
|
|
174
176
|
end
|
|
175
177
|
|
|
@@ -5,7 +5,6 @@ require "time"
|
|
|
5
5
|
module Textus
|
|
6
6
|
module Ports
|
|
7
7
|
class BuildLock
|
|
8
|
-
LOCK_FILENAME = ".build.lock"
|
|
9
8
|
MAX_HOLDER_BYTES = 512
|
|
10
9
|
|
|
11
10
|
def self.with(root:, &)
|
|
@@ -13,7 +12,7 @@ module Textus
|
|
|
13
12
|
end
|
|
14
13
|
|
|
15
14
|
def initialize(root:)
|
|
16
|
-
@path =
|
|
15
|
+
@path = Textus::Layout.build_lock(root)
|
|
17
16
|
@file = nil
|
|
18
17
|
end
|
|
19
18
|
|
data/lib/textus/read/audit.rb
CHANGED
|
@@ -3,7 +3,7 @@ require "time"
|
|
|
3
3
|
|
|
4
4
|
module Textus
|
|
5
5
|
module Read
|
|
6
|
-
# Queries .textus/audit.log. Filters: key, zone, role, verb, since,
|
|
6
|
+
# Queries .textus/.run/audit/audit.log. Filters: key, zone, role, verb, since,
|
|
7
7
|
# correlation_id, limit. Reads the log file as JSON-Lines (legacy TSV
|
|
8
8
|
# rows produce nil and are skipped).
|
|
9
9
|
class Audit
|
|
@@ -33,7 +33,7 @@ module Textus
|
|
|
33
33
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
34
34
|
@manifest = container.manifest
|
|
35
35
|
@root = container.root
|
|
36
|
-
@log_path =
|
|
36
|
+
@log_path = Textus::Layout.audit_log(container.root)
|
|
37
37
|
@audit_log = container.audit_log
|
|
38
38
|
end
|
|
39
39
|
|
|
@@ -84,7 +84,7 @@ module Textus
|
|
|
84
84
|
end
|
|
85
85
|
|
|
86
86
|
def all_log_files
|
|
87
|
-
rotated = Dir.glob(File.join(@root, "audit.log.*"))
|
|
87
|
+
rotated = Dir.glob(File.join(Textus::Layout.audit_dir(@root), "audit.log.*"))
|
|
88
88
|
.reject { |p| p.end_with?(".meta.json") }
|
|
89
89
|
.sort_by { |p| -p.scan(/\d+$/).first.to_i } # .5 .4 .3 .2 .1 → oldest first
|
|
90
90
|
active = File.exist?(@log_path) ? [@log_path] : []
|
data/lib/textus/read/boot.rb
CHANGED
|
@@ -5,6 +5,12 @@ module Textus
|
|
|
5
5
|
# (container:, call:) entry point that Dispatcher::VERBS resolves to.
|
|
6
6
|
# Boot is role-independent, so `call` is not consulted.
|
|
7
7
|
class Boot
|
|
8
|
+
extend Textus::Contract::DSL
|
|
9
|
+
|
|
10
|
+
verb :boot
|
|
11
|
+
summary "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart."
|
|
12
|
+
surfaces :cli, :ruby, :mcp
|
|
13
|
+
|
|
8
14
|
def initialize(container:, call:)
|
|
9
15
|
@container = container
|
|
10
16
|
@call = call
|
data/lib/textus/read/get.rb
CHANGED
|
@@ -6,6 +6,14 @@ module Textus
|
|
|
6
6
|
# For interactive reads that want fetch-on-stale, use
|
|
7
7
|
# `Read::GetOrFetch`, which composes this with the orchestrator.
|
|
8
8
|
class Get
|
|
9
|
+
extend Textus::Contract::DSL
|
|
10
|
+
|
|
11
|
+
verb :get
|
|
12
|
+
summary "Read one entry. Returns the envelope (uid, etag, _meta, body, freshness)."
|
|
13
|
+
surfaces :cli, :ruby, :mcp
|
|
14
|
+
arg :key, String, required: true, positional: true
|
|
15
|
+
response(&:to_h_for_wire)
|
|
16
|
+
|
|
9
17
|
def initialize(container:, call:, evaluator: Textus::Domain::Freshness::Evaluator)
|
|
10
18
|
@container = container
|
|
11
19
|
@call = call
|
data/lib/textus/read/list.rb
CHANGED
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
class List
|
|
4
|
+
extend Textus::Contract::DSL
|
|
5
|
+
|
|
6
|
+
verb :list
|
|
7
|
+
summary "List keys filtered by zone and/or prefix."
|
|
8
|
+
surfaces :cli, :ruby, :mcp
|
|
9
|
+
arg :prefix, String
|
|
10
|
+
arg :zone, String
|
|
11
|
+
|
|
4
12
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
13
|
@manifest = container.manifest
|
|
6
14
|
end
|
data/lib/textus/read/pulse.rb
CHANGED
|
@@ -7,6 +7,13 @@ module Textus
|
|
|
7
7
|
# APIs; pulse is sugar with a stable envelope shape and a monotonic
|
|
8
8
|
# cursor (seq).
|
|
9
9
|
class Pulse
|
|
10
|
+
extend Textus::Contract::DSL
|
|
11
|
+
|
|
12
|
+
verb :pulse
|
|
13
|
+
summary "Delta since cursor — changed entries, stale, pending proposals, doctor summary."
|
|
14
|
+
surfaces :cli, :ruby, :mcp
|
|
15
|
+
arg :since, Integer, session_default: :cursor, description: "audit seq to diff from; defaults to the session cursor"
|
|
16
|
+
|
|
10
17
|
def initialize(container:, call:)
|
|
11
18
|
@container = container
|
|
12
19
|
@call = call
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Read
|
|
3
|
+
# Effective rule set (fetch + guard) for a key. Was the inlined MCP
|
|
4
|
+
# `rules` tool; promoted to a first-class verb so MCP is a pure projection
|
|
5
|
+
# (ADR 0039).
|
|
6
|
+
class Rules
|
|
7
|
+
extend Textus::Contract::DSL
|
|
8
|
+
|
|
9
|
+
verb :rules
|
|
10
|
+
summary "Return effective rules for a key (fetch, guard, ...)."
|
|
11
|
+
surfaces :ruby, :mcp
|
|
12
|
+
arg :key, String, required: true, positional: true
|
|
13
|
+
|
|
14
|
+
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
15
|
+
@manifest = container.manifest
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(key)
|
|
19
|
+
set = @manifest.rules.for(key)
|
|
20
|
+
{ "fetch" => set.fetch&.to_h, "guard" => set.guard }.compact
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Read
|
|
3
3
|
class SchemaEnvelope
|
|
4
|
+
extend Textus::Contract::DSL
|
|
5
|
+
|
|
6
|
+
verb :schema
|
|
7
|
+
summary "Return the schema (field shape) for an entry's family, by key."
|
|
8
|
+
surfaces :ruby, :mcp
|
|
9
|
+
arg :key, String, required: true, positional: true
|
|
10
|
+
|
|
4
11
|
def initialize(container:, call: nil) # rubocop:disable Lint/UnusedMethodArgument
|
|
5
12
|
@manifest = container.manifest
|
|
6
13
|
@schemas = container.schemas
|
data/lib/textus/role.rb
CHANGED
|
@@ -2,9 +2,13 @@ module Textus
|
|
|
2
2
|
module Role
|
|
3
3
|
PATTERN = /\A[a-z][a-z0-9_-]*\z/
|
|
4
4
|
DEFAULT = "human".freeze
|
|
5
|
+
# The default acting identity for the MCP transport (ADR 0040): an agent
|
|
6
|
+
# over stdio proposes; it does not inherit the human's authority. CLI
|
|
7
|
+
# callers keep the `human` DEFAULT.
|
|
8
|
+
AGENT = "agent".freeze
|
|
5
9
|
|
|
6
|
-
def self.resolve(root:, flag: nil, env: ENV)
|
|
7
|
-
candidate = flag || env["TEXTUS_ROLE"] || read_file(root) ||
|
|
10
|
+
def self.resolve(root:, flag: nil, env: ENV, default: DEFAULT)
|
|
11
|
+
candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || default
|
|
8
12
|
raise InvalidRole.new(candidate) unless candidate.match?(PATTERN)
|
|
9
13
|
|
|
10
14
|
candidate
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
# The agent session: per-connection (MCP), per-process (CLI), or per-loop
|
|
3
|
+
# (Ruby) orientation state — the audit cursor plus the manifest etag and
|
|
4
|
+
# propose_zone captured at boot. Immutable Data value; advance_cursor
|
|
5
|
+
# returns a new instance. ADR 0036.
|
|
6
|
+
Session = Data.define(:role, :cursor, :propose_zone, :manifest_etag) do
|
|
7
|
+
def advance_cursor(new_cursor) = with(cursor: new_cursor)
|
|
8
|
+
|
|
9
|
+
def check_etag!(observed_etag)
|
|
10
|
+
return if observed_etag == manifest_etag
|
|
11
|
+
|
|
12
|
+
raise Textus::MCP::ContractDrift.new(
|
|
13
|
+
"manifest changed (was #{short_etag(manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
# First 8 hex chars after the "sha256:" prefix — a stable short id for
|
|
20
|
+
# the drift diagnostic. Tolerates non-prefixed values (delete_prefix is
|
|
21
|
+
# a no-op when the prefix is absent).
|
|
22
|
+
def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
|
|
23
|
+
end
|
|
24
|
+
end
|
data/lib/textus/store.rb
CHANGED
|
@@ -50,6 +50,17 @@ module Textus
|
|
|
50
50
|
@container ||= Textus::Container.from_store(self)
|
|
51
51
|
end
|
|
52
52
|
|
|
53
|
+
# Build an agent Session oriented at the current cursor/manifest — the
|
|
54
|
+
# Ruby equivalent of an MCP `initialize`. ADR 0036.
|
|
55
|
+
def session(role:)
|
|
56
|
+
Textus::Session.new(
|
|
57
|
+
role: role,
|
|
58
|
+
cursor: audit_log.latest_seq,
|
|
59
|
+
propose_zone: manifest.policy.propose_zone_for(role),
|
|
60
|
+
manifest_etag: file_store.etag(File.join(root, "manifest.yaml")),
|
|
61
|
+
)
|
|
62
|
+
end
|
|
63
|
+
|
|
53
64
|
def as(role, dry_run: false, correlation_id: nil)
|
|
54
65
|
RoleScope.new(container: container, role: role, dry_run: dry_run, correlation_id: correlation_id)
|
|
55
66
|
end
|
data/lib/textus/version.rb
CHANGED
data/lib/textus/write/accept.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Textus
|
|
|
18
18
|
guard.for(:accept, target).check!(
|
|
19
19
|
Textus::Domain::Policy::Evaluation.new(
|
|
20
20
|
actor: @call.role, transition: :accept, origin: pending_key,
|
|
21
|
-
target: target, envelope: env,
|
|
21
|
+
target: target, envelope: env, manifest: @manifest
|
|
22
22
|
),
|
|
23
23
|
)
|
|
24
24
|
|
data/lib/textus/write/delete.rb
CHANGED
|
@@ -36,7 +36,7 @@ module Textus
|
|
|
36
36
|
def eval_for(transition, target_key:, envelope: nil)
|
|
37
37
|
Textus::Domain::Policy::Evaluation.new(
|
|
38
38
|
actor: @call.role, transition: transition, origin: nil,
|
|
39
|
-
target: target_key, envelope: envelope,
|
|
39
|
+
target: target_key, envelope: envelope, manifest: @manifest
|
|
40
40
|
)
|
|
41
41
|
end
|
|
42
42
|
|
|
@@ -1,6 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Write
|
|
3
3
|
class FetchAll
|
|
4
|
+
extend Textus::Contract::DSL
|
|
5
|
+
|
|
6
|
+
verb :fetch_all
|
|
7
|
+
summary "Fetch all stale quarantine entries, optionally scoped by zone/prefix."
|
|
8
|
+
surfaces :cli, :ruby, :mcp
|
|
9
|
+
arg :prefix, String
|
|
10
|
+
arg :zone, String
|
|
11
|
+
|
|
4
12
|
def initialize(container:, call:)
|
|
5
13
|
@container = container
|
|
6
14
|
@call = call
|
|
@@ -3,6 +3,14 @@ require "timeout"
|
|
|
3
3
|
module Textus
|
|
4
4
|
module Write
|
|
5
5
|
class FetchWorker
|
|
6
|
+
extend Textus::Contract::DSL
|
|
7
|
+
|
|
8
|
+
verb :fetch
|
|
9
|
+
summary "Run a fetch action for one quarantine entry."
|
|
10
|
+
surfaces :cli, :ruby, :mcp
|
|
11
|
+
arg :key, String, required: true, positional: true
|
|
12
|
+
response { |outcome| { "outcome" => outcome.class.name.split("::").last.downcase } }
|
|
13
|
+
|
|
6
14
|
FETCH_TIMEOUT_SECONDS = IntakeFetch::FETCH_TIMEOUT_SECONDS
|
|
7
15
|
|
|
8
16
|
def initialize(container:, call:)
|
|
@@ -101,7 +109,7 @@ module Textus
|
|
|
101
109
|
).for(:fetch, key).check!(
|
|
102
110
|
Textus::Domain::Policy::Evaluation.new(
|
|
103
111
|
actor: @call.role, transition: :fetch, origin: nil,
|
|
104
|
-
target: key, envelope: nil,
|
|
112
|
+
target: key, envelope: nil, manifest: @manifest
|
|
105
113
|
),
|
|
106
114
|
)
|
|
107
115
|
envelope = writer.put(
|
data/lib/textus/write/mv.rb
CHANGED
|
@@ -109,7 +109,7 @@ module Textus
|
|
|
109
109
|
def eval_for(transition, target_key:, envelope: nil)
|
|
110
110
|
Textus::Domain::Policy::Evaluation.new(
|
|
111
111
|
actor: @call.role, transition: transition, origin: nil,
|
|
112
|
-
target: target_key, envelope: envelope,
|
|
112
|
+
target: target_key, envelope: envelope, manifest: @manifest
|
|
113
113
|
)
|
|
114
114
|
end
|
|
115
115
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Write
|
|
3
|
+
# Queue a proposal: resolve the acting role's propose_zone, prefix the key,
|
|
4
|
+
# and write there via the Put verb. Was inlined in the MCP `propose` tool
|
|
5
|
+
# and the CLI propose verb; promoted to a first-class verb so all three
|
|
6
|
+
# transports share one implementation (ADR 0036, ADR 0039).
|
|
7
|
+
class Propose
|
|
8
|
+
extend Textus::Contract::DSL
|
|
9
|
+
|
|
10
|
+
verb :propose
|
|
11
|
+
summary "Write a proposal to the role's propose_zone. Auto-prefixes the key."
|
|
12
|
+
surfaces :cli, :ruby, :mcp
|
|
13
|
+
arg :key, String, required: true, positional: true,
|
|
14
|
+
description: "key relative to propose_zone, e.g. 'decisions.feature-x'"
|
|
15
|
+
arg :meta, Hash, required: true
|
|
16
|
+
arg :body, String
|
|
17
|
+
arg :content, Hash
|
|
18
|
+
response { |env| { "uid" => env.uid, "etag" => env.etag, "key" => env.key } }
|
|
19
|
+
|
|
20
|
+
def initialize(container:, call:)
|
|
21
|
+
@container = container
|
|
22
|
+
@call = call
|
|
23
|
+
@manifest = container.manifest
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
# if_etag is intentionally absent: a proposal is always a fresh queue write.
|
|
27
|
+
def call(key, meta: nil, body: nil, content: nil)
|
|
28
|
+
zone = @manifest.policy.propose_zone_for(@call.role)
|
|
29
|
+
unless zone
|
|
30
|
+
raise Textus::Error.new(
|
|
31
|
+
"propose_forbidden",
|
|
32
|
+
"role '#{@call.role}' has no writable propose_zone",
|
|
33
|
+
details: { "role" => @call.role },
|
|
34
|
+
hint: "the manifest must define a queue zone and '#{@call.role}' must hold the 'propose' capability",
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
Textus::Dispatcher.invoke(
|
|
39
|
+
:put, container: @container, call: @call,
|
|
40
|
+
args: ["#{zone}.#{key}"],
|
|
41
|
+
kwargs: { meta: meta || {}, body: body, content: content }
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/textus/write/put.rb
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Write
|
|
3
3
|
class Put
|
|
4
|
+
extend Textus::Contract::DSL
|
|
5
|
+
|
|
6
|
+
verb :put
|
|
7
|
+
summary "Create or update an entry. Schema-validated. Returns {uid, etag}."
|
|
8
|
+
surfaces :cli, :ruby, :mcp
|
|
9
|
+
arg :key, String, required: true, positional: true
|
|
10
|
+
arg :meta, Hash, required: true
|
|
11
|
+
arg :body, String
|
|
12
|
+
arg :content, Hash
|
|
13
|
+
arg :if_etag, String
|
|
14
|
+
response { |env| { "uid" => env.uid, "etag" => env.etag } }
|
|
15
|
+
|
|
4
16
|
def initialize(container:, call:)
|
|
5
17
|
@container = container
|
|
6
18
|
@call = call
|
|
@@ -41,7 +53,7 @@ module Textus
|
|
|
41
53
|
def eval_for(transition, target_key:, envelope: nil)
|
|
42
54
|
Textus::Domain::Policy::Evaluation.new(
|
|
43
55
|
actor: @call.role, transition: transition, origin: nil,
|
|
44
|
-
target: target_key, envelope: envelope,
|
|
56
|
+
target: target_key, envelope: envelope, manifest: @manifest
|
|
45
57
|
)
|
|
46
58
|
end
|
|
47
59
|
|
data/lib/textus/write/reject.rb
CHANGED
|
@@ -13,7 +13,7 @@ module Textus
|
|
|
13
13
|
guard.for(:reject, pending_key).check!(
|
|
14
14
|
Textus::Domain::Policy::Evaluation.new(
|
|
15
15
|
actor: @call.role, transition: :reject, origin: pending_key,
|
|
16
|
-
target: pending_key, envelope: nil,
|
|
16
|
+
target: pending_key, envelope: nil, manifest: @manifest
|
|
17
17
|
),
|
|
18
18
|
)
|
|
19
19
|
|
data/lib/textus.rb
CHANGED
|
@@ -20,6 +20,10 @@ loader.ignore(File.expand_path("textus/mcp/errors.rb", __dir__))
|
|
|
20
20
|
loader.setup
|
|
21
21
|
loader.eager_load
|
|
22
22
|
|
|
23
|
+
# Derive CLI_VERBS after eager_load so all contract-declaring files are present
|
|
24
|
+
# (boot.rb loads first alphabetically; Dispatcher contracts are declared later).
|
|
25
|
+
Textus::Boot::CLI_VERBS = Textus::Boot.build_cli_verbs.freeze
|
|
26
|
+
|
|
23
27
|
module Textus
|
|
24
28
|
@hook_mutex = Mutex.new
|
|
25
29
|
@hook_blocks = []
|