textus 0.53.0 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (149) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +34 -0
  3. data/SPEC.md +2 -5
  4. data/docs/architecture/README.md +149 -159
  5. data/lib/textus/action/accept.rb +0 -9
  6. data/lib/textus/action/audit.rb +0 -2
  7. data/lib/textus/action/blame.rb +0 -2
  8. data/lib/textus/action/boot.rb +2 -11
  9. data/lib/textus/action/data_mv.rb +1 -3
  10. data/lib/textus/action/deps.rb +1 -15
  11. data/lib/textus/action/doctor.rb +0 -2
  12. data/lib/textus/action/drain.rb +4 -6
  13. data/lib/textus/action/enqueue.rb +3 -5
  14. data/lib/textus/action/get.rb +1 -3
  15. data/lib/textus/action/ingest.rb +135 -0
  16. data/lib/textus/action/jobs.rb +1 -3
  17. data/lib/textus/action/key_delete.rb +0 -10
  18. data/lib/textus/action/key_delete_prefix.rb +1 -3
  19. data/lib/textus/action/key_mv.rb +1 -15
  20. data/lib/textus/action/key_mv_prefix.rb +1 -3
  21. data/lib/textus/action/list.rb +0 -2
  22. data/lib/textus/action/propose.rb +0 -2
  23. data/lib/textus/action/published.rb +0 -2
  24. data/lib/textus/action/pulse.rb +7 -38
  25. data/lib/textus/action/put.rb +1 -12
  26. data/lib/textus/action/rdeps.rb +2 -12
  27. data/lib/textus/action/reject.rb +0 -12
  28. data/lib/textus/action/rule_explain.rb +0 -4
  29. data/lib/textus/action/rule_lint.rb +1 -3
  30. data/lib/textus/action/rule_list.rb +0 -4
  31. data/lib/textus/action/schema_envelope.rb +0 -2
  32. data/lib/textus/action/uid.rb +0 -2
  33. data/lib/textus/action/where.rb +0 -2
  34. data/lib/textus/action/write_verb.rb +4 -18
  35. data/lib/textus/boot.rb +33 -109
  36. data/lib/textus/command.rb +6 -1
  37. data/lib/textus/container.rb +1 -1
  38. data/lib/textus/core/freshness/evaluator.rb +18 -34
  39. data/lib/textus/doctor/check/notebook_sources.rb +50 -0
  40. data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
  41. data/lib/textus/doctor/check.rb +0 -1
  42. data/lib/textus/doctor.rb +2 -42
  43. data/lib/textus/envelope/reader.rb +46 -0
  44. data/lib/textus/envelope/writer.rb +209 -0
  45. data/lib/textus/errors.rb +8 -4
  46. data/lib/textus/{entry → format}/base.rb +1 -6
  47. data/lib/textus/{entry → format}/json.rb +1 -4
  48. data/lib/textus/{entry → format}/markdown.rb +2 -6
  49. data/lib/textus/{entry → format}/text.rb +2 -6
  50. data/lib/textus/{entry → format}/yaml.rb +1 -4
  51. data/lib/textus/{entry.rb → format.rb} +9 -10
  52. data/lib/textus/gate/auth.rb +58 -12
  53. data/lib/textus/gate.rb +2 -0
  54. data/lib/textus/init.rb +12 -75
  55. data/lib/textus/jobs/base.rb +23 -0
  56. data/lib/textus/jobs/materialize.rb +20 -0
  57. data/lib/textus/{background → jobs}/plan.rb +1 -1
  58. data/lib/textus/jobs/planner.rb +101 -0
  59. data/lib/textus/jobs/retention.rb +48 -0
  60. data/lib/textus/jobs/sweep.rb +27 -0
  61. data/lib/textus/{background → jobs}/worker.rb +2 -2
  62. data/lib/textus/jobs.rb +15 -0
  63. data/lib/textus/key/path.rb +1 -1
  64. data/lib/textus/manifest/entry/base.rb +4 -19
  65. data/lib/textus/manifest/entry/parser.rb +27 -10
  66. data/lib/textus/manifest/entry/produced.rb +13 -34
  67. data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
  68. data/lib/textus/manifest/entry/publish.rb +0 -12
  69. data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
  70. data/lib/textus/manifest/entry/validators.rb +0 -1
  71. data/lib/textus/manifest/policy/source.rb +14 -62
  72. data/lib/textus/manifest/policy.rb +2 -5
  73. data/lib/textus/manifest/resolver.rb +2 -2
  74. data/lib/textus/manifest/schema/keys.rb +6 -20
  75. data/lib/textus/manifest/schema/validator.rb +3 -11
  76. data/lib/textus/manifest/schema/vocabulary.rb +1 -0
  77. data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
  78. data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
  79. data/lib/textus/produce/engine.rb +50 -0
  80. data/lib/textus/produce/render.rb +18 -0
  81. data/lib/textus/session.rb +1 -1
  82. data/lib/textus/store.rb +1 -10
  83. data/lib/textus/surfaces/mcp/catalog.rb +1 -1
  84. data/lib/textus/surfaces/mcp/errors.rb +0 -9
  85. data/lib/textus/surfaces/mcp/routing.rb +51 -0
  86. data/lib/textus/surfaces/mcp/server.rb +101 -62
  87. data/lib/textus/surfaces/role_scope.rb +0 -4
  88. data/lib/textus/surfaces/watcher.rb +3 -3
  89. data/lib/textus/version.rb +1 -1
  90. data/lib/textus/workflow/collector.rb +27 -0
  91. data/lib/textus/workflow/context.rb +5 -0
  92. data/lib/textus/workflow/dsl.rb +44 -0
  93. data/lib/textus/workflow/loader.rb +17 -0
  94. data/lib/textus/workflow/pattern.rb +18 -0
  95. data/lib/textus/workflow/registry.rb +21 -0
  96. data/lib/textus/workflow/runner.rb +84 -0
  97. data/lib/textus/workflow.rb +28 -0
  98. data/lib/textus.rb +12 -1
  99. metadata +33 -61
  100. data/lib/textus/action/pulse/scanner.rb +0 -118
  101. data/lib/textus/background/job/base.rb +0 -27
  102. data/lib/textus/background/job/materialize.rb +0 -31
  103. data/lib/textus/background/job/refresh.rb +0 -22
  104. data/lib/textus/background/job/sweep.rb +0 -31
  105. data/lib/textus/background/job.rb +0 -19
  106. data/lib/textus/background/planner/plan.rb +0 -113
  107. data/lib/textus/background/retention/apply.rb +0 -50
  108. data/lib/textus/doctor/check/handler_permit.rb +0 -34
  109. data/lib/textus/doctor/check/hooks.rb +0 -25
  110. data/lib/textus/doctor/check/intake_registration.rb +0 -46
  111. data/lib/textus/envelope/io/reader.rb +0 -48
  112. data/lib/textus/envelope/io/writer.rb +0 -211
  113. data/lib/textus/events.rb +0 -21
  114. data/lib/textus/init/templates/machine_intake.rb +0 -49
  115. data/lib/textus/init/templates/orientation_reducer.rb +0 -21
  116. data/lib/textus/manifest/entry/validators/events.rb +0 -22
  117. data/lib/textus/manifest/policy/handler_permit.rb +0 -17
  118. data/lib/textus/pipeline/acquire/handler.rb +0 -29
  119. data/lib/textus/pipeline/acquire/intake.rb +0 -132
  120. data/lib/textus/pipeline/acquire/projection.rb +0 -129
  121. data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
  122. data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
  123. data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
  124. data/lib/textus/pipeline/acquire/serializer.rb +0 -17
  125. data/lib/textus/pipeline/engine.rb +0 -97
  126. data/lib/textus/pipeline/render.rb +0 -25
  127. data/lib/textus/ports/audit_subscriber.rb +0 -42
  128. data/lib/textus/projection.rb +0 -103
  129. data/lib/textus/step/base.rb +0 -35
  130. data/lib/textus/step/builtin/csv_fetch.rb +0 -19
  131. data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
  132. data/lib/textus/step/builtin/json_fetch.rb +0 -18
  133. data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
  134. data/lib/textus/step/builtin/rss_fetch.rb +0 -26
  135. data/lib/textus/step/builtin.rb +0 -22
  136. data/lib/textus/step/catalog.rb +0 -37
  137. data/lib/textus/step/context.rb +0 -74
  138. data/lib/textus/step/discovery.rb +0 -24
  139. data/lib/textus/step/error_log.rb +0 -32
  140. data/lib/textus/step/event_bus.rb +0 -113
  141. data/lib/textus/step/fetch.rb +0 -13
  142. data/lib/textus/step/fire_report.rb +0 -23
  143. data/lib/textus/step/loader.rb +0 -108
  144. data/lib/textus/step/observe.rb +0 -31
  145. data/lib/textus/step/registry_store.rb +0 -66
  146. data/lib/textus/step/signature.rb +0 -31
  147. data/lib/textus/step/transform.rb +0 -12
  148. data/lib/textus/step/validate.rb +0 -11
  149. data/lib/textus/step.rb +0 -10
@@ -0,0 +1,209 @@
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
data/lib/textus/errors.rb CHANGED
@@ -158,10 +158,6 @@ module Textus
158
158
  end
159
159
  end
160
160
 
161
- class InvalidProjection < Error
162
- def initialize(m) = super("invalid_projection", m)
163
- end
164
-
165
161
  class TemplateError < Error
166
162
  def initialize(m, template_name: nil)
167
163
  hint =
@@ -209,6 +205,14 @@ module Textus
209
205
  end
210
206
  end
211
207
 
208
+ class ContractDrift < Error
209
+ JSONRPC_CODE = -32_001
210
+
211
+ def initialize(message, details: {})
212
+ super("contract_drift", message, details: details)
213
+ end
214
+ end
215
+
212
216
  class FlagRenamed < Error
213
217
  def initialize(old_flag, new_flag)
214
218
  super(
@@ -1,8 +1,5 @@
1
1
  module Textus
2
- module Entry
3
- # Abstract base for entry format strategies. Each concrete strategy
4
- # owns parsing, serialization, file-extension claims, and schema
5
- # validation for entries declared with its format.
2
+ module Format
6
3
  class Base
7
4
  def self.parse(_raw, path: nil)
8
5
  _ = path
@@ -20,8 +17,6 @@ module Textus
20
17
  raise NotImplementedError.new("#{name}.extensions not implemented")
21
18
  end
22
19
 
23
- # Default: validate the meta hash. Overridden by formats that put the
24
- # validatable payload elsewhere (json/yaml put it under "content").
25
20
  def self.validate_against(schema, parsed)
26
21
  schema.validate!(parsed["_meta"] || {})
27
22
  end
@@ -1,8 +1,7 @@
1
1
  require "json"
2
2
 
3
3
  module Textus
4
- module Entry
5
- # JSON entry storage. Top-level must be an object so we can carry _meta.
4
+ module Format
6
5
  class Json < Base
7
6
  def self.parse(raw, path: nil)
8
7
  raw = raw.dup.force_encoding(Encoding::UTF_8)
@@ -23,7 +22,6 @@ module Textus
23
22
 
24
23
  def self.serialize(meta:, body:, content: nil)
25
24
  if content.is_a?(Hash)
26
- # Re-inject _meta as the first key so on-disk shape is stable.
27
25
  on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
28
26
  out = ::JSON.pretty_generate(on_disk)
29
27
  out += "\n" unless out.end_with?("\n")
@@ -61,7 +59,6 @@ module Textus
61
59
  end
62
60
  end
63
61
 
64
- # Mutating filesystem op; returns true if a write happened.
65
62
  def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
66
63
  raw = File.binread(path)
67
64
  parsed = parse(raw, path: path)
@@ -1,8 +1,7 @@
1
1
  require "yaml"
2
2
 
3
3
  module Textus
4
- module Entry
5
- # Markdown with YAML frontmatter. Original Entry implementation.
4
+ module Format
6
5
  class Markdown < Base
7
6
  def self.parse(raw, path: nil)
8
7
  raw = raw.dup.force_encoding(Encoding::UTF_8)
@@ -26,7 +25,7 @@ module Textus
26
25
  end
27
26
 
28
27
  def self.serialize(meta:, body:, content: nil)
29
- _ = content # markdown ignores content
28
+ _ = content
30
29
  fm_yaml = meta.empty? ? "" : ::YAML.dump(meta).sub(/\A---\n/, "")
31
30
  body = body.to_s
32
31
  body += "\n" unless body.empty? || body.end_with?("\n")
@@ -44,9 +43,6 @@ module Textus
44
43
  [bytes, meta, body.to_s, nil]
45
44
  end
46
45
 
47
- # Mutating filesystem op; returns true if a write happened (boolean is
48
- # informational, not a predicate). Rubocop's predicate-name heuristic
49
- # disabled here on purpose.
50
46
  def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
51
47
  raw = File.binread(path)
52
48
  parsed = parse(raw, path: path)
@@ -1,6 +1,5 @@
1
1
  module Textus
2
- module Entry
3
- # Plain-text entry storage. No frontmatter or structured content.
2
+ module Format
4
3
  class Text < Base
5
4
  def self.parse(raw, path: nil)
6
5
  raw = raw.dup.force_encoding(Encoding::UTF_8)
@@ -25,9 +24,7 @@ module Textus
25
24
  [meta, content]
26
25
  end
27
26
 
28
- def self.enforce_name_match!(_path, _meta)
29
- # text has no meta home; no-op
30
- end
27
+ def self.enforce_name_match!(_path, _meta); end
31
28
 
32
29
  def self.serialize_for_put(meta:, body:, content:, path:)
33
30
  _ = path
@@ -36,7 +33,6 @@ module Textus
36
33
  [bytes, meta, body.to_s, nil]
37
34
  end
38
35
 
39
- # No-op; text has no meta. Returns false (never writes).
40
36
  def self.rewrite_name(_path, _basename) # rubocop:disable Naming/PredicateMethod
41
37
  false
42
38
  end
@@ -1,8 +1,7 @@
1
1
  require "yaml"
2
2
 
3
3
  module Textus
4
- module Entry
5
- # YAML entry storage. Top-level must be a mapping so we can carry _meta.
4
+ module Format
6
5
  class Yaml < Base
7
6
  def self.parse(raw, path: nil)
8
7
  raw = raw.dup.force_encoding(Encoding::UTF_8)
@@ -23,7 +22,6 @@ module Textus
23
22
 
24
23
  def self.serialize(meta:, body:, content: nil)
25
24
  if content.is_a?(Hash)
26
- # Re-inject _meta as the first key so on-disk shape is stable.
27
25
  on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
28
26
  ::YAML.dump(on_disk).sub(/\A---\n/, "")
29
27
  elsif body && !body.to_s.empty?
@@ -59,7 +57,6 @@ module Textus
59
57
  end
60
58
  end
61
59
 
62
- # Mutating filesystem op; returns true if a write happened.
63
60
  def self.rewrite_name(path, basename) # rubocop:disable Naming/PredicateMethod
64
61
  raw = File.binread(path)
65
62
  parsed = parse(raw, path: path)
@@ -1,13 +1,12 @@
1
1
  module Textus
2
- # Public entry-format dispatcher.
3
- module Entry
2
+ module Format
4
3
  SEP = "---".freeze
5
4
 
6
5
  STRATEGIES = {
7
- "markdown" => Markdown,
8
- "json" => Json,
9
- "yaml" => Yaml,
10
- "text" => Text,
6
+ "markdown" => -> { Format::Markdown },
7
+ "json" => -> { Format::Json },
8
+ "yaml" => -> { Format::Yaml },
9
+ "text" => -> { Format::Text },
11
10
  }.freeze
12
11
 
13
12
  EXT_TO_FORMAT = {
@@ -18,8 +17,8 @@ module Textus
18
17
  ".txt" => "text",
19
18
  }.freeze
20
19
 
21
- def self.for_format(format)
22
- STRATEGIES.fetch(format.to_s) { raise UsageError.new("unknown entry format: #{format.inspect}") }
20
+ def self.for(format)
21
+ STRATEGIES.fetch(format.to_s) { raise Textus::UsageError.new("unknown entry format: #{format.inspect}") }.call
23
22
  end
24
23
 
25
24
  def self.infer_from_extension(ext)
@@ -31,11 +30,11 @@ module Textus
31
30
  end
32
31
 
33
32
  def self.parse(raw, path: nil, format: "markdown")
34
- for_format(format).parse(raw, path: path)
33
+ Format.for(format).parse(raw, path: path)
35
34
  end
36
35
 
37
36
  def self.serialize(meta: {}, body: "", content: nil, format: "markdown")
38
- for_format(format).serialize(meta: meta, body: body, content: content)
37
+ Format.for(format).serialize(meta: meta, body: body, content: content)
39
38
  end
40
39
  end
41
40
  end
@@ -4,14 +4,15 @@ module Textus
4
4
  class Gate
5
5
  class Auth
6
6
  FLOOR = {
7
- put: %w[lane_writable_by],
8
- key_delete: %w[lane_writable_by],
9
- key_mv: %w[lane_writable_by],
7
+ put: %w[lane_writable_by raw_lane_ingest_only],
8
+ key_delete: %w[lane_deletable_by],
9
+ key_mv: %w[lane_writable_by raw_lane_ingest_only],
10
10
  accept: %w[author_held],
11
11
  reject: %w[author_held],
12
- propose: %w[lane_writable_by],
13
- key_mv_prefix: %w[lane_writable_by],
14
- key_delete_prefix: %w[lane_writable_by],
12
+ propose: %w[lane_writable_by raw_lane_ingest_only],
13
+ key_mv_prefix: %w[lane_writable_by raw_lane_ingest_only],
14
+ key_delete_prefix: %w[lane_writable_by raw_lane_ingest_only],
15
+ ingest: %w[lane_writable_by raw_write_once],
15
16
  }.freeze
16
17
 
17
18
  AuthContext = Struct.new(
@@ -104,12 +105,15 @@ module Textus
104
105
 
105
106
  def evaluate(pred_name, ctx, extra)
106
107
  case pred_name
107
- when "lane_writable_by" then evaluate_lane_writable_by(ctx)
108
- when "author_held" then evaluate_author_held(ctx)
109
- when "target_is_canon" then evaluate_target_is_canon(ctx)
110
- when "etag_match" then evaluate_etag_match(ctx, extra)
111
- when "schema_valid" then evaluate_schema_valid(ctx)
112
- when "fresh_within" then { pass: true }
108
+ when "lane_writable_by" then evaluate_lane_writable_by(ctx)
109
+ when "author_held" then evaluate_author_held(ctx)
110
+ when "target_is_canon" then evaluate_target_is_canon(ctx)
111
+ when "etag_match" then evaluate_etag_match(ctx, extra)
112
+ when "schema_valid" then evaluate_schema_valid(ctx)
113
+ when "fresh_within" then { pass: true }
114
+ when "raw_lane_ingest_only" then evaluate_raw_lane_ingest_only(ctx)
115
+ when "raw_write_once" then evaluate_raw_write_once(ctx)
116
+ when "lane_deletable_by" then evaluate_lane_deletable_by(ctx)
113
117
  else raise Textus::UsageError.new("unknown predicate '#{pred_name}'")
114
118
  end
115
119
  end
@@ -168,6 +172,48 @@ module Textus
168
172
  end
169
173
  end
170
174
 
175
+ def evaluate_raw_lane_ingest_only(ctx)
176
+ return { pass: true } unless @manifest.policy.declared_kind(ctx.mentry.lane.to_s) == :raw
177
+ return { pass: true } if ctx.action == :ingest
178
+
179
+ { pass: false, error: Textus::Error.new(
180
+ :raw_lane_ingest_only,
181
+ "raw lane '#{ctx.mentry.lane}' only accepts `textus ingest` — " \
182
+ "use that verb instead of '#{ctx.action}'",
183
+ ) }
184
+ end
185
+
186
+ def evaluate_raw_write_once(ctx)
187
+ path = @manifest.resolver.resolve(ctx.target).path
188
+ return { pass: true } unless File.exist?(path)
189
+
190
+ { pass: false, error: Textus::Error.new(
191
+ :raw_write_once,
192
+ "raw entry '#{ctx.target}' already exists; " \
193
+ "delete it first (`textus key-delete #{ctx.target}`), then re-ingest",
194
+ ) }
195
+ end
196
+
197
+ # Deletion authority: the lane's write capability OR the author capability.
198
+ # On raw-kind lanes only the author capability grants deletion (correction
199
+ # escape hatch); the lane's own verb (ingest) is write-only. On all other
200
+ # lane kinds the behaviour matches lane_writable_by — the lane's writer
201
+ # can delete as before.
202
+ def evaluate_lane_deletable_by(ctx)
203
+ is_raw = @manifest.policy.declared_kind(ctx.mentry.lane.to_s) == :raw
204
+ pass = if is_raw
205
+ ctx.actor_caps.include?("author")
206
+ else
207
+ ctx.actor_caps.include?(ctx.lane_verb.to_s) || ctx.actor_caps.include?("author")
208
+ end
209
+ return { pass: true } if pass
210
+
211
+ extra_holders = is_raw ? ["author"] : [ctx.lane_verb.to_s, "author"]
212
+ holders = extra_holders.flat_map { |v| @manifest.policy.roles_with_capability(v) }.uniq
213
+ { pass: false, error: Textus::WriteForbidden.new(ctx.mentry.key, ctx.mentry.lane,
214
+ verb: ctx.lane_verb, holders:) }
215
+ end
216
+
171
217
  def schema_reason(err)
172
218
  d = err.details
173
219
  return err.message.dup unless d.is_a?(Hash)
data/lib/textus/gate.rb CHANGED
@@ -31,6 +31,7 @@ module Textus
31
31
  key_mv_prefix: Textus::Command::KeyMvPrefix,
32
32
  key_delete_prefix: Textus::Command::KeyDeletePrefix,
33
33
  drain: Textus::Command::Drain,
34
+ ingest: Textus::Command::Ingest,
34
35
  }.freeze
35
36
 
36
37
  ROUTES = {
@@ -62,6 +63,7 @@ module Textus
62
63
  Command::KeyMvPrefix => [Textus::Action::KeyMvPrefix],
63
64
  Command::KeyDeletePrefix => [Textus::Action::KeyDeletePrefix],
64
65
  Command::Drain => [Textus::Action::Drain],
66
+ Command::Ingest => [Textus::Action::Ingest],
65
67
  }.freeze
66
68
 
67
69
  def initialize(container)
data/lib/textus/init.rb CHANGED
@@ -3,7 +3,7 @@ require "pathname"
3
3
 
4
4
  module Textus
5
5
  module Init
6
- ZONES = %w[knowledge notebook proposals artifacts].freeze
6
+ ZONES = %w[knowledge notebook proposals artifacts raw].freeze
7
7
 
8
8
  DEFAULT_MANIFEST = <<~YAML
9
9
  version: textus/3
@@ -21,9 +21,8 @@ module Textus
21
21
  - { key: knowledge.notes, path: data/knowledge/notes, lane: knowledge, schema: null, owner: human:self, nested: true, kind: nested }
22
22
  - { key: notebook.notes, path: data/notebook/notes, lane: notebook, schema: null, owner: agent:self, nested: true, kind: nested }
23
23
  - { key: proposals.notes, path: data/proposals/notes, lane: proposals, schema: null, owner: agent:self, nested: true, kind: nested }
24
- # A per-host snapshot, refreshed from its declared intake by `textus drain` (scheduled, or on demand).
25
- # Nested so it grows to a fleet — add artifacts.feeds.machines.<host> leaves over SSH
26
- # (see docs/cookbook/environment-scan.md) without renaming. tracked:false →
24
+ # A per-host snapshot, populated by a registered workflow. Nested so it
25
+ # grows to a fleet — add leaves over SSH without renaming. tracked:false →
27
26
  # gitignored (machine info can be sensitive/noisy) but still protocol-readable
28
27
  # via `textus get artifacts.feeds.machines.local`. Delete to opt out. (ADR 0043)
29
28
  - key: artifacts.feeds.machines
@@ -32,60 +31,10 @@ module Textus
32
31
  format: yaml
33
32
  nested: true
34
33
  tracked: false
35
- kind: produced
36
- source:
37
- from: fetch
38
- handler: machine-intake
39
- ttl: 1h # cadence on a long-running server
40
- config:
41
- machines:
42
- local: { via: local }
34
+ kind: nested
43
35
  rules: []
44
36
  YAML
45
37
 
46
- STEPS_README = <<~MD
47
- # Steps
48
-
49
- Drop one Ruby file per step. Steps are discovered by convention.
50
- Files under `.textus/steps/<kind>/<name>.rb` are loaded at
51
- startup and registered.
52
-
53
- ## Conventions
54
-
55
- The directory name (`<kind>`) must be one of:
56
- - `fetch`: Acquires data from outside the store.
57
- - `transform`: Reshapes projection rows.
58
- - `validate`: Validates data before writing.
59
- - `observe`: Listens to store events.
60
-
61
- The filename (`<name>.rb`) defines the step name. The class defined
62
- in the file must be a subclass of `Textus::Step::<Kind>` (e.g.
63
- `Textus::Step::Fetch`) and be wrapped in the `Textus::Step` module.
64
-
65
- ## Example
66
-
67
- ```ruby
68
- module Textus
69
- module Step
70
- class MyFetch < Fetch
71
- def call(config:, args:, caps:, **)
72
- { content: { "foo" => "bar" } }
73
- end
74
- end
75
- end
76
- end
77
- ```
78
-
79
- Events: :fetch, :transform, :validate (rpc — return value used)
80
- :entry_written, :entry_deleted, :entry_fetched, :entry_renamed,
81
- :entry_produced, :produce_failed,
82
- :proposal_accepted, :proposal_rejected,
83
- :entry_published, :store_loaded, :session_opened,
84
- :entry_fetch_started, :entry_fetch_failed (pub-sub — return discarded)
85
-
86
- See SPEC.md §5.10 for the full table.
87
- MD
88
-
89
38
  AGENT_ENTRIES = <<~YAML.gsub(/^/, " ")
90
39
  - { key: knowledge.project, path: data/knowledge/project.md, lane: knowledge, schema: project, owner: human:self, kind: leaf }
91
40
  - { key: knowledge.runbooks, path: data/knowledge/runbooks, lane: knowledge, schema: runbook, owner: human:self, nested: true, kind: nested }
@@ -95,12 +44,6 @@ module Textus
95
44
  publish:
96
45
  - { to: CLAUDE.md, template: orientation.mustache, inject_boot: true }
97
46
  - { to: AGENTS.md, template: orientation.mustache, inject_boot: true }
98
- source:
99
- from: derive
100
- select:
101
- - knowledge.project
102
- - knowledge.runbooks
103
- transform: orientation
104
47
  kind: produced
105
48
  YAML
106
49
 
@@ -108,7 +51,6 @@ module Textus
108
51
  check_target!(target_root)
109
52
  scaffold_dir = File.expand_path("init/templates", __dir__)
110
53
  create_directories(target_root)
111
- write_steps_readme(target_root, scaffold_dir)
112
54
  write_manifest(target_root, with_agent:)
113
55
  mcp_status = scaffold_agent(target_root, scaffold_dir, with_agent:)
114
56
  setup_state_dirs(target_root)
@@ -120,26 +62,22 @@ module Textus
120
62
  raise UsageError.new(".textus/ already exists at #{target_root}") if File.directory?(target_root)
121
63
  end
122
64
 
65
+ ASSET_ZONES = %w[raw].freeze
66
+
123
67
  def self.create_directories(target_root)
124
68
  FileUtils.mkdir_p(File.join(target_root, "schemas"))
125
69
  FileUtils.mkdir_p(File.join(target_root, "templates"))
126
- FileUtils.mkdir_p(File.join(target_root, "steps/fetch"))
127
- FileUtils.mkdir_p(File.join(target_root, "steps/transform"))
128
- FileUtils.mkdir_p(File.join(target_root, "steps/validate"))
129
- FileUtils.mkdir_p(File.join(target_root, "steps/observe"))
70
+ FileUtils.mkdir_p(File.join(target_root, "workflows"))
130
71
  ZONES.each do |z|
131
72
  dir = File.join(target_root, "data", z)
132
73
  FileUtils.mkdir_p(dir)
133
74
  File.write(File.join(dir, ".gitkeep"), "")
134
75
  end
135
- end
136
-
137
- def self.write_steps_readme(target_root, scaffold_dir)
138
- File.write(File.join(target_root, "steps/README.md"), STEPS_README)
139
- File.write(
140
- File.join(target_root, "steps/fetch/machine-intake.rb"),
141
- File.read(File.join(scaffold_dir, "machine_intake.rb")),
142
- )
76
+ ASSET_ZONES.each do |z|
77
+ dir = File.join(target_root, "assets", z)
78
+ FileUtils.mkdir_p(dir)
79
+ File.write(File.join(dir, ".gitkeep"), "")
80
+ end
143
81
  end
144
82
 
145
83
  def self.write_manifest(target_root, with_agent:)
@@ -184,7 +122,6 @@ module Textus
184
122
  "project.schema.yaml" => File.join("schemas", "project.yaml"),
185
123
  "runbook.schema.yaml" => File.join("schemas", "runbook.yaml"),
186
124
  "orientation.mustache" => File.join("templates", "orientation.mustache"),
187
- "orientation_reducer.rb" => File.join("steps/transform", "orientation.rb"),
188
125
  }.each do |src, dest|
189
126
  File.write(File.join(target_root, dest), File.read(File.join(scaffold_dir, src)))
190
127
  end
@@ -0,0 +1,23 @@
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