jazari 0.1.0
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 +7 -0
- data/CHANGELOG.md +38 -0
- data/LICENSE +9 -0
- data/README.md +255 -0
- data/app/models/jazari/anchor.rb +16 -0
- data/app/models/jazari/application_record.rb +10 -0
- data/app/models/jazari/recipe_record.rb +29 -0
- data/app/models/jazari/run.rb +28 -0
- data/app/models/jazari/runbook.rb +16 -0
- data/lib/generators/jazari/install/install_generator.rb +36 -0
- data/lib/generators/jazari/install/templates/create_jazari_tables.rb +81 -0
- data/lib/jazari/anchors.rb +49 -0
- data/lib/jazari/checklist.rb +62 -0
- data/lib/jazari/errors.rb +17 -0
- data/lib/jazari/mcp/handler.rb +130 -0
- data/lib/jazari/operations.rb +264 -0
- data/lib/jazari/railtie.rb +24 -0
- data/lib/jazari/recipe.rb +44 -0
- data/lib/jazari/recipe_registry.rb +37 -0
- data/lib/jazari/resolved_runbook.rb +24 -0
- data/lib/jazari/runs.rb +179 -0
- data/lib/jazari/targets.rb +15 -0
- data/lib/jazari/version.rb +5 -0
- data/lib/jazari.rb +143 -0
- metadata +97 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jazari
|
|
4
|
+
module Mcp
|
|
5
|
+
# Transport-neutral adapter between an MCP action name and the domain.
|
|
6
|
+
#
|
|
7
|
+
# It knows nothing about transport, authentication, or product naming. The
|
|
8
|
+
# HOST owns tool identity — one flat `action`-enum tool per product, named
|
|
9
|
+
# for that product — and the host authorizes the actor and constructs the
|
|
10
|
+
# target BEFORE calling here. This class only maps action names onto domain
|
|
11
|
+
# calls and shapes the reply.
|
|
12
|
+
#
|
|
13
|
+
# That split is deliberate: two products sharing this handler still present
|
|
14
|
+
# their own tool, their own subject vocabulary, and their own permissions.
|
|
15
|
+
class Handler
|
|
16
|
+
READ_ACTIONS = %w[get last_run].freeze
|
|
17
|
+
WRITE_ACTIONS = %w[set add_item remove_item check_item reset start tick evidence finish].freeze
|
|
18
|
+
ACTIONS = (READ_ACTIONS + WRITE_ACTIONS).freeze
|
|
19
|
+
|
|
20
|
+
# Advertise only what the granted scope may call. A read-scoped connection
|
|
21
|
+
# should not see mutations in its tool list at all — hiding them is not
|
|
22
|
+
# security, but offering them and refusing is a worse experience.
|
|
23
|
+
def self.actions_for(scope)
|
|
24
|
+
scope.to_s == "read" ? READ_ACTIONS : ACTIONS
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def call(action:, target:, arguments: {})
|
|
28
|
+
args = symbolize(arguments)
|
|
29
|
+
name = action.to_s
|
|
30
|
+
raise ArgumentError, "unknown runbook action #{action.inspect}" unless ACTIONS.include?(name)
|
|
31
|
+
|
|
32
|
+
reply(public_send(:"handle_#{name}", target, args))
|
|
33
|
+
rescue Jazari::Error => error
|
|
34
|
+
# The closed taxonomy crosses the wire as a code, never as a message
|
|
35
|
+
# that could disclose a record, a query, or whether a target exists.
|
|
36
|
+
{ ok: false, error: error.code }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# -- read ---------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
def handle_get(target, _args) = Operations.resolve(target: target)
|
|
42
|
+
|
|
43
|
+
def handle_last_run(target, _args)
|
|
44
|
+
run = Runs.last(target: target)
|
|
45
|
+
run ? run_view(run) : { last_run: nil }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# -- runbook ------------------------------------------------------------
|
|
49
|
+
|
|
50
|
+
def handle_set(target, args)
|
|
51
|
+
Operations.customize(
|
|
52
|
+
target: target, expected_revision: args[:expected_revision],
|
|
53
|
+
topic: args[:topic], description: args[:description].to_s,
|
|
54
|
+
checklist: args.fetch(:checklist, [])
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def handle_add_item(target, args)
|
|
59
|
+
Operations.add_item(target: target, expected_revision: args[:expected_revision],
|
|
60
|
+
text: args[:text], required: args.fetch(:required, true))
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def handle_remove_item(target, args)
|
|
64
|
+
Operations.remove_item(target: target, expected_revision: args[:expected_revision],
|
|
65
|
+
item_id: args[:item_id])
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def handle_check_item(target, args)
|
|
69
|
+
Operations.check_item(target: target, expected_revision: args[:expected_revision],
|
|
70
|
+
item_id: args[:item_id], done: args[:done])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Destructive: it discards the operator's customization. The host must
|
|
74
|
+
# gate this on explicit confirmation before calling.
|
|
75
|
+
def handle_reset(target, args)
|
|
76
|
+
unless args[:confirm] == true
|
|
77
|
+
raise InvalidRunbook, "reset discards the customization and requires confirm: true"
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
Operations.reset(target: target, expected_revision: args[:expected_revision])
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# -- runs ---------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
def handle_start(target, args)
|
|
86
|
+
result = Runs.open(target: target, actor_ref: args.fetch(:actor_ref))
|
|
87
|
+
run_view(result[:run]).merge(created: result[:created], idempotent_reuse: result[:idempotent_reuse])
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def handle_tick(_target, args)
|
|
91
|
+
run = Runs.tick(run: args.fetch(:run_id), expected_revision: args[:expected_revision],
|
|
92
|
+
item_id: args[:item_id], done: args.fetch(:done, true),
|
|
93
|
+
actor_ref: args.fetch(:actor_ref), note: args[:note])
|
|
94
|
+
run_view(run)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def handle_evidence(_target, args)
|
|
98
|
+
run = Runs.attach_evidence(run: args.fetch(:run_id), expected_revision: args[:expected_revision],
|
|
99
|
+
item_id: args[:item_id], kind: args.fetch(:kind), value: args.fetch(:value))
|
|
100
|
+
run_view(run)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def handle_finish(_target, args)
|
|
104
|
+
run = Runs.close(run: args.fetch(:run_id), expected_revision: args[:expected_revision],
|
|
105
|
+
outcome: args.fetch(:outcome, "completed"))
|
|
106
|
+
run_view(run)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
private
|
|
110
|
+
|
|
111
|
+
def reply(result)
|
|
112
|
+
payload = result.respond_to?(:to_h) ? result.to_h : result
|
|
113
|
+
{ ok: true }.merge(payload)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def run_view(run)
|
|
117
|
+
{
|
|
118
|
+
run_id: run.id, revision: run.lock_version, recipe: run.recipe_id,
|
|
119
|
+
started_at: run.started_at, started_on: run.started_on,
|
|
120
|
+
finished_at: run.finished_at, outcome: run.outcome, open: run.open?,
|
|
121
|
+
ticks: run.ticks, evidence: run.evidence
|
|
122
|
+
}
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def symbolize(arguments)
|
|
126
|
+
arguments.to_h { |key, value| [ key.to_sym, value ] }
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jazari
|
|
4
|
+
# The runbook half: resolving a subject's operating truth and customizing it.
|
|
5
|
+
#
|
|
6
|
+
# Reading a default writes NOTHING — no row, no anchor, no audit noise. The
|
|
7
|
+
# first customization materializes a record; reset destroys it and reveals the
|
|
8
|
+
# current canon again, so a recipe correction reaches every subject that never
|
|
9
|
+
# overrode it.
|
|
10
|
+
module Operations
|
|
11
|
+
MAX_TOPIC = 120
|
|
12
|
+
MAX_DESCRIPTION = 20_000
|
|
13
|
+
|
|
14
|
+
module_function
|
|
15
|
+
|
|
16
|
+
def resolve(target:)
|
|
17
|
+
recipe = RecipeRegistry.fetch(target.recipe_id)
|
|
18
|
+
record = find_runbook(target)
|
|
19
|
+
last = Runs.last(target: target)
|
|
20
|
+
|
|
21
|
+
return custom_value(record, target, recipe, last) if record
|
|
22
|
+
|
|
23
|
+
default_value(recipe, target, last)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def customize(target:, expected_revision:, topic:, description:, checklist:)
|
|
27
|
+
writable!(target)
|
|
28
|
+
validate_document!(topic, description)
|
|
29
|
+
items = Checklist.normalize(checklist)
|
|
30
|
+
write(target, expected_revision) do |current|
|
|
31
|
+
current.merge(topic: topic, description: description, checklist: items)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def add_item(target:, expected_revision:, text:, required: true)
|
|
36
|
+
writable!(target)
|
|
37
|
+
write(target, expected_revision) do |current|
|
|
38
|
+
items = current[:checklist] + [
|
|
39
|
+
{ id: Checklist.generate_id, text: text.to_s, done: false, required: required == true }
|
|
40
|
+
]
|
|
41
|
+
Checklist.validate!(items)
|
|
42
|
+
current.merge(checklist: items)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def remove_item(target:, expected_revision:, item_id:)
|
|
47
|
+
writable!(target)
|
|
48
|
+
write(target, expected_revision) do |current|
|
|
49
|
+
remaining = current[:checklist].reject { |item| item[:id] == item_id.to_s }
|
|
50
|
+
raise ItemNotFound, "unknown checklist item #{item_id}" if remaining.length == current[:checklist].length
|
|
51
|
+
|
|
52
|
+
current.merge(checklist: remaining)
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def check_item(target:, expected_revision:, item_id:, done:)
|
|
57
|
+
writable!(target)
|
|
58
|
+
write(target, expected_revision) do |current|
|
|
59
|
+
found = false
|
|
60
|
+
items = current[:checklist].map do |item|
|
|
61
|
+
next item unless item[:id] == item_id.to_s
|
|
62
|
+
|
|
63
|
+
found = true
|
|
64
|
+
item.merge(done: done == true)
|
|
65
|
+
end
|
|
66
|
+
raise ItemNotFound, "unknown checklist item #{item_id}" unless found
|
|
67
|
+
|
|
68
|
+
current.merge(checklist: items)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Destroys the customization and reveals the current canon. Idempotent on an
|
|
73
|
+
# already-default target only while the supplied default revision matches.
|
|
74
|
+
def reset(target:, expected_revision:)
|
|
75
|
+
writable!(target)
|
|
76
|
+
record = find_runbook(target)
|
|
77
|
+
if record
|
|
78
|
+
record.with_lock do
|
|
79
|
+
verify_custom_revision!(record, expected_revision)
|
|
80
|
+
destroy_with_anchor(record, target)
|
|
81
|
+
end
|
|
82
|
+
else
|
|
83
|
+
verify_default_revision!(target, expected_revision)
|
|
84
|
+
end
|
|
85
|
+
resolve(target: target)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A host calls this from its own after-commit when a subject is destroyed.
|
|
89
|
+
#
|
|
90
|
+
# Jazari cannot hook the host's model itself: the subject may live in a
|
|
91
|
+
# different logical database, so no cross-database foreign key is claimed
|
|
92
|
+
# and no cascade exists. The host calls in; the gem cleans up.
|
|
93
|
+
#
|
|
94
|
+
# Runs are deliberately PRESERVED. A run is an audit record of something
|
|
95
|
+
# that actually happened, and deleting the subject does not un-happen it.
|
|
96
|
+
# Their subject columns keep pointing at the departed record — which is
|
|
97
|
+
# sound precisely because no FK was ever claimed.
|
|
98
|
+
def forget_subject(subject)
|
|
99
|
+
runbook = Runbook.find_by(runbookable: subject)
|
|
100
|
+
runbook&.destroy!
|
|
101
|
+
subject.destroy! if subject.is_a?(Anchor) && subject.persisted?
|
|
102
|
+
Jazari.config.on_subject_destroyed&.call(subject)
|
|
103
|
+
true
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# -- internals ---------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
# A queue is a stable name for a ritual, and a ritual has exactly one
|
|
109
|
+
# editable home: its recipe. Per-subject customization belongs to subjects.
|
|
110
|
+
def writable!(target)
|
|
111
|
+
raise ReadOnlyTarget, "queues are read-only" if target.is_a?(QueueTarget)
|
|
112
|
+
end
|
|
113
|
+
private_class_method :writable!
|
|
114
|
+
|
|
115
|
+
def write(target, expected_revision)
|
|
116
|
+
record = find_runbook(target)
|
|
117
|
+
if record
|
|
118
|
+
record.with_lock do
|
|
119
|
+
verify_custom_revision!(record, expected_revision)
|
|
120
|
+
document = yield(current_document(record))
|
|
121
|
+
record.update!(
|
|
122
|
+
topic: document[:topic], description: document[:description],
|
|
123
|
+
checklist: store_items(document[:checklist])
|
|
124
|
+
)
|
|
125
|
+
end
|
|
126
|
+
else
|
|
127
|
+
create_custom(target, expected_revision) { |current| yield(current) }
|
|
128
|
+
end
|
|
129
|
+
resolve(target: target)
|
|
130
|
+
end
|
|
131
|
+
private_class_method :write
|
|
132
|
+
|
|
133
|
+
def create_custom(target, expected_revision)
|
|
134
|
+
verify_default_revision!(target, expected_revision)
|
|
135
|
+
recipe = RecipeRegistry.fetch(target.recipe_id)
|
|
136
|
+
document = yield(
|
|
137
|
+
{ topic: recipe.topic, description: recipe.description,
|
|
138
|
+
checklist: recipe.checklist.map(&:dup) }
|
|
139
|
+
)
|
|
140
|
+
Runbook.create!(
|
|
141
|
+
runbookable: runbookable_for(target),
|
|
142
|
+
recipe_id: recipe.id,
|
|
143
|
+
topic: document[:topic], description: document[:description],
|
|
144
|
+
checklist: store_items(document[:checklist])
|
|
145
|
+
)
|
|
146
|
+
rescue ActiveRecord::RecordNotUnique
|
|
147
|
+
raise RevisionConflict, "another writer materialized this runbook first"
|
|
148
|
+
rescue ActiveRecord::RecordInvalid => error
|
|
149
|
+
raise InvalidRunbook, error.message
|
|
150
|
+
end
|
|
151
|
+
private_class_method :create_custom
|
|
152
|
+
|
|
153
|
+
def runbookable_for(target)
|
|
154
|
+
case target
|
|
155
|
+
when RecordTarget then target.runbookable
|
|
156
|
+
when AnchorTarget then Anchors.resolve(target, strict: true, create: true)
|
|
157
|
+
else raise TargetNotFound, "unsupported target"
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
private_class_method :runbookable_for
|
|
161
|
+
|
|
162
|
+
# Whether the anchor goes with the runbook is decided by the TARGET, not by
|
|
163
|
+
# the anchor's class.
|
|
164
|
+
#
|
|
165
|
+
# The first version asked `anchor.is_a?(Jazari::Anchor)`, which quietly
|
|
166
|
+
# assumed the gem owns the anchor model. A host that adopted jazari onto its
|
|
167
|
+
# own anchor table returns its own class, the check failed, and reset left an
|
|
168
|
+
# orphan anchor behind. The target always knows the truth.
|
|
169
|
+
def destroy_with_anchor(record, target)
|
|
170
|
+
anchor = record.runbookable
|
|
171
|
+
record.destroy!
|
|
172
|
+
anchor.destroy! if target.is_a?(AnchorTarget) && anchor.respond_to?(:destroy!)
|
|
173
|
+
end
|
|
174
|
+
private_class_method :destroy_with_anchor
|
|
175
|
+
|
|
176
|
+
def find_runbook(target)
|
|
177
|
+
case target
|
|
178
|
+
when RecordTarget then Runbook.find_by(runbookable: target.runbookable)
|
|
179
|
+
when AnchorTarget
|
|
180
|
+
anchor = Anchors.resolve(target, strict: false)
|
|
181
|
+
anchor && Runbook.find_by(runbookable: anchor)
|
|
182
|
+
when QueueTarget then nil
|
|
183
|
+
else raise TargetNotFound, "unsupported target"
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
private_class_method :find_runbook
|
|
187
|
+
|
|
188
|
+
# Transports deliver the revision as a string; lock_version is an integer.
|
|
189
|
+
def verify_custom_revision!(record, expected_revision)
|
|
190
|
+
return if record.lock_version.to_s == expected_revision.to_s
|
|
191
|
+
|
|
192
|
+
raise RevisionConflict, "expected revision #{expected_revision}, actual #{record.lock_version}"
|
|
193
|
+
end
|
|
194
|
+
private_class_method :verify_custom_revision!
|
|
195
|
+
|
|
196
|
+
def verify_default_revision!(target, expected_revision)
|
|
197
|
+
current = "default:#{RecipeRegistry.fetch(target.recipe_id).digest}"
|
|
198
|
+
return if expected_revision.to_s == current
|
|
199
|
+
|
|
200
|
+
raise RevisionConflict, "expected #{expected_revision}, current default is #{current}"
|
|
201
|
+
end
|
|
202
|
+
private_class_method :verify_default_revision!
|
|
203
|
+
|
|
204
|
+
def validate_document!(topic, description)
|
|
205
|
+
raise InvalidRunbook, "topic is required" if topic.to_s.empty?
|
|
206
|
+
raise InvalidRunbook, "topic exceeds #{MAX_TOPIC}" if topic.to_s.length > MAX_TOPIC
|
|
207
|
+
raise InvalidRunbook, "description exceeds #{MAX_DESCRIPTION}" if description.to_s.length > MAX_DESCRIPTION
|
|
208
|
+
end
|
|
209
|
+
private_class_method :validate_document!
|
|
210
|
+
|
|
211
|
+
def current_document(record)
|
|
212
|
+
{ topic: record.topic, description: record.description,
|
|
213
|
+
checklist: stored_items(record.checklist) }
|
|
214
|
+
end
|
|
215
|
+
private_class_method :current_document
|
|
216
|
+
|
|
217
|
+
def custom_value(record, target, recipe, last)
|
|
218
|
+
ResolvedRunbook.new(
|
|
219
|
+
state: "custom", revision: record.lock_version, topic: record.topic,
|
|
220
|
+
description: record.description, checklist: stored_items(record.checklist),
|
|
221
|
+
target_reference: target.public_reference, recipe: recipe.provenance,
|
|
222
|
+
last_run: run_summary(last)
|
|
223
|
+
)
|
|
224
|
+
end
|
|
225
|
+
private_class_method :custom_value
|
|
226
|
+
|
|
227
|
+
def default_value(recipe, target, last)
|
|
228
|
+
ResolvedRunbook.new(
|
|
229
|
+
state: "default", revision: "default:#{recipe.digest}", topic: recipe.topic,
|
|
230
|
+
description: recipe.description, checklist: recipe.checklist,
|
|
231
|
+
target_reference: target.public_reference, recipe: recipe.provenance,
|
|
232
|
+
last_run: run_summary(last)
|
|
233
|
+
)
|
|
234
|
+
end
|
|
235
|
+
private_class_method :default_value
|
|
236
|
+
|
|
237
|
+
# The field that makes "is this ritual actually happening?" answerable from
|
|
238
|
+
# a single read.
|
|
239
|
+
def run_summary(run)
|
|
240
|
+
return nil unless run
|
|
241
|
+
|
|
242
|
+
{ id: run.id, outcome: run.outcome, started_at: run.started_at,
|
|
243
|
+
finished_at: run.finished_at, open: run.open? }
|
|
244
|
+
end
|
|
245
|
+
private_class_method :run_summary
|
|
246
|
+
|
|
247
|
+
def stored_items(items)
|
|
248
|
+
Array(items).map do |item|
|
|
249
|
+
row = item.to_h.transform_keys(&:to_s)
|
|
250
|
+
{ id: row["id"].to_s, text: row["text"].to_s,
|
|
251
|
+
done: row["done"] == true, required: row.fetch("required", true) == true }
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
private_class_method :stored_items
|
|
255
|
+
|
|
256
|
+
def store_items(items)
|
|
257
|
+
items.map do |item|
|
|
258
|
+
{ "id" => item[:id], "text" => item[:text],
|
|
259
|
+
"done" => item[:done] == true, "required" => item.fetch(:required, true) == true }
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
private_class_method :store_items
|
|
263
|
+
end
|
|
264
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/railtie"
|
|
4
|
+
|
|
5
|
+
module Jazari
|
|
6
|
+
# Jazari is a plain gem, NOT an engine: it contributes no routes, controllers,
|
|
7
|
+
# views, or assets, and a host should not have to mount anything to use it.
|
|
8
|
+
#
|
|
9
|
+
# But its models live in `app/models`, and nothing puts that on a host's
|
|
10
|
+
# autoload path by itself. Without this the gem loads, `Jazari.resolve` is
|
|
11
|
+
# callable, and the first call dies on `uninitialized constant
|
|
12
|
+
# Jazari::RecipeRecord` — which is exactly how the first host adoption found
|
|
13
|
+
# it. The gem's own suite had masked it with `require_relative`.
|
|
14
|
+
#
|
|
15
|
+
# A Railtie is the smallest thing that fixes it: autoload paths only, no
|
|
16
|
+
# engine, nothing mounted.
|
|
17
|
+
class Railtie < ::Rails::Railtie
|
|
18
|
+
config.before_configuration do
|
|
19
|
+
models = File.expand_path("../../app/models", __dir__)
|
|
20
|
+
ActiveSupport::Dependencies.autoload_paths << models
|
|
21
|
+
Rails.autoloaders.main.push_dir(models) if Rails.respond_to?(:autoloaders)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Jazari
|
|
7
|
+
# Whether a ritual may run more than once in a day is a property of the
|
|
8
|
+
# RITUAL, not a global rule: verifying a backup should happen once a day;
|
|
9
|
+
# triaging an incident may happen five times.
|
|
10
|
+
module RunPolicy
|
|
11
|
+
UNRESTRICTED = "unrestricted"
|
|
12
|
+
ONCE_PER_CALENDAR_DAY = "once_per_calendar_day"
|
|
13
|
+
ALL = [ UNRESTRICTED, ONCE_PER_CALENDAR_DAY ].freeze
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
# The canon. Content is data, not code — the gem ships no recipe content at
|
|
17
|
+
# all, only this shape and the empty fallback. Editing a recipe changes its
|
|
18
|
+
# digest, so outstanding default revisions conflict instead of drifting
|
|
19
|
+
# silently under someone who is mid-checklist.
|
|
20
|
+
Recipe = Data.define(:id, :version, :topic, :description, :checklist, :run_policy) do
|
|
21
|
+
def initialize(id:, version:, topic:, description:, checklist:,
|
|
22
|
+
run_policy: RunPolicy::UNRESTRICTED)
|
|
23
|
+
unless RunPolicy::ALL.include?(run_policy)
|
|
24
|
+
raise InvalidRunbook, "unknown run_policy #{run_policy.inspect}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
super(
|
|
28
|
+
id: id.freeze, version: version, topic: topic.freeze,
|
|
29
|
+
description: description.freeze, checklist: Checklist.freeze_items(checklist),
|
|
30
|
+
run_policy: run_policy.freeze
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def digest
|
|
35
|
+
Digest::SHA256.hexdigest(
|
|
36
|
+
JSON.generate([ id, version, topic, description, checklist, run_policy ])
|
|
37
|
+
)[0, 16]
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def once_per_calendar_day? = run_policy == RunPolicy::ONCE_PER_CALENDAR_DAY
|
|
41
|
+
|
|
42
|
+
def provenance = { id: id, version: version, digest: digest }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jazari
|
|
4
|
+
# Recipes are data, not code. The gem owns the lookup mechanism, the
|
|
5
|
+
# digest-based default-revision guard, an idempotent seed, and one
|
|
6
|
+
# content-free fallback. It ships no recipe content.
|
|
7
|
+
#
|
|
8
|
+
# A missing recipe is not an error and performs zero writes.
|
|
9
|
+
module RecipeRegistry
|
|
10
|
+
EMPTY = Recipe.new(
|
|
11
|
+
id: "core.empty.v1", version: 1, topic: "", description: "",
|
|
12
|
+
checklist: [], run_policy: RunPolicy::UNRESTRICTED
|
|
13
|
+
).freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def fetch(recipe_id)
|
|
18
|
+
RecipeRecord.find_by(recipe_id: recipe_id.to_s)&.to_recipe || EMPTY
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# Create-if-missing. Existing records are operator-owned: reseeding never
|
|
22
|
+
# overwrites them, because the operator's edit is the truth once it exists.
|
|
23
|
+
def seed!(entries)
|
|
24
|
+
Array(entries).map do |entry|
|
|
25
|
+
attributes = entry.to_h.transform_keys(&:to_sym)
|
|
26
|
+
items = Checklist.normalize(attributes.fetch(:checklist, []))
|
|
27
|
+
RecipeRecord.find_or_create_by!(recipe_id: attributes.fetch(:id).to_s) do |record|
|
|
28
|
+
record.version = attributes.fetch(:version, 1)
|
|
29
|
+
record.topic = attributes.fetch(:topic)
|
|
30
|
+
record.description = attributes.fetch(:description, "").to_s
|
|
31
|
+
record.run_policy = attributes.fetch(:run_policy, RunPolicy::UNRESTRICTED)
|
|
32
|
+
record.checklist = items.map { |item| item.transform_keys(&:to_s) }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jazari
|
|
4
|
+
# Every operation returns this. Never an unsaved ActiveRecord object.
|
|
5
|
+
ResolvedRunbook = Data.define(
|
|
6
|
+
:state, :revision, :topic, :description, :checklist, :progress,
|
|
7
|
+
:target_reference, :recipe, :last_run
|
|
8
|
+
) do
|
|
9
|
+
def initialize(state:, revision:, topic:, description:, checklist:,
|
|
10
|
+
target_reference:, recipe:, last_run: nil)
|
|
11
|
+
items = Checklist.freeze_items(checklist)
|
|
12
|
+
super(
|
|
13
|
+
state: state.freeze, revision: revision, topic: topic.freeze,
|
|
14
|
+
description: description.freeze, checklist: items,
|
|
15
|
+
progress: Checklist.progress(items).freeze,
|
|
16
|
+
target_reference: target_reference.freeze, recipe: recipe.freeze,
|
|
17
|
+
last_run: last_run.freeze
|
|
18
|
+
)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def default? = state == "default"
|
|
22
|
+
def custom? = state == "custom"
|
|
23
|
+
end
|
|
24
|
+
end
|