textus 0.53.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 +34 -0
- data/SPEC.md +2 -5
- data/docs/architecture/README.md +149 -159
- data/lib/textus/action/accept.rb +0 -9
- data/lib/textus/action/audit.rb +0 -2
- data/lib/textus/action/blame.rb +0 -2
- data/lib/textus/action/boot.rb +2 -11
- data/lib/textus/action/data_mv.rb +1 -3
- data/lib/textus/action/deps.rb +1 -15
- data/lib/textus/action/doctor.rb +0 -2
- data/lib/textus/action/drain.rb +4 -6
- data/lib/textus/action/enqueue.rb +3 -5
- data/lib/textus/action/get.rb +1 -3
- data/lib/textus/action/ingest.rb +135 -0
- data/lib/textus/action/jobs.rb +1 -3
- data/lib/textus/action/key_delete.rb +0 -10
- data/lib/textus/action/key_delete_prefix.rb +1 -3
- data/lib/textus/action/key_mv.rb +1 -15
- data/lib/textus/action/key_mv_prefix.rb +1 -3
- data/lib/textus/action/list.rb +0 -2
- data/lib/textus/action/propose.rb +0 -2
- data/lib/textus/action/published.rb +0 -2
- data/lib/textus/action/pulse.rb +7 -38
- data/lib/textus/action/put.rb +1 -12
- data/lib/textus/action/rdeps.rb +2 -12
- data/lib/textus/action/reject.rb +0 -12
- data/lib/textus/action/rule_explain.rb +0 -4
- data/lib/textus/action/rule_lint.rb +1 -3
- data/lib/textus/action/rule_list.rb +0 -4
- data/lib/textus/action/schema_envelope.rb +0 -2
- data/lib/textus/action/uid.rb +0 -2
- data/lib/textus/action/where.rb +0 -2
- data/lib/textus/action/write_verb.rb +4 -18
- data/lib/textus/boot.rb +33 -109
- data/lib/textus/command.rb +6 -1
- data/lib/textus/container.rb +1 -1
- data/lib/textus/core/freshness/evaluator.rb +18 -34
- data/lib/textus/doctor/check/notebook_sources.rb +50 -0
- data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
- data/lib/textus/doctor/check.rb +0 -1
- data/lib/textus/doctor.rb +2 -42
- data/lib/textus/envelope/reader.rb +46 -0
- data/lib/textus/envelope/writer.rb +209 -0
- data/lib/textus/errors.rb +8 -4
- 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 +58 -12
- data/lib/textus/gate.rb +2 -0
- data/lib/textus/init.rb +12 -75
- data/lib/textus/jobs/base.rb +23 -0
- data/lib/textus/jobs/materialize.rb +20 -0
- data/lib/textus/{background → jobs}/plan.rb +1 -1
- 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/{background → jobs}/worker.rb +2 -2
- data/lib/textus/jobs.rb +15 -0
- data/lib/textus/key/path.rb +1 -1
- data/lib/textus/manifest/entry/base.rb +4 -19
- data/lib/textus/manifest/entry/parser.rb +27 -10
- data/lib/textus/manifest/entry/produced.rb +13 -34
- data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
- 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/manifest/policy/source.rb +14 -62
- data/lib/textus/manifest/policy.rb +2 -5
- data/lib/textus/manifest/resolver.rb +2 -2
- data/lib/textus/manifest/schema/keys.rb +6 -20
- data/lib/textus/manifest/schema/validator.rb +3 -11
- data/lib/textus/manifest/schema/vocabulary.rb +1 -0
- data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
- data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
- data/lib/textus/produce/engine.rb +50 -0
- data/lib/textus/produce/render.rb +18 -0
- data/lib/textus/session.rb +1 -1
- data/lib/textus/store.rb +1 -10
- data/lib/textus/surfaces/mcp/catalog.rb +1 -1
- data/lib/textus/surfaces/mcp/errors.rb +0 -9
- data/lib/textus/surfaces/mcp/routing.rb +51 -0
- data/lib/textus/surfaces/mcp/server.rb +101 -62
- data/lib/textus/surfaces/role_scope.rb +0 -4
- data/lib/textus/surfaces/watcher.rb +3 -3
- 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 +12 -1
- metadata +33 -61
- data/lib/textus/action/pulse/scanner.rb +0 -118
- data/lib/textus/background/job/base.rb +0 -27
- data/lib/textus/background/job/materialize.rb +0 -31
- data/lib/textus/background/job/refresh.rb +0 -22
- data/lib/textus/background/job/sweep.rb +0 -31
- data/lib/textus/background/job.rb +0 -19
- data/lib/textus/background/planner/plan.rb +0 -113
- data/lib/textus/background/retention/apply.rb +0 -50
- data/lib/textus/doctor/check/handler_permit.rb +0 -34
- data/lib/textus/doctor/check/hooks.rb +0 -25
- data/lib/textus/doctor/check/intake_registration.rb +0 -46
- data/lib/textus/envelope/io/reader.rb +0 -48
- data/lib/textus/envelope/io/writer.rb +0 -211
- data/lib/textus/events.rb +0 -21
- data/lib/textus/init/templates/machine_intake.rb +0 -49
- data/lib/textus/init/templates/orientation_reducer.rb +0 -21
- data/lib/textus/manifest/entry/validators/events.rb +0 -22
- data/lib/textus/manifest/policy/handler_permit.rb +0 -17
- data/lib/textus/pipeline/acquire/handler.rb +0 -29
- data/lib/textus/pipeline/acquire/intake.rb +0 -132
- data/lib/textus/pipeline/acquire/projection.rb +0 -129
- data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
- data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
- data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
- data/lib/textus/pipeline/acquire/serializer.rb +0 -17
- data/lib/textus/pipeline/engine.rb +0 -97
- data/lib/textus/pipeline/render.rb +0 -25
- data/lib/textus/ports/audit_subscriber.rb +0 -42
- data/lib/textus/projection.rb +0 -103
- data/lib/textus/step/base.rb +0 -35
- data/lib/textus/step/builtin/csv_fetch.rb +0 -19
- data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
- data/lib/textus/step/builtin/json_fetch.rb +0 -18
- data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
- data/lib/textus/step/builtin/rss_fetch.rb +0 -26
- data/lib/textus/step/builtin.rb +0 -22
- data/lib/textus/step/catalog.rb +0 -37
- data/lib/textus/step/context.rb +0 -74
- data/lib/textus/step/discovery.rb +0 -24
- data/lib/textus/step/error_log.rb +0 -32
- data/lib/textus/step/event_bus.rb +0 -113
- data/lib/textus/step/fetch.rb +0 -13
- data/lib/textus/step/fire_report.rb +0 -23
- data/lib/textus/step/loader.rb +0 -108
- data/lib/textus/step/observe.rb +0 -31
- data/lib/textus/step/registry_store.rb +0 -66
- data/lib/textus/step/signature.rb +0 -31
- data/lib/textus/step/transform.rb +0 -12
- data/lib/textus/step/validate.rb +0 -11
- data/lib/textus/step.rb +0 -10
|
@@ -9,23 +9,16 @@ module Textus
|
|
|
9
9
|
LANE_KEYS = %w[name kind owner desc].freeze
|
|
10
10
|
ENTRY_KEYS = %w[
|
|
11
11
|
key path lane kind schema owner nested format
|
|
12
|
-
source publish
|
|
13
|
-
events ignore tracked
|
|
12
|
+
source publish tracked ignore
|
|
14
13
|
].freeze
|
|
15
14
|
# ADR 0052: the typed publish block — `publish: { to: [...] }` (file
|
|
16
15
|
# fan-out) xor `publish: { tree: "dir" }` (subtree mirror).
|
|
17
16
|
PUBLISH_KEYS = %w[to tree].freeze
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
# `inject_boot`/`provenance` fields are kept here so the schema walk can
|
|
24
|
-
# still emit the migration hint rather than a bare "unknown key".
|
|
25
|
-
SOURCE_KEYS = %w[
|
|
26
|
-
from handler config template project command sources ttl inject_boot provenance
|
|
27
|
-
select pluck sort_by transform
|
|
28
|
-
].freeze
|
|
17
|
+
# entry-level external-generator block — only `from: external` is valid.
|
|
18
|
+
# `from: fetch` and `from: derive` and their specific fields (handler,
|
|
19
|
+
# config, ttl, select, pluck, sort_by, transform) were removed in the
|
|
20
|
+
# workflow redesign.
|
|
21
|
+
SOURCE_KEYS = %w[from command sources].freeze
|
|
29
22
|
# ADR 0093: rule-level GC slot. drop/archive only (refresh gone).
|
|
30
23
|
RETENTION_KEYS = %w[ttl action].freeze
|
|
31
24
|
|
|
@@ -60,13 +53,6 @@ module Textus
|
|
|
60
53
|
# Key order here fixes the order of RULE_KEYS (after match), the slots,
|
|
61
54
|
# the RuleSet members, and the doctor SLOTS.
|
|
62
55
|
FIELD_REGISTRY = {
|
|
63
|
-
handler_permit: {
|
|
64
|
-
yaml_key: "handler_permit",
|
|
65
|
-
policy_class: Textus::Manifest::Policy::HandlerPermit,
|
|
66
|
-
validation: :immediate, sub_keys: nil, arg_key: :handlers,
|
|
67
|
-
in_pick: true, in_ambiguity: true,
|
|
68
|
-
in_rule_list: true, in_rule_explain: %i[detail]
|
|
69
|
-
},
|
|
70
56
|
guard: {
|
|
71
57
|
yaml_key: "guard",
|
|
72
58
|
policy_class: nil,
|
|
@@ -289,17 +289,9 @@ module Textus
|
|
|
289
289
|
# out). Per ADR 0095 the produce-method is read from source.from on the one
|
|
290
290
|
# Produced kind, so there is no longer a kind to agree against the source.
|
|
291
291
|
# (Replaces validate_upkeep_kinds!.)
|
|
292
|
-
def validate_source_and_retention!(
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
next if retention.nil?
|
|
296
|
-
next unless entry.derived?
|
|
297
|
-
|
|
298
|
-
raise BadManifest.new(
|
|
299
|
-
"entry '#{entry.key}': a derived entry regenerates from its source; " \
|
|
300
|
-
"retention (drop/archive) is invalid",
|
|
301
|
-
)
|
|
302
|
-
end
|
|
292
|
+
def validate_source_and_retention!(_manifest)
|
|
293
|
+
# No-op: from: derive is removed; retention + produced entries are
|
|
294
|
+
# always valid (retention drives age-GC on workflow-produced entries).
|
|
303
295
|
end
|
|
304
296
|
|
|
305
297
|
# Write authority is derived from capabilities (ADR 0030): a lane of a
|
|
@@ -3,7 +3,7 @@ require "json"
|
|
|
3
3
|
|
|
4
4
|
module Textus
|
|
5
5
|
module Ports
|
|
6
|
-
class
|
|
6
|
+
class JobStore
|
|
7
7
|
# A unit of deferred work. Pure data. The id is `<type>:<digest>` where the
|
|
8
8
|
# digest is over the args with sorted keys, so logically-identical enqueues
|
|
9
9
|
# collide on the same id — which is how Queue dedups (file already exists).
|
|
@@ -10,7 +10,7 @@ module Textus
|
|
|
10
10
|
# worker pool needs no central lock. Dedup falls out of the id-as-filename:
|
|
11
11
|
# enqueueing an id that already exists is a no-op. ADR 0038 (runtime subtree),
|
|
12
12
|
# ADR 0108 (instantiable port).
|
|
13
|
-
class
|
|
13
|
+
class JobStore
|
|
14
14
|
STATES = %i[ready leased done failed].freeze
|
|
15
15
|
|
|
16
16
|
def initialize(root:)
|
|
@@ -20,7 +20,7 @@ module Textus
|
|
|
20
20
|
|
|
21
21
|
def enqueue(job)
|
|
22
22
|
dest = path(:ready, job.id)
|
|
23
|
-
return if File.exist?(dest)
|
|
23
|
+
return if File.exist?(dest)
|
|
24
24
|
|
|
25
25
|
write_atomic(dest, job.to_h)
|
|
26
26
|
end
|
|
@@ -29,7 +29,6 @@ module Textus
|
|
|
29
29
|
Dir.children(Textus::Layout.queue_state(@root, :ready)).map { |f| File.basename(f, ".json") }
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
# A claimed job plus the path it lives at, so ack/fail act on this copy.
|
|
33
32
|
Leased = Struct.new(:job, :leased_path, keyword_init: true)
|
|
34
33
|
|
|
35
34
|
def lease(worker_id:, lease_ttl:)
|
|
@@ -38,9 +37,9 @@ module Textus
|
|
|
38
37
|
src = File.join(ready_dir, name)
|
|
39
38
|
dst = File.join(Textus::Layout.queue_state(@root, :leased), name)
|
|
40
39
|
begin
|
|
41
|
-
File.rename(src, dst)
|
|
40
|
+
File.rename(src, dst)
|
|
42
41
|
rescue Errno::ENOENT
|
|
43
|
-
next
|
|
42
|
+
next
|
|
44
43
|
end
|
|
45
44
|
job = Job.from_h(JSON.parse(File.read(dst)))
|
|
46
45
|
stamp_lease(dst, worker_id: worker_id, expires_at: Time.now.utc + lease_ttl)
|
|
@@ -54,9 +53,6 @@ module Textus
|
|
|
54
53
|
File.rename(leased.leased_path, dest)
|
|
55
54
|
end
|
|
56
55
|
|
|
57
|
-
# Increment attempts and either requeue (transient) or dead-letter (attempts
|
|
58
|
-
# exhausted). Returns :requeued or :dead_lettered so the worker can count
|
|
59
|
-
# terminal failures distinctly from retries.
|
|
60
56
|
def fail(leased, error:)
|
|
61
57
|
job = leased.job
|
|
62
58
|
job.attempts += 1
|
|
@@ -67,9 +63,6 @@ module Textus
|
|
|
67
63
|
dead ? :dead_lettered : :requeued
|
|
68
64
|
end
|
|
69
65
|
|
|
70
|
-
# Return expired leases to ready/ (the holding worker crashed). Returns the
|
|
71
|
-
# count reclaimed. At-least-once delivery: a job whose handler actually
|
|
72
|
-
# finished but whose ack was lost will re-run — handlers must be idempotent.
|
|
73
66
|
def reclaim(now:)
|
|
74
67
|
leased_dir = Textus::Layout.queue_state(@root, :leased)
|
|
75
68
|
count = 0
|
|
@@ -85,7 +78,7 @@ module Textus
|
|
|
85
78
|
File.rename(src, dst)
|
|
86
79
|
count += 1
|
|
87
80
|
rescue Errno::ENOENT
|
|
88
|
-
next
|
|
81
|
+
next
|
|
89
82
|
end
|
|
90
83
|
count
|
|
91
84
|
end
|
|
@@ -123,7 +116,7 @@ module Textus
|
|
|
123
116
|
def write_atomic(dest, hash)
|
|
124
117
|
tmp = "#{dest}.#{Process.pid}.tmp"
|
|
125
118
|
File.write(tmp, JSON.pretty_generate(hash))
|
|
126
|
-
File.rename(tmp, dest)
|
|
119
|
+
File.rename(tmp, dest)
|
|
127
120
|
end
|
|
128
121
|
end
|
|
129
122
|
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Produce
|
|
3
|
+
class Engine
|
|
4
|
+
def self.converge(container:, call:, keys:)
|
|
5
|
+
new(container:, call:).run(keys)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def initialize(container:, call:)
|
|
9
|
+
@container = container
|
|
10
|
+
@call = call
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run(keys)
|
|
14
|
+
results = { completed: [], failed: [] }
|
|
15
|
+
Array(keys).each { |key| produce_one(key, results) }
|
|
16
|
+
results
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
alias call run
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def produce_one(key, results)
|
|
24
|
+
workflow = @container.workflows.for(key)
|
|
25
|
+
if workflow
|
|
26
|
+
Workflow::Runner.new(workflow, container: @container, call: @call).run(key)
|
|
27
|
+
else
|
|
28
|
+
publish_only(key)
|
|
29
|
+
end
|
|
30
|
+
results[:completed] << key
|
|
31
|
+
rescue StandardError => e
|
|
32
|
+
results[:failed] << { key: key, error: e.message }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def publish_only(key)
|
|
36
|
+
entry = @container.manifest.resolver.resolve(key).entry
|
|
37
|
+
return unless entry.publish_tree || !Array(entry.publish_to).empty?
|
|
38
|
+
|
|
39
|
+
reader = Textus::Envelope::Reader.from(container: @container)
|
|
40
|
+
entry_path = @container.manifest.resolver.resolve(key).path
|
|
41
|
+
return unless entry.publish_tree || File.exist?(entry_path)
|
|
42
|
+
|
|
43
|
+
pctx = Textus::Manifest::Entry::Base::PublishContext.new(
|
|
44
|
+
container: @container, call: @call, reader: reader.method(:read),
|
|
45
|
+
)
|
|
46
|
+
entry.publish_via(pctx)
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
require "mustache"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Produce
|
|
5
|
+
class Render
|
|
6
|
+
def initialize(template_loader:)
|
|
7
|
+
@template_loader = template_loader
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def bytes_for(target:, data:, boot:)
|
|
11
|
+
raise ArgumentError.new("Produce::Render called for a verbatim target #{target.to.inspect}") unless target.renders?
|
|
12
|
+
|
|
13
|
+
ctx = target.inject_boot ? data.merge("boot" => boot) : data
|
|
14
|
+
Mustache.render(@template_loader.call(target.template), ctx)
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
data/lib/textus/session.rb
CHANGED
|
@@ -12,7 +12,7 @@ module Textus
|
|
|
12
12
|
def check_etag!(observed_etag)
|
|
13
13
|
return if observed_etag == contract_etag
|
|
14
14
|
|
|
15
|
-
raise Textus::
|
|
15
|
+
raise Textus::ContractDrift.new(
|
|
16
16
|
"contract changed (manifest/hooks/schemas were #{short_etag(contract_etag)}, " \
|
|
17
17
|
"now #{short_etag(observed_etag)}); re-run boot",
|
|
18
18
|
)
|
data/lib/textus/store.rb
CHANGED
|
@@ -44,9 +44,6 @@ module Textus
|
|
|
44
44
|
|
|
45
45
|
def initialize(root)
|
|
46
46
|
@container = build_container(File.expand_path(root))
|
|
47
|
-
bootstrap_hooks
|
|
48
|
-
steps.publish(:store_loaded, ctx: Step::Context.for(container: @container,
|
|
49
|
-
call: Textus::Call.build(role: Role::DEFAULT)))
|
|
50
47
|
end
|
|
51
48
|
|
|
52
49
|
# Build an agent Session oriented at the current cursor/manifest — the
|
|
@@ -82,7 +79,7 @@ module Textus
|
|
|
82
79
|
max_size: manifest.data.audit_config[:max_size],
|
|
83
80
|
keep: manifest.data.audit_config[:keep],
|
|
84
81
|
),
|
|
85
|
-
|
|
82
|
+
workflows: Workflow::Loader.load_all(root),
|
|
86
83
|
gate: nil,
|
|
87
84
|
)
|
|
88
85
|
gate = Textus::Gate.new(container)
|
|
@@ -90,11 +87,5 @@ module Textus
|
|
|
90
87
|
gate.instance_variable_set(:@container, container)
|
|
91
88
|
container
|
|
92
89
|
end
|
|
93
|
-
|
|
94
|
-
def bootstrap_hooks
|
|
95
|
-
Ports::AuditSubscriber.new(audit_log).attach(steps)
|
|
96
|
-
Textus::Step::Builtin.register_all(steps)
|
|
97
|
-
Textus::Step::Loader.new(registry: steps).load_dir(File.join(root, "steps"))
|
|
98
|
-
end
|
|
99
90
|
end
|
|
100
91
|
end
|
|
@@ -88,7 +88,7 @@ module Textus
|
|
|
88
88
|
Textus::Contract::View.render(spec, :default, result, inputs)
|
|
89
89
|
rescue Textus::Contract::MissingArgs => e
|
|
90
90
|
raise ToolError.new("#{spec.verb}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
|
|
91
|
-
rescue ContractDrift, CursorExpired
|
|
91
|
+
rescue Textus::ContractDrift, CursorExpired
|
|
92
92
|
raise
|
|
93
93
|
rescue Textus::Error => e
|
|
94
94
|
raise ToolError.new("#{name}: #{e.message}")
|
|
@@ -1,15 +1,6 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Surfaces
|
|
3
3
|
module MCP
|
|
4
|
-
# Manifest fingerprint changed mid-session. Client should re-boot.
|
|
5
|
-
class ContractDrift < Textus::Error
|
|
6
|
-
JSONRPC_CODE = -32_001
|
|
7
|
-
|
|
8
|
-
def initialize(message, details: {})
|
|
9
|
-
super("contract_drift", message, details: details)
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
|
|
13
4
|
# Audit cursor fell off the keep window. Client should re-boot and
|
|
14
5
|
# resume from the new latest_seq.
|
|
15
6
|
class CursorExpired < Textus::Error
|
|
@@ -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
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "json"
|
|
2
|
+
require_relative "routing"
|
|
2
3
|
|
|
3
4
|
module Textus
|
|
4
5
|
module Surfaces
|
|
@@ -6,9 +7,11 @@ module Textus
|
|
|
6
7
|
# Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. One line per
|
|
7
8
|
# message (NDJSON). Holds a single Session for the lifetime of stdin.
|
|
8
9
|
class Server
|
|
10
|
+
include Routing
|
|
11
|
+
|
|
9
12
|
PROTOCOL_VERSION = "2024-11-05"
|
|
10
|
-
SERVER_INFO
|
|
11
|
-
MAX_LINE_BYTES
|
|
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
|
|
12
15
|
|
|
13
16
|
def initialize(store:, stdin: $stdin, stdout: $stdout, role: Textus::Role::DEFAULT)
|
|
14
17
|
@store = store
|
|
@@ -30,59 +33,41 @@ module Textus
|
|
|
30
33
|
private
|
|
31
34
|
|
|
32
35
|
def handle_line(line)
|
|
33
|
-
if line.bytesize > MAX_LINE_BYTES
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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))
|
|
38
47
|
rescue JSON::ParserError => e
|
|
39
48
|
emit_error(nil, -32_700, "parse error: #{e.message}")
|
|
40
|
-
else
|
|
41
|
-
dispatch(msg)
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def dispatch(msg)
|
|
45
|
-
rid = msg["id"]
|
|
46
|
-
case msg["method"]
|
|
47
|
-
when "initialize" then handle_initialize(rid, msg["params"] || {})
|
|
48
|
-
when "tools/list" then handle_tools_list(rid)
|
|
49
|
-
when "tools/call" then handle_tools_call(rid, msg["params"] || {})
|
|
50
|
-
when "ping" then emit_result(rid, {})
|
|
51
|
-
when "shutdown" then emit_result(rid, nil)
|
|
52
|
-
when "notifications/initialized" then nil
|
|
53
|
-
else emit_error(rid, -32_601, "method not found: #{msg["method"]}")
|
|
54
|
-
end
|
|
55
49
|
end
|
|
56
50
|
|
|
57
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
|
|
58
61
|
# The acting role IS the resolved connection role (ADR 0040): the MCP
|
|
59
62
|
# transport defaults to `agent`, which can write the queue, so its
|
|
60
63
|
# propose_lane resolves directly. If a connection's role cannot propose,
|
|
61
64
|
# propose_lane is nil and the `propose` tool reports that honestly.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
@session = Session.new(
|
|
65
|
+
Session.new(
|
|
65
66
|
role: @role,
|
|
66
67
|
cursor: @store.audit_log.latest_seq,
|
|
67
|
-
propose_lane:
|
|
68
|
+
propose_lane: @store.manifest.policy.propose_lane_for(@role),
|
|
68
69
|
contract_etag: contract_etag,
|
|
69
70
|
)
|
|
70
|
-
|
|
71
|
-
# ADR 0075: announce the connection to connect-time hooks with the
|
|
72
|
-
# resolved role. Distinct from :store_loaded (fired at Store.new under
|
|
73
|
-
# the default role, before any connection's role is known).
|
|
74
|
-
@store.steps.publish(
|
|
75
|
-
:session_opened,
|
|
76
|
-
ctx: Step::Context.new(scope: @store.as(@role)),
|
|
77
|
-
role: @role,
|
|
78
|
-
cursor: @session.cursor,
|
|
79
|
-
)
|
|
80
|
-
|
|
81
|
-
emit_result(rid, {
|
|
82
|
-
"protocolVersion" => PROTOCOL_VERSION,
|
|
83
|
-
"serverInfo" => SERVER_INFO,
|
|
84
|
-
"capabilities" => { "tools" => {} },
|
|
85
|
-
})
|
|
86
71
|
end
|
|
87
72
|
|
|
88
73
|
def handle_tools_list(rid)
|
|
@@ -90,43 +75,97 @@ module Textus
|
|
|
90
75
|
end
|
|
91
76
|
|
|
92
77
|
def handle_tools_call(rid, params)
|
|
93
|
-
unless
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
97
86
|
|
|
98
|
-
|
|
99
|
-
|
|
87
|
+
def session_ready?(rid)
|
|
88
|
+
return true if @session
|
|
100
89
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
# read returns on-disk truth; boot re-orients). Keying on read_verbs
|
|
105
|
-
# (not write_verbs) keeps the destructive Maintenance:: verbs gated.
|
|
106
|
-
@session.check_etag!(contract_etag) unless Catalog.read_verbs.include?(name)
|
|
90
|
+
emit_error(rid, -32_002, "session not initialized; call 'initialize' first")
|
|
91
|
+
false
|
|
92
|
+
end
|
|
107
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)
|
|
108
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)
|
|
109
103
|
@session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "pulse"
|
|
110
104
|
@session = @session.with(contract_etag: contract_etag) if name == "boot"
|
|
105
|
+
end
|
|
111
106
|
|
|
107
|
+
def emit_tool_result(rid, result)
|
|
112
108
|
emit_result(rid, {
|
|
113
109
|
"content" => [{ "type" => "text", "text" => JSON.dump(result) }],
|
|
114
110
|
"isError" => false,
|
|
115
111
|
})
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
124
155
|
end
|
|
125
156
|
|
|
126
157
|
def contract_etag
|
|
127
158
|
Textus::Etag.for_contract(@store.root)
|
|
128
159
|
end
|
|
129
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
|
+
|
|
130
169
|
def emit_result(rid, result)
|
|
131
170
|
write({ "jsonrpc" => "2.0", "id" => rid, "result" => result })
|
|
132
171
|
end
|
|
@@ -7,17 +7,17 @@ module Textus
|
|
|
7
7
|
class Watcher
|
|
8
8
|
def initialize(container:)
|
|
9
9
|
@container = container
|
|
10
|
-
@queue = Textus::Ports::
|
|
10
|
+
@queue = Textus::Ports::JobStore.new(root: container.root)
|
|
11
11
|
end
|
|
12
12
|
|
|
13
13
|
def tick
|
|
14
|
-
Textus::
|
|
14
|
+
Textus::Jobs::Planner.seed(
|
|
15
15
|
container: @container,
|
|
16
16
|
queue: @queue,
|
|
17
17
|
role: Textus::Role::AUTOMATION,
|
|
18
18
|
)
|
|
19
19
|
@queue.reclaim(now: Textus::Ports::Clock.new.now)
|
|
20
|
-
Textus::
|
|
20
|
+
Textus::Jobs::Worker.for(container: @container, queue: @queue).drain
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def run(poll: nil)
|
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
|