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
@@ -0,0 +1,55 @@
1
+ module Textus
2
+ module Protocol
3
+ module Format
4
+ class Script < Base
5
+ EXTENSIONS = %w[.rb .py .sh .js .pl .rs .exs].freeze
6
+
7
+ def self.parse(raw, path: nil)
8
+ raw = raw.dup.force_encoding(Encoding::UTF_8)
9
+ raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
10
+
11
+ { "_meta" => {}, "body" => raw, "content" => nil }
12
+ end
13
+
14
+ def self.serialize(meta:, body:, content: nil)
15
+ _ = meta
16
+ _ = content
17
+ b = body.to_s
18
+ b += "\n" unless b.empty? || b.end_with?("\n")
19
+ b
20
+ end
21
+
22
+ def self.extensions = EXTENSIONS
23
+ def self.nested_glob = "**/*"
24
+ def self.nested_ext(*) = ""
25
+ def self.enforce_name_match!(_path, _meta); end
26
+
27
+ def self.rewrite_name(_path, _basename)
28
+ false
29
+ end
30
+
31
+ def self.validate_path_extension(path, nested)
32
+ ext = File.extname(path)
33
+ if nested
34
+ return if ext == ""
35
+
36
+ raise UsageError.new("nested script path must not have an extension")
37
+ end
38
+
39
+ return if EXTENSIONS.include?(ext) || ext == ""
40
+
41
+ raise UsageError.new("script format requires #{EXTENSIONS.join(" | ")} extension (got #{ext.inspect})")
42
+ end
43
+
44
+ def self.validate_raw_entry!(_parsed, lane)
45
+ nil unless lane == "scratchpad"
46
+ end
47
+
48
+ def self.data_to_payload(data)
49
+ data = data.transform_keys(&:to_s) if data.is_a?(Hash)
50
+ { meta: data["_meta"] || {}, body: (data["body"] || "").to_s, content: nil }
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,48 @@
1
+ module Textus
2
+ module Protocol
3
+ module Format
4
+ class Text < Base
5
+ def self.parse(raw, path: nil)
6
+ raw = raw.dup.force_encoding(Encoding::UTF_8)
7
+ raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
8
+
9
+ { "_meta" => {}, "body" => raw, "content" => nil }
10
+ end
11
+
12
+ def self.serialize(meta:, body:, content: nil)
13
+ _ = meta
14
+ _ = content
15
+ b = body.to_s
16
+ b += "\n" unless b.empty? || b.end_with?("\n")
17
+ b
18
+ end
19
+
20
+ def self.extensions = [".txt"]
21
+ def self.nested_glob = "**/*.txt"
22
+ def self.enforce_name_match!(_path, _meta); end
23
+
24
+ def self.rewrite_name(_path, _basename) # rubocop:disable Naming/PredicateMethod
25
+ false
26
+ end
27
+
28
+ def self.validate_path_extension(path, nested)
29
+ ext = File.extname(path)
30
+ if nested
31
+ return if ext == ""
32
+
33
+ raise UsageError.new("nested text path must not have an extension")
34
+ end
35
+
36
+ return if [".txt", ""].include?(ext)
37
+
38
+ raise UsageError.new("text format requires '.txt' or no extension (got #{ext.inspect})")
39
+ end
40
+
41
+ def self.data_to_payload(data)
42
+ data = data.transform_keys(&:to_s) if data.is_a?(Hash)
43
+ { meta: data["_meta"] || {}, body: (data["body"] || "").to_s, content: nil }
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,105 @@
1
+ require "yaml"
2
+
3
+ module Textus
4
+ module Protocol
5
+ module Format
6
+ class Yaml < Base
7
+ def self.parse(raw, path: nil)
8
+ raw = raw.dup.force_encoding(Encoding::UTF_8)
9
+ raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
10
+
11
+ begin
12
+ parsed = ::YAML.safe_load(raw, permitted_classes: [Date, Time], aliases: false)
13
+ rescue Psych::SyntaxError, Psych::AliasesNotEnabled, Psych::DisallowedClass => e
14
+ raise BadFrontmatter.new(path, "YAML parse failed: #{e.message}")
15
+ end
16
+ raise BadFrontmatter.new(path, "YAML top-level must be a mapping") unless parsed.is_a?(Hash)
17
+
18
+ meta = parsed["_meta"]
19
+ fm = meta.is_a?(Hash) ? meta : {}
20
+ content_without_meta = parsed.except("_meta")
21
+ { "_meta" => fm, "body" => raw, "content" => content_without_meta }
22
+ end
23
+
24
+ def self.serialize(meta:, body:, content: nil)
25
+ if content.is_a?(Hash)
26
+ on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
27
+ ::YAML.dump(on_disk).sub(/\A---\n/, "")
28
+ elsif body && !body.to_s.empty?
29
+ b = body.to_s
30
+ b += "\n" unless b.end_with?("\n")
31
+ b
32
+ else
33
+ raise UsageError.new("yaml serialize requires :content or :body")
34
+ end
35
+ end
36
+
37
+ def self.validate_against(schema, parsed)
38
+ schema.validate!(parsed["content"] || {})
39
+ end
40
+
41
+ RAW_REQUIRED = %w[ingested_at content_hash].freeze
42
+ RAW_SOURCE_KINDS = %w[url file asset].freeze
43
+
44
+ def self.validate_raw_entry!(parsed, lane)
45
+ return unless lane == "raw"
46
+
47
+ content = parsed["content"] || {}
48
+ return if content["superseded_by"]
49
+
50
+ missing = RAW_REQUIRED.reject { |f| content[f] }
51
+ raise Textus::BadContent.new(nil, "raw entry missing required field(s): #{missing.join(", ")}") if missing.any?
52
+
53
+ source = content["source"] || {}
54
+ kind = source["kind"]
55
+ unless RAW_SOURCE_KINDS.include?(kind)
56
+ raise Textus::BadContent.new(
57
+ nil, "raw entry source.kind must be #{RAW_SOURCE_KINDS.join("|")}, got #{kind.inspect}"
58
+ )
59
+ end
60
+
61
+ return unless kind == "url" && !source["url"]
62
+
63
+ raise Textus::BadContent.new(nil, "raw entry with source.kind=url must have source.url")
64
+ end
65
+
66
+ def self.extensions = [".yaml", ".yml"]
67
+ def self.nested_glob = "**/*.{yaml,yml}"
68
+
69
+ def self.serialize_for_put(meta:, body:, content:, path:)
70
+ raise UsageError.new("put for yaml requires content: or body:") if content.nil? && (body.nil? || body.to_s.empty?)
71
+
72
+ if content.nil?
73
+ begin
74
+ parsed = parse(body.to_s, path: path)
75
+ rescue BadFrontmatter => e
76
+ raise BadContent.new(path, "bad_content: #{e.message}")
77
+ end
78
+ [body.to_s, parsed["_meta"], body.to_s, parsed["content"]]
79
+ else
80
+ bytes = serialize(meta: meta, body: "", content: content)
81
+ [bytes, meta, bytes, content]
82
+ end
83
+ end
84
+
85
+ def self.validate_path_extension(path, nested)
86
+ ext = File.extname(path)
87
+ if nested
88
+ return if ext == ""
89
+
90
+ raise UsageError.new("nested yaml path must not have an extension")
91
+ end
92
+
93
+ return if [".yaml", ".yml"].include?(ext)
94
+
95
+ raise UsageError.new("yaml format requires '.yaml' or '.yml' path (got #{ext.inspect})")
96
+ end
97
+
98
+ def self.data_to_payload(data)
99
+ data = data.transform_keys(&:to_s) if data.is_a?(Hash)
100
+ { meta: data["_meta"] || {}, body: nil, content: data["content"] || data }
101
+ end
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,73 @@
1
+ module Textus
2
+ module Protocol
3
+ module Format
4
+ SEP = "---".freeze
5
+
6
+ STRATEGIES = {
7
+ "markdown" => -> { Protocol::Format::Markdown },
8
+ "json" => -> { Protocol::Format::Json },
9
+ "yaml" => -> { Protocol::Format::Yaml },
10
+ "text" => -> { Protocol::Format::Text },
11
+ "script" => -> { Protocol::Format::Script },
12
+ "bash" => -> { Protocol::Format::Bash },
13
+ "ruby" => -> { Protocol::Format::Ruby },
14
+ "python" => -> { Protocol::Format::Python },
15
+ "javascript" => -> { Protocol::Format::Javascript },
16
+ }.freeze
17
+
18
+ @registry = nil
19
+
20
+ def self.registry=(reg)
21
+ @registry = reg
22
+ end
23
+
24
+ def self.registry
25
+ @registry
26
+ end
27
+
28
+ EXT_TO_FORMAT = {
29
+ ".md" => "markdown",
30
+ ".json" => "json",
31
+ ".yaml" => "yaml",
32
+ ".yml" => "yaml",
33
+ ".txt" => "text",
34
+ ".rb" => "ruby",
35
+ ".py" => "python",
36
+ ".sh" => "bash",
37
+ ".js" => "javascript",
38
+ ".pl" => "script",
39
+ ".rs" => "script",
40
+ ".exs" => "script",
41
+ }.freeze
42
+
43
+ def self.for(format)
44
+ key = format.to_s
45
+ return registry.fetch(key).call if registry&.key?(key)
46
+
47
+ STRATEGIES.fetch(key) { raise Textus::UsageError.new("unknown entry format: #{format.inspect}") }.call
48
+ end
49
+
50
+ def self.infer_from_extension(ext)
51
+ EXT_TO_FORMAT[ext]
52
+ end
53
+
54
+ def self.formats
55
+ EXT_TO_FORMAT.values.uniq
56
+ end
57
+
58
+ def self.parse(raw, path: nil, format: "markdown")
59
+ Protocol::Format.for(format).parse(raw, path: path)
60
+ end
61
+
62
+ def self.serialize(meta: {}, body: "", content: nil, format: "markdown")
63
+ Protocol::Format.for(format).serialize(meta: meta, body: body, content: content)
64
+ end
65
+
66
+ def self.data_to_payload(data, format)
67
+ return { meta: {}, body: "", content: nil } if data.nil?
68
+
69
+ Protocol::Format.for(format).data_to_payload(data)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Textus
4
- class Store
4
+ module Protocol
5
5
  module Freshness
6
6
  class TtlEvaluator
7
7
  def initialize(manifest:, file_stat:, clock:)
@@ -11,7 +11,7 @@ module Textus
11
11
  end
12
12
 
13
13
  def verdict(mentry)
14
- ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
14
+ ttl = lane_ttl(mentry.lane)
15
15
  return fresh if ttl.nil?
16
16
 
17
17
  stale = age_stale?(file_basis(mentry), ttl)
@@ -33,11 +33,15 @@ module Textus
33
33
  @file_stat.mtime(path)
34
34
  end
35
35
 
36
+ def lane_ttl(lane)
37
+ Textus::Protocol::V4::LANES.dig(lane, :retention_ttl)
38
+ end
39
+
36
40
  def due?(mentry, prefix:, lane:)
37
41
  return false if lane && mentry.lane != lane
38
42
  return false if prefix && !mentry.key.start_with?(prefix)
39
43
 
40
- ttl = @manifest.rules.for(mentry.key).retention&.ttl_seconds
44
+ ttl = lane_ttl(mentry.lane)
41
45
  return false if ttl.nil?
42
46
 
43
47
  path = @manifest.resolver.resolve(mentry.key).path
@@ -0,0 +1,61 @@
1
+ module Textus
2
+ module Protocol
3
+ class Gate
4
+ HANDLER_GROUPS = {
5
+ read: Handlers::Read,
6
+ write: Handlers::Write,
7
+ maintenance: Handlers::System,
8
+ }.freeze
9
+
10
+ def initialize(manifest:, resolver:, container:, store_engine:, policy: nil, async: nil)
11
+ @manifest = manifest
12
+ @resolver = resolver
13
+ @container = container
14
+ @store_engine = store_engine
15
+ @async = async
16
+ @policy = policy || manifest.policy
17
+ end
18
+
19
+ def dispatch(role, verb, params = {}, call: nil)
20
+ spec = VerbRegistry.for(verb)
21
+ return error(:unknown_verb, "Unknown verb: #{verb}") unless spec
22
+
23
+ key = params[:key]
24
+ authorize!(role, verb, key, spec)
25
+
26
+ call_obj = call || Value::Call.build(role:, correlation_id: SecureRandom.uuid)
27
+ bound = Protocol::Binder.bind(spec, params)
28
+ ctx_class = spec.read? && !spec.system? ? Handlers::QueryContext : Handlers::CommandContext
29
+ ctx = ctx_class.new(@store_engine, @container, call_obj)
30
+
31
+ handler = spec.lane ? Lane.handler_for(spec.lane) : handler_group(spec)
32
+ handler.public_send(verb, **bound, ctx:, call: call_obj)
33
+ end
34
+
35
+ private
36
+
37
+ def handler_group(spec)
38
+ return Handlers::System if spec.system?
39
+
40
+ HANDLER_GROUPS[spec.category] || Handlers::System
41
+ end
42
+
43
+ def authorize!(role, _verb, key, spec)
44
+ return true unless spec&.write?
45
+
46
+ key_lane = key&.split(".")&.first
47
+ lane = key_lane || spec.lane&.to_s
48
+ return true unless lane
49
+
50
+ lane_map = Textus::Protocol::V4::LANES[lane]
51
+ return true unless lane_map
52
+
53
+ @policy.allowed!(role:, lane:, key:)
54
+ end
55
+
56
+ def error(code, message)
57
+ { error: { code: code.to_s, message: } }
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,19 @@
1
+ module Textus
2
+ module Protocol
3
+ module Handlers
4
+ class CommandContext < QueryContext
5
+ # StoreEngine delegation (write side)
6
+ def put(...) = @store_engine.put(...)
7
+ def delete(...) = @store_engine.delete(...)
8
+ def move(...) = @store_engine.move(...)
9
+
10
+ # Infrastructure (ops, drain, pulse)
11
+ def audit_log = @container.store.audit_log
12
+ def workflow_queue = @container.store.workflow_queue
13
+ def file_system = @container.store.file_system
14
+ def workflows = @container.workflows
15
+ def workflow_registry = @container.workflow_registry
16
+ end
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Protocol
3
+ module Handlers
4
+ module Concern
5
+ def self.included(base)
6
+ base.extend(self)
7
+ end
8
+
9
+ def response(**fields)
10
+ { "protocol" => Textus::PROTOCOL, **fields }
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,37 @@
1
+ module Textus
2
+ module Protocol
3
+ module Handlers
4
+ module Ops
5
+ class AuditFilter
6
+ FILTER_KEYS = %i[key lane role verb since seq_since correlation_id limit].freeze
7
+
8
+ def call(ctx:, filters: {})
9
+ seq = filters[:seq_since]
10
+ if seq
11
+ min = ctx.audit_log.min_available
12
+ raise Textus::CursorExpired.new(requested: seq, min_available: min) if min && seq < min - 1
13
+ end
14
+
15
+ rows = ctx.audit_log.list(seq_since: filters[:seq_since], key: filters[:key], role: filters[:role], verb: filters[:verb],
16
+ correlation_id: filters[:correlation_id], limit: filters[:limit]).select do |row|
17
+ next false if filters[:lane] && !key_in_lane?(row["key"], filters[:lane], ctx)
18
+ next false if filters[:since] && row["ts"] && Time.parse(row["ts"]) < filters[:since]
19
+
20
+ true
21
+ end
22
+ { "verb" => "audit", "rows" => rows }
23
+ end
24
+
25
+ private
26
+
27
+ def key_in_lane?(key, lane_name, ctx)
28
+ mentry = ctx.mentry_for(key:)
29
+ mentry && mentry.lane == lane_name
30
+ rescue Textus::Error
31
+ false
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,38 @@
1
+ module Textus
2
+ module Protocol
3
+ module Handlers
4
+ module Ops
5
+ class BulkPlanner
6
+ Plan = Data.define(:steps, :warnings) do
7
+ def to_h
8
+ { "steps" => steps, "warnings" => warnings }
9
+ end
10
+ end
11
+
12
+ def plan_mv(from_prefix:, to_prefix:, ctx:)
13
+ raise Textus::UsageError.new("from_prefix and to_prefix required") unless from_prefix && to_prefix
14
+
15
+ leaves = ctx.list(prefix: from_prefix)
16
+ raise Textus::UsageError.new("from_prefix is a leaf — use mv") if leaves.any? { |r| r["key"] == from_prefix }
17
+
18
+ warnings = leaves.empty? ? ["no keys under #{from_prefix}"] : []
19
+ steps = leaves.map do |row|
20
+ tail = row["key"].delete_prefix("#{from_prefix}.")
21
+ { "op" => "mv", "from" => row["key"], "to" => "#{to_prefix}.#{tail}" }
22
+ end
23
+ Plan.new(steps:, warnings:)
24
+ end
25
+
26
+ def plan_delete(prefix:, ctx:)
27
+ raise Textus::UsageError.new("prefix required") unless prefix && !prefix.empty?
28
+
29
+ leaves = ctx.list(prefix:)
30
+ warnings = leaves.empty? ? ["no keys under #{prefix}"] : []
31
+ steps = leaves.map { |row| { "op" => "delete", "key" => row["key"] } }
32
+ Plan.new(steps:, warnings:)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,49 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Protocol
5
+ module Handlers
6
+ module Ops
7
+ class DataMv
8
+ Plan = Data.define(:steps, :warnings) do
9
+ def to_h
10
+ { "steps" => steps, "warnings" => warnings }
11
+ end
12
+ end
13
+
14
+ def call(from:, to:, ctx:, dry_run: false)
15
+ raise Textus::UsageError.new("from and to required") unless from && to
16
+ raise Textus::UsageError.new("data lane '#{from}' not declared") unless ctx.manifest.data.declared_lane_kinds.key?(from)
17
+
18
+ dest_dir = ctx.layout.lane_path(to)
19
+ raise Textus::UsageError.new("destination data/#{to} already exists") if File.exist?(dest_dir)
20
+
21
+ affected_keys = ctx.manifest.data.entries.select { |e| e.lane == from }.map(&:key)
22
+ steps = [{ "op" => "rename_zone", "from" => from, "to" => to }]
23
+ steps += affected_keys.map { |k| { "op" => "mv", "from" => k, "to" => "#{to}#{k[from.length..]}" } }
24
+ plan = Plan.new(steps:, warnings: [])
25
+ return plan.to_h if dry_run
26
+
27
+ rewrite_manifest!(from:, to:, ctx:)
28
+ FileUtils.mv(ctx.layout.lane_path(from), dest_dir)
29
+ plan.to_h
30
+ end
31
+
32
+ private
33
+
34
+ def rewrite_manifest!(from:, to:, ctx:)
35
+ path = ctx.layout.manifest_path
36
+ raw = YAML.safe_load_file(path, permitted_classes: [Symbol], aliases: false)
37
+ raw["lanes"].each { |lane| lane["name"] = to if lane["name"] == from }
38
+ raw["entries"].each do |entry|
39
+ entry["lane"] = to if entry["lane"] == from
40
+ entry["key"] = entry["key"].sub(/\A#{Regexp.escape(from)}(\.|\z)/, "#{to}\\1")
41
+ entry["path"] = entry["path"].sub(%r{\A(data/)?#{Regexp.escape(from)}(/|\z)}, "\\1#{to}\\2")
42
+ end
43
+ File.write(path, YAML.dump(raw))
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,65 @@
1
+ require "open3"
2
+
3
+ module Textus
4
+ module Protocol
5
+ module Handlers
6
+ module Ops
7
+ class GitBlame
8
+ def call(key:, ctx:)
9
+ root = ctx.manifest.data.root
10
+ path = resolve_blame_path(key, ctx)
11
+ return nil unless path && git_tracked?(path, root:)
12
+
13
+ audit_rows = ctx.audit_log.list(key:)
14
+ audit_rows.to_h { |row| [row["ts"], git_commit_at(path, timestamp: row["ts"], root:)] }
15
+ end
16
+
17
+ private
18
+
19
+ def resolve_blame_path(key, ctx)
20
+ res = ctx.resolve(key:)
21
+ res.path || Textus::Protocol::Key::Path.resolve(ctx.manifest.data, res.entry)
22
+ rescue Textus::Error
23
+ nil
24
+ end
25
+
26
+ def git_tracked?(path, root:)
27
+ return false if path.nil? || !File.exist?(path) || !git_repo?(root)
28
+
29
+ _out, _err, status = Open3.capture3("git", "ls-files", "--error-unmatch", path, chdir: root)
30
+ status.success?
31
+ rescue Errno::ENOENT
32
+ false
33
+ end
34
+
35
+ def git_repo?(root)
36
+ dir = root
37
+ loop do
38
+ return true if File.directory?(File.join(dir, ".git"))
39
+
40
+ parent = File.dirname(dir)
41
+ return false if parent == dir
42
+
43
+ dir = parent
44
+ end
45
+ end
46
+
47
+ def git_commit_at(path, timestamp:, root:)
48
+ args = ["git", "log", "-1"]
49
+ args << "--before=#{timestamp}" if timestamp
50
+ args += ["--format=%H%x09%an%x09%aI%x09%s", "--", path]
51
+ out, _err, status = Open3.capture3(*args, chdir: root)
52
+ return nil unless status.success?
53
+
54
+ sha, author, date, subject = out.strip.split("\t", 4)
55
+ return nil if sha.nil? || sha.empty?
56
+
57
+ { "sha" => sha, "author" => author, "date" => date, "subject" => subject }
58
+ rescue Errno::ENOENT
59
+ nil
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,37 @@
1
+ module Textus
2
+ module Protocol
3
+ module Handlers
4
+ module Ops
5
+ class PulseCoordinator
6
+ def call(role:, ctx:, since: nil)
7
+ cursor_val = since || ctx.cursor_store.get(role)[:seq]
8
+ audit_filter = AuditFilter.new
9
+ audit_result = audit_filter.call(ctx:, filters: { seq_since: cursor_val })
10
+ changed = audit_result["rows"] || []
11
+ queue = ctx.manifest.policy.queue_lane
12
+ pending = queue ? ctx.list(lane: queue) : []
13
+ runner = Textus::Lanes::Knowledge::Runner.new(ctx.manifest, cursor_store: ctx.cursor_store)
14
+ result = {
15
+ "cursor" => ctx.audit_log.latest[:seq],
16
+ "changed" => changed,
17
+ "pending_review" => pending.is_a?(Array) ? pending.map { |r| r["key"] } : [],
18
+ "contract_etag" => Textus::Value::Etag.for_contract(ctx.layout),
19
+ "index_etag" => pulse_index_etag(ctx),
20
+ "loop_state" => runner.current_position(role),
21
+ }
22
+ ctx.cursor_store.set(role, result["cursor"])
23
+ result
24
+ end
25
+
26
+ private
27
+
28
+ def pulse_index_etag(ctx)
29
+ ctx.etag_for("artifacts.system.index")
30
+ rescue Textus::Error
31
+ nil
32
+ end
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,41 @@
1
+ module Textus
2
+ module Protocol
3
+ module Handlers
4
+ module Ops
5
+ class SourceExpander
6
+ def call(env, ctx:, depth: 0)
7
+ return env if depth >= 5
8
+
9
+ raw_sources = Array(env.meta["sources"])
10
+ return env if raw_sources.empty?
11
+
12
+ expanded = raw_sources.map { |src| expand_one(src, ctx:, depth:) }
13
+ env.with(sources: expanded)
14
+ end
15
+
16
+ private
17
+
18
+ def expand_one(src, ctx:, depth:)
19
+ src = { "key" => src } if src.is_a?(String)
20
+ return src unless src.is_a?(Hash) && src["key"].is_a?(String)
21
+
22
+ key = src["key"]
23
+ stored_etag = src["etag"]
24
+ res = ctx.resolve(key:)
25
+ path = res.path
26
+ current_etag = File.exist?(path) ? ctx.file_system.etag(path) : nil
27
+ suspended = stored_etag && current_etag ? stored_etag != current_etag : false
28
+ result = src.merge("suspended" => suspended)
29
+ child_env = ctx.read(key:)
30
+ if child_env
31
+ child_expanded = call(child_env, ctx:, depth: depth + 1)
32
+ child_sources = Array(child_expanded.sources)
33
+ result = result.merge("sources" => child_sources) unless child_sources.empty?
34
+ end
35
+ result
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end