textus 0.54.2 → 0.55.2
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 +38 -1
- data/README.md +17 -10
- data/SPEC.md +39 -11
- data/docs/architecture/README.md +23 -11
- data/docs/reference/conventions.md +5 -2
- data/exe/textus +1 -1
- data/lib/textus/boot.rb +64 -93
- data/lib/textus/dispatch/binder.rb +48 -0
- data/lib/textus/dispatch/contracts.rb +63 -0
- data/lib/textus/dispatch/handler_registry.rb +21 -0
- data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
- data/lib/textus/dispatch/middleware/auth.rb +40 -0
- data/lib/textus/dispatch/middleware/base.rb +26 -0
- data/lib/textus/dispatch/middleware/binder.rb +20 -0
- data/lib/textus/dispatch/middleware/cascade.rb +53 -0
- data/lib/textus/dispatch/pipeline.rb +35 -0
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/generator_drift.rb +4 -4
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +2 -2
- data/lib/textus/doctor/check/schemas.rb +1 -1
- data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
- data/lib/textus/doctor/check/sentinels.rb +4 -4
- data/lib/textus/doctor/check/templates.rb +1 -1
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +9 -10
- data/lib/textus/doctor.rb +2 -2
- data/lib/textus/errors.rb +8 -0
- data/lib/textus/format/base.rb +34 -10
- data/lib/textus/format/json.rb +5 -27
- data/lib/textus/format/markdown.rb +5 -27
- data/lib/textus/format/text.rb +5 -4
- data/lib/textus/format/yaml.rb +30 -27
- data/lib/textus/format.rb +22 -1
- data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
- data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
- data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
- data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
- data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
- data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
- data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
- data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
- data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
- data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
- data/lib/textus/handlers/read/audit_entries.rb +48 -0
- data/lib/textus/handlers/read/blame_entry.rb +71 -0
- data/lib/textus/handlers/read/deps_entry.rb +17 -0
- data/lib/textus/handlers/read/get_entry.rb +68 -0
- data/lib/textus/handlers/read/list_keys.rb +36 -0
- data/lib/textus/handlers/read/pulse_entries.rb +66 -0
- data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
- data/lib/textus/handlers/read/uid_entry.rb +18 -0
- data/lib/textus/handlers/read/where_entry.rb +18 -0
- data/lib/textus/handlers/write/accept_proposal.rb +39 -0
- data/lib/textus/handlers/write/data_mv.rb +55 -0
- data/lib/textus/handlers/write/delete_key.rb +17 -0
- data/lib/textus/handlers/write/enqueue_job.rb +27 -0
- data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
- data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
- data/lib/textus/handlers/write/move_key.rb +80 -0
- data/lib/textus/handlers/write/propose_entry.rb +29 -0
- data/lib/textus/handlers/write/put_entry.rb +29 -0
- data/lib/textus/handlers/write/reject_proposal.rb +29 -0
- data/lib/textus/init.rb +5 -7
- data/lib/textus/jobs.rb +3 -9
- data/lib/textus/manifest/capabilities.rb +4 -4
- data/lib/textus/manifest/entry/base.rb +3 -3
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
- data/lib/textus/manifest/entry/publish/to_paths.rb +2 -2
- data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
- data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
- data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
- data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
- data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
- data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
- data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
- data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
- data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
- data/lib/textus/manifest/policy/predicates.rb +54 -0
- data/lib/textus/manifest/policy/retention.rb +1 -1
- data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
- data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
- data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
- data/lib/textus/manifest/schema/semantics.rb +11 -216
- data/lib/textus/orchestration.rb +55 -0
- data/lib/textus/{ports → port}/audit_log.rb +46 -6
- data/lib/textus/{ports → port}/build_lock.rb +2 -2
- data/lib/textus/{ports → port}/clock.rb +1 -1
- data/lib/textus/{ports → port}/publisher.rb +5 -5
- data/lib/textus/{core → port}/sentinel.rb +1 -6
- data/lib/textus/{ports → port}/sentinel_store.rb +4 -4
- data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
- data/lib/textus/port/storage/file_store.rb +49 -0
- data/lib/textus/port/storage/interface.rb +17 -0
- data/lib/textus/port/store.rb +149 -0
- data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
- data/lib/textus/produce/engine.rb +1 -11
- data/lib/textus/produce/publisher.rb +21 -0
- data/lib/textus/schema/registry.rb +42 -0
- data/lib/textus/schema/tools.rb +7 -10
- data/lib/textus/store/container.rb +173 -0
- data/lib/textus/store/cursor.rb +26 -0
- data/lib/textus/store/entry/reader.rb +47 -0
- data/lib/textus/store/entry/writer.rb +219 -0
- data/lib/textus/store/envelope/meta.rb +61 -0
- data/lib/textus/store/freshness/drift_detector.rb +93 -0
- data/lib/textus/store/freshness/evaluator.rb +20 -0
- data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
- data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
- data/lib/textus/store/freshness.rb +8 -0
- data/lib/textus/store/index/builder.rb +76 -0
- data/lib/textus/store/index/lookup.rb +60 -0
- data/lib/textus/store/jobs/base.rb +13 -0
- data/lib/textus/store/jobs/index.rb +15 -0
- data/lib/textus/store/jobs/materialize.rb +15 -0
- data/lib/textus/store/jobs/plan.rb +11 -0
- data/lib/textus/store/jobs/planner.rb +124 -0
- data/lib/textus/store/jobs/queue.rb +162 -0
- data/lib/textus/store/jobs/registry.rb +19 -0
- data/lib/textus/store/jobs/retention/base.rb +52 -0
- data/lib/textus/store/jobs/retention/sweep.rb +55 -0
- data/lib/textus/store/jobs/retention.rb +8 -0
- data/lib/textus/store/jobs/sweep.rb +21 -0
- data/lib/textus/store/jobs/worker.rb +64 -0
- data/lib/textus/store/layout.rb +97 -0
- data/lib/textus/store.rb +63 -32
- data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
- data/lib/textus/{surfaces → surface}/cli/runner.rb +14 -68
- data/lib/textus/surface/cli/sources.rb +41 -0
- data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +5 -6
- data/lib/textus/{surfaces → surface}/cli/verb/get.rb +7 -5
- data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
- data/lib/textus/surface/cli/verb/put.rb +27 -0
- data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
- data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
- data/lib/textus/{surfaces → surface}/cli.rb +1 -1
- data/lib/textus/surface/mcp/catalog.rb +58 -0
- data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -11
- data/lib/textus/surface/mcp/projector.rb +20 -0
- data/lib/textus/{surfaces → surface}/mcp/server.rb +24 -35
- data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
- data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
- data/lib/textus/value/call.rb +30 -0
- data/lib/textus/value/command.rb +16 -0
- data/lib/textus/{core → value}/duration.rb +1 -4
- data/lib/textus/value/envelope.rb +90 -0
- data/lib/textus/value/etag.rb +39 -0
- data/lib/textus/value/payload.rb +7 -0
- data/lib/textus/value/result.rb +46 -0
- data/lib/textus/value/role.rb +38 -0
- data/lib/textus/value/types.rb +13 -0
- data/lib/textus/{uid.rb → value/uid.rb} +9 -7
- data/lib/textus/verb_registry.rb +417 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/loader.rb +4 -4
- data/lib/textus/workflow/runner.rb +11 -33
- data/lib/textus.rb +4 -69
- metadata +142 -105
- data/lib/textus/action/accept.rb +0 -44
- data/lib/textus/action/audit.rb +0 -131
- data/lib/textus/action/base.rb +0 -42
- data/lib/textus/action/blame.rb +0 -97
- data/lib/textus/action/boot.rb +0 -17
- data/lib/textus/action/data_mv.rb +0 -69
- data/lib/textus/action/deps.rb +0 -34
- data/lib/textus/action/doctor.rb +0 -24
- data/lib/textus/action/drain.rb +0 -39
- data/lib/textus/action/enqueue.rb +0 -53
- data/lib/textus/action/get.rb +0 -78
- data/lib/textus/action/ingest.rb +0 -203
- data/lib/textus/action/jobs.rb +0 -36
- data/lib/textus/action/key_delete.rb +0 -36
- data/lib/textus/action/key_delete_prefix.rb +0 -44
- data/lib/textus/action/key_mv.rb +0 -129
- data/lib/textus/action/key_mv_prefix.rb +0 -57
- data/lib/textus/action/list.rb +0 -42
- data/lib/textus/action/propose.rb +0 -52
- data/lib/textus/action/published.rb +0 -24
- data/lib/textus/action/pulse.rb +0 -56
- data/lib/textus/action/put.rb +0 -52
- data/lib/textus/action/rdeps.rb +0 -39
- data/lib/textus/action/reject.rb +0 -37
- data/lib/textus/action/rule_explain.rb +0 -91
- data/lib/textus/action/rule_lint.rb +0 -68
- data/lib/textus/action/rule_list.rb +0 -42
- data/lib/textus/action/schema_envelope.rb +0 -29
- data/lib/textus/action/uid.rb +0 -33
- data/lib/textus/action/where.rb +0 -36
- data/lib/textus/action/write_verb.rb +0 -44
- data/lib/textus/call.rb +0 -28
- data/lib/textus/command.rb +0 -41
- data/lib/textus/container.rb +0 -26
- data/lib/textus/contract/around.rb +0 -29
- data/lib/textus/contract/binder.rb +0 -88
- data/lib/textus/contract/resources/build_lock.rb +0 -17
- data/lib/textus/contract/resources/cursor.rb +0 -26
- data/lib/textus/contract/sources.rb +0 -39
- data/lib/textus/contract/view.rb +0 -15
- data/lib/textus/contract.rb +0 -174
- data/lib/textus/core/freshness/evaluator.rb +0 -150
- data/lib/textus/core/freshness.rb +0 -11
- data/lib/textus/core/retention/sweep.rb +0 -57
- data/lib/textus/core/retention.rb +0 -11
- data/lib/textus/cursor_store.rb +0 -24
- data/lib/textus/envelope/reader.rb +0 -46
- data/lib/textus/envelope/writer.rb +0 -209
- data/lib/textus/envelope.rb +0 -79
- data/lib/textus/etag.rb +0 -36
- data/lib/textus/gate/auth.rb +0 -227
- data/lib/textus/gate.rb +0 -116
- data/lib/textus/jobs/base.rb +0 -23
- data/lib/textus/jobs/materialize.rb +0 -20
- data/lib/textus/jobs/plan.rb +0 -9
- data/lib/textus/jobs/planner.rb +0 -101
- data/lib/textus/jobs/retention.rb +0 -48
- data/lib/textus/jobs/sweep.rb +0 -27
- data/lib/textus/jobs/worker.rb +0 -67
- data/lib/textus/layout.rb +0 -91
- data/lib/textus/ports/job_store/job.rb +0 -65
- data/lib/textus/ports/job_store.rb +0 -123
- data/lib/textus/ports/raw_index.rb +0 -61
- data/lib/textus/ports/storage/file_store.rb +0 -26
- data/lib/textus/role.rb +0 -36
- data/lib/textus/schemas.rb +0 -54
- data/lib/textus/session.rb +0 -35
- data/lib/textus/surfaces/cli/verb/put.rb +0 -30
- data/lib/textus/surfaces/mcp/catalog.rb +0 -111
- data/lib/textus/surfaces/role_scope.rb +0 -34
- data/lib/textus/types.rb +0 -15
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Surface
|
|
3
3
|
class CLI
|
|
4
4
|
class Verb
|
|
5
5
|
class Watch < Verb
|
|
@@ -8,7 +8,7 @@ module Textus
|
|
|
8
8
|
option :poll, "--poll=SECONDS"
|
|
9
9
|
|
|
10
10
|
def call(store)
|
|
11
|
-
watcher = Textus::
|
|
11
|
+
watcher = Textus::Surface::Watcher.new(container: store.container)
|
|
12
12
|
watcher.run(poll: poll&.to_f)
|
|
13
13
|
0
|
|
14
14
|
end
|
|
@@ -2,7 +2,7 @@ require "json"
|
|
|
2
2
|
require "optparse"
|
|
3
3
|
|
|
4
4
|
module Textus
|
|
5
|
-
module
|
|
5
|
+
module Surface
|
|
6
6
|
class CLI
|
|
7
7
|
# Subclasses must implement #call(store) and return an integer exit code.
|
|
8
8
|
# Use #emit(obj) for normal JSON output (returns 0).
|
|
@@ -93,14 +93,9 @@ module Textus
|
|
|
93
93
|
# Resolves the active role for this invocation. Honors the verb's
|
|
94
94
|
# `--as` flag if declared, then TEXTUS_ROLE, then the project default.
|
|
95
95
|
# Pass `default:` to override the fallback (e.g. MCPServe uses AGENT).
|
|
96
|
-
def resolved_role(store, default: Role::DEFAULT)
|
|
96
|
+
def resolved_role(store, default: Value::Role::DEFAULT)
|
|
97
97
|
flag = respond_to?(:as_flag) ? as_flag : nil
|
|
98
|
-
Role.resolve(flag: flag, env: ENV, root: store.root, default: default)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# Builds a Command from spec + inputs and dispatches through Gate.
|
|
102
|
-
def gate_dispatch(cmd, store)
|
|
103
|
-
store.gate.dispatch(cmd)
|
|
98
|
+
Value::Role.resolve(flag: flag, env: ENV, root: store.root, default: default)
|
|
104
99
|
end
|
|
105
100
|
|
|
106
101
|
# The input stream — the source for a `cli_stdin` envelope (ADR 0068).
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Surface
|
|
3
|
+
module MCP
|
|
4
|
+
module Catalog
|
|
5
|
+
PROJECTOR = Projector.new(view_key: :default).freeze
|
|
6
|
+
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
def specs
|
|
10
|
+
VerbRegistry.registered.select(&:mcp?)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def build_tools(mcp_server)
|
|
14
|
+
specs.map do |spec|
|
|
15
|
+
schema = spec.input_schema
|
|
16
|
+
schema = schema.reject { |k, v| k == :required && Array(v).empty? }
|
|
17
|
+
::MCP::Tool.define(
|
|
18
|
+
name: spec.verb.to_s,
|
|
19
|
+
description: spec.summary,
|
|
20
|
+
input_schema: schema,
|
|
21
|
+
) do |server_context:, **args|
|
|
22
|
+
mcp_server.dispatch(spec.verb, args, server_context)
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def names
|
|
28
|
+
specs.map(&:verb).map(&:to_s)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def read_verbs
|
|
32
|
+
VerbRegistry.registered
|
|
33
|
+
.select { |s| s.read? && s.mcp? }
|
|
34
|
+
.map { |s| s.verb.to_s }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def write_verbs
|
|
38
|
+
VerbRegistry.registered
|
|
39
|
+
.select { |s| s.write? && s.mcp? }
|
|
40
|
+
.map { |s| s.verb.to_s }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call(name, store:, args:)
|
|
44
|
+
spec = VerbRegistry.for(name.to_sym)
|
|
45
|
+
raise ToolError.new("unknown tool: #{name}") unless spec&.mcp?
|
|
46
|
+
|
|
47
|
+
PROJECTOR.dispatch(name, inputs: args, store:)
|
|
48
|
+
rescue Textus::Dispatch::MissingArgs => e
|
|
49
|
+
raise ToolError.new("#{name}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
|
|
50
|
+
rescue Textus::ContractDrift, Textus::CursorExpired
|
|
51
|
+
raise
|
|
52
|
+
rescue Textus::Error => e
|
|
53
|
+
raise ToolError.new("#{name}: #{e.message}")
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
@@ -1,16 +1,6 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Surface
|
|
3
3
|
module MCP
|
|
4
|
-
# Audit cursor fell off the keep window. Client should re-boot and
|
|
5
|
-
# resume from the new latest_seq.
|
|
6
|
-
class CursorExpired < Textus::Error
|
|
7
|
-
JSONRPC_CODE = -32_002
|
|
8
|
-
|
|
9
|
-
def initialize(message, details: {})
|
|
10
|
-
super("cursor_expired", message, details: details)
|
|
11
|
-
end
|
|
12
|
-
end
|
|
13
|
-
|
|
14
4
|
# Tool execution failed (validation, authorization, IO). Wraps an
|
|
15
5
|
# underlying Textus::Error or generic StandardError.
|
|
16
6
|
class ToolError < Textus::Error
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Surface
|
|
3
|
+
module MCP
|
|
4
|
+
class Projector
|
|
5
|
+
def initialize(view_key: :default)
|
|
6
|
+
@view_key = view_key
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def dispatch(verb_name, inputs:, store:)
|
|
10
|
+
spec = VerbRegistry.for(verb_name.to_sym)
|
|
11
|
+
raise Textus::UsageError.new("unknown verb: #{verb_name}") unless spec
|
|
12
|
+
|
|
13
|
+
bound = Textus::Dispatch::Binder.inputs_from_wire(spec, inputs)
|
|
14
|
+
result = store.public_send(verb_name.to_sym, **bound)
|
|
15
|
+
spec.view(@view_key).call(result, bound)
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -3,26 +3,17 @@
|
|
|
3
3
|
require "mcp"
|
|
4
4
|
|
|
5
5
|
module Textus
|
|
6
|
-
module
|
|
6
|
+
module Surface
|
|
7
7
|
module MCP
|
|
8
8
|
# MCP stdio server backed by the official mcp gem. The SDK owns protocol
|
|
9
9
|
# negotiation, tool dispatch, and JSON-RPC framing. This class owns the
|
|
10
10
|
# textus Session lifecycle (built lazily on first tool call) and delegates
|
|
11
11
|
# execution to Catalog.
|
|
12
12
|
class Server
|
|
13
|
-
def initialize(store:, role: Textus::Role::DEFAULT, stdin: $stdin, stdout: $stdout)
|
|
14
|
-
@store = store
|
|
15
|
-
@role = role
|
|
13
|
+
def initialize(store:, role: Textus::Value::Role::DEFAULT, stdin: $stdin, stdout: $stdout)
|
|
14
|
+
@store = store.with_role(role)
|
|
16
15
|
@stdin = stdin
|
|
17
16
|
@stdout = stdout
|
|
18
|
-
# Session built eagerly so the contract_etag is captured at server start.
|
|
19
|
-
# Changes to manifest/hooks/schemas after this point are detected as drift.
|
|
20
|
-
@session = Textus::Session.new(
|
|
21
|
-
role: @role,
|
|
22
|
-
cursor: @store.audit_log.latest_seq,
|
|
23
|
-
propose_lane: @store.manifest.policy.propose_lane_for(@role),
|
|
24
|
-
contract_etag: contract_etag_now,
|
|
25
|
-
)
|
|
26
17
|
|
|
27
18
|
@sdk = ::MCP::Server.new(
|
|
28
19
|
name: "textus",
|
|
@@ -34,7 +25,6 @@ module Textus
|
|
|
34
25
|
@sdk.resources_read_handler { |params, server_context:| handle_resource_read(params[:uri].to_s, server_context) }
|
|
35
26
|
end
|
|
36
27
|
|
|
37
|
-
# Runs the stdio line loop; delegates each JSON line to the SDK.
|
|
38
28
|
def run
|
|
39
29
|
@stdin.each_line do |line|
|
|
40
30
|
line = line.strip
|
|
@@ -48,32 +38,28 @@ module Textus
|
|
|
48
38
|
end
|
|
49
39
|
end
|
|
50
40
|
|
|
51
|
-
# Called from every MCP::Tool handler block in Catalog.
|
|
52
|
-
# The SDK parses JSON with symbolize_names: true — all nested keys are symbols.
|
|
53
|
-
# Deep-stringify so Catalog.call receives the string-key format it expects.
|
|
54
41
|
def dispatch(verb_name, args, _server_context)
|
|
55
42
|
str_args = deep_stringify_keys(args)
|
|
56
|
-
@
|
|
57
|
-
result = Catalog.call(verb_name.to_s,
|
|
58
|
-
|
|
43
|
+
@store.check_etag!(contract_etag_now) unless Catalog.read_verbs.include?(verb_name.to_s)
|
|
44
|
+
result = Catalog.call(verb_name.to_s, store: @store, args: str_args)
|
|
45
|
+
@store = @store.advance_cursor(@store.audit_log.latest_seq) if verb_name == :pulse
|
|
46
|
+
@store = @store.with_role(@store.role) if verb_name == :boot
|
|
59
47
|
::MCP::Tool::Response.new([{ type: "text", text: JSON.dump(result) }])
|
|
60
48
|
rescue Textus::ContractDrift => e
|
|
61
49
|
raise_handler_error(e.message, Textus::ContractDrift::JSONRPC_CODE)
|
|
62
|
-
rescue CursorExpired => e
|
|
63
|
-
raise_handler_error(e.message, CursorExpired::JSONRPC_CODE)
|
|
64
|
-
rescue Textus::
|
|
50
|
+
rescue Textus::CursorExpired => e
|
|
51
|
+
raise_handler_error(e.message, Textus::CursorExpired::JSONRPC_CODE)
|
|
52
|
+
rescue Textus::Surface::MCP::ToolError => e
|
|
65
53
|
raise_handler_error(e.message, ToolError::JSONRPC_CODE)
|
|
66
54
|
rescue StandardError => e
|
|
67
|
-
raise_handler_error("internal: #{e.
|
|
55
|
+
raise_handler_error("internal: #{e.message}", -32_603)
|
|
68
56
|
end
|
|
69
57
|
|
|
70
58
|
private
|
|
71
59
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
end
|
|
76
|
-
|
|
60
|
+
# Snapshot at server init against the boot-time manifest. New produced entries
|
|
61
|
+
# added by a later reconcile are invisible until the server restarts — this is
|
|
62
|
+
# intentional: a ContractDrift will gate writes on any mid-session manifest change.
|
|
77
63
|
def build_resources
|
|
78
64
|
machine_lane = @store.manifest.policy.machine_lane
|
|
79
65
|
return [] unless machine_lane
|
|
@@ -84,16 +70,18 @@ module Textus
|
|
|
84
70
|
end
|
|
85
71
|
|
|
86
72
|
def handle_resource_read(uri, _server_context)
|
|
87
|
-
key
|
|
88
|
-
env = @store.
|
|
73
|
+
key = uri.delete_prefix("textus://").tr("/", ".")
|
|
74
|
+
env = @store.get(key:)
|
|
89
75
|
text = env.content.is_a?(Hash) ? JSON.dump(env.content) : (env.body || "").to_s
|
|
90
|
-
mime = mime_for_format(
|
|
76
|
+
mime = mime_for_format(env.format)
|
|
91
77
|
[{ uri: uri, mimeType: mime, text: text }]
|
|
92
78
|
rescue Textus::Error => e
|
|
93
79
|
raise_handler_error("resource read failed: #{e.message}", -32_603)
|
|
80
|
+
rescue StandardError => e
|
|
81
|
+
raise_handler_error("internal: #{e.message}", -32_603)
|
|
94
82
|
end
|
|
95
83
|
|
|
96
|
-
def contract_etag_now = Textus::Etag.for_contract(@store.root)
|
|
84
|
+
def contract_etag_now = Textus::Value::Etag.for_contract(@store.root)
|
|
97
85
|
|
|
98
86
|
# The SDK parses JSON with symbolize_names:true, making all nested hash keys symbols.
|
|
99
87
|
# Recursively stringify so Catalog.call receives string-keyed hashes throughout.
|
|
@@ -107,9 +95,10 @@ module Textus
|
|
|
107
95
|
|
|
108
96
|
def mime_for_format(format)
|
|
109
97
|
case format.to_s
|
|
110
|
-
when "json"
|
|
111
|
-
when "yaml"
|
|
112
|
-
|
|
98
|
+
when "json" then "application/json"
|
|
99
|
+
when "yaml" then "application/yaml"
|
|
100
|
+
when "markdown" then "text/markdown"
|
|
101
|
+
else "text/plain"
|
|
113
102
|
end
|
|
114
103
|
end
|
|
115
104
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Surface
|
|
3
3
|
# The agent gate. Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05.
|
|
4
|
-
# Wraps Textus::
|
|
4
|
+
# Wraps Textus::Surface::RoleScope as auto-derived tools. See ADR 0015.
|
|
5
5
|
module MCP
|
|
6
6
|
end
|
|
7
7
|
end
|
|
@@ -3,26 +3,26 @@
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
|
|
5
5
|
module Textus
|
|
6
|
-
module
|
|
6
|
+
module Surface
|
|
7
7
|
class Watcher
|
|
8
8
|
def initialize(container:)
|
|
9
9
|
@container = container
|
|
10
|
-
@queue = Textus::Ports::JobStore.new(root: container.root)
|
|
11
10
|
end
|
|
12
11
|
|
|
13
12
|
def tick
|
|
14
|
-
Textus::Jobs::
|
|
13
|
+
queue = Textus::Store::Jobs::Queue.new(store: @container.job_store)
|
|
14
|
+
Textus::Store::Jobs::Planner.seed(
|
|
15
15
|
container: @container,
|
|
16
|
-
queue:
|
|
17
|
-
role: Textus::Role::AUTOMATION,
|
|
16
|
+
queue: queue,
|
|
17
|
+
role: Textus::Value::Role::AUTOMATION,
|
|
18
18
|
)
|
|
19
|
-
|
|
20
|
-
Textus::Jobs::Worker.for(container: @container, queue:
|
|
19
|
+
queue.reclaim(now: Textus::Port::Clock.new.now)
|
|
20
|
+
Textus::Store::Jobs::Worker.for(container: @container, queue: queue).drain
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def run(poll: nil)
|
|
24
24
|
interval = poll || @container.manifest.data.worker_config[:poll]
|
|
25
|
-
lock = Textus::
|
|
25
|
+
lock = Textus::Port::WatcherLock.new(@container.root)
|
|
26
26
|
lock.acquire
|
|
27
27
|
begin
|
|
28
28
|
loop do
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
# Immutable per-invocation value. Carries who is acting (role), the
|
|
5
|
+
# request correlation id, the wall clock, and the dry_run flag — the
|
|
6
|
+
# bits Use Cases need that are not part of the Container.
|
|
7
|
+
module Value
|
|
8
|
+
Call = Data.define(:role, :correlation_id, :now, :dry_run) do
|
|
9
|
+
def self.build(role:, correlation_id: nil, now: nil, dry_run: false)
|
|
10
|
+
new(
|
|
11
|
+
role: role.to_s,
|
|
12
|
+
correlation_id: correlation_id || SecureRandom.uuid,
|
|
13
|
+
now: now || Textus::Port::Clock.new.now,
|
|
14
|
+
dry_run: dry_run,
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def dry_run? = dry_run
|
|
19
|
+
|
|
20
|
+
def with_role(new_role)
|
|
21
|
+
self.class.new(
|
|
22
|
+
role: new_role.to_s,
|
|
23
|
+
correlation_id: correlation_id,
|
|
24
|
+
now: now,
|
|
25
|
+
dry_run: dry_run,
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Value
|
|
3
|
+
Command = Data.define(:verb, :params, :role) do
|
|
4
|
+
def initialize(verb:, params:, role:)
|
|
5
|
+
super
|
|
6
|
+
params.freeze
|
|
7
|
+
freeze
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def [](key) = params[key]
|
|
11
|
+
def key = params[:key]
|
|
12
|
+
def pending_key = params[:pending_key]
|
|
13
|
+
def dry_run = params[:dry_run]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
3
|
-
# Parses a duration value into whole seconds. Accepts a bare integer (or
|
|
4
|
-
# integer-string) of seconds, or `<n><unit>` with unit s/m/h/d. Returns
|
|
5
|
-
# nil for nil or any unparseable value.
|
|
2
|
+
module Value
|
|
6
3
|
module Duration
|
|
7
4
|
UNIT_SECONDS = { "s" => 1, "m" => 60, "h" => 3600, "d" => 86_400 }.freeze
|
|
8
5
|
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-struct"
|
|
4
|
+
|
|
5
|
+
module Textus
|
|
6
|
+
module Value
|
|
7
|
+
class Envelope < Dry::Struct
|
|
8
|
+
attribute :protocol, Types::String
|
|
9
|
+
attribute :key, Types::String
|
|
10
|
+
attribute :lane, Types::String
|
|
11
|
+
attribute :owner, Types::String.optional
|
|
12
|
+
attribute :path, Types::String
|
|
13
|
+
attribute :format, Types::FormatName
|
|
14
|
+
attribute :etag, Types::String
|
|
15
|
+
attribute :uid, Types::String.optional
|
|
16
|
+
attribute :sources, Types::Array.of(Types::Any).optional
|
|
17
|
+
attribute :schema_ref, Types::String.optional
|
|
18
|
+
attribute :meta, Types::Hash.default({}.freeze)
|
|
19
|
+
attribute :body, Types::String.optional
|
|
20
|
+
attribute :content, Types::Any.optional
|
|
21
|
+
attribute :freshness, Types::Any.optional
|
|
22
|
+
|
|
23
|
+
def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil, freshness: nil)
|
|
24
|
+
new(
|
|
25
|
+
protocol: Textus::PROTOCOL,
|
|
26
|
+
key: key,
|
|
27
|
+
lane: mentry.lane,
|
|
28
|
+
owner: mentry.owner,
|
|
29
|
+
path: path,
|
|
30
|
+
format: mentry.format,
|
|
31
|
+
uid: extract_uid(meta),
|
|
32
|
+
sources: extract_sources(meta),
|
|
33
|
+
etag: etag,
|
|
34
|
+
schema_ref: mentry.schema,
|
|
35
|
+
meta: meta,
|
|
36
|
+
body: body,
|
|
37
|
+
content: content,
|
|
38
|
+
freshness: freshness,
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.extract_uid(meta)
|
|
43
|
+
v = meta.is_a?(Hash) ? meta["uid"] : nil
|
|
44
|
+
v.is_a?(String) ? v : nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def self.extract_sources(meta)
|
|
48
|
+
v = meta.is_a?(Hash) ? meta["sources"] : nil
|
|
49
|
+
return nil unless v.is_a?(Array) && !v.empty?
|
|
50
|
+
|
|
51
|
+
valid = v.select { |s| s.is_a?(String) || (s.is_a?(Hash) && s["key"].is_a?(String)) }
|
|
52
|
+
valid.empty? ? nil : valid
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def with(**attrs) = self.class.new(to_h.merge(attrs))
|
|
56
|
+
|
|
57
|
+
def to_h_for_wire
|
|
58
|
+
h = {
|
|
59
|
+
"protocol" => protocol,
|
|
60
|
+
"key" => key,
|
|
61
|
+
"lane" => lane,
|
|
62
|
+
"owner" => owner,
|
|
63
|
+
"path" => path,
|
|
64
|
+
"format" => format,
|
|
65
|
+
"_meta" => meta,
|
|
66
|
+
"body" => body,
|
|
67
|
+
"etag" => etag,
|
|
68
|
+
"schema_ref" => schema_ref,
|
|
69
|
+
"uid" => uid,
|
|
70
|
+
}
|
|
71
|
+
h["sources"] = sources if sources
|
|
72
|
+
h["content"] = content unless content.nil?
|
|
73
|
+
freshness&.to_h_for_wire&.each { |k, v| h[k] = v }
|
|
74
|
+
h
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def stale?
|
|
78
|
+
return false if freshness.nil?
|
|
79
|
+
|
|
80
|
+
freshness.stale == true
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def fetching?
|
|
84
|
+
return false if freshness.nil?
|
|
85
|
+
|
|
86
|
+
freshness.fetching == true
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Value
|
|
5
|
+
module Etag
|
|
6
|
+
def self.for_bytes(bytes)
|
|
7
|
+
"sha256:#{Digest::SHA256.hexdigest(bytes)}"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.for_file(path)
|
|
11
|
+
for_bytes(File.binread(path))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# The fingerprint of everything an agent's boot orientation depends on:
|
|
15
|
+
# the manifest PLUS the executable contract — hooks and schemas. A
|
|
16
|
+
# mid-session edit to any of these makes the cached orientation stale, so
|
|
17
|
+
# the session must re-boot (ADR 0074). The composite is one digest over the
|
|
18
|
+
# sorted per-file listing, so it is order-stable.
|
|
19
|
+
def self.for_contract(root)
|
|
20
|
+
listing = contract_files(root).map do |path|
|
|
21
|
+
rel = path.delete_prefix(root).delete_prefix("/")
|
|
22
|
+
"#{rel}:#{for_file(path)}"
|
|
23
|
+
end.join("\n")
|
|
24
|
+
for_bytes(listing)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# manifest.yaml, then every hook and schema file. Dir.glob already returns
|
|
28
|
+
# sorted paths (Ruby 3.0+), keeping the digest independent of FS order.
|
|
29
|
+
def self.contract_files(root)
|
|
30
|
+
geom = Textus::Store::Layout.new(root)
|
|
31
|
+
[
|
|
32
|
+
geom.manifest_path,
|
|
33
|
+
*Dir.glob(File.join(geom.hooks_dir, "**", "*.rb")),
|
|
34
|
+
*Dir.glob(geom.schemas_glob).select { |f| File.file?(f) },
|
|
35
|
+
]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Value
|
|
3
|
+
# rubocop:disable Lint/ConstantDefinitionInBlock
|
|
4
|
+
Result = Data.define(:ok, :value, :error) do
|
|
5
|
+
def self.success(value) = new(ok: true, value: value, error: nil)
|
|
6
|
+
|
|
7
|
+
def self.failure(code, message, details: {})
|
|
8
|
+
new(ok: false, value: nil, error: { code: code, message: message, details: details })
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.extract(result)
|
|
12
|
+
case result
|
|
13
|
+
when self
|
|
14
|
+
if result.success?
|
|
15
|
+
result.value
|
|
16
|
+
else
|
|
17
|
+
err = result.error
|
|
18
|
+
raise Textus::ActionError.new(err[:code] || :error, err[:message] || "action failed", details: err[:details] || {})
|
|
19
|
+
end
|
|
20
|
+
else
|
|
21
|
+
result
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def success? = ok
|
|
26
|
+
def failure? = !ok
|
|
27
|
+
|
|
28
|
+
def unwrap
|
|
29
|
+
raise Result::UnwrapError.new(error[:code], error[:message], details: error[:details]) unless ok
|
|
30
|
+
|
|
31
|
+
value
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
class UnwrapError < StandardError
|
|
35
|
+
attr_reader :code, :details
|
|
36
|
+
|
|
37
|
+
def initialize(code, message, details: {})
|
|
38
|
+
super(message)
|
|
39
|
+
@code = code
|
|
40
|
+
@details = details
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
# rubocop:enable Lint/ConstantDefinitionInBlock
|
|
45
|
+
end
|
|
46
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Value
|
|
3
|
+
module Role
|
|
4
|
+
# The three role archetypes, each string sourced exactly once: human curates
|
|
5
|
+
# canon, agent proposes, automation converges the machine-maintained lanes
|
|
6
|
+
# (refresh + materialize) (explanation/concepts.md).
|
|
7
|
+
# Reference these constants instead of bare literals (ADR 0044).
|
|
8
|
+
HUMAN = "human".freeze
|
|
9
|
+
AGENT = "agent".freeze
|
|
10
|
+
AUTOMATION = "automation".freeze
|
|
11
|
+
|
|
12
|
+
# The closed set of legal role names (ADR 0045), built FROM the archetypes
|
|
13
|
+
# above so it stays the single source of truth — a manifest declaring any
|
|
14
|
+
# other name is rejected at load, and DEFAULT ∈ NAMES holds structurally.
|
|
15
|
+
# Capabilities (`can:`) remain freely tunable per role.
|
|
16
|
+
NAMES = [HUMAN, AGENT, AUTOMATION].freeze
|
|
17
|
+
|
|
18
|
+
# Default acting identity (ADR 0040): a *choice* over the vocabulary, not a
|
|
19
|
+
# new name. CLI callers act as the human; an agent over stdio proposes and
|
|
20
|
+
# does not inherit the human's authority (it defaults to AGENT per transport).
|
|
21
|
+
DEFAULT = HUMAN
|
|
22
|
+
|
|
23
|
+
def self.resolve(root:, flag: nil, env: ENV, default: DEFAULT)
|
|
24
|
+
candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || default
|
|
25
|
+
raise InvalidRole.new(candidate) unless NAMES.include?(candidate)
|
|
26
|
+
|
|
27
|
+
candidate
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.read_file(root)
|
|
31
|
+
path = File.join(root, "role")
|
|
32
|
+
return nil unless File.exist?(path)
|
|
33
|
+
|
|
34
|
+
File.read(path).strip.then { |s| s.empty? ? nil : s }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Value
|
|
3
|
+
module Types
|
|
4
|
+
include Dry.Types()
|
|
5
|
+
|
|
6
|
+
RoleName = Types::String.constrained(included_in: Textus::Value::Role::NAMES)
|
|
7
|
+
Cursor = Types::Integer.constrained(gteq: 0)
|
|
8
|
+
FormatName = Types::String.constrained(
|
|
9
|
+
included_in: %w[markdown json yaml text], # must match Format::STRATEGIES.keys
|
|
10
|
+
)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -4,15 +4,17 @@ module Textus
|
|
|
4
4
|
# A Textus UID: 16 lowercase hex chars (SecureRandom.hex(8)). Not a UUID —
|
|
5
5
|
# short on purpose. Random enough for collision-never-in-practice within a
|
|
6
6
|
# single store.
|
|
7
|
-
module
|
|
8
|
-
|
|
7
|
+
module Value
|
|
8
|
+
module Uid
|
|
9
|
+
module_function
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
def mint
|
|
12
|
+
SecureRandom.hex(8)
|
|
13
|
+
end
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
def valid?(str)
|
|
16
|
+
str.is_a?(String) && str.match?(/\A[0-9a-f]{16}\z/)
|
|
17
|
+
end
|
|
16
18
|
end
|
|
17
19
|
end
|
|
18
20
|
end
|