textus 0.53.0 → 0.54.1
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 +52 -0
- data/README.md +53 -73
- 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 +40 -109
- data/lib/textus/command.rb +6 -1
- data/lib/textus/container.rb +21 -5
- data/lib/textus/core/freshness/evaluator.rb +18 -34
- data/lib/textus/core/freshness/verdict.rb +1 -1
- data/lib/textus/doctor/check/notebook_sources.rb +50 -0
- data/lib/textus/doctor/check/protocol_version.rb +6 -6
- data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
- data/lib/textus/doctor/check/stale_reviewed_stamp.rb +54 -0
- data/lib/textus/doctor/check.rb +0 -1
- data/lib/textus/doctor.rb +3 -42
- data/lib/textus/envelope/reader.rb +46 -0
- data/lib/textus/envelope/writer.rb +209 -0
- data/lib/textus/envelope.rb +19 -6
- data/lib/textus/errors.rb +9 -5
- 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 +16 -79
- 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/contract.rb +61 -0
- data/lib/textus/manifest/schema/keys.rb +6 -20
- data/lib/textus/manifest/schema/semantics.rb +232 -0
- data/lib/textus/manifest/schema/validator.rb +24 -302
- data/lib/textus/manifest/schema/vocabulary.rb +1 -0
- data/lib/textus/manifest/schema.rb +1 -4
- data/lib/textus/manifest.rb +1 -3
- 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 +20 -0
- data/lib/textus/session.rb +15 -8
- data/lib/textus/store.rb +5 -14
- data/lib/textus/surfaces/mcp/catalog.rb +23 -11
- data/lib/textus/surfaces/mcp/errors.rb +0 -9
- data/lib/textus/surfaces/mcp/server.rb +87 -110
- data/lib/textus/surfaces/role_scope.rb +0 -4
- data/lib/textus/surfaces/watcher.rb +3 -3
- data/lib/textus/types.rb +15 -0
- data/lib/textus/version.rb +2 -2
- 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 +99 -0
- data/lib/textus/workflow.rb +28 -0
- data/lib/textus.rb +12 -1
- metadata +67 -66
- 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
- data/lib/textus/surfaces/mcp/session.rb +0 -9
- data/lib/textus/surfaces/mcp/tool_schemas.rb +0 -17
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
module Surfaces
|
|
3
3
|
module MCP
|
|
4
|
-
# Derives the entire MCP tool surface from the per-verb contracts
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# return value with the contract's default view. No per-tool code.
|
|
4
|
+
# Derives the entire MCP tool surface from the per-verb contracts (ADR 0039).
|
|
5
|
+
# `build_tools` builds MCP::Tool instances for the SDK; `call` is the generic
|
|
6
|
+
# dispatch: map JSON args -> (positional, keyword) per the contract, invoke
|
|
7
|
+
# the verb through the role scope, then shape the return value. No per-tool code.
|
|
9
8
|
module Catalog
|
|
10
9
|
module_function
|
|
11
10
|
|
|
@@ -24,14 +23,27 @@ module Textus
|
|
|
24
23
|
.map(&:contract)
|
|
25
24
|
end
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
26
|
+
# Builds MCP::Tool instances for the SDK, bound to mcp_server.dispatch.
|
|
27
|
+
def build_tools(mcp_server)
|
|
28
|
+
Textus::Action::VERBS
|
|
29
|
+
.select { |_, klass| mcp_surfaced?(klass) }
|
|
30
|
+
.map do |name, action|
|
|
31
|
+
schema = action.contract.input_schema
|
|
32
|
+
schema = schema.reject { |k, v| k == :required && Array(v).empty? }
|
|
33
|
+
::MCP::Tool.define(
|
|
34
|
+
name: name.to_s,
|
|
35
|
+
description: action.contract.summary,
|
|
36
|
+
input_schema: schema,
|
|
37
|
+
) do |server_context:, **args|
|
|
38
|
+
mcp_server.dispatch(name, args, server_context)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
31
41
|
end
|
|
32
42
|
|
|
33
43
|
def names
|
|
34
|
-
|
|
44
|
+
Textus::Action::VERBS
|
|
45
|
+
.select { |_, klass| mcp_surfaced?(klass) }
|
|
46
|
+
.keys.map(&:to_s)
|
|
35
47
|
end
|
|
36
48
|
|
|
37
49
|
# MCP-surfaced read verbs, by Dispatcher class namespace — the agent's
|
|
@@ -88,7 +100,7 @@ module Textus
|
|
|
88
100
|
Textus::Contract::View.render(spec, :default, result, inputs)
|
|
89
101
|
rescue Textus::Contract::MissingArgs => e
|
|
90
102
|
raise ToolError.new("#{spec.verb}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
|
|
91
|
-
rescue ContractDrift, CursorExpired
|
|
103
|
+
rescue Textus::ContractDrift, CursorExpired
|
|
92
104
|
raise
|
|
93
105
|
rescue Textus::Error => e
|
|
94
106
|
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
|
|
@@ -1,143 +1,120 @@
|
|
|
1
|
-
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "mcp"
|
|
2
4
|
|
|
3
5
|
module Textus
|
|
4
6
|
module Surfaces
|
|
5
7
|
module MCP
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
+
# MCP stdio server backed by the official mcp gem. The SDK owns protocol
|
|
9
|
+
# negotiation, tool dispatch, and JSON-RPC framing. This class owns the
|
|
10
|
+
# textus Session lifecycle (built lazily on first tool call) and delegates
|
|
11
|
+
# execution to Catalog.
|
|
8
12
|
class Server
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@
|
|
17
|
-
|
|
18
|
-
|
|
13
|
+
def initialize(store:, role: Textus::Role::DEFAULT, stdin: $stdin, stdout: $stdout)
|
|
14
|
+
@store = store
|
|
15
|
+
@role = role
|
|
16
|
+
@stdin = stdin
|
|
17
|
+
@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
|
+
|
|
27
|
+
@sdk = ::MCP::Server.new(
|
|
28
|
+
name: "textus",
|
|
29
|
+
version: Textus::VERSION,
|
|
30
|
+
tools: Catalog.build_tools(self),
|
|
31
|
+
resources: build_resources,
|
|
32
|
+
server_context: { mcp_server: self },
|
|
33
|
+
)
|
|
34
|
+
@sdk.resources_read_handler { |params, server_context:| handle_resource_read(params[:uri].to_s, server_context) }
|
|
19
35
|
end
|
|
20
36
|
|
|
37
|
+
# Runs the stdio line loop; delegates each JSON line to the SDK.
|
|
21
38
|
def run
|
|
22
39
|
@stdin.each_line do |line|
|
|
23
40
|
line = line.strip
|
|
24
41
|
next if line.empty?
|
|
25
42
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
end
|
|
43
|
+
response = @sdk.handle_json(line)
|
|
44
|
+
next unless response
|
|
29
45
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def handle_line(line)
|
|
33
|
-
if line.bytesize > MAX_LINE_BYTES
|
|
34
|
-
emit_error(nil, -32_700, "message too large (#{line.bytesize} bytes, limit #{MAX_LINE_BYTES})")
|
|
35
|
-
return
|
|
46
|
+
@stdout.puts(response)
|
|
47
|
+
@stdout.flush
|
|
36
48
|
end
|
|
37
|
-
msg = JSON.parse(line)
|
|
38
|
-
rescue JSON::ParserError => e
|
|
39
|
-
emit_error(nil, -32_700, "parse error: #{e.message}")
|
|
40
|
-
else
|
|
41
|
-
dispatch(msg)
|
|
42
49
|
end
|
|
43
50
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
def dispatch(verb_name, args, _server_context)
|
|
55
|
+
str_args = deep_stringify_keys(args)
|
|
56
|
+
@session.check_etag!(contract_etag_now) unless Catalog.read_verbs.include?(verb_name.to_s)
|
|
57
|
+
result = Catalog.call(verb_name.to_s, session: @session, store: @store, args: str_args)
|
|
58
|
+
update_session_for(verb_name.to_s)
|
|
59
|
+
::MCP::Tool::Response.new([{ type: "text", text: JSON.dump(result) }])
|
|
60
|
+
rescue Textus::ContractDrift => e
|
|
61
|
+
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::Surfaces::MCP::ToolError => e
|
|
65
|
+
raise_handler_error(e.message, ToolError::JSONRPC_CODE)
|
|
66
|
+
rescue StandardError => e
|
|
67
|
+
raise_handler_error("internal: #{e.class}: #{e.message}", -32_603)
|
|
55
68
|
end
|
|
56
69
|
|
|
57
|
-
|
|
58
|
-
# The acting role IS the resolved connection role (ADR 0040): the MCP
|
|
59
|
-
# transport defaults to `agent`, which can write the queue, so its
|
|
60
|
-
# propose_lane resolves directly. If a connection's role cannot propose,
|
|
61
|
-
# propose_lane is nil and the `propose` tool reports that honestly.
|
|
62
|
-
propose_lane = @store.manifest.policy.propose_lane_for(@role)
|
|
63
|
-
|
|
64
|
-
@session = Session.new(
|
|
65
|
-
role: @role,
|
|
66
|
-
cursor: @store.audit_log.latest_seq,
|
|
67
|
-
propose_lane: propose_lane,
|
|
68
|
-
contract_etag: contract_etag,
|
|
69
|
-
)
|
|
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
|
-
end
|
|
70
|
+
private
|
|
87
71
|
|
|
88
|
-
def
|
|
89
|
-
|
|
72
|
+
def update_session_for(verb_name)
|
|
73
|
+
@session = @session.advance_cursor(@store.audit_log.latest_seq) if verb_name == "pulse"
|
|
74
|
+
@session = @session.with(contract_etag: contract_etag_now) if verb_name == "boot"
|
|
90
75
|
end
|
|
91
76
|
|
|
92
|
-
def
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return
|
|
96
|
-
end
|
|
77
|
+
def build_resources
|
|
78
|
+
machine_lane = @store.manifest.policy.machine_lane
|
|
79
|
+
return [] unless machine_lane
|
|
97
80
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
# ADR 0083: the contract-drift guard gates mutating verbs — every MCP
|
|
102
|
-
# verb that is NOT a pure read (Write:: + the destructive Maintenance::
|
|
103
|
-
# verbs drain/data_mv/key_*_prefix). Reads and boot bypass it (a stale
|
|
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)
|
|
107
|
-
|
|
108
|
-
result = Catalog.call(name, session: @session, store: @store, args: args)
|
|
109
|
-
@session = @session.advance_cursor(@store.audit_log.latest_seq) if name == "pulse"
|
|
110
|
-
@session = @session.with(contract_etag: contract_etag) if name == "boot"
|
|
111
|
-
|
|
112
|
-
emit_result(rid, {
|
|
113
|
-
"content" => [{ "type" => "text", "text" => JSON.dump(result) }],
|
|
114
|
-
"isError" => false,
|
|
115
|
-
})
|
|
116
|
-
rescue ContractDrift => e
|
|
117
|
-
emit_error(rid, ContractDrift::JSONRPC_CODE, e.message)
|
|
118
|
-
rescue CursorExpired => e
|
|
119
|
-
emit_error(rid, CursorExpired::JSONRPC_CODE, e.message)
|
|
120
|
-
rescue ToolError => e
|
|
121
|
-
emit_error(rid, ToolError::JSONRPC_CODE, e.message)
|
|
122
|
-
rescue StandardError => e
|
|
123
|
-
emit_error(rid, -32_603, "internal: #{e.class}: #{e.message}")
|
|
81
|
+
@store.manifest.data.entries
|
|
82
|
+
.select { |e| e.lane == machine_lane && e.is_a?(Textus::Manifest::Entry::Produced) }
|
|
83
|
+
.map { |e| ::MCP::Resource.new(uri: "textus://#{e.key.tr(".", "/")}", name: e.key, mime_type: mime_for_format(e.format)) }
|
|
124
84
|
end
|
|
125
85
|
|
|
126
|
-
def
|
|
127
|
-
|
|
86
|
+
def handle_resource_read(uri, _server_context)
|
|
87
|
+
key = uri.delete_prefix("textus://").tr("/", ".")
|
|
88
|
+
env = @store.as(@role).get(key)
|
|
89
|
+
text = env.content.is_a?(Hash) ? JSON.dump(env.content) : (env.body || "").to_s
|
|
90
|
+
mime = mime_for_format(@store.manifest.resolver.resolve(key).entry.format)
|
|
91
|
+
[{ uri: uri, mimeType: mime, text: text }]
|
|
92
|
+
rescue Textus::Error => e
|
|
93
|
+
raise_handler_error("resource read failed: #{e.message}", -32_603)
|
|
128
94
|
end
|
|
129
95
|
|
|
130
|
-
def
|
|
131
|
-
|
|
96
|
+
def contract_etag_now = Textus::Etag.for_contract(@store.root)
|
|
97
|
+
|
|
98
|
+
# The SDK parses JSON with symbolize_names:true, making all nested hash keys symbols.
|
|
99
|
+
# Recursively stringify so Catalog.call receives string-keyed hashes throughout.
|
|
100
|
+
def deep_stringify_keys(obj)
|
|
101
|
+
case obj
|
|
102
|
+
when Hash then obj.transform_keys(&:to_s).transform_values { |v| deep_stringify_keys(v) }
|
|
103
|
+
when Array then obj.map { |v| deep_stringify_keys(v) }
|
|
104
|
+
else obj
|
|
105
|
+
end
|
|
132
106
|
end
|
|
133
107
|
|
|
134
|
-
def
|
|
135
|
-
|
|
108
|
+
def mime_for_format(format)
|
|
109
|
+
case format.to_s
|
|
110
|
+
when "json" then "application/json"
|
|
111
|
+
when "yaml" then "application/yaml"
|
|
112
|
+
else "text/plain"
|
|
113
|
+
end
|
|
136
114
|
end
|
|
137
115
|
|
|
138
|
-
def
|
|
139
|
-
|
|
140
|
-
@stdout.flush
|
|
116
|
+
def raise_handler_error(message, code)
|
|
117
|
+
raise ::MCP::Server::RequestHandlerError.new(message, nil, error_code: code)
|
|
141
118
|
end
|
|
142
119
|
end
|
|
143
120
|
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/types.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry/types"
|
|
4
|
+
|
|
5
|
+
module Textus
|
|
6
|
+
module Types
|
|
7
|
+
include Dry.Types()
|
|
8
|
+
|
|
9
|
+
RoleName = Types::String.constrained(included_in: Textus::Role::NAMES)
|
|
10
|
+
Cursor = Types::Integer.constrained(gteq: 0)
|
|
11
|
+
FormatName = Types::String.constrained(
|
|
12
|
+
included_in: %w[markdown json yaml text], # must match Format::STRATEGIES.keys
|
|
13
|
+
)
|
|
14
|
+
end
|
|
15
|
+
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
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Workflow
|
|
3
|
+
module DSL
|
|
4
|
+
Step = Data.define(:name, :callable, :timeout)
|
|
5
|
+
|
|
6
|
+
class Definition
|
|
7
|
+
attr_reader :name, :steps, :match_pattern, :publish_block
|
|
8
|
+
|
|
9
|
+
def initialize(name)
|
|
10
|
+
@name = name
|
|
11
|
+
@steps = []
|
|
12
|
+
@match_pattern = nil
|
|
13
|
+
@publish_block = nil
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def match(pattern)
|
|
17
|
+
@match_pattern = pattern
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def step(name, callable_or_opt = nil, timeout: nil, &block)
|
|
21
|
+
callable = if callable_or_opt.respond_to?(:call)
|
|
22
|
+
callable_or_opt
|
|
23
|
+
elsif block
|
|
24
|
+
block
|
|
25
|
+
else
|
|
26
|
+
raise ArgumentError.new("step :#{name} requires a block or a callable (got neither)")
|
|
27
|
+
end
|
|
28
|
+
t = callable_or_opt.is_a?(Hash) ? callable_or_opt[:timeout] : timeout
|
|
29
|
+
@steps << Step.new(name: name, callable: callable, timeout: t)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def publish(&block)
|
|
33
|
+
@publish_block = block || :default
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def match?(key)
|
|
37
|
+
return false unless @match_pattern
|
|
38
|
+
|
|
39
|
+
Pattern.match?(@match_pattern, key)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Workflow
|
|
3
|
+
class Loader
|
|
4
|
+
def self.load_all(root)
|
|
5
|
+
registry = Registry.new
|
|
6
|
+
workflows_dir = File.join(root, "workflows")
|
|
7
|
+
return registry unless File.directory?(workflows_dir)
|
|
8
|
+
|
|
9
|
+
collector = Collector.new(registry)
|
|
10
|
+
Collector.with(collector) do
|
|
11
|
+
Dir.glob(File.join(workflows_dir, "**", "*.rb")).each { |path| load path }
|
|
12
|
+
end
|
|
13
|
+
registry
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Workflow
|
|
3
|
+
module Pattern
|
|
4
|
+
def self.match?(pattern, key)
|
|
5
|
+
if pattern.end_with?(".**")
|
|
6
|
+
prefix = pattern.delete_suffix(".**")
|
|
7
|
+
key.start_with?("#{prefix}.")
|
|
8
|
+
elsif pattern.end_with?(".*")
|
|
9
|
+
prefix = pattern.delete_suffix(".*")
|
|
10
|
+
suffix = key.delete_prefix("#{prefix}.")
|
|
11
|
+
key != suffix && !suffix.include?(".")
|
|
12
|
+
else
|
|
13
|
+
key == pattern
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Workflow
|
|
3
|
+
class Registry
|
|
4
|
+
def initialize
|
|
5
|
+
@definitions = []
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def register(definition)
|
|
9
|
+
@definitions << definition
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def for(key)
|
|
13
|
+
@definitions.find { |d| d.match?(key) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def all
|
|
17
|
+
@definitions.dup
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
require "timeout"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Workflow
|
|
5
|
+
class Runner
|
|
6
|
+
DEFAULT_TIMEOUT = 30
|
|
7
|
+
|
|
8
|
+
def initialize(definition, container:, call:)
|
|
9
|
+
@definition = definition
|
|
10
|
+
@container = container
|
|
11
|
+
@call = call
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def run(key)
|
|
15
|
+
ctx = build_context(key)
|
|
16
|
+
data = execute_steps(ctx)
|
|
17
|
+
publish(key, data, ctx)
|
|
18
|
+
data
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def build_context(key)
|
|
24
|
+
res = @container.manifest.resolver.resolve(key)
|
|
25
|
+
Context.new(
|
|
26
|
+
key: key,
|
|
27
|
+
entry: res.entry,
|
|
28
|
+
config: {}.freeze,
|
|
29
|
+
lane: res.entry.lane.to_s,
|
|
30
|
+
container: @container,
|
|
31
|
+
call: @call,
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def execute_steps(ctx)
|
|
36
|
+
data = nil
|
|
37
|
+
@definition.steps.each { |step| data = execute_one(step, data, ctx) }
|
|
38
|
+
data
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def execute_one(step, data, ctx)
|
|
42
|
+
timeout = step.timeout || DEFAULT_TIMEOUT
|
|
43
|
+
Timeout.timeout(timeout) { step.callable.call(data, ctx) }
|
|
44
|
+
rescue Timeout::Error => e
|
|
45
|
+
raise StepFailed.new(step.name, e)
|
|
46
|
+
rescue Textus::Error
|
|
47
|
+
raise
|
|
48
|
+
rescue StandardError => e
|
|
49
|
+
raise StepFailed.new(step.name, e)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def publish(key, data, ctx)
|
|
53
|
+
blk = @definition.publish_block
|
|
54
|
+
return blk.call(data, ctx) if blk && blk != :default
|
|
55
|
+
|
|
56
|
+
built_in_publish(key, data, ctx)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def built_in_publish(key, data, ctx)
|
|
60
|
+
normalized = normalize(data, ctx.entry.format)
|
|
61
|
+
Gate::Auth.new(@container).check_action!(action: :converge, actor: @call.role, key: key)
|
|
62
|
+
Envelope::Writer.from(container: @container, call: @call).put(
|
|
63
|
+
key,
|
|
64
|
+
mentry: ctx.entry,
|
|
65
|
+
payload: Envelope::Writer::Payload.new(**normalized),
|
|
66
|
+
)
|
|
67
|
+
publish_external(key, ctx)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def publish_external(key, ctx)
|
|
71
|
+
entry = ctx.entry
|
|
72
|
+
return unless entry.publish_tree || !Array(entry.publish_to).empty?
|
|
73
|
+
|
|
74
|
+
entry_path = @container.manifest.resolver.resolve(key).path
|
|
75
|
+
return unless entry.publish_tree || File.exist?(entry_path)
|
|
76
|
+
|
|
77
|
+
reader = Textus::Envelope::Reader.from(container: @container)
|
|
78
|
+
pctx = Textus::Manifest::Entry::Base::PublishContext.new(
|
|
79
|
+
container: @container, call: @call, reader: reader.method(:read),
|
|
80
|
+
)
|
|
81
|
+
entry.publish_via(pctx)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def normalize(data, format)
|
|
85
|
+
return { meta: {}, body: "", content: nil } if data.nil?
|
|
86
|
+
|
|
87
|
+
data = data.transform_keys(&:to_s) if data.is_a?(Hash)
|
|
88
|
+
case format.to_s
|
|
89
|
+
when "markdown", "text"
|
|
90
|
+
{ meta: data["_meta"] || {}, body: (data["body"] || "").to_s, content: nil }
|
|
91
|
+
when "json", "yaml"
|
|
92
|
+
{ meta: data["_meta"] || {}, body: nil, content: data["content"] || data }
|
|
93
|
+
else
|
|
94
|
+
{ meta: {}, body: data.to_s, content: nil }
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Workflow
|
|
3
|
+
class StepFailed < Textus::Error
|
|
4
|
+
attr_reader :step_name, :cause
|
|
5
|
+
|
|
6
|
+
def initialize(step_name, cause)
|
|
7
|
+
@step_name = step_name
|
|
8
|
+
@cause = cause
|
|
9
|
+
super(:workflow_step_failed, "workflow step '#{step_name}' failed: #{cause.message}")
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
class NotFound < Textus::Error
|
|
14
|
+
def initialize(key)
|
|
15
|
+
super(:workflow_not_found, "no workflow matches key '#{key}'; add a .textus/workflows/*.rb file with match: '#{key}'")
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.workflow(name, &)
|
|
21
|
+
collector = Workflow::Collector.current
|
|
22
|
+
raise "Textus.workflow called outside Workflow::Loader.load_all context" unless collector
|
|
23
|
+
|
|
24
|
+
defn = Workflow::DSL::Definition.new(name)
|
|
25
|
+
defn.instance_eval(&)
|
|
26
|
+
collector.register(defn)
|
|
27
|
+
end
|
|
28
|
+
end
|
data/lib/textus.rb
CHANGED
|
@@ -9,17 +9,19 @@ loader.inflector.inflect(
|
|
|
9
9
|
"cli" => "CLI",
|
|
10
10
|
"json" => "Json",
|
|
11
11
|
"yaml" => "Yaml",
|
|
12
|
-
"io" => "IO",
|
|
13
12
|
"mcp" => "MCP",
|
|
14
13
|
"mcp_serve" => "MCPServe",
|
|
14
|
+
"dsl" => "DSL",
|
|
15
15
|
)
|
|
16
16
|
loader.ignore(File.expand_path("textus/errors.rb", __dir__))
|
|
17
17
|
loader.ignore(File.expand_path("textus/surfaces/mcp.rb", __dir__))
|
|
18
18
|
loader.ignore(File.expand_path("textus/surfaces/mcp/errors.rb", __dir__))
|
|
19
|
+
loader.ignore(File.expand_path("textus/workflow/errors.rb", __dir__))
|
|
19
20
|
# Scaffold sources copied verbatim into user stores by `textus init`. They are
|
|
20
21
|
# templates for user-owned step classes, not gem constants — Zeitwerk must not
|
|
21
22
|
# manage or eager-load them.
|
|
22
23
|
loader.ignore(File.expand_path("textus/init/templates", __dir__))
|
|
24
|
+
loader.ignore(File.expand_path("textus/produce/acquire", __dir__))
|
|
23
25
|
loader.setup
|
|
24
26
|
loader.eager_load
|
|
25
27
|
|
|
@@ -33,6 +35,7 @@ Textus::Action::VERBS = {
|
|
|
33
35
|
reject: Textus::Action::Reject,
|
|
34
36
|
enqueue: Textus::Action::Enqueue,
|
|
35
37
|
get: Textus::Action::Get,
|
|
38
|
+
ingest: Textus::Action::Ingest,
|
|
36
39
|
list: Textus::Action::List,
|
|
37
40
|
where: Textus::Action::Where,
|
|
38
41
|
uid: Textus::Action::Uid,
|
|
@@ -87,4 +90,12 @@ Textus::Action::VERBS.each_key do |verb|
|
|
|
87
90
|
end
|
|
88
91
|
|
|
89
92
|
module Textus
|
|
93
|
+
def self.workflow(name, &)
|
|
94
|
+
collector = Workflow::Collector.current
|
|
95
|
+
raise "Textus.workflow called outside Workflow::Loader.load_all context" unless collector
|
|
96
|
+
|
|
97
|
+
defn = Workflow::DSL::Definition.new(name)
|
|
98
|
+
defn.instance_eval(&)
|
|
99
|
+
collector.register(defn)
|
|
100
|
+
end
|
|
90
101
|
end
|