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
data/lib/textus/jobs/worker.rb
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Jobs
|
|
3
|
-
class Worker
|
|
4
|
-
Summary = Struct.new(:completed, :failed, keyword_init: true)
|
|
5
|
-
|
|
6
|
-
def self.for(container:, queue:)
|
|
7
|
-
new(queue: queue, container: container,
|
|
8
|
-
lease_ttl: container.manifest.data.worker_config[:lease_ttl])
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def initialize(queue:, container:, lease_ttl: 60)
|
|
12
|
-
@queue = queue
|
|
13
|
-
@container = container
|
|
14
|
-
@lease_ttl = lease_ttl
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def drain(worker_id: "drain-#{Process.pid}")
|
|
18
|
-
completed = 0
|
|
19
|
-
failed = 0
|
|
20
|
-
loop do
|
|
21
|
-
leased = @queue.lease(worker_id: worker_id, lease_ttl: @lease_ttl)
|
|
22
|
-
break unless leased
|
|
23
|
-
|
|
24
|
-
case run_one(leased)
|
|
25
|
-
when :completed then completed += 1
|
|
26
|
-
when :dead_lettered then failed += 1
|
|
27
|
-
end
|
|
28
|
-
end
|
|
29
|
-
Summary.new(completed: completed, failed: failed)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
def drain_pool(pool: 4)
|
|
33
|
-
summaries = []
|
|
34
|
-
mutex = Mutex.new
|
|
35
|
-
threads = Array.new(pool) do |i|
|
|
36
|
-
Thread.new do
|
|
37
|
-
s = drain(worker_id: "pool-#{Process.pid}-#{i}")
|
|
38
|
-
mutex.synchronize { summaries << s }
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
threads.each(&:join)
|
|
42
|
-
Summary.new(completed: summaries.sum(&:completed), failed: summaries.sum(&:failed))
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
private
|
|
46
|
-
|
|
47
|
-
def run_one(leased)
|
|
48
|
-
job = leased.job
|
|
49
|
-
klass = Textus::Jobs.fetch(job.type)
|
|
50
|
-
action = if klass.instance_method(:initialize).parameters.any?
|
|
51
|
-
klass.new(**job.args.transform_keys(&:to_sym))
|
|
52
|
-
else
|
|
53
|
-
klass.new
|
|
54
|
-
end
|
|
55
|
-
call = Textus::Call.build(
|
|
56
|
-
role: job.enqueued_by || Textus::Role::AUTOMATION,
|
|
57
|
-
correlation_id: SecureRandom.uuid,
|
|
58
|
-
)
|
|
59
|
-
action.call(container: @container, call: call)
|
|
60
|
-
@queue.ack(leased)
|
|
61
|
-
:completed
|
|
62
|
-
rescue StandardError => e
|
|
63
|
-
@queue.fail(leased, error: e.message)
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
end
|
|
67
|
-
end
|
data/lib/textus/layout.rb
DELETED
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
# Single source of truth for every path textus owns under a store root.
|
|
3
|
-
# All disposable runtime state nests under <root>/.state/ so the
|
|
4
|
-
# tracked/disposable boundary is a directory boundary. ADR 0038.
|
|
5
|
-
module Layout
|
|
6
|
-
RUN = ".state"
|
|
7
|
-
DATA = "data"
|
|
8
|
-
|
|
9
|
-
def self.data(root)
|
|
10
|
-
File.join(root, DATA)
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
def self.data_lane(root, lane_name)
|
|
14
|
-
File.join(data(root), lane_name)
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def self.run(root)
|
|
18
|
-
File.join(root, RUN)
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def self.cursors(root)
|
|
22
|
-
File.join(run(root), "cursors")
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
def self.cursor(root, role)
|
|
26
|
-
File.join(cursors(root), role.to_s)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def self.locks(root)
|
|
30
|
-
File.join(run(root), "locks")
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def self.build_lock(root)
|
|
34
|
-
File.join(run(root), "build.lock")
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
def self.watcher_lock(root)
|
|
38
|
-
File.join(run(root), "watcher.lock")
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def self.queue(root)
|
|
42
|
-
File.join(run(root), "queue")
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def self.queue_state(root, state)
|
|
46
|
-
File.join(queue(root), state.to_s)
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
def self.audit_dir(root)
|
|
50
|
-
File.join(run(root), "audit")
|
|
51
|
-
end
|
|
52
|
-
|
|
53
|
-
# Sentinels are machine-generated (the published target's sha), not authored
|
|
54
|
-
# source, so they live on the runtime side under `.state/` — git-ignored,
|
|
55
|
-
# regenerated by the next build via content-identical adoption (ADR 0070,
|
|
56
|
-
# superseding ADR 0038's `:config` classification).
|
|
57
|
-
def self.sentinels(root)
|
|
58
|
-
File.join(run(root), "sentinels")
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
def self.indexes(root)
|
|
62
|
-
File.join(run(root), "indexes")
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def self.raw_index(root)
|
|
66
|
-
File.join(indexes(root), "raw.yaml")
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def self.audit_log(root)
|
|
70
|
-
File.join(audit_dir(root), "audit.log")
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
# The store's `.gitignore` body. Always ignores the runtime subtree
|
|
74
|
-
# (`.state/`, ADR 0038); when given untracked entry paths (entries marked
|
|
75
|
-
# `tracked: false`), it also lists those so they stay protocol-readable but
|
|
76
|
-
# uncommitted (ADR 0043, refining 0038). Generated, never hand-kept — no
|
|
77
|
-
# drift between the manifest and the ignore file.
|
|
78
|
-
def self.gitignore_body(untracked_paths: [])
|
|
79
|
-
lines = ["# textus runtime artifacts — safe to delete, never commit",
|
|
80
|
-
"#{RUN}/"]
|
|
81
|
-
unless untracked_paths.empty?
|
|
82
|
-
lines << "# tracked:false entries — protocol-readable, not committed (sensitive)"
|
|
83
|
-
lines.concat(untracked_paths)
|
|
84
|
-
end
|
|
85
|
-
"#{lines.join("\n")}\n"
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Back-compat constant: the no-untracked-entries body (just the run subtree).
|
|
89
|
-
GITIGNORE = gitignore_body
|
|
90
|
-
end
|
|
91
|
-
end
|
|
@@ -1,65 +0,0 @@
|
|
|
1
|
-
require "digest"
|
|
2
|
-
require "json"
|
|
3
|
-
|
|
4
|
-
module Textus
|
|
5
|
-
module Ports
|
|
6
|
-
class JobStore
|
|
7
|
-
# A unit of deferred work. Pure data. The id is `<type>:<digest>` where the
|
|
8
|
-
# digest is over the args with sorted keys, so logically-identical enqueues
|
|
9
|
-
# collide on the same id — which is how Queue dedups (file already exists).
|
|
10
|
-
# At-least-once delivery means handlers must be idempotent.
|
|
11
|
-
class Job
|
|
12
|
-
DIGEST_BYTES = 16
|
|
13
|
-
|
|
14
|
-
attr_reader :type, :args, :enqueued_by, :max_attempts
|
|
15
|
-
attr_accessor :attempts, :last_error
|
|
16
|
-
|
|
17
|
-
def initialize(type:, args:, enqueued_by: nil, attempts: 0, max_attempts: 3, last_error: nil)
|
|
18
|
-
@type = type.to_s
|
|
19
|
-
@args = stringify(args)
|
|
20
|
-
@enqueued_by = enqueued_by
|
|
21
|
-
@attempts = attempts
|
|
22
|
-
@max_attempts = max_attempts
|
|
23
|
-
@last_error = last_error
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
def id
|
|
27
|
-
"#{@type}:#{digest}"
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
def to_h
|
|
31
|
-
{
|
|
32
|
-
"type" => @type,
|
|
33
|
-
"args" => @args,
|
|
34
|
-
"enqueued_by" => @enqueued_by,
|
|
35
|
-
"attempts" => @attempts,
|
|
36
|
-
"max_attempts" => @max_attempts,
|
|
37
|
-
"last_error" => @last_error,
|
|
38
|
-
}
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def self.from_h(hash)
|
|
42
|
-
new(
|
|
43
|
-
type: hash["type"],
|
|
44
|
-
args: hash["args"] || {},
|
|
45
|
-
enqueued_by: hash["enqueued_by"],
|
|
46
|
-
attempts: hash["attempts"] || 0,
|
|
47
|
-
max_attempts: hash["max_attempts"] || 3,
|
|
48
|
-
last_error: hash["last_error"],
|
|
49
|
-
)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
private
|
|
53
|
-
|
|
54
|
-
def digest
|
|
55
|
-
canonical = JSON.dump(@args.sort.to_h)
|
|
56
|
-
Digest::SHA256.hexdigest(canonical)[0, DIGEST_BYTES]
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def stringify(hash)
|
|
60
|
-
hash.transform_keys(&:to_s)
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
@@ -1,123 +0,0 @@
|
|
|
1
|
-
require "fileutils"
|
|
2
|
-
require "json"
|
|
3
|
-
require "time"
|
|
4
|
-
|
|
5
|
-
module Textus
|
|
6
|
-
module Ports
|
|
7
|
-
# File-backed durable job queue under `<root>/.run/queue/`. Each job state
|
|
8
|
-
# is a directory; a job is one `<id>.json` file. Claiming is an atomic
|
|
9
|
-
# `rename(2)` from ready/ to leased/ — the rename winner owns the job, so a
|
|
10
|
-
# worker pool needs no central lock. Dedup falls out of the id-as-filename:
|
|
11
|
-
# enqueueing an id that already exists is a no-op. ADR 0038 (runtime subtree),
|
|
12
|
-
# ADR 0108 (instantiable port).
|
|
13
|
-
class JobStore
|
|
14
|
-
STATES = %i[ready leased done failed].freeze
|
|
15
|
-
|
|
16
|
-
def initialize(root:)
|
|
17
|
-
@root = root
|
|
18
|
-
STATES.each { |s| FileUtils.mkdir_p(Textus::Layout.queue_state(root, s)) }
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
def enqueue(job)
|
|
22
|
-
dest = path(:ready, job.id)
|
|
23
|
-
return if File.exist?(dest)
|
|
24
|
-
|
|
25
|
-
write_atomic(dest, job.to_h)
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def ready_ids
|
|
29
|
-
Dir.children(Textus::Layout.queue_state(@root, :ready)).map { |f| File.basename(f, ".json") }
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
Leased = Struct.new(:job, :leased_path, keyword_init: true)
|
|
33
|
-
|
|
34
|
-
def lease(worker_id:, lease_ttl:)
|
|
35
|
-
ready_dir = Textus::Layout.queue_state(@root, :ready)
|
|
36
|
-
Dir.children(ready_dir).each do |name|
|
|
37
|
-
src = File.join(ready_dir, name)
|
|
38
|
-
dst = File.join(Textus::Layout.queue_state(@root, :leased), name)
|
|
39
|
-
begin
|
|
40
|
-
File.rename(src, dst)
|
|
41
|
-
rescue Errno::ENOENT
|
|
42
|
-
next
|
|
43
|
-
end
|
|
44
|
-
job = Job.from_h(JSON.parse(File.read(dst)))
|
|
45
|
-
stamp_lease(dst, worker_id: worker_id, expires_at: Time.now.utc + lease_ttl)
|
|
46
|
-
return Leased.new(job: job, leased_path: dst)
|
|
47
|
-
end
|
|
48
|
-
nil
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def ack(leased)
|
|
52
|
-
dest = File.join(Textus::Layout.queue_state(@root, :done), File.basename(leased.leased_path))
|
|
53
|
-
File.rename(leased.leased_path, dest)
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
def fail(leased, error:)
|
|
57
|
-
job = leased.job
|
|
58
|
-
job.attempts += 1
|
|
59
|
-
job.last_error = error
|
|
60
|
-
dead = job.attempts >= job.max_attempts
|
|
61
|
-
write_atomic(path(dead ? :failed : :ready, job.id), job.to_h)
|
|
62
|
-
File.delete(leased.leased_path)
|
|
63
|
-
dead ? :dead_lettered : :requeued
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
def reclaim(now:)
|
|
67
|
-
leased_dir = Textus::Layout.queue_state(@root, :leased)
|
|
68
|
-
count = 0
|
|
69
|
-
Dir.children(leased_dir).each do |name|
|
|
70
|
-
src = File.join(leased_dir, name)
|
|
71
|
-
data = JSON.parse(File.read(src))
|
|
72
|
-
expires = data.dig("lease", "expires_at")
|
|
73
|
-
next if expires && Time.parse(expires) > now
|
|
74
|
-
|
|
75
|
-
dst = File.join(Textus::Layout.queue_state(@root, :ready), name)
|
|
76
|
-
data.delete("lease")
|
|
77
|
-
File.write(src, JSON.pretty_generate(data))
|
|
78
|
-
File.rename(src, dst)
|
|
79
|
-
count += 1
|
|
80
|
-
rescue Errno::ENOENT
|
|
81
|
-
next
|
|
82
|
-
end
|
|
83
|
-
count
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
def list(state)
|
|
87
|
-
Dir.children(Textus::Layout.queue_state(@root, state.to_sym)).map { |f| File.basename(f, ".json") }
|
|
88
|
-
end
|
|
89
|
-
|
|
90
|
-
def retry_failed(job_id)
|
|
91
|
-
src = path(:failed, job_id)
|
|
92
|
-
data = JSON.parse(File.read(src))
|
|
93
|
-
data["attempts"] = 0
|
|
94
|
-
data["last_error"] = nil
|
|
95
|
-
write_atomic(path(:ready, job_id), data)
|
|
96
|
-
File.delete(src)
|
|
97
|
-
end
|
|
98
|
-
|
|
99
|
-
def purge(state)
|
|
100
|
-
dir = Textus::Layout.queue_state(@root, state.to_sym)
|
|
101
|
-
Dir.children(dir).each { |f| File.delete(File.join(dir, f)) }
|
|
102
|
-
end
|
|
103
|
-
|
|
104
|
-
private
|
|
105
|
-
|
|
106
|
-
def stamp_lease(leased_path, worker_id:, expires_at:)
|
|
107
|
-
data = JSON.parse(File.read(leased_path))
|
|
108
|
-
data["lease"] = { "worker_id" => worker_id, "expires_at" => expires_at.iso8601 }
|
|
109
|
-
File.write(leased_path, JSON.pretty_generate(data))
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
def path(state, job_id)
|
|
113
|
-
File.join(Textus::Layout.queue_state(@root, state), "#{job_id}.json")
|
|
114
|
-
end
|
|
115
|
-
|
|
116
|
-
def write_atomic(dest, hash)
|
|
117
|
-
tmp = "#{dest}.#{Process.pid}.tmp"
|
|
118
|
-
File.write(tmp, JSON.pretty_generate(hash))
|
|
119
|
-
File.rename(tmp, dest)
|
|
120
|
-
end
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
end
|
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "yaml"
|
|
4
|
-
require "fileutils"
|
|
5
|
-
|
|
6
|
-
module Textus
|
|
7
|
-
module Ports
|
|
8
|
-
# Content-addressable index for the raw lane. Maps content hashes and URLs
|
|
9
|
-
# to their current canonical key. Lives under <root>/.state/indexes/raw.yaml
|
|
10
|
-
# (gitignored, regenerable — truth is in the on-disk raw entries).
|
|
11
|
-
class RawIndex
|
|
12
|
-
def initialize(root:)
|
|
13
|
-
@root = root
|
|
14
|
-
@path = Layout.raw_index(root)
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
attr_reader :path
|
|
18
|
-
|
|
19
|
-
def load
|
|
20
|
-
return empty_index unless File.exist?(@path)
|
|
21
|
-
|
|
22
|
-
YAML.safe_load_file(@path) || empty_index
|
|
23
|
-
rescue StandardError
|
|
24
|
-
empty_index
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def save(index)
|
|
28
|
-
FileUtils.mkdir_p(File.dirname(@path))
|
|
29
|
-
File.write(@path, YAML.dump(index))
|
|
30
|
-
index
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def find_by_hash(content_hash)
|
|
34
|
-
index = load
|
|
35
|
-
index["hashes"]&.fetch(content_hash, nil)
|
|
36
|
-
end
|
|
37
|
-
|
|
38
|
-
def find_by_url(url)
|
|
39
|
-
return nil unless url
|
|
40
|
-
|
|
41
|
-
index = load
|
|
42
|
-
index["urls"]&.fetch(url, nil)
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
def upsert(content_hash:, url:, key:)
|
|
46
|
-
index = load
|
|
47
|
-
index["hashes"] ||= {}
|
|
48
|
-
index["urls"] ||= {}
|
|
49
|
-
index["hashes"][content_hash] = key
|
|
50
|
-
index["urls"][url] = key if url
|
|
51
|
-
save(index)
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
private
|
|
55
|
-
|
|
56
|
-
def empty_index
|
|
57
|
-
{ "hashes" => {}, "urls" => {} }
|
|
58
|
-
end
|
|
59
|
-
end
|
|
60
|
-
end
|
|
61
|
-
end
|
data/lib/textus/role.rb
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
module Textus
|
|
2
|
-
module Role
|
|
3
|
-
# The three role archetypes, each string sourced exactly once: human curates
|
|
4
|
-
# canon, agent proposes, automation converges the machine-maintained lanes
|
|
5
|
-
# (refresh + materialize) (explanation/concepts.md).
|
|
6
|
-
# Reference these constants instead of bare literals (ADR 0044).
|
|
7
|
-
HUMAN = "human".freeze
|
|
8
|
-
AGENT = "agent".freeze
|
|
9
|
-
AUTOMATION = "automation".freeze
|
|
10
|
-
|
|
11
|
-
# The closed set of legal role names (ADR 0045), built FROM the archetypes
|
|
12
|
-
# above so it stays the single source of truth — a manifest declaring any
|
|
13
|
-
# other name is rejected at load, and DEFAULT ∈ NAMES holds structurally.
|
|
14
|
-
# Capabilities (`can:`) remain freely tunable per role.
|
|
15
|
-
NAMES = [HUMAN, AGENT, AUTOMATION].freeze
|
|
16
|
-
|
|
17
|
-
# Default acting identity (ADR 0040): a *choice* over the vocabulary, not a
|
|
18
|
-
# new name. CLI callers act as the human; an agent over stdio proposes and
|
|
19
|
-
# does not inherit the human's authority (it defaults to AGENT per transport).
|
|
20
|
-
DEFAULT = HUMAN
|
|
21
|
-
|
|
22
|
-
def self.resolve(root:, flag: nil, env: ENV, default: DEFAULT)
|
|
23
|
-
candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || default
|
|
24
|
-
raise InvalidRole.new(candidate) unless NAMES.include?(candidate)
|
|
25
|
-
|
|
26
|
-
candidate
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
def self.read_file(root)
|
|
30
|
-
path = File.join(root, "role")
|
|
31
|
-
return nil unless File.exist?(path)
|
|
32
|
-
|
|
33
|
-
File.read(path).strip.then { |s| s.empty? ? nil : s }
|
|
34
|
-
end
|
|
35
|
-
end
|
|
36
|
-
end
|
data/lib/textus/session.rb
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
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 Session < Dry::Struct
|
|
11
|
-
attribute :role, Types::RoleName
|
|
12
|
-
attribute :cursor, Types::Cursor
|
|
13
|
-
attribute :propose_lane, Types::String.optional
|
|
14
|
-
attribute :contract_etag, Types::String
|
|
15
|
-
|
|
16
|
-
def with(**attrs) = self.class.new(to_h.merge(attrs))
|
|
17
|
-
|
|
18
|
-
def advance_cursor(new_cursor) = with(cursor: new_cursor)
|
|
19
|
-
|
|
20
|
-
def check_etag!(observed_etag)
|
|
21
|
-
return if observed_etag == contract_etag
|
|
22
|
-
|
|
23
|
-
raise Textus::ContractDrift.new(
|
|
24
|
-
"contract changed (manifest/hooks/schemas were #{short_etag(contract_etag)}, " \
|
|
25
|
-
"now #{short_etag(observed_etag)}); re-run boot",
|
|
26
|
-
)
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
private
|
|
30
|
-
|
|
31
|
-
# First 8 hex chars after the "sha256:" prefix — a stable short id for
|
|
32
|
-
# the drift diagnostic.
|
|
33
|
-
def short_etag(etag) = etag.to_s.delete_prefix("sha256:")[0, 8]
|
|
34
|
-
end
|
|
35
|
-
end
|
data/lib/textus/types.rb
DELETED
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "dry/types"
|
|
4
|
-
|
|
5
|
-
module Textus
|
|
6
|
-
module Types
|
|
7
|
-
include Dry.Types()
|
|
8
|
-
|
|
9
|
-
RoleName = Types::String.constrained(included_in: Textus::Role::NAMES)
|
|
10
|
-
Cursor = Types::Integer.constrained(gteq: 0)
|
|
11
|
-
FormatName = Types::String.constrained(
|
|
12
|
-
included_in: %w[markdown json yaml text], # must match Format::STRATEGIES.keys
|
|
13
|
-
)
|
|
14
|
-
end
|
|
15
|
-
end
|