textus 0.35.1 → 0.38.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 +109 -1
- data/README.md +7 -8
- data/SPEC.md +8 -2
- data/docs/conventions.md +1 -1
- 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/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/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 +13 -4
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
# Single source of truth for every path textus owns under a store root.
|
|
3
|
+
# All disposable runtime state nests under <root>/.run/ so the
|
|
4
|
+
# tracked/disposable boundary is a directory boundary. ADR 0038.
|
|
5
|
+
module Layout
|
|
6
|
+
RUN = ".run"
|
|
7
|
+
|
|
8
|
+
def self.run(root)
|
|
9
|
+
File.join(root, RUN)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.state(root)
|
|
13
|
+
File.join(run(root), "state")
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def self.cursor(root, role)
|
|
17
|
+
File.join(state(root), "cursor.#{role}")
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.locks(root)
|
|
21
|
+
File.join(run(root), "locks")
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.build_lock(root)
|
|
25
|
+
File.join(run(root), "build.lock")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.audit_dir(root)
|
|
29
|
+
File.join(run(root), "audit")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.audit_log(root)
|
|
33
|
+
File.join(audit_dir(root), "audit.log")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
GITIGNORE = <<~GITIGNORE
|
|
37
|
+
# textus runtime artifacts — safe to delete, never commit
|
|
38
|
+
#{RUN}/
|
|
39
|
+
GITIGNORE
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -2,6 +2,15 @@ module Textus
|
|
|
2
2
|
module Maintenance
|
|
3
3
|
# Bulk-delete every leaf key under `prefix`.
|
|
4
4
|
class KeyDeletePrefix
|
|
5
|
+
extend Textus::Contract::DSL
|
|
6
|
+
|
|
7
|
+
verb :key_delete_prefix
|
|
8
|
+
summary "Bulk-delete every leaf key under prefix."
|
|
9
|
+
surfaces :cli, :ruby, :mcp
|
|
10
|
+
arg :prefix, String, required: true
|
|
11
|
+
arg :dry_run, :boolean
|
|
12
|
+
response(&:to_h)
|
|
13
|
+
|
|
5
14
|
def initialize(container:, call:)
|
|
6
15
|
@container = container
|
|
7
16
|
@call = call
|
|
@@ -3,6 +3,16 @@ module Textus
|
|
|
3
3
|
# Bulk-rename every leaf key under `from_prefix` to `to_prefix`.
|
|
4
4
|
# Calls Write::Mv directly for each entry — emits one audit row per file moved.
|
|
5
5
|
class KeyMvPrefix
|
|
6
|
+
extend Textus::Contract::DSL
|
|
7
|
+
|
|
8
|
+
verb :key_mv_prefix
|
|
9
|
+
summary "Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false."
|
|
10
|
+
surfaces :cli, :ruby, :mcp
|
|
11
|
+
arg :from_prefix, String, required: true
|
|
12
|
+
arg :to_prefix, String, required: true
|
|
13
|
+
arg :dry_run, :boolean
|
|
14
|
+
response(&:to_h)
|
|
15
|
+
|
|
6
16
|
def initialize(container:, call:)
|
|
7
17
|
@container = container
|
|
8
18
|
@call = call
|
|
@@ -5,6 +5,15 @@ module Textus
|
|
|
5
5
|
# Loads a YAML migration plan and dispatches each op to the
|
|
6
6
|
# appropriate Maintenance use case. Concatenates resulting Plans.
|
|
7
7
|
class Migrate
|
|
8
|
+
extend Textus::Contract::DSL
|
|
9
|
+
|
|
10
|
+
verb :migrate
|
|
11
|
+
summary "Run a YAML migration plan (multi-op)."
|
|
12
|
+
surfaces :cli, :ruby, :mcp
|
|
13
|
+
arg :plan_yaml, String, required: true
|
|
14
|
+
arg :dry_run, :boolean
|
|
15
|
+
response(&:to_h)
|
|
16
|
+
|
|
8
17
|
def initialize(container:, call:)
|
|
9
18
|
@container = container
|
|
10
19
|
@call = call
|
|
@@ -6,6 +6,14 @@ module Textus
|
|
|
6
6
|
# YAML string. Returns a Plan describing rule additions/removals/
|
|
7
7
|
# changes. Does NOT write anything.
|
|
8
8
|
class RuleLint
|
|
9
|
+
extend Textus::Contract::DSL
|
|
10
|
+
|
|
11
|
+
verb :rule_lint
|
|
12
|
+
summary "Diff candidate manifest YAML's rules against the live manifest. No writes."
|
|
13
|
+
surfaces :cli, :ruby, :mcp
|
|
14
|
+
arg :candidate_yaml, String, required: true
|
|
15
|
+
response(&:to_h)
|
|
16
|
+
|
|
9
17
|
def initialize(container:, call:)
|
|
10
18
|
@container = container
|
|
11
19
|
@call = call
|
|
@@ -6,6 +6,16 @@ module Textus
|
|
|
6
6
|
# the `zone:` field on every entry under the old zone, and moves
|
|
7
7
|
# every file from zones/<old>/ to zones/<new>/.
|
|
8
8
|
class ZoneMv
|
|
9
|
+
extend Textus::Contract::DSL
|
|
10
|
+
|
|
11
|
+
verb :zone_mv
|
|
12
|
+
summary "Rename a zone — manifest + files. Refuses if destination exists."
|
|
13
|
+
surfaces :cli, :ruby, :mcp
|
|
14
|
+
arg :from, String, required: true
|
|
15
|
+
arg :to, String, required: true
|
|
16
|
+
arg :dry_run, :boolean
|
|
17
|
+
response(&:to_h)
|
|
18
|
+
|
|
9
19
|
def initialize(container:, call:)
|
|
10
20
|
@container = container
|
|
11
21
|
@call = call
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module MCP
|
|
3
|
+
# Derives the entire MCP tool surface from the per-verb contracts
|
|
4
|
+
# (ADR 0039). `tool_schemas` feeds tools/list; `call` is the generic
|
|
5
|
+
# tools/call dispatch: map JSON args -> (positional, keyword) per the
|
|
6
|
+
# contract, invoke the verb through the role scope, then shape the
|
|
7
|
+
# return value with the contract's response block. No per-tool code.
|
|
8
|
+
module Catalog
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Contracts of every MCP-surfaced verb, in Dispatcher order.
|
|
12
|
+
def specs
|
|
13
|
+
Textus::Dispatcher::VERBS.values
|
|
14
|
+
.select { |k| k.respond_to?(:contract?) && k.contract? && k.contract.mcp? }
|
|
15
|
+
.map(&:contract)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def tool_schemas
|
|
19
|
+
specs.map do |s|
|
|
20
|
+
{ name: s.verb.to_s, description: s.summary, inputSchema: s.input_schema }
|
|
21
|
+
end.freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def names
|
|
25
|
+
specs.map { |s| s.verb.to_s }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def call(name, session:, store:, args:)
|
|
29
|
+
klass = Textus::Dispatcher::VERBS[name.to_sym]
|
|
30
|
+
raise ToolError.new("unknown tool: #{name}") unless klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
|
|
31
|
+
|
|
32
|
+
spec = klass.contract
|
|
33
|
+
pos, kw = map_args(spec, args || {}, session)
|
|
34
|
+
result = store.as(session.role).public_send(spec.verb, *pos, **kw)
|
|
35
|
+
spec.response.call(result)
|
|
36
|
+
rescue ContractDrift, CursorExpired
|
|
37
|
+
raise
|
|
38
|
+
rescue Textus::Error => e
|
|
39
|
+
raise ToolError.new("#{name}: #{e.message}")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# Splits the raw JSON arg hash into the positional list and keyword hash
|
|
43
|
+
# the use-case expects, validating required presence first.
|
|
44
|
+
# Session-default args (session_default: :method_name) are injected from
|
|
45
|
+
# the session when absent from the wire; they are never treated as missing.
|
|
46
|
+
# Positional args are emitted in contract declaration order; use-case signatures must match.
|
|
47
|
+
def map_args(spec, raw, session = nil)
|
|
48
|
+
missing = spec.required_args.map { |a| a.name.to_s } - raw.keys
|
|
49
|
+
raise ToolError.new("#{spec.verb}: missing #{missing.join(", ")}") unless missing.empty?
|
|
50
|
+
|
|
51
|
+
positional = []
|
|
52
|
+
keyword = {}
|
|
53
|
+
spec.args.each do |a|
|
|
54
|
+
if raw.key?(a.name.to_s)
|
|
55
|
+
value = raw[a.name.to_s]
|
|
56
|
+
elsif a.session_default && session
|
|
57
|
+
value = session.public_send(a.session_default)
|
|
58
|
+
else
|
|
59
|
+
next
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
if a.positional
|
|
63
|
+
positional << value
|
|
64
|
+
else
|
|
65
|
+
keyword[a.name] = value
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
[positional, keyword]
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/textus/mcp/server.rb
CHANGED
|
@@ -49,8 +49,11 @@ module Textus
|
|
|
49
49
|
end
|
|
50
50
|
|
|
51
51
|
def handle_initialize(rid, _params)
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
# The acting role IS the resolved connection role (ADR 0040): the MCP
|
|
53
|
+
# transport defaults to `agent`, which can write the queue, so its
|
|
54
|
+
# propose_zone resolves directly. If a connection's role cannot propose,
|
|
55
|
+
# propose_zone is nil and the `propose` tool reports that honestly.
|
|
56
|
+
propose_zone = @store.manifest.policy.propose_zone_for(@role)
|
|
54
57
|
|
|
55
58
|
@session = Session.new(
|
|
56
59
|
role: @role,
|
|
@@ -67,7 +70,7 @@ module Textus
|
|
|
67
70
|
end
|
|
68
71
|
|
|
69
72
|
def handle_tools_list(rid)
|
|
70
|
-
emit_result(rid, { "tools" =>
|
|
73
|
+
emit_result(rid, { "tools" => Catalog.tool_schemas })
|
|
71
74
|
end
|
|
72
75
|
|
|
73
76
|
def handle_tools_call(rid, params)
|
|
@@ -80,8 +83,8 @@ module Textus
|
|
|
80
83
|
|
|
81
84
|
name = params["name"]
|
|
82
85
|
args = params["arguments"] || {}
|
|
83
|
-
result =
|
|
84
|
-
@session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "
|
|
86
|
+
result = Catalog.call(name, session: @session, store: @store, args: args)
|
|
87
|
+
@session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "pulse"
|
|
85
88
|
|
|
86
89
|
emit_result(rid, {
|
|
87
90
|
"content" => [{ "type" => "text", "text" => JSON.dump(result) }],
|
data/lib/textus/mcp/session.rb
CHANGED
|
@@ -1,24 +1,7 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module MCP
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
Session =
|
|
6
|
-
def advance_cursor(new_cursor) = with(cursor: new_cursor)
|
|
7
|
-
|
|
8
|
-
def check_etag!(observed_etag)
|
|
9
|
-
return if observed_etag == manifest_etag
|
|
10
|
-
|
|
11
|
-
raise ContractDrift.new(
|
|
12
|
-
"manifest changed (was #{short_etag(manifest_etag)}, now #{short_etag(observed_etag)}); re-run boot",
|
|
13
|
-
)
|
|
14
|
-
end
|
|
15
|
-
|
|
16
|
-
private
|
|
17
|
-
|
|
18
|
-
# First 8 hex chars after the "sha256:" prefix — a stable short id for
|
|
19
|
-
# the drift diagnostic. Tolerates non-prefixed values (delete_prefix is
|
|
20
|
-
# a no-op when the prefix is absent).
|
|
21
|
-
def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
|
|
22
|
-
end
|
|
3
|
+
# The session value now lives in core (ADR 0036); retained here as an
|
|
4
|
+
# alias so existing MCP references keep resolving.
|
|
5
|
+
Session = Textus::Session
|
|
23
6
|
end
|
|
24
7
|
end
|
|
@@ -1,70 +1,14 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module MCP
|
|
3
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
3
|
+
# Kept for name stability (ADR 0039). The JSON schemas are DERIVED from
|
|
4
|
+
# per-verb contracts; this delegates to MCP::Catalog. The hand-written
|
|
5
|
+
# array is gone — a kwarg rename now updates the schema automatically (and
|
|
6
|
+
# the signature guard fails if the contract lags the use-case).
|
|
6
7
|
module ToolSchemas
|
|
7
8
|
module_function
|
|
8
9
|
|
|
9
|
-
def all
|
|
10
|
-
|
|
11
|
-
tool("boot", "Return the orientation contract: zones, entries, schemas, write_flows, agent_quickstart.", {}, []),
|
|
12
|
-
tool("tick", "Delta since cursor. Returns {cursor, changed, stale, pending_review, doctor}.",
|
|
13
|
-
{ "since" => { "type" => "integer", "minimum" => 0 } }, []),
|
|
14
|
-
tool("find", "List keys filtered by zone and/or prefix.",
|
|
15
|
-
{ "zone" => { "type" => "string" }, "prefix" => { "type" => "string" } }, []),
|
|
16
|
-
tool("read", "Read one entry. Returns the envelope (uid, etag, _meta, body, freshness).",
|
|
17
|
-
{ "key" => { "type" => "string" } }, ["key"]),
|
|
18
|
-
tool("write", "Create or update an entry. Schema-validated. Returns {uid, etag}.",
|
|
19
|
-
{
|
|
20
|
-
"key" => { "type" => "string" },
|
|
21
|
-
"meta" => { "type" => "object" },
|
|
22
|
-
"body" => { "type" => "string" },
|
|
23
|
-
"content" => { "type" => "object" },
|
|
24
|
-
"if_etag" => { "type" => "string" },
|
|
25
|
-
}, %w[key meta]),
|
|
26
|
-
tool("propose", "Write a proposal to the session's propose_zone. Auto-prefixes the key.",
|
|
27
|
-
{
|
|
28
|
-
"key" => { "type" => "string", "description" => "Key relative to propose_zone, e.g. 'proposal.feature-x'" },
|
|
29
|
-
"meta" => { "type" => "object" },
|
|
30
|
-
"body" => { "type" => "string" },
|
|
31
|
-
}, %w[key meta]),
|
|
32
|
-
tool("fetch", "Run an intake fetch for one key. Returns the fetch Outcome.",
|
|
33
|
-
{ "key" => { "type" => "string" } }, ["key"]),
|
|
34
|
-
tool("fetch_stale", "Fetch all stale intake entries, optionally scoped by zone/prefix.",
|
|
35
|
-
{
|
|
36
|
-
"zone" => { "type" => "string" },
|
|
37
|
-
"prefix" => { "type" => "string" },
|
|
38
|
-
}, []),
|
|
39
|
-
tool("schema", "Return the schema (field shape) for an entry family.",
|
|
40
|
-
{ "family" => { "type" => "string" } }, ["family"]),
|
|
41
|
-
tool("rules", "Return effective rules for a key (fetch, promote, ...).",
|
|
42
|
-
{ "key" => { "type" => "string" } }, ["key"]),
|
|
43
|
-
tool("key_mv_prefix",
|
|
44
|
-
"Bulk-rename every leaf key under from_prefix to to_prefix. Dry-run returns a Plan; apply with dry_run: false.",
|
|
45
|
-
{ "from_prefix" => { "type" => "string" }, "to_prefix" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
|
|
46
|
-
%w[from_prefix to_prefix]),
|
|
47
|
-
tool("key_delete_prefix", "Bulk-delete every leaf key under prefix.",
|
|
48
|
-
{ "prefix" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
|
|
49
|
-
["prefix"]),
|
|
50
|
-
tool("zone_mv", "Rename a zone — manifest + files. Refuses if destination exists.",
|
|
51
|
-
{ "from" => { "type" => "string" }, "to" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
|
|
52
|
-
%w[from to]),
|
|
53
|
-
tool("rule_lint", "Diff candidate manifest YAML's rules against the live manifest. No writes.",
|
|
54
|
-
{ "candidate_yaml" => { "type" => "string" } },
|
|
55
|
-
["candidate_yaml"]),
|
|
56
|
-
tool("migrate", "Run a YAML migration plan (multi-op).",
|
|
57
|
-
{ "plan_yaml" => { "type" => "string" }, "dry_run" => { "type" => "boolean" } },
|
|
58
|
-
["plan_yaml"]),
|
|
59
|
-
].freeze
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def tool(name, description, properties, required)
|
|
63
|
-
{
|
|
64
|
-
name: name,
|
|
65
|
-
description: description,
|
|
66
|
-
inputSchema: { type: "object", properties: properties, required: required },
|
|
67
|
-
}
|
|
10
|
+
def all
|
|
11
|
+
Catalog.tool_schemas
|
|
68
12
|
end
|
|
69
13
|
end
|
|
70
14
|
end
|
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
|