jazari 0.3.0 → 0.5.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 +68 -0
- data/README.md +40 -5
- data/lib/jazari/mcp/actions.rb +2 -1
- data/lib/jazari/mcp/handler.rb +3 -2
- data/lib/jazari/recipe_files.rb +174 -0
- data/lib/jazari/runs.rb +19 -5
- data/lib/jazari/version.rb +1 -1
- data/lib/jazari.rb +12 -5
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f9cbfc9b54c5489c8f751da47fb21b875022595d50b41b144e3aab021e46d9cd
|
|
4
|
+
data.tar.gz: a477a52e534f3683e07d05cae377cdbaaf114c28c8ddb738e532f43bc9ab2cdb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 79803d6a78f7a6bfc032b3319ca341d2f327e9839492d5512f7ddc42d05d5994cc0163cfb9864a230bfd04dee0d9934728a21948b0ebbb49e05ac1dd251a88e1
|
|
7
|
+
data.tar.gz: 6609743e4e095d8e37b8aaf4a753300e67596faef66cf97ceb364cb7d974b3bb24da76bd3d16ba6ddf57afc5db5a16004a8bc44de929ff430f627dbe0b31efd7
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,74 @@ 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.5.0] - 2026-08-12
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- **Execution-scoped actor identity.** Runs now require an opaque `actor_ref`,
|
|
16
|
+
with explicit values taking precedence over a configured trusted-system
|
|
17
|
+
default. Ticks and evidence inherit the run actor unless explicitly
|
|
18
|
+
attributed otherwise.
|
|
19
|
+
- Evidence records now retain `actor_ref`, making it possible to answer who or
|
|
20
|
+
what performed each procedure step without giving Jazari access to actor
|
|
21
|
+
objects.
|
|
22
|
+
- MCP evidence declarations and public examples now expose the actor contract.
|
|
23
|
+
- Public guides and the landing page now document actor attribution and
|
|
24
|
+
RecipeFiles load, seed, dump, and drift workflows.
|
|
25
|
+
|
|
26
|
+
### Compatibility
|
|
27
|
+
|
|
28
|
+
- Existing explicit `actor_ref` calls continue to work unchanged.
|
|
29
|
+
- MCP run start continues to require an explicit actor identity; subsequent
|
|
30
|
+
tick and evidence calls may inherit the actor from the open run.
|
|
31
|
+
|
|
32
|
+
## [0.4.0] - 2026-08-11
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- **`Jazari::RecipeFiles` — recipes as YAML or JSON, without files silently
|
|
37
|
+
overwriting operators.**
|
|
38
|
+
|
|
39
|
+
Nothing ever forbade files: `RecipeRegistry.seed!` takes plain hashes, so
|
|
40
|
+
`seed!(YAML.load_file(…))` already worked. What was missing was everything
|
|
41
|
+
that makes it *safe*.
|
|
42
|
+
|
|
43
|
+
- **`load(path)`** — a file or a directory of `.yml` / `.yaml` / `.json`.
|
|
44
|
+
Accepts one recipe, a list, or a list under a `recipes:` key. It validates
|
|
45
|
+
at LOAD, which is the whole reason it exists rather than "just call
|
|
46
|
+
`YAML.load_file`": an unknown key is a typo, and a typo that loads silently
|
|
47
|
+
becomes a recipe resolving to something nobody wrote. Unknown keys, missing
|
|
48
|
+
topics, bad `run_policy`, duplicate ids and malformed YAML all raise, naming
|
|
49
|
+
the file.
|
|
50
|
+
- **`dump(dir)`** — writes what is actually stored back out, one file per
|
|
51
|
+
recipe. Without this the loop never closes: an operator's runtime fix could
|
|
52
|
+
not be reviewed or committed, so runtime editing would quietly become the
|
|
53
|
+
thing you avoid rather than the thing the design is built around.
|
|
54
|
+
- **`drift(entries)`** — which stored recipes disagree with their file, and in
|
|
55
|
+
which fields. **Reported, never applied.**
|
|
56
|
+
|
|
57
|
+
**Files seed; they do not sync.** `seed!` stays create-if-missing, so a file
|
|
58
|
+
never overwrites a row an operator edited. That is the same rule the runbook
|
|
59
|
+
layer already follows — a customisation diverges rather than rebasing, because
|
|
60
|
+
silently overwriting a deliberate edit with a change nobody saw is the worst
|
|
61
|
+
available outcome. Applying files on every deploy would do exactly that, one
|
|
62
|
+
layer up. The cost is drift, so drift is made visible instead of resolved.
|
|
63
|
+
|
|
64
|
+
One place the loader is deliberately **stricter than the API**: a malformed
|
|
65
|
+
checklist id is an error rather than a fixup. `Checklist.normalize` replaces an
|
|
66
|
+
unusable id with a generated one, which is right when an id is absent and
|
|
67
|
+
opaque — but in a file someone wrote it, MCP addresses the step by it, and
|
|
68
|
+
documentation quotes it. Swapping it for a random token would create exactly
|
|
69
|
+
the file-versus-row disagreement this loader exists to prevent.
|
|
70
|
+
|
|
71
|
+
### Fixed
|
|
72
|
+
|
|
73
|
+
- **The boundary check read heredoc bodies as code.** It stripped `"…"` and
|
|
74
|
+
`'…'` on the stated principle that a capitalised word inside a string is data,
|
|
75
|
+
but a fixture written as `<<~YML` had its own prose reported as constant
|
|
76
|
+
references. Heredoc bodies are now skipped. Verified still catching a planted
|
|
77
|
+
breach.
|
|
78
|
+
|
|
11
79
|
## [0.3.0] - 2026-08-11
|
|
12
80
|
|
|
13
81
|
### Added
|
data/README.md
CHANGED
|
@@ -12,7 +12,8 @@ Requires Ruby 3.2+, Rails 7.1+, and PostgreSQL.
|
|
|
12
12
|
|
|
13
13
|
## Guides
|
|
14
14
|
|
|
15
|
-
The README is the pitch; the [
|
|
15
|
+
The README is the pitch; the public [Kuickr guide](https://kuickr.co/jazari/guide)
|
|
16
|
+
is the operating manual. Its source lives in [`guide/`](guide/), including
|
|
16
17
|
[concepts](guide/01-concepts.md) (start here: one word means something
|
|
17
18
|
different than you expect), [adoption](guide/02-adoption.md),
|
|
18
19
|
[anchors](guide/03-anchors.md), [runs and evidence](guide/04-runs.md),
|
|
@@ -96,7 +97,8 @@ Jazari.tick(run: run, expected_revision: run.lock_version,
|
|
|
96
97
|
item_id: "restore", done: true, actor_ref: "agent:nightly")
|
|
97
98
|
|
|
98
99
|
Jazari.attach_evidence(run: run.reload, expected_revision: run.lock_version,
|
|
99
|
-
item_id: "counts", kind: "count", value: "4211 rows"
|
|
100
|
+
item_id: "counts", kind: "count", value: "4211 rows",
|
|
101
|
+
actor_ref: "agent:nightly")
|
|
100
102
|
|
|
101
103
|
Jazari.close_run(run: run.reload, expected_revision: run.lock_version,
|
|
102
104
|
outcome: "completed")
|
|
@@ -156,6 +158,15 @@ operator-owned. Reseeding never overwrites an edit.
|
|
|
156
158
|
That means fixing a ritual is a **write, not a deploy** — and a fresh install
|
|
157
159
|
can ship with working procedures instead of an empty text box.
|
|
158
160
|
|
|
161
|
+
For version-controlled recipes, load YAML or JSON as a seed and report drift
|
|
162
|
+
without overwriting operator edits:
|
|
163
|
+
|
|
164
|
+
```ruby
|
|
165
|
+
entries = Jazari::RecipeFiles.load("config/recipes")
|
|
166
|
+
Jazari::RecipeRegistry.seed!(entries)
|
|
167
|
+
Jazari::RecipeFiles.drift(entries)
|
|
168
|
+
```
|
|
169
|
+
|
|
159
170
|
## Runs are bound to the canon they opened against
|
|
160
171
|
|
|
161
172
|
A run snapshots its checklist when it opens. Edit the recipe mid-run and the
|
|
@@ -163,6 +174,29 @@ in-flight run still ticks its own steps, and refuses steps that did not exist
|
|
|
163
174
|
when it started. Without this, an operator improving a procedure silently breaks
|
|
164
175
|
every run in progress.
|
|
165
176
|
|
|
177
|
+
## Actor identity is part of the evidence
|
|
178
|
+
|
|
179
|
+
Jazari stores the opaque identity attached to the run, every tick, and every
|
|
180
|
+
evidence entry. Pass a stable reference when a human, agent, or job acts:
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
Jazari.open_run(target: target, actor_ref: "user:42")
|
|
184
|
+
Jazari.tick(run: run, expected_revision: run.lock_version,
|
|
185
|
+
item_id: "restore", done: true, actor_ref: "user:42")
|
|
186
|
+
Jazari.attach_evidence(run: run.reload, expected_revision: run.lock_version,
|
|
187
|
+
item_id: "restore", kind: "note", value: "verified",
|
|
188
|
+
actor_ref: "user:42")
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
For trusted system jobs, configure a zero-argument default. Explicit references
|
|
192
|
+
always win. When a tick or evidence entry omits its actor, it inherits the run's
|
|
193
|
+
actor; opening a run without an explicit actor requires this configured default.
|
|
194
|
+
|
|
195
|
+
```ruby
|
|
196
|
+
Jazari.configure { |c| c.actor_ref = -> { "system:nightly-backup" } }
|
|
197
|
+
Jazari.open_run(target: target)
|
|
198
|
+
```
|
|
199
|
+
|
|
166
200
|
## MCP
|
|
167
201
|
|
|
168
202
|
`Jazari::Mcp::Handler` maps action names onto the domain and knows nothing about
|
|
@@ -183,10 +217,11 @@ could disclose a record or whether a target exists.
|
|
|
183
217
|
`Handler.actions_for("read")` returns the read-only subset, so a read-scoped
|
|
184
218
|
connection never advertises mutations.
|
|
185
219
|
|
|
186
|
-
## You authorize; Jazari never sees an actor
|
|
220
|
+
## You authorize; Jazari never sees an actor object
|
|
187
221
|
|
|
188
|
-
The domain accepts no raw IDs,
|
|
189
|
-
authorizes first, then constructs exactly one immutable target
|
|
222
|
+
The domain accepts no raw IDs, arbitrary records, or actor objects. Your app
|
|
223
|
+
authorizes first, then constructs exactly one immutable target. It passes only an
|
|
224
|
+
opaque `actor_ref` string for audit history:
|
|
190
225
|
|
|
191
226
|
```ruby
|
|
192
227
|
Jazari::RecordTarget.new(runbookable: site, public_reference: { kind: "site" },
|
data/lib/jazari/mcp/actions.rb
CHANGED
|
@@ -74,7 +74,8 @@ module Jazari
|
|
|
74
74
|
summary: "Attach evidence to a run: output, url, sha, count, or note.",
|
|
75
75
|
params: { run_id: RUN_ID, expected_revision: REVISION, item_id: ITEM_ID,
|
|
76
76
|
kind: { type: "string", description: "One of: output, url, sha, count, note." },
|
|
77
|
-
value: { type: "string", description: "The evidence itself." }
|
|
77
|
+
value: { type: "string", description: "The evidence itself." },
|
|
78
|
+
actor_ref: ACTOR }),
|
|
78
79
|
Action.new(name: "finish", scope: :write, effect: :additive, confirm: false,
|
|
79
80
|
summary: "Close a run with an outcome: completed, abandoned, or failed.",
|
|
80
81
|
params: { run_id: RUN_ID, expected_revision: REVISION,
|
data/lib/jazari/mcp/handler.rb
CHANGED
|
@@ -86,13 +86,14 @@ module Jazari
|
|
|
86
86
|
def handle_tick(_target, args)
|
|
87
87
|
run = Runs.tick(run: args.fetch(:run_id), expected_revision: args[:expected_revision],
|
|
88
88
|
item_id: args[:item_id], done: args.fetch(:done, true),
|
|
89
|
-
actor_ref: args
|
|
89
|
+
actor_ref: args[:actor_ref], note: args[:note])
|
|
90
90
|
run_view(run)
|
|
91
91
|
end
|
|
92
92
|
|
|
93
93
|
def handle_evidence(_target, args)
|
|
94
94
|
run = Runs.attach_evidence(run: args.fetch(:run_id), expected_revision: args[:expected_revision],
|
|
95
|
-
item_id: args[:item_id], kind: args.fetch(:kind),
|
|
95
|
+
item_id: args[:item_id], kind: args.fetch(:kind),
|
|
96
|
+
value: args.fetch(:value), actor_ref: args[:actor_ref])
|
|
96
97
|
run_view(run)
|
|
97
98
|
end
|
|
98
99
|
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Jazari
|
|
7
|
+
# Recipes as FILES — the version-controlled, reviewable form of the same data.
|
|
8
|
+
#
|
|
9
|
+
# This does not make files a second source of truth. `seed!` has always been
|
|
10
|
+
# create-if-missing, so a file is a SEED, not a sync: the row wins once it
|
|
11
|
+
# exists, because an operator editing a procedure at runtime is the whole
|
|
12
|
+
# reason recipes are data rather than code.
|
|
13
|
+
#
|
|
14
|
+
# That is deliberate and it is the same rule the runbook layer already follows —
|
|
15
|
+
# a customised runbook diverges permanently rather than rebasing, because
|
|
16
|
+
# silently overwriting a deliberate edit with a change nobody saw is the worst
|
|
17
|
+
# available outcome. Applying files on every deploy would do exactly that, one
|
|
18
|
+
# layer up.
|
|
19
|
+
#
|
|
20
|
+
# The cost of that choice is drift: a file and a row can disagree and nothing
|
|
21
|
+
# says so. So drift is REPORTED (`drift`) rather than resolved, and there is a
|
|
22
|
+
# way back out (`dump`) — edit at runtime, export, review the diff in a pull
|
|
23
|
+
# request, commit. The loop closes without anyone's work being overwritten.
|
|
24
|
+
module RecipeFiles
|
|
25
|
+
EXTENSIONS = %w[.yml .yaml .json].freeze
|
|
26
|
+
|
|
27
|
+
# Keys a recipe file may carry. Anything else is a typo, and a typo that
|
|
28
|
+
# loads silently becomes a recipe that resolves to something nobody wrote.
|
|
29
|
+
KEYS = %i[id version topic description checklist run_policy].freeze
|
|
30
|
+
|
|
31
|
+
module_function
|
|
32
|
+
|
|
33
|
+
# Reads one file or every recipe file in a directory. Returns plain hashes,
|
|
34
|
+
# ready for `RecipeRegistry.seed!` — which is why this is a loader and not a
|
|
35
|
+
# registry: producing the data and storing it are separate concerns.
|
|
36
|
+
def load(path)
|
|
37
|
+
entries = Array(paths_for(path)).flat_map { |file| parse(file) }
|
|
38
|
+
entries.each { |entry| validate!(entry) }
|
|
39
|
+
ids = entries.map { |entry| entry[:id] }
|
|
40
|
+
duplicated = ids.tally.select { |_, count| count > 1 }.keys
|
|
41
|
+
raise InvalidRunbook, "duplicate recipe ids: #{duplicated.join(', ')}" if duplicated.any?
|
|
42
|
+
|
|
43
|
+
entries
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Writes what is actually stored back out as YAML, one file per recipe.
|
|
47
|
+
# This is the half that makes runtime editing safe to allow: whatever an
|
|
48
|
+
# operator changed can be exported, diffed and committed.
|
|
49
|
+
def dump(directory, recipes: RecipeRecord.order(:recipe_id))
|
|
50
|
+
dir = File.expand_path(directory.to_s)
|
|
51
|
+
Dir.mkdir(dir) unless Dir.exist?(dir)
|
|
52
|
+
recipes.map do |record|
|
|
53
|
+
file = File.join(dir, "#{record.recipe_id}.yml")
|
|
54
|
+
File.write(file, YAML.dump(stringify(to_entry(record))))
|
|
55
|
+
file
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Which stored recipes disagree with their file definition, and how.
|
|
60
|
+
#
|
|
61
|
+
# Reported, never applied. A host decides what a difference means: on one
|
|
62
|
+
# fleet a file is the reviewed truth and a divergent row is an incident; on
|
|
63
|
+
# another the row is an operator's fix and the file is simply stale.
|
|
64
|
+
def drift(entries)
|
|
65
|
+
Array(entries).filter_map do |entry|
|
|
66
|
+
attributes = normalize(entry)
|
|
67
|
+
record = RecipeRecord.find_by(recipe_id: attributes[:id].to_s)
|
|
68
|
+
next { id: attributes[:id], state: :missing } if record.nil?
|
|
69
|
+
|
|
70
|
+
differing = KEYS.reject { |key| same?(key, attributes, record) }
|
|
71
|
+
next if differing.empty?
|
|
72
|
+
|
|
73
|
+
{ id: attributes[:id], state: :differs, fields: differing }
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# -- internals ---------------------------------------------------------
|
|
78
|
+
|
|
79
|
+
def paths_for(path)
|
|
80
|
+
expanded = File.expand_path(path.to_s)
|
|
81
|
+
return [ expanded ] if File.file?(expanded)
|
|
82
|
+
raise InvalidRunbook, "no such recipe path: #{path}" unless File.directory?(expanded)
|
|
83
|
+
|
|
84
|
+
Dir.children(expanded).sort
|
|
85
|
+
.select { |name| EXTENSIONS.include?(File.extname(name)) }
|
|
86
|
+
.map { |name| File.join(expanded, name) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def parse(file)
|
|
90
|
+
raw = File.read(file)
|
|
91
|
+
data = if File.extname(file) == ".json"
|
|
92
|
+
JSON.parse(raw)
|
|
93
|
+
else
|
|
94
|
+
# safe_load: a recipe file is operational content, never a place to
|
|
95
|
+
# instantiate arbitrary objects.
|
|
96
|
+
YAML.safe_load(raw, permitted_classes: [], aliases: false)
|
|
97
|
+
end
|
|
98
|
+
# A file is either one recipe, a list of them, or a list under a `recipes:`
|
|
99
|
+
# key. `Array(hash)` would explode a single recipe into key/value pairs, so
|
|
100
|
+
# the Hash cases are named rather than coerced.
|
|
101
|
+
entries = if data.is_a?(Hash)
|
|
102
|
+
data.key?("recipes") ? Array(data["recipes"]) : [ data ]
|
|
103
|
+
else
|
|
104
|
+
Array(data)
|
|
105
|
+
end
|
|
106
|
+
entries.map { |entry| normalize(entry) }
|
|
107
|
+
rescue JSON::ParserError, Psych::SyntaxError => error
|
|
108
|
+
raise InvalidRunbook, "#{File.basename(file)}: #{error.message}"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def normalize(entry)
|
|
112
|
+
entry.to_h.transform_keys { |key| key.to_s.to_sym }
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate!(entry)
|
|
116
|
+
unknown = entry.keys - KEYS
|
|
117
|
+
raise InvalidRunbook, "unknown recipe keys: #{unknown.join(', ')}" if unknown.any?
|
|
118
|
+
raise InvalidRunbook, "recipe id is required" if entry[:id].to_s.empty?
|
|
119
|
+
raise InvalidRunbook, "recipe #{entry[:id]} has no topic" if entry[:topic].to_s.empty?
|
|
120
|
+
|
|
121
|
+
policy = entry.fetch(:run_policy, RunPolicy::UNRESTRICTED).to_s
|
|
122
|
+
unless RunPolicy::ALL.include?(policy)
|
|
123
|
+
raise InvalidRunbook, "recipe #{entry[:id]} has unknown run_policy #{policy}"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Reuse the one checklist validator rather than writing a second, laxer
|
|
127
|
+
# one here — a file must not be able to store an item the API would reject.
|
|
128
|
+
items = entry.fetch(:checklist, [])
|
|
129
|
+
Checklist.normalize(items)
|
|
130
|
+
|
|
131
|
+
# STRICTER than the API on one point, deliberately. `normalize` REPLACES an
|
|
132
|
+
# unusable id with a generated one, which is right when an id is absent and
|
|
133
|
+
# opaque. In a file it is neither: someone wrote it, MCP addresses the step
|
|
134
|
+
# by it, and documentation quotes it. Silently swapping it for a random
|
|
135
|
+
# token would put the file and the row into exactly the disagreement this
|
|
136
|
+
# loader exists to prevent — so a malformed id is an error, not a fixup.
|
|
137
|
+
Array(items).each do |item|
|
|
138
|
+
id = (item[:id] || item["id"]).to_s
|
|
139
|
+
next if id.empty? || id.match?(Checklist::ID_FORMAT)
|
|
140
|
+
|
|
141
|
+
raise InvalidRunbook, "recipe #{entry[:id]}: checklist id #{id.inspect} is not a valid token"
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
entry
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
def to_entry(record)
|
|
148
|
+
{ id: record.recipe_id, version: record.version, topic: record.topic,
|
|
149
|
+
description: record.description, run_policy: record.run_policy,
|
|
150
|
+
checklist: Checklist.normalize(record.checklist) }
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def stringify(value)
|
|
154
|
+
case value
|
|
155
|
+
when Hash then value.to_h { |key, inner| [ key.to_s, stringify(inner) ] }
|
|
156
|
+
when Array then value.map { |inner| stringify(inner) }
|
|
157
|
+
when Symbol then value.to_s
|
|
158
|
+
else value
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def same?(key, attributes, record)
|
|
163
|
+
stored = case key
|
|
164
|
+
when :id then record.recipe_id
|
|
165
|
+
when :checklist then Checklist.normalize(record.checklist)
|
|
166
|
+
else record.public_send(key)
|
|
167
|
+
end
|
|
168
|
+
expected = key == :checklist ? Checklist.normalize(attributes.fetch(key, [])) : attributes[key]
|
|
169
|
+
return true if expected.nil? && key != :id
|
|
170
|
+
|
|
171
|
+
stringify(stored) == stringify(expected)
|
|
172
|
+
end
|
|
173
|
+
end
|
|
174
|
+
end
|
data/lib/jazari/runs.rb
CHANGED
|
@@ -18,10 +18,11 @@ module Jazari
|
|
|
18
18
|
# Insert FIRST, then rescue the unique violation and select the winner.
|
|
19
19
|
# A find-then-insert races under concurrent writers — the same defect class
|
|
20
20
|
# the revision guard exists to prevent.
|
|
21
|
-
def open(target:, actor_ref
|
|
21
|
+
def open(target:, actor_ref: nil, now: Time.now.utc)
|
|
22
22
|
recipe = RecipeRegistry.fetch(target.recipe_id)
|
|
23
23
|
subject = subject_for(target)
|
|
24
24
|
started_at = now.utc
|
|
25
|
+
actor_ref = resolve_actor_ref(actor_ref)
|
|
25
26
|
|
|
26
27
|
attributes = {
|
|
27
28
|
recipe_id: recipe.id,
|
|
@@ -67,7 +68,7 @@ module Jazari
|
|
|
67
68
|
end
|
|
68
69
|
end
|
|
69
70
|
|
|
70
|
-
def tick(run:, expected_revision:, item_id:, done:, actor_ref
|
|
71
|
+
def tick(run:, expected_revision:, item_id:, done:, actor_ref: nil, note: nil, now: Time.now.utc)
|
|
71
72
|
mutate(run, expected_revision) do |record|
|
|
72
73
|
raise RunClosed, "run #{record.id} is already closed" if record.closed?
|
|
73
74
|
|
|
@@ -84,12 +85,14 @@ module Jazari
|
|
|
84
85
|
|
|
85
86
|
ticks = stored(record.ticks).reject { |t| t["id"] == item_id.to_s }
|
|
86
87
|
ticks << { "id" => item_id.to_s, "done" => done == true, "at" => now.utc.iso8601,
|
|
87
|
-
"actor_ref" => actor_ref
|
|
88
|
+
"actor_ref" => resolve_actor_ref(actor_ref, fallback: record.actor_ref),
|
|
89
|
+
"note" => note&.to_s }
|
|
88
90
|
record.ticks = ticks
|
|
89
91
|
end
|
|
90
92
|
end
|
|
91
93
|
|
|
92
|
-
def attach_evidence(run:, expected_revision:, item_id:, kind:, value:,
|
|
94
|
+
def attach_evidence(run:, expected_revision:, item_id:, kind:, value:, actor_ref: nil,
|
|
95
|
+
now: Time.now.utc)
|
|
93
96
|
raise InvalidRunbook, "unknown evidence kind #{kind.inspect}" unless EVIDENCE_KINDS.include?(kind.to_s)
|
|
94
97
|
|
|
95
98
|
mutate(run, expected_revision) do |record|
|
|
@@ -97,7 +100,8 @@ module Jazari
|
|
|
97
100
|
|
|
98
101
|
record.evidence = stored(record.evidence) + [
|
|
99
102
|
{ "item_id" => item_id&.to_s, "kind" => kind.to_s,
|
|
100
|
-
"value" => value.to_s[0, MAX_EVIDENCE], "at" => now.utc.iso8601
|
|
103
|
+
"value" => value.to_s[0, MAX_EVIDENCE], "at" => now.utc.iso8601,
|
|
104
|
+
"actor_ref" => resolve_actor_ref(actor_ref, fallback: record.actor_ref) }
|
|
101
105
|
]
|
|
102
106
|
end
|
|
103
107
|
end
|
|
@@ -175,5 +179,15 @@ module Jazari
|
|
|
175
179
|
|
|
176
180
|
def stored(value) = Array(value).map { |h| h.to_h.transform_keys(&:to_s) }
|
|
177
181
|
private_class_method :stored
|
|
182
|
+
|
|
183
|
+
def resolve_actor_ref(explicit, fallback: nil)
|
|
184
|
+
value = explicit || fallback || Jazari.config.actor_ref
|
|
185
|
+
value = value.call if value.respond_to?(:call)
|
|
186
|
+
value = value.to_s
|
|
187
|
+
raise InvalidRunbook, "actor_ref is required" if value.empty?
|
|
188
|
+
|
|
189
|
+
value
|
|
190
|
+
end
|
|
191
|
+
private_class_method :resolve_actor_ref
|
|
178
192
|
end
|
|
179
193
|
end
|
data/lib/jazari/version.rb
CHANGED
data/lib/jazari.rb
CHANGED
|
@@ -8,6 +8,7 @@ require "jazari/targets"
|
|
|
8
8
|
require "jazari/anchors"
|
|
9
9
|
require "jazari/resolved_runbook"
|
|
10
10
|
require "jazari/recipe_registry"
|
|
11
|
+
require "jazari/recipe_files"
|
|
11
12
|
require "jazari/runs"
|
|
12
13
|
require "jazari/operations"
|
|
13
14
|
# The MCP layer is OPTIONAL. Descriptors are cheap and a host may want them to
|
|
@@ -43,7 +44,9 @@ module Jazari
|
|
|
43
44
|
# renaming live tables in the same deploy as a cut-over is exactly what
|
|
44
45
|
# the adoption plan forbids. Any key omitted falls back to the prefix.
|
|
45
46
|
self.table_names ||= {}
|
|
46
|
-
|
|
47
|
+
# Optional zero-argument provider for trusted system contexts. User and
|
|
48
|
+
# agent callers should pass an explicit opaque actor_ref.
|
|
49
|
+
self.actor_ref = nil if actor_ref.nil?
|
|
47
50
|
end
|
|
48
51
|
|
|
49
52
|
# Fail at boot, not at first call.
|
|
@@ -51,6 +54,10 @@ module Jazari
|
|
|
51
54
|
anchor_scopes.each_key do |scope|
|
|
52
55
|
raise ArgumentError, "anchor scope #{scope.inspect} must be a String" unless scope.is_a?(String)
|
|
53
56
|
end
|
|
57
|
+
if actor_ref && !actor_ref.is_a?(String) &&
|
|
58
|
+
!(actor_ref.respond_to?(:call) && actor_ref.respond_to?(:arity) && actor_ref.arity.zero?)
|
|
59
|
+
raise ArgumentError, "actor_ref must be a String or zero-argument callable"
|
|
60
|
+
end
|
|
54
61
|
true
|
|
55
62
|
end
|
|
56
63
|
end
|
|
@@ -81,18 +88,18 @@ module Jazari
|
|
|
81
88
|
# The documented public interface (spec 02 section 3). `Runs` is the
|
|
82
89
|
# implementation; these are the names hosts and the MCP handler call.
|
|
83
90
|
class << self
|
|
84
|
-
def open_run(target:, actor_ref
|
|
91
|
+
def open_run(target:, actor_ref: nil, now: Time.now.utc)
|
|
85
92
|
Runs.open(target: target, actor_ref: actor_ref, now: now)
|
|
86
93
|
end
|
|
87
94
|
|
|
88
|
-
def tick(run:, expected_revision:, item_id:, done:, actor_ref
|
|
95
|
+
def tick(run:, expected_revision:, item_id:, done:, actor_ref: nil, note: nil)
|
|
89
96
|
Runs.tick(run: run, expected_revision: expected_revision, item_id: item_id,
|
|
90
97
|
done: done, actor_ref: actor_ref, note: note)
|
|
91
98
|
end
|
|
92
99
|
|
|
93
|
-
def attach_evidence(run:, expected_revision:, item_id:, kind:, value:)
|
|
100
|
+
def attach_evidence(run:, expected_revision:, item_id:, kind:, value:, actor_ref: nil)
|
|
94
101
|
Runs.attach_evidence(run: run, expected_revision: expected_revision,
|
|
95
|
-
item_id: item_id, kind: kind, value: value)
|
|
102
|
+
item_id: item_id, kind: kind, value: value, actor_ref: actor_ref)
|
|
96
103
|
end
|
|
97
104
|
|
|
98
105
|
def close_run(run:, expected_revision:, outcome:)
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: jazari
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.5.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Nauman Tariq
|
|
@@ -67,6 +67,7 @@ files:
|
|
|
67
67
|
- lib/jazari/operations.rb
|
|
68
68
|
- lib/jazari/railtie.rb
|
|
69
69
|
- lib/jazari/recipe.rb
|
|
70
|
+
- lib/jazari/recipe_files.rb
|
|
70
71
|
- lib/jazari/recipe_registry.rb
|
|
71
72
|
- lib/jazari/resolved_runbook.rb
|
|
72
73
|
- lib/jazari/runs.rb
|