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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +34 -0
- data/SPEC.md +2 -5
- data/docs/architecture/README.md +149 -159
- data/lib/textus/action/accept.rb +0 -9
- data/lib/textus/action/audit.rb +0 -2
- data/lib/textus/action/blame.rb +0 -2
- data/lib/textus/action/boot.rb +2 -11
- data/lib/textus/action/data_mv.rb +1 -3
- data/lib/textus/action/deps.rb +1 -15
- data/lib/textus/action/doctor.rb +0 -2
- data/lib/textus/action/drain.rb +4 -6
- data/lib/textus/action/enqueue.rb +3 -5
- data/lib/textus/action/get.rb +1 -3
- data/lib/textus/action/ingest.rb +135 -0
- data/lib/textus/action/jobs.rb +1 -3
- data/lib/textus/action/key_delete.rb +0 -10
- data/lib/textus/action/key_delete_prefix.rb +1 -3
- data/lib/textus/action/key_mv.rb +1 -15
- data/lib/textus/action/key_mv_prefix.rb +1 -3
- data/lib/textus/action/list.rb +0 -2
- data/lib/textus/action/propose.rb +0 -2
- data/lib/textus/action/published.rb +0 -2
- data/lib/textus/action/pulse.rb +7 -38
- data/lib/textus/action/put.rb +1 -12
- data/lib/textus/action/rdeps.rb +2 -12
- data/lib/textus/action/reject.rb +0 -12
- data/lib/textus/action/rule_explain.rb +0 -4
- data/lib/textus/action/rule_lint.rb +1 -3
- data/lib/textus/action/rule_list.rb +0 -4
- data/lib/textus/action/schema_envelope.rb +0 -2
- data/lib/textus/action/uid.rb +0 -2
- data/lib/textus/action/where.rb +0 -2
- data/lib/textus/action/write_verb.rb +4 -18
- data/lib/textus/boot.rb +33 -109
- data/lib/textus/command.rb +6 -1
- data/lib/textus/container.rb +1 -1
- data/lib/textus/core/freshness/evaluator.rb +18 -34
- data/lib/textus/doctor/check/notebook_sources.rb +50 -0
- data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
- data/lib/textus/doctor/check.rb +0 -1
- data/lib/textus/doctor.rb +2 -42
- data/lib/textus/envelope/reader.rb +46 -0
- data/lib/textus/envelope/writer.rb +209 -0
- data/lib/textus/errors.rb +8 -4
- data/lib/textus/{entry → format}/base.rb +1 -6
- data/lib/textus/{entry → format}/json.rb +1 -4
- data/lib/textus/{entry → format}/markdown.rb +2 -6
- data/lib/textus/{entry → format}/text.rb +2 -6
- data/lib/textus/{entry → format}/yaml.rb +1 -4
- data/lib/textus/{entry.rb → format.rb} +9 -10
- data/lib/textus/gate/auth.rb +58 -12
- data/lib/textus/gate.rb +2 -0
- data/lib/textus/init.rb +12 -75
- data/lib/textus/jobs/base.rb +23 -0
- data/lib/textus/jobs/materialize.rb +20 -0
- data/lib/textus/{background → jobs}/plan.rb +1 -1
- data/lib/textus/jobs/planner.rb +101 -0
- data/lib/textus/jobs/retention.rb +48 -0
- data/lib/textus/jobs/sweep.rb +27 -0
- data/lib/textus/{background → jobs}/worker.rb +2 -2
- data/lib/textus/jobs.rb +15 -0
- data/lib/textus/key/path.rb +1 -1
- data/lib/textus/manifest/entry/base.rb +4 -19
- data/lib/textus/manifest/entry/parser.rb +27 -10
- data/lib/textus/manifest/entry/produced.rb +13 -34
- data/lib/textus/manifest/entry/publish/to_paths.rb +4 -4
- data/lib/textus/manifest/entry/publish.rb +0 -12
- data/lib/textus/manifest/entry/validators/format_matrix.rb +1 -1
- data/lib/textus/manifest/entry/validators.rb +0 -1
- data/lib/textus/manifest/policy/source.rb +14 -62
- data/lib/textus/manifest/policy.rb +2 -5
- data/lib/textus/manifest/resolver.rb +2 -2
- data/lib/textus/manifest/schema/keys.rb +6 -20
- data/lib/textus/manifest/schema/validator.rb +3 -11
- data/lib/textus/manifest/schema/vocabulary.rb +1 -0
- data/lib/textus/ports/{queue → job_store}/job.rb +1 -1
- data/lib/textus/ports/{queue.rb → job_store.rb} +6 -13
- data/lib/textus/produce/engine.rb +50 -0
- data/lib/textus/produce/render.rb +18 -0
- data/lib/textus/session.rb +1 -1
- data/lib/textus/store.rb +1 -10
- data/lib/textus/surfaces/mcp/catalog.rb +1 -1
- data/lib/textus/surfaces/mcp/errors.rb +0 -9
- data/lib/textus/surfaces/mcp/routing.rb +51 -0
- data/lib/textus/surfaces/mcp/server.rb +101 -62
- data/lib/textus/surfaces/role_scope.rb +0 -4
- data/lib/textus/surfaces/watcher.rb +3 -3
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/collector.rb +27 -0
- data/lib/textus/workflow/context.rb +5 -0
- data/lib/textus/workflow/dsl.rb +44 -0
- data/lib/textus/workflow/loader.rb +17 -0
- data/lib/textus/workflow/pattern.rb +18 -0
- data/lib/textus/workflow/registry.rb +21 -0
- data/lib/textus/workflow/runner.rb +84 -0
- data/lib/textus/workflow.rb +28 -0
- data/lib/textus.rb +12 -1
- metadata +33 -61
- data/lib/textus/action/pulse/scanner.rb +0 -118
- data/lib/textus/background/job/base.rb +0 -27
- data/lib/textus/background/job/materialize.rb +0 -31
- data/lib/textus/background/job/refresh.rb +0 -22
- data/lib/textus/background/job/sweep.rb +0 -31
- data/lib/textus/background/job.rb +0 -19
- data/lib/textus/background/planner/plan.rb +0 -113
- data/lib/textus/background/retention/apply.rb +0 -50
- data/lib/textus/doctor/check/handler_permit.rb +0 -34
- data/lib/textus/doctor/check/hooks.rb +0 -25
- data/lib/textus/doctor/check/intake_registration.rb +0 -46
- data/lib/textus/envelope/io/reader.rb +0 -48
- data/lib/textus/envelope/io/writer.rb +0 -211
- data/lib/textus/events.rb +0 -21
- data/lib/textus/init/templates/machine_intake.rb +0 -49
- data/lib/textus/init/templates/orientation_reducer.rb +0 -21
- data/lib/textus/manifest/entry/validators/events.rb +0 -22
- data/lib/textus/manifest/policy/handler_permit.rb +0 -17
- data/lib/textus/pipeline/acquire/handler.rb +0 -29
- data/lib/textus/pipeline/acquire/intake.rb +0 -132
- data/lib/textus/pipeline/acquire/projection.rb +0 -129
- data/lib/textus/pipeline/acquire/serializer/json.rb +0 -31
- data/lib/textus/pipeline/acquire/serializer/text.rb +0 -16
- data/lib/textus/pipeline/acquire/serializer/yaml.rb +0 -31
- data/lib/textus/pipeline/acquire/serializer.rb +0 -17
- data/lib/textus/pipeline/engine.rb +0 -97
- data/lib/textus/pipeline/render.rb +0 -25
- data/lib/textus/ports/audit_subscriber.rb +0 -42
- data/lib/textus/projection.rb +0 -103
- data/lib/textus/step/base.rb +0 -35
- data/lib/textus/step/builtin/csv_fetch.rb +0 -19
- data/lib/textus/step/builtin/ical_events_fetch.rb +0 -30
- data/lib/textus/step/builtin/json_fetch.rb +0 -18
- data/lib/textus/step/builtin/markdown_links_fetch.rb +0 -20
- data/lib/textus/step/builtin/rss_fetch.rb +0 -26
- data/lib/textus/step/builtin.rb +0 -22
- data/lib/textus/step/catalog.rb +0 -37
- data/lib/textus/step/context.rb +0 -74
- data/lib/textus/step/discovery.rb +0 -24
- data/lib/textus/step/error_log.rb +0 -32
- data/lib/textus/step/event_bus.rb +0 -113
- data/lib/textus/step/fetch.rb +0 -13
- data/lib/textus/step/fire_report.rb +0 -23
- data/lib/textus/step/loader.rb +0 -108
- data/lib/textus/step/observe.rb +0 -31
- data/lib/textus/step/registry_store.rb +0 -66
- data/lib/textus/step/signature.rb +0 -31
- data/lib/textus/step/transform.rb +0 -12
- data/lib/textus/step/validate.rb +0 -11
- 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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
37
|
+
Format.for(format).serialize(meta: meta, body: body, content: content)
|
|
39
38
|
end
|
|
40
39
|
end
|
|
41
40
|
end
|
data/lib/textus/gate/auth.rb
CHANGED
|
@@ -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[
|
|
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"
|
|
108
|
-
when "author_held"
|
|
109
|
-
when "target_is_canon"
|
|
110
|
-
when "etag_match"
|
|
111
|
-
when "schema_valid"
|
|
112
|
-
when "fresh_within"
|
|
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,
|
|
25
|
-
#
|
|
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:
|
|
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, "
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|