textus 0.52.0 → 0.54.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 +59 -0
- data/README.md +62 -54
- data/SPEC.md +64 -192
- data/docs/architecture/README.md +157 -156
- data/exe/textus +1 -1
- data/lib/textus/action/accept.rb +44 -0
- data/lib/textus/action/audit.rb +131 -0
- data/lib/textus/action/base.rb +42 -0
- data/lib/textus/{read → action}/blame.rb +28 -22
- data/lib/textus/action/boot.rb +17 -0
- data/lib/textus/action/data_mv.rb +69 -0
- data/lib/textus/action/deps.rb +34 -0
- data/lib/textus/action/doctor.rb +24 -0
- data/lib/textus/action/drain.rb +39 -0
- data/lib/textus/action/enqueue.rb +53 -0
- data/lib/textus/action/get.rb +78 -0
- data/lib/textus/action/ingest.rb +135 -0
- data/lib/textus/action/jobs.rb +36 -0
- data/lib/textus/action/key_delete.rb +36 -0
- data/lib/textus/action/key_delete_prefix.rb +44 -0
- data/lib/textus/action/key_mv.rb +129 -0
- data/lib/textus/action/key_mv_prefix.rb +57 -0
- data/lib/textus/action/list.rb +42 -0
- data/lib/textus/action/propose.rb +52 -0
- data/lib/textus/action/published.rb +24 -0
- data/lib/textus/action/pulse.rb +56 -0
- data/lib/textus/action/put.rb +52 -0
- data/lib/textus/action/rdeps.rb +39 -0
- data/lib/textus/action/reject.rb +37 -0
- data/lib/textus/action/rule_explain.rb +91 -0
- data/lib/textus/action/rule_lint.rb +68 -0
- data/lib/textus/action/rule_list.rb +42 -0
- data/lib/textus/action/schema_envelope.rb +29 -0
- data/lib/textus/action/uid.rb +33 -0
- data/lib/textus/action/where.rb +36 -0
- data/lib/textus/action/write_verb.rb +44 -0
- data/lib/textus/boot.rb +64 -132
- data/lib/textus/command.rb +41 -0
- data/lib/textus/container.rb +1 -1
- data/lib/textus/{domain → core}/duration.rb +1 -1
- data/lib/textus/{domain → core}/freshness/evaluator.rb +25 -41
- data/lib/textus/{domain → core}/freshness/verdict.rb +2 -2
- data/lib/textus/{domain → core}/freshness.rb +2 -2
- data/lib/textus/{domain → core}/retention/sweep.rb +7 -7
- data/lib/textus/{domain → core}/retention.rb +2 -2
- data/lib/textus/{domain → core}/sentinel.rb +1 -1
- data/lib/textus/doctor/check/generator_drift.rb +1 -1
- data/lib/textus/doctor/check/illegal_keys.rb +1 -1
- data/lib/textus/doctor/check/notebook_sources.rb +50 -0
- data/lib/textus/doctor/check/proposal_targets.rb +3 -3
- data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
- data/lib/textus/doctor/check/rule_ambiguity.rb +2 -2
- data/lib/textus/doctor/check/schema_violations.rb +8 -2
- data/lib/textus/doctor/check.rb +11 -9
- data/lib/textus/{read → doctor}/validator.rb +22 -13
- data/lib/textus/doctor.rb +4 -44
- data/lib/textus/envelope/reader.rb +46 -0
- data/lib/textus/envelope/writer.rb +209 -0
- data/lib/textus/envelope.rb +5 -3
- data/lib/textus/errors.rb +25 -13
- data/lib/textus/{entry → format}/base.rb +1 -6
- data/lib/textus/{entry → format}/json.rb +1 -4
- data/lib/textus/{entry → format}/markdown.rb +2 -6
- data/lib/textus/{entry → format}/text.rb +2 -6
- data/lib/textus/{entry → format}/yaml.rb +1 -4
- data/lib/textus/{entry.rb → format.rb} +9 -10
- data/lib/textus/gate/auth.rb +227 -0
- data/lib/textus/gate.rb +116 -0
- data/lib/textus/init.rb +59 -105
- data/lib/textus/jobs/base.rb +23 -0
- data/lib/textus/jobs/materialize.rb +20 -0
- data/lib/textus/jobs/plan.rb +9 -0
- data/lib/textus/jobs/planner.rb +101 -0
- data/lib/textus/jobs/retention.rb +48 -0
- data/lib/textus/jobs/sweep.rb +27 -0
- data/lib/textus/jobs/worker.rb +67 -0
- data/lib/textus/jobs.rb +15 -0
- data/lib/textus/key/path.rb +10 -3
- data/lib/textus/layout.rb +13 -0
- data/lib/textus/manifest/data.rb +14 -14
- data/lib/textus/manifest/entry/base.rb +14 -25
- data/lib/textus/manifest/entry/parser.rb +31 -14
- data/lib/textus/manifest/entry/produced.rb +13 -33
- data/lib/textus/manifest/entry/publish/mode.rb +1 -1
- data/lib/textus/manifest/entry/publish/to_paths.rb +4 -3
- data/lib/textus/manifest/entry/publish.rb +0 -12
- data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/{domain → manifest}/policy/matcher.rb +2 -2
- data/lib/textus/{domain → manifest}/policy/publish_target.rb +2 -2
- data/lib/textus/manifest/policy/react.rb +30 -0
- data/lib/textus/{domain → manifest}/policy/retention.rb +3 -3
- data/lib/textus/manifest/policy/source.rb +30 -0
- data/lib/textus/manifest/policy.rb +38 -53
- data/lib/textus/manifest/resolver.rb +5 -4
- data/lib/textus/manifest/rules.rb +4 -4
- data/lib/textus/manifest/schema/keys.rb +20 -28
- data/lib/textus/manifest/schema/validator.rb +27 -33
- data/lib/textus/manifest/schema/vocabulary.rb +2 -1
- data/lib/textus/manifest/schema.rb +2 -2
- data/lib/textus/manifest.rb +2 -2
- data/lib/textus/ports/audit_log.rb +31 -5
- data/lib/textus/{domain/jobs → ports/job_store}/job.rb +19 -12
- data/lib/textus/ports/{queue.rb → job_store.rb} +7 -14
- data/lib/textus/ports/sentinel_store.rb +2 -2
- data/lib/textus/ports/watcher_lock.rb +48 -0
- data/lib/textus/produce/engine.rb +25 -70
- data/lib/textus/produce/render.rb +2 -7
- data/lib/textus/schema/tools.rb +4 -3
- data/lib/textus/session.rb +6 -3
- data/lib/textus/store.rb +12 -19
- data/lib/textus/surfaces/cli/group/data.rb +11 -0
- data/lib/textus/surfaces/cli/group/key.rb +11 -0
- data/lib/textus/surfaces/cli/group/mcp.rb +11 -0
- data/lib/textus/surfaces/cli/group/rule.rb +11 -0
- data/lib/textus/surfaces/cli/group/schema.rb +11 -0
- data/lib/textus/surfaces/cli/group.rb +50 -0
- data/lib/textus/surfaces/cli/runner.rb +236 -0
- data/lib/textus/surfaces/cli/verb/doctor.rb +21 -0
- data/lib/textus/surfaces/cli/verb/get.rb +21 -0
- data/lib/textus/surfaces/cli/verb/init.rb +20 -0
- data/lib/textus/surfaces/cli/verb/mcp_serve.rb +24 -0
- data/lib/textus/surfaces/cli/verb/put.rb +30 -0
- data/lib/textus/surfaces/cli/verb/schema_diff.rb +17 -0
- data/lib/textus/surfaces/cli/verb/schema_init.rb +21 -0
- data/lib/textus/surfaces/cli/verb/schema_migrate.rb +21 -0
- data/lib/textus/surfaces/cli/verb/watch.rb +19 -0
- data/lib/textus/surfaces/cli/verb.rb +111 -0
- data/lib/textus/surfaces/cli.rb +148 -0
- data/lib/textus/surfaces/mcp/catalog.rb +99 -0
- data/lib/textus/surfaces/mcp/errors.rb +25 -0
- data/lib/textus/surfaces/mcp/routing.rb +51 -0
- data/lib/textus/surfaces/mcp/server.rb +184 -0
- data/lib/textus/surfaces/mcp/session.rb +9 -0
- data/lib/textus/surfaces/mcp/tool_schemas.rb +17 -0
- data/lib/textus/surfaces/mcp.rb +8 -0
- data/lib/textus/surfaces/role_scope.rb +34 -0
- data/lib/textus/surfaces/watcher.rb +38 -0
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/collector.rb +27 -0
- data/lib/textus/workflow/context.rb +5 -0
- data/lib/textus/workflow/dsl.rb +44 -0
- data/lib/textus/workflow/loader.rb +17 -0
- data/lib/textus/workflow/pattern.rb +18 -0
- data/lib/textus/workflow/registry.rb +21 -0
- data/lib/textus/workflow/runner.rb +84 -0
- data/lib/textus/workflow.rb +28 -0
- data/lib/textus.rb +75 -22
- metadata +118 -132
- data/lib/textus/cli/group/hook.rb +0 -9
- data/lib/textus/cli/group/key.rb +0 -9
- data/lib/textus/cli/group/mcp.rb +0 -9
- data/lib/textus/cli/group/rule.rb +0 -9
- data/lib/textus/cli/group/schema.rb +0 -9
- data/lib/textus/cli/group/zone.rb +0 -9
- data/lib/textus/cli/group.rb +0 -48
- data/lib/textus/cli/runner.rb +0 -193
- data/lib/textus/cli/verb/doctor.rb +0 -17
- data/lib/textus/cli/verb/get.rb +0 -18
- data/lib/textus/cli/verb/hook_run.rb +0 -48
- data/lib/textus/cli/verb/hooks.rb +0 -50
- data/lib/textus/cli/verb/init.rb +0 -18
- data/lib/textus/cli/verb/mcp_serve.rb +0 -22
- data/lib/textus/cli/verb/put.rb +0 -30
- data/lib/textus/cli/verb/schema_diff.rb +0 -15
- data/lib/textus/cli/verb/schema_init.rb +0 -19
- data/lib/textus/cli/verb/schema_migrate.rb +0 -19
- data/lib/textus/cli/verb/serve.rb +0 -19
- data/lib/textus/cli/verb.rb +0 -116
- data/lib/textus/cli.rb +0 -138
- data/lib/textus/dispatcher.rb +0 -54
- data/lib/textus/doctor/check/handler_allowlist.rb +0 -34
- data/lib/textus/doctor/check/hooks.rb +0 -32
- data/lib/textus/doctor/check/intake_registration.rb +0 -46
- data/lib/textus/domain/action.rb +0 -9
- data/lib/textus/domain/jobs/registry.rb +0 -37
- data/lib/textus/domain/permission.rb +0 -7
- data/lib/textus/domain/policy/base_guards.rb +0 -25
- data/lib/textus/domain/policy/evaluation.rb +0 -15
- data/lib/textus/domain/policy/guard.rb +0 -35
- data/lib/textus/domain/policy/guard_factory.rb +0 -40
- data/lib/textus/domain/policy/handler_allowlist.rb +0 -17
- data/lib/textus/domain/policy/predicates/author_held.rb +0 -33
- data/lib/textus/domain/policy/predicates/etag_match.rb +0 -32
- data/lib/textus/domain/policy/predicates/fresh_within.rb +0 -59
- data/lib/textus/domain/policy/predicates/registry.rb +0 -39
- data/lib/textus/domain/policy/predicates/schema_valid.rb +0 -61
- data/lib/textus/domain/policy/predicates/target_is_canon.rb +0 -33
- data/lib/textus/domain/policy/predicates/zone_writable_by.rb +0 -39
- data/lib/textus/domain/policy/source.rb +0 -73
- data/lib/textus/envelope/io/reader.rb +0 -48
- data/lib/textus/envelope/io/writer.rb +0 -182
- data/lib/textus/hooks/builtin.rb +0 -70
- data/lib/textus/hooks/catalog.rb +0 -37
- data/lib/textus/hooks/context.rb +0 -72
- data/lib/textus/hooks/error_log.rb +0 -32
- data/lib/textus/hooks/event_bus.rb +0 -114
- data/lib/textus/hooks/fire_report.rb +0 -23
- data/lib/textus/hooks/loader.rb +0 -54
- data/lib/textus/hooks/rpc_registry.rb +0 -43
- data/lib/textus/hooks/signature.rb +0 -31
- data/lib/textus/init/templates/machine_intake.rb +0 -45
- data/lib/textus/init/templates/orientation_reducer.rb +0 -17
- data/lib/textus/jobs/handlers.rb +0 -62
- data/lib/textus/jobs/scheduler.rb +0 -36
- data/lib/textus/jobs/seeder.rb +0 -57
- data/lib/textus/maintenance/drain.rb +0 -42
- data/lib/textus/maintenance/key_delete_prefix.rb +0 -48
- data/lib/textus/maintenance/key_mv_prefix.rb +0 -68
- data/lib/textus/maintenance/retention/apply.rb +0 -52
- data/lib/textus/maintenance/rule_lint.rb +0 -66
- data/lib/textus/maintenance/serve.rb +0 -30
- data/lib/textus/maintenance/worker.rb +0 -74
- data/lib/textus/maintenance/zone_mv.rb +0 -64
- data/lib/textus/maintenance.rb +0 -15
- data/lib/textus/manifest/entry/validators/events.rb +0 -22
- data/lib/textus/mcp/catalog.rb +0 -70
- data/lib/textus/mcp/errors.rb +0 -32
- data/lib/textus/mcp/server.rb +0 -138
- data/lib/textus/mcp/session.rb +0 -7
- data/lib/textus/mcp/tool_schemas.rb +0 -15
- data/lib/textus/mcp.rb +0 -6
- data/lib/textus/mustache.rb +0 -117
- data/lib/textus/ports/audit_subscriber.rb +0 -42
- data/lib/textus/ports/produce_on_write_subscriber.rb +0 -73
- data/lib/textus/produce/acquire/handler.rb +0 -29
- data/lib/textus/produce/acquire/intake.rb +0 -130
- data/lib/textus/produce/acquire/projection.rb +0 -127
- data/lib/textus/produce/acquire/serializer/json.rb +0 -31
- data/lib/textus/produce/acquire/serializer/text.rb +0 -16
- data/lib/textus/produce/acquire/serializer/yaml.rb +0 -31
- data/lib/textus/produce/acquire/serializer.rb +0 -17
- data/lib/textus/produce/events.rb +0 -36
- data/lib/textus/projection.rb +0 -103
- data/lib/textus/read/audit.rb +0 -130
- data/lib/textus/read/boot.rb +0 -26
- data/lib/textus/read/capabilities.rb +0 -70
- data/lib/textus/read/deps.rb +0 -38
- data/lib/textus/read/doctor.rb +0 -27
- data/lib/textus/read/freshness.rb +0 -152
- data/lib/textus/read/get.rb +0 -73
- data/lib/textus/read/jobs.rb +0 -31
- data/lib/textus/read/list.rb +0 -24
- data/lib/textus/read/published.rb +0 -22
- data/lib/textus/read/pulse.rb +0 -98
- data/lib/textus/read/rdeps.rb +0 -39
- data/lib/textus/read/rule_explain.rb +0 -96
- data/lib/textus/read/rule_list.rb +0 -54
- data/lib/textus/read/schema_envelope.rb +0 -25
- data/lib/textus/read/uid.rb +0 -29
- data/lib/textus/read/validate_all.rb +0 -36
- data/lib/textus/read/where.rb +0 -24
- data/lib/textus/role_scope.rb +0 -78
- data/lib/textus/write/accept.rb +0 -58
- data/lib/textus/write/enqueue.rb +0 -50
- data/lib/textus/write/key_delete.rb +0 -65
- data/lib/textus/write/key_mv.rb +0 -141
- data/lib/textus/write/propose.rb +0 -54
- data/lib/textus/write/put.rb +0 -74
- data/lib/textus/write/reject.rb +0 -68
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "optparse"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
module Surfaces
|
|
6
|
+
class CLI
|
|
7
|
+
# Auto-derived verb table. Every CLI::Verb (or Group) subclass that
|
|
8
|
+
# declares `command_name "X"` and has no `parent_group` is a top-level
|
|
9
|
+
# verb. Sorted alphabetically for stable help output. Adding a new
|
|
10
|
+
# verb requires only a new file declaring its `command_name`.
|
|
11
|
+
#
|
|
12
|
+
# `k.name` gates out anonymous (Class.new) subclasses: real verbs are always
|
|
13
|
+
# named constants (generated Gen* or hand-authored classes), so this is a
|
|
14
|
+
# no-op in production but keeps throwaway test fixtures from leaking into the
|
|
15
|
+
# registry (and tripping the reconciliation guards order-dependently).
|
|
16
|
+
def self.verbs
|
|
17
|
+
Runner.install!
|
|
18
|
+
Verb.descendants
|
|
19
|
+
.select { |k| k.name && k.command_name && k.parent_group.nil? }
|
|
20
|
+
.sort_by(&:command_name)
|
|
21
|
+
.to_h { |k| [k.command_name, k] }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.run(argv, stdin: $stdin, stdout: $stdout, stderr: $stderr, cwd: Dir.pwd)
|
|
25
|
+
new(stdin: stdin, stdout: stdout, stderr: stderr, cwd: cwd).run(argv)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(stdin:, stdout:, stderr:, cwd:)
|
|
29
|
+
@stdin = stdin
|
|
30
|
+
@stdout = stdout
|
|
31
|
+
@stderr = stderr
|
|
32
|
+
@cwd = cwd
|
|
33
|
+
@root_arg = nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def run(argv)
|
|
37
|
+
# `--root` is a global, position-agnostic option: pull it out of argv
|
|
38
|
+
# wherever it appears so it works uniformly before OR after any verb or
|
|
39
|
+
# group (e.g. both `textus --root=X hook list` and
|
|
40
|
+
# `textus hook list --root=X`). Without this, `order!` below only sees
|
|
41
|
+
# options before the first verb token, so a trailing `--root` reached the
|
|
42
|
+
# verb's own parser and raised InvalidOption (#161 F5). TEXTUS_ROOT already
|
|
43
|
+
# works everywhere via Store.discover, so this brings the flag to parity.
|
|
44
|
+
@root_arg = extract_root!(argv)
|
|
45
|
+
|
|
46
|
+
# Define --version/--help ourselves so OptionParser doesn't intercept them
|
|
47
|
+
# with its built-in handlers (which print "version unknown" and a bare usage
|
|
48
|
+
# line, then exit before we ever reach the verb dispatch below).
|
|
49
|
+
show_version = false
|
|
50
|
+
show_help = false
|
|
51
|
+
OptionParser.new do |o|
|
|
52
|
+
o.on("--version", "-v") { show_version = true }
|
|
53
|
+
o.on("--help", "-h") { show_help = true }
|
|
54
|
+
end.order!(argv)
|
|
55
|
+
|
|
56
|
+
return @stdout.puts(VERSION) || 0 if show_version
|
|
57
|
+
return print_help || 0 if show_help
|
|
58
|
+
|
|
59
|
+
verb = argv.shift
|
|
60
|
+
raise UsageError.new("missing verb") if verb.nil?
|
|
61
|
+
|
|
62
|
+
klass = self.class.verbs[verb] or raise UsageError.new("unknown verb: #{verb}")
|
|
63
|
+
coerce_exit_code(dispatch(klass, argv))
|
|
64
|
+
rescue Textus::Error => e
|
|
65
|
+
emit_error(e)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
private
|
|
69
|
+
|
|
70
|
+
# Remove the first `--root=PATH` or `--root PATH` token from argv (anywhere)
|
|
71
|
+
# and return its value, or nil if absent. Mutates argv in place.
|
|
72
|
+
def extract_root!(argv)
|
|
73
|
+
i = argv.index { |a| a == "--root" || a.start_with?("--root=") }
|
|
74
|
+
return nil unless i
|
|
75
|
+
|
|
76
|
+
tok = argv[i]
|
|
77
|
+
if tok.start_with?("--root=")
|
|
78
|
+
argv.delete_at(i)
|
|
79
|
+
tok.delete_prefix("--root=")
|
|
80
|
+
else
|
|
81
|
+
val = argv[i + 1]
|
|
82
|
+
raise UsageError.new("--root requires a PATH") if val.nil? || val.start_with?("-")
|
|
83
|
+
|
|
84
|
+
argv.delete_at(i + 1)
|
|
85
|
+
argv.delete_at(i)
|
|
86
|
+
val
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def coerce_exit_code(value)
|
|
91
|
+
case value
|
|
92
|
+
when Integer then value
|
|
93
|
+
when true, nil then 0
|
|
94
|
+
when false then 1
|
|
95
|
+
else
|
|
96
|
+
@stderr.puts("warning: verb returned non-Integer #{value.class}; treating as 0")
|
|
97
|
+
0
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def store
|
|
102
|
+
@store ||= Store.discover(@cwd, root: @root_arg)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def dispatch(klass, argv)
|
|
106
|
+
v = klass.new(stdin: @stdin, stdout: @stdout, stderr: @stderr, cwd: @cwd)
|
|
107
|
+
v.parse(argv)
|
|
108
|
+
v.call(klass.needs_store? ? store : nil)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def emit_error(err)
|
|
112
|
+
@stdout.puts(JSON.generate(err.to_envelope))
|
|
113
|
+
@stderr.puts("#{err.code}: #{err.message}")
|
|
114
|
+
@stderr.puts(" → #{err.hint}") if err.hint
|
|
115
|
+
err.exit_code
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def print_help
|
|
119
|
+
@stdout.puts <<~HELP
|
|
120
|
+
textus #{VERSION} — reference implementation of #{PROTOCOL}
|
|
121
|
+
|
|
122
|
+
Usage (json output is the default):
|
|
123
|
+
textus list [--prefix=KEY] [--lane=LANE]
|
|
124
|
+
textus where KEY
|
|
125
|
+
textus get KEY
|
|
126
|
+
textus put KEY --stdin --as=ROLE
|
|
127
|
+
textus propose KEY --stdin --as=ROLE
|
|
128
|
+
textus accept KEY --as=ROLE
|
|
129
|
+
textus reject KEY --as=ROLE
|
|
130
|
+
textus audit [--key=K] [--lane=LANE] [--role=R] [--verb=V] [--since=X] [--correlation-id=ID] [--limit=N]
|
|
131
|
+
textus blame KEY [--limit=N]
|
|
132
|
+
textus pulse [--since=N]
|
|
133
|
+
textus boot
|
|
134
|
+
textus doctor
|
|
135
|
+
textus drain [PREFIX] --as=ROLE
|
|
136
|
+
textus watch
|
|
137
|
+
textus jobs
|
|
138
|
+
|
|
139
|
+
textus key {delete,mv,mv-prefix,delete-prefix,uid}
|
|
140
|
+
textus rule {explain,lint,list}
|
|
141
|
+
textus schema {diff,init,migrate,show}
|
|
142
|
+
textus data {mv}
|
|
143
|
+
textus mcp {serve}
|
|
144
|
+
HELP
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Surfaces
|
|
3
|
+
module MCP
|
|
4
|
+
# Derives the entire MCP tool surface from the per-verb contracts
|
|
5
|
+
# (ADR 0039). `tool_schemas` feeds tools/list; `call` is the generic
|
|
6
|
+
# tools/call dispatch: map JSON args -> (positional, keyword) per the
|
|
7
|
+
# contract, invoke the verb through the role scope, then shape the
|
|
8
|
+
# return value with the contract's default view. No per-tool code.
|
|
9
|
+
module Catalog
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
WRITE_VERBS = %i[
|
|
13
|
+
put propose key_delete key_mv accept reject enqueue
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
MAINTENANCE_VERBS = %i[
|
|
17
|
+
data_mv key_mv_prefix key_delete_prefix drain rule_lint
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
# Contracts of every MCP-surfaced verb, in Dispatcher order.
|
|
21
|
+
def specs
|
|
22
|
+
Textus::Action::VERBS.values
|
|
23
|
+
.select { |k| mcp_surfaced?(k) }
|
|
24
|
+
.map(&:contract)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def tool_schemas
|
|
28
|
+
specs.map do |s|
|
|
29
|
+
{ name: s.verb.to_s, description: s.summary, inputSchema: s.input_schema }
|
|
30
|
+
end.freeze
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def names
|
|
34
|
+
specs.map { |s| s.verb.to_s }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# MCP-surfaced read verbs, by Dispatcher class namespace — the agent's
|
|
38
|
+
# real read/discovery surface. `boot.agent_quickstart.read_verbs` derives
|
|
39
|
+
# from this so it can never advertise a verb the agent cannot call, nor
|
|
40
|
+
# omit one it can (ADR 0056). Excludes write/maintenance verbs by verb
|
|
41
|
+
# identity (routing may be legacy UseCases or Dispatch::Actions).
|
|
42
|
+
def read_verbs
|
|
43
|
+
Textus::Action::VERBS
|
|
44
|
+
.reject { |verb, _klass| WRITE_VERBS.include?(verb) || MAINTENANCE_VERBS.include?(verb) }
|
|
45
|
+
.select { |_verb, klass| mcp_surfaced?(klass) }
|
|
46
|
+
.keys.map(&:to_s)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# MCP-surfaced write verbs, by Dispatcher class namespace — the mirror of
|
|
50
|
+
# read_verbs for the write side. `boot.agent_quickstart.write_verbs` derives
|
|
51
|
+
# from this so it advertises bare verb names the agent can call (no `--as`/
|
|
52
|
+
# `--stdin` CLI framing), finishing the de-CLI-ing of the agent surface
|
|
53
|
+
# (ADR 0056, ADR 0057).
|
|
54
|
+
def write_verbs
|
|
55
|
+
Textus::Action::VERBS
|
|
56
|
+
.select { |verb, klass| WRITE_VERBS.include?(verb) && mcp_surfaced?(klass) }
|
|
57
|
+
.keys.map(&:to_s)
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def mcp_surfaced?(klass)
|
|
61
|
+
klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def call(name, session:, store:, args:) # rubocop:disable Metrics/AbcSize
|
|
65
|
+
klass = Textus::Action::VERBS[name.to_sym]
|
|
66
|
+
raise ToolError.new("unknown tool: #{name}") unless klass && mcp_surfaced?(klass)
|
|
67
|
+
|
|
68
|
+
spec = klass.contract
|
|
69
|
+
inputs = Textus::Contract::Binder.inputs_from_wire(spec, args)
|
|
70
|
+
|
|
71
|
+
invoke = lambda do |effective_inputs|
|
|
72
|
+
pos, kwargs = Textus::Contract::Binder.bind(spec, effective_inputs, session: session)
|
|
73
|
+
spec.args.select(&:positional).zip(pos).each { |a, v| kwargs[a.name] = v unless kwargs.key?(a.name) }
|
|
74
|
+
cmd_class = Textus::Gate::VERB_COMMAND.fetch(spec.verb) do
|
|
75
|
+
raise Textus::MCP::ToolError.new("unknown verb: #{spec.verb}")
|
|
76
|
+
end
|
|
77
|
+
merged = kwargs.merge(role: session.role)
|
|
78
|
+
filled = cmd_class.members.to_h { |m| [m, merged.key?(m) ? merged[m] : nil] }
|
|
79
|
+
cmd = cmd_class.new(**filled)
|
|
80
|
+
store.gate.dispatch(cmd)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
result = if spec.around
|
|
84
|
+
Textus::Contract::Around.with(spec.around, scope: store.as(session.role), inputs: inputs, session: session, &invoke)
|
|
85
|
+
else
|
|
86
|
+
invoke.call(inputs)
|
|
87
|
+
end
|
|
88
|
+
Textus::Contract::View.render(spec, :default, result, inputs)
|
|
89
|
+
rescue Textus::Contract::MissingArgs => e
|
|
90
|
+
raise ToolError.new("#{spec.verb}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
|
|
91
|
+
rescue Textus::ContractDrift, CursorExpired
|
|
92
|
+
raise
|
|
93
|
+
rescue Textus::Error => e
|
|
94
|
+
raise ToolError.new("#{name}: #{e.message}")
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Surfaces
|
|
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
|
+
# Tool execution failed (validation, authorization, IO). Wraps an
|
|
15
|
+
# underlying Textus::Error or generic StandardError.
|
|
16
|
+
class ToolError < Textus::Error
|
|
17
|
+
JSONRPC_CODE = -32_000
|
|
18
|
+
|
|
19
|
+
def initialize(message, details: {})
|
|
20
|
+
super("tool_error", message, details: details)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Surfaces
|
|
3
|
+
module MCP
|
|
4
|
+
# Protocol routing for the MCP JSON-RPC server. Mixed into Server so all
|
|
5
|
+
# handle_* and emit_* methods are in scope without exposing them publicly.
|
|
6
|
+
module Routing
|
|
7
|
+
TOOL_METHODS = %w[initialize tools/list tools/call].freeze
|
|
8
|
+
RESOURCE_METHODS = %w[resources/list resources/read].freeze
|
|
9
|
+
|
|
10
|
+
def dispatch(msg)
|
|
11
|
+
rid = msg["id"]
|
|
12
|
+
params = msg["params"] || {}
|
|
13
|
+
route(msg["method"], rid, params)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def route(method, rid, params)
|
|
19
|
+
return route_tool(method, rid, params) if TOOL_METHODS.include?(method)
|
|
20
|
+
return route_resource(method, rid, params) if RESOURCE_METHODS.include?(method)
|
|
21
|
+
|
|
22
|
+
route_protocol(method, rid)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def route_tool(method, rid, params)
|
|
26
|
+
case method
|
|
27
|
+
when "initialize" then handle_initialize(rid, params)
|
|
28
|
+
when "tools/list" then handle_tools_list(rid)
|
|
29
|
+
when "tools/call" then handle_tools_call(rid, params)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def route_resource(method, rid, params)
|
|
34
|
+
case method
|
|
35
|
+
when "resources/list" then handle_resources_list(rid)
|
|
36
|
+
when "resources/read" then handle_resources_read(rid, params)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def route_protocol(method, rid)
|
|
41
|
+
case method
|
|
42
|
+
when "ping" then emit_result(rid, {})
|
|
43
|
+
when "shutdown" then emit_result(rid, nil)
|
|
44
|
+
when "notifications/initialized" then nil
|
|
45
|
+
else emit_error(rid, -32_601, "method not found: #{method}")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "routing"
|
|
3
|
+
|
|
4
|
+
module Textus
|
|
5
|
+
module Surfaces
|
|
6
|
+
module MCP
|
|
7
|
+
# Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. One line per
|
|
8
|
+
# message (NDJSON). Holds a single Session for the lifetime of stdin.
|
|
9
|
+
class Server
|
|
10
|
+
include Routing
|
|
11
|
+
|
|
12
|
+
PROTOCOL_VERSION = "2024-11-05"
|
|
13
|
+
SERVER_INFO = { "name" => "textus", "version" => Textus::VERSION }.freeze
|
|
14
|
+
MAX_LINE_BYTES = 1_048_576 # 1 MB — protects against OOM from oversized tool calls
|
|
15
|
+
|
|
16
|
+
def initialize(store:, stdin: $stdin, stdout: $stdout, role: Textus::Role::DEFAULT)
|
|
17
|
+
@store = store
|
|
18
|
+
@stdin = stdin
|
|
19
|
+
@stdout = stdout
|
|
20
|
+
@role = role
|
|
21
|
+
@session = nil
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run
|
|
25
|
+
@stdin.each_line do |line|
|
|
26
|
+
line = line.strip
|
|
27
|
+
next if line.empty?
|
|
28
|
+
|
|
29
|
+
handle_line(line)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def handle_line(line)
|
|
36
|
+
return reject_oversized(line) if line.bytesize > MAX_LINE_BYTES
|
|
37
|
+
|
|
38
|
+
parse_and_dispatch(line)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def reject_oversized(line)
|
|
42
|
+
emit_error(nil, -32_700, "message too large (#{line.bytesize} bytes, limit #{MAX_LINE_BYTES})")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def parse_and_dispatch(line)
|
|
46
|
+
dispatch(JSON.parse(line))
|
|
47
|
+
rescue JSON::ParserError => e
|
|
48
|
+
emit_error(nil, -32_700, "parse error: #{e.message}")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def handle_initialize(rid, _params)
|
|
52
|
+
@session = build_session
|
|
53
|
+
emit_result(rid, {
|
|
54
|
+
"protocolVersion" => PROTOCOL_VERSION,
|
|
55
|
+
"serverInfo" => SERVER_INFO,
|
|
56
|
+
"capabilities" => { "tools" => {}, "resources" => {} },
|
|
57
|
+
})
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def build_session
|
|
61
|
+
# The acting role IS the resolved connection role (ADR 0040): the MCP
|
|
62
|
+
# transport defaults to `agent`, which can write the queue, so its
|
|
63
|
+
# propose_lane resolves directly. If a connection's role cannot propose,
|
|
64
|
+
# propose_lane is nil and the `propose` tool reports that honestly.
|
|
65
|
+
Session.new(
|
|
66
|
+
role: @role,
|
|
67
|
+
cursor: @store.audit_log.latest_seq,
|
|
68
|
+
propose_lane: @store.manifest.policy.propose_lane_for(@role),
|
|
69
|
+
contract_etag: contract_etag,
|
|
70
|
+
)
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def handle_tools_list(rid)
|
|
74
|
+
emit_result(rid, { "tools" => Catalog.tool_schemas })
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def handle_tools_call(rid, params)
|
|
78
|
+
return unless session_ready?(rid)
|
|
79
|
+
|
|
80
|
+
invoke_tool(rid, params["name"], params["arguments"] || {})
|
|
81
|
+
rescue Textus::ContractDrift, CursorExpired, ToolError => e
|
|
82
|
+
emit_error(rid, e.class::JSONRPC_CODE, e.message)
|
|
83
|
+
rescue StandardError => e
|
|
84
|
+
emit_error(rid, -32_603, "internal: #{e.class}: #{e.message}")
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def session_ready?(rid)
|
|
88
|
+
return true if @session
|
|
89
|
+
|
|
90
|
+
emit_error(rid, -32_002, "session not initialized; call 'initialize' first")
|
|
91
|
+
false
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def invoke_tool(rid, name, args)
|
|
95
|
+
# ADR 0083: contract-drift guard gates mutating verbs only
|
|
96
|
+
@session.check_etag!(contract_etag) unless Catalog.read_verbs.include?(name)
|
|
97
|
+
result = Catalog.call(name, session: @session, store: @store, args: args)
|
|
98
|
+
update_session_for(name)
|
|
99
|
+
emit_tool_result(rid, result)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def update_session_for(name)
|
|
103
|
+
@session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "pulse"
|
|
104
|
+
@session = @session.with(contract_etag: contract_etag) if name == "boot"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def emit_tool_result(rid, result)
|
|
108
|
+
emit_result(rid, {
|
|
109
|
+
"content" => [{ "type" => "text", "text" => JSON.dump(result) }],
|
|
110
|
+
"isError" => false,
|
|
111
|
+
})
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def handle_resources_list(rid)
|
|
115
|
+
emit_result(rid, { "resources" => machine_resources })
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def machine_resources
|
|
119
|
+
machine_lane = @store.manifest.policy.machine_lane
|
|
120
|
+
return [] unless machine_lane
|
|
121
|
+
|
|
122
|
+
produced_entries(machine_lane).map { |e| resource_descriptor(e) }
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def produced_entries(machine_lane)
|
|
126
|
+
@store.manifest.data.entries
|
|
127
|
+
.select { |e| e.lane == machine_lane && e.is_a?(Textus::Manifest::Entry::Produced) }
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
def resource_descriptor(entry)
|
|
131
|
+
{
|
|
132
|
+
"uri" => "textus://#{entry.key.tr(".", "/")}",
|
|
133
|
+
"name" => entry.key,
|
|
134
|
+
"mimeType" => mime_for_format(entry.format),
|
|
135
|
+
}
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def handle_resources_read(rid, params)
|
|
139
|
+
uri = params["uri"].to_s
|
|
140
|
+
key = uri.delete_prefix("textus://").tr("/", ".")
|
|
141
|
+
emit_result(rid, resource_contents(uri, key))
|
|
142
|
+
rescue Textus::Error => e
|
|
143
|
+
emit_error(rid, ToolError::JSONRPC_CODE, "resource read failed: #{e.message}")
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def resource_contents(uri, key)
|
|
147
|
+
env = @store.as(@role).get(key)
|
|
148
|
+
text = resource_text(env.content || env.body || "")
|
|
149
|
+
mime = mime_for_format(@store.manifest.resolver.resolve(key).entry.format)
|
|
150
|
+
{ "contents" => [{ "uri" => uri, "mimeType" => mime, "text" => text }] }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def resource_text(content)
|
|
154
|
+
content.is_a?(Hash) ? JSON.dump(content) : content.to_s
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def contract_etag
|
|
158
|
+
Textus::Etag.for_contract(@store.root)
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def mime_for_format(format)
|
|
162
|
+
case format.to_s
|
|
163
|
+
when "json" then "application/json"
|
|
164
|
+
when "yaml" then "application/yaml"
|
|
165
|
+
else "text/plain"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def emit_result(rid, result)
|
|
170
|
+
write({ "jsonrpc" => "2.0", "id" => rid, "result" => result })
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def emit_error(rid, code, message)
|
|
174
|
+
write({ "jsonrpc" => "2.0", "id" => rid, "error" => { "code" => code, "message" => message } })
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def write(obj)
|
|
178
|
+
@stdout.puts(JSON.dump(obj))
|
|
179
|
+
@stdout.flush
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Surfaces
|
|
3
|
+
module MCP
|
|
4
|
+
# Kept for name stability (ADR 0039). The JSON schemas are DERIVED from
|
|
5
|
+
# per-verb contracts; this delegates to MCP::Catalog. The hand-written
|
|
6
|
+
# array is gone — a kwarg rename now updates the schema automatically (and
|
|
7
|
+
# the signature guard fails if the contract lags the use-case).
|
|
8
|
+
module ToolSchemas
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def all
|
|
12
|
+
Catalog.tool_schemas
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Surfaces
|
|
5
|
+
# Role-scoped identity carrier. Holds the acting identity (role,
|
|
6
|
+
# correlation_id, dry_run) bound to a container. All verb methods
|
|
7
|
+
# (put, get, accept, ...) are injected by textus.rb's define_method
|
|
8
|
+
# loop, which dispatches directly through Gate.
|
|
9
|
+
class RoleScope
|
|
10
|
+
attr_reader :container, :role, :correlation_id
|
|
11
|
+
|
|
12
|
+
def initialize(container:, role:, dry_run: false, correlation_id: nil)
|
|
13
|
+
@container = container
|
|
14
|
+
@role = role.to_s
|
|
15
|
+
@dry_run = dry_run
|
|
16
|
+
@correlation_id = correlation_id || SecureRandom.uuid
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def dry_run? = !!@dry_run
|
|
20
|
+
|
|
21
|
+
def with_role(role)
|
|
22
|
+
self.class.new(container: @container, role:, dry_run: @dry_run, correlation_id: @correlation_id)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def with_correlation_id(cid)
|
|
26
|
+
self.class.new(container: @container, role: @role, dry_run: @dry_run, correlation_id: cid)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def with_dry_run
|
|
30
|
+
self.class.new(container: @container, role: @role, dry_run: true, correlation_id: @correlation_id)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Textus
|
|
6
|
+
module Surfaces
|
|
7
|
+
class Watcher
|
|
8
|
+
def initialize(container:)
|
|
9
|
+
@container = container
|
|
10
|
+
@queue = Textus::Ports::JobStore.new(root: container.root)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def tick
|
|
14
|
+
Textus::Jobs::Planner.seed(
|
|
15
|
+
container: @container,
|
|
16
|
+
queue: @queue,
|
|
17
|
+
role: Textus::Role::AUTOMATION,
|
|
18
|
+
)
|
|
19
|
+
@queue.reclaim(now: Textus::Ports::Clock.new.now)
|
|
20
|
+
Textus::Jobs::Worker.for(container: @container, queue: @queue).drain
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def run(poll: nil)
|
|
24
|
+
interval = poll || @container.manifest.data.worker_config[:poll]
|
|
25
|
+
lock = Textus::Ports::WatcherLock.new(@container.root)
|
|
26
|
+
lock.acquire
|
|
27
|
+
begin
|
|
28
|
+
loop do
|
|
29
|
+
tick
|
|
30
|
+
sleep(interval)
|
|
31
|
+
end
|
|
32
|
+
ensure
|
|
33
|
+
lock.release
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
data/lib/textus/version.rb
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Workflow
|
|
3
|
+
class Collector
|
|
4
|
+
@current = nil
|
|
5
|
+
|
|
6
|
+
class << self
|
|
7
|
+
attr_reader :current
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.with(collector)
|
|
11
|
+
prev = @current
|
|
12
|
+
@current = collector
|
|
13
|
+
yield
|
|
14
|
+
ensure
|
|
15
|
+
@current = prev
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(registry)
|
|
19
|
+
@registry = registry
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def register(defn)
|
|
23
|
+
@registry.register(defn)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|