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.
Files changed (53) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +109 -1
  3. data/README.md +7 -8
  4. data/SPEC.md +8 -2
  5. data/docs/conventions.md +1 -1
  6. data/lib/textus/boot.rb +41 -21
  7. data/lib/textus/cli/verb/mcp_serve.rb +8 -3
  8. data/lib/textus/cli/verb/propose.rb +28 -0
  9. data/lib/textus/cli/verb/pulse.rb +12 -3
  10. data/lib/textus/cli/verb/schema.rb +1 -1
  11. data/lib/textus/cli/verb.rb +3 -2
  12. data/lib/textus/contract.rb +106 -0
  13. data/lib/textus/cursor_store.rb +24 -0
  14. data/lib/textus/dispatcher.rb +3 -1
  15. data/lib/textus/doctor/check/audit_log.rb +1 -1
  16. data/lib/textus/doctor/check/fetch_locks.rb +2 -2
  17. data/lib/textus/domain/policy/evaluation.rb +3 -6
  18. data/lib/textus/init.rb +4 -0
  19. data/lib/textus/layout.rb +41 -0
  20. data/lib/textus/maintenance/key_delete_prefix.rb +9 -0
  21. data/lib/textus/maintenance/key_mv_prefix.rb +10 -0
  22. data/lib/textus/maintenance/migrate.rb +9 -0
  23. data/lib/textus/maintenance/rule_lint.rb +8 -0
  24. data/lib/textus/maintenance/zone_mv.rb +10 -0
  25. data/lib/textus/mcp/catalog.rb +72 -0
  26. data/lib/textus/mcp/server.rb +8 -5
  27. data/lib/textus/mcp/session.rb +3 -20
  28. data/lib/textus/mcp/tool_schemas.rb +6 -62
  29. data/lib/textus/mcp/tools.rb +4 -119
  30. data/lib/textus/ports/audit_log.rb +17 -15
  31. data/lib/textus/ports/build_lock.rb +1 -2
  32. data/lib/textus/ports/fetch/lock.rb +1 -1
  33. data/lib/textus/read/audit.rb +3 -3
  34. data/lib/textus/read/boot.rb +6 -0
  35. data/lib/textus/read/get.rb +8 -0
  36. data/lib/textus/read/list.rb +8 -0
  37. data/lib/textus/read/pulse.rb +7 -0
  38. data/lib/textus/read/rules.rb +24 -0
  39. data/lib/textus/read/schema_envelope.rb +7 -0
  40. data/lib/textus/role.rb +6 -2
  41. data/lib/textus/session.rb +24 -0
  42. data/lib/textus/store.rb +11 -0
  43. data/lib/textus/version.rb +1 -1
  44. data/lib/textus/write/accept.rb +1 -1
  45. data/lib/textus/write/delete.rb +1 -1
  46. data/lib/textus/write/fetch_all.rb +8 -0
  47. data/lib/textus/write/fetch_worker.rb +9 -1
  48. data/lib/textus/write/mv.rb +1 -1
  49. data/lib/textus/write/propose.rb +46 -0
  50. data/lib/textus/write/put.rb +13 -1
  51. data/lib/textus/write/reject.rb +1 -1
  52. data/lib/textus.rb +4 -0
  53. 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
@@ -49,8 +49,11 @@ module Textus
49
49
  end
50
50
 
51
51
  def handle_initialize(rid, _params)
52
- proposer = @store.manifest.policy.proposer_role
53
- propose_zone = @store.manifest.policy.propose_zone_for(proposer)
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" => ToolSchemas.all })
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 = Tools.call(name, session: @session, store: @store, args: args)
84
- @session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "tick"
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) }],
@@ -1,24 +1,7 @@
1
1
  module Textus
2
2
  module MCP
3
- # Per-connection state held by the server. Immutable Data value;
4
- # advance_cursor returns a new instance via #with.
5
- Session = Data.define(:role, :cursor, :propose_zone, :manifest_etag) do
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
- # JSON-Schema definitions for every MCP tool's inputSchema. Returned by
4
- # the server in tools/list. Static today a follow-up will enrich with
5
- # manifest-derived enums for `zone`, `key`, etc.
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 # rubocop:disable Metrics/MethodLength
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
@@ -1,129 +1,14 @@
1
1
  module Textus
2
2
  module MCP
3
- # Dispatch table for MCP tool names → implementations. Each implementation
4
- # receives (session:, store:, args:) and returns a JSON-encodable value.
5
- # Tool errors are wrapped in ToolError; ContractDrift / CursorExpired
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
- impl = REGISTRY[name] or raise ToolError.new("unknown tool: #{name}")
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 = File.join(root, "audit.log")
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 = File.join(@root, "audit.log.#{n}.meta.json")
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
- oldest = File.join(@root, "audit.log.#{@keep}")
155
- oldest_meta = File.join(@root, "audit.log.#{@keep}.meta.json")
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
- src = File.join(@root, "audit.log.#{n}")
162
- dst = File.join(@root, "audit.log.#{n + 1}")
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, File.join(@root, "audit.log.1"))
172
- File.write(File.join(@root, "audit.log.1.meta.json"), JSON.generate(meta) + "\n")
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 = File.join(root, LOCK_FILENAME)
15
+ @path = Textus::Layout.build_lock(root)
17
16
  @file = nil
18
17
  end
19
18
 
@@ -7,7 +7,7 @@ module Textus
7
7
  def initialize(root:, key:)
8
8
  @root = root
9
9
  @key = key
10
- @path = File.join(root, ".locks", "#{safe_key}.lock")
10
+ @path = File.join(Textus::Layout.locks(root), "#{safe_key}.lock")
11
11
  @file = nil
12
12
  end
13
13
 
@@ -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 = File.join(container.root, "audit.log")
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] : []
@@ -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
@@ -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
@@ -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
@@ -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