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
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Surface
|
|
3
3
|
class CLI
|
|
4
4
|
class Verb
|
|
5
5
|
class Get < Runner::Base
|
|
@@ -8,11 +8,11 @@ module Textus
|
|
|
8
8
|
|
|
9
9
|
def invoke(store)
|
|
10
10
|
key = positional.shift or raise UsageError.new("get requires a key")
|
|
11
|
-
|
|
12
|
-
result = store.gate.dispatch(
|
|
11
|
+
spec = Textus::Action::Get.contract
|
|
12
|
+
result = store.gate.dispatch(spec: spec, inputs: { key: key }, role: resolved_role(store), surface: :cli)
|
|
13
13
|
raise Textus::UnknownKey.new(key, suggestions: store.manifest.resolver.suggestions_for(key)) if result.nil?
|
|
14
14
|
|
|
15
|
-
emit(result
|
|
15
|
+
emit(result)
|
|
16
16
|
end
|
|
17
17
|
end
|
|
18
18
|
end
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Surface
|
|
3
3
|
class CLI
|
|
4
4
|
class Verb
|
|
5
5
|
# Launches the MCP stdio server in the current process. Blocks on stdin;
|
|
@@ -13,8 +13,8 @@ module Textus
|
|
|
13
13
|
option :as_flag, "--as=ROLE"
|
|
14
14
|
|
|
15
15
|
def call(store)
|
|
16
|
-
role = resolved_role(store, default: Textus::Role::AGENT)
|
|
17
|
-
Textus::
|
|
16
|
+
role = resolved_role(store, default: Textus::Value::Role::AGENT)
|
|
17
|
+
Textus::Surface::MCP::Server.new(store: store, stdin: @stdin, stdout: @stdout, role: role).run
|
|
18
18
|
0
|
|
19
19
|
end
|
|
20
20
|
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 Put < Runner::Base
|
|
@@ -12,16 +12,11 @@ module Textus
|
|
|
12
12
|
raise UsageError.new("put requires --stdin in v1") unless use_stdin
|
|
13
13
|
|
|
14
14
|
payload = JSON.parse(@stdin.read)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
if_etag: payload["if_etag"],
|
|
21
|
-
role: resolved_role(store),
|
|
22
|
-
)
|
|
23
|
-
result = store.gate.dispatch(cmd)
|
|
24
|
-
emit(result.to_h_for_wire)
|
|
15
|
+
spec = Textus::Action::Put.contract
|
|
16
|
+
inputs = { key: key, meta: payload["_meta"] || {}, body: payload["body"] || "",
|
|
17
|
+
content: nil, if_etag: payload["if_etag"] }
|
|
18
|
+
result = store.gate.dispatch(spec: spec, inputs: inputs, role: resolved_role(store), surface: :cli)
|
|
19
|
+
emit(result)
|
|
25
20
|
end
|
|
26
21
|
end
|
|
27
22
|
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 Watch < Verb
|
|
@@ -8,7 +8,7 @@ module Textus
|
|
|
8
8
|
option :poll, "--poll=SECONDS"
|
|
9
9
|
|
|
10
10
|
def call(store)
|
|
11
|
-
watcher = Textus::
|
|
11
|
+
watcher = Textus::Surface::Watcher.new(container: store.container)
|
|
12
12
|
watcher.run(poll: poll&.to_f)
|
|
13
13
|
0
|
|
14
14
|
end
|
|
@@ -2,7 +2,7 @@ require "json"
|
|
|
2
2
|
require "optparse"
|
|
3
3
|
|
|
4
4
|
module Textus
|
|
5
|
-
module
|
|
5
|
+
module Surface
|
|
6
6
|
class CLI
|
|
7
7
|
# Subclasses must implement #call(store) and return an integer exit code.
|
|
8
8
|
# Use #emit(obj) for normal JSON output (returns 0).
|
|
@@ -93,14 +93,9 @@ module Textus
|
|
|
93
93
|
# Resolves the active role for this invocation. Honors the verb's
|
|
94
94
|
# `--as` flag if declared, then TEXTUS_ROLE, then the project default.
|
|
95
95
|
# Pass `default:` to override the fallback (e.g. MCPServe uses AGENT).
|
|
96
|
-
def resolved_role(store, default: Role::DEFAULT)
|
|
96
|
+
def resolved_role(store, default: Value::Role::DEFAULT)
|
|
97
97
|
flag = respond_to?(:as_flag) ? as_flag : nil
|
|
98
|
-
Role.resolve(flag: flag, env: ENV, root: store.root, default: default)
|
|
99
|
-
end
|
|
100
|
-
|
|
101
|
-
# Builds a Command from spec + inputs and dispatches through Gate.
|
|
102
|
-
def gate_dispatch(cmd, store)
|
|
103
|
-
store.gate.dispatch(cmd)
|
|
98
|
+
Value::Role.resolve(flag: flag, env: ENV, root: store.root, default: default)
|
|
104
99
|
end
|
|
105
100
|
|
|
106
101
|
# The input stream — the source for a `cli_stdin` envelope (ADR 0068).
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Surface
|
|
3
3
|
module MCP
|
|
4
4
|
# Derives the entire MCP tool surface from the per-verb contracts (ADR 0039).
|
|
5
5
|
# `build_tools` builds MCP::Tool instances for the SDK; `call` is the generic
|
|
6
6
|
# dispatch: map JSON args -> (positional, keyword) per the contract, invoke
|
|
7
7
|
# the verb through the role scope, then shape the return value. No per-tool code.
|
|
8
8
|
module Catalog
|
|
9
|
+
PROJECTOR = Projector.new(view_key: :default, binder_method: :inputs_from_wire).freeze
|
|
10
|
+
|
|
9
11
|
module_function
|
|
10
12
|
|
|
11
13
|
WRITE_VERBS = %i[
|
|
@@ -41,9 +43,9 @@ module Textus
|
|
|
41
43
|
end
|
|
42
44
|
|
|
43
45
|
def names
|
|
44
|
-
|
|
45
|
-
.select { |_, klass| mcp_surfaced?(klass) }
|
|
46
|
-
|
|
46
|
+
PROJECTOR.names(
|
|
47
|
+
Textus::Action::VERBS.select { |_, klass| mcp_surfaced?(klass) },
|
|
48
|
+
)
|
|
47
49
|
end
|
|
48
50
|
|
|
49
51
|
# MCP-surfaced read verbs, by Dispatcher class namespace — the agent's
|
|
@@ -73,32 +75,13 @@ module Textus
|
|
|
73
75
|
klass.respond_to?(:contract?) && klass.contract? && klass.contract.mcp?
|
|
74
76
|
end
|
|
75
77
|
|
|
76
|
-
def call(name, session:, store:, args:)
|
|
78
|
+
def call(name, session:, store:, args:)
|
|
77
79
|
klass = Textus::Action::VERBS[name.to_sym]
|
|
78
80
|
raise ToolError.new("unknown tool: #{name}") unless klass && mcp_surfaced?(klass)
|
|
79
81
|
|
|
82
|
+
PROJECTOR.dispatch(name, inputs: args, store:, role: session.role, session:)
|
|
83
|
+
rescue Textus::Gate::MissingArgs => e
|
|
80
84
|
spec = klass.contract
|
|
81
|
-
inputs = Textus::Contract::Binder.inputs_from_wire(spec, args)
|
|
82
|
-
|
|
83
|
-
invoke = lambda do |effective_inputs|
|
|
84
|
-
pos, kwargs = Textus::Contract::Binder.bind(spec, effective_inputs, session: session)
|
|
85
|
-
spec.args.select(&:positional).zip(pos).each { |a, v| kwargs[a.name] = v unless kwargs.key?(a.name) }
|
|
86
|
-
cmd_class = Textus::Gate::VERB_COMMAND.fetch(spec.verb) do
|
|
87
|
-
raise Textus::MCP::ToolError.new("unknown verb: #{spec.verb}")
|
|
88
|
-
end
|
|
89
|
-
merged = kwargs.merge(role: session.role)
|
|
90
|
-
filled = cmd_class.members.to_h { |m| [m, merged.key?(m) ? merged[m] : nil] }
|
|
91
|
-
cmd = cmd_class.new(**filled)
|
|
92
|
-
store.gate.dispatch(cmd)
|
|
93
|
-
end
|
|
94
|
-
|
|
95
|
-
result = if spec.around
|
|
96
|
-
Textus::Contract::Around.with(spec.around, scope: store.as(session.role), inputs: inputs, session: session, &invoke)
|
|
97
|
-
else
|
|
98
|
-
invoke.call(inputs)
|
|
99
|
-
end
|
|
100
|
-
Textus::Contract::View.render(spec, :default, result, inputs)
|
|
101
|
-
rescue Textus::Contract::MissingArgs => e
|
|
102
85
|
raise ToolError.new("#{spec.verb}: missing #{e.missing.map { |a| a.wire.to_s }.join(", ")}")
|
|
103
86
|
rescue Textus::ContractDrift, CursorExpired
|
|
104
87
|
raise
|
|
@@ -3,21 +3,21 @@
|
|
|
3
3
|
require "mcp"
|
|
4
4
|
|
|
5
5
|
module Textus
|
|
6
|
-
module
|
|
6
|
+
module Surface
|
|
7
7
|
module MCP
|
|
8
8
|
# MCP stdio server backed by the official mcp gem. The SDK owns protocol
|
|
9
9
|
# negotiation, tool dispatch, and JSON-RPC framing. This class owns the
|
|
10
10
|
# textus Session lifecycle (built lazily on first tool call) and delegates
|
|
11
11
|
# execution to Catalog.
|
|
12
12
|
class Server
|
|
13
|
-
def initialize(store:, role: Textus::Role::DEFAULT, stdin: $stdin, stdout: $stdout)
|
|
13
|
+
def initialize(store:, role: Textus::Value::Role::DEFAULT, stdin: $stdin, stdout: $stdout)
|
|
14
14
|
@store = store
|
|
15
15
|
@role = role
|
|
16
16
|
@stdin = stdin
|
|
17
17
|
@stdout = stdout
|
|
18
18
|
# Session built eagerly so the contract_etag is captured at server start.
|
|
19
19
|
# Changes to manifest/hooks/schemas after this point are detected as drift.
|
|
20
|
-
@session = Textus::Session.new(
|
|
20
|
+
@session = Textus::Store::Session.new(
|
|
21
21
|
role: @role,
|
|
22
22
|
cursor: @store.audit_log.latest_seq,
|
|
23
23
|
propose_lane: @store.manifest.policy.propose_lane_for(@role),
|
|
@@ -61,7 +61,7 @@ module Textus
|
|
|
61
61
|
raise_handler_error(e.message, Textus::ContractDrift::JSONRPC_CODE)
|
|
62
62
|
rescue CursorExpired => e
|
|
63
63
|
raise_handler_error(e.message, CursorExpired::JSONRPC_CODE)
|
|
64
|
-
rescue Textus::
|
|
64
|
+
rescue Textus::Surface::MCP::ToolError => e
|
|
65
65
|
raise_handler_error(e.message, ToolError::JSONRPC_CODE)
|
|
66
66
|
rescue StandardError => e
|
|
67
67
|
raise_handler_error("internal: #{e.class}: #{e.message}", -32_603)
|
|
@@ -93,7 +93,7 @@ module Textus
|
|
|
93
93
|
raise_handler_error("resource read failed: #{e.message}", -32_603)
|
|
94
94
|
end
|
|
95
95
|
|
|
96
|
-
def contract_etag_now = Textus::Etag.for_contract(@store.root)
|
|
96
|
+
def contract_etag_now = Textus::Value::Etag.for_contract(@store.root)
|
|
97
97
|
|
|
98
98
|
# The SDK parses JSON with symbolize_names:true, making all nested hash keys symbols.
|
|
99
99
|
# Recursively stringify so Catalog.call receives string-keyed hashes throughout.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
module Textus
|
|
2
|
-
module
|
|
2
|
+
module Surface
|
|
3
3
|
# The agent gate. Stdio JSON-RPC 2.0 server speaking MCP draft 2024-11-05.
|
|
4
|
-
# Wraps Textus::
|
|
4
|
+
# Wraps Textus::Surface::RoleScope as auto-derived tools. See ADR 0015.
|
|
5
5
|
module MCP
|
|
6
6
|
end
|
|
7
7
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Surface
|
|
3
|
+
class Projector
|
|
4
|
+
def initialize(view_key: :default, binder_method: :inputs_from_wire)
|
|
5
|
+
@view_key = view_key
|
|
6
|
+
@binder_method = binder_method
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def verbs(action_verbs = Textus::Action::VERBS)
|
|
10
|
+
action_verbs.select do |_verb, klass|
|
|
11
|
+
klass.respond_to?(:contract?) && klass.contract?
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def names(action_verbs = Textus::Action::VERBS)
|
|
16
|
+
verbs(action_verbs).keys.map(&:to_s)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def dispatch(verb_name, inputs:, store:, role:, session: nil)
|
|
20
|
+
klass = Textus::Action::VERBS.fetch(verb_name.to_sym)
|
|
21
|
+
spec = klass.contract
|
|
22
|
+
bound = Textus::Gate::Binder.public_send(@binder_method, spec, inputs)
|
|
23
|
+
store.gate.dispatch(spec:, inputs: bound, role:, session:, surface: @view_key)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Textus
|
|
4
|
-
module
|
|
4
|
+
module Surface
|
|
5
5
|
# Role-scoped identity carrier. Holds the acting identity (role,
|
|
6
6
|
# correlation_id, dry_run) bound to a container. All verb methods
|
|
7
7
|
# (put, get, accept, ...) are injected by textus.rb's define_method
|
|
@@ -3,26 +3,26 @@
|
|
|
3
3
|
require "securerandom"
|
|
4
4
|
|
|
5
5
|
module Textus
|
|
6
|
-
module
|
|
6
|
+
module Surface
|
|
7
7
|
class Watcher
|
|
8
8
|
def initialize(container:)
|
|
9
9
|
@container = container
|
|
10
|
-
@queue = Textus::Ports::JobStore.new(root: container.root)
|
|
11
10
|
end
|
|
12
11
|
|
|
13
12
|
def tick
|
|
14
|
-
Textus::Jobs::
|
|
13
|
+
queue = Textus::Store::Jobs::Queue.new(store: @container.job_store)
|
|
14
|
+
Textus::Store::Jobs::Planner.seed(
|
|
15
15
|
container: @container,
|
|
16
|
-
queue:
|
|
17
|
-
role: Textus::Role::AUTOMATION,
|
|
16
|
+
queue: queue,
|
|
17
|
+
role: Textus::Value::Role::AUTOMATION,
|
|
18
18
|
)
|
|
19
|
-
|
|
20
|
-
Textus::Jobs::Worker.for(container: @container, queue:
|
|
19
|
+
queue.reclaim(now: Textus::Port::Clock.new.now)
|
|
20
|
+
Textus::Store::Jobs::Worker.for(container: @container, queue: queue).drain
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def run(poll: nil)
|
|
24
24
|
interval = poll || @container.manifest.data.worker_config[:poll]
|
|
25
|
-
lock = Textus::
|
|
25
|
+
lock = Textus::Port::WatcherLock.new(@container.root)
|
|
26
26
|
lock.acquire
|
|
27
27
|
begin
|
|
28
28
|
loop do
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
# Immutable per-invocation value. Carries who is acting (role), the
|
|
5
|
+
# request correlation id, the wall clock, and the dry_run flag — the
|
|
6
|
+
# bits Use Cases need that are not part of the Container.
|
|
7
|
+
module Value
|
|
8
|
+
Call = Data.define(:role, :correlation_id, :now, :dry_run) do
|
|
9
|
+
def self.build(role:, correlation_id: nil, now: nil, dry_run: false)
|
|
10
|
+
new(
|
|
11
|
+
role: role.to_s,
|
|
12
|
+
correlation_id: correlation_id || SecureRandom.uuid,
|
|
13
|
+
now: now || Textus::Port::Clock.new.now,
|
|
14
|
+
dry_run: dry_run,
|
|
15
|
+
)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def dry_run? = dry_run
|
|
19
|
+
|
|
20
|
+
def with_role(new_role)
|
|
21
|
+
self.class.new(
|
|
22
|
+
role: new_role.to_s,
|
|
23
|
+
correlation_id: correlation_id,
|
|
24
|
+
now: now,
|
|
25
|
+
dry_run: dry_run,
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Value
|
|
3
|
+
Command = Data.define(:verb, :params, :role) do
|
|
4
|
+
def initialize(verb:, params:, role:)
|
|
5
|
+
super
|
|
6
|
+
params.freeze
|
|
7
|
+
freeze
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def [](key) = params[key]
|
|
11
|
+
def key = params[:key]
|
|
12
|
+
def pending_key = params[:pending_key]
|
|
13
|
+
def dry_run = params[:dry_run]
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "dry-struct"
|
|
4
|
+
|
|
5
|
+
module Textus
|
|
6
|
+
module Value
|
|
7
|
+
class Envelope < Dry::Struct
|
|
8
|
+
attribute :protocol, Types::String
|
|
9
|
+
attribute :key, Types::String
|
|
10
|
+
attribute :lane, Types::String
|
|
11
|
+
attribute :owner, Types::String.optional
|
|
12
|
+
attribute :path, Types::String
|
|
13
|
+
attribute :format, Types::FormatName
|
|
14
|
+
attribute :etag, Types::String
|
|
15
|
+
attribute :uid, Types::String.optional
|
|
16
|
+
attribute :sources, Types::Array.of(Types::String).optional
|
|
17
|
+
attribute :schema_ref, Types::String.optional
|
|
18
|
+
attribute :meta, Types::Hash.default({}.freeze)
|
|
19
|
+
attribute :body, Types::String.optional
|
|
20
|
+
attribute :content, Types::Any.optional
|
|
21
|
+
attribute :freshness, Types::Any.optional
|
|
22
|
+
|
|
23
|
+
# rubocop:disable Metrics/ParameterLists
|
|
24
|
+
def self.build(key:, mentry:, path:, meta:, body:, etag:, content: nil, freshness: nil)
|
|
25
|
+
# rubocop:enable Metrics/ParameterLists
|
|
26
|
+
new(
|
|
27
|
+
protocol: Textus::PROTOCOL,
|
|
28
|
+
key: key,
|
|
29
|
+
lane: mentry.lane,
|
|
30
|
+
owner: mentry.owner,
|
|
31
|
+
path: path,
|
|
32
|
+
format: mentry.format,
|
|
33
|
+
uid: extract_uid(meta),
|
|
34
|
+
sources: extract_sources(meta),
|
|
35
|
+
etag: etag,
|
|
36
|
+
schema_ref: mentry.schema,
|
|
37
|
+
meta: meta,
|
|
38
|
+
body: body,
|
|
39
|
+
content: content,
|
|
40
|
+
freshness: freshness,
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def self.extract_uid(meta)
|
|
45
|
+
v = meta.is_a?(Hash) ? meta["uid"] : nil
|
|
46
|
+
v.is_a?(String) ? v : nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.extract_sources(meta)
|
|
50
|
+
v = meta.is_a?(Hash) ? meta["sources"] : nil
|
|
51
|
+
v.is_a?(Array) && !v.empty? ? v : nil
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def with(**attrs) = self.class.new(to_h.merge(attrs))
|
|
55
|
+
|
|
56
|
+
def to_h_for_wire
|
|
57
|
+
h = {
|
|
58
|
+
"protocol" => protocol,
|
|
59
|
+
"key" => key,
|
|
60
|
+
"lane" => lane,
|
|
61
|
+
"owner" => owner,
|
|
62
|
+
"path" => path,
|
|
63
|
+
"format" => format,
|
|
64
|
+
"_meta" => meta,
|
|
65
|
+
"body" => body,
|
|
66
|
+
"etag" => etag,
|
|
67
|
+
"schema_ref" => schema_ref,
|
|
68
|
+
"uid" => uid,
|
|
69
|
+
}
|
|
70
|
+
h["sources"] = sources if sources
|
|
71
|
+
h["content"] = content unless content.nil?
|
|
72
|
+
freshness&.to_h_for_wire&.each { |k, v| h[k] = v }
|
|
73
|
+
h
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def stale?
|
|
77
|
+
return false if freshness.nil?
|
|
78
|
+
|
|
79
|
+
freshness.stale == true
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def fetching?
|
|
83
|
+
return false if freshness.nil?
|
|
84
|
+
|
|
85
|
+
freshness.fetching == true
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Value
|
|
5
|
+
module Etag
|
|
6
|
+
def self.for_bytes(bytes)
|
|
7
|
+
"sha256:#{Digest::SHA256.hexdigest(bytes)}"
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def self.for_file(path)
|
|
11
|
+
for_bytes(File.binread(path))
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
# The fingerprint of everything an agent's boot orientation depends on:
|
|
15
|
+
# the manifest PLUS the executable contract — hooks and schemas. A
|
|
16
|
+
# mid-session edit to any of these makes the cached orientation stale, so
|
|
17
|
+
# the session must re-boot (ADR 0074). The composite is one digest over the
|
|
18
|
+
# sorted per-file listing, so it is order-stable.
|
|
19
|
+
def self.for_contract(root)
|
|
20
|
+
listing = contract_files(root).map do |path|
|
|
21
|
+
rel = path.delete_prefix(root).delete_prefix("/")
|
|
22
|
+
"#{rel}:#{for_file(path)}"
|
|
23
|
+
end.join("\n")
|
|
24
|
+
for_bytes(listing)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# manifest.yaml, then every hook and schema file. Dir.glob already returns
|
|
28
|
+
# sorted paths (Ruby 3.0+), keeping the digest independent of FS order.
|
|
29
|
+
def self.contract_files(root)
|
|
30
|
+
geom = Textus::Store::Geometry.new(root)
|
|
31
|
+
[
|
|
32
|
+
geom.manifest_path,
|
|
33
|
+
*Dir.glob(File.join(geom.hooks_dir, "**", "*.rb")),
|
|
34
|
+
*Dir.glob(geom.schemas_glob).select { |f| File.file?(f) },
|
|
35
|
+
]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Textus
|
|
4
|
+
module Value
|
|
5
|
+
# Unwraps Dry::Monads results at the Gate seam.
|
|
6
|
+
# Every action returns Success(value) or Failure(code:, message:, details:).
|
|
7
|
+
# This module converts Failure into an ActionError for surfaces (CLI, MCP)
|
|
8
|
+
# that expect exceptions.
|
|
9
|
+
module Result
|
|
10
|
+
def self.unwrap(result)
|
|
11
|
+
case result
|
|
12
|
+
when Dry::Monads::Result::Success then result.value!
|
|
13
|
+
when Dry::Monads::Result::Failure
|
|
14
|
+
failure = result.failure
|
|
15
|
+
raise ActionError.new(
|
|
16
|
+
failure[:code] || :internal,
|
|
17
|
+
failure[:message] || "action failed",
|
|
18
|
+
details: failure[:details] || {},
|
|
19
|
+
)
|
|
20
|
+
else
|
|
21
|
+
result
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Value
|
|
3
|
+
module Role
|
|
4
|
+
# The three role archetypes, each string sourced exactly once: human curates
|
|
5
|
+
# canon, agent proposes, automation converges the machine-maintained lanes
|
|
6
|
+
# (refresh + materialize) (explanation/concepts.md).
|
|
7
|
+
# Reference these constants instead of bare literals (ADR 0044).
|
|
8
|
+
HUMAN = "human".freeze
|
|
9
|
+
AGENT = "agent".freeze
|
|
10
|
+
AUTOMATION = "automation".freeze
|
|
11
|
+
|
|
12
|
+
# The closed set of legal role names (ADR 0045), built FROM the archetypes
|
|
13
|
+
# above so it stays the single source of truth — a manifest declaring any
|
|
14
|
+
# other name is rejected at load, and DEFAULT ∈ NAMES holds structurally.
|
|
15
|
+
# Capabilities (`can:`) remain freely tunable per role.
|
|
16
|
+
NAMES = [HUMAN, AGENT, AUTOMATION].freeze
|
|
17
|
+
|
|
18
|
+
# Default acting identity (ADR 0040): a *choice* over the vocabulary, not a
|
|
19
|
+
# new name. CLI callers act as the human; an agent over stdio proposes and
|
|
20
|
+
# does not inherit the human's authority (it defaults to AGENT per transport).
|
|
21
|
+
DEFAULT = HUMAN
|
|
22
|
+
|
|
23
|
+
def self.resolve(root:, flag: nil, env: ENV, default: DEFAULT)
|
|
24
|
+
candidate = flag || env["TEXTUS_ROLE"] || read_file(root) || default
|
|
25
|
+
raise InvalidRole.new(candidate) unless NAMES.include?(candidate)
|
|
26
|
+
|
|
27
|
+
candidate
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def self.read_file(root)
|
|
31
|
+
path = File.join(root, "role")
|
|
32
|
+
return nil unless File.exist?(path)
|
|
33
|
+
|
|
34
|
+
File.read(path).strip.then { |s| s.empty? ? nil : s }
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
module Textus
|
|
2
|
+
module Value
|
|
3
|
+
module Types
|
|
4
|
+
include Dry.Types()
|
|
5
|
+
|
|
6
|
+
RoleName = Types::String.constrained(included_in: Textus::Value::Role::NAMES)
|
|
7
|
+
Cursor = Types::Integer.constrained(gteq: 0)
|
|
8
|
+
FormatName = Types::String.constrained(
|
|
9
|
+
included_in: %w[markdown json yaml text], # must match Format::STRATEGIES.keys
|
|
10
|
+
)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
@@ -4,15 +4,17 @@ module Textus
|
|
|
4
4
|
# A Textus UID: 16 lowercase hex chars (SecureRandom.hex(8)). Not a UUID —
|
|
5
5
|
# short on purpose. Random enough for collision-never-in-practice within a
|
|
6
6
|
# single store.
|
|
7
|
-
module
|
|
8
|
-
|
|
7
|
+
module Value
|
|
8
|
+
module Uid
|
|
9
|
+
module_function
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
def mint
|
|
12
|
+
SecureRandom.hex(8)
|
|
13
|
+
end
|
|
13
14
|
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
def valid?(str)
|
|
16
|
+
str.is_a?(String) && str.match?(/\A[0-9a-f]{16}\z/)
|
|
17
|
+
end
|
|
16
18
|
end
|
|
17
19
|
end
|
|
18
20
|
end
|
data/lib/textus/version.rb
CHANGED
|
@@ -2,13 +2,13 @@ module Textus
|
|
|
2
2
|
module Workflow
|
|
3
3
|
class Loader
|
|
4
4
|
def self.load_all(root)
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
return registry unless File.directory?(
|
|
5
|
+
geometry = Textus::Store::Geometry.new(root)
|
|
6
|
+
registry = Registry.new
|
|
7
|
+
return registry unless File.directory?(geometry.workflow_dir)
|
|
8
8
|
|
|
9
9
|
collector = Collector.new(registry)
|
|
10
10
|
Collector.with(collector) do
|
|
11
|
-
Dir.glob(File.join(
|
|
11
|
+
Dir.glob(File.join(geometry.workflow_dir, "**", "*.rb")).each { |path| load path }
|
|
12
12
|
end
|
|
13
13
|
registry
|
|
14
14
|
end
|