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.
Files changed (149) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/SPEC.md +2 -5
  4. data/docs/architecture/README.md +149 -159
  5. data/lib/textus/action/accept.rb +0 -9
  6. data/lib/textus/action/audit.rb +0 -2
  7. data/lib/textus/action/blame.rb +0 -2
  8. data/lib/textus/action/boot.rb +2 -11
  9. data/lib/textus/action/data_mv.rb +1 -3
  10. data/lib/textus/action/deps.rb +1 -15
  11. data/lib/textus/action/doctor.rb +0 -2
  12. data/lib/textus/action/drain.rb +4 -6
  13. data/lib/textus/action/enqueue.rb +3 -5
  14. data/lib/textus/action/get.rb +1 -3
  15. data/lib/textus/action/ingest.rb +135 -0
  16. data/lib/textus/action/jobs.rb +1 -3
  17. data/lib/textus/action/key_delete.rb +0 -10
  18. data/lib/textus/action/key_delete_prefix.rb +1 -3
  19. data/lib/textus/action/key_mv.rb +1 -15
  20. data/lib/textus/action/key_mv_prefix.rb +1 -3
  21. data/lib/textus/action/list.rb +0 -2
  22. data/lib/textus/action/propose.rb +0 -2
  23. data/lib/textus/action/published.rb +0 -2
  24. data/lib/textus/action/pulse.rb +7 -38
  25. data/lib/textus/action/put.rb +1 -12
  26. data/lib/textus/action/rdeps.rb +2 -12
  27. data/lib/textus/action/reject.rb +0 -12
  28. data/lib/textus/action/rule_explain.rb +0 -4
  29. data/lib/textus/action/rule_lint.rb +1 -3
  30. data/lib/textus/action/rule_list.rb +0 -4
  31. data/lib/textus/action/schema_envelope.rb +0 -2
  32. data/lib/textus/action/uid.rb +0 -2
  33. data/lib/textus/action/where.rb +0 -2
  34. data/lib/textus/action/write_verb.rb +4 -18
  35. data/lib/textus/boot.rb +33 -109
  36. data/lib/textus/command.rb +6 -1
  37. data/lib/textus/container.rb +1 -1
  38. data/lib/textus/core/freshness/evaluator.rb +18 -34
  39. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  40. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  41. data/lib/textus/doctor/check.rb +0 -1
  42. data/lib/textus/doctor.rb +2 -42
  43. data/lib/textus/envelope/reader.rb +46 -0
  44. data/lib/textus/envelope/writer.rb +209 -0
  45. data/lib/textus/errors.rb +8 -4
  46. data/lib/textus/{entry → format}/base.rb +1 -6
  47. data/lib/textus/{entry → format}/json.rb +1 -4
  48. data/lib/textus/{entry → format}/markdown.rb +2 -6
  49. data/lib/textus/{entry → format}/text.rb +2 -6
  50. data/lib/textus/{entry → format}/yaml.rb +1 -4
  51. data/lib/textus/{entry.rb → format.rb} +9 -10
  52. data/lib/textus/gate/auth.rb +58 -12
  53. data/lib/textus/gate.rb +2 -0
  54. data/lib/textus/init.rb +12 -75
  55. data/lib/textus/jobs/base.rb +23 -0
  56. data/lib/textus/jobs/materialize.rb +20 -0
  57. data/lib/textus/{background → jobs}/plan.rb +1 -1
  58. data/lib/textus/jobs/planner.rb +101 -0
  59. data/lib/textus/jobs/retention.rb +48 -0
  60. data/lib/textus/jobs/sweep.rb +27 -0
  61. data/lib/textus/{background → jobs}/worker.rb +2 -2
  62. data/lib/textus/jobs.rb +15 -0
  63. data/lib/textus/key/path.rb +1 -1
  64. data/lib/textus/manifest/entry/base.rb +4 -19
  65. data/lib/textus/manifest/entry/parser.rb +27 -10
  66. data/lib/textus/manifest/entry/produced.rb +13 -34
  67. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
  68. data/lib/textus/manifest/entry/publish.rb +0 -12
  69. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  70. data/lib/textus/manifest/entry/validators.rb +0 -1
  71. data/lib/textus/manifest/policy/source.rb +14 -62
  72. data/lib/textus/manifest/policy.rb +2 -5
  73. data/lib/textus/manifest/resolver.rb +2 -2
  74. data/lib/textus/manifest/schema/keys.rb +6 -20
  75. data/lib/textus/manifest/schema/validator.rb +3 -11
  76. data/lib/textus/manifest/schema/vocabulary.rb +1 -0
  77. data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
  78. data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
  79. data/lib/textus/produce/engine.rb +50 -0
  80. data/lib/textus/produce/render.rb +18 -0
  81. data/lib/textus/session.rb +1 -1
  82. data/lib/textus/store.rb +1 -10
  83. data/lib/textus/surfaces/mcp/catalog.rb +1 -1
  84. data/lib/textus/surfaces/mcp/errors.rb +0 -9
  85. data/lib/textus/surfaces/mcp/routing.rb +51 -0
  86. data/lib/textus/surfaces/mcp/server.rb +101 -62
  87. data/lib/textus/surfaces/role_scope.rb +0 -4
  88. data/lib/textus/surfaces/watcher.rb +3 -3
  89. data/lib/textus/version.rb +1 -1
  90. data/lib/textus/workflow/collector.rb +27 -0
  91. data/lib/textus/workflow/context.rb +5 -0
  92. data/lib/textus/workflow/dsl.rb +44 -0
  93. data/lib/textus/workflow/loader.rb +17 -0
  94. data/lib/textus/workflow/pattern.rb +18 -0
  95. data/lib/textus/workflow/registry.rb +21 -0
  96. data/lib/textus/workflow/runner.rb +84 -0
  97. data/lib/textus/workflow.rb +28 -0
  98. data/lib/textus.rb +12 -1
  99. metadata +33 -61
  100. data/lib/textus/action/pulse/scanner.rb +0 -118
  101. data/lib/textus/background/job/base.rb +0 -27
  102. data/lib/textus/background/job/materialize.rb +0 -31
  103. data/lib/textus/background/job/refresh.rb +0 -22
  104. data/lib/textus/background/job/sweep.rb +0 -31
  105. data/lib/textus/background/job.rb +0 -19
  106. data/lib/textus/background/planner/plan.rb +0 -113
  107. data/lib/textus/background/retention/apply.rb +0 -50
  108. data/lib/textus/doctor/check/handler_permit.rb +0 -34
  109. data/lib/textus/doctor/check/hooks.rb +0 -25
  110. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  111. data/lib/textus/envelope/io/reader.rb +0 -48
  112. data/lib/textus/envelope/io/writer.rb +0 -211
  113. data/lib/textus/events.rb +0 -21
  114. data/lib/textus/init/templates/machine_intake.rb +0 -49
  115. data/lib/textus/init/templates/orientation_reducer.rb +0 -21
  116. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  117. data/lib/textus/manifest/policy/handler_permit.rb +0 -17
  118. data/lib/textus/pipeline/acquire/handler.rb +0 -29
  119. data/lib/textus/pipeline/acquire/intake.rb +0 -132
  120. data/lib/textus/pipeline/acquire/projection.rb +0 -129
  121. data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
  122. data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
  123. data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
  124. data/lib/textus/pipeline/acquire/serializer.rb +0 -17
  125. data/lib/textus/pipeline/engine.rb +0 -97
  126. data/lib/textus/pipeline/render.rb +0 -25
  127. data/lib/textus/ports/audit_subscriber.rb +0 -42
  128. data/lib/textus/projection.rb +0 -103
  129. data/lib/textus/step/base.rb +0 -35
  130. data/lib/textus/step/builtin/csv_fetch.rb +0 -19
  131. data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
  132. data/lib/textus/step/builtin/json_fetch.rb +0 -18
  133. data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
  134. data/lib/textus/step/builtin/rss_fetch.rb +0 -26
  135. data/lib/textus/step/builtin.rb +0 -22
  136. data/lib/textus/step/catalog.rb +0 -37
  137. data/lib/textus/step/context.rb +0 -74
  138. data/lib/textus/step/discovery.rb +0 -24
  139. data/lib/textus/step/error_log.rb +0 -32
  140. data/lib/textus/step/event_bus.rb +0 -113
  141. data/lib/textus/step/fetch.rb +0 -13
  142. data/lib/textus/step/fire_report.rb +0 -23
  143. data/lib/textus/step/loader.rb +0 -108
  144. data/lib/textus/step/observe.rb +0 -31
  145. data/lib/textus/step/registry_store.rb +0 -66
  146. data/lib/textus/step/signature.rb +0 -31
  147. data/lib/textus/step/transform.rb +0 -12
  148. data/lib/textus/step/validate.rb +0 -11
  149. 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
- # ADR 0093/0094: entry-level acquisition block. `from: project` sources
19
- # expose flat projection fields (select/pluck/sort_by/transform) directly
20
- # on the source block (ADR 0094). Render fields (template/inject_boot/
21
- # provenance) that were formerly on the source are retired — they live on
22
- # publish targets. The legacy `project:` free hash and `template`/
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!(manifest)
293
- manifest.data.entries.each do |entry|
294
- retention = manifest.rules.for(entry.key).retention
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
@@ -13,6 +13,7 @@ module Textus
13
13
  "workspace" => "keep",
14
14
  "machine" => "converge",
15
15
  "queue" => "propose",
16
+ "raw" => "ingest",
16
17
  }.freeze
17
18
 
18
19
  LANE_KINDS = LANES.keys.freeze
@@ -3,7 +3,7 @@ require "json"
3
3
 
4
4
  module Textus
5
5
  module Ports
6
- class Queue
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 Queue
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) # dedup: identical work already queued
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) # atomic claim; loser's rename raises ENOENT
40
+ File.rename(src, dst)
42
41
  rescue Errno::ENOENT
43
- next # another worker won this one
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 # raced with another reclaimer / the worker's ack
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) # atomic on same filesystem
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
@@ -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::Surfaces::MCP::ContractDrift.new(
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
- steps: Textus::Step::RegistryStore.new,
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 = { "name" => "textus", "version" => Textus::VERSION }.freeze
11
- MAX_LINE_BYTES = 1_048_576 # 1 MB — protects against OOM from oversized tool calls
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
- emit_error(nil, -32_700, "message too large (#{line.bytesize} bytes, limit #{MAX_LINE_BYTES})")
35
- return
36
- end
37
- msg = JSON.parse(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))
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
- propose_lane = @store.manifest.policy.propose_lane_for(@role)
63
-
64
- @session = Session.new(
65
+ Session.new(
65
66
  role: @role,
66
67
  cursor: @store.audit_log.latest_seq,
67
- propose_lane: 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 @session
94
- emit_error(rid, -32_002, "session not initialized; call 'initialize' first")
95
- return
96
- end
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
- name = params["name"]
99
- args = params["arguments"] || {}
87
+ def session_ready?(rid)
88
+ return true if @session
100
89
 
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)
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
- 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}")
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
@@ -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)
@@ -1,4 +1,4 @@
1
1
  module Textus
2
- VERSION = "0.53.0"
2
+ VERSION = "0.54.0"
3
3
  PROTOCOL = "textus/3"
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