textus 0.54.2 → 0.55.2

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 (242) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -1
  3. data/README.md +17 -10
  4. data/SPEC.md +39 -11
  5. data/docs/architecture/README.md +23 -11
  6. data/docs/reference/conventions.md +5 -2
  7. data/exe/textus +1 -1
  8. data/lib/textus/boot.rb +64 -93
  9. data/lib/textus/dispatch/binder.rb +48 -0
  10. data/lib/textus/dispatch/contracts.rb +63 -0
  11. data/lib/textus/dispatch/handler_registry.rb +21 -0
  12. data/lib/textus/dispatch/middleware/audit_index.rb +51 -0
  13. data/lib/textus/dispatch/middleware/auth.rb +40 -0
  14. data/lib/textus/dispatch/middleware/base.rb +26 -0
  15. data/lib/textus/dispatch/middleware/binder.rb +20 -0
  16. data/lib/textus/dispatch/middleware/cascade.rb +53 -0
  17. data/lib/textus/dispatch/pipeline.rb +35 -0
  18. data/lib/textus/doctor/check/audit_log.rb +2 -2
  19. data/lib/textus/doctor/check/generator_drift.rb +4 -4
  20. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  21. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  22. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  23. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  24. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  25. data/lib/textus/doctor/check/schemas.rb +1 -1
  26. data/lib/textus/doctor/check/{notebook_sources.rb → scratchpad_sources.rb} +10 -5
  27. data/lib/textus/doctor/check/sentinels.rb +4 -4
  28. data/lib/textus/doctor/check/templates.rb +1 -1
  29. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  30. data/lib/textus/doctor/check.rb +9 -10
  31. data/lib/textus/doctor.rb +2 -2
  32. data/lib/textus/errors.rb +8 -0
  33. data/lib/textus/format/base.rb +34 -10
  34. data/lib/textus/format/json.rb +5 -27
  35. data/lib/textus/format/markdown.rb +5 -27
  36. data/lib/textus/format/text.rb +5 -4
  37. data/lib/textus/format/yaml.rb +30 -27
  38. data/lib/textus/format.rb +22 -1
  39. data/lib/textus/handlers/maintenance/boot_store.rb +15 -0
  40. data/lib/textus/handlers/maintenance/doctor_store.rb +15 -0
  41. data/lib/textus/handlers/maintenance/drain_store.rb +21 -0
  42. data/lib/textus/handlers/maintenance/ingest_entry.rb +159 -0
  43. data/lib/textus/handlers/maintenance/jobs_action.rb +21 -0
  44. data/lib/textus/handlers/maintenance/published_entries.rb +17 -0
  45. data/lib/textus/handlers/maintenance/rule_explain.rb +77 -0
  46. data/lib/textus/handlers/maintenance/rule_lint.rb +54 -0
  47. data/lib/textus/handlers/maintenance/rule_list.rb +32 -0
  48. data/lib/textus/handlers/maintenance/schema_envelope.rb +19 -0
  49. data/lib/textus/handlers/read/audit_entries.rb +48 -0
  50. data/lib/textus/handlers/read/blame_entry.rb +71 -0
  51. data/lib/textus/handlers/read/deps_entry.rb +17 -0
  52. data/lib/textus/handlers/read/get_entry.rb +68 -0
  53. data/lib/textus/handlers/read/list_keys.rb +36 -0
  54. data/lib/textus/handlers/read/pulse_entries.rb +66 -0
  55. data/lib/textus/handlers/read/rdeps_entry.rb +21 -0
  56. data/lib/textus/handlers/read/uid_entry.rb +18 -0
  57. data/lib/textus/handlers/read/where_entry.rb +18 -0
  58. data/lib/textus/handlers/write/accept_proposal.rb +39 -0
  59. data/lib/textus/handlers/write/data_mv.rb +55 -0
  60. data/lib/textus/handlers/write/delete_key.rb +17 -0
  61. data/lib/textus/handlers/write/enqueue_job.rb +27 -0
  62. data/lib/textus/handlers/write/key_delete_prefix.rb +32 -0
  63. data/lib/textus/handlers/write/key_mv_prefix.rb +45 -0
  64. data/lib/textus/handlers/write/move_key.rb +80 -0
  65. data/lib/textus/handlers/write/propose_entry.rb +29 -0
  66. data/lib/textus/handlers/write/put_entry.rb +29 -0
  67. data/lib/textus/handlers/write/reject_proposal.rb +29 -0
  68. data/lib/textus/init.rb +5 -7
  69. data/lib/textus/jobs.rb +3 -9
  70. data/lib/textus/manifest/capabilities.rb +4 -4
  71. data/lib/textus/manifest/entry/base.rb +3 -3
  72. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  73. data/lib/textus/manifest/entry/publish/to_paths.rb +2 -2
  74. data/lib/textus/manifest/policy/predicates/author_held.rb +22 -0
  75. data/lib/textus/manifest/policy/predicates/etag_match.rb +18 -0
  76. data/lib/textus/manifest/policy/predicates/fresh_within.rb +13 -0
  77. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +31 -0
  78. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +23 -0
  79. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +25 -0
  80. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +24 -0
  81. data/lib/textus/manifest/policy/predicates/schema_valid.rb +41 -0
  82. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +20 -0
  83. data/lib/textus/manifest/policy/predicates.rb +54 -0
  84. data/lib/textus/manifest/policy/retention.rb +1 -1
  85. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  86. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  87. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  88. data/lib/textus/manifest/schema/semantics.rb +11 -216
  89. data/lib/textus/orchestration.rb +55 -0
  90. data/lib/textus/{ports → port}/audit_log.rb +46 -6
  91. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  92. data/lib/textus/{ports → port}/clock.rb +1 -1
  93. data/lib/textus/{ports → port}/publisher.rb +5 -5
  94. data/lib/textus/{core → port}/sentinel.rb +1 -6
  95. data/lib/textus/{ports → port}/sentinel_store.rb +4 -4
  96. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  97. data/lib/textus/port/storage/file_store.rb +49 -0
  98. data/lib/textus/port/storage/interface.rb +17 -0
  99. data/lib/textus/port/store.rb +149 -0
  100. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  101. data/lib/textus/produce/engine.rb +1 -11
  102. data/lib/textus/produce/publisher.rb +21 -0
  103. data/lib/textus/schema/registry.rb +42 -0
  104. data/lib/textus/schema/tools.rb +7 -10
  105. data/lib/textus/store/container.rb +173 -0
  106. data/lib/textus/store/cursor.rb +26 -0
  107. data/lib/textus/store/entry/reader.rb +47 -0
  108. data/lib/textus/store/entry/writer.rb +219 -0
  109. data/lib/textus/store/envelope/meta.rb +61 -0
  110. data/lib/textus/store/freshness/drift_detector.rb +93 -0
  111. data/lib/textus/store/freshness/evaluator.rb +20 -0
  112. data/lib/textus/store/freshness/ttl_evaluator.rb +57 -0
  113. data/lib/textus/{core → store}/freshness/verdict.rb +1 -11
  114. data/lib/textus/store/freshness.rb +8 -0
  115. data/lib/textus/store/index/builder.rb +76 -0
  116. data/lib/textus/store/index/lookup.rb +60 -0
  117. data/lib/textus/store/jobs/base.rb +13 -0
  118. data/lib/textus/store/jobs/index.rb +15 -0
  119. data/lib/textus/store/jobs/materialize.rb +15 -0
  120. data/lib/textus/store/jobs/plan.rb +11 -0
  121. data/lib/textus/store/jobs/planner.rb +124 -0
  122. data/lib/textus/store/jobs/queue.rb +162 -0
  123. data/lib/textus/store/jobs/registry.rb +19 -0
  124. data/lib/textus/store/jobs/retention/base.rb +52 -0
  125. data/lib/textus/store/jobs/retention/sweep.rb +55 -0
  126. data/lib/textus/store/jobs/retention.rb +8 -0
  127. data/lib/textus/store/jobs/sweep.rb +21 -0
  128. data/lib/textus/store/jobs/worker.rb +64 -0
  129. data/lib/textus/store/layout.rb +97 -0
  130. data/lib/textus/store.rb +63 -32
  131. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  132. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  133. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  135. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  136. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  137. data/lib/textus/{surfaces → surface}/cli/runner.rb +14 -68
  138. data/lib/textus/surface/cli/sources.rb +41 -0
  139. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +5 -6
  140. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +7 -5
  141. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  142. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  143. data/lib/textus/surface/cli/verb/put.rb +27 -0
  144. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  145. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  146. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  147. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  148. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  149. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  150. data/lib/textus/surface/mcp/catalog.rb +58 -0
  151. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -11
  152. data/lib/textus/surface/mcp/projector.rb +20 -0
  153. data/lib/textus/{surfaces → surface}/mcp/server.rb +24 -35
  154. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  155. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  156. data/lib/textus/value/call.rb +30 -0
  157. data/lib/textus/value/command.rb +16 -0
  158. data/lib/textus/{core → value}/duration.rb +1 -4
  159. data/lib/textus/value/envelope.rb +90 -0
  160. data/lib/textus/value/etag.rb +39 -0
  161. data/lib/textus/value/payload.rb +7 -0
  162. data/lib/textus/value/result.rb +46 -0
  163. data/lib/textus/value/role.rb +38 -0
  164. data/lib/textus/value/types.rb +13 -0
  165. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  166. data/lib/textus/verb_registry.rb +417 -0
  167. data/lib/textus/version.rb +1 -1
  168. data/lib/textus/workflow/loader.rb +4 -4
  169. data/lib/textus/workflow/runner.rb +11 -33
  170. data/lib/textus.rb +4 -69
  171. metadata +142 -105
  172. data/lib/textus/action/accept.rb +0 -44
  173. data/lib/textus/action/audit.rb +0 -131
  174. data/lib/textus/action/base.rb +0 -42
  175. data/lib/textus/action/blame.rb +0 -97
  176. data/lib/textus/action/boot.rb +0 -17
  177. data/lib/textus/action/data_mv.rb +0 -69
  178. data/lib/textus/action/deps.rb +0 -34
  179. data/lib/textus/action/doctor.rb +0 -24
  180. data/lib/textus/action/drain.rb +0 -39
  181. data/lib/textus/action/enqueue.rb +0 -53
  182. data/lib/textus/action/get.rb +0 -78
  183. data/lib/textus/action/ingest.rb +0 -203
  184. data/lib/textus/action/jobs.rb +0 -36
  185. data/lib/textus/action/key_delete.rb +0 -36
  186. data/lib/textus/action/key_delete_prefix.rb +0 -44
  187. data/lib/textus/action/key_mv.rb +0 -129
  188. data/lib/textus/action/key_mv_prefix.rb +0 -57
  189. data/lib/textus/action/list.rb +0 -42
  190. data/lib/textus/action/propose.rb +0 -52
  191. data/lib/textus/action/published.rb +0 -24
  192. data/lib/textus/action/pulse.rb +0 -56
  193. data/lib/textus/action/put.rb +0 -52
  194. data/lib/textus/action/rdeps.rb +0 -39
  195. data/lib/textus/action/reject.rb +0 -37
  196. data/lib/textus/action/rule_explain.rb +0 -91
  197. data/lib/textus/action/rule_lint.rb +0 -68
  198. data/lib/textus/action/rule_list.rb +0 -42
  199. data/lib/textus/action/schema_envelope.rb +0 -29
  200. data/lib/textus/action/uid.rb +0 -33
  201. data/lib/textus/action/where.rb +0 -36
  202. data/lib/textus/action/write_verb.rb +0 -44
  203. data/lib/textus/call.rb +0 -28
  204. data/lib/textus/command.rb +0 -41
  205. data/lib/textus/container.rb +0 -26
  206. data/lib/textus/contract/around.rb +0 -29
  207. data/lib/textus/contract/binder.rb +0 -88
  208. data/lib/textus/contract/resources/build_lock.rb +0 -17
  209. data/lib/textus/contract/resources/cursor.rb +0 -26
  210. data/lib/textus/contract/sources.rb +0 -39
  211. data/lib/textus/contract/view.rb +0 -15
  212. data/lib/textus/contract.rb +0 -174
  213. data/lib/textus/core/freshness/evaluator.rb +0 -150
  214. data/lib/textus/core/freshness.rb +0 -11
  215. data/lib/textus/core/retention/sweep.rb +0 -57
  216. data/lib/textus/core/retention.rb +0 -11
  217. data/lib/textus/cursor_store.rb +0 -24
  218. data/lib/textus/envelope/reader.rb +0 -46
  219. data/lib/textus/envelope/writer.rb +0 -209
  220. data/lib/textus/envelope.rb +0 -79
  221. data/lib/textus/etag.rb +0 -36
  222. data/lib/textus/gate/auth.rb +0 -227
  223. data/lib/textus/gate.rb +0 -116
  224. data/lib/textus/jobs/base.rb +0 -23
  225. data/lib/textus/jobs/materialize.rb +0 -20
  226. data/lib/textus/jobs/plan.rb +0 -9
  227. data/lib/textus/jobs/planner.rb +0 -101
  228. data/lib/textus/jobs/retention.rb +0 -48
  229. data/lib/textus/jobs/sweep.rb +0 -27
  230. data/lib/textus/jobs/worker.rb +0 -67
  231. data/lib/textus/layout.rb +0 -91
  232. data/lib/textus/ports/job_store/job.rb +0 -65
  233. data/lib/textus/ports/job_store.rb +0 -123
  234. data/lib/textus/ports/raw_index.rb +0 -61
  235. data/lib/textus/ports/storage/file_store.rb +0 -26
  236. data/lib/textus/role.rb +0 -36
  237. data/lib/textus/schemas.rb +0 -54
  238. data/lib/textus/session.rb +0 -35
  239. data/lib/textus/surfaces/cli/verb/put.rb +0 -30
  240. data/lib/textus/surfaces/mcp/catalog.rb +0 -111
  241. data/lib/textus/surfaces/role_scope.rb +0 -34
  242. data/lib/textus/types.rb +0 -15
data/lib/textus/layout.rb DELETED
@@ -1,91 +0,0 @@
1
- module Textus
2
- # Single source of truth for every path textus owns under a store root.
3
- # All disposable runtime state nests under <root>/.state/ so the
4
- # tracked/disposable boundary is a directory boundary. ADR 0038.
5
- module Layout
6
- RUN = ".state"
7
- DATA = "data"
8
-
9
- def self.data(root)
10
- File.join(root, DATA)
11
- end
12
-
13
- def self.data_lane(root, lane_name)
14
- File.join(data(root), lane_name)
15
- end
16
-
17
- def self.run(root)
18
- File.join(root, RUN)
19
- end
20
-
21
- def self.cursors(root)
22
- File.join(run(root), "cursors")
23
- end
24
-
25
- def self.cursor(root, role)
26
- File.join(cursors(root), role.to_s)
27
- end
28
-
29
- def self.locks(root)
30
- File.join(run(root), "locks")
31
- end
32
-
33
- def self.build_lock(root)
34
- File.join(run(root), "build.lock")
35
- end
36
-
37
- def self.watcher_lock(root)
38
- File.join(run(root), "watcher.lock")
39
- end
40
-
41
- def self.queue(root)
42
- File.join(run(root), "queue")
43
- end
44
-
45
- def self.queue_state(root, state)
46
- File.join(queue(root), state.to_s)
47
- end
48
-
49
- def self.audit_dir(root)
50
- File.join(run(root), "audit")
51
- end
52
-
53
- # Sentinels are machine-generated (the published target's sha), not authored
54
- # source, so they live on the runtime side under `.state/` — git-ignored,
55
- # regenerated by the next build via content-identical adoption (ADR 0070,
56
- # superseding ADR 0038's `:config` classification).
57
- def self.sentinels(root)
58
- File.join(run(root), "sentinels")
59
- end
60
-
61
- def self.indexes(root)
62
- File.join(run(root), "indexes")
63
- end
64
-
65
- def self.raw_index(root)
66
- File.join(indexes(root), "raw.yaml")
67
- end
68
-
69
- def self.audit_log(root)
70
- File.join(audit_dir(root), "audit.log")
71
- end
72
-
73
- # The store's `.gitignore` body. Always ignores the runtime subtree
74
- # (`.state/`, ADR 0038); when given untracked entry paths (entries marked
75
- # `tracked: false`), it also lists those so they stay protocol-readable but
76
- # uncommitted (ADR 0043, refining 0038). Generated, never hand-kept — no
77
- # drift between the manifest and the ignore file.
78
- def self.gitignore_body(untracked_paths: [])
79
- lines = ["# textus runtime artifacts — safe to delete, never commit",
80
- "#{RUN}/"]
81
- unless untracked_paths.empty?
82
- lines << "# tracked:false entries — protocol-readable, not committed (sensitive)"
83
- lines.concat(untracked_paths)
84
- end
85
- "#{lines.join("\n")}\n"
86
- end
87
-
88
- # Back-compat constant: the no-untracked-entries body (just the run subtree).
89
- GITIGNORE = gitignore_body
90
- end
91
- end
@@ -1,65 +0,0 @@
1
- require "digest"
2
- require "json"
3
-
4
- module Textus
5
- module Ports
6
- class JobStore
7
- # A unit of deferred work. Pure data. The id is `<type>:<digest>` where the
8
- # digest is over the args with sorted keys, so logically-identical enqueues
9
- # collide on the same id — which is how Queue dedups (file already exists).
10
- # At-least-once delivery means handlers must be idempotent.
11
- class Job
12
- DIGEST_BYTES = 16
13
-
14
- attr_reader :type, :args, :enqueued_by, :max_attempts
15
- attr_accessor :attempts, :last_error
16
-
17
- def initialize(type:, args:, enqueued_by: nil, attempts: 0, max_attempts: 3, last_error: nil)
18
- @type = type.to_s
19
- @args = stringify(args)
20
- @enqueued_by = enqueued_by
21
- @attempts = attempts
22
- @max_attempts = max_attempts
23
- @last_error = last_error
24
- end
25
-
26
- def id
27
- "#{@type}:#{digest}"
28
- end
29
-
30
- def to_h
31
- {
32
- "type" => @type,
33
- "args" => @args,
34
- "enqueued_by" => @enqueued_by,
35
- "attempts" => @attempts,
36
- "max_attempts" => @max_attempts,
37
- "last_error" => @last_error,
38
- }
39
- end
40
-
41
- def self.from_h(hash)
42
- new(
43
- type: hash["type"],
44
- args: hash["args"] || {},
45
- enqueued_by: hash["enqueued_by"],
46
- attempts: hash["attempts"] || 0,
47
- max_attempts: hash["max_attempts"] || 3,
48
- last_error: hash["last_error"],
49
- )
50
- end
51
-
52
- private
53
-
54
- def digest
55
- canonical = JSON.dump(@args.sort.to_h)
56
- Digest::SHA256.hexdigest(canonical)[0, DIGEST_BYTES]
57
- end
58
-
59
- def stringify(hash)
60
- hash.transform_keys(&:to_s)
61
- end
62
- end
63
- end
64
- end
65
- end
@@ -1,123 +0,0 @@
1
- require "fileutils"
2
- require "json"
3
- require "time"
4
-
5
- module Textus
6
- module Ports
7
- # File-backed durable job queue under `<root>/.run/queue/`. Each job state
8
- # is a directory; a job is one `<id>.json` file. Claiming is an atomic
9
- # `rename(2)` from ready/ to leased/ — the rename winner owns the job, so a
10
- # worker pool needs no central lock. Dedup falls out of the id-as-filename:
11
- # enqueueing an id that already exists is a no-op. ADR 0038 (runtime subtree),
12
- # ADR 0108 (instantiable port).
13
- class JobStore
14
- STATES = %i[ready leased done failed].freeze
15
-
16
- def initialize(root:)
17
- @root = root
18
- STATES.each { |s| FileUtils.mkdir_p(Textus::Layout.queue_state(root, s)) }
19
- end
20
-
21
- def enqueue(job)
22
- dest = path(:ready, job.id)
23
- return if File.exist?(dest)
24
-
25
- write_atomic(dest, job.to_h)
26
- end
27
-
28
- def ready_ids
29
- Dir.children(Textus::Layout.queue_state(@root, :ready)).map { |f| File.basename(f, ".json") }
30
- end
31
-
32
- Leased = Struct.new(:job, :leased_path, keyword_init: true)
33
-
34
- def lease(worker_id:, lease_ttl:)
35
- ready_dir = Textus::Layout.queue_state(@root, :ready)
36
- Dir.children(ready_dir).each do |name|
37
- src = File.join(ready_dir, name)
38
- dst = File.join(Textus::Layout.queue_state(@root, :leased), name)
39
- begin
40
- File.rename(src, dst)
41
- rescue Errno::ENOENT
42
- next
43
- end
44
- job = Job.from_h(JSON.parse(File.read(dst)))
45
- stamp_lease(dst, worker_id: worker_id, expires_at: Time.now.utc + lease_ttl)
46
- return Leased.new(job: job, leased_path: dst)
47
- end
48
- nil
49
- end
50
-
51
- def ack(leased)
52
- dest = File.join(Textus::Layout.queue_state(@root, :done), File.basename(leased.leased_path))
53
- File.rename(leased.leased_path, dest)
54
- end
55
-
56
- def fail(leased, error:)
57
- job = leased.job
58
- job.attempts += 1
59
- job.last_error = error
60
- dead = job.attempts >= job.max_attempts
61
- write_atomic(path(dead ? :failed : :ready, job.id), job.to_h)
62
- File.delete(leased.leased_path)
63
- dead ? :dead_lettered : :requeued
64
- end
65
-
66
- def reclaim(now:)
67
- leased_dir = Textus::Layout.queue_state(@root, :leased)
68
- count = 0
69
- Dir.children(leased_dir).each do |name|
70
- src = File.join(leased_dir, name)
71
- data = JSON.parse(File.read(src))
72
- expires = data.dig("lease", "expires_at")
73
- next if expires && Time.parse(expires) > now
74
-
75
- dst = File.join(Textus::Layout.queue_state(@root, :ready), name)
76
- data.delete("lease")
77
- File.write(src, JSON.pretty_generate(data))
78
- File.rename(src, dst)
79
- count += 1
80
- rescue Errno::ENOENT
81
- next
82
- end
83
- count
84
- end
85
-
86
- def list(state)
87
- Dir.children(Textus::Layout.queue_state(@root, state.to_sym)).map { |f| File.basename(f, ".json") }
88
- end
89
-
90
- def retry_failed(job_id)
91
- src = path(:failed, job_id)
92
- data = JSON.parse(File.read(src))
93
- data["attempts"] = 0
94
- data["last_error"] = nil
95
- write_atomic(path(:ready, job_id), data)
96
- File.delete(src)
97
- end
98
-
99
- def purge(state)
100
- dir = Textus::Layout.queue_state(@root, state.to_sym)
101
- Dir.children(dir).each { |f| File.delete(File.join(dir, f)) }
102
- end
103
-
104
- private
105
-
106
- def stamp_lease(leased_path, worker_id:, expires_at:)
107
- data = JSON.parse(File.read(leased_path))
108
- data["lease"] = { "worker_id" => worker_id, "expires_at" => expires_at.iso8601 }
109
- File.write(leased_path, JSON.pretty_generate(data))
110
- end
111
-
112
- def path(state, job_id)
113
- File.join(Textus::Layout.queue_state(@root, state), "#{job_id}.json")
114
- end
115
-
116
- def write_atomic(dest, hash)
117
- tmp = "#{dest}.#{Process.pid}.tmp"
118
- File.write(tmp, JSON.pretty_generate(hash))
119
- File.rename(tmp, dest)
120
- end
121
- end
122
- end
123
- end
@@ -1,61 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "yaml"
4
- require "fileutils"
5
-
6
- module Textus
7
- module Ports
8
- # Content-addressable index for the raw lane. Maps content hashes and URLs
9
- # to their current canonical key. Lives under <root>/.state/indexes/raw.yaml
10
- # (gitignored, regenerable — truth is in the on-disk raw entries).
11
- class RawIndex
12
- def initialize(root:)
13
- @root = root
14
- @path = Layout.raw_index(root)
15
- end
16
-
17
- attr_reader :path
18
-
19
- def load
20
- return empty_index unless File.exist?(@path)
21
-
22
- YAML.safe_load_file(@path) || empty_index
23
- rescue StandardError
24
- empty_index
25
- end
26
-
27
- def save(index)
28
- FileUtils.mkdir_p(File.dirname(@path))
29
- File.write(@path, YAML.dump(index))
30
- index
31
- end
32
-
33
- def find_by_hash(content_hash)
34
- index = load
35
- index["hashes"]&.fetch(content_hash, nil)
36
- end
37
-
38
- def find_by_url(url)
39
- return nil unless url
40
-
41
- index = load
42
- index["urls"]&.fetch(url, nil)
43
- end
44
-
45
- def upsert(content_hash:, url:, key:)
46
- index = load
47
- index["hashes"] ||= {}
48
- index["urls"] ||= {}
49
- index["hashes"][content_hash] = key
50
- index["urls"][url] = key if url
51
- save(index)
52
- end
53
-
54
- private
55
-
56
- def empty_index
57
- { "hashes" => {}, "urls" => {} }
58
- end
59
- end
60
- end
61
- end
@@ -1,26 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Ports
5
- module Storage
6
- # Pure filesystem I/O port. Wraps File/FileUtils/Etag with no knowledge
7
- # of envelopes, entries, schemas, or audit.
8
- class FileStore
9
- def read(path) = File.binread(path)
10
-
11
- def write(path, bytes)
12
- FileUtils.mkdir_p(File.dirname(path))
13
- File.binwrite(path, bytes)
14
- end
15
-
16
- # Raises Errno::ENOENT if absent — mirrors File.delete and matches the
17
- # semantics used by Store::Writer (which guards with File.exist? first).
18
- def delete(path) = File.delete(path)
19
-
20
- def exists?(path) = File.exist?(path)
21
-
22
- def etag(path) = Etag.for_file(path)
23
- end
24
- end
25
- end
26
- end
data/lib/textus/role.rb DELETED
@@ -1,36 +0,0 @@
1
- module Textus
2
- module Role
3
- # The three role archetypes, each string sourced exactly once: human curates
4
- # canon, agent proposes, automation converges the machine-maintained lanes
5
- # (refresh + materialize) (explanation/concepts.md).
6
- # Reference these constants instead of bare literals (ADR 0044).
7
- HUMAN = "human".freeze
8
- AGENT = "agent".freeze
9
- AUTOMATION = "automation".freeze
10
-
11
- # The closed set of legal role names (ADR 0045), built FROM the archetypes
12
- # above so it stays the single source of truth — a manifest declaring any
13
- # other name is rejected at load, and DEFAULT ∈ NAMES holds structurally.
14
- # Capabilities (`can:`) remain freely tunable per role.
15
- NAMES = [HUMAN, AGENT, AUTOMATION].freeze
16
-
17
- # Default acting identity (ADR 0040): a *choice* over the vocabulary, not a
18
- # new name. CLI callers act as the human; an agent over stdio proposes and
19
- # does not inherit the human's authority (it defaults to AGENT per transport).
20
- DEFAULT = HUMAN
21
-
22
- def self.resolve(root:, flag: nil, env: ENV, default: DEFAULT)
23
- candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || default
24
- raise InvalidRole.new(candidate) unless NAMES.include?(candidate)
25
-
26
- candidate
27
- end
28
-
29
- def self.read_file(root)
30
- path = File.join(root, "role")
31
- return nil unless File.exist?(path)
32
-
33
- File.read(path).strip.then { |s| s.empty? ? nil : s }
34
- end
35
- end
36
- end
@@ -1,54 +0,0 @@
1
- module Textus
2
- # Eager-loading schema cache. Loads every *.yaml under +dir+ at construction.
3
- # A missing directory is treated as "no schemas" (does not raise) to mirror
4
- # the lazy behavior previously embedded in Store#schema_for.
5
- class Schemas
6
- def initialize(dir)
7
- @dir = dir
8
- @schemas = {}
9
- load_all
10
- end
11
-
12
- def fetch(name)
13
- @schemas[name] || raise(IoError.new("schema not found: #{File.join(@dir, "#{name}.yaml")}"))
14
- end
15
-
16
- # Only nil short-circuits. A missing-but-named schema still raises IoError.
17
- def fetch_or_nil(name)
18
- return nil if name.nil?
19
-
20
- fetch(name)
21
- end
22
-
23
- def all
24
- @schemas.values
25
- end
26
-
27
- # Name-keyed view: { canonical_name => Schema }. The key is the schema's
28
- # file stem, which is authoritative even when a schema file carries no
29
- # top-level `name:` (Schema#name reads the body and may be nil). Symmetric
30
- # with #all (values); use this when you need the names too.
31
- def by_name
32
- @schemas.dup
33
- end
34
-
35
- private
36
-
37
- def load_all
38
- return unless File.directory?(@dir)
39
-
40
- Dir.glob(File.join(@dir, "*.yaml")).each do |path|
41
- name = File.basename(path, ".yaml")
42
- begin
43
- @schemas[name] = Schema.load(path)
44
- rescue StandardError
45
- # Tolerate broken schema files at construction time so the rest of
46
- # the store remains loadable. Surfacing the failure is the job of
47
- # Doctor::Check::SchemaParseError. Lookups via #fetch still raise
48
- # IoError for the missing-but-named schema.
49
- next
50
- end
51
- end
52
- end
53
- end
54
- end
@@ -1,35 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry-struct"
4
-
5
- module Textus
6
- # The agent session: per-connection (MCP), per-process (CLI), or per-loop
7
- # (Ruby) orientation state — the audit cursor plus the contract etag and
8
- # propose_lane captured at boot. Immutable Dry::Struct::Value; advance_cursor
9
- # and with return new instances. ADR 0036; contract_etag widened in ADR 0074.
10
- class Session < Dry::Struct
11
- attribute :role, Types::RoleName
12
- attribute :cursor, Types::Cursor
13
- attribute :propose_lane, Types::String.optional
14
- attribute :contract_etag, Types::String
15
-
16
- def with(**attrs) = self.class.new(to_h.merge(attrs))
17
-
18
- def advance_cursor(new_cursor) = with(cursor: new_cursor)
19
-
20
- def check_etag!(observed_etag)
21
- return if observed_etag == contract_etag
22
-
23
- raise Textus::ContractDrift.new(
24
- "contract changed (manifest/hooks/schemas were #{short_etag(contract_etag)}, " \
25
- "now #{short_etag(observed_etag)}); re-run boot",
26
- )
27
- end
28
-
29
- private
30
-
31
- # First 8 hex chars after the "sha256:" prefix — a stable short id for
32
- # the drift diagnostic.
33
- def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
34
- end
35
- end
@@ -1,30 +0,0 @@
1
- module Textus
2
- module Surfaces
3
- class CLI
4
- class Verb
5
- class Put < Runner::Base
6
- self.spec = Textus::Action::Put.contract
7
- option :as_flag, "--as=ROLE"
8
- option :use_stdin, "--stdin"
9
-
10
- def invoke(store)
11
- key = positional.shift or raise UsageError.new("put requires a key")
12
- raise UsageError.new("put requires --stdin in v1") unless use_stdin
13
-
14
- payload = JSON.parse(@stdin.read)
15
- cmd = Textus::Command::Put.new(
16
- key: key,
17
- meta: payload["_meta"] || {},
18
- body: payload["body"] || "",
19
- content: nil,
20
- if_etag: payload["if_etag"],
21
- role: resolved_role(store),
22
- )
23
- result = store.gate.dispatch(cmd)
24
- emit(result.to_h_for_wire)
25
- end
26
- end
27
- end
28
- end
29
- end
30
- end
@@ -1,111 +0,0 @@
1
- module Textus
2
- module Surfaces
3
- module MCP
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.
8
- module Catalog
9
- module_function
10
-
11
- WRITE_VERBS = %i[
12
- put propose key_delete key_mv accept reject enqueue
13
- ].freeze
14
-
15
- MAINTENANCE_VERBS = %i[
16
- data_mv key_mv_prefix key_delete_prefix drain rule_lint
17
- ].freeze
18
-
19
- # Contracts of every MCP-surfaced verb, in Dispatcher order.
20
- def specs
21
- Textus::Action::VERBS.values
22
- .select { |k| mcp_surfaced?(k) }
23
- .map(&:contract)
24
- end
25
-
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
41
- end
42
-
43
- def names
44
- Textus::Action::VERBS
45
- .select { |_, klass| mcp_surfaced?(klass) }
46
- .keys.map(&:to_s)
47
- end
48
-
49
- # MCP-surfaced read verbs, by Dispatcher class namespace — the agent's
50
- # real read/discovery surface. `boot.agent_quickstart.read_verbs` derives
51
- # from this so it can never advertise a verb the agent cannot call, nor
52
- # omit one it can (ADR 0056). Excludes write/maintenance verbs by verb
53
- # identity (routing may be legacy UseCases or Dispatch::Actions).
54
- def read_verbs
55
- Textus::Action::VERBS
56
- .reject { |verb, _klass| WRITE_VERBS.include?(verb) || MAINTENANCE_VERBS.include?(verb) }
57
- .select { |_verb, klass| mcp_surfaced?(klass) }
58
- .keys.map(&:to_s)
59
- end
60
-
61
- # MCP-surfaced write verbs, by Dispatcher class namespace — the mirror of
62
- # read_verbs for the write side. `boot.agent_quickstart.write_verbs` derives
63
- # from this so it advertises bare verb names the agent can call (no `--as`/
64
- # `--stdin` CLI framing), finishing the de-CLI-ing of the agent surface
65
- # (ADR 0056, ADR 0057).
66
- def write_verbs
67
- Textus::Action::VERBS
68
- .select { |verb, klass| WRITE_VERBS.include?(verb) && mcp_surfaced?(klass) }
69
- .keys.map(&:to_s)
70
- end
71
-
72
- def mcp_surfaced?(klass)
73
- klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
74
- end
75
-
76
- def call(name, session:, store:, args:) # rubocop:disable Metrics/AbcSize
77
- klass = Textus::Action::VERBS[name.to_sym]
78
- raise ToolError.new("unknown tool: #{name}") unless klass && mcp_surfaced?(klass)
79
-
80
- spec = klass.contract
81
- inputs = Textus::Contract::Binder.inputs_from_wire(spec, args)
82
-
83
- invoke = lambda do |effective_inputs|
84
- pos, kwargs = Textus::Contract::Binder.bind(spec, effective_inputs, session: session)
85
- spec.args.select(&:positional).zip(pos).each { |a, v| kwargs[a.name] = v unless kwargs.key?(a.name) }
86
- cmd_class = Textus::Gate::VERB_COMMAND.fetch(spec.verb) do
87
- raise Textus::MCP::ToolError.new("unknown verb: #{spec.verb}")
88
- end
89
- merged = kwargs.merge(role: session.role)
90
- filled = cmd_class.members.to_h { |m| [m, merged.key?(m) ? merged[m] : nil] }
91
- cmd = cmd_class.new(**filled)
92
- store.gate.dispatch(cmd)
93
- end
94
-
95
- result = if spec.around
96
- Textus::Contract::Around.with(spec.around, scope: store.as(session.role), inputs: inputs, session: session, &invoke)
97
- else
98
- invoke.call(inputs)
99
- end
100
- Textus::Contract::View.render(spec, :default, result, inputs)
101
- rescue Textus::Contract::MissingArgs => e
102
- raise ToolError.new("#{spec.verb}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
103
- rescue Textus::ContractDrift, CursorExpired
104
- raise
105
- rescue Textus::Error => e
106
- raise ToolError.new("#{name}: #{e.message}")
107
- end
108
- end
109
- end
110
- end
111
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Surfaces
5
- # Role-scoped identity carrier. Holds the acting identity (role,
6
- # correlation_id, dry_run) bound to a container. All verb methods
7
- # (put, get, accept, ...) are injected by textus.rb's define_method
8
- # loop, which dispatches directly through Gate.
9
- class RoleScope
10
- attr_reader :container, :role, :correlation_id
11
-
12
- def initialize(container:, role:, dry_run: false, correlation_id: nil)
13
- @container = container
14
- @role = role.to_s
15
- @dry_run = dry_run
16
- @correlation_id = correlation_id || SecureRandom.uuid
17
- end
18
-
19
- def dry_run? = !!@dry_run
20
-
21
- def with_role(role)
22
- self.class.new(container: @container, role:, dry_run: @dry_run, correlation_id: @correlation_id)
23
- end
24
-
25
- def with_correlation_id(cid)
26
- self.class.new(container: @container, role: @role, dry_run: @dry_run, correlation_id: cid)
27
- end
28
-
29
- def with_dry_run
30
- self.class.new(container: @container, role: @role, dry_run: true, correlation_id: @correlation_id)
31
- end
32
- end
33
- end
34
- end
data/lib/textus/types.rb DELETED
@@ -1,15 +0,0 @@
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