textus 0.55.2 → 0.56.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 (338) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +175 -2768
  3. data/README.md +8 -9
  4. data/lib/textus/artifact.rb +8 -0
  5. data/lib/textus/errors.rb +0 -39
  6. data/lib/textus/human.rb +7 -0
  7. data/lib/textus/infra/adapters/concurrency_adapter.rb +15 -0
  8. data/lib/textus/infra/clock.rb +7 -0
  9. data/lib/textus/infra/database.rb +127 -0
  10. data/lib/textus/infra/file_system.rb +54 -0
  11. data/lib/textus/infra/locks.rb +50 -0
  12. data/lib/textus/infra/port/clock.rb +18 -0
  13. data/lib/textus/infra/port/publisher.rb +71 -0
  14. data/lib/textus/infra/port/sentinel.rb +25 -0
  15. data/lib/textus/infra/port/sentinel_store.rb +93 -0
  16. data/lib/textus/infra/store/audit_log.rb +42 -0
  17. data/lib/textus/infra/store/base.rb +25 -0
  18. data/lib/textus/infra/store/cursor_store.rb +27 -0
  19. data/lib/textus/infra/store/entry_index.rb +31 -0
  20. data/lib/textus/infra/store/envelope/meta.rb +63 -0
  21. data/lib/textus/infra/store/file_store.rb +23 -0
  22. data/lib/textus/infra/store/index/builder.rb +28 -0
  23. data/lib/textus/infra/store/index/lookup.rb +23 -0
  24. data/lib/textus/infra/store/link_store.rb +43 -0
  25. data/lib/textus/infra/store/workflow_queue.rb +54 -0
  26. data/lib/textus/infra/store.rb +29 -0
  27. data/lib/textus/ingest.rb +8 -0
  28. data/lib/textus/lanes/artifact/cache.rb +35 -0
  29. data/lib/textus/lanes/artifact/handlers.rb +29 -0
  30. data/lib/textus/lanes/artifact/lifecycle.rb +23 -0
  31. data/lib/textus/lanes/artifact/verbs.rb +18 -0
  32. data/lib/textus/lanes/ingest/dedup.rb +17 -0
  33. data/lib/textus/lanes/ingest/entry_types/asset.rb +45 -0
  34. data/lib/textus/lanes/ingest/entry_types/link.rb +16 -0
  35. data/lib/textus/lanes/ingest/entry_types/text.rb +17 -0
  36. data/lib/textus/lanes/ingest/entry_types.rb +25 -0
  37. data/lib/textus/lanes/ingest/handlers.rb +54 -0
  38. data/lib/textus/lanes/ingest/index_rebuilder.rb +13 -0
  39. data/lib/textus/lanes/ingest/key_builder.rb +33 -0
  40. data/lib/textus/lanes/ingest/resolver.rb +21 -0
  41. data/lib/textus/lanes/knowledge/freeform.rb +15 -0
  42. data/lib/textus/lanes/knowledge/handlers.rb +53 -0
  43. data/lib/textus/lanes/knowledge/runner.rb +45 -0
  44. data/lib/textus/lanes/knowledge/verbs.rb +16 -0
  45. data/lib/textus/lanes/scratchpad/handlers.rb +164 -0
  46. data/lib/textus/lanes/scratchpad/verbs.rb +73 -0
  47. data/lib/textus/{dispatch → protocol}/binder.rb +1 -7
  48. data/lib/textus/protocol/boot/catalog.rb +34 -0
  49. data/lib/textus/protocol/boot/context.rb +124 -0
  50. data/lib/textus/protocol/boot/protocol.rb +33 -0
  51. data/lib/textus/protocol/boot.rb +42 -0
  52. data/lib/textus/protocol/builder/domain.rb +17 -0
  53. data/lib/textus/protocol/builder/infrastructure.rb +17 -0
  54. data/lib/textus/protocol/builder/runtime.rb +46 -0
  55. data/lib/textus/protocol/builder.rb +11 -0
  56. data/lib/textus/protocol/container.rb +22 -0
  57. data/lib/textus/protocol/diff.rb +170 -0
  58. data/lib/textus/protocol/entry_constraint.rb +54 -0
  59. data/lib/textus/protocol/envelope.rb +70 -0
  60. data/lib/textus/protocol/format/base.rb +80 -0
  61. data/lib/textus/protocol/format/bash.rb +10 -0
  62. data/lib/textus/protocol/format/javascript.rb +10 -0
  63. data/lib/textus/protocol/format/json.rb +82 -0
  64. data/lib/textus/protocol/format/markdown.rb +53 -0
  65. data/lib/textus/protocol/format/python.rb +10 -0
  66. data/lib/textus/protocol/format/ruby.rb +10 -0
  67. data/lib/textus/protocol/format/script.rb +55 -0
  68. data/lib/textus/protocol/format/text.rb +48 -0
  69. data/lib/textus/protocol/format/yaml.rb +105 -0
  70. data/lib/textus/protocol/format.rb +73 -0
  71. data/lib/textus/{store → protocol}/freshness/ttl_evaluator.rb +7 -3
  72. data/lib/textus/protocol/gate.rb +61 -0
  73. data/lib/textus/protocol/handlers/command_context.rb +19 -0
  74. data/lib/textus/protocol/handlers/concern.rb +15 -0
  75. data/lib/textus/protocol/handlers/ops/audit_filter.rb +37 -0
  76. data/lib/textus/protocol/handlers/ops/bulk_planner.rb +38 -0
  77. data/lib/textus/protocol/handlers/ops/data_mv.rb +49 -0
  78. data/lib/textus/protocol/handlers/ops/git_blame.rb +65 -0
  79. data/lib/textus/protocol/handlers/ops/pulse_coordinator.rb +37 -0
  80. data/lib/textus/protocol/handlers/ops/source_expander.rb +41 -0
  81. data/lib/textus/protocol/handlers/query_context.rb +41 -0
  82. data/lib/textus/protocol/handlers/read.rb +67 -0
  83. data/lib/textus/protocol/handlers/system.rb +93 -0
  84. data/lib/textus/protocol/handlers/write.rb +57 -0
  85. data/lib/textus/protocol/init.rb +143 -0
  86. data/lib/textus/protocol/key/grammar.rb +35 -0
  87. data/lib/textus/protocol/key/matching.rb +23 -0
  88. data/lib/textus/protocol/key/path.rb +23 -0
  89. data/lib/textus/protocol/key.rb +25 -0
  90. data/lib/textus/protocol/lane.rb +12 -0
  91. data/lib/textus/{store → protocol}/layout.rb +16 -22
  92. data/lib/textus/protocol/links/resolver.rb +33 -0
  93. data/lib/textus/protocol/links/uri_rewriter.rb +30 -0
  94. data/lib/textus/protocol/links.rb +10 -0
  95. data/lib/textus/protocol/manifest/data.rb +90 -0
  96. data/lib/textus/protocol/manifest/entry/base.rb +95 -0
  97. data/lib/textus/protocol/manifest/entry/leaf.rb +19 -0
  98. data/lib/textus/protocol/manifest/entry/nested.rb +19 -0
  99. data/lib/textus/protocol/manifest/entry/parser.rb +71 -0
  100. data/lib/textus/protocol/manifest/entry/publish.rb +209 -0
  101. data/lib/textus/protocol/manifest/entry.rb +10 -0
  102. data/lib/textus/protocol/manifest/policy/publish_target.rb +36 -0
  103. data/lib/textus/protocol/manifest/policy.rb +69 -0
  104. data/lib/textus/protocol/manifest/resolver.rb +156 -0
  105. data/lib/textus/protocol/manifest/schema/contract.rb +53 -0
  106. data/lib/textus/protocol/manifest/schema/validator.rb +52 -0
  107. data/lib/textus/protocol/manifest/schema.rb +40 -0
  108. data/lib/textus/protocol/manifest.rb +62 -0
  109. data/lib/textus/protocol/produce/render/context.rb +43 -0
  110. data/lib/textus/protocol/produce/render.rb +44 -0
  111. data/lib/textus/protocol/schema/registry.rb +44 -0
  112. data/lib/textus/protocol/schema/tools.rb +104 -0
  113. data/lib/textus/protocol/schema.rb +117 -0
  114. data/lib/textus/protocol/session/bridge.rb +11 -0
  115. data/lib/textus/protocol/session/context.rb +5 -0
  116. data/lib/textus/protocol/session.rb +87 -0
  117. data/lib/textus/protocol/store_engine/delete.rb +37 -0
  118. data/lib/textus/protocol/store_engine/move.rb +109 -0
  119. data/lib/textus/protocol/store_engine/pipeline.rb +61 -0
  120. data/lib/textus/protocol/store_engine/put.rb +104 -0
  121. data/lib/textus/protocol/store_engine.rb +86 -0
  122. data/lib/textus/protocol/v4.rb +25 -0
  123. data/lib/textus/protocol/verb_registry/arg_spec.rb +19 -0
  124. data/lib/textus/protocol/verb_registry/core_verbs.rb +164 -0
  125. data/lib/textus/protocol/verb_registry/verb_spec.rb +42 -0
  126. data/lib/textus/protocol/verb_registry.rb +42 -0
  127. data/lib/textus/surface/cli/group/{rule.rb → web.rb} +2 -2
  128. data/lib/textus/surface/cli/runner.rb +22 -21
  129. data/lib/textus/surface/cli/sources.rb +1 -3
  130. data/lib/textus/surface/cli/verb/get.rb +2 -7
  131. data/lib/textus/surface/cli/verb/init.rb +1 -1
  132. data/lib/textus/surface/cli/verb/put.rb +7 -7
  133. data/lib/textus/surface/cli/verb/web_serve.rb +22 -0
  134. data/lib/textus/surface/cli.rb +25 -6
  135. data/lib/textus/surface/dispatch.rb +17 -0
  136. data/lib/textus/surface/mcp/adapters/mcp_adapter.rb +28 -0
  137. data/lib/textus/surface/mcp/catalog.rb +12 -12
  138. data/lib/textus/surface/mcp/cursor_manager.rb +22 -0
  139. data/lib/textus/surface/mcp/drift_annotation.rb +19 -0
  140. data/lib/textus/surface/mcp/server.rb +35 -24
  141. data/lib/textus/surface/watcher.rb +18 -12
  142. data/lib/textus/surface/web.rb +50 -0
  143. data/lib/textus/value/call.rb +1 -1
  144. data/lib/textus/value/etag.rb +7 -7
  145. data/lib/textus/version.rb +1 -1
  146. data/lib/textus/workflow/consumer.rb +39 -0
  147. data/lib/textus/workflow/dsl/lifecycle.rb +32 -0
  148. data/lib/textus/workflow/dsl/pattern_matcher.rb +20 -0
  149. data/lib/textus/workflow/dsl/step_builder.rb +56 -0
  150. data/lib/textus/workflow/dsl.rb +111 -21
  151. data/lib/textus/workflow/event_emitter.rb +21 -0
  152. data/lib/textus/workflow/publisher.rb +59 -0
  153. data/lib/textus/workflow/registry.rb +31 -8
  154. data/lib/textus/workflow/runner.rb +25 -53
  155. data/lib/textus/workflow/scheduler.rb +45 -0
  156. data/lib/textus/workflow/step_executor.rb +78 -0
  157. data/lib/textus/workflow/step_helpers.rb +102 -0
  158. data/lib/textus/workflow.rb +2 -5
  159. data/lib/textus.rb +16 -16
  160. metadata +169 -185
  161. data/SPEC.md +0 -909
  162. data/docs/architecture/README.md +0 -273
  163. data/docs/reference/conventions.md +0 -141
  164. data/lib/textus/boot.rb +0 -181
  165. data/lib/textus/dispatch/contracts.rb +0 -63
  166. data/lib/textus/dispatch/handler_registry.rb +0 -21
  167. data/lib/textus/dispatch/middleware/audit_index.rb +0 -51
  168. data/lib/textus/dispatch/middleware/auth.rb +0 -40
  169. data/lib/textus/dispatch/middleware/base.rb +0 -26
  170. data/lib/textus/dispatch/middleware/binder.rb +0 -20
  171. data/lib/textus/dispatch/middleware/cascade.rb +0 -53
  172. data/lib/textus/dispatch/pipeline.rb +0 -35
  173. data/lib/textus/doctor/check/audit_log.rb +0 -34
  174. data/lib/textus/doctor/check/generator_drift.rb +0 -29
  175. data/lib/textus/doctor/check/illegal_keys.rb +0 -60
  176. data/lib/textus/doctor/check/manifest_files.rb +0 -25
  177. data/lib/textus/doctor/check/orphaned_publish_targets.rb +0 -35
  178. data/lib/textus/doctor/check/proposal_targets.rb +0 -45
  179. data/lib/textus/doctor/check/protocol_version.rb +0 -47
  180. data/lib/textus/doctor/check/publish_tree_index_overlap.rb +0 -48
  181. data/lib/textus/doctor/check/raw_asset_paths.rb +0 -50
  182. data/lib/textus/doctor/check/rule_ambiguity.rb +0 -49
  183. data/lib/textus/doctor/check/schema_parse_error.rb +0 -28
  184. data/lib/textus/doctor/check/schema_violations.rb +0 -28
  185. data/lib/textus/doctor/check/schemas.rb +0 -26
  186. data/lib/textus/doctor/check/scratchpad_sources.rb +0 -55
  187. data/lib/textus/doctor/check/sentinels.rb +0 -60
  188. data/lib/textus/doctor/check/stale_reviewed_stamp.rb +0 -54
  189. data/lib/textus/doctor/check/templates.rb +0 -29
  190. data/lib/textus/doctor/check/unowned_schema_fields.rb +0 -40
  191. data/lib/textus/doctor/check.rb +0 -43
  192. data/lib/textus/doctor/validator.rb +0 -93
  193. data/lib/textus/doctor.rb +0 -54
  194. data/lib/textus/format/base.rb +0 -77
  195. data/lib/textus/format/json.rb +0 -81
  196. data/lib/textus/format/markdown.rb +0 -59
  197. data/lib/textus/format/text.rb +0 -55
  198. data/lib/textus/format/yaml.rb +0 -104
  199. data/lib/textus/format.rb +0 -61
  200. data/lib/textus/handlers/maintenance/boot_store.rb +0 -15
  201. data/lib/textus/handlers/maintenance/doctor_store.rb +0 -15
  202. data/lib/textus/handlers/maintenance/drain_store.rb +0 -21
  203. data/lib/textus/handlers/maintenance/ingest_entry.rb +0 -159
  204. data/lib/textus/handlers/maintenance/jobs_action.rb +0 -21
  205. data/lib/textus/handlers/maintenance/published_entries.rb +0 -17
  206. data/lib/textus/handlers/maintenance/rule_explain.rb +0 -77
  207. data/lib/textus/handlers/maintenance/rule_lint.rb +0 -54
  208. data/lib/textus/handlers/maintenance/rule_list.rb +0 -32
  209. data/lib/textus/handlers/maintenance/schema_envelope.rb +0 -19
  210. data/lib/textus/handlers/read/audit_entries.rb +0 -48
  211. data/lib/textus/handlers/read/blame_entry.rb +0 -71
  212. data/lib/textus/handlers/read/deps_entry.rb +0 -17
  213. data/lib/textus/handlers/read/get_entry.rb +0 -68
  214. data/lib/textus/handlers/read/list_keys.rb +0 -36
  215. data/lib/textus/handlers/read/pulse_entries.rb +0 -66
  216. data/lib/textus/handlers/read/rdeps_entry.rb +0 -21
  217. data/lib/textus/handlers/read/uid_entry.rb +0 -18
  218. data/lib/textus/handlers/read/where_entry.rb +0 -18
  219. data/lib/textus/handlers/write/accept_proposal.rb +0 -39
  220. data/lib/textus/handlers/write/data_mv.rb +0 -55
  221. data/lib/textus/handlers/write/delete_key.rb +0 -17
  222. data/lib/textus/handlers/write/enqueue_job.rb +0 -27
  223. data/lib/textus/handlers/write/key_delete_prefix.rb +0 -32
  224. data/lib/textus/handlers/write/key_mv_prefix.rb +0 -45
  225. data/lib/textus/handlers/write/move_key.rb +0 -80
  226. data/lib/textus/handlers/write/propose_entry.rb +0 -29
  227. data/lib/textus/handlers/write/put_entry.rb +0 -29
  228. data/lib/textus/handlers/write/reject_proposal.rb +0 -29
  229. data/lib/textus/init.rb +0 -157
  230. data/lib/textus/jobs.rb +0 -9
  231. data/lib/textus/key/distance.rb +0 -55
  232. data/lib/textus/key/grammar.rb +0 -33
  233. data/lib/textus/key/matching.rb +0 -24
  234. data/lib/textus/key/path.rb +0 -28
  235. data/lib/textus/manifest/capabilities.rb +0 -29
  236. data/lib/textus/manifest/data.rb +0 -102
  237. data/lib/textus/manifest/entry/base.rb +0 -105
  238. data/lib/textus/manifest/entry/ignore_matcher.rb +0 -46
  239. data/lib/textus/manifest/entry/leaf.rb +0 -17
  240. data/lib/textus/manifest/entry/nested.rb +0 -37
  241. data/lib/textus/manifest/entry/parser.rb +0 -99
  242. data/lib/textus/manifest/entry/produced.rb +0 -36
  243. data/lib/textus/manifest/entry/publish/mode.rb +0 -45
  244. data/lib/textus/manifest/entry/publish/none.rb +0 -14
  245. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +0 -73
  246. data/lib/textus/manifest/entry/publish/template.rb +0 -16
  247. data/lib/textus/manifest/entry/publish/to_paths.rb +0 -76
  248. data/lib/textus/manifest/entry/publish/tree.rb +0 -50
  249. data/lib/textus/manifest/entry/publish.rb +0 -45
  250. data/lib/textus/manifest/entry/validators/format_matrix.rb +0 -21
  251. data/lib/textus/manifest/entry/validators/ignore.rb +0 -28
  252. data/lib/textus/manifest/entry/validators/publish.rb +0 -30
  253. data/lib/textus/manifest/entry/validators.rb +0 -18
  254. data/lib/textus/manifest/entry.rb +0 -8
  255. data/lib/textus/manifest/policy/matcher.rb +0 -51
  256. data/lib/textus/manifest/policy/predicates/author_held.rb +0 -22
  257. data/lib/textus/manifest/policy/predicates/etag_match.rb +0 -18
  258. data/lib/textus/manifest/policy/predicates/fresh_within.rb +0 -13
  259. data/lib/textus/manifest/policy/predicates/lane_deletable_by.rb +0 -31
  260. data/lib/textus/manifest/policy/predicates/lane_writable_by.rb +0 -23
  261. data/lib/textus/manifest/policy/predicates/raw_lane_ingest_only.rb +0 -25
  262. data/lib/textus/manifest/policy/predicates/raw_write_once.rb +0 -24
  263. data/lib/textus/manifest/policy/predicates/schema_valid.rb +0 -41
  264. data/lib/textus/manifest/policy/predicates/target_is_canon.rb +0 -20
  265. data/lib/textus/manifest/policy/predicates.rb +0 -54
  266. data/lib/textus/manifest/policy/publish_target.rb +0 -34
  267. data/lib/textus/manifest/policy/react.rb +0 -30
  268. data/lib/textus/manifest/policy/retention.rb +0 -29
  269. data/lib/textus/manifest/policy/source.rb +0 -30
  270. data/lib/textus/manifest/policy.rb +0 -95
  271. data/lib/textus/manifest/resolver.rb +0 -118
  272. data/lib/textus/manifest/rules.rb +0 -86
  273. data/lib/textus/manifest/schema/contract.rb +0 -61
  274. data/lib/textus/manifest/schema/keys.rb +0 -90
  275. data/lib/textus/manifest/schema/semantics/cross_field.rb +0 -53
  276. data/lib/textus/manifest/schema/semantics/invariants.rb +0 -125
  277. data/lib/textus/manifest/schema/semantics/migration.rb +0 -83
  278. data/lib/textus/manifest/schema/semantics.rb +0 -27
  279. data/lib/textus/manifest/schema/validator.rb +0 -48
  280. data/lib/textus/manifest/schema/vocabulary.rb +0 -25
  281. data/lib/textus/manifest/schema.rb +0 -30
  282. data/lib/textus/manifest.rb +0 -65
  283. data/lib/textus/orchestration.rb +0 -55
  284. data/lib/textus/port/audit_log.rb +0 -268
  285. data/lib/textus/port/build_lock.rb +0 -64
  286. data/lib/textus/port/clock.rb +0 -10
  287. data/lib/textus/port/publisher.rb +0 -60
  288. data/lib/textus/port/sentinel.rb +0 -25
  289. data/lib/textus/port/sentinel_store.rb +0 -83
  290. data/lib/textus/port/storage/file_stat.rb +0 -19
  291. data/lib/textus/port/storage/file_store.rb +0 -49
  292. data/lib/textus/port/storage/interface.rb +0 -17
  293. data/lib/textus/port/store.rb +0 -149
  294. data/lib/textus/port/watcher_lock.rb +0 -48
  295. data/lib/textus/produce/engine.rb +0 -40
  296. data/lib/textus/produce/publisher.rb +0 -21
  297. data/lib/textus/produce/render.rb +0 -20
  298. data/lib/textus/schema/registry.rb +0 -42
  299. data/lib/textus/schema/tools.rb +0 -108
  300. data/lib/textus/schema.rb +0 -100
  301. data/lib/textus/store/container.rb +0 -173
  302. data/lib/textus/store/cursor.rb +0 -26
  303. data/lib/textus/store/entry/reader.rb +0 -47
  304. data/lib/textus/store/entry/writer.rb +0 -219
  305. data/lib/textus/store/envelope/meta.rb +0 -61
  306. data/lib/textus/store/freshness/drift_detector.rb +0 -93
  307. data/lib/textus/store/freshness/evaluator.rb +0 -20
  308. data/lib/textus/store/freshness/verdict.rb +0 -24
  309. data/lib/textus/store/freshness.rb +0 -8
  310. data/lib/textus/store/index/builder.rb +0 -76
  311. data/lib/textus/store/index/lookup.rb +0 -60
  312. data/lib/textus/store/jobs/base.rb +0 -13
  313. data/lib/textus/store/jobs/index.rb +0 -15
  314. data/lib/textus/store/jobs/materialize.rb +0 -15
  315. data/lib/textus/store/jobs/plan.rb +0 -11
  316. data/lib/textus/store/jobs/planner.rb +0 -124
  317. data/lib/textus/store/jobs/queue.rb +0 -162
  318. data/lib/textus/store/jobs/registry.rb +0 -19
  319. data/lib/textus/store/jobs/retention/base.rb +0 -52
  320. data/lib/textus/store/jobs/retention/sweep.rb +0 -55
  321. data/lib/textus/store/jobs/retention.rb +0 -8
  322. data/lib/textus/store/jobs/sweep.rb +0 -21
  323. data/lib/textus/store/jobs/worker.rb +0 -64
  324. data/lib/textus/store.rb +0 -122
  325. data/lib/textus/surface/cli/verb/doctor.rb +0 -20
  326. data/lib/textus/surface/cli/verb/schema_diff.rb +0 -17
  327. data/lib/textus/surface/cli/verb/schema_init.rb +0 -21
  328. data/lib/textus/surface/cli/verb/schema_migrate.rb +0 -21
  329. data/lib/textus/surface/mcp/projector.rb +0 -20
  330. data/lib/textus/surface/mcp.rb +0 -8
  331. data/lib/textus/value/command.rb +0 -16
  332. data/lib/textus/value/envelope.rb +0 -90
  333. data/lib/textus/value/result.rb +0 -46
  334. data/lib/textus/verb_registry.rb +0 -417
  335. data/lib/textus/workflow/collector.rb +0 -27
  336. data/lib/textus/workflow/context.rb +0 -5
  337. data/lib/textus/workflow/loader.rb +0 -17
  338. data/lib/textus/workflow/pattern.rb +0 -18
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- Generated from knowledge.readme.* fragments by the artifacts.docs.readme workflow. Edit the fragments under .textus/data/knowledge/readme/ and run `textus drain`. -->
1
+ <!-- Generated from knowledge.intent.readme-* fragments by the artifacts.docs.readme workflow. Edit the fragments under .textus/data/knowledge/intent/ and run `textus drain`. -->
2
2
  <!-- This is a fragment of the README. Edit here; the README is composed from fragments by a workflow. -->
3
3
  <p align="center">
4
4
  <picture>
@@ -125,7 +125,7 @@ Try the gate the other way (`textus put knowledge.notes.X --as=agent`) and you g
125
125
 
126
126
  This Ruby gem is the reference implementation of **`textus/4`** — a wire format and storage convention any language can speak. The protocol owns the envelope shape, the role/lane gate, the audit log format, and the key grammar. The gem version (semver, see badge) and the protocol version (`textus/4`) move independently; envelopes carry the `protocol` field so consumers can pin to the contract, not the implementation.
127
127
 
128
- - Specification: [`SPEC.md`](SPEC.md)
128
+ - Specification: the wire protocol spec (`textus get knowledge.specs.*`)
129
129
  - Architecture: [`docs/architecture/README.md`](docs/architecture/README.md)
130
130
  - Per-release notes: [`CHANGELOG.md`](CHANGELOG.md)
131
131
 
@@ -201,9 +201,9 @@ For a worked store — knowledge entries, a staged proposal, schemas, ERB templa
201
201
 
202
202
  ## What's shipped
203
203
 
204
- - **Per-entry formats & publish.** `format: markdown|json|yaml|text` per entry; a typed `publish:` block (`to:` for file fan-out, `tree:` for a whole-subtree mirror) byte-copies derived files to their consumer paths. ([SPEC §5.2–5.3](SPEC.md))
204
+ - **Per-entry formats & publish.** `format: markdown|json|yaml|text` per entry; a typed `publish:` block (`to:` for file fan-out, `tree:` for a whole-subtree mirror) byte-copies derived files to their consumer paths. (the wire protocol spec §5.2–5.3)
205
205
  - **Stable identity.** Auto-minted `uid:` survives writes and `textus key mv`; reorganising never breaks references.
206
- - **Capability × lane-kind gate.** Writes carry `--as=<role>`; a role may write a lane iff it holds the capability the lane's `kind:` requires (`canon`→`author`, `workspace`→`keep`, `machine`→`converge`, `queue`→`propose`). The wrong role gets `write_forbidden` naming the capability needed and the roles that hold it. ([SPEC §5](SPEC.md))
206
+ - **Capability × lane-kind gate.** Writes carry `--as=<role>`; a role may write a lane iff it holds the capability the lane's `kind:` requires (`canon`→`author`, `workspace`→`keep`, `machine`→`converge`, `queue`→`propose`). The wrong role gets `write_forbidden` naming the capability needed and the roles that hold it. (the wire protocol spec §5)
207
207
  - **Agent loop.** `textus boot` orients a fresh session; `textus pulse --since=N` is the per-turn heartbeat (changed entries, pending proposals, index etag for catalog drift detection). ([docs/how-to/agents-mcp.md](docs/how-to/agents-mcp.md))
208
208
  - **MCP surface.** The official `mcp` Ruby SDK drives the stdio JSON-RPC server; protocol version auto-negotiated up to `2025-11-25`. Wire textus into Claude Code, Cursor, or any MCP host in one config block.
209
209
  - **`textus doctor`.** Health checks across schemas, workflow registrations, keys, sentinels, and the audit log.
@@ -211,10 +211,10 @@ For a worked store — knowledge entries, a staged proposal, schemas, ERB templa
211
211
 
212
212
  ## CLI and lanes
213
213
 
214
- Every command operates on one store, located in this order: `--root <path>` flag → **`TEXTUS_ROOT`** env → walk up from the working directory for a `.textus/` ([SPEC §3.1](SPEC.md)). Write verbs require `--as=<role>`, resolved as: `--as` flag → **`TEXTUS_ROLE`** env → `.textus/role` file → default `human` ([SPEC §5.1](SPEC.md)). Default roles: `human`, `agent`, `automation` (rename or add your own in the manifest's `roles:` block). All verbs accept `--output=json` and return the envelope defined in [SPEC §8](SPEC.md).
214
+ Every command operates on one store, located in this order: `--root <path>` flag → **`TEXTUS_ROOT`** env → walk up from the working directory for a `.textus/` (the wire protocol spec §3.1). Write verbs require `--as=<role>`, resolved as: `--as` flag → **`TEXTUS_ROLE`** env → `.textus/role` file → default `human` (the wire protocol spec §5.1). Default roles: `human`, `agent`, `automation` (rename or add your own in the manifest's `roles:` block). All verbs accept `--output=json` and return the envelope defined in the wire protocol spec §8.
215
215
 
216
- - Full verb table — read, write, health, scaffolding — is in [SPEC §9](SPEC.md).
217
- - Lane semantics and the capability × lane-kind mapping live in [SPEC §5](SPEC.md), with the reference in [`docs/reference/lanes.md`](docs/reference/lanes.md).
216
+ - Full verb table — read, write, health, scaffolding — is in the wire protocol spec §9.
217
+ - Lane semantics and the capability × lane-kind mapping live in the wire protocol spec §5, with the reference in [`docs/reference/lanes.md`](docs/reference/lanes.md).
218
218
 
219
219
  `textus boot` prints the same information for the current store: lanes, entry families with schemas, registered workflows, write flows, and the verb catalog. Run it inside a store and you get the live picture; reach for the SPEC when you want the contract.
220
220
 
@@ -225,7 +225,7 @@ Produced entries (`kind: produced`) declare how they're acquired in one `source:
225
225
  - **`source: { from: external, command: "...", sources: [...] }`** — *externally managed*: an out-of-band command or workflow writes the file; textus tracks staleness via declared `sources`.
226
226
  - **`source: { from: external, command: "true", sources: [] }` + a workflow** — *workflow-driven*: a `Textus.workflow` block (in `.textus/workflows/`) acquires and shapes the data on `drain`.
227
227
 
228
- Publishing is one typed `publish:` block (ADR 0052/0094). Each target is either `{ to: path, template?: name }` for a single file (optionally rendered through an ERB template) or `{ tree: "dir" }` to mirror a whole stored subtree. Sentinels for every published file live under `.textus/.state/sentinels/` (git-ignored, regenerated on drain). See SPEC §5.2, §5.3, §5.12.
228
+ Publishing is one typed `publish:` block (ADR 0052/0094). Each target is either `{ to: path, template?: name }` for a single file (optionally rendered through an ERB template) or `{ tree: "dir" }` to mirror a whole stored subtree. Sentinels for every published file live under `.textus/track/sentinels/` (git-ignored, regenerated on drain). See SPEC §5.2, §5.3, §5.12.
229
229
 
230
230
  Templates live in `.textus/templates/` as ERB files (`.erb`). The template receives the entry's `content` hash as local variables via `ERB#result_with_hash`. If `inject_boot: true`, a `boot` variable is also available with the live orientation context.
231
231
 
@@ -322,4 +322,3 @@ Lefthook hooks (`brew bundle install` then `lefthook install`) run rubocop on `p
322
322
 
323
323
  [MIT](LICENSE)
324
324
 
325
-
@@ -0,0 +1,8 @@
1
+ module Textus
2
+ module Artifact
3
+ end
4
+ end
5
+
6
+ require_relative "lanes/artifact/cache"
7
+ require_relative "lanes/artifact/lifecycle"
8
+ require_relative "lanes/artifact/handlers"
data/lib/textus/errors.rb CHANGED
@@ -131,18 +131,6 @@ module Textus
131
131
  def initialize(m) = super("io_error", m, exit_code: 64)
132
132
  end
133
133
 
134
- class BuildInProgress < Error
135
- def initialize(holder)
136
- super(
137
- "build_in_progress",
138
- "a textus maintenance pass is already running (#{holder})",
139
- details: { "holder" => holder },
140
- exit_code: 75,
141
- hint: "wait for the running maintenance pass to finish, or check for a recursive hook trigger"
142
- )
143
- end
144
- end
145
-
146
134
  class UsageError < Error
147
135
  def initialize(m, hint: nil) = super("usage", m, exit_code: 2, hint: hint)
148
136
  end
@@ -166,18 +154,6 @@ module Textus
166
154
  end
167
155
  end
168
156
 
169
- class BadRender < Error
170
- def initialize(m, format: nil)
171
- hint =
172
- if format
173
- "the template rendered invalid #{format}; try rendering with mock data and parsing the output before re-running drain"
174
- else
175
- "the template rendered invalid content; try rendering with mock data and parsing the output before re-running drain"
176
- end
177
- super("bad_render", m, hint: hint)
178
- end
179
- end
180
-
181
157
  class PublishError < Error
182
158
  def initialize(m, target: nil)
183
159
  hint =
@@ -190,21 +166,6 @@ module Textus
190
166
  def initialize(m) = super("proposal_error", m)
191
167
  end
192
168
 
193
- class GuardFailed < Error
194
- def initialize(failed)
195
- # failed: [[predicate_name, reason], ...]
196
- rows = failed.map { |name, reason| { "predicate" => name, "reason" => reason } }
197
- names = failed.map(&:first)
198
- super(
199
- "guard_failed",
200
- "guard refused crossing: #{failed.map { |n, r| "#{n} (#{r})" }.join("; ")}",
201
- details: { "failed" => rows },
202
- hint: "run 'textus policy explain <key> --output=json' to see the full guard; " \
203
- "unmet: #{names.join(", ")}",
204
- )
205
- end
206
- end
207
-
208
169
  class ContractDrift < Error
209
170
  JSONRPC_CODE = -32_001
210
171
 
@@ -0,0 +1,7 @@
1
+ module Textus
2
+ module Human
3
+ end
4
+ end
5
+
6
+ require_relative "lanes/knowledge/runner"
7
+ require_relative "lanes/knowledge/freeform"
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Infra
3
+ module Adapters
4
+ class ConcurrencyAdapter
5
+ def future(&)
6
+ Concurrent::Promises.future(&)
7
+ end
8
+
9
+ def zip_futures(*promises)
10
+ Concurrent::Promises.zip_futures(*promises)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ module Textus
2
+ module Infra
3
+ class Clock
4
+ def now = Time.now
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,127 @@
1
+ require "sequel"
2
+ require "fileutils"
3
+
4
+ module Textus
5
+ module Infra
6
+ class Database
7
+ def initialize(layout:)
8
+ @layout = layout
9
+ @db = nil
10
+ end
11
+
12
+ def connection = @db
13
+
14
+ def setup!
15
+ path = @layout.db_path
16
+ FileUtils.mkdir_p(File.dirname(path))
17
+ @db = Sequel.sqlite(path)
18
+ @db.run("PRAGMA journal_mode=WAL")
19
+ @db.run("PRAGMA foreign_keys=ON")
20
+ create_tables
21
+ self
22
+ end
23
+
24
+ def transaction(&) = @db.transaction(&)
25
+ def close = @db.disconnect
26
+
27
+ private
28
+
29
+ def create_tables
30
+ @db.create_table?(:workflow_events) do
31
+ primary_key :id
32
+ String :event_type, null: false
33
+ String :workflow_type, null: false
34
+ String :key
35
+ String :lane
36
+ String :payload, text: true
37
+ DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
38
+ DateTime :scheduled_at
39
+ String :state, null: false, default: "pending"
40
+ Integer :retry_count, null: false, default: 0
41
+ Integer :max_attempts, null: false, default: 3
42
+ String :error, text: true
43
+ index %i[state scheduled_at], where: "state = 'pending'"
44
+ end
45
+
46
+ @db.create_table?(:entries) do
47
+ String :key, primary_key: true
48
+ String :lane, null: false
49
+ String :format, null: false
50
+ String :schema
51
+ String :content, text: true
52
+ DateTime :indexed_at, null: false
53
+ end
54
+
55
+ @db.create_table?(:link_edges) do
56
+ String :from_key, null: false
57
+ String :to_key, null: false
58
+ primary_key %i[from_key to_key]
59
+ index :to_key
60
+ index :from_key
61
+ end
62
+
63
+ @db.create_table?(:audit_events) do
64
+ primary_key :seq
65
+ String :ts, null: false
66
+ String :role, null: false
67
+ String :verb, null: false
68
+ String :key, null: false
69
+ String :etag_before
70
+ String :etag_after
71
+ String :correlation_id
72
+ index :key
73
+ index %i[role verb]
74
+ index :correlation_id
75
+ end
76
+
77
+ @db.create_table?(:cursors) do
78
+ String :role, primary_key: true
79
+ Integer :seq, null: false, default: 0
80
+ DateTime :updated_at, null: false, default: Sequel::CURRENT_TIMESTAMP
81
+ end
82
+
83
+ @db.create_table?(:loop_positions) do
84
+ String :role, primary_key: true
85
+ Integer :index, null: false, default: 0
86
+ DateTime :updated_at, null: false, default: Sequel::CURRENT_TIMESTAMP
87
+ end
88
+
89
+ create_entries_fts
90
+ end
91
+
92
+ def create_entries_fts
93
+ @db.run(<<~SQL)
94
+ CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
95
+ key, lane, schema, content,
96
+ content=entries, content_rowid=rowid
97
+ )
98
+ SQL
99
+
100
+ @db.run(<<~SQL)
101
+ CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
102
+ INSERT INTO entries_fts(rowid, key, lane, schema, content)
103
+ VALUES (new.rowid, new.key, new.lane, new.schema, new.content);
104
+ END
105
+ SQL
106
+
107
+ @db.run(<<~SQL)
108
+ CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
109
+ INSERT INTO entries_fts(entries_fts, rowid, key, lane, schema, content)
110
+ VALUES ('delete', old.rowid, old.key, old.lane, old.schema, old.content);
111
+ END
112
+ SQL
113
+
114
+ @db.run(<<~SQL)
115
+ CREATE TRIGGER IF NOT EXISTS entries_au AFTER UPDATE ON entries BEGIN
116
+ INSERT INTO entries_fts(entries_fts, rowid, key, lane, schema, content)
117
+ VALUES ('delete', old.rowid, old.key, old.lane, old.schema, old.content);
118
+ INSERT INTO entries_fts(rowid, key, lane, schema, content)
119
+ VALUES (new.rowid, new.key, new.lane, new.schema, new.content);
120
+ END
121
+ SQL
122
+ rescue StandardError
123
+ nil
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,54 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Infra
5
+ class FileSystem
6
+ module Interface
7
+ def read(path) = raise NotImplementedError
8
+ def write(path, bytes) = raise NotImplementedError
9
+ def delete(path) = raise NotImplementedError
10
+ def exists?(path) = raise NotImplementedError
11
+ def mkdir_p(path) = raise NotImplementedError
12
+ def mv(from, to) = raise NotImplementedError
13
+ def copy(source, target) = raise NotImplementedError
14
+ def glob(pattern) = raise NotImplementedError
15
+ def etag(path) = raise NotImplementedError
16
+ end
17
+
18
+ include Interface
19
+
20
+ def read(path) = File.binread(path)
21
+
22
+ def write(path, bytes)
23
+ FileUtils.mkdir_p(File.dirname(path))
24
+ File.binwrite(path, bytes)
25
+ end
26
+
27
+ def delete(path) = File.delete(path)
28
+ def exists?(path) = File.exist?(path)
29
+ def size(path) = File.size(path)
30
+ def mkdir_p(path) = FileUtils.mkdir_p(path)
31
+ def rmdir(path) = Dir.rmdir(path)
32
+ def dir_empty?(dir) = Dir.empty?(dir)
33
+ def glob(pattern) = Dir.glob(pattern)
34
+ def mv(from, to) = File.rename(from, to)
35
+ def copy(source, target) = FileUtils.cp(source, target)
36
+ def symlink?(path) = File.symlink?(path)
37
+ def identical?(left, right) = FileUtils.identical?(left, right)
38
+ def mtime(path) = File.mtime(path)
39
+ def directory?(path) = File.directory?(path)
40
+
41
+ def etag(path)
42
+ Value::Etag.for_file(path)
43
+ end
44
+
45
+ def flock_exclusive(path, nonblock: false)
46
+ File.open(path, File::RDWR | File::CREAT, 0o644) do |f|
47
+ f.close_on_exec = true
48
+ f.flock(nonblock ? File::LOCK_EX | File::LOCK_NB : File::LOCK_EX)
49
+ yield f
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,50 @@
1
+ require "fileutils"
2
+ require "socket"
3
+ require "time"
4
+
5
+ module Textus
6
+ module Infra
7
+ module Locks
8
+ class WatcherLock
9
+ def initialize(root, file_system: Infra::FileSystem.new)
10
+ @root = File.expand_path(root)
11
+ @file_system = file_system
12
+ @path = Textus::Protocol::Layout.new(@root).lock_path("watcher")
13
+ @file = nil
14
+ @file_system.mkdir_p(File.dirname(@path))
15
+ end
16
+
17
+ def self.running?(root, file_system: Infra::FileSystem.new)
18
+ path = Textus::Protocol::Layout.new(root).lock_path("watcher")
19
+ return false unless file_system.exists?(path)
20
+
21
+ File.open(path, "r+") do |file|
22
+ got = file.flock(File::LOCK_EX | File::LOCK_NB)
23
+ file.flock(File::LOCK_UN) if got
24
+ return !got
25
+ end
26
+ rescue Errno::ENOENT
27
+ false
28
+ end
29
+
30
+ def acquire
31
+ @file = File.open(@path, File::RDWR | File::CREAT, 0o644)
32
+ raise "watcher already running" unless @file.flock(File::LOCK_EX | File::LOCK_NB)
33
+
34
+ @file.write("pid=#{Process.pid} host=#{Socket.gethostname}\n")
35
+ @file.flush
36
+ self
37
+ end
38
+
39
+ def release
40
+ return unless @file
41
+
42
+ @file.flock(File::LOCK_UN)
43
+ @file.close
44
+ @file_system.delete(@path) if @file_system.exists?(@path)
45
+ @file = nil
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,18 @@
1
+ module Textus
2
+ module Infra
3
+ module Port
4
+ # The wall clock. An instantiable class (ADR 0109) — uniform with the other
5
+ # ports; `now` reads the system time. Callers that need a fixed time still
6
+ # pass it as data via `Call#now`.
7
+ class Clock
8
+ module Interface
9
+ def now = raise NotImplementedError
10
+ end
11
+
12
+ include Interface
13
+
14
+ def now = Time.now
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,71 @@
1
+ module Textus
2
+ module Infra
3
+ module Port
4
+ # Publishes built artifacts from the store to repo-relative consumer paths.
5
+ # Publish = copy + sentinel. The in-store file is already the consumer-shaped
6
+ # artifact; no parsing or stripping.
7
+ #
8
+ # Sentinel I/O is delegated to Textus::Infra::Port::SentinelStore. Sentinels live
9
+ # under `<store_root>/track/sentinels/` (runtime, git-ignored — ADR 0070) and
10
+ # mirror the target's repo-relative layout so consumer directories aren't
11
+ # polluted with `.textus-managed.json` siblings.
12
+ #
13
+ # An instantiable class (ADR 0109).
14
+ class Publisher
15
+ module Interface
16
+ def publish(source:, target:, store_root:, provenance_source: nil) = raise NotImplementedError
17
+ def unpublish(target:, store_root:) = raise NotImplementedError
18
+ end
19
+ include Interface
20
+
21
+ def initialize(file_system: Infra::FileSystem.new)
22
+ @file_system = file_system
23
+ end
24
+
25
+ def publish(source:, target:, store_root:, provenance_source: source)
26
+ @file_system.mkdir_p(File.dirname(target))
27
+ guard_clobber(source, target, store_root)
28
+ @file_system.delete(target) if @file_system.symlink?(target)
29
+ @file_system.copy(source, target)
30
+ Textus::Infra::Port::SentinelStore.new(file_system: @file_system).write!(target: target, source: provenance_source,
31
+ store_root: store_root)
32
+ end
33
+
34
+ # Removes a previously-published file and its sentinel. No-op unless the
35
+ # target is textus-managed — never deletes an unmanaged file.
36
+ def unpublish(target:, store_root:)
37
+ return unless managed?(target, store_root)
38
+
39
+ @file_system.delete(target)
40
+ sentinel = Textus::Infra::Port::SentinelStore.new(file_system: @file_system).sentinel_path(target, store_root)
41
+ @file_system.delete(sentinel)
42
+ end
43
+
44
+ private
45
+
46
+ # Refuse to clobber an unmanaged target — EXCEPT adopt one whose bytes
47
+ # already equal the source (ADR 0050: a migration copies files into the
48
+ # store and publishes them back to where they already live, so the target
49
+ # is byte-identical — nothing is at risk). Adoption writes no sentinel
50
+ # here; the normal publish path below does, and the cp is a content no-op.
51
+ # An unmanaged target whose content DIFFERS, or any unmanaged symlink, is
52
+ # still refused — that is the guard's real job.
53
+ def guard_clobber(source, target, store_root)
54
+ return unless @file_system.exists?(target) || @file_system.symlink?(target)
55
+ return if managed?(target, store_root)
56
+ return if adoptable?(source, target)
57
+
58
+ raise PublishError.new("refusing to clobber unmanaged file at #{target}", target: target)
59
+ end
60
+
61
+ def adoptable?(source, target)
62
+ !@file_system.symlink?(target) && File.file?(target) && @file_system.identical?(source, target)
63
+ end
64
+
65
+ def managed?(target, store_root)
66
+ @file_system.exists?(Textus::Infra::Port::SentinelStore.new(file_system: @file_system).sentinel_path(target, store_root))
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,25 @@
1
+ module Textus
2
+ module Infra
3
+ module Port
4
+ class Sentinel
5
+ attr_reader :target, :source, :sha256, :mode
6
+
7
+ def initialize(target:, source:, sha256:, mode:)
8
+ @target = target
9
+ @source = source
10
+ @sha256 = sha256
11
+ @mode = mode
12
+ end
13
+
14
+ def orphan?(file_stat) = @target.nil? || !file_stat.exists?(@target)
15
+
16
+ def drift?(file_stat)
17
+ return false if orphan?(file_stat)
18
+ return false if @sha256.nil?
19
+
20
+ Digest::SHA256.hexdigest(file_stat.read(@target)) != @sha256
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,93 @@
1
+ module Textus
2
+ module Infra
3
+ module Port
4
+ # Persistence adapter for sentinel files. Owns the on-disk JSON shape, the
5
+ # path layout (<store_root>/track/sentinels/<target-rel-to-repo>.textus-managed.json
6
+ # — runtime, git-ignored, ADR 0070), and all File/FileUtils I/O.
7
+ # Core::Sentinel is a pure value object that depends on this port for
8
+ # reads and writes.
9
+ class SentinelStore
10
+ module Interface
11
+ def write!(target:, source:, store_root:) = raise NotImplementedError
12
+ def load(path, repo_root) = raise NotImplementedError
13
+ def sentinel_path(target, store_root) = raise NotImplementedError
14
+ def targets_under(target_dir, store_root) = raise NotImplementedError
15
+ end
16
+ include Interface
17
+
18
+ SUFFIX = ".textus-managed.json".freeze
19
+
20
+ def initialize(file_system: Infra::FileSystem.new)
21
+ @file_system = file_system
22
+ end
23
+
24
+ def write!(target:, source:, store_root:)
25
+ path = sentinel_path(target, store_root)
26
+ @file_system.mkdir_p(File.dirname(path))
27
+ repo_root = File.dirname(store_root)
28
+ @file_system.write(path, JSON.generate(
29
+ "source" => rel_or_abs(source, repo_root),
30
+ "target" => rel_or_abs(target, repo_root),
31
+ "sha256" => Digest::SHA256.hexdigest(@file_system.read(target)),
32
+ "mode" => "copy",
33
+ ))
34
+ end
35
+
36
+ def load(path, repo_root)
37
+ raw = JSON.parse(@file_system.read(path))
38
+ Textus::Infra::Port::Sentinel.new(
39
+ target: absolutize(raw["target"], repo_root),
40
+ source: absolutize(raw["source"], repo_root),
41
+ sha256: raw["sha256"],
42
+ mode: raw["mode"],
43
+ )
44
+ rescue JSON::ParserError, Errno::ENOENT
45
+ nil
46
+ end
47
+
48
+ def sentinel_path(target, store_root)
49
+ repo_root = File.dirname(store_root)
50
+ rel = relative_to(target, repo_root) || File.basename(target)
51
+ File.join(Textus::Protocol::Layout.new(store_root).sentinels_root, rel + SUFFIX)
52
+ end
53
+
54
+ # Absolute target paths of every sentinel recorded under `target_dir`.
55
+ def targets_under(target_dir, store_root)
56
+ repo_root = File.dirname(store_root)
57
+ rel = relative_to(target_dir, repo_root) or return []
58
+ root = Textus::Protocol::Layout.new(store_root).sentinels_root
59
+ sdir = File.join(root, rel)
60
+ return [] unless @file_system.directory?(sdir)
61
+
62
+ prefix = root + "/"
63
+ @file_system.glob(File.join(sdir, "**", "*#{SUFFIX}")).map do |spath|
64
+ # strip the sentinel-store prefix and the .textus-managed.json suffix to recover the repo-relative target path
65
+ trel = spath.delete_prefix(prefix).delete_suffix(SUFFIX)
66
+ File.join(repo_root, trel)
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def rel_or_abs(path, repo_root)
73
+ relative_to(path, repo_root) || File.expand_path(path)
74
+ end
75
+
76
+ def relative_to(path, repo_root)
77
+ path = File.expand_path(path)
78
+ base = File.expand_path(repo_root)
79
+ return nil unless path.start_with?(base + File::SEPARATOR)
80
+
81
+ path[(base.length + 1)..]
82
+ end
83
+
84
+ def absolutize(path, repo_root)
85
+ return path if path.nil?
86
+ return path if File.absolute_path?(path)
87
+
88
+ File.expand_path(path, repo_root)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end
@@ -0,0 +1,42 @@
1
+ module Textus
2
+ module Infra
3
+ class Store
4
+ class AuditLog < Base
5
+ def append(role:, verb:, key:, etag_before: nil, etag_after: nil, correlation_id: nil)
6
+ table(:audit_events).insert(
7
+ ts: Time.now.utc.iso8601,
8
+ role:, verb:, key:,
9
+ etag_before:, etag_after:, correlation_id:
10
+ )
11
+ end
12
+
13
+ def list(seq_since: nil, key: nil, role: nil, verb: nil, correlation_id: nil, limit: nil)
14
+ dataset = table(:audit_events)
15
+ dataset = dataset.where { seq > seq_since } if seq_since
16
+ dataset = dataset.where(key:) if key
17
+ dataset = dataset.where(role:) if role
18
+ dataset = dataset.where(verb:) if verb
19
+ dataset = dataset.where(correlation_id:) if correlation_id
20
+ dataset = dataset.limit(limit) if limit
21
+ results(dataset.order(:seq).all)
22
+ end
23
+
24
+ def latest
25
+ result(table(:audit_events).order(Sequel.desc(:seq)).first) || { seq: 0 }
26
+ end
27
+
28
+ def min_available
29
+ result(table(:audit_events).order(:seq).first)&.dig(:seq)
30
+ end
31
+
32
+ def last_writer(key)
33
+ row = table(:audit_events)
34
+ .where(key:, verb: %w[put delete key_delete])
35
+ .order(Sequel.desc(:seq))
36
+ .first
37
+ result(row)
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,25 @@
1
+ module Textus
2
+ module Infra
3
+ class Store
4
+ class Base
5
+ def initialize(database)
6
+ @db = database
7
+ end
8
+
9
+ private
10
+
11
+ def table(name)
12
+ @db[name]
13
+ end
14
+
15
+ def result(row)
16
+ row&.transform_keys(&:to_sym)
17
+ end
18
+
19
+ def results(rows)
20
+ rows.map { |r| result(r) }
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end