textus 0.54.2 → 0.55.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/README.md +8 -1
- data/SPEC.md +27 -0
- data/docs/architecture/README.md +20 -8
- data/docs/reference/conventions.md +1 -1
- data/exe/textus +1 -1
- data/lib/textus/action/accept.rb +23 -21
- data/lib/textus/action/audit.rb +24 -61
- data/lib/textus/action/base.rb +9 -9
- data/lib/textus/action/blame.rb +18 -36
- data/lib/textus/action/boot.rb +2 -4
- data/lib/textus/action/data_mv.rb +20 -31
- data/lib/textus/action/deps.rb +3 -18
- data/lib/textus/action/doctor.rb +2 -9
- data/lib/textus/action/drain.rb +11 -19
- data/lib/textus/action/enqueue.rb +14 -30
- data/lib/textus/action/get.rb +12 -56
- data/lib/textus/action/ingest.rb +74 -78
- data/lib/textus/action/jobs.rb +6 -15
- data/lib/textus/action/key_delete.rb +6 -16
- data/lib/textus/action/key_delete_prefix.rb +8 -17
- data/lib/textus/action/key_mv.rb +54 -61
- data/lib/textus/action/key_mv_prefix.rb +13 -22
- data/lib/textus/action/list.rb +7 -21
- data/lib/textus/action/propose.rb +16 -26
- data/lib/textus/action/published.rb +3 -5
- data/lib/textus/action/pulse.rb +19 -26
- data/lib/textus/action/put.rb +15 -29
- data/lib/textus/action/rdeps.rb +3 -18
- data/lib/textus/action/reject.rb +12 -21
- data/lib/textus/action/rule_explain.rb +12 -22
- data/lib/textus/action/rule_lint.rb +10 -16
- data/lib/textus/action/rule_list.rb +5 -9
- data/lib/textus/action/schema_envelope.rb +3 -10
- data/lib/textus/action/uid.rb +3 -17
- data/lib/textus/action/where.rb +3 -18
- data/lib/textus/boot.rb +7 -15
- data/lib/textus/contract/arg.rb +10 -0
- data/lib/textus/contract/dsl.rb +88 -0
- data/lib/textus/contract/spec.rb +25 -0
- data/lib/textus/contract.rb +0 -162
- data/lib/textus/doctor/check/audit_log.rb +2 -2
- data/lib/textus/doctor/check/generator_drift.rb +2 -2
- data/lib/textus/doctor/check/orphaned_publish_targets.rb +3 -3
- data/lib/textus/doctor/check/protocol_version.rb +2 -2
- data/lib/textus/doctor/check/raw_asset_paths.rb +1 -1
- data/lib/textus/doctor/check/schema_parse_error.rb +1 -1
- data/lib/textus/doctor/check/schema_violations.rb +2 -2
- data/lib/textus/doctor/check/schemas.rb +1 -1
- data/lib/textus/doctor/check/sentinels.rb +4 -4
- data/lib/textus/doctor/check/templates.rb +1 -1
- data/lib/textus/doctor/check/unowned_schema_fields.rb +1 -1
- data/lib/textus/doctor/check.rb +4 -7
- data/lib/textus/doctor.rb +1 -1
- data/lib/textus/errors.rb +6 -0
- data/lib/textus/format/base.rb +0 -4
- data/lib/textus/format/json.rb +5 -6
- data/lib/textus/format/markdown.rb +5 -6
- data/lib/textus/format/shared.rb +17 -0
- data/lib/textus/format/text.rb +5 -4
- data/lib/textus/format/yaml.rb +30 -6
- data/lib/textus/format.rb +6 -0
- data/lib/textus/gate/auth.rb +2 -17
- data/lib/textus/gate/binder.rb +50 -0
- data/lib/textus/gate.rb +64 -88
- data/lib/textus/init.rb +2 -4
- data/lib/textus/jobs.rb +3 -9
- data/lib/textus/manifest/capabilities.rb +3 -3
- data/lib/textus/manifest/entry/base.rb +1 -1
- data/lib/textus/manifest/entry/publish/subtree_mirror.rb +2 -2
- data/lib/textus/manifest/entry/publish/to_paths.rb +1 -1
- data/lib/textus/manifest/schema/semantics/cross_field.rb +53 -0
- data/lib/textus/manifest/schema/semantics/invariants.rb +125 -0
- data/lib/textus/manifest/schema/semantics/migration.rb +83 -0
- data/lib/textus/manifest/schema/semantics.rb +11 -216
- data/lib/textus/meta.rb +54 -0
- data/lib/textus/{ports → port}/audit_log.rb +44 -4
- data/lib/textus/{ports → port}/build_lock.rb +2 -2
- data/lib/textus/{ports → port}/clock.rb +1 -1
- data/lib/textus/{ports → port}/publisher.rb +5 -5
- data/lib/textus/{ports → port}/sentinel_store.rb +3 -3
- data/lib/textus/{ports → port}/storage/file_stat.rb +1 -1
- data/lib/textus/{ports → port}/storage/file_store.rb +2 -2
- data/lib/textus/port/store.rb +93 -0
- data/lib/textus/{ports → port}/watcher_lock.rb +3 -3
- data/lib/textus/produce/engine.rb +1 -1
- data/lib/textus/schema/tools.rb +11 -7
- data/lib/textus/store/compositor.rb +34 -0
- data/lib/textus/store/container.rb +43 -0
- data/lib/textus/store/cursor.rb +26 -0
- data/lib/textus/store/envelope/reader.rb +43 -0
- data/lib/textus/store/envelope/writer.rb +195 -0
- data/lib/textus/store/geometry.rb +81 -0
- data/lib/textus/store/index/builder.rb +74 -0
- data/lib/textus/store/index/lookup.rb +60 -0
- data/lib/textus/store/jobs/base.rb +13 -0
- data/lib/textus/store/jobs/index.rb +15 -0
- data/lib/textus/store/jobs/materialize.rb +15 -0
- data/lib/textus/store/jobs/plan.rb +11 -0
- data/lib/textus/store/jobs/planner.rb +104 -0
- data/lib/textus/store/jobs/queue.rb +154 -0
- data/lib/textus/store/jobs/registry.rb +19 -0
- data/lib/textus/store/jobs/retention.rb +50 -0
- data/lib/textus/store/jobs/sweep.rb +21 -0
- data/lib/textus/store/jobs/worker.rb +64 -0
- data/lib/textus/store/session.rb +37 -0
- data/lib/textus/store.rb +21 -13
- data/lib/textus/{surfaces → surface}/cli/group/data.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/key.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/mcp.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/rule.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group/schema.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/group.rb +2 -2
- data/lib/textus/{surfaces → surface}/cli/runner.rb +10 -63
- data/lib/textus/surface/cli/sources.rb +41 -0
- data/lib/textus/{surfaces → surface}/cli/verb/doctor.rb +4 -6
- data/lib/textus/{surfaces → surface}/cli/verb/get.rb +4 -4
- data/lib/textus/{surfaces → surface}/cli/verb/init.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/mcp_serve.rb +3 -3
- data/lib/textus/{surfaces → surface}/cli/verb/put.rb +6 -11
- data/lib/textus/{surfaces → surface}/cli/verb/schema_diff.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/schema_init.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/schema_migrate.rb +1 -1
- data/lib/textus/{surfaces → surface}/cli/verb/watch.rb +2 -2
- data/lib/textus/{surfaces → surface}/cli/verb.rb +3 -8
- data/lib/textus/{surfaces → surface}/cli.rb +1 -1
- data/lib/textus/{surfaces → surface}/mcp/catalog.rb +9 -26
- data/lib/textus/{surfaces → surface}/mcp/errors.rb +1 -1
- data/lib/textus/{surfaces → surface}/mcp/server.rb +5 -5
- data/lib/textus/{surfaces → surface}/mcp.rb +2 -2
- data/lib/textus/surface/projector.rb +27 -0
- data/lib/textus/{surfaces → surface}/role_scope.rb +1 -1
- data/lib/textus/{surfaces → surface}/watcher.rb +8 -8
- data/lib/textus/value/call.rb +30 -0
- data/lib/textus/value/command.rb +16 -0
- data/lib/textus/value/envelope.rb +89 -0
- data/lib/textus/value/etag.rb +39 -0
- data/lib/textus/value/result.rb +26 -0
- data/lib/textus/value/role.rb +38 -0
- data/lib/textus/value/types.rb +13 -0
- data/lib/textus/{uid.rb → value/uid.rb} +9 -7
- data/lib/textus/version.rb +1 -1
- data/lib/textus/workflow/loader.rb +4 -4
- data/lib/textus/workflow/runner.rb +4 -18
- data/lib/textus.rb +9 -10
- metadata +100 -63
- data/lib/textus/action/write_verb.rb +0 -44
- data/lib/textus/call.rb +0 -28
- data/lib/textus/command.rb +0 -41
- data/lib/textus/container.rb +0 -26
- data/lib/textus/contract/around.rb +0 -29
- data/lib/textus/contract/binder.rb +0 -88
- data/lib/textus/contract/resources/build_lock.rb +0 -17
- data/lib/textus/contract/resources/cursor.rb +0 -26
- data/lib/textus/contract/sources.rb +0 -39
- data/lib/textus/contract/view.rb +0 -15
- data/lib/textus/cursor_store.rb +0 -24
- data/lib/textus/envelope/reader.rb +0 -46
- data/lib/textus/envelope/writer.rb +0 -209
- data/lib/textus/envelope.rb +0 -79
- data/lib/textus/etag.rb +0 -36
- data/lib/textus/jobs/base.rb +0 -23
- data/lib/textus/jobs/materialize.rb +0 -20
- data/lib/textus/jobs/plan.rb +0 -9
- data/lib/textus/jobs/planner.rb +0 -101
- data/lib/textus/jobs/retention.rb +0 -48
- data/lib/textus/jobs/sweep.rb +0 -27
- data/lib/textus/jobs/worker.rb +0 -67
- data/lib/textus/layout.rb +0 -91
- data/lib/textus/ports/job_store/job.rb +0 -65
- data/lib/textus/ports/job_store.rb +0 -123
- data/lib/textus/ports/raw_index.rb +0 -61
- data/lib/textus/role.rb +0 -36
- data/lib/textus/session.rb +0 -35
- data/lib/textus/types.rb +0 -15
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
require "securerandom"
|
|
6
|
+
require "time"
|
|
7
|
+
|
|
8
|
+
module Textus
|
|
9
|
+
class Store
|
|
10
|
+
module Jobs
|
|
11
|
+
class Queue
|
|
12
|
+
VALID_STATES = %w[ready leased done failed].freeze
|
|
13
|
+
|
|
14
|
+
Leased = Data.define(:job)
|
|
15
|
+
|
|
16
|
+
class Job
|
|
17
|
+
DIGEST_BYTES = 16
|
|
18
|
+
|
|
19
|
+
attr_reader :type, :args, :role, :attempts, :max_attempts, :errors
|
|
20
|
+
|
|
21
|
+
def initialize(type:, args:, role:, attempts: 0, max_attempts: 3, errors: [])
|
|
22
|
+
@type = type.to_s
|
|
23
|
+
@args = stringify(args)
|
|
24
|
+
@role = role.to_s
|
|
25
|
+
@attempts = attempts.to_i
|
|
26
|
+
@max_attempts = max_attempts.to_i
|
|
27
|
+
@errors = Array(errors)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def id
|
|
31
|
+
"#{type}:#{Digest::SHA256.hexdigest(JSON.dump(args.sort.to_h))[0, DIGEST_BYTES]}"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def stringify(hash)
|
|
37
|
+
hash.to_h.transform_keys(&:to_s)
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def initialize(store:)
|
|
42
|
+
@store = store
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def enqueue(job)
|
|
46
|
+
now = iso_now
|
|
47
|
+
@store.execute(
|
|
48
|
+
"INSERT OR IGNORE INTO jobs (id, type, args, state, role, attempts, max_attempts, errors, lease, created_at, updated_at)
|
|
49
|
+
VALUES (?, ?, ?, 'ready', ?, ?, ?, ?, NULL, ?, ?)",
|
|
50
|
+
[job.id, job.type, JSON.dump(job.args), job.role, job.attempts, job.max_attempts, JSON.dump(job.errors), now, now],
|
|
51
|
+
)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def ready_ids
|
|
55
|
+
list(:ready)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def lease(worker_id:, lease_ttl:)
|
|
59
|
+
now = Time.now.utc
|
|
60
|
+
expires_at = now + lease_ttl
|
|
61
|
+
token = SecureRandom.hex(8)
|
|
62
|
+
marked_lease = JSON.dump({ "worker_id" => worker_id, "expires_at" => expires_at.iso8601, "token" => token })
|
|
63
|
+
|
|
64
|
+
@store.execute(
|
|
65
|
+
"UPDATE jobs
|
|
66
|
+
SET state = 'leased', lease = ?, updated_at = ?
|
|
67
|
+
WHERE id = (
|
|
68
|
+
SELECT id FROM jobs WHERE state = 'ready' ORDER BY created_at, id LIMIT 1
|
|
69
|
+
)",
|
|
70
|
+
[marked_lease, now.iso8601],
|
|
71
|
+
)
|
|
72
|
+
row = @store.execute("SELECT * FROM jobs WHERE state = 'leased' AND lease = ? LIMIT 1", [marked_lease]).first
|
|
73
|
+
return nil unless row
|
|
74
|
+
|
|
75
|
+
Leased.new(job_from_row(row))
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def ack(leased)
|
|
79
|
+
@store.execute(
|
|
80
|
+
"UPDATE jobs SET state = 'done', lease = NULL, updated_at = ? WHERE id = ? AND state = 'leased'",
|
|
81
|
+
[iso_now, leased.job.id],
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def fail(leased, error:)
|
|
86
|
+
job = leased.job
|
|
87
|
+
attempts = job.attempts + 1
|
|
88
|
+
errors = job.errors + [{ "attempt" => attempts, "error" => error, "at" => iso_now }]
|
|
89
|
+
dead = attempts >= job.max_attempts
|
|
90
|
+
state = dead ? "failed" : "ready"
|
|
91
|
+
@store.execute(
|
|
92
|
+
"UPDATE jobs SET state = ?, attempts = ?, errors = ?, lease = NULL, updated_at = ? WHERE id = ?",
|
|
93
|
+
[state, attempts, JSON.dump(errors), iso_now, job.id],
|
|
94
|
+
)
|
|
95
|
+
dead ? :dead_lettered : :requeued
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def reclaim(now:)
|
|
99
|
+
rows = @store.execute("SELECT id, lease FROM jobs WHERE state = 'leased'")
|
|
100
|
+
expired = rows.select do |row|
|
|
101
|
+
lease = JSON.parse(row["lease"] || "{}")
|
|
102
|
+
expires_at = lease["expires_at"]
|
|
103
|
+
expires_at.nil? || Time.parse(expires_at) <= now
|
|
104
|
+
end
|
|
105
|
+
expired.each do |row|
|
|
106
|
+
@store.execute(
|
|
107
|
+
"UPDATE jobs SET state = 'ready', lease = NULL, updated_at = ? WHERE id = ?",
|
|
108
|
+
[now.utc.iso8601, row["id"]],
|
|
109
|
+
)
|
|
110
|
+
end
|
|
111
|
+
expired.size
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def list(state)
|
|
115
|
+
state = state.to_s
|
|
116
|
+
raise Textus::UsageError.new("unknown job state: #{state}") unless VALID_STATES.include?(state)
|
|
117
|
+
|
|
118
|
+
@store.execute("SELECT id FROM jobs WHERE state = ? ORDER BY created_at, id", [state]).map { |row| row["id"] }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def retry_failed(job_id)
|
|
122
|
+
@store.execute(
|
|
123
|
+
"UPDATE jobs SET state = 'ready', attempts = 0, errors = ?, lease = NULL, updated_at = ? WHERE id = ? AND state = 'failed'",
|
|
124
|
+
[JSON.dump([]), iso_now, job_id],
|
|
125
|
+
)
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def purge(state)
|
|
129
|
+
state = state.to_s
|
|
130
|
+
raise Textus::UsageError.new("unknown job state: #{state}") unless VALID_STATES.include?(state)
|
|
131
|
+
|
|
132
|
+
@store.execute("DELETE FROM jobs WHERE state = ?", [state])
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
private
|
|
136
|
+
|
|
137
|
+
def job_from_row(row)
|
|
138
|
+
Job.new(
|
|
139
|
+
type: row["type"],
|
|
140
|
+
args: JSON.parse(row["args"] || "{}"),
|
|
141
|
+
role: row["role"],
|
|
142
|
+
attempts: row["attempts"],
|
|
143
|
+
max_attempts: row["max_attempts"],
|
|
144
|
+
errors: JSON.parse(row["errors"] || "[]"),
|
|
145
|
+
)
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
def iso_now
|
|
149
|
+
Time.now.utc.iso8601
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Store
|
|
3
|
+
module Jobs
|
|
4
|
+
module Registry
|
|
5
|
+
class UnknownJob < KeyError; end
|
|
6
|
+
|
|
7
|
+
JOBS = {
|
|
8
|
+
"index" => Store::Jobs::Index,
|
|
9
|
+
"materialize" => Store::Jobs::Materialize,
|
|
10
|
+
"sweep" => Store::Jobs::Sweep,
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def self.fetch(type)
|
|
14
|
+
JOBS.fetch(type.to_s) { raise UnknownJob.new("Unknown job type: #{type}") }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
require "fileutils"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
class Store
|
|
5
|
+
module Jobs
|
|
6
|
+
class Retention
|
|
7
|
+
def initialize(container:, call:)
|
|
8
|
+
@container = container
|
|
9
|
+
@call = call
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def call(rows)
|
|
13
|
+
out = { dropped: [], archived: [], failed: [] }
|
|
14
|
+
rows.each do |row|
|
|
15
|
+
key = row["key"]
|
|
16
|
+
begin
|
|
17
|
+
case row["action"]
|
|
18
|
+
when "drop"
|
|
19
|
+
delete(key)
|
|
20
|
+
out[:dropped] << key
|
|
21
|
+
when "archive"
|
|
22
|
+
archive_leaf(row)
|
|
23
|
+
delete(key)
|
|
24
|
+
out[:archived] << key
|
|
25
|
+
end
|
|
26
|
+
rescue Textus::Error => e
|
|
27
|
+
out[:failed] << { "key" => key, "error" => e.message }
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
out
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def archive_leaf(row)
|
|
36
|
+
src = row["path"]
|
|
37
|
+
root = @container.root.to_s
|
|
38
|
+
rel = src.delete_prefix("#{root}/")
|
|
39
|
+
dest = File.join(root, "archive", rel)
|
|
40
|
+
FileUtils.mkdir_p(File.dirname(dest))
|
|
41
|
+
FileUtils.cp(src, dest)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def delete(key)
|
|
45
|
+
Textus::Action::KeyDelete.call(container: @container, call: @call, key: key)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Store
|
|
3
|
+
module Jobs
|
|
4
|
+
class Sweep < Base
|
|
5
|
+
REQUIRED_ROLE = Textus::Value::Role::AUTOMATION
|
|
6
|
+
TYPE = "sweep"
|
|
7
|
+
|
|
8
|
+
def self.call(container:, call:, scope: {}, key: nil)
|
|
9
|
+
prefix = key || (scope.is_a?(Hash) ? scope["prefix"] : nil)
|
|
10
|
+
lane = scope.is_a?(Hash) ? scope["lane"] : nil
|
|
11
|
+
rows = Textus::Core::Retention::Sweep.new(
|
|
12
|
+
manifest: container.manifest,
|
|
13
|
+
file_stat: Textus::Port::Storage::FileStat.new,
|
|
14
|
+
clock: Textus::Port::Clock.new,
|
|
15
|
+
).call(prefix: prefix, lane: lane)
|
|
16
|
+
Textus::Store::Jobs::Retention.new(container: container, call: call).call(rows)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
class Store
|
|
3
|
+
module Jobs
|
|
4
|
+
class Worker
|
|
5
|
+
Summary = Struct.new(:completed, :failed, keyword_init: true)
|
|
6
|
+
|
|
7
|
+
def self.for(container:, queue:)
|
|
8
|
+
new(queue: queue, container: container,
|
|
9
|
+
lease_ttl: container.manifest.data.worker_config[:lease_ttl])
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def initialize(queue:, container:, lease_ttl: 60)
|
|
13
|
+
@queue = queue
|
|
14
|
+
@container = container
|
|
15
|
+
@lease_ttl = lease_ttl
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def drain(worker_id: "drain-#{Process.pid}")
|
|
19
|
+
completed = 0
|
|
20
|
+
failed = 0
|
|
21
|
+
loop do
|
|
22
|
+
leased = @queue.lease(worker_id: worker_id, lease_ttl: @lease_ttl)
|
|
23
|
+
break unless leased
|
|
24
|
+
|
|
25
|
+
case run_one(leased)
|
|
26
|
+
when :completed then completed += 1
|
|
27
|
+
when :dead_lettered then failed += 1
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
Summary.new(completed: completed, failed: failed)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def drain_pool(pool: 4)
|
|
34
|
+
summaries = []
|
|
35
|
+
mutex = Mutex.new
|
|
36
|
+
threads = Array.new(pool) do |i|
|
|
37
|
+
Thread.new do
|
|
38
|
+
s = drain(worker_id: "pool-#{Process.pid}-#{i}")
|
|
39
|
+
mutex.synchronize { summaries << s }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
threads.each(&:join)
|
|
43
|
+
Summary.new(completed: summaries.sum(&:completed), failed: summaries.sum(&:failed))
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def run_one(leased)
|
|
49
|
+
job = leased.job
|
|
50
|
+
klass = Textus::Jobs.fetch(job.type)
|
|
51
|
+
call = Textus::Value::Call.build(
|
|
52
|
+
role: job.role || Textus::Value::Role::AUTOMATION,
|
|
53
|
+
correlation_id: SecureRandom.uuid,
|
|
54
|
+
)
|
|
55
|
+
klass.call(container: @container, call: call, **job.args.transform_keys(&:to_sym))
|
|
56
|
+
@queue.ack(leased)
|
|
57
|
+
:completed
|
|
58
|
+
rescue StandardError => e
|
|
59
|
+
@queue.fail(leased, error: e.message)
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-struct"
|
|
4
|
+
|
|
5
|
+
module Textus
|
|
6
|
+
# The agent session: per-connection (MCP), per-process (CLI), or per-loop
|
|
7
|
+
# (Ruby) orientation state — the audit cursor plus the contract etag and
|
|
8
|
+
# propose_lane captured at boot. Immutable Dry::Struct::Value; advance_cursor
|
|
9
|
+
# and with return new instances. ADR 0036; contract_etag widened in ADR 0074.
|
|
10
|
+
class Store
|
|
11
|
+
class Session < Dry::Struct
|
|
12
|
+
attribute :role, Value::Types::RoleName
|
|
13
|
+
attribute :cursor, Value::Types::Cursor
|
|
14
|
+
attribute :propose_lane, Value::Types::String.optional
|
|
15
|
+
attribute :contract_etag, Value::Types::String
|
|
16
|
+
|
|
17
|
+
def with(**attrs) = self.class.new(to_h.merge(attrs))
|
|
18
|
+
|
|
19
|
+
def advance_cursor(new_cursor) = with(cursor: new_cursor)
|
|
20
|
+
|
|
21
|
+
def check_etag!(observed_etag)
|
|
22
|
+
return if observed_etag == contract_etag
|
|
23
|
+
|
|
24
|
+
raise Textus::ContractDrift.new(
|
|
25
|
+
"contract changed (manifest/hooks/schemas were #{short_etag(contract_etag)}, " \
|
|
26
|
+
"now #{short_etag(observed_etag)}); re-run boot",
|
|
27
|
+
)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
# First 8 hex chars after the "sha256:" prefix — a stable short id for
|
|
33
|
+
# the drift diagnostic.
|
|
34
|
+
def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/textus/store.rb
CHANGED
|
@@ -7,7 +7,7 @@ module Textus
|
|
|
7
7
|
# Readers are derived from the Container's schema, so the field set lives
|
|
8
8
|
# in exactly one place (Container). A new capability added there is
|
|
9
9
|
# automatically exposed on the Store.
|
|
10
|
-
Textus::Container.attribute_names.each do |field|
|
|
10
|
+
Textus::Store::Container.attribute_names.each do |field|
|
|
11
11
|
define_method(field) { @container.public_send(field) }
|
|
12
12
|
end
|
|
13
13
|
|
|
@@ -49,11 +49,11 @@ module Textus
|
|
|
49
49
|
# Build an agent Session oriented at the current cursor/manifest — the
|
|
50
50
|
# Ruby equivalent of an MCP `initialize`. ADR 0036.
|
|
51
51
|
def session(role:)
|
|
52
|
-
Textus::Session.new(
|
|
52
|
+
Textus::Store::Session.new(
|
|
53
53
|
role: role.to_s,
|
|
54
54
|
cursor: audit_log.latest_seq,
|
|
55
55
|
propose_lane: manifest.policy.propose_lane_for(role),
|
|
56
|
-
contract_etag: Textus::Etag.for_contract(root),
|
|
56
|
+
contract_etag: Textus::Value::Etag.for_contract(root),
|
|
57
57
|
)
|
|
58
58
|
end
|
|
59
59
|
|
|
@@ -62,30 +62,38 @@ module Textus
|
|
|
62
62
|
end
|
|
63
63
|
|
|
64
64
|
def as(role, dry_run: false, correlation_id: nil)
|
|
65
|
-
Textus::
|
|
65
|
+
Textus::Surface::RoleScope.new(container: container, role: role, dry_run: dry_run, correlation_id: correlation_id)
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
private
|
|
69
69
|
|
|
70
70
|
def build_container(root)
|
|
71
71
|
manifest = Manifest.load(root)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
audit_log:
|
|
72
|
+
job_store = Port::Store.new(root: root).setup!
|
|
73
|
+
geometry = Store::Geometry.new(root)
|
|
74
|
+
infra = Container::Infrastructure.new(
|
|
75
|
+
file_store: Port::Storage::FileStore.new,
|
|
76
|
+
schemas: Schemas.new(geometry.schemas_dir),
|
|
77
|
+
audit_log: Port::AuditLog.new(
|
|
78
78
|
root,
|
|
79
79
|
max_size: manifest.data.audit_config[:max_size],
|
|
80
80
|
keep: manifest.data.audit_config[:keep],
|
|
81
81
|
),
|
|
82
|
+
job_store:,
|
|
83
|
+
geometry:,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
coord = Container::Coordination.new(
|
|
87
|
+
manifest:,
|
|
82
88
|
workflows: Workflow::Loader.load_all(root),
|
|
83
89
|
gate: nil,
|
|
90
|
+
compositor: nil,
|
|
84
91
|
)
|
|
92
|
+
|
|
93
|
+
container = Container.new(infra, coord)
|
|
94
|
+
compositor = Store::Compositor.new(container)
|
|
85
95
|
gate = Textus::Gate.new(container)
|
|
86
|
-
container
|
|
87
|
-
gate.instance_variable_set(:@container, container)
|
|
88
|
-
container
|
|
96
|
+
container.wire_gate!(gate, compositor)
|
|
89
97
|
end
|
|
90
98
|
end
|
|
91
99
|
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Surface
|
|
3
3
|
class CLI
|
|
4
4
|
class Group < Verb
|
|
5
5
|
class << self
|
|
@@ -7,7 +7,7 @@ module Textus
|
|
|
7
7
|
# `parent_group` is this group counts as a subcommand. Sorted
|
|
8
8
|
# alphabetically by command_name for stable help output.
|
|
9
9
|
def subcommands
|
|
10
|
-
Textus::
|
|
10
|
+
Textus::Surface::CLI::Runner.install!
|
|
11
11
|
Verb.descendants
|
|
12
12
|
.select { |k| k.parent_group == self && k.command_name }
|
|
13
13
|
.sort_by(&:command_name)
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Surface
|
|
3
3
|
class CLI
|
|
4
4
|
# Generates CLI::Verb (and CLI::Group) subclasses from per-verb contracts,
|
|
5
5
|
# so the CLI surface is a projection of the contract — the operator-facing
|
|
@@ -49,63 +49,21 @@ module Textus
|
|
|
49
49
|
|
|
50
50
|
module_function
|
|
51
51
|
|
|
52
|
-
# Build a Command from the spec + parsed inputs, dispatch through Gate.
|
|
53
52
|
def dispatch(verb_instance, store, spec)
|
|
54
|
-
inputs = Textus::
|
|
53
|
+
inputs = Textus::Gate::Binder.inputs_from_ordered(
|
|
55
54
|
spec, verb_instance.positional, verb_instance.flag_values(spec)
|
|
56
55
|
)
|
|
57
|
-
inputs = inputs.merge(
|
|
58
|
-
inputs =
|
|
56
|
+
inputs = inputs.merge(Surface::CLI::Sources.from_stdin(spec, verb_instance.stdin)) if spec.cli_stdin
|
|
57
|
+
inputs = Surface::CLI::Sources.acquire(spec, inputs)
|
|
59
58
|
inputs = apply_cli_defaults(spec, inputs)
|
|
60
59
|
role = verb_instance.resolved_role(store)
|
|
61
60
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
result = if spec.around
|
|
68
|
-
scope = store.as(role)
|
|
69
|
-
Textus::Contract::Around.with(spec.around, scope: scope, inputs: inputs, session: nil, &invoke)
|
|
70
|
-
else
|
|
71
|
-
invoke.call(inputs)
|
|
72
|
-
end
|
|
73
|
-
verb_instance.emit(shape(spec, result, inputs))
|
|
74
|
-
rescue Textus::Contract::MissingArgs => e
|
|
61
|
+
result = store.gate.dispatch(spec:, inputs:, role:, surface: :cli)
|
|
62
|
+
verb_instance.emit(result)
|
|
63
|
+
rescue Textus::Gate::MissingArgs => e
|
|
75
64
|
raise UsageError.new("#{spec.cli_path} requires #{e.missing.first.wire}")
|
|
76
65
|
end
|
|
77
66
|
|
|
78
|
-
def build_command(spec, inputs, role)
|
|
79
|
-
cmd_class = Textus::Gate::VERB_COMMAND.fetch(spec.verb) do
|
|
80
|
-
raise Textus::UsageError.new("no Command for verb: #{spec.verb}")
|
|
81
|
-
end
|
|
82
|
-
defaults = {}
|
|
83
|
-
spec.args.each do |a|
|
|
84
|
-
next if a.default == :__unset || inputs.key?(a.name)
|
|
85
|
-
next if a.default.nil? && a.required
|
|
86
|
-
|
|
87
|
-
defaults[a.name] = a.default
|
|
88
|
-
end
|
|
89
|
-
kwargs = defaults.merge(inputs)
|
|
90
|
-
kwargs[:role] = role if cmd_class.members.include?(:role) && !inputs.key?(:role) && spec.verb != :audit
|
|
91
|
-
check_missing_args!(spec, cmd_class, kwargs)
|
|
92
|
-
|
|
93
|
-
cmd_class.new(**kwargs.slice(*cmd_class.members))
|
|
94
|
-
end
|
|
95
|
-
|
|
96
|
-
def check_missing_args!(spec, cmd_class, kwargs)
|
|
97
|
-
params = cmd_class.instance_method(:initialize).parameters
|
|
98
|
-
required = if params == [[:rest]]
|
|
99
|
-
cmd_class.members
|
|
100
|
-
else
|
|
101
|
-
params.select { |t,| t == :keyreq }.map(&:last)
|
|
102
|
-
end
|
|
103
|
-
missing = required - kwargs.keys
|
|
104
|
-
return if missing.empty?
|
|
105
|
-
|
|
106
|
-
raise Textus::Contract::MissingArgs.new(spec, missing.map { |m| Struct.new(:wire, :name).new(m.to_s, m) })
|
|
107
|
-
end
|
|
108
|
-
|
|
109
67
|
# Fill CLI-specific defaults (cli_default:) for args the operator did not
|
|
110
68
|
# pass, where the CLI default diverges from the contract default the agent
|
|
111
69
|
# surfaces use — e.g. migrate/data_mv apply by default on the CLI but plan
|
|
@@ -119,14 +77,6 @@ module Textus
|
|
|
119
77
|
end
|
|
120
78
|
end
|
|
121
79
|
|
|
122
|
-
# Shape the use-case result for the CLI wire via the verb's :cli view
|
|
123
|
-
# (falling back to the default view). The view is called uniformly as
|
|
124
|
-
# (result, inputs); an inputs-aware view echoes an input such as the key
|
|
125
|
-
# (ADR 0067).
|
|
126
|
-
def shape(spec, result, inputs)
|
|
127
|
-
Textus::Contract::View.render(spec, :cli, result, inputs)
|
|
128
|
-
end
|
|
129
|
-
|
|
130
80
|
# The default the CLI flag is generated against — `cli_default:` when the
|
|
131
81
|
# operator-facing default diverges from the contract default the agent
|
|
132
82
|
# surfaces use, else the contract `default`. This drives boolean flag
|
|
@@ -151,6 +101,7 @@ module Textus
|
|
|
151
101
|
def coerce(arg, raw)
|
|
152
102
|
return effective_default(arg) != true if arg.type == :boolean
|
|
153
103
|
return Integer(raw) if arg.type == Integer
|
|
104
|
+
return JSON.parse(raw) if arg.type == Hash
|
|
154
105
|
|
|
155
106
|
raw
|
|
156
107
|
end
|
|
@@ -193,11 +144,7 @@ module Textus
|
|
|
193
144
|
|
|
194
145
|
def install!
|
|
195
146
|
@installed ||= {}
|
|
196
|
-
Textus::
|
|
197
|
-
verb = Textus::Gate::VERB_COMMAND.key(cmd_class)
|
|
198
|
-
next unless verb
|
|
199
|
-
|
|
200
|
-
action_class = Textus::Gate::ROUTES[cmd_class].first
|
|
147
|
+
Textus::Action::VERBS.each_value do |action_class|
|
|
201
148
|
next unless action_class.respond_to?(:contract?) && action_class.contract?
|
|
202
149
|
|
|
203
150
|
spec = action_class.contract
|
|
@@ -224,7 +171,7 @@ module Textus
|
|
|
224
171
|
non_positional.each { |a| klass.option a.name, Runner.flagspec_for(a) }
|
|
225
172
|
|
|
226
173
|
# Anchor the anonymous class to a constant so descendants discovery is
|
|
227
|
-
# stable. Name it
|
|
174
|
+
# stable. Name it under a Generated namespace.
|
|
228
175
|
const_name = spec.verb.to_s.split("_").map(&:capitalize).join
|
|
229
176
|
gen = "Gen#{const_name}"
|
|
230
177
|
Verb.const_set(gen, klass) unless Verb.const_defined?(gen, false)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Surface
|
|
5
|
+
class CLI
|
|
6
|
+
# CLI-only input acquisition. Transforms entries of the uniform `inputs`
|
|
7
|
+
# hash that declare a `source:`/`coerce:`, and builds `inputs` from a
|
|
8
|
+
# `cli_stdin` envelope — so put/propose/migrate/rule_lint/audit need no
|
|
9
|
+
# hand-authored CLI class (ADR 0068). MCP receives typed JSON, so these
|
|
10
|
+
# never run there.
|
|
11
|
+
module Sources
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
# Apply per-arg :file sources (value is a path -> file contents) and
|
|
15
|
+
# :coerce callables to a by-name inputs hash. Returns a new hash.
|
|
16
|
+
def acquire(spec, inputs)
|
|
17
|
+
spec.args.each_with_object(inputs.dup) do |a, h|
|
|
18
|
+
next unless h.key?(a.name)
|
|
19
|
+
|
|
20
|
+
h[a.name] = File.read(h[a.name]) if a.source == :file
|
|
21
|
+
h[a.name] = a.coerce.call(h[a.name]) if a.coerce
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Parse a cli_stdin :json envelope into a by-name inputs hash, mapping
|
|
26
|
+
# envelope keys (wire-names) to arg names.
|
|
27
|
+
def from_stdin(spec, stream)
|
|
28
|
+
return {} unless spec.cli_stdin == :json
|
|
29
|
+
|
|
30
|
+
raw = stream.read.to_s
|
|
31
|
+
return {} if raw.strip.empty? # no envelope piped -> required args surface as missing
|
|
32
|
+
|
|
33
|
+
envelope = JSON.parse(raw)
|
|
34
|
+
spec.args.each_with_object({}) do |a, h|
|
|
35
|
+
h[a.name] = envelope[a.wire.to_s] if envelope.key?(a.wire.to_s)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Surface
|
|
3
3
|
class CLI
|
|
4
4
|
class Verb
|
|
5
5
|
class Doctor < Verb
|
|
@@ -7,11 +7,9 @@ module Textus
|
|
|
7
7
|
option :checks, "--check=NAME"
|
|
8
8
|
|
|
9
9
|
def call(store)
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
)
|
|
14
|
-
res = store.gate.dispatch(cmd)
|
|
10
|
+
spec = Textus::Action::Doctor.contract
|
|
11
|
+
inputs = { checks: checks&.split(",")&.map(&:strip) }
|
|
12
|
+
res = store.gate.dispatch(spec: spec, inputs: inputs, role: resolved_role(store))
|
|
15
13
|
emit(res, exit_code: res["ok"] ? 0 : 1)
|
|
16
14
|
end
|
|
17
15
|
end
|