jazari 0.7.0 → 0.8.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 +40 -0
- data/README.md +31 -9
- data/exe/jazari +96 -0
- data/lib/generators/jazari/install/install_generator.rb +4 -3
- data/lib/generators/jazari/install/templates/create_jazari_tables.rb +25 -8
- data/lib/generators/jazari/upgrade/upgrade_generator.rb +1 -1
- data/lib/jazari/checklist.rb +2 -2
- data/lib/jazari/mcp/actions.rb +58 -13
- data/lib/jazari/mcp/stdio_server.rb +191 -0
- data/lib/jazari/operations.rb +2 -0
- data/lib/jazari/protocol/dispatcher.rb +271 -0
- data/lib/jazari/runs.rb +2 -0
- data/lib/jazari/standalone/database.rb +54 -0
- data/lib/jazari/standalone.rb +36 -0
- data/lib/jazari/version.rb +1 -1
- data/protocol/v1/capability.schema.json +22 -0
- data/protocol/v1/error.schema.json +18 -0
- data/protocol/v1/fixtures/actor-attribution/scenario.json +8 -0
- data/protocol/v1/fixtures/daily-idempotency/scenario.json +8 -0
- data/protocol/v1/fixtures/evidence/scenario.json +8 -0
- data/protocol/v1/fixtures/operator-edit-wins/scenario.json +10 -0
- data/protocol/v1/fixtures/resolve-default/scenario.json +7 -0
- data/protocol/v1/fixtures/revision-conflict/scenario.json +7 -0
- data/protocol/v1/fixtures/run-closed/scenario.json +9 -0
- data/protocol/v1/fixtures/run-snapshot/scenario.json +9 -0
- data/protocol/v1/request.schema.json +215 -0
- data/protocol/v1/response.schema.json +124 -0
- metadata +52 -6
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "jazari/version"
|
|
5
|
+
require "jazari/mcp/actions"
|
|
6
|
+
|
|
7
|
+
module Jazari
|
|
8
|
+
module Mcp
|
|
9
|
+
class StdioServer
|
|
10
|
+
PROTOCOL_VERSION = "2025-11-25"
|
|
11
|
+
SUPPORTED_VERSIONS = %w[2024-11-05 2025-03-26 2025-06-18 2025-11-25].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(dispatcher:, version: Jazari::VERSION)
|
|
14
|
+
@dispatcher = dispatcher
|
|
15
|
+
@version = version
|
|
16
|
+
@initialization_response_sent = false
|
|
17
|
+
@initialized = false
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def serve(input: $stdin, output: $stdout)
|
|
21
|
+
input.each_line do |line|
|
|
22
|
+
next if line.strip.empty?
|
|
23
|
+
|
|
24
|
+
message = JSON.parse(line)
|
|
25
|
+
response = receive(message)
|
|
26
|
+
output.puts(JSON.generate(response)) if response
|
|
27
|
+
output.flush
|
|
28
|
+
rescue JSON::ParserError
|
|
29
|
+
output.puts(JSON.generate(jsonrpc_error(nil, -32_700, "Parse error")))
|
|
30
|
+
output.flush
|
|
31
|
+
rescue StandardError
|
|
32
|
+
id = message.is_a?(Hash) ? message["id"] : nil
|
|
33
|
+
output.puts(JSON.generate(jsonrpc_error(id, -32_603, "Internal error")))
|
|
34
|
+
output.flush
|
|
35
|
+
end
|
|
36
|
+
0
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def receive(message)
|
|
40
|
+
id = message.is_a?(Hash) ? message["id"] : nil
|
|
41
|
+
return jsonrpc_error(id, -32_600, "Invalid Request") unless valid_message?(message)
|
|
42
|
+
return receive_notification(message) unless message.key?("id")
|
|
43
|
+
|
|
44
|
+
case message["method"]
|
|
45
|
+
when "initialize" then initialize_response(message)
|
|
46
|
+
when "ping" then empty_response(message)
|
|
47
|
+
else
|
|
48
|
+
return jsonrpc_error(message["id"], -32_002, "Server not initialized") unless @initialized
|
|
49
|
+
|
|
50
|
+
operational_response(message)
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def operational_response(message)
|
|
55
|
+
case message["method"]
|
|
56
|
+
when "tools/list" then list_tools(message)
|
|
57
|
+
when "tools/call" then call_tool(message)
|
|
58
|
+
else jsonrpc_error(message["id"], -32_601, "Method not found")
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def valid_message?(message)
|
|
65
|
+
message.is_a?(Hash) && message["jsonrpc"] == "2.0" && message["method"].is_a?(String) &&
|
|
66
|
+
(!message.key?("id") || message["id"].is_a?(String) || message["id"].is_a?(Numeric))
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def initialize_response(message)
|
|
70
|
+
return jsonrpc_error(message["id"], -32_600, "Already initialized") if @initialization_response_sent
|
|
71
|
+
return jsonrpc_error(message["id"], -32_602, "Invalid params") unless valid_initialize_params?(message["params"])
|
|
72
|
+
|
|
73
|
+
requested = message.dig("params", "protocolVersion")
|
|
74
|
+
negotiated = SUPPORTED_VERSIONS.include?(requested) ? requested : PROTOCOL_VERSION
|
|
75
|
+
@initialization_response_sent = true
|
|
76
|
+
jsonrpc_result(
|
|
77
|
+
message["id"],
|
|
78
|
+
protocolVersion: negotiated,
|
|
79
|
+
capabilities: { tools: { listChanged: false } },
|
|
80
|
+
serverInfo: {
|
|
81
|
+
name: "jazari", title: "Jazari", version: @version,
|
|
82
|
+
description: "Addressable operating procedures and run evidence."
|
|
83
|
+
},
|
|
84
|
+
instructions: "Authorize the caller before constructing a target. Jazari records procedure state; it does not execute commands."
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def valid_initialize_params?(params)
|
|
89
|
+
params.is_a?(Hash) && params["protocolVersion"].is_a?(String) &&
|
|
90
|
+
params["capabilities"].is_a?(Hash) && params["clientInfo"].is_a?(Hash) &&
|
|
91
|
+
params.dig("clientInfo", "name").is_a?(String) &&
|
|
92
|
+
params.dig("clientInfo", "version").is_a?(String)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def receive_notification(message)
|
|
96
|
+
if message["method"] == "notifications/initialized" && @initialization_response_sent
|
|
97
|
+
@initialized = true
|
|
98
|
+
end
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def empty_response(message)
|
|
103
|
+
return jsonrpc_error(message["id"], -32_602, "Invalid params") unless object_params?(message)
|
|
104
|
+
|
|
105
|
+
jsonrpc_result(message["id"], {})
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def list_tools(message)
|
|
109
|
+
return jsonrpc_error(message["id"], -32_602, "Invalid params") unless object_params?(message)
|
|
110
|
+
|
|
111
|
+
jsonrpc_result(message["id"], tools: [ tool ])
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def object_params?(message)
|
|
115
|
+
!message.key?("params") || message["params"].is_a?(Hash)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def tool
|
|
119
|
+
{
|
|
120
|
+
name: "jazari",
|
|
121
|
+
title: "Jazari procedure state",
|
|
122
|
+
description: "Resolve or update a procedure and record run evidence.",
|
|
123
|
+
inputSchema: Actions.input_schema(target_schema: target_schema),
|
|
124
|
+
annotations: {
|
|
125
|
+
readOnlyHint: false, destructiveHint: true,
|
|
126
|
+
idempotentHint: false, openWorldHint: false
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def target_schema
|
|
132
|
+
{
|
|
133
|
+
oneOf: [
|
|
134
|
+
{
|
|
135
|
+
type: "object", required: %w[kind key recipe_id],
|
|
136
|
+
properties: {
|
|
137
|
+
kind: { const: "queue" }, key: { type: "string", minLength: 1 },
|
|
138
|
+
recipe_id: { type: "string", minLength: 1 },
|
|
139
|
+
public_reference: { type: "object" }
|
|
140
|
+
},
|
|
141
|
+
additionalProperties: false
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
type: "object", required: %w[kind scope_type scope_id key recipe_id],
|
|
145
|
+
properties: {
|
|
146
|
+
kind: { const: "anchor" }, scope_type: { type: "string", minLength: 1 },
|
|
147
|
+
scope_id: { type: "integer" }, key: { type: "string", minLength: 1 },
|
|
148
|
+
recipe_id: { type: "string", minLength: 1 },
|
|
149
|
+
public_reference: { type: "object" }
|
|
150
|
+
},
|
|
151
|
+
additionalProperties: false
|
|
152
|
+
}
|
|
153
|
+
]
|
|
154
|
+
}
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def call_tool(message)
|
|
158
|
+
params = message.fetch("params", {})
|
|
159
|
+
return jsonrpc_error(message["id"], -32_602, "Unknown tool") unless params["name"] == "jazari"
|
|
160
|
+
|
|
161
|
+
arguments = params.fetch("arguments", {})
|
|
162
|
+
return jsonrpc_error(message["id"], -32_602, "Invalid params") unless arguments.is_a?(Hash)
|
|
163
|
+
|
|
164
|
+
action = arguments["action"]
|
|
165
|
+
target = arguments["target"]
|
|
166
|
+
action_arguments = arguments.reject { |key, _| %w[action target].include?(key) }
|
|
167
|
+
response = @dispatcher.call(
|
|
168
|
+
"protocol" => Protocol::Dispatcher::VERSION,
|
|
169
|
+
"request_id" => "mcp:#{message.fetch('id')}",
|
|
170
|
+
"action" => action,
|
|
171
|
+
"target" => target,
|
|
172
|
+
"arguments" => action_arguments
|
|
173
|
+
)
|
|
174
|
+
result = {
|
|
175
|
+
content: [ { type: "text", text: JSON.generate(response) } ],
|
|
176
|
+
structuredContent: response,
|
|
177
|
+
isError: response["ok"] == false
|
|
178
|
+
}
|
|
179
|
+
jsonrpc_result(message["id"], result)
|
|
180
|
+
rescue KeyError, TypeError
|
|
181
|
+
jsonrpc_error(message["id"], -32_602, "Invalid params")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def jsonrpc_result(id, result) = { jsonrpc: "2.0", id: id, result: result }
|
|
185
|
+
|
|
186
|
+
def jsonrpc_error(id, code, message)
|
|
187
|
+
{ jsonrpc: "2.0", id: id, error: { code: code, message: message } }
|
|
188
|
+
end
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
data/lib/jazari/operations.rb
CHANGED
|
@@ -142,6 +142,8 @@ module Jazari
|
|
|
142
142
|
create_custom(target, expected_revision, origin: origin) { |current| yield(current) }
|
|
143
143
|
end
|
|
144
144
|
resolve(target: target)
|
|
145
|
+
rescue ActiveRecord::StaleObjectError
|
|
146
|
+
raise RevisionConflict, "another writer changed this runbook first"
|
|
145
147
|
end
|
|
146
148
|
private_class_method :write
|
|
147
149
|
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "date"
|
|
4
|
+
require "time"
|
|
5
|
+
require "active_record"
|
|
6
|
+
require "jazari"
|
|
7
|
+
require "jazari/mcp/handler"
|
|
8
|
+
|
|
9
|
+
module Jazari
|
|
10
|
+
module Protocol
|
|
11
|
+
class InvalidRequest < StandardError; end
|
|
12
|
+
|
|
13
|
+
class Dispatcher
|
|
14
|
+
VERSION = "jazari/v1"
|
|
15
|
+
ACTIONS = ([ "capabilities" ] + Mcp::Actions::NAMES).freeze
|
|
16
|
+
REQUEST_KEYS = %w[protocol request_id action target arguments].freeze
|
|
17
|
+
DOMAIN_ERRORS = %w[
|
|
18
|
+
target_not_found invalid_runbook revision_conflict item_not_found
|
|
19
|
+
item_not_in_snapshot read_only_target run_closed
|
|
20
|
+
].freeze
|
|
21
|
+
|
|
22
|
+
def initialize(handler: Mcp::Handler.new)
|
|
23
|
+
@handler = handler
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def capabilities
|
|
27
|
+
adapter = Jazari::ApplicationRecord.connection.adapter_name.downcase
|
|
28
|
+
storage = if adapter.include?("sqlite")
|
|
29
|
+
"sqlite"
|
|
30
|
+
elsif adapter.include?("postgres")
|
|
31
|
+
"postgresql"
|
|
32
|
+
else
|
|
33
|
+
raise ActiveRecord::AdapterNotSpecified, "jazari/v1 does not support the #{adapter} adapter"
|
|
34
|
+
end
|
|
35
|
+
{
|
|
36
|
+
protocol: VERSION,
|
|
37
|
+
storage: storage,
|
|
38
|
+
transports: storage == "sqlite" ? %w[cli mcp-stdio] : %w[ruby],
|
|
39
|
+
features: %w[recipes runbooks runs evidence]
|
|
40
|
+
}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def call(input)
|
|
44
|
+
request = stringify(input)
|
|
45
|
+
validate_request!(request)
|
|
46
|
+
action = request.fetch("action")
|
|
47
|
+
result = if action == "capabilities"
|
|
48
|
+
capabilities
|
|
49
|
+
else
|
|
50
|
+
@handler.call(
|
|
51
|
+
action: action,
|
|
52
|
+
target: target_from(request.fetch("target")),
|
|
53
|
+
arguments: request.fetch("arguments")
|
|
54
|
+
)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
envelope(request, result)
|
|
58
|
+
rescue InvalidRequest => error
|
|
59
|
+
error_envelope(input, "invalid_request", error.message)
|
|
60
|
+
rescue ActiveRecord::RecordNotFound
|
|
61
|
+
error_envelope(input, "target_not_found")
|
|
62
|
+
rescue ActiveRecord::StatementInvalid => error
|
|
63
|
+
code = sqlite_busy?(error) ? "storage_busy" : "storage_failure"
|
|
64
|
+
error_envelope(input, code)
|
|
65
|
+
rescue StandardError
|
|
66
|
+
error_envelope(input, "storage_failure")
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
private
|
|
70
|
+
|
|
71
|
+
def validate_request!(request)
|
|
72
|
+
invalid!("request must be an object") unless request.is_a?(Hash)
|
|
73
|
+
unknown = request.keys - REQUEST_KEYS
|
|
74
|
+
invalid!("unknown request fields: #{unknown.join(', ')}") if unknown.any?
|
|
75
|
+
invalid!("protocol must be #{VERSION}") unless request["protocol"] == VERSION
|
|
76
|
+
invalid!("request_id must be a non-empty string") unless request["request_id"].is_a?(String) && !request["request_id"].empty?
|
|
77
|
+
|
|
78
|
+
action = request["action"]
|
|
79
|
+
invalid!("unknown action #{action.inspect}") unless ACTIONS.include?(action)
|
|
80
|
+
invalid!("arguments is required") unless request.key?("arguments")
|
|
81
|
+
arguments = request["arguments"]
|
|
82
|
+
invalid!("arguments must be an object") unless arguments.is_a?(Hash)
|
|
83
|
+
|
|
84
|
+
if action == "capabilities"
|
|
85
|
+
invalid!("capabilities does not accept a target") if request.key?("target")
|
|
86
|
+
invalid!("capabilities does not accept arguments") unless arguments.empty?
|
|
87
|
+
return
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
invalid!("target is required") unless request["target"].is_a?(Hash)
|
|
91
|
+
validate_arguments!(action, arguments)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def validate_arguments!(action, arguments)
|
|
95
|
+
descriptor = Mcp::Actions.fetch(action)
|
|
96
|
+
allowed = descriptor.params.keys.map(&:to_s)
|
|
97
|
+
unknown = arguments.keys - allowed
|
|
98
|
+
invalid!("unknown #{action} arguments: #{unknown.join(', ')}") if unknown.any?
|
|
99
|
+
missing = descriptor.required.map(&:to_s) - arguments.keys
|
|
100
|
+
invalid!("missing #{action} arguments: #{missing.join(', ')}") if missing.any?
|
|
101
|
+
|
|
102
|
+
arguments.each do |key, value|
|
|
103
|
+
validate_type!(key, value, descriptor.params.fetch(key.to_sym))
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def validate_type!(key, value, schema)
|
|
108
|
+
valid = case schema[:type]
|
|
109
|
+
when "string" then value.is_a?(String)
|
|
110
|
+
when "integer" then value.is_a?(Integer)
|
|
111
|
+
when "boolean" then value == true || value == false
|
|
112
|
+
when "array" then value.is_a?(Array)
|
|
113
|
+
else true
|
|
114
|
+
end
|
|
115
|
+
invalid!("#{key} must be a #{schema[:type]}") unless valid
|
|
116
|
+
invalid!("#{key} must be #{schema[:const].inspect}") if schema.key?(:const) && value != schema[:const]
|
|
117
|
+
invalid!("unknown #{key} #{value.inspect}") if schema[:enum] && !schema[:enum].include?(value)
|
|
118
|
+
invalid!("#{key} must be at least #{schema[:minimum]}") if schema[:minimum] && value < schema[:minimum]
|
|
119
|
+
validate_value!(key, value)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def validate_value!(key, value)
|
|
123
|
+
case key
|
|
124
|
+
when "expected_revision", "actor_ref"
|
|
125
|
+
invalid!("#{key} must not be empty") if value.empty?
|
|
126
|
+
when "item_id"
|
|
127
|
+
invalid!("item_id must not be empty") if value.empty?
|
|
128
|
+
when "run_id"
|
|
129
|
+
invalid!("run_id must be positive") unless value.positive?
|
|
130
|
+
when "topic"
|
|
131
|
+
invalid!("topic must not be empty") if value.empty?
|
|
132
|
+
invalid!("topic exceeds #{Operations::MAX_TOPIC}") if value.length > Operations::MAX_TOPIC
|
|
133
|
+
when "description"
|
|
134
|
+
invalid!("description exceeds #{Operations::MAX_DESCRIPTION}") if value.length > Operations::MAX_DESCRIPTION
|
|
135
|
+
when "note"
|
|
136
|
+
invalid!("note exceeds #{Checklist::MAX_TEXT}") if value.length > Checklist::MAX_TEXT
|
|
137
|
+
when "text"
|
|
138
|
+
invalid!("text must not be empty") if value.empty?
|
|
139
|
+
invalid!("text exceeds #{Checklist::MAX_TEXT}") if value.length > Checklist::MAX_TEXT
|
|
140
|
+
when "checklist"
|
|
141
|
+
validate_checklist!(value)
|
|
142
|
+
when "kind"
|
|
143
|
+
invalid!("unknown evidence kind #{value.inspect}") unless Runs::EVIDENCE_KINDS.include?(value)
|
|
144
|
+
when "value"
|
|
145
|
+
invalid!("evidence value exceeds #{Runs::MAX_EVIDENCE}") if value.length > Runs::MAX_EVIDENCE
|
|
146
|
+
when "outcome"
|
|
147
|
+
invalid!("unknown outcome #{value.inspect}") unless Run::OUTCOMES.include?(value)
|
|
148
|
+
when "confirm"
|
|
149
|
+
invalid!("confirm must be true") unless value
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def validate_checklist!(items)
|
|
154
|
+
items.each do |item|
|
|
155
|
+
invalid!("checklist items must be objects") unless item.is_a?(Hash)
|
|
156
|
+
entry = stringify(item)
|
|
157
|
+
unknown = entry.keys - %w[id text done required]
|
|
158
|
+
invalid!("unknown checklist fields: #{unknown.join(', ')}") if unknown.any?
|
|
159
|
+
invalid!("checklist item text is required") unless entry["text"].is_a?(String) && !entry["text"].empty?
|
|
160
|
+
invalid!("checklist item text exceeds #{Checklist::MAX_TEXT}") if entry["text"].length > Checklist::MAX_TEXT
|
|
161
|
+
invalid!("checklist item id is invalid") if entry.key?("id") &&
|
|
162
|
+
(!entry["id"].is_a?(String) || !entry["id"].match?(Checklist::ID_FORMAT))
|
|
163
|
+
%w[done required].each do |flag|
|
|
164
|
+
invalid!("checklist item #{flag} must be a boolean") if entry.key?(flag) && ![ true, false ].include?(entry[flag])
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def target_from(target)
|
|
170
|
+
kind = target["kind"]
|
|
171
|
+
case kind
|
|
172
|
+
when "queue"
|
|
173
|
+
exact_keys!(target, %w[kind key recipe_id public_reference])
|
|
174
|
+
QueueTarget.new(
|
|
175
|
+
queue: required_string(target, "key"),
|
|
176
|
+
recipe_id: required_string(target, "recipe_id"),
|
|
177
|
+
public_reference: public_reference(target)
|
|
178
|
+
)
|
|
179
|
+
when "anchor"
|
|
180
|
+
exact_keys!(target, %w[kind scope_type scope_id key recipe_id public_reference])
|
|
181
|
+
scope_type = required_string(target, "scope_type")
|
|
182
|
+
AnchorTarget.new(
|
|
183
|
+
scope_type: scope_type,
|
|
184
|
+
scope_id: required_integer(target, "scope_id"),
|
|
185
|
+
key: required_string(target, "key"),
|
|
186
|
+
recipe_id: required_string(target, "recipe_id"),
|
|
187
|
+
public_reference: public_reference(target)
|
|
188
|
+
)
|
|
189
|
+
else
|
|
190
|
+
invalid!("target kind must be queue or anchor")
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
def exact_keys!(value, allowed)
|
|
195
|
+
unknown = value.keys - allowed
|
|
196
|
+
invalid!("unknown target fields: #{unknown.join(', ')}") if unknown.any?
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def required_string(value, key)
|
|
200
|
+
result = value[key]
|
|
201
|
+
invalid!("target #{key} must be a non-empty string") unless result.is_a?(String) && !result.empty?
|
|
202
|
+
result
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def required_integer(value, key)
|
|
206
|
+
result = value[key]
|
|
207
|
+
invalid!("target #{key} must be an integer") unless result.is_a?(Integer)
|
|
208
|
+
result
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
def public_reference(target)
|
|
212
|
+
fallback_keys = %w[kind scope_type scope_id key]
|
|
213
|
+
value = target.fetch("public_reference", target.select { |key, _| fallback_keys.include?(key) })
|
|
214
|
+
invalid!("target public_reference must be an object") unless value.is_a?(Hash)
|
|
215
|
+
value
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def envelope(request, result)
|
|
219
|
+
normalized = normalize(result)
|
|
220
|
+
if normalized.delete("ok") == false
|
|
221
|
+
code = normalized.fetch("error")
|
|
222
|
+
return error_envelope(request, DOMAIN_ERRORS.include?(code) ? code : "storage_failure")
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
normalized["revision"] = normalized["revision"].to_s if normalized.key?("revision")
|
|
226
|
+
{
|
|
227
|
+
"protocol" => VERSION,
|
|
228
|
+
"request_id" => request.fetch("request_id"),
|
|
229
|
+
"action" => request.fetch("action"),
|
|
230
|
+
"ok" => true,
|
|
231
|
+
"result" => normalized
|
|
232
|
+
}
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def error_envelope(input, code, detail = nil)
|
|
236
|
+
request = input.is_a?(Hash) ? stringify(input) : {}
|
|
237
|
+
error = { "code" => code }
|
|
238
|
+
error["detail"] = detail.to_s unless detail.to_s.empty?
|
|
239
|
+
{
|
|
240
|
+
"protocol" => VERSION,
|
|
241
|
+
"request_id" => request["request_id"].to_s,
|
|
242
|
+
"action" => request["action"].to_s,
|
|
243
|
+
"ok" => false,
|
|
244
|
+
"error" => error
|
|
245
|
+
}
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
def normalize(value)
|
|
249
|
+
case value
|
|
250
|
+
when Hash then value.to_h { |key, nested| [ key.to_s, normalize(nested) ] }
|
|
251
|
+
when Array then value.map { |nested| normalize(nested) }
|
|
252
|
+
when Time then value.utc.iso8601
|
|
253
|
+
when Date then value.iso8601
|
|
254
|
+
when NilClass then nil
|
|
255
|
+
else value.respond_to?(:to_h) ? normalize(value.to_h) : value
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
def stringify(value)
|
|
260
|
+
return value unless value.is_a?(Hash)
|
|
261
|
+
value.to_h { |key, nested| [ key.to_s, nested.is_a?(Hash) ? stringify(nested) : nested ] }
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
def sqlite_busy?(error)
|
|
265
|
+
error.cause&.class&.name.to_s.include?("Busy") || error.message.include?("database is locked")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def invalid!(message) = raise InvalidRequest, message
|
|
269
|
+
end
|
|
270
|
+
end
|
|
271
|
+
end
|
data/lib/jazari/runs.rb
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jazari
|
|
4
|
+
module Standalone
|
|
5
|
+
module Database
|
|
6
|
+
TABLES = Jazari::TABLES.keys.freeze
|
|
7
|
+
BUSY_TIMEOUT = 5_000
|
|
8
|
+
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
def connect!(path)
|
|
12
|
+
raise ArgumentError, "database path is required" if path.to_s.empty?
|
|
13
|
+
raise ArgumentError, "standalone storage requires a SQLite file, not :memory:" if path.to_s == ":memory:"
|
|
14
|
+
|
|
15
|
+
configure_local_anchors!
|
|
16
|
+
path = File.expand_path(path.to_s)
|
|
17
|
+
|
|
18
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
19
|
+
ActiveRecord::Base.establish_connection(
|
|
20
|
+
adapter: "sqlite3", database: path, timeout: BUSY_TIMEOUT, pool: 5
|
|
21
|
+
)
|
|
22
|
+
connection = Jazari::ApplicationRecord.connection
|
|
23
|
+
connection.execute("PRAGMA foreign_keys = ON")
|
|
24
|
+
connection.execute("PRAGMA journal_mode = WAL")
|
|
25
|
+
connection.execute("PRAGMA busy_timeout = #{BUSY_TIMEOUT}")
|
|
26
|
+
migrate!(connection)
|
|
27
|
+
connection
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def migrate!(connection, migration: CreateJazariTables.new)
|
|
31
|
+
connection.raw_connection.transaction(:immediate) do
|
|
32
|
+
existing = TABLES.select { |key| connection.data_source_exists?(Jazari.table_name_for(key)) }
|
|
33
|
+
next if existing.length == TABLES.length
|
|
34
|
+
|
|
35
|
+
if existing.any?
|
|
36
|
+
raise "partial Jazari schema: found #{existing.join(', ')}; refusing an implicit repair"
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
ActiveRecord::Migration.suppress_messages do
|
|
40
|
+
migration.migrate(:up)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def configure_local_anchors!
|
|
46
|
+
Jazari.configure do |config|
|
|
47
|
+
config.table_prefix = "jazari_"
|
|
48
|
+
config.table_names = {}
|
|
49
|
+
config.anchor_scopes = { "local" => nil }
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_record"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "jazari"
|
|
7
|
+
require "jazari/mcp/handler"
|
|
8
|
+
|
|
9
|
+
require_relative "../../app/models/jazari/application_record"
|
|
10
|
+
require_relative "../../app/models/jazari/recipe_record"
|
|
11
|
+
require_relative "../../app/models/jazari/runbook"
|
|
12
|
+
require_relative "../../app/models/jazari/anchor"
|
|
13
|
+
require_relative "../../app/models/jazari/run"
|
|
14
|
+
require_relative "../generators/jazari/install/templates/create_jazari_tables"
|
|
15
|
+
require "jazari/standalone/database"
|
|
16
|
+
require "jazari/protocol/dispatcher"
|
|
17
|
+
require "jazari/mcp/stdio_server"
|
|
18
|
+
|
|
19
|
+
module Jazari
|
|
20
|
+
module Standalone
|
|
21
|
+
class App
|
|
22
|
+
attr_reader :dispatcher
|
|
23
|
+
|
|
24
|
+
def initialize(database:, recipes: [])
|
|
25
|
+
Database.connect!(database)
|
|
26
|
+
Array(recipes).each do |path|
|
|
27
|
+
RecipeRegistry.seed!(RecipeFiles.load(path))
|
|
28
|
+
end
|
|
29
|
+
@dispatcher = Protocol::Dispatcher.new
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def call(request) = dispatcher.call(request)
|
|
33
|
+
def capabilities = dispatcher.capabilities
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
data/lib/jazari/version.rb
CHANGED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/nauman/jazari/protocol/v1/capability.schema.json",
|
|
4
|
+
"title": "Jazari v1 capabilities",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["protocol", "storage", "transports", "features"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"protocol": { "const": "jazari/v1" },
|
|
9
|
+
"storage": { "enum": ["postgresql", "sqlite"] },
|
|
10
|
+
"transports": {
|
|
11
|
+
"type": "array",
|
|
12
|
+
"items": { "enum": ["ruby", "cli", "mcp-stdio"] },
|
|
13
|
+
"uniqueItems": true
|
|
14
|
+
},
|
|
15
|
+
"features": {
|
|
16
|
+
"type": "array",
|
|
17
|
+
"items": { "enum": ["recipes", "runbooks", "runs", "evidence"] },
|
|
18
|
+
"uniqueItems": true
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"additionalProperties": false
|
|
22
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/nauman/jazari/protocol/v1/error.schema.json",
|
|
4
|
+
"title": "Jazari v1 error",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["code"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"code": {
|
|
9
|
+
"enum": [
|
|
10
|
+
"target_not_found", "invalid_runbook", "revision_conflict",
|
|
11
|
+
"item_not_found", "item_not_in_snapshot", "read_only_target",
|
|
12
|
+
"run_closed", "invalid_request", "storage_busy", "storage_failure"
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"detail": { "type": "string" }
|
|
16
|
+
},
|
|
17
|
+
"additionalProperties": false
|
|
18
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "jazari-conformance/v1",
|
|
3
|
+
"recipes": [{"id":"actor.v1","topic":"Actor","checklist":[{"id":"first","text":"First"}]}],
|
|
4
|
+
"steps": [
|
|
5
|
+
{"request":{"protocol":"jazari/v1","request_id":"open","action":"start","target":{"kind":"queue","key":"actor","recipe_id":"actor.v1"},"arguments":{"actor_ref":"agent:fixture"}},"expect":{"ok":true}},
|
|
6
|
+
{"request":{"protocol":"jazari/v1","request_id":"tick","action":"tick","target":{"kind":"queue","key":"actor","recipe_id":"actor.v1"},"arguments":{"run_id":"$0.result.run_id","expected_revision":"$0.result.revision","item_id":"first","done":true}},"expect":{"ok":true,"result":{"ticks":[{"actor_ref":"agent:fixture"}]}}}
|
|
7
|
+
]
|
|
8
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "jazari-conformance/v1",
|
|
3
|
+
"recipes": [{"id":"daily.v1","topic":"Daily","run_policy":"once_per_calendar_day","checklist":[{"id":"first","text":"First"}]}],
|
|
4
|
+
"steps": [
|
|
5
|
+
{"request":{"protocol":"jazari/v1","request_id":"open-1","action":"start","target":{"kind":"queue","key":"daily","recipe_id":"daily.v1"},"arguments":{"actor_ref":"agent:fixture"}},"expect":{"ok":true,"result":{"created":true,"idempotent_reuse":false}}},
|
|
6
|
+
{"request":{"protocol":"jazari/v1","request_id":"open-2","action":"start","target":{"kind":"queue","key":"daily","recipe_id":"daily.v1"},"arguments":{"actor_ref":"agent:fixture"}},"expect":{"ok":true,"result":{"created":false,"idempotent_reuse":true}},"equal":[["result.run_id",0,"result.run_id"]]}
|
|
7
|
+
]
|
|
8
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "jazari-conformance/v1",
|
|
3
|
+
"recipes": [{"id":"evidence.v1","topic":"Evidence","checklist":[{"id":"first","text":"First"}]}],
|
|
4
|
+
"steps": [
|
|
5
|
+
{"request":{"protocol":"jazari/v1","request_id":"open","action":"start","target":{"kind":"queue","key":"evidence","recipe_id":"evidence.v1"},"arguments":{"actor_ref":"agent:fixture"}},"expect":{"ok":true}},
|
|
6
|
+
{"request":{"protocol":"jazari/v1","request_id":"evidence","action":"evidence","target":{"kind":"queue","key":"evidence","recipe_id":"evidence.v1"},"arguments":{"run_id":"$0.result.run_id","expected_revision":"$0.result.revision","item_id":"first","kind":"count","value":"4211"}},"expect":{"ok":true,"result":{"evidence":[{"item_id":"first","kind":"count","value":"4211","actor_ref":"agent:fixture"}]}}}
|
|
7
|
+
]
|
|
8
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "jazari-conformance/v1",
|
|
3
|
+
"recipes": [{"id":"operator.v1","topic":"Seeded","checklist":[]}],
|
|
4
|
+
"steps": [
|
|
5
|
+
{"request":{"protocol":"jazari/v1","request_id":"default","action":"get","target":{"kind":"anchor","scope_type":"local","scope_id":1,"key":"operator","recipe_id":"operator.v1"},"arguments":{}},"expect":{"ok":true,"result":{"state":"default","topic":"Seeded"}}},
|
|
6
|
+
{"request":{"protocol":"jazari/v1","request_id":"edit","action":"set","target":{"kind":"anchor","scope_type":"local","scope_id":1,"key":"operator","recipe_id":"operator.v1"},"arguments":{"expected_revision":"$0.result.revision","topic":"Operator wording","description":"","checklist":[]}},"expect":{"ok":true,"result":{"state":"custom","topic":"Operator wording"}}},
|
|
7
|
+
{"setup":{"replace_recipe":{"id":"operator.v1","topic":"New seed","checklist":[]}}},
|
|
8
|
+
{"request":{"protocol":"jazari/v1","request_id":"get","action":"get","target":{"kind":"anchor","scope_type":"local","scope_id":1,"key":"operator","recipe_id":"operator.v1"},"arguments":{}},"expect":{"ok":true,"result":{"state":"custom","topic":"Operator wording"}}}
|
|
9
|
+
]
|
|
10
|
+
}
|