textus 0.53.0 → 0.54.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +52 -0
- data/README.md +53 -73
- 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 +40 -109
- data/lib/textus/command.rb +6 -1
- data/lib/textus/container.rb +21 -5
- data/lib/textus/core/freshness/evaluator.rb +18 -34
- data/lib/textus/core/freshness/verdict.rb +1 -1
- data/lib/textus/doctor/check/notebook_sources.rb +50 -0
- data/lib/textus/doctor/check/protocol_version.rb +6 -6
- data/lib/textus/doctor/check/raw_asset_paths.rb +50 -0
- data/lib/textus/doctor/check/stale_reviewed_stamp.rb +54 -0
- data/lib/textus/doctor/check.rb +0 -1
- data/lib/textus/doctor.rb +3 -42
- data/lib/textus/envelope/reader.rb +46 -0
- data/lib/textus/envelope/writer.rb +209 -0
- data/lib/textus/envelope.rb +19 -6
- data/lib/textus/errors.rb +9 -5
- 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 +16 -79
- 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/contract.rb +61 -0
- data/lib/textus/manifest/schema/keys.rb +6 -20
- data/lib/textus/manifest/schema/semantics.rb +232 -0
- data/lib/textus/manifest/schema/validator.rb +24 -302
- data/lib/textus/manifest/schema/vocabulary.rb +1 -0
- data/lib/textus/manifest/schema.rb +1 -4
- data/lib/textus/manifest.rb +1 -3
- 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 +20 -0
- data/lib/textus/session.rb +15 -8
- data/lib/textus/store.rb +5 -14
- data/lib/textus/surfaces/mcp/catalog.rb +23 -11
- data/lib/textus/surfaces/mcp/errors.rb +0 -9
- data/lib/textus/surfaces/mcp/server.rb +87 -110
- data/lib/textus/surfaces/role_scope.rb +0 -4
- data/lib/textus/surfaces/watcher.rb +3 -3
- data/lib/textus/types.rb +15 -0
- data/lib/textus/version.rb +2 -2
- 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 +99 -0
- data/lib/textus/workflow.rb +28 -0
- data/lib/textus.rb +12 -1
- metadata +67 -66
- 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
- data/lib/textus/surfaces/mcp/session.rb +0 -9
- data/lib/textus/surfaces/mcp/tool_schemas.rb +0 -17
data/lib/textus/init.rb
CHANGED
|
@@ -3,10 +3,10 @@ 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
|
-
version: textus/
|
|
9
|
+
version: textus/4
|
|
10
10
|
roles:
|
|
11
11
|
- { name: human, can: [author, propose] }
|
|
12
12
|
- { name: agent, can: [propose, keep] }
|
|
@@ -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 }
|
|
@@ -93,14 +42,8 @@ module Textus
|
|
|
93
42
|
path: data/artifacts/derived/orientation.json
|
|
94
43
|
lane: artifacts
|
|
95
44
|
publish:
|
|
96
|
-
- { to: CLAUDE.md, template: orientation.
|
|
97
|
-
- { to: AGENTS.md, template: orientation.
|
|
98
|
-
source:
|
|
99
|
-
from: derive
|
|
100
|
-
select:
|
|
101
|
-
- knowledge.project
|
|
102
|
-
- knowledge.runbooks
|
|
103
|
-
transform: orientation
|
|
45
|
+
- { to: CLAUDE.md, template: orientation.erb, inject_boot: true }
|
|
46
|
+
- { to: AGENTS.md, template: orientation.erb, inject_boot: true }
|
|
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:)
|
|
@@ -183,8 +121,7 @@ module Textus
|
|
|
183
121
|
{
|
|
184
122
|
"project.schema.yaml" => File.join("schemas", "project.yaml"),
|
|
185
123
|
"runbook.schema.yaml" => File.join("schemas", "runbook.yaml"),
|
|
186
|
-
"orientation.
|
|
187
|
-
"orientation_reducer.rb" => File.join("steps/transform", "orientation.rb"),
|
|
124
|
+
"orientation.erb" => File.join("templates", "orientation.erb"),
|
|
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
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
|
@@ -0,0 +1,48 @@
|
|
|
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
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Jobs
|
|
3
3
|
class Worker
|
|
4
4
|
Summary = Struct.new(:completed, :failed, keyword_init: true)
|
|
5
5
|
|
|
@@ -46,7 +46,7 @@ module Textus
|
|
|
46
46
|
|
|
47
47
|
def run_one(leased)
|
|
48
48
|
job = leased.job
|
|
49
|
-
klass = Textus::
|
|
49
|
+
klass = Textus::Jobs.fetch(job.type)
|
|
50
50
|
action = if klass.instance_method(:initialize).parameters.any?
|
|
51
51
|
klass.new(**job.args.transform_keys(&:to_sym))
|
|
52
52
|
else
|
data/lib/textus/jobs.rb
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Jobs
|
|
3
|
+
@registry = {}
|
|
4
|
+
|
|
5
|
+
def self.registry = @registry
|
|
6
|
+
|
|
7
|
+
def self.register(klass)
|
|
8
|
+
@registry[klass::TYPE] = klass if klass.const_defined?(:TYPE, false)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def self.fetch(type)
|
|
12
|
+
@registry.fetch(type) { raise Textus::UsageError.new("unknown job type: #{type}") }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
data/lib/textus/key/path.rb
CHANGED
|
@@ -9,7 +9,7 @@ module Textus
|
|
|
9
9
|
# callers historically passed the whole Manifest but should now pass
|
|
10
10
|
# `manifest.data`.
|
|
11
11
|
def self.resolve(data, mentry)
|
|
12
|
-
primary_ext =
|
|
12
|
+
primary_ext = Format.for(mentry.format).extensions.first
|
|
13
13
|
rel_path = normalize_relative_path(mentry.path)
|
|
14
14
|
if File.extname(mentry.path) == ""
|
|
15
15
|
File.join(data.root, rel_path + primary_ext)
|
|
@@ -62,22 +62,15 @@ module Textus
|
|
|
62
62
|
def ignored?(_rel_path) = false
|
|
63
63
|
|
|
64
64
|
# Minimal context object passed into entry `publish_via` hooks.
|
|
65
|
-
# Everything beyond the three primitives is derived.
|
|
66
|
-
# instances are frozen, so we recompute per-call rather than
|
|
67
|
-
# memoizing — RoleScope/Step::Context construction is cheap.
|
|
65
|
+
# Everything beyond the three primitives is derived.
|
|
68
66
|
PublishContext = ::Data.define(:container, :call, :reader) do
|
|
69
67
|
def manifest = container.manifest
|
|
70
68
|
def root = container.root
|
|
71
69
|
def repo_root = File.dirname(container.root)
|
|
72
|
-
def steps = container.steps
|
|
73
70
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def emit(event, **payload)
|
|
79
|
-
steps.publish(event, ctx: hook_context, **payload)
|
|
80
|
-
end
|
|
71
|
+
# No-op: event bus removed in workflow redesign; callers that fire
|
|
72
|
+
# :entry_published / :entry_produced remain unchanged in the source.
|
|
73
|
+
def emit(_event, **_payload) = nil
|
|
81
74
|
|
|
82
75
|
# Read a named template from the store's templates/ directory.
|
|
83
76
|
# Raises TemplateError when the file doesn't exist.
|
|
@@ -91,14 +84,6 @@ module Textus
|
|
|
91
84
|
end
|
|
92
85
|
File.read(path)
|
|
93
86
|
end
|
|
94
|
-
|
|
95
|
-
private
|
|
96
|
-
|
|
97
|
-
def scope_for_hooks
|
|
98
|
-
Textus::Surfaces::RoleScope.new(
|
|
99
|
-
container: container, role: call.role, dry_run: call.dry_run,
|
|
100
|
-
)
|
|
101
|
-
end
|
|
102
87
|
end
|
|
103
88
|
|
|
104
89
|
# ADR 0049: an entry resolves, once, to one Publish::* mode that owns its
|
|
@@ -4,7 +4,6 @@ module Textus
|
|
|
4
4
|
module Parser
|
|
5
5
|
def self.call(raw)
|
|
6
6
|
key = raw["key"] or raise UsageError.new("manifest entry missing key")
|
|
7
|
-
path = raw["path"] or raise UsageError.new("manifest entry '#{key}' missing path")
|
|
8
7
|
lane = raw["lane"] or raise UsageError.new("manifest entry '#{key}' missing lane")
|
|
9
8
|
|
|
10
9
|
raw_kind = raw["kind"] or raise BadManifest.new("entry '#{key}' missing required `kind:` (#{Entry::REGISTRY.keys.join("|")})")
|
|
@@ -15,7 +14,10 @@ module Textus
|
|
|
15
14
|
"the produce method is `source.from` (#{kind == :intake ? "handler" : "project|command"})",
|
|
16
15
|
)
|
|
17
16
|
end
|
|
18
|
-
|
|
17
|
+
|
|
18
|
+
explicit_path = raw["path"]
|
|
19
|
+
format = resolve_format(raw, explicit_path)
|
|
20
|
+
path = explicit_path || derive_path(key, kind, format)
|
|
19
21
|
|
|
20
22
|
common = {
|
|
21
23
|
raw: raw,
|
|
@@ -30,11 +32,11 @@ module Textus
|
|
|
30
32
|
klass.from_raw(common, raw)
|
|
31
33
|
end
|
|
32
34
|
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
def self.parse_source(raw,
|
|
36
|
-
block = raw["source"]
|
|
37
|
-
|
|
35
|
+
# Parse the optional `source:` block. Returns nil when absent (workflow
|
|
36
|
+
# produced entries register their produce logic in .textus/workflows/).
|
|
37
|
+
def self.parse_source(raw, _key)
|
|
38
|
+
block = raw["source"]
|
|
39
|
+
return nil if block.nil?
|
|
38
40
|
|
|
39
41
|
Textus::Manifest::Policy::Source.new(block)
|
|
40
42
|
end
|
|
@@ -57,8 +59,11 @@ module Textus
|
|
|
57
59
|
|
|
58
60
|
def self.resolve_format(raw, path)
|
|
59
61
|
declared = raw["format"]
|
|
60
|
-
|
|
61
|
-
|
|
62
|
+
|
|
63
|
+
return declared || "markdown" if path.nil? || path.empty?
|
|
64
|
+
|
|
65
|
+
ext = File.extname(path)
|
|
66
|
+
inferred = Textus::Format.infer_from_extension(ext)
|
|
62
67
|
|
|
63
68
|
if declared.nil?
|
|
64
69
|
return inferred if inferred
|
|
@@ -66,7 +71,7 @@ module Textus
|
|
|
66
71
|
return "markdown"
|
|
67
72
|
end
|
|
68
73
|
|
|
69
|
-
raise UsageError.new("entry '#{raw["key"]}': unknown format #{declared.inspect}") unless Textus::
|
|
74
|
+
raise UsageError.new("entry '#{raw["key"]}': unknown format #{declared.inspect}") unless Textus::Format.formats.include?(declared)
|
|
70
75
|
|
|
71
76
|
if ext != "" && inferred && inferred != declared
|
|
72
77
|
raise UsageError.new(
|
|
@@ -76,6 +81,18 @@ module Textus
|
|
|
76
81
|
|
|
77
82
|
declared
|
|
78
83
|
end
|
|
84
|
+
|
|
85
|
+
# Derives the manifest-relative path from key + kind + format.
|
|
86
|
+
# Key::Path.normalize_relative_path will prepend data/ at resolution time.
|
|
87
|
+
def self.derive_path(key, kind_sym, format)
|
|
88
|
+
dir_path = key.split(".").join("/")
|
|
89
|
+
return dir_path if kind_sym == :nested
|
|
90
|
+
|
|
91
|
+
ext = Textus::Format.for(format).extensions.first
|
|
92
|
+
"#{dir_path}#{ext}"
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
private_class_method :derive_path
|
|
79
96
|
end
|
|
80
97
|
end
|
|
81
98
|
end
|
|
@@ -1,53 +1,32 @@
|
|
|
1
1
|
module Textus
|
|
2
2
|
class Manifest
|
|
3
3
|
class Entry
|
|
4
|
-
# A produced entry
|
|
5
|
-
#
|
|
6
|
-
#
|
|
4
|
+
# A produced entry — `kind: produced` with an optional `source:` block.
|
|
5
|
+
# When `source:` is present it must be `from: external` (out-of-band
|
|
6
|
+
# generator; textus detects drift but never runs it). When absent the
|
|
7
|
+
# entry is produced by a workflow file in .textus/workflows/.
|
|
7
8
|
class Produced < Base
|
|
8
|
-
attr_reader :source
|
|
9
|
+
attr_reader :source
|
|
9
10
|
|
|
10
|
-
def initialize(source:,
|
|
11
|
+
def initialize(source:, **rest)
|
|
11
12
|
super(**rest)
|
|
12
13
|
@source = source
|
|
13
|
-
@events = events || {}
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
-
def
|
|
17
|
-
def
|
|
18
|
-
def external? = @source.external?
|
|
19
|
-
def projection? = @source.projection?
|
|
20
|
-
def fetch? = @source.fetch?
|
|
21
|
-
def derive? = @source.derive?
|
|
22
|
-
def nested? = !!@raw["nested"]
|
|
23
|
-
def handler = @source.handler
|
|
24
|
-
def config = @source.config
|
|
16
|
+
def external? = @source&.external? || false
|
|
17
|
+
def nested? = !!@raw["nested"]
|
|
25
18
|
|
|
26
19
|
KIND = :produced
|
|
27
20
|
|
|
28
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
# out-of-band runner — neither builds, but both still publish their
|
|
32
|
-
# existing store bytes through the same mode. A projection entry with no
|
|
33
|
-
# targets is a terminal data node: it produced data, so report :built
|
|
34
|
-
# even though nothing was emitted.
|
|
21
|
+
# Publish existing store bytes via the shared publish mode (Publish::ToPaths
|
|
22
|
+
# or Publish::None). Workflow runners handle the produce step; this method
|
|
23
|
+
# only publishes whatever bytes are already on disk.
|
|
35
24
|
def publish_via(pctx, prefix: nil)
|
|
36
|
-
|
|
37
|
-
if projection?
|
|
38
|
-
Textus::Pipeline::Acquire::Projection.new(container: pctx.container, call: pctx.call).run(self)
|
|
39
|
-
built = true
|
|
40
|
-
pctx.emit(:entry_produced, key: @key, envelope: pctx.reader.call(@key), sources: Array(@source.select).compact)
|
|
41
|
-
end
|
|
42
|
-
emitted = publish_mode.publish(pctx, prefix: prefix)
|
|
43
|
-
return emitted if emitted
|
|
44
|
-
return nil unless built
|
|
45
|
-
|
|
46
|
-
{ kind: :built, value: { "key" => @key, "path" => Key::Path.resolve(pctx.manifest.data, self), "published_to" => [] } }
|
|
25
|
+
publish_mode.publish(pctx, prefix: prefix)
|
|
47
26
|
end
|
|
48
27
|
|
|
49
28
|
def self.from_raw(common, raw)
|
|
50
|
-
new(source: Parser.parse_source(raw, common[:key]),
|
|
29
|
+
new(source: Parser.parse_source(raw, common[:key]), **common)
|
|
51
30
|
end
|
|
52
31
|
|
|
53
32
|
Entry::REGISTRY[KIND] = self
|
|
@@ -21,16 +21,16 @@ module Textus
|
|
|
21
21
|
|
|
22
22
|
data_path = pctx.manifest.resolver.resolve(entry.key).path
|
|
23
23
|
envelope = pctx.reader.call(entry.key)
|
|
24
|
-
renderer = Textus::
|
|
24
|
+
renderer = Textus::Produce::Render.new(template_loader: ->(n) { pctx.read_template(n) })
|
|
25
25
|
content = nil # parsed lazily; the data's `content` (always _meta-free)
|
|
26
26
|
|
|
27
27
|
targets.each do |t|
|
|
28
28
|
if t.renders?
|
|
29
|
-
content ||= Textus::
|
|
29
|
+
content ||= Textus::Format.for(entry.format).parse(File.read(data_path), path: data_path)["content"]
|
|
30
30
|
publish_bytes(render_bytes(t, content, renderer, pctx), entry.key, t, pctx, data_path, envelope)
|
|
31
31
|
elsif strip_meta?(entry)
|
|
32
|
-
content ||= Textus::
|
|
33
|
-
bytes = Textus::
|
|
32
|
+
content ||= Textus::Format.for(entry.format).parse(File.read(data_path), path: data_path)["content"]
|
|
33
|
+
bytes = Textus::Format.for(entry.format).serialize(meta: {}, body: "", content: content)
|
|
34
34
|
publish_bytes(bytes, entry.key, t, pctx, data_path, envelope)
|
|
35
35
|
else
|
|
36
36
|
# opaque / command / non-structured — publish the stored file as-is
|
|
@@ -18,8 +18,6 @@ module Textus
|
|
|
18
18
|
# the block groups the two but does not make exclusivity structural, so
|
|
19
19
|
# this stays the one enforcement point (ADR 0052 D2).
|
|
20
20
|
def self.resolve(entry)
|
|
21
|
-
reject_removed_publish_each(entry)
|
|
22
|
-
|
|
23
21
|
set = []
|
|
24
22
|
set << "publish.to" unless Array(entry.publish_to).empty?
|
|
25
23
|
set << "publish.tree" unless entry.publish_tree.nil?
|
|
@@ -33,16 +31,6 @@ module Textus
|
|
|
33
31
|
mode_for(entry, set.first)
|
|
34
32
|
end
|
|
35
33
|
|
|
36
|
-
def self.reject_removed_publish_each(entry)
|
|
37
|
-
return unless entry.raw["publish_each"]
|
|
38
|
-
|
|
39
|
-
raise Textus::UsageError.new(
|
|
40
|
-
"entry '#{entry.key}': publish_each was removed in 0.42.0 (ADR 0051) — " \
|
|
41
|
-
"mirror the subtree with `publish: { tree: \"...\" }`.",
|
|
42
|
-
)
|
|
43
|
-
end
|
|
44
|
-
private_class_method :reject_removed_publish_each
|
|
45
|
-
|
|
46
34
|
def self.mode_for(entry, key)
|
|
47
35
|
case key
|
|
48
36
|
when "publish.to" then ToPaths.new(entry)
|