textus 0.3.0 → 0.5.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 +79 -1
- data/README.md +22 -18
- data/SPEC.md +49 -35
- data/docs/architecture.md +63 -28
- data/lib/textus/audit_log.rb +46 -11
- data/lib/textus/builder.rb +3 -3
- data/lib/textus/{builtin_fetchers.rb → builtin_actions.rb} +16 -11
- data/lib/textus/cli/accept.rb +13 -0
- data/lib/textus/cli/action.rb +51 -0
- data/lib/textus/cli/build.rb +11 -0
- data/lib/textus/cli/delete.rb +14 -0
- data/lib/textus/cli/deprecated_alias.rb +31 -0
- data/lib/textus/cli/deps.rb +10 -0
- data/lib/textus/cli/doctor.rb +13 -0
- data/lib/textus/cli/extension_group.rb +9 -0
- data/lib/textus/cli/extensions.rb +49 -0
- data/lib/textus/cli/get.rb +10 -0
- data/lib/textus/cli/group.rb +51 -0
- data/lib/textus/cli/init.rb +12 -0
- data/lib/textus/cli/intro.rb +9 -0
- data/lib/textus/cli/key_group.rb +10 -0
- data/lib/textus/cli/list.rb +12 -0
- data/lib/textus/cli/migrate.rb +41 -0
- data/lib/textus/cli/migrate_keys.rb +19 -0
- data/lib/textus/cli/mv.rb +20 -0
- data/lib/textus/cli/published.rb +9 -0
- data/lib/textus/cli/put.rb +48 -0
- data/lib/textus/cli/rdeps.rb +10 -0
- data/lib/textus/cli/refresh.rb +13 -0
- data/lib/textus/cli/schema.rb +10 -0
- data/lib/textus/cli/schema_diff.rb +15 -0
- data/lib/textus/cli/schema_group.rb +33 -0
- data/lib/textus/cli/schema_init.rb +19 -0
- data/lib/textus/cli/schema_migrate.rb +19 -0
- data/lib/textus/cli/stale.rb +12 -0
- data/lib/textus/cli/uid.rb +15 -0
- data/lib/textus/cli/verb.rb +62 -0
- data/lib/textus/cli/where.rb +10 -0
- data/lib/textus/cli.rb +65 -347
- data/lib/textus/doctor.rb +103 -32
- data/lib/textus/entry/json.rb +6 -4
- data/lib/textus/entry/markdown.rb +4 -4
- data/lib/textus/entry/text.rb +3 -3
- data/lib/textus/entry/yaml.rb +6 -4
- data/lib/textus/entry.rb +2 -2
- data/lib/textus/errors.rb +2 -2
- data/lib/textus/extension_registry.rb +22 -9
- data/lib/textus/extensions.rb +6 -2
- data/lib/textus/init.rb +6 -5
- data/lib/textus/intro.rb +11 -9
- data/lib/textus/manifest.rb +11 -215
- data/lib/textus/manifest_entry.rb +185 -0
- data/lib/textus/migrate_v2.rb +27 -0
- data/lib/textus/projection.rb +1 -1
- data/lib/textus/proposal.rb +3 -3
- data/lib/textus/refresh.rb +21 -20
- data/lib/textus/schema_tools.rb +8 -8
- data/lib/textus/store/events.rb +31 -0
- data/lib/textus/store/mover.rb +118 -0
- data/lib/textus/store/staleness.rb +142 -0
- data/lib/textus/store/validator.rb +53 -0
- data/lib/textus/store.rb +50 -355
- data/lib/textus/store_view.rb +11 -2
- data/lib/textus/version.rb +2 -2
- data/lib/textus.rb +39 -1
- metadata +39 -2
data/lib/textus/doctor.rb
CHANGED
|
@@ -1,25 +1,32 @@
|
|
|
1
1
|
require "digest"
|
|
2
2
|
require "json"
|
|
3
|
+
require "timeout"
|
|
3
4
|
|
|
4
5
|
module Textus
|
|
5
6
|
# Health check for a Textus store. Returns a JSON-friendly Hash envelope
|
|
6
7
|
# with an `issues` array and a summary. Each issue is a Hash with
|
|
7
8
|
# `code`, `level`, `subject`, `message`, and optionally `fix`.
|
|
8
|
-
module Doctor
|
|
9
|
+
module Doctor # rubocop:disable Metrics/ModuleLength -- 9 built-in checks + extension dispatch
|
|
9
10
|
LEVELS = %w[error warning info].freeze
|
|
11
|
+
DOCTOR_CHECK_TIMEOUT_SECONDS = 2
|
|
12
|
+
ALL_CHECKS = %w[
|
|
13
|
+
manifest_files schemas templates extensions illegal_keys
|
|
14
|
+
sentinels audit_log unowned_schema_fields schema_violations
|
|
15
|
+
].freeze
|
|
10
16
|
|
|
11
17
|
module_function
|
|
12
18
|
|
|
13
|
-
def run(store)
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
issues
|
|
19
|
+
def run(store, checks: nil)
|
|
20
|
+
selected = checks ? Array(checks).map(&:to_s) : ALL_CHECKS
|
|
21
|
+
unknown = selected - ALL_CHECKS
|
|
22
|
+
unless unknown.empty?
|
|
23
|
+
raise UsageError.new(
|
|
24
|
+
"unknown doctor check: #{unknown.first}. Valid checks: #{ALL_CHECKS.join(", ")}",
|
|
25
|
+
)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
issues = run_builtin_checks(store, selected)
|
|
29
|
+
issues.concat(run_registered_checks(store)) # extensions always run
|
|
23
30
|
|
|
24
31
|
summary = LEVELS.to_h { |l| [l, issues.count { |i| i["level"] == l }] }
|
|
25
32
|
{
|
|
@@ -30,6 +37,20 @@ module Textus
|
|
|
30
37
|
}
|
|
31
38
|
end
|
|
32
39
|
|
|
40
|
+
def run_builtin_checks(store, selected)
|
|
41
|
+
issues = []
|
|
42
|
+
issues.concat(check_manifest_files(store)) if selected.include?("manifest_files")
|
|
43
|
+
issues.concat(check_schemas(store)) if selected.include?("schemas")
|
|
44
|
+
issues.concat(check_templates(store)) if selected.include?("templates")
|
|
45
|
+
issues.concat(check_extensions(store)) if selected.include?("extensions")
|
|
46
|
+
issues.concat(check_illegal_keys(store)) if selected.include?("illegal_keys")
|
|
47
|
+
issues.concat(check_sentinels(store)) if selected.include?("sentinels")
|
|
48
|
+
issues.concat(check_audit_log(store)) if selected.include?("audit_log")
|
|
49
|
+
issues.concat(check_unowned_schema_fields(store)) if selected.include?("unowned_schema_fields")
|
|
50
|
+
issues.concat(check_schema_violations(store)) if selected.include?("schema_violations")
|
|
51
|
+
issues
|
|
52
|
+
end
|
|
53
|
+
|
|
33
54
|
# --- Checks -----------------------------------------------------------
|
|
34
55
|
|
|
35
56
|
def check_manifest_files(store)
|
|
@@ -196,32 +217,30 @@ module Textus
|
|
|
196
217
|
stripped = line.chomp
|
|
197
218
|
next if stripped.empty?
|
|
198
219
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
220
|
+
if stripped.start_with?("{")
|
|
221
|
+
begin
|
|
222
|
+
JSON.parse(stripped)
|
|
223
|
+
rescue JSON::ParserError => e
|
|
224
|
+
out << {
|
|
225
|
+
"code" => "audit.parse_error",
|
|
226
|
+
"level" => "warning",
|
|
227
|
+
"subject" => "#{path}:#{lineno}",
|
|
228
|
+
"message" => "audit log line #{lineno} is invalid JSON: #{e.message}",
|
|
229
|
+
"fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
|
|
230
|
+
}
|
|
231
|
+
end
|
|
232
|
+
else
|
|
233
|
+
# Legacy TSV: minimum 6 fields. Removed in 0.6.
|
|
234
|
+
fields = stripped.split("\t")
|
|
235
|
+
next if fields.length >= 6
|
|
215
236
|
|
|
216
|
-
begin
|
|
217
|
-
JSON.parse(extras)
|
|
218
|
-
rescue JSON::ParserError => e
|
|
219
237
|
out << {
|
|
220
238
|
"code" => "audit.parse_error",
|
|
221
239
|
"level" => "warning",
|
|
222
240
|
"subject" => "#{path}:#{lineno}",
|
|
223
|
-
"message" => "audit log line #{lineno}
|
|
224
|
-
|
|
241
|
+
"message" => "audit log line #{lineno} has #{fields.length} fields " \
|
|
242
|
+
"(expected >=6 for legacy TSV; consider migrating to NDJSON)",
|
|
243
|
+
"fix" => "inspect #{path} at line #{lineno} and remove the corrupted row",
|
|
225
244
|
}
|
|
226
245
|
end
|
|
227
246
|
end
|
|
@@ -255,6 +274,58 @@ module Textus
|
|
|
255
274
|
out
|
|
256
275
|
end
|
|
257
276
|
|
|
277
|
+
def check_schema_violations(store)
|
|
278
|
+
res = store.validate_all
|
|
279
|
+
res["violations"].map do |v|
|
|
280
|
+
fix = v["expected"] &&
|
|
281
|
+
"field '#{v["field"]}' should be written by '#{v["expected"]}' (last writer: #{v["last_writer"]})"
|
|
282
|
+
{
|
|
283
|
+
"code" => v["code"],
|
|
284
|
+
"level" => "error",
|
|
285
|
+
"subject" => v["key"],
|
|
286
|
+
"message" => v["message"] || "#{v["code"]} on #{v["key"]}",
|
|
287
|
+
"fix" => fix,
|
|
288
|
+
}.compact
|
|
289
|
+
end
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def run_registered_checks(store)
|
|
293
|
+
out = []
|
|
294
|
+
view = StoreView.new(store)
|
|
295
|
+
store.registry.doctor_check_names.each do |name|
|
|
296
|
+
callable = store.registry.doctor_check(name)
|
|
297
|
+
begin
|
|
298
|
+
result = Timeout.timeout(DOCTOR_CHECK_TIMEOUT_SECONDS) { callable.call(store: view) }
|
|
299
|
+
if result.is_a?(Array)
|
|
300
|
+
out.concat(result.map { |h| h.transform_keys(&:to_s) })
|
|
301
|
+
else
|
|
302
|
+
out << fail_issue(name, code: "doctor_check.bad_return",
|
|
303
|
+
message: "doctor_check '#{name}' returned #{result.class} (expected Array)",
|
|
304
|
+
fix: "return an array of issue hashes from the doctor_check block")
|
|
305
|
+
end
|
|
306
|
+
rescue Timeout::Error
|
|
307
|
+
out << fail_issue(name, code: "doctor_check.timeout",
|
|
308
|
+
message: "doctor_check '#{name}' exceeded #{DOCTOR_CHECK_TIMEOUT_SECONDS}s",
|
|
309
|
+
fix: "shorten the check or split it into smaller checks")
|
|
310
|
+
rescue StandardError => e
|
|
311
|
+
out << fail_issue(name, code: "doctor_check.failed",
|
|
312
|
+
message: "#{e.class}: #{e.message}",
|
|
313
|
+
fix: "fix the doctor_check block in .textus/extensions/")
|
|
314
|
+
end
|
|
315
|
+
end
|
|
316
|
+
out
|
|
317
|
+
end
|
|
318
|
+
|
|
319
|
+
def fail_issue(name, code:, message:, fix:)
|
|
320
|
+
{
|
|
321
|
+
"code" => code,
|
|
322
|
+
"level" => "error",
|
|
323
|
+
"subject" => name.to_s,
|
|
324
|
+
"message" => message,
|
|
325
|
+
"fix" => fix,
|
|
326
|
+
}
|
|
327
|
+
end
|
|
328
|
+
|
|
258
329
|
# --- Helpers ----------------------------------------------------------
|
|
259
330
|
|
|
260
331
|
def leaf_path_for(store, entry)
|
data/lib/textus/entry/json.rb
CHANGED
|
@@ -17,13 +17,15 @@ module Textus
|
|
|
17
17
|
|
|
18
18
|
meta = parsed["_meta"]
|
|
19
19
|
fm = meta.is_a?(Hash) ? meta : {}
|
|
20
|
-
|
|
20
|
+
content_without_meta = parsed.except("_meta")
|
|
21
|
+
{ "_meta" => fm, "body" => raw, "content" => content_without_meta }
|
|
21
22
|
end
|
|
22
23
|
|
|
23
|
-
def self.serialize(
|
|
24
|
-
_ = frontmatter
|
|
24
|
+
def self.serialize(meta:, body:, content: nil)
|
|
25
25
|
if content.is_a?(Hash)
|
|
26
|
-
|
|
26
|
+
# Re-inject _meta as the first key so on-disk shape is stable.
|
|
27
|
+
on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
|
|
28
|
+
out = ::JSON.pretty_generate(on_disk)
|
|
27
29
|
out += "\n" unless out.end_with?("\n")
|
|
28
30
|
out
|
|
29
31
|
elsif body && !body.to_s.empty?
|
|
@@ -7,7 +7,7 @@ module Textus
|
|
|
7
7
|
def self.parse(raw, path: nil)
|
|
8
8
|
raw = raw.dup.force_encoding(Encoding::UTF_8)
|
|
9
9
|
raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
|
|
10
|
-
return { "
|
|
10
|
+
return { "_meta" => {}, "body" => raw, "content" => nil } unless raw.start_with?("---\n") || raw.start_with?("---\r\n")
|
|
11
11
|
|
|
12
12
|
lines = raw.split(/\r?\n/, -1)
|
|
13
13
|
close_idx = lines[1..].index("---")
|
|
@@ -22,12 +22,12 @@ module Textus
|
|
|
22
22
|
raise BadFrontmatter.new(path, "YAML parse failed: #{e.message}")
|
|
23
23
|
end
|
|
24
24
|
fm = {} unless fm.is_a?(Hash)
|
|
25
|
-
{ "
|
|
25
|
+
{ "_meta" => fm, "body" => body, "content" => nil }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
|
-
def self.serialize(
|
|
28
|
+
def self.serialize(meta:, body:, content: nil)
|
|
29
29
|
_ = content # markdown ignores content
|
|
30
|
-
fm_yaml =
|
|
30
|
+
fm_yaml = meta.empty? ? "" : ::YAML.dump(meta).sub(/\A---\n/, "")
|
|
31
31
|
body = body.to_s
|
|
32
32
|
body += "\n" unless body.empty? || body.end_with?("\n")
|
|
33
33
|
"---\n#{fm_yaml}---\n#{body}"
|
data/lib/textus/entry/text.rb
CHANGED
|
@@ -6,11 +6,11 @@ module Textus
|
|
|
6
6
|
raw = raw.dup.force_encoding(Encoding::UTF_8)
|
|
7
7
|
raise BadFrontmatter.new(path, "entry is not valid UTF-8") unless raw.valid_encoding?
|
|
8
8
|
|
|
9
|
-
{ "
|
|
9
|
+
{ "_meta" => {}, "body" => raw, "content" => nil }
|
|
10
10
|
end
|
|
11
11
|
|
|
12
|
-
def self.serialize(
|
|
13
|
-
_ =
|
|
12
|
+
def self.serialize(meta:, body:, content: nil)
|
|
13
|
+
_ = meta
|
|
14
14
|
_ = content
|
|
15
15
|
b = body.to_s
|
|
16
16
|
b += "\n" unless b.empty? || b.end_with?("\n")
|
data/lib/textus/entry/yaml.rb
CHANGED
|
@@ -17,13 +17,15 @@ module Textus
|
|
|
17
17
|
|
|
18
18
|
meta = parsed["_meta"]
|
|
19
19
|
fm = meta.is_a?(Hash) ? meta : {}
|
|
20
|
-
|
|
20
|
+
content_without_meta = parsed.except("_meta")
|
|
21
|
+
{ "_meta" => fm, "body" => raw, "content" => content_without_meta }
|
|
21
22
|
end
|
|
22
23
|
|
|
23
|
-
def self.serialize(
|
|
24
|
-
_ = frontmatter
|
|
24
|
+
def self.serialize(meta:, body:, content: nil)
|
|
25
25
|
if content.is_a?(Hash)
|
|
26
|
-
|
|
26
|
+
# Re-inject _meta as the first key so on-disk shape is stable.
|
|
27
|
+
on_disk = meta && !meta.empty? ? { "_meta" => meta }.merge(content) : content
|
|
28
|
+
::YAML.dump(on_disk).sub(/\A---\n/, "")
|
|
27
29
|
elsif body && !body.to_s.empty?
|
|
28
30
|
b = body.to_s
|
|
29
31
|
b += "\n" unless b.end_with?("\n")
|
data/lib/textus/entry.rb
CHANGED
|
@@ -23,8 +23,8 @@ module Textus
|
|
|
23
23
|
for_format(format).parse(raw, path: path)
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def self.serialize(
|
|
27
|
-
for_format(format).serialize(
|
|
26
|
+
def self.serialize(meta: {}, body: "", content: nil, format: "markdown")
|
|
27
|
+
for_format(format).serialize(meta: meta, body: body, content: content)
|
|
28
28
|
end
|
|
29
29
|
end
|
|
30
30
|
end
|
data/lib/textus/errors.rb
CHANGED
|
@@ -51,10 +51,10 @@ module Textus
|
|
|
51
51
|
private
|
|
52
52
|
|
|
53
53
|
def default_hint_for(path, m)
|
|
54
|
-
if m.is_a?(String) && (match = m.match(/
|
|
54
|
+
if m.is_a?(String) && (match = m.match(/name '([^']+)' does not match basename '([^']+)'/))
|
|
55
55
|
name, basename = match.captures
|
|
56
56
|
ext = File.extname(path)
|
|
57
|
-
"rename the file to '#{name}#{ext}' or change
|
|
57
|
+
"rename the file to '#{name}#{ext}' or change _meta.name to '#{basename}'"
|
|
58
58
|
else
|
|
59
59
|
"open #{path} and check the YAML frontmatter for syntax errors"
|
|
60
60
|
end
|
|
@@ -3,16 +3,17 @@ module Textus
|
|
|
3
3
|
EVENTS = %i[put delete refresh build accept].freeze
|
|
4
4
|
|
|
5
5
|
def initialize
|
|
6
|
-
@
|
|
6
|
+
@actions = {}
|
|
7
7
|
@reducers = {}
|
|
8
8
|
@hooks = {}
|
|
9
|
+
@doctor_checks = {}
|
|
9
10
|
end
|
|
10
11
|
|
|
11
|
-
def
|
|
12
|
+
def register_action(name, &blk)
|
|
12
13
|
name = name.to_sym
|
|
13
|
-
raise UsageError.new("
|
|
14
|
+
raise UsageError.new("action '#{name}' already registered") if @actions.key?(name)
|
|
14
15
|
|
|
15
|
-
@
|
|
16
|
+
@actions[name] = blk
|
|
16
17
|
end
|
|
17
18
|
|
|
18
19
|
def register_reducer(name, &blk)
|
|
@@ -29,8 +30,15 @@ module Textus
|
|
|
29
30
|
(@hooks[event] ||= []) << { name: name.to_sym, callable: blk }
|
|
30
31
|
end
|
|
31
32
|
|
|
32
|
-
def
|
|
33
|
-
|
|
33
|
+
def register_doctor_check(name, &blk)
|
|
34
|
+
name = name.to_sym
|
|
35
|
+
raise UsageError.new("doctor_check '#{name}' already registered") if @doctor_checks.key?(name)
|
|
36
|
+
|
|
37
|
+
@doctor_checks[name] = blk
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def action(name)
|
|
41
|
+
@actions[name.to_sym] or raise UsageError.new("unknown action: #{name}")
|
|
34
42
|
end
|
|
35
43
|
|
|
36
44
|
def reducer(name)
|
|
@@ -41,8 +49,13 @@ module Textus
|
|
|
41
49
|
@hooks[event.to_sym] || []
|
|
42
50
|
end
|
|
43
51
|
|
|
44
|
-
def
|
|
45
|
-
|
|
46
|
-
|
|
52
|
+
def doctor_check(name)
|
|
53
|
+
@doctor_checks[name.to_sym] or raise UsageError.new("unknown doctor_check: #{name}")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def action_names = @actions.keys
|
|
57
|
+
def reducer_names = @reducers.keys
|
|
58
|
+
def hook_events = @hooks.keys
|
|
59
|
+
def doctor_check_names = @doctor_checks.keys
|
|
47
60
|
end
|
|
48
61
|
end
|
data/lib/textus/extensions.rb
CHANGED
|
@@ -15,8 +15,8 @@ module Textus
|
|
|
15
15
|
raise UsageError.new("no active registry; extension code must be loaded by a Store")
|
|
16
16
|
end
|
|
17
17
|
|
|
18
|
-
def self.
|
|
19
|
-
current_registry.
|
|
18
|
+
def self.action(name, &)
|
|
19
|
+
current_registry.register_action(name, &)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def self.reducer(name, &)
|
|
@@ -26,4 +26,8 @@ module Textus
|
|
|
26
26
|
def self.hook(event, name, &)
|
|
27
27
|
current_registry.register_hook(event, name, &)
|
|
28
28
|
end
|
|
29
|
+
|
|
30
|
+
def self.doctor_check(name, &)
|
|
31
|
+
current_registry.register_doctor_check(name, &)
|
|
32
|
+
end
|
|
29
33
|
end
|
data/lib/textus/init.rb
CHANGED
|
@@ -5,7 +5,7 @@ module Textus
|
|
|
5
5
|
ZONES = %w[canon working intake pending derived].freeze
|
|
6
6
|
|
|
7
7
|
DEFAULT_MANIFEST = <<~YAML
|
|
8
|
-
version: textus/
|
|
8
|
+
version: textus/2
|
|
9
9
|
zones:
|
|
10
10
|
- { name: canon, writable_by: [human] }
|
|
11
11
|
- { name: working, writable_by: [human, ai, script] }
|
|
@@ -31,12 +31,13 @@ module Textus
|
|
|
31
31
|
File.write(File.join(target_root, "extensions", "README.md"), <<~MD)
|
|
32
32
|
# Extensions
|
|
33
33
|
|
|
34
|
-
Drop one Ruby file per extension.
|
|
34
|
+
Drop one Ruby file per extension. Four verbs are available:
|
|
35
35
|
|
|
36
36
|
```ruby
|
|
37
|
-
Textus.
|
|
38
|
-
Textus.reducer(:name) { |rows:, config:|
|
|
39
|
-
Textus.hook(:event, :name) { |key:, envelope:,
|
|
37
|
+
Textus.action(:name) { |config:, store:, args:| ... }
|
|
38
|
+
Textus.reducer(:name) { |rows:, config:| ... }
|
|
39
|
+
Textus.hook(:event, :name) { |key:, envelope:, **kw| ... }
|
|
40
|
+
Textus.doctor_check(:name) { |store:| ... }
|
|
40
41
|
```
|
|
41
42
|
|
|
42
43
|
Events: :put, :delete, :refresh, :build, :accept.
|
data/lib/textus/intro.rb
CHANGED
|
@@ -6,14 +6,14 @@ module Textus
|
|
|
6
6
|
#
|
|
7
7
|
# Intro is side-effect-free.
|
|
8
8
|
module Intro
|
|
9
|
-
PROTOCOL_ID =
|
|
9
|
+
PROTOCOL_ID = PROTOCOL
|
|
10
10
|
|
|
11
11
|
# Conventional zone purposes. Unknown zones (declared in the manifest
|
|
12
12
|
# but not listed here) get no `purpose` field.
|
|
13
13
|
ZONE_PURPOSES = {
|
|
14
14
|
"canon" => "slow-changing identity; human-only writes",
|
|
15
15
|
"working" => "active project state; humans, AI, and scripts share this surface",
|
|
16
|
-
"intake" => "declared external inputs; script-refreshed via
|
|
16
|
+
"intake" => "declared external inputs; script-refreshed via actions",
|
|
17
17
|
"pending" => "AI proposals awaiting human accept",
|
|
18
18
|
"derived" => "build-computed outputs; never hand-edited",
|
|
19
19
|
}.freeze
|
|
@@ -22,7 +22,7 @@ module Textus
|
|
|
22
22
|
"human" => "edit files in canon/working zones, then 'textus put KEY --as=human'",
|
|
23
23
|
"ai" => "propose changes by writing 'pending.*' entries with --as=ai and a 'proposal:' frontmatter block; " \
|
|
24
24
|
"a human runs 'textus accept' to apply",
|
|
25
|
-
"script" => "refresh intake entries with 'textus refresh KEY --as=script' (uses the entry's declared
|
|
25
|
+
"script" => "refresh intake entries with 'textus refresh KEY --as=script' (uses the entry's declared action)",
|
|
26
26
|
"build" => "'textus build' computes derived entries from projections; derived files are never hand-edited",
|
|
27
27
|
}.freeze
|
|
28
28
|
|
|
@@ -32,7 +32,7 @@ module Textus
|
|
|
32
32
|
CLI_VERBS = [
|
|
33
33
|
{ "name" => "intro", "summary" => "this output — orientation for agents and tools" },
|
|
34
34
|
{ "name" => "list", "summary" => "enumerate keys (optional --prefix)" },
|
|
35
|
-
{ "name" => "get", "summary" => "read an entry; envelope with
|
|
35
|
+
{ "name" => "get", "summary" => "read an entry; envelope with _meta, body, uid, etag" },
|
|
36
36
|
{ "name" => "where", "summary" => "resolve a key to its zone and path without reading" },
|
|
37
37
|
{ "name" => "schema", "summary" => "field shape for a key family" },
|
|
38
38
|
{ "name" => "put", "summary" => "write an entry; --as=<role>, --stdin payload" },
|
|
@@ -40,11 +40,11 @@ module Textus
|
|
|
40
40
|
{ "name" => "mv", "summary" => "rename a key in place; uid preserved, audit row written" },
|
|
41
41
|
{ "name" => "delete", "summary" => "delete an entry; --as=<role>" },
|
|
42
42
|
{ "name" => "build", "summary" => "materialize derived entries; publish_to and publish_each fan out copies" },
|
|
43
|
-
{ "name" => "refresh", "summary" => "run
|
|
43
|
+
{ "name" => "refresh", "summary" => "run an action for an intake entry" },
|
|
44
44
|
{ "name" => "stale", "summary" => "list derived/intake entries past their freshness check" },
|
|
45
45
|
{ "name" => "doctor", "summary" => "health-check the store (missing schemas, illegal keys, sentinel drift, etc.)" },
|
|
46
46
|
{ "name" => "migrate-keys", "summary" => "rename files whose basenames violate the strict key grammar" },
|
|
47
|
-
{ "name" => "extensions", "summary" => "list registered reducers
|
|
47
|
+
{ "name" => "extensions", "summary" => "list registered actions, reducers, doctor_checks, declared hooks" },
|
|
48
48
|
].freeze
|
|
49
49
|
|
|
50
50
|
def self.run(store)
|
|
@@ -80,7 +80,7 @@ module Textus
|
|
|
80
80
|
"owner" => e.owner,
|
|
81
81
|
"format" => e.format,
|
|
82
82
|
"derived" => derived,
|
|
83
|
-
"intake" => !e.
|
|
83
|
+
"intake" => !e.action.nil?,
|
|
84
84
|
"publish_to" => Array(e.publish_to),
|
|
85
85
|
"publish_each" => e.publish_each,
|
|
86
86
|
}
|
|
@@ -90,13 +90,15 @@ module Textus
|
|
|
90
90
|
def self.extensions_for(store)
|
|
91
91
|
reg = store.registry
|
|
92
92
|
reducers = reg.reducer_names.map(&:to_s).sort
|
|
93
|
-
|
|
93
|
+
actions = reg.action_names.map(&:to_s).sort
|
|
94
|
+
doctor_checks = reg.doctor_check_names.map(&:to_s).sort
|
|
94
95
|
hooks = reg.hook_events.flat_map do |evt|
|
|
95
96
|
reg.hooks(evt).map { |h| { "event" => evt.to_s, "name" => h[:name].to_s } }
|
|
96
97
|
end.sort_by { |h| [h["event"], h["name"]] }
|
|
97
98
|
{
|
|
98
99
|
"reducers" => reducers,
|
|
99
|
-
"
|
|
100
|
+
"actions" => actions,
|
|
101
|
+
"doctor_checks" => doctor_checks,
|
|
100
102
|
"hooks" => hooks,
|
|
101
103
|
}
|
|
102
104
|
end
|