textus 0.54.2 → 0.55.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (176) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +37 -0
  3. data/README.md +8 -1
  4. data/SPEC.md +27 -0
  5. data/docs/architecture/README.md +20 -8
  6. data/docs/reference/conventions.md +1 -1
  7. data/exe/textus +1 -1
  8. data/lib/textus/action/accept.rb +23 -21
  9. data/lib/textus/action/audit.rb +24 -61
  10. data/lib/textus/action/base.rb +9 -9
  11. data/lib/textus/action/blame.rb +18 -36
  12. data/lib/textus/action/boot.rb +2 -4
  13. data/lib/textus/action/data_mv.rb +20 -31
  14. data/lib/textus/action/deps.rb +3 -18
  15. data/lib/textus/action/doctor.rb +2 -9
  16. data/lib/textus/action/drain.rb +11 -19
  17. data/lib/textus/action/enqueue.rb +14 -30
  18. data/lib/textus/action/get.rb +12 -56
  19. data/lib/textus/action/ingest.rb +74 -78
  20. data/lib/textus/action/jobs.rb +6 -15
  21. data/lib/textus/action/key_delete.rb +6 -16
  22. data/lib/textus/action/key_delete_prefix.rb +8 -17
  23. data/lib/textus/action/key_mv.rb +54 -61
  24. data/lib/textus/action/key_mv_prefix.rb +13 -22
  25. data/lib/textus/action/list.rb +7 -21
  26. data/lib/textus/action/propose.rb +16 -26
  27. data/lib/textus/action/published.rb +3 -5
  28. data/lib/textus/action/pulse.rb +19 -26
  29. data/lib/textus/action/put.rb +15 -29
  30. data/lib/textus/action/rdeps.rb +3 -18
  31. data/lib/textus/action/reject.rb +12 -21
  32. data/lib/textus/action/rule_explain.rb +12 -22
  33. data/lib/textus/action/rule_lint.rb +10 -16
  34. data/lib/textus/action/rule_list.rb +5 -9
  35. data/lib/textus/action/schema_envelope.rb +3 -10
  36. data/lib/textus/action/uid.rb +3 -17
  37. data/lib/textus/action/where.rb +3 -18
  38. data/lib/textus/boot.rb +7 -15
  39. data/lib/textus/contract/arg.rb +10 -0
  40. data/lib/textus/contract/dsl.rb +88 -0
  41. data/lib/textus/contract/spec.rb +25 -0
  42. data/lib/textus/contract.rb +0 -162
  43. data/lib/textus/doctor/check/audit_log.rb +2 -2
  44. data/lib/textus/doctor/check/generator_drift.rb +2 -2
  45. data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
  46. data/lib/textus/doctor/check/protocol_version.rb +2 -2
  47. data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
  48. data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
  49. data/lib/textus/doctor/check/schema_violations.rb +2 -2
  50. data/lib/textus/doctor/check/schemas.rb +1 -1
  51. data/lib/textus/doctor/check/sentinels.rb +4 -4
  52. data/lib/textus/doctor/check/templates.rb +1 -1
  53. data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
  54. data/lib/textus/doctor/check.rb +4 -7
  55. data/lib/textus/doctor.rb +1 -1
  56. data/lib/textus/errors.rb +6 -0
  57. data/lib/textus/format/base.rb +0 -4
  58. data/lib/textus/format/json.rb +5 -6
  59. data/lib/textus/format/markdown.rb +5 -6
  60. data/lib/textus/format/shared.rb +17 -0
  61. data/lib/textus/format/text.rb +5 -4
  62. data/lib/textus/format/yaml.rb +30 -6
  63. data/lib/textus/format.rb +6 -0
  64. data/lib/textus/gate/auth.rb +2 -17
  65. data/lib/textus/gate/binder.rb +50 -0
  66. data/lib/textus/gate.rb +64 -88
  67. data/lib/textus/init.rb +2 -4
  68. data/lib/textus/jobs.rb +3 -9
  69. data/lib/textus/manifest/capabilities.rb +3 -3
  70. data/lib/textus/manifest/entry/base.rb +1 -1
  71. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
  72. data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
  73. data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
  74. data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
  75. data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
  76. data/lib/textus/manifest/schema/semantics.rb +11 -216
  77. data/lib/textus/meta.rb +54 -0
  78. data/lib/textus/{ports → port}/audit_log.rb +44 -4
  79. data/lib/textus/{ports → port}/build_lock.rb +2 -2
  80. data/lib/textus/{ports → port}/clock.rb +1 -1
  81. data/lib/textus/{ports → port}/publisher.rb +5 -5
  82. data/lib/textus/{ports → port}/sentinel_store.rb +3 -3
  83. data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
  84. data/lib/textus/{ports → port}/storage/file_store.rb +2 -2
  85. data/lib/textus/port/store.rb +93 -0
  86. data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
  87. data/lib/textus/produce/engine.rb +1 -1
  88. data/lib/textus/schema/tools.rb +11 -7
  89. data/lib/textus/store/compositor.rb +34 -0
  90. data/lib/textus/store/container.rb +43 -0
  91. data/lib/textus/store/cursor.rb +26 -0
  92. data/lib/textus/store/envelope/reader.rb +43 -0
  93. data/lib/textus/store/envelope/writer.rb +195 -0
  94. data/lib/textus/store/geometry.rb +81 -0
  95. data/lib/textus/store/index/builder.rb +74 -0
  96. data/lib/textus/store/index/lookup.rb +60 -0
  97. data/lib/textus/store/jobs/base.rb +13 -0
  98. data/lib/textus/store/jobs/index.rb +15 -0
  99. data/lib/textus/store/jobs/materialize.rb +15 -0
  100. data/lib/textus/store/jobs/plan.rb +11 -0
  101. data/lib/textus/store/jobs/planner.rb +104 -0
  102. data/lib/textus/store/jobs/queue.rb +154 -0
  103. data/lib/textus/store/jobs/registry.rb +19 -0
  104. data/lib/textus/store/jobs/retention.rb +50 -0
  105. data/lib/textus/store/jobs/sweep.rb +21 -0
  106. data/lib/textus/store/jobs/worker.rb +64 -0
  107. data/lib/textus/store/session.rb +37 -0
  108. data/lib/textus/store.rb +21 -13
  109. data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
  110. data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
  111. data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
  112. data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
  113. data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
  114. data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
  115. data/lib/textus/{surfaces → surface}/cli/runner.rb +10 -63
  116. data/lib/textus/surface/cli/sources.rb +41 -0
  117. data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +4 -6
  118. data/lib/textus/{surfaces → surface}/cli/verb/get.rb +4 -4
  119. data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
  120. data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
  121. data/lib/textus/{surfaces → surface}/cli/verb/put.rb +6 -11
  122. data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
  123. data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
  124. data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
  125. data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
  126. data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
  127. data/lib/textus/{surfaces → surface}/cli.rb +1 -1
  128. data/lib/textus/{surfaces → surface}/mcp/catalog.rb +9 -26
  129. data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -1
  130. data/lib/textus/{surfaces → surface}/mcp/server.rb +5 -5
  131. data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
  132. data/lib/textus/surface/projector.rb +27 -0
  133. data/lib/textus/{surfaces → surface}/role_scope.rb +1 -1
  134. data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
  135. data/lib/textus/value/call.rb +30 -0
  136. data/lib/textus/value/command.rb +16 -0
  137. data/lib/textus/value/envelope.rb +89 -0
  138. data/lib/textus/value/etag.rb +39 -0
  139. data/lib/textus/value/result.rb +26 -0
  140. data/lib/textus/value/role.rb +38 -0
  141. data/lib/textus/value/types.rb +13 -0
  142. data/lib/textus/{uid.rb → value/uid.rb} +9 -7
  143. data/lib/textus/version.rb +1 -1
  144. data/lib/textus/workflow/loader.rb +4 -4
  145. data/lib/textus/workflow/runner.rb +4 -18
  146. data/lib/textus.rb +9 -10
  147. metadata +100 -63
  148. data/lib/textus/action/write_verb.rb +0 -44
  149. data/lib/textus/call.rb +0 -28
  150. data/lib/textus/command.rb +0 -41
  151. data/lib/textus/container.rb +0 -26
  152. data/lib/textus/contract/around.rb +0 -29
  153. data/lib/textus/contract/binder.rb +0 -88
  154. data/lib/textus/contract/resources/build_lock.rb +0 -17
  155. data/lib/textus/contract/resources/cursor.rb +0 -26
  156. data/lib/textus/contract/sources.rb +0 -39
  157. data/lib/textus/contract/view.rb +0 -15
  158. data/lib/textus/cursor_store.rb +0 -24
  159. data/lib/textus/envelope/reader.rb +0 -46
  160. data/lib/textus/envelope/writer.rb +0 -209
  161. data/lib/textus/envelope.rb +0 -79
  162. data/lib/textus/etag.rb +0 -36
  163. data/lib/textus/jobs/base.rb +0 -23
  164. data/lib/textus/jobs/materialize.rb +0 -20
  165. data/lib/textus/jobs/plan.rb +0 -9
  166. data/lib/textus/jobs/planner.rb +0 -101
  167. data/lib/textus/jobs/retention.rb +0 -48
  168. data/lib/textus/jobs/sweep.rb +0 -27
  169. data/lib/textus/jobs/worker.rb +0 -67
  170. data/lib/textus/layout.rb +0 -91
  171. data/lib/textus/ports/job_store/job.rb +0 -65
  172. data/lib/textus/ports/job_store.rb +0 -123
  173. data/lib/textus/ports/raw_index.rb +0 -61
  174. data/lib/textus/role.rb +0 -36
  175. data/lib/textus/session.rb +0 -35
  176. data/lib/textus/types.rb +0 -15
@@ -1,39 +0,0 @@
1
- require "json"
2
-
3
- module Textus
4
- module Contract
5
- # CLI-only input acquisition. Transforms entries of the uniform `inputs`
6
- # hash that declare a `source:`/`coerce:`, and builds `inputs` from a
7
- # `cli_stdin` envelope — so put/propose/migrate/rule_lint/audit need no
8
- # hand-authored CLI class (ADR 0068). MCP receives typed JSON, so these
9
- # never run there.
10
- module Sources
11
- module_function
12
-
13
- # Apply per-arg :file sources (value is a path -> file contents) and
14
- # :coerce callables to a by-name inputs hash. Returns a new hash.
15
- def acquire(spec, inputs)
16
- spec.args.each_with_object(inputs.dup) do |a, h|
17
- next unless h.key?(a.name)
18
-
19
- h[a.name] = File.read(h[a.name]) if a.source == :file
20
- h[a.name] = a.coerce.call(h[a.name]) if a.coerce
21
- end
22
- end
23
-
24
- # Parse a cli_stdin :json envelope into a by-name inputs hash, mapping
25
- # envelope keys (wire-names) to arg names.
26
- def from_stdin(spec, stream)
27
- return {} unless spec.cli_stdin == :json
28
-
29
- raw = stream.read.to_s
30
- return {} if raw.strip.empty? # no envelope piped -> required args surface as missing
31
-
32
- envelope = JSON.parse(raw)
33
- spec.args.each_with_object({}) do |a, h|
34
- h[a.name] = envelope[a.wire.to_s] if envelope.key?(a.wire.to_s)
35
- end
36
- end
37
- end
38
- end
39
- end
@@ -1,15 +0,0 @@
1
- module Textus
2
- module Contract
3
- # Renders a use-case result for a surface, using the verb's declared view
4
- # (falling back to the default). The single replacement for the old
5
- # response/cli_response split and the Proc#arity sniff: views are always
6
- # called as (result, inputs); a one-parameter view ignores inputs.
7
- module View
8
- module_function
9
-
10
- def render(spec, surface, result, inputs)
11
- spec.view(surface).call(result, inputs)
12
- end
13
- end
14
- end
15
- end
@@ -1,24 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- # Per-role cursor cache under <root>/.state/cursors/<role>. A convenience so
5
- # `textus pulse` (no --since) means "since I last looked". Gitignored;
6
- # losing it just re-emits recent deltas, never corrupts the store. ADR 0036/0038.
7
- class CursorStore
8
- def initialize(root:, role:)
9
- @path = Textus::Layout.cursor(root, role)
10
- end
11
-
12
- def read
13
- Integer(File.read(@path).strip)
14
- rescue Errno::ENOENT, ArgumentError
15
- 0
16
- end
17
-
18
- def write(seq)
19
- FileUtils.mkdir_p(File.dirname(@path))
20
- File.write(@path, seq.to_s)
21
- seq
22
- end
23
- end
24
- end
@@ -1,46 +0,0 @@
1
- module Textus
2
- class Envelope
3
- # Read-only counterpart to EnvelopeWriter. Resolves a key, reads the
4
- # bytes, parses them via the format strategy, and hands back an
5
- # Envelope. Used by Mv (pre-move inspection) and by EnvelopeWriter
6
- # (existing-uid lookup for the uid-preservation step in #put).
7
- #
8
- # No audit, no events, no permission checks — those live one layer up.
9
- class Reader
10
- def self.from(container:)
11
- new(file_store: container.file_store, manifest: container.manifest)
12
- end
13
-
14
- def initialize(file_store:, manifest:)
15
- @file_store = file_store
16
- @manifest = manifest
17
- end
18
-
19
- def read(key)
20
- res = @manifest.resolver.resolve(key)
21
- path = res.path
22
- return nil unless @file_store.exists?(path)
23
-
24
- mentry = res.entry
25
- raw = @file_store.read(path)
26
- parsed = Format.for(mentry.format).parse(raw, path: path)
27
- Textus::Envelope.build(
28
- key: key, mentry: mentry, path: path,
29
- meta: parsed["_meta"], body: parsed["body"],
30
- etag: Etag.for_bytes(raw), content: parsed["content"]
31
- )
32
- end
33
-
34
- def existing_uid(key)
35
- env = read(key)
36
- env&.uid
37
- rescue StandardError
38
- nil
39
- end
40
-
41
- def exists?(key)
42
- @file_store.exists?(@manifest.resolver.resolve(key).path)
43
- end
44
- end
45
- end
46
- end
@@ -1,209 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- class Envelope
5
- # Owns the write pipeline (validate, serialize, etag-check, write, audit).
6
- # Talks to ports (FileStore, Schemas, AuditLog, Manifest) and an
7
- # Reader for the existing-uid lookup.
8
- #
9
- # Invariant: every public method's final action is @audit_log.append(...).
10
- #
11
- # No permission check, no event firing — those belong to the caller
12
- # (Write::Put / ::Delete / ::Mv).
13
- class Writer
14
- Payload = Data.define(:meta, :body, :content)
15
-
16
- def self.from(container:, call:)
17
- new(
18
- file_store: container.file_store, manifest: container.manifest,
19
- schemas: container.schemas, audit_log: container.audit_log,
20
- call: call, reader: Reader.from(container: container)
21
- )
22
- end
23
-
24
- def initialize(file_store:, manifest:, schemas:, audit_log:, call:, reader:)
25
- @file_store = file_store
26
- @manifest = manifest
27
- @schemas = schemas
28
- @audit_log = audit_log
29
- @call = call
30
- @reader = reader
31
- end
32
-
33
- def put(key, mentry:, payload:, if_etag: nil)
34
- path = resolve_path(key)
35
- meta, content = prepare_uid(mentry, payload, key)
36
- bytes, eff_meta, eff_body, eff_content = serialize_entry(mentry, path, meta, payload, content)
37
- enforce_name_match!(path, eff_meta, mentry.format)
38
- validate_schema(mentry, eff_meta, eff_content)
39
- etag_before = check_etag!(path, key, if_etag)
40
- write_bytes(path, bytes)
41
- envelope = build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
42
- audit_put(key, etag_before, envelope.etag)
43
- envelope
44
- end
45
-
46
- def delete(key, mentry: nil, if_etag: nil) # rubocop:disable Lint/UnusedMethodArgument
47
- # `mentry:` is accepted for symmetry with `put` / `move` and to
48
- # leave room for future format-specific delete hooks; no field
49
- # on it is needed today.
50
- path = @manifest.resolver.resolve(key).path
51
- raise UnknownKey.new(key, suggestions: @manifest.resolver.suggestions_for(key)) unless @file_store.exists?(path)
52
-
53
- etag_before = @file_store.etag(path)
54
- raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && if_etag != etag_before
55
-
56
- @file_store.delete(path)
57
- prune_empty_parents(path)
58
- @audit_log.append(
59
- role: @call.role, verb: "key_delete", key: key,
60
- etag_before: etag_before, etag_after: nil,
61
- extras: @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
62
- )
63
- end
64
-
65
- def move(from_key:, to_key:, new_mentry:, if_etag: nil)
66
- from_path = @manifest.resolver.resolve(from_key).path
67
- to_path = @manifest.resolver.resolve(to_key).path
68
- raise UnknownKey.new(from_key, suggestions: @manifest.resolver.suggestions_for(from_key)) unless @file_store.exists?(from_path)
69
-
70
- etag_before = @file_store.etag(from_path)
71
- raise EtagMismatch.new(from_key, if_etag, etag_before) if if_etag && if_etag != etag_before
72
-
73
- FileUtils.mkdir_p(File.dirname(to_path))
74
- FileUtils.mv(from_path, to_path)
75
- prune_empty_parents(from_path)
76
- basename = to_key.split(".").last
77
- Format.for(new_mentry.format).rewrite_name(to_path, basename)
78
- etag_after = Etag.for_file(to_path)
79
-
80
- raw = @file_store.read(to_path)
81
- parsed = Format.for(new_mentry.format).parse(raw, path: to_path)
82
- envelope = Textus::Envelope.build(
83
- key: to_key, mentry: new_mentry, path: to_path,
84
- meta: parsed["_meta"], body: parsed["body"],
85
- etag: etag_after, content: parsed["content"]
86
- )
87
-
88
- extras = {
89
- "from_key" => from_key, "to_key" => to_key,
90
- "from_path" => from_path, "to_path" => to_path,
91
- "uid" => envelope.uid
92
- }
93
- extras["correlation_id"] = @call.correlation_id if @call.correlation_id
94
-
95
- @audit_log.append(
96
- role: @call.role, verb: "key_mv", key: to_key,
97
- etag_before: etag_before, etag_after: etag_after,
98
- extras: extras
99
- )
100
-
101
- envelope
102
- end
103
-
104
- private
105
-
106
- # After a file leaves a directory (delete or move-source), remove any
107
- # now-empty parent dirs so bulk move/delete doesn't accrue orphan dirs
108
- # (F3 of #161). Floored at the entry's *zone directory* — a zone is a
109
- # declared, first-class container, so its own dir is preserved even when
110
- # momentarily empty; only the sub-dirs the bulk op carved out are
111
- # pruned. Stops at the first non-empty ancestor, so a dir holding a
112
- # `.gitkeep` or sibling entries survives. Best-effort: a lost race or a
113
- # non-empty dir is silently fine, never fatal to the write.
114
- def prune_empty_parents(path)
115
- floor = zone_floor(path)
116
- return unless floor
117
-
118
- dir = File.dirname(path)
119
- while dir.start_with?("#{floor}/") && Dir.empty?(dir)
120
- Dir.rmdir(dir)
121
- dir = File.dirname(dir)
122
- end
123
- rescue SystemCallError
124
- nil
125
- end
126
-
127
- # The zone directory under which `path` lives (`<root>/zones/<zone>`),
128
- # or nil if `path` is not under the store's zones tree.
129
- def zone_floor(path)
130
- zones_root = File.join(@manifest.data.root, "data")
131
- prefix = "#{zones_root}/"
132
- return nil unless path.start_with?(prefix)
133
-
134
- zone_seg = path.delete_prefix(prefix).split("/").first
135
- zone_seg && File.join(zones_root, zone_seg)
136
- end
137
-
138
- def ensure_uid(format, meta, content, existing_uid)
139
- Textus::Format.for(format).inject_uid(meta, content, existing_uid)
140
- end
141
-
142
- def enforce_name_match!(path, meta, format)
143
- Textus::Format.for(format).enforce_name_match!(path, meta)
144
- end
145
-
146
- def serialize_for_put(mentry:, path:, meta:, body:, content:)
147
- Textus::Format.for(mentry.format).serialize_for_put(
148
- meta: meta, body: body, content: content, path: path,
149
- )
150
- end
151
-
152
- def resolve_path(key)
153
- @manifest.resolver.resolve(key).path
154
- end
155
-
156
- def prepare_uid(mentry, payload, key)
157
- meta = payload.meta || {}
158
- existing_uid = @reader.existing_uid(key)
159
- ensure_uid(mentry.format, meta, payload.content, existing_uid)
160
- end
161
-
162
- def serialize_entry(mentry, path, meta, payload, content)
163
- serialize_for_put(
164
- mentry: mentry, path: path,
165
- meta: meta, body: payload.body, content: content
166
- )
167
- end
168
-
169
- def validate_schema(mentry, eff_meta, eff_content)
170
- schema = @schemas.fetch_or_nil(mentry.schema)
171
- return unless schema
172
-
173
- Format.for(mentry.format).validate_against(
174
- schema,
175
- { "_meta" => eff_meta, "content" => eff_content },
176
- )
177
- end
178
-
179
- def check_etag!(path, key, if_etag)
180
- etag_before = @file_store.exists?(path) ? @file_store.etag(path) : nil
181
- raise EtagMismatch.new(key, if_etag, etag_before) if if_etag && (etag_before != if_etag)
182
-
183
- etag_before
184
- end
185
-
186
- def write_bytes(path, bytes)
187
- @file_store.write(path, bytes)
188
- end
189
-
190
- def build_envelope(key, mentry, path, eff_meta, eff_body, eff_content)
191
- Textus::Envelope.build(
192
- key: key, mentry: mentry, path: path,
193
- meta: eff_meta, body: eff_body,
194
- etag: Etag.for_bytes(@file_store.read(path)),
195
- content: eff_content
196
- )
197
- end
198
-
199
- def audit_put(key, etag_before, etag_after)
200
- extras = @call.correlation_id ? { "correlation_id" => @call.correlation_id } : nil
201
- @audit_log.append(
202
- role: @call.role, verb: "put", key: key,
203
- etag_before: etag_before, etag_after: etag_after,
204
- extras: extras
205
- )
206
- end
207
- end
208
- end
209
- end
@@ -1,79 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "dry-struct"
4
-
5
- module Textus
6
- class Envelope < Dry::Struct
7
- attribute :protocol, Types::String
8
- attribute :key, Types::String
9
- attribute :lane, Types::String
10
- attribute :owner, Types::String.optional
11
- attribute :path, Types::String
12
- attribute :format, Types::FormatName
13
- attribute :etag, Types::String
14
- attribute :uid, Types::String.optional
15
- attribute :schema_ref, Types::String.optional
16
- attribute :meta, Types::Hash.default({}.freeze)
17
- attribute :body, Types::String.optional
18
- attribute :content, Types::Any.optional
19
- attribute :freshness, Types::Any.optional
20
-
21
- # rubocop:disable Metrics/ParameterLists
22
- def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil, freshness: nil)
23
- # rubocop:enable Metrics/ParameterLists
24
- new(
25
- protocol: Textus::PROTOCOL,
26
- key: key,
27
- lane: mentry.lane,
28
- owner: mentry.owner,
29
- path: path,
30
- format: mentry.format,
31
- uid: extract_uid(meta),
32
- etag: etag,
33
- schema_ref: mentry.schema,
34
- meta: meta,
35
- body: body,
36
- content: content,
37
- freshness: freshness,
38
- )
39
- end
40
-
41
- def self.extract_uid(meta)
42
- v = meta.is_a?(Hash) ? meta["uid"] : nil
43
- v.is_a?(String) ? v : nil
44
- end
45
-
46
- def with(**attrs) = self.class.new(to_h.merge(attrs))
47
-
48
- def to_h_for_wire
49
- h = {
50
- "protocol" => protocol,
51
- "key" => key,
52
- "lane" => lane,
53
- "owner" => owner,
54
- "path" => path,
55
- "format" => format,
56
- "_meta" => meta,
57
- "body" => body,
58
- "etag" => etag,
59
- "schema_ref" => schema_ref,
60
- "uid" => uid,
61
- }
62
- h["content"] = content unless content.nil?
63
- freshness&.to_h_for_wire&.each { |k, v| h[k] = v }
64
- h
65
- end
66
-
67
- def stale?
68
- return false if freshness.nil?
69
-
70
- freshness.stale == true
71
- end
72
-
73
- def fetching?
74
- return false if freshness.nil?
75
-
76
- freshness.fetching == true
77
- end
78
- end
79
- end
data/lib/textus/etag.rb DELETED
@@ -1,36 +0,0 @@
1
- require "digest"
2
-
3
- module Textus
4
- module Etag
5
- def self.for_bytes(bytes)
6
- "sha256:#{Digest::SHA256.hexdigest(bytes)}"
7
- end
8
-
9
- def self.for_file(path)
10
- for_bytes(File.binread(path))
11
- end
12
-
13
- # The fingerprint of everything an agent's boot orientation depends on:
14
- # the manifest PLUS the executable contract — hooks and schemas. A
15
- # mid-session edit to any of these makes the cached orientation stale, so
16
- # the session must re-boot (ADR 0074). The composite is one digest over the
17
- # sorted per-file listing, so it is order-stable.
18
- def self.for_contract(root)
19
- listing = contract_files(root).map do |path|
20
- rel = path.delete_prefix(root).delete_prefix("/")
21
- "#{rel}:#{for_file(path)}"
22
- end.join("\n")
23
- for_bytes(listing)
24
- end
25
-
26
- # manifest.yaml, then every hook and schema file. Dir.glob already returns
27
- # sorted paths (Ruby 3.0+), keeping the digest independent of FS order.
28
- def self.contract_files(root)
29
- [
30
- File.join(root, "manifest.yaml"),
31
- *Dir.glob(File.join(root, "hooks", "**", "*.rb")),
32
- *Dir.glob(File.join(root, "schemas", "**", "*")).select { |f| File.file?(f) },
33
- ]
34
- end
35
- end
36
- end
@@ -1,23 +0,0 @@
1
- module Textus
2
- module Jobs
3
- class Base
4
- def self.inherited(subclass)
5
- super
6
- return unless subclass.name
7
-
8
- TracePoint.new(:end) do |tp|
9
- if tp.self == subclass
10
- Textus::Jobs.register(subclass)
11
- tp.disable
12
- end
13
- end.enable
14
- end
15
-
16
- def call(**)
17
- raise NotImplementedError.new("#{self.class}#call")
18
- end
19
-
20
- def args = {}
21
- end
22
- end
23
- end
@@ -1,20 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Jobs
5
- class Materialize < Base
6
- TYPE = "materialize"
7
-
8
- def initialize(key:)
9
- super()
10
- @key = key
11
- end
12
-
13
- def args = { key: @key }
14
-
15
- def call(container:, call:)
16
- Textus::Produce::Engine.converge(container: container, call: call, keys: [@key])
17
- end
18
- end
19
- end
20
- end
@@ -1,9 +0,0 @@
1
- module Textus
2
- module Jobs
3
- Plan = Data.define(:steps, :warnings) do
4
- def to_h
5
- { "steps" => steps, "warnings" => warnings }
6
- end
7
- end
8
- end
9
- end
@@ -1,101 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Textus
4
- module Jobs
5
- class Planner
6
- ACTIONS_BY_TRIGGER = {
7
- "convergence" => %w[materialize sweep],
8
- "entry.written" => %w[materialize],
9
- "entry.deleted" => %w[materialize],
10
- "entry.moved" => %w[materialize],
11
- "proposal.accepted" => %w[materialize],
12
- "proposal.rejected" => %w[materialize],
13
- }.freeze
14
-
15
- SCOPE_RESOLVERS = {
16
- "materialize" => :producible_keys,
17
- "sweep" => :lane_keys,
18
- }.freeze
19
-
20
- def self.seed(container:, queue:, role:)
21
- jobs = new(container: container).plan(
22
- trigger: { "type" => "convergence" },
23
- role: role,
24
- )
25
- jobs.each { |j| queue.enqueue(j) }
26
- end
27
-
28
- def initialize(container:)
29
- @container = container
30
- @manifest = container.manifest
31
- end
32
-
33
- def plan(trigger:, role:)
34
- type = trigger["type"] || trigger[:type]
35
- trigger["target"] || trigger[:target]
36
- return [] if type.nil?
37
-
38
- blocks_with_react = @manifest.rules.blocks.select(&:react)
39
- if blocks_with_react.any?
40
- plan_from_rules(blocks_with_react, type, role)
41
- else
42
- plan_from_defaults(type, role)
43
- end
44
- end
45
-
46
- private
47
-
48
- def plan_from_rules(blocks, type, role)
49
- jobs = []
50
- blocks
51
- .select { |b| matches_trigger?(b.react, type) }
52
- .each do |block|
53
- do_action = block.react.raw["do"]
54
- Array(do_action).each do |action|
55
- if action == "sweep"
56
- jobs << Textus::Ports::JobStore::Job.new(
57
- type: "sweep", args: { "scope" => {} }, enqueued_by: role,
58
- )
59
- else
60
- resolver = SCOPE_RESOLVERS.fetch(action, :producible_keys)
61
- keys = send(resolver, nil)
62
- keys.each { |key| jobs << job(action, key, role) }
63
- end
64
- end
65
- end
66
- jobs
67
- end
68
-
69
- def plan_from_defaults(type, role)
70
- actions = ACTIONS_BY_TRIGGER.fetch(type, [])
71
- jobs = []
72
- producible_keys(nil).each { |k| jobs << job("materialize", k, role) } if actions.include?("materialize")
73
- if actions.include?("sweep")
74
- jobs << Textus::Ports::JobStore::Job.new(
75
- type: "sweep", args: { "scope" => {} }, enqueued_by: role,
76
- )
77
- end
78
- jobs
79
- end
80
-
81
- def matches_trigger?(react, type)
82
- on = react.raw["on"]
83
- Array(on).include?(type)
84
- end
85
-
86
- def job(type, key, enqueued_by)
87
- Textus::Ports::JobStore::Job.new(type: type, args: { "key" => key }, enqueued_by: enqueued_by)
88
- end
89
-
90
- def producible_keys(_target)
91
- @manifest.data.entries
92
- .select { |e| !e.publish_tree.nil? || !e.publish_to.empty? }
93
- .map(&:key)
94
- end
95
-
96
- def lane_keys(_target)
97
- @manifest.data.entries.map(&:key)
98
- end
99
- end
100
- end
101
- end
@@ -1,48 +0,0 @@
1
- require "fileutils"
2
-
3
- module Textus
4
- module Jobs
5
- class Retention
6
- def initialize(container:, call:)
7
- @container = container
8
- @call = call
9
- end
10
-
11
- def call(rows)
12
- out = { dropped: [], archived: [], failed: [] }
13
- rows.each do |row|
14
- key = row["key"]
15
- begin
16
- case row["action"]
17
- when "drop"
18
- delete(key)
19
- out[:dropped] << key
20
- when "archive"
21
- archive_leaf(row)
22
- delete(key)
23
- out[:archived] << key
24
- end
25
- rescue Textus::Error => e
26
- out[:failed] << { "key" => key, "error" => e.message }
27
- end
28
- end
29
- out
30
- end
31
-
32
- private
33
-
34
- def archive_leaf(row)
35
- src = row["path"]
36
- root = @container.root.to_s
37
- rel = src.delete_prefix("#{root}/")
38
- dest = File.join(root, "archive", rel)
39
- FileUtils.mkdir_p(File.dirname(dest))
40
- FileUtils.cp(src, dest)
41
- end
42
-
43
- def delete(key)
44
- Textus::Action::KeyDelete.new(key: key).call(container: @container, call: @call)
45
- end
46
- end
47
- end
48
- end
@@ -1,27 +0,0 @@
1
- module Textus
2
- module Jobs
3
- class Sweep < Base
4
- REQUIRED_ROLE = Textus::Role::AUTOMATION
5
- TYPE = "sweep"
6
-
7
- def initialize(scope: nil, key: nil)
8
- super()
9
- @scope = scope || {}
10
- @key = key
11
- end
12
-
13
- def args = { scope: @scope, key: @key }.compact
14
-
15
- def call(container:, call:)
16
- prefix = @key || (@scope.is_a?(Hash) ? @scope["prefix"] : nil)
17
- lane = @scope.is_a?(Hash) ? @scope["lane"] : nil
18
- rows = Textus::Core::Retention::Sweep.new(
19
- manifest: container.manifest,
20
- file_stat: Textus::Ports::Storage::FileStat.new,
21
- clock: Textus::Ports::Clock.new,
22
- ).call(prefix: prefix, lane: lane)
23
- Textus::Jobs::Retention.new(container: container, call: call).call(rows)
24
- end
25
- end
26
- end
27
- end