jazari 0.5.2 → 0.7.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 +4 -4
- data/CHANGELOG.md +69 -0
- data/lib/jazari/checklist.rb +68 -3
- data/lib/jazari/errors.rb +7 -0
- data/lib/jazari/mcp/actions.rb +47 -1
- data/lib/jazari/operations.rb +6 -4
- data/lib/jazari/runs.rb +65 -4
- data/lib/jazari/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a2e678c6397f2809010bf357f7bce0f95a6dd41ec910b4f3b9ffad7999c910f4
|
|
4
|
+
data.tar.gz: 34213c339b09aacbac45a878d878935f8160a55f13e8c0a5f2189cd70d96ef75
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: b880ae1c427030d149979921ea489c637a71cec89bd42018dec05bee415d4baa852c8a6a08ce4e49ac7dff2a24cc07a84fafec91e7d515276928c64476586f1b
|
|
7
|
+
data.tar.gz: 47fb2f0a403a85a1b6394116d6e3b577481b326a31485c28193a3697ce1ca397b9046d5487e96a9d79e6edd3fa5b8bf8e06e5fee29c99f865751758332f42f57
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,75 @@ codes, the resolved-value shape, how revisions are computed, and the schema the
|
|
|
8
8
|
generator emits — changes to any of those are breaking even when the method
|
|
9
9
|
signatures do not move.
|
|
10
10
|
|
|
11
|
+
## [0.7.0] - 2026-08-30
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
|
|
15
|
+
- **The checklist bound is now BYTES, not rows.** `MAX_PAYLOAD = 65_536`,
|
|
16
|
+
measured on the normalized stored form — the string-keyed rows actually
|
|
17
|
+
written to jsonb, never the caller's input and never an MCP envelope. Input
|
|
18
|
+
arrives in four shapes and envelopes differ per host, so counting either
|
|
19
|
+
would make the same document legal on one path and illegal on another.
|
|
20
|
+
|
|
21
|
+
**`MAX_ITEMS` is removed — there is no row cap at all.** The 50 was inherited
|
|
22
|
+
from a host that already had it and was never benchmarked: a measured 50-step
|
|
23
|
+
procedure serializes to ~16.7 KB at 278 characters a step, so the row cap was
|
|
24
|
+
rejecting documents four times smaller than what actually hurts. An interim
|
|
25
|
+
draft kept a cap at 200 as a "guard against absurdity"; that was as unmeasured
|
|
26
|
+
as the 50, and would have rejected a 201-step document sitting well inside the
|
|
27
|
+
byte bound for exactly the old reason. The absurdity case is already answered
|
|
28
|
+
by measurement — a normalized minimal step is 67 bytes, so `MAX_PAYLOAD`
|
|
29
|
+
admits 992 of them and no more. Hosts referencing `Jazari::Checklist::MAX_ITEMS`
|
|
30
|
+
must switch to `MAX_PAYLOAD`.
|
|
31
|
+
|
|
32
|
+
One validator now covers every path — whole-document writes, recipe files,
|
|
33
|
+
and the 0.6.0 late snapshot widening. `add_item` routes through `normalize`
|
|
34
|
+
rather than `validate!`, closing the hole where a document could be walked
|
|
35
|
+
past the ceiling one step at a time.
|
|
36
|
+
|
|
37
|
+
`MAX_TEXT` is unchanged at 500.
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
|
|
41
|
+
- **The published MCP `checklist` schema was an untyped `array`.** A client
|
|
42
|
+
generating from the descriptor could infer `string[]`, send `["step one"]`,
|
|
43
|
+
and be rejected by the domain with "checklist item must be a hash" — the
|
|
44
|
+
schema and the validator disagreed, and only the client found out. It now
|
|
45
|
+
publishes the object shape the validator enforces — `text` required with
|
|
46
|
+
`minLength`/`maxLength`, `id` carrying the real `pattern`, `additionalProperties:
|
|
47
|
+
false` matching the key allowlist — and discloses the aggregate byte bound in
|
|
48
|
+
its description, since JSON Schema cannot express it and an undocumented limit
|
|
49
|
+
is the same failure as an untyped array.
|
|
50
|
+
|
|
51
|
+
The contract test asserts **containment**: nothing the published schema admits
|
|
52
|
+
may be rejected by the domain. `done` is the one deliberate exception in the
|
|
53
|
+
safe direction — the schema publishes `boolean` while the domain silently
|
|
54
|
+
coerces `"yes"` to `false`, so a client is told up front rather than misled
|
|
55
|
+
afterwards.
|
|
56
|
+
|
|
57
|
+
## [0.6.0] - 2026-08-15
|
|
58
|
+
|
|
59
|
+
### Added
|
|
60
|
+
|
|
61
|
+
- **A step discovered mid-run is legal.** `tick` now accepts an item that was
|
|
62
|
+
added to the subject's own runbook after the run opened: it widens the run's
|
|
63
|
+
`checklist_snapshot` to carry the item and marks both the snapshot entry and
|
|
64
|
+
the tick with `post_snapshot: true`. Reported from a production deploy where
|
|
65
|
+
a host's canonical `check_item` had already committed when `tick` raised —
|
|
66
|
+
the caller was told a write failed while it stood, and retrying a
|
|
67
|
+
half-applied two-phase write is how a double record happens.
|
|
68
|
+
|
|
69
|
+
A **recipe** change mid-run is still refused. Those are different events: the
|
|
70
|
+
canon moving underneath an in-flight run is someone else's edit arriving
|
|
71
|
+
uninvited, and that is what the snapshot exists to hold out. A queue run has
|
|
72
|
+
no subject, so its checklist is the recipe and it never widens.
|
|
73
|
+
|
|
74
|
+
- `Jazari::ItemNotInSnapshot` (code `item_not_in_snapshot`), raised when the
|
|
75
|
+
item exists in the canon but post-dates the run's snapshot. It subclasses
|
|
76
|
+
`ItemNotFound`, so hosts rescuing the old class keep working, and hosts that
|
|
77
|
+
need to choose between failing, retrying and proceeding stop matching on the
|
|
78
|
+
message text.
|
|
79
|
+
|
|
11
80
|
## [0.5.2] - 2026-08-12
|
|
12
81
|
|
|
13
82
|
### Fixed
|
data/lib/jazari/checklist.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "json"
|
|
3
4
|
require "securerandom"
|
|
4
5
|
|
|
5
6
|
module Jazari
|
|
@@ -7,7 +8,31 @@ module Jazari
|
|
|
7
8
|
# opaque token, never array position: MCP has to be able to check one item
|
|
8
9
|
# without knowing where it sits.
|
|
9
10
|
module Checklist
|
|
10
|
-
|
|
11
|
+
# The real bound is BYTES, not rows.
|
|
12
|
+
#
|
|
13
|
+
# 50 was inherited from a host that already had it, never benchmarked. A
|
|
14
|
+
# 50-item procedure measured live serializes to 17,252 bytes at 278
|
|
15
|
+
# characters per step (codex, on the session-anchor board; the suite's
|
|
16
|
+
# synthetic equivalent is 16,691, since real ids and text are not uniform), so the row cap was rejecting documents four times smaller than
|
|
17
|
+
# what actually hurts — and would have accepted 50 steps of 500 characters,
|
|
18
|
+
# which is nearly twice as large again.
|
|
19
|
+
#
|
|
20
|
+
# What breaks is a payload: a jsonb column, a run snapshot copied per run,
|
|
21
|
+
# and an MCP response that has to fit in a context window. None of those
|
|
22
|
+
# count rows.
|
|
23
|
+
MAX_PAYLOAD = 65_536
|
|
24
|
+
|
|
25
|
+
# There is deliberately NO row cap. The first draft kept one at 200 as a
|
|
26
|
+
# "guard against absurdity" — but 200 was as unmeasured as the 50 it
|
|
27
|
+
# replaced, and it would still have rejected a 201-step document sitting
|
|
28
|
+
# well inside the byte bound for exactly the old reason.
|
|
29
|
+
#
|
|
30
|
+
# The absurdity case is already answered by measurement: a normalized
|
|
31
|
+
# minimal step is 67 bytes — the generated id is 16 of them — so MAX_PAYLOAD
|
|
32
|
+
# admits 992 of them and no more. One bound, derived, with nothing left to
|
|
33
|
+
# pick out of the air. (An earlier draft of this comment said ~1,090 by
|
|
34
|
+
# estimating the row at 60 bytes instead of measuring it after id
|
|
35
|
+
# generation. Measured, not reasoned, is the whole point of the change.)
|
|
11
36
|
MAX_TEXT = 500
|
|
12
37
|
ID_FORMAT = /\A[A-Za-z0-9_-]{1,64}\z/
|
|
13
38
|
|
|
@@ -21,18 +46,58 @@ module Jazari
|
|
|
21
46
|
def normalize(items)
|
|
22
47
|
list = validate!(items)
|
|
23
48
|
seen = []
|
|
24
|
-
list.map do |item|
|
|
49
|
+
normalized = list.map do |item|
|
|
25
50
|
id = item[:id].to_s
|
|
26
51
|
id = generate_id unless id.match?(ID_FORMAT) && !seen.include?(id)
|
|
27
52
|
seen << id
|
|
28
53
|
{ id: id, text: item[:text].to_s, done: item[:done] == true,
|
|
29
54
|
required: item.fetch(:required, true) == true }
|
|
30
55
|
end
|
|
56
|
+
bound!(normalized)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Measured on the NORMALIZED STORED FORM — the string-keyed rows actually
|
|
60
|
+
# written to jsonb — and never on the caller's input or on an MCP envelope.
|
|
61
|
+
#
|
|
62
|
+
# That choice is the whole contract. Input arrives in four shapes (symbol
|
|
63
|
+
# keys, string keys, a YAML file, a Ruby literal) and an envelope differs
|
|
64
|
+
# per host, so counting either would make the same document legal in one
|
|
65
|
+
# path and illegal in another. The stored form is the one representation
|
|
66
|
+
# every path converges on, and ids are already assigned by the time it
|
|
67
|
+
# exists — so the number is deterministic and reproducible by a host that
|
|
68
|
+
# wants to check before it calls.
|
|
69
|
+
def payload_bytes(items) = serialized_bytes(stored_form(items))
|
|
70
|
+
|
|
71
|
+
# Counts rows VERBATIM. A run snapshot carries a key the canonical checklist
|
|
72
|
+
# does not (`post_snapshot`), and projecting it away before measuring would
|
|
73
|
+
# undercount the value actually written — by a margin that grows with every
|
|
74
|
+
# late step. Measure the bytes that land in the column.
|
|
75
|
+
def serialized_bytes(rows) = JSON.generate(rows).bytesize
|
|
76
|
+
|
|
77
|
+
def stored_form(items)
|
|
78
|
+
items.map do |item|
|
|
79
|
+
row = item.to_h.transform_keys(&:to_s)
|
|
80
|
+
{ "id" => row["id"].to_s, "text" => row["text"].to_s,
|
|
81
|
+
"done" => row["done"] == true, "required" => row.fetch("required", true) == true }
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def bound!(items) = bound_bytes!(payload_bytes(items), items)
|
|
86
|
+
|
|
87
|
+
# For rows that are already in their stored shape, extra keys included.
|
|
88
|
+
def bound_stored!(rows) = bound_bytes!(serialized_bytes(rows), rows)
|
|
89
|
+
|
|
90
|
+
def bound_bytes!(bytes, items)
|
|
91
|
+
if bytes > MAX_PAYLOAD
|
|
92
|
+
raise InvalidRunbook,
|
|
93
|
+
"checklist payload is #{bytes} bytes, over the #{MAX_PAYLOAD} limit"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
items
|
|
31
97
|
end
|
|
32
98
|
|
|
33
99
|
def validate!(items)
|
|
34
100
|
raise InvalidRunbook, "checklist must be an array" unless items.is_a?(Array)
|
|
35
|
-
raise InvalidRunbook, "checklist exceeds #{MAX_ITEMS} items" if items.length > MAX_ITEMS
|
|
36
101
|
|
|
37
102
|
items.map do |item|
|
|
38
103
|
raise InvalidRunbook, "checklist item must be a hash" unless item.is_a?(Hash)
|
data/lib/jazari/errors.rb
CHANGED
|
@@ -12,6 +12,13 @@ module Jazari
|
|
|
12
12
|
class InvalidRunbook < Error; end
|
|
13
13
|
class RevisionConflict < Error; end
|
|
14
14
|
class ItemNotFound < Error; end
|
|
15
|
+
|
|
16
|
+
# The item exists — it just post-dates the snapshot this run froze at open.
|
|
17
|
+
# A subclass, so a host that already rescues ItemNotFound keeps working, and
|
|
18
|
+
# one that wants to tell "no such step" from "not this run's step" can.
|
|
19
|
+
# Without the distinction a caller cannot choose between failing, retrying,
|
|
20
|
+
# and proceeding, and ends up matching on prose.
|
|
21
|
+
class ItemNotInSnapshot < ItemNotFound; end
|
|
15
22
|
class ReadOnlyTarget < Error; end
|
|
16
23
|
class RunClosed < Error; end
|
|
17
24
|
end
|
data/lib/jazari/mcp/actions.rb
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# The descriptors quote the domain's own limits rather than restating them, so
|
|
4
|
+
# the schema cannot drift from the validator. That means this file needs
|
|
5
|
+
# Checklist even when it is required on its own — which the standalone-require
|
|
6
|
+
# test exists to catch, and did.
|
|
7
|
+
require "jazari/checklist"
|
|
8
|
+
|
|
3
9
|
module Jazari
|
|
4
10
|
module Mcp
|
|
5
11
|
# Declarative descriptors for every action, so a host can absorb jazari into
|
|
@@ -28,7 +34,47 @@ module Jazari
|
|
|
28
34
|
|
|
29
35
|
REVISION = { type: "string",
|
|
30
36
|
description: "The revision from the read immediately before this call." }.freeze
|
|
37
|
+
# JSON Schema patterns are ECMAScript, which has no `\A`/`\z`. Publishing
|
|
38
|
+
# Ruby's source verbatim hands validators an anchor they may reject or,
|
|
39
|
+
# worse, read as a literal — so translate the anchors and keep everything
|
|
40
|
+
# else derived from ID_FORMAT, which stays the single source of truth.
|
|
41
|
+
ID_PATTERN = Checklist::ID_FORMAT.source.sub('\\A', "^").sub('\\z', "$").freeze
|
|
42
|
+
|
|
31
43
|
ITEM_ID = { type: "string", description: "Opaque checklist item id." }.freeze
|
|
44
|
+
|
|
45
|
+
# An `array` with no `items` is not a contract, it is a guess. A client
|
|
46
|
+
# generating from that descriptor is free to infer `string[]`, send
|
|
47
|
+
# `["step one"]`, and be rejected by the domain with "checklist item must
|
|
48
|
+
# be a hash" — which is what happened. The schema must publish the shape
|
|
49
|
+
# the validator actually enforces, or the descriptor is documentation
|
|
50
|
+
# that disagrees with the code.
|
|
51
|
+
CHECKLIST_ITEM = {
|
|
52
|
+
type: "object",
|
|
53
|
+
required: [ "text" ],
|
|
54
|
+
properties: {
|
|
55
|
+
id: { type: "string", pattern: ID_PATTERN, maxLength: 64,
|
|
56
|
+
description: "Opaque id. Omit to have one generated; send it back to keep a step " \
|
|
57
|
+
"stable across edits. An id that does not match the pattern is REPLACED " \
|
|
58
|
+
"with a generated one rather than rejected." },
|
|
59
|
+
# minLength/maxLength, not just "string": empty text and 501 characters
|
|
60
|
+
# are both rejected by the domain, and a schema that admits them makes
|
|
61
|
+
# the client discover it at call time.
|
|
62
|
+
text: { type: "string", minLength: 1, maxLength: Checklist::MAX_TEXT,
|
|
63
|
+
description: "The step. Required, non-empty." },
|
|
64
|
+
done: { type: "boolean", description: "Default false." },
|
|
65
|
+
required: { type: "boolean", description: "Whether the step gates completion. Default true." }
|
|
66
|
+
},
|
|
67
|
+
additionalProperties: false
|
|
68
|
+
}.freeze
|
|
69
|
+
|
|
70
|
+
# The one domain rule JSON Schema cannot express is the aggregate byte
|
|
71
|
+
# bound, so it is DISCLOSED rather than left for the client to discover by
|
|
72
|
+
# being rejected. An undocumented limit is the same failure as an untyped
|
|
73
|
+
# array: the schema knows something the caller does not.
|
|
74
|
+
CHECKLIST = { type: "array", items: CHECKLIST_ITEM,
|
|
75
|
+
description: "Full checklist; replaces the existing one. There is no row limit, " \
|
|
76
|
+
"but the serialized checklist must be at most " \
|
|
77
|
+
"#{Checklist::MAX_PAYLOAD} bytes." }.freeze
|
|
32
78
|
RUN_ID = { type: "integer", description: "The run returned by start." }.freeze
|
|
33
79
|
ACTOR = { type: "string", description: "Opaque identity of who is acting." }.freeze
|
|
34
80
|
|
|
@@ -44,7 +90,7 @@ module Jazari
|
|
|
44
90
|
params: { expected_revision: REVISION,
|
|
45
91
|
topic: { type: "string", description: "Short title." },
|
|
46
92
|
description: { type: "string", description: "Markdown body." },
|
|
47
|
-
checklist:
|
|
93
|
+
checklist: CHECKLIST }),
|
|
48
94
|
Action.new(name: "add_item", scope: :write, effect: :additive, confirm: false,
|
|
49
95
|
summary: "Append one checklist step.",
|
|
50
96
|
params: { expected_revision: REVISION,
|
data/lib/jazari/operations.rb
CHANGED
|
@@ -38,10 +38,12 @@ module Jazari
|
|
|
38
38
|
def add_item(target:, expected_revision:, text:, required: true)
|
|
39
39
|
writable!(target)
|
|
40
40
|
write(target, expected_revision) do |current|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
41
|
+
# normalize, not validate! — one choke point, so a step appended one at
|
|
42
|
+
# a time cannot walk past a bound that a whole-document write enforces.
|
|
43
|
+
items = Checklist.normalize(
|
|
44
|
+
current[:checklist] + [ { id: Checklist.generate_id, text: text.to_s,
|
|
45
|
+
done: false, required: required == true } ]
|
|
46
|
+
)
|
|
45
47
|
current.merge(checklist: items)
|
|
46
48
|
end
|
|
47
49
|
end
|
data/lib/jazari/runs.rb
CHANGED
|
@@ -85,12 +85,15 @@ module Jazari
|
|
|
85
85
|
end
|
|
86
86
|
|
|
87
87
|
known = snapshot.map { |item| item["id"] }
|
|
88
|
-
|
|
88
|
+
late = admit_late_item!(record, snapshot, item_id) unless known.include?(item_id.to_s)
|
|
89
|
+
record.checklist_snapshot = snapshot + [ late ] if late
|
|
89
90
|
|
|
90
91
|
ticks = stored(record.ticks).reject { |t| t["id"] == item_id.to_s }
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
tick = { "id" => item_id.to_s, "done" => done == true, "at" => now.utc.iso8601,
|
|
93
|
+
"actor_ref" => resolve_actor_ref(actor_ref, fallback: record.actor_ref),
|
|
94
|
+
"note" => note&.to_s }
|
|
95
|
+
tick["post_snapshot"] = true if late
|
|
96
|
+
ticks << tick
|
|
94
97
|
record.ticks = ticks
|
|
95
98
|
end
|
|
96
99
|
end
|
|
@@ -158,6 +161,64 @@ module Jazari
|
|
|
158
161
|
end
|
|
159
162
|
private_class_method :mutate
|
|
160
163
|
|
|
164
|
+
# A step discovered mid-run is the ordinary case, not an anomaly: a deploy
|
|
165
|
+
# is exactly when a missing step is found. Refusing the tick pushes that
|
|
166
|
+
# work outside the record, which is the one thing the record exists to
|
|
167
|
+
# provide — so the snapshot WIDENS, and says that it did.
|
|
168
|
+
#
|
|
169
|
+
# It widens from the SUBJECT'S OWN runbook only, never from the recipe.
|
|
170
|
+
# Those are different events wearing the same shape:
|
|
171
|
+
#
|
|
172
|
+
# * someone performing this run added a step to this subject — legal, and
|
|
173
|
+
# the run should carry it;
|
|
174
|
+
# * the canon moved underneath an in-flight run — not legal, and the
|
|
175
|
+
# snapshot exists precisely to hold that line (see `open`).
|
|
176
|
+
#
|
|
177
|
+
# A queue run has no subject, so its checklist IS the recipe and it can
|
|
178
|
+
# never widen. That is the correct answer there, not a limitation.
|
|
179
|
+
def admit_late_item!(record, snapshot, item_id)
|
|
180
|
+
id = item_id.to_s
|
|
181
|
+
item = subject_checklist(record).find { |i| i["id"] == id }
|
|
182
|
+
unless item
|
|
183
|
+
# Distinguish "no such step anywhere" from "exists in the canon, but
|
|
184
|
+
# this run froze before it did" — the caller's response differs.
|
|
185
|
+
raise ItemNotInSnapshot, "checklist item #{item_id} post-dates run #{record.id}'s snapshot" if
|
|
186
|
+
recipe_item?(record, id)
|
|
187
|
+
|
|
188
|
+
raise ItemNotFound, "unknown checklist item #{item_id}"
|
|
189
|
+
end
|
|
190
|
+
# The same bound as every other write path. A snapshot widened one late
|
|
191
|
+
# step at a time is still a payload — it is copied per run, and it is what
|
|
192
|
+
# an MCP reader has to fit in a context window.
|
|
193
|
+
widened = item.merge("post_snapshot" => true)
|
|
194
|
+
# bound_stored!, not bound! — these rows carry `post_snapshot`, and the
|
|
195
|
+
# canonical projection would drop it and undercount the column by a
|
|
196
|
+
# margin that grows with every late step.
|
|
197
|
+
Checklist.bound_stored!(snapshot + [ widened ])
|
|
198
|
+
|
|
199
|
+
# Marked, so a reader can tell what the run opened against from what it
|
|
200
|
+
# picked up along the way. Widening silently would make the snapshot a
|
|
201
|
+
# record of the present, which is the opposite of its job.
|
|
202
|
+
widened
|
|
203
|
+
end
|
|
204
|
+
private_class_method :admit_late_item!
|
|
205
|
+
|
|
206
|
+
def subject_checklist(record)
|
|
207
|
+
return [] unless record.subject_type && record.subject_id
|
|
208
|
+
|
|
209
|
+
runbook = Runbook.find_by(runbookable_type: record.subject_type,
|
|
210
|
+
runbookable_id: record.subject_id)
|
|
211
|
+
stored(runbook&.checklist)
|
|
212
|
+
end
|
|
213
|
+
private_class_method :subject_checklist
|
|
214
|
+
|
|
215
|
+
def recipe_item?(record, id)
|
|
216
|
+
RecipeRegistry.fetch(record.recipe_id).checklist.any? { |i| i[:id].to_s == id }
|
|
217
|
+
rescue Error
|
|
218
|
+
false
|
|
219
|
+
end
|
|
220
|
+
private_class_method :recipe_item?
|
|
221
|
+
|
|
161
222
|
def find_days_run(attributes)
|
|
162
223
|
Run.where(
|
|
163
224
|
recipe_id: attributes[:recipe_id],
|
data/lib/jazari/version.rb
CHANGED