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.
Files changed (160) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +52 -0
  3. data/README.md +53 -73
  4. data/SPEC.md +2 -5
  5. data/docs/architecture/README.md +149 -159
  6. data/lib/textus/action/accept.rb +0 -9
  7. data/lib/textus/action/audit.rb +0 -2
  8. data/lib/textus/action/blame.rb +0 -2
  9. data/lib/textus/action/boot.rb +2 -11
  10. data/lib/textus/action/data_mv.rb +1 -3
  11. data/lib/textus/action/deps.rb +1 -15
  12. data/lib/textus/action/doctor.rb +0 -2
  13. data/lib/textus/action/drain.rb +4 -6
  14. data/lib/textus/action/enqueue.rb +3 -5
  15. data/lib/textus/action/get.rb +1 -3
  16. data/lib/textus/action/ingest.rb +135 -0
  17. data/lib/textus/action/jobs.rb +1 -3
  18. data/lib/textus/action/key_delete.rb +0 -10
  19. data/lib/textus/action/key_delete_prefix.rb +1 -3
  20. data/lib/textus/action/key_mv.rb +1 -15
  21. data/lib/textus/action/key_mv_prefix.rb +1 -3
  22. data/lib/textus/action/list.rb +0 -2
  23. data/lib/textus/action/propose.rb +0 -2
  24. data/lib/textus/action/published.rb +0 -2
  25. data/lib/textus/action/pulse.rb +7 -38
  26. data/lib/textus/action/put.rb +1 -12
  27. data/lib/textus/action/rdeps.rb +2 -12
  28. data/lib/textus/action/reject.rb +0 -12
  29. data/lib/textus/action/rule_explain.rb +0 -4
  30. data/lib/textus/action/rule_lint.rb +1 -3
  31. data/lib/textus/action/rule_list.rb +0 -4
  32. data/lib/textus/action/schema_envelope.rb +0 -2
  33. data/lib/textus/action/uid.rb +0 -2
  34. data/lib/textus/action/where.rb +0 -2
  35. data/lib/textus/action/write_verb.rb +4 -18
  36. data/lib/textus/boot.rb +40 -109
  37. data/lib/textus/command.rb +6 -1
  38. data/lib/textus/container.rb +21 -5
  39. data/lib/textus/core/freshness/evaluator.rb +18 -34
  40. data/lib/textus/core/freshness/verdict.rb +1 -1
  41. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  42. data/lib/textus/doctor/check/protocol_version.rb +6 -6
  43. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  44. data/lib/textus/doctor/check/stale_reviewed_stamp.rb +54 -0
  45. data/lib/textus/doctor/check.rb +0 -1
  46. data/lib/textus/doctor.rb +3 -42
  47. data/lib/textus/envelope/reader.rb +46 -0
  48. data/lib/textus/envelope/writer.rb +209 -0
  49. data/lib/textus/envelope.rb +19 -6
  50. data/lib/textus/errors.rb +9 -5
  51. data/lib/textus/{entry → format}/base.rb +1 -6
  52. data/lib/textus/{entry → format}/json.rb +1 -4
  53. data/lib/textus/{entry → format}/markdown.rb +2 -6
  54. data/lib/textus/{entry → format}/text.rb +2 -6
  55. data/lib/textus/{entry → format}/yaml.rb +1 -4
  56. data/lib/textus/{entry.rb → format.rb} +9 -10
  57. data/lib/textus/gate/auth.rb +58 -12
  58. data/lib/textus/gate.rb +2 -0
  59. data/lib/textus/init.rb +16 -79
  60. data/lib/textus/jobs/base.rb +23 -0
  61. data/lib/textus/jobs/materialize.rb +20 -0
  62. data/lib/textus/{background → jobs}/plan.rb +1 -1
  63. data/lib/textus/jobs/planner.rb +101 -0
  64. data/lib/textus/jobs/retention.rb +48 -0
  65. data/lib/textus/jobs/sweep.rb +27 -0
  66. data/lib/textus/{background → jobs}/worker.rb +2 -2
  67. data/lib/textus/jobs.rb +15 -0
  68. data/lib/textus/key/path.rb +1 -1
  69. data/lib/textus/manifest/entry/base.rb +4 -19
  70. data/lib/textus/manifest/entry/parser.rb +27 -10
  71. data/lib/textus/manifest/entry/produced.rb +13 -34
  72. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
  73. data/lib/textus/manifest/entry/publish.rb +0 -12
  74. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  75. data/lib/textus/manifest/entry/validators.rb +0 -1
  76. data/lib/textus/manifest/policy/source.rb +14 -62
  77. data/lib/textus/manifest/policy.rb +2 -5
  78. data/lib/textus/manifest/resolver.rb +2 -2
  79. data/lib/textus/manifest/schema/contract.rb +61 -0
  80. data/lib/textus/manifest/schema/keys.rb +6 -20
  81. data/lib/textus/manifest/schema/semantics.rb +232 -0
  82. data/lib/textus/manifest/schema/validator.rb +24 -302
  83. data/lib/textus/manifest/schema/vocabulary.rb +1 -0
  84. data/lib/textus/manifest/schema.rb +1 -4
  85. data/lib/textus/manifest.rb +1 -3
  86. data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
  87. data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
  88. data/lib/textus/produce/engine.rb +50 -0
  89. data/lib/textus/produce/render.rb +20 -0
  90. data/lib/textus/session.rb +15 -8
  91. data/lib/textus/store.rb +5 -14
  92. data/lib/textus/surfaces/mcp/catalog.rb +23 -11
  93. data/lib/textus/surfaces/mcp/errors.rb +0 -9
  94. data/lib/textus/surfaces/mcp/server.rb +87 -110
  95. data/lib/textus/surfaces/role_scope.rb +0 -4
  96. data/lib/textus/surfaces/watcher.rb +3 -3
  97. data/lib/textus/types.rb +15 -0
  98. data/lib/textus/version.rb +2 -2
  99. data/lib/textus/workflow/collector.rb +27 -0
  100. data/lib/textus/workflow/context.rb +5 -0
  101. data/lib/textus/workflow/dsl.rb +44 -0
  102. data/lib/textus/workflow/loader.rb +17 -0
  103. data/lib/textus/workflow/pattern.rb +18 -0
  104. data/lib/textus/workflow/registry.rb +21 -0
  105. data/lib/textus/workflow/runner.rb +99 -0
  106. data/lib/textus/workflow.rb +28 -0
  107. data/lib/textus.rb +12 -1
  108. metadata +67 -66
  109. data/lib/textus/action/pulse/scanner.rb +0 -118
  110. data/lib/textus/background/job/base.rb +0 -27
  111. data/lib/textus/background/job/materialize.rb +0 -31
  112. data/lib/textus/background/job/refresh.rb +0 -22
  113. data/lib/textus/background/job/sweep.rb +0 -31
  114. data/lib/textus/background/job.rb +0 -19
  115. data/lib/textus/background/planner/plan.rb +0 -113
  116. data/lib/textus/background/retention/apply.rb +0 -50
  117. data/lib/textus/doctor/check/handler_permit.rb +0 -34
  118. data/lib/textus/doctor/check/hooks.rb +0 -25
  119. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  120. data/lib/textus/envelope/io/reader.rb +0 -48
  121. data/lib/textus/envelope/io/writer.rb +0 -211
  122. data/lib/textus/events.rb +0 -21
  123. data/lib/textus/init/templates/machine_intake.rb +0 -49
  124. data/lib/textus/init/templates/orientation_reducer.rb +0 -21
  125. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  126. data/lib/textus/manifest/policy/handler_permit.rb +0 -17
  127. data/lib/textus/pipeline/acquire/handler.rb +0 -29
  128. data/lib/textus/pipeline/acquire/intake.rb +0 -132
  129. data/lib/textus/pipeline/acquire/projection.rb +0 -129
  130. data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
  131. data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
  132. data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
  133. data/lib/textus/pipeline/acquire/serializer.rb +0 -17
  134. data/lib/textus/pipeline/engine.rb +0 -97
  135. data/lib/textus/pipeline/render.rb +0 -25
  136. data/lib/textus/ports/audit_subscriber.rb +0 -42
  137. data/lib/textus/projection.rb +0 -103
  138. data/lib/textus/step/base.rb +0 -35
  139. data/lib/textus/step/builtin/csv_fetch.rb +0 -19
  140. data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
  141. data/lib/textus/step/builtin/json_fetch.rb +0 -18
  142. data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
  143. data/lib/textus/step/builtin/rss_fetch.rb +0 -26
  144. data/lib/textus/step/builtin.rb +0 -22
  145. data/lib/textus/step/catalog.rb +0 -37
  146. data/lib/textus/step/context.rb +0 -74
  147. data/lib/textus/step/discovery.rb +0 -24
  148. data/lib/textus/step/error_log.rb +0 -32
  149. data/lib/textus/step/event_bus.rb +0 -113
  150. data/lib/textus/step/fetch.rb +0 -13
  151. data/lib/textus/step/fire_report.rb +0 -23
  152. data/lib/textus/step/loader.rb +0 -108
  153. data/lib/textus/step/observe.rb +0 -31
  154. data/lib/textus/step/registry_store.rb +0 -66
  155. data/lib/textus/step/signature.rb +0 -31
  156. data/lib/textus/step/transform.rb +0 -12
  157. data/lib/textus/step/validate.rb +0 -11
  158. data/lib/textus/step.rb +0 -10
  159. data/lib/textus/surfaces/mcp/session.rb +0 -9
  160. 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
- # (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.
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
- def tool_schemas
28
- specs.map do |s|
29
- { name: s.verb.to_s, description: s.summary, inputSchema: s.input_schema }
30
- end.freeze
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
- specs.map { |s| s.verb.to_s }
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
- require "json"
1
+ # frozen_string_literal: true
2
+
3
+ require "mcp"
2
4
 
3
5
  module Textus
4
6
  module Surfaces
5
7
  module MCP
6
- # Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05. One line per
7
- # message (NDJSON). Holds a single Session for the lifetime of stdin.
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
- PROTOCOL_VERSION = "2024-11-05"
10
- SERVER_INFO = { "name" => "textus", "version" => Textus::VERSION }.freeze
11
- MAX_LINE_BYTES = 1_048_576 # 1 MB — protects against OOM from oversized tool calls
12
-
13
- def initialize(store:, stdin: $stdin, stdout: $stdout, role: Textus::Role::DEFAULT)
14
- @store = store
15
- @stdin = stdin
16
- @stdout = stdout
17
- @role = role
18
- @session = nil
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
- handle_line(line)
27
- end
28
- end
43
+ response = @sdk.handle_json(line)
44
+ next unless response
29
45
 
30
- private
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
- 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
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
- def handle_initialize(rid, _params)
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 handle_tools_list(rid)
89
- emit_result(rid, { "tools" => Catalog.tool_schemas })
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 handle_tools_call(rid, params)
93
- unless @session
94
- emit_error(rid, -32_002, "session not initialized; call 'initialize' first")
95
- return
96
- end
77
+ def build_resources
78
+ machine_lane = @store.manifest.policy.machine_lane
79
+ return [] unless machine_lane
97
80
 
98
- name = params["name"]
99
- args = params["arguments"] || {}
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 contract_etag
127
- Textus::Etag.for_contract(@store.root)
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 emit_result(rid, result)
131
- write({ "jsonrpc" => "2.0", "id" => rid, "result" => result })
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 emit_error(rid, code, message)
135
- write({ "jsonrpc" => "2.0", "id" => rid, "error" => { "code" => code, "message" => message } })
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 write(obj)
139
- @stdout.puts(JSON.dump(obj))
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
@@ -29,10 +29,6 @@ module Textus
29
29
  def with_dry_run
30
30
  self.class.new(container: @container, role: @role, dry_run: true, correlation_id: @correlation_id)
31
31
  end
32
-
33
- def hook_context
34
- @hook_context ||= Textus::Step::Context.new(scope: self)
35
- end
36
32
  end
37
33
  end
38
34
  end
@@ -7,17 +7,17 @@ module Textus
7
7
  class Watcher
8
8
  def initialize(container:)
9
9
  @container = container
10
- @queue = Textus::Ports::Queue.new(root: container.root)
10
+ @queue = Textus::Ports::JobStore.new(root: container.root)
11
11
  end
12
12
 
13
13
  def tick
14
- Textus::Background::Planner::Plan.seed(
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::Background::Worker.for(container: @container, queue: @queue).drain
20
+ Textus::Jobs::Worker.for(container: @container, queue: @queue).drain
21
21
  end
22
22
 
23
23
  def run(poll: nil)
@@ -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
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.53.0"
3
- PROTOCOL = "textus/3"
2
+ VERSION = "0.54.1"
3
+ PROTOCOL = "textus/4"
4
4
  end
@@ -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,5 @@
1
+ module Textus
2
+ module Workflow
3
+ Context = Data.define(:key, :entry, :config, :lane, :container, :call)
4
+ end
5
+ 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