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
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: dcd3c77813c6290564bdaeb857e3e16ccf09381f14ed5042bec05c4c78d34af1
|
|
4
|
+
data.tar.gz: 655b4e0d33237093113ee341b02acc5f7a51b1ad16a781fa4c15faa8ce482af0
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: dd38fccf2e5720b002824294ae7a178e206d6dccef3489cc61ce953fa507f991069e69ee9ed34b15f3a8de64c29edc7cbce8d8c02dea7c3a351a2d9ce8ab3b8f
|
|
7
|
+
data.tar.gz: e163aa00ba58ef159416ca8f41360ee8bf1ad3743c1b129d61ad0e0e5cfb353111903f890545d5439e449149b9a4c2e0deda8c13b853ff3272fabff1788abfd4
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes are recorded here. The release workflow REFUSES to publish
|
|
4
|
+
a version with no entry — see RELEASING.md.
|
|
5
|
+
|
|
6
|
+
Pre-1.0: minor versions may break. The public contract includes the error
|
|
7
|
+
codes, the resolved-value shape, how revisions are computed, and the schema the
|
|
8
|
+
generator emits — changes to any of those are breaking even when the method
|
|
9
|
+
signatures do not move.
|
|
10
|
+
|
|
11
|
+
## [0.1.0] - 2026-08-10
|
|
12
|
+
|
|
13
|
+
First release. Proven against one host adoption.
|
|
14
|
+
|
|
15
|
+
- Four layers: recipe (the canon), runbook (per-subject override), queue
|
|
16
|
+
(a stable name for a ritual), run (one execution with evidence).
|
|
17
|
+
- Per-recipe idempotency (`unrestricted` / `once_per_calendar_day`), enforced by
|
|
18
|
+
a partial unique index over a `COALESCE`d polymorphic subject and a UTC day.
|
|
19
|
+
- Revision guards on every mutation; digest-based revisions for defaults.
|
|
20
|
+
- Runs are bound to the canon they opened against via a checklist snapshot.
|
|
21
|
+
- `Jazari::Mcp::Handler` — transport-neutral action dispatch; host owns tool identity.
|
|
22
|
+
- `rails g jazari:install` — host-adopted migration; the gem never auto-appends.
|
|
23
|
+
- PostgreSQL only. The suite runs the migration the gem ships.
|
|
24
|
+
- `Jazari.forget_subject` — host-called cleanup; runs deliberately survive.
|
|
25
|
+
- Railtie so models autoload in a host (the gem was unusable without it).
|
|
26
|
+
- Per-table name overrides, for hosts adopting tables they already have.
|
|
27
|
+
|
|
28
|
+
### Found by the first host adoption
|
|
29
|
+
|
|
30
|
+
Five defects that no amount of unit testing had surfaced, each now
|
|
31
|
+
regression-tested — every one a place the gem assumed it owned something the
|
|
32
|
+
host actually owns:
|
|
33
|
+
|
|
34
|
+
- models never autoloaded in a real application
|
|
35
|
+
- read and write paths resolved anchors differently
|
|
36
|
+
- host resolvers were not told whether they may create, so reads wrote
|
|
37
|
+
- `reset` assumed the gem's own anchor class and left host-owned orphans
|
|
38
|
+
- the recipe registry could disagree with itself across a shim boundary
|
data/LICENSE
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction.
|
|
8
|
+
|
|
9
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
|
data/README.md
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# Jazari
|
|
2
|
+
|
|
3
|
+
**Operating procedures you can call, instead of documents you hope someone reads.**
|
|
4
|
+
|
|
5
|
+
Recipes as data, per-subject runbooks, stable names for rituals that outlive any
|
|
6
|
+
record, and per-run evidence — so *"did last night's run actually complete?"*
|
|
7
|
+
is a query rather than a guess.
|
|
8
|
+
|
|
9
|
+
Requires Ruby 3.2+, Rails 7.1+, and PostgreSQL.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## The problem
|
|
14
|
+
|
|
15
|
+
You have a procedure. Verify a backup by restoring it. Provision a server.
|
|
16
|
+
Triage an alert before waking anyone.
|
|
17
|
+
|
|
18
|
+
It is written down. It lives in a document, or a wiki, or a comment. And so:
|
|
19
|
+
|
|
20
|
+
- Nothing can **check it off**, so nobody knows how far a run got.
|
|
21
|
+
- Nothing knows whether it is **current**, so it rots silently.
|
|
22
|
+
- Nothing records **who ran it, when, or what they saw**.
|
|
23
|
+
- An agent cannot **call** it, because it has no name — only a location.
|
|
24
|
+
|
|
25
|
+
The usual fix is a checklist attached to a record. That helps, and then it
|
|
26
|
+
runs out: some procedures belong to no record at all, and a checklist you
|
|
27
|
+
`reset` to run again has just destroyed the evidence it ever ran.
|
|
28
|
+
|
|
29
|
+
## Four layers
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
RECIPE the canon — how this ritual is done. Data, not code.
|
|
33
|
+
↓ operator-editable at runtime, digest-versioned
|
|
34
|
+
RUNBOOK one subject's override — how THIS record differs
|
|
35
|
+
↓ materialised on first edit; reading a default writes nothing
|
|
36
|
+
QUEUE a stable name for a ritual that outlives any record
|
|
37
|
+
↓ read-only: a ritual has exactly one editable home
|
|
38
|
+
RUN one execution — who, when, which ticks, what evidence
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Most systems stop at the second. The third makes a procedure **callable**; the
|
|
42
|
+
fourth makes it **auditable**.
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
gem "jazari"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
bin/rails generate jazari:install
|
|
52
|
+
bin/rails db:migrate
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
The generator copies one migration. **Jazari never auto-appends migrations** to
|
|
56
|
+
your schema — a shared operations table appearing in someone's next
|
|
57
|
+
`db:migrate` without them asking is how a gem loses trust in a production fleet.
|
|
58
|
+
|
|
59
|
+
## Thirty seconds
|
|
60
|
+
|
|
61
|
+
```ruby
|
|
62
|
+
# 1. Seed a recipe. The gem ships NO content — these are your procedures.
|
|
63
|
+
Jazari::RecipeRegistry.seed!([
|
|
64
|
+
{ id: "backup.verify.v1",
|
|
65
|
+
topic: "Prove a backup by restoring it",
|
|
66
|
+
description: "## Purpose\n\nA green schedule is not a verified backup.",
|
|
67
|
+
run_policy: "once_per_calendar_day",
|
|
68
|
+
checklist: [
|
|
69
|
+
{ id: "dump", text: "Dump to scratch" },
|
|
70
|
+
{ id: "restore", text: "Restore into a throwaway database" },
|
|
71
|
+
{ id: "counts", text: "Compare table and row counts" }
|
|
72
|
+
] }
|
|
73
|
+
])
|
|
74
|
+
|
|
75
|
+
# 2. Address the ritual by NAME. No record required.
|
|
76
|
+
target = Jazari::QueueTarget.new(
|
|
77
|
+
queue: "backup-verify", public_reference: { kind: "queue" },
|
|
78
|
+
recipe_id: "backup.verify.v1"
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
Jazari.resolve(target: target).progress # => { done: 0, total: 3, percent: 0 }
|
|
82
|
+
|
|
83
|
+
# 3. Open a run, work it, attach what you saw.
|
|
84
|
+
result = Jazari.open_run(target: target, actor_ref: "agent:nightly")
|
|
85
|
+
run = result[:run]
|
|
86
|
+
|
|
87
|
+
Jazari.tick(run: run, expected_revision: run.lock_version,
|
|
88
|
+
item_id: "restore", done: true, actor_ref: "agent:nightly")
|
|
89
|
+
|
|
90
|
+
Jazari.attach_evidence(run: run.reload, expected_revision: run.lock_version,
|
|
91
|
+
item_id: "counts", kind: "count", value: "4211 rows")
|
|
92
|
+
|
|
93
|
+
Jazari.close_run(run: run.reload, expected_revision: run.lock_version,
|
|
94
|
+
outcome: "completed")
|
|
95
|
+
|
|
96
|
+
# 4. The question that started all this:
|
|
97
|
+
Jazari.last_run(target: target).outcome # => "completed"
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Idempotency belongs to the ritual
|
|
101
|
+
|
|
102
|
+
Verifying a backup should happen **once a day**. Triaging an incident may happen
|
|
103
|
+
five times. So there is no global rule — each recipe declares its own:
|
|
104
|
+
|
|
105
|
+
| `run_policy` | Behaviour |
|
|
106
|
+
|---|---|
|
|
107
|
+
| `unrestricted` (default) | every `open_run` starts a run |
|
|
108
|
+
| `once_per_calendar_day` | one run per recipe + subject + **UTC** day |
|
|
109
|
+
|
|
110
|
+
Under the daily policy a second call **returns the existing run** rather than
|
|
111
|
+
erroring, so a retrying cron converges:
|
|
112
|
+
|
|
113
|
+
```ruby
|
|
114
|
+
Jazari.open_run(target: target, actor_ref: "cron")
|
|
115
|
+
# => { run: #<Run id: 4412>, created: false, idempotent_reuse: true }
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Enforced by a partial unique index, not by application logic — a
|
|
119
|
+
find-then-insert races. Two details that are easy to get wrong and are handled
|
|
120
|
+
here: the index `COALESCE`s the nullable polymorphic subject (otherwise queue
|
|
121
|
+
runs are unconstrained entirely, because `NULL != NULL`), and the day is **UTC**
|
|
122
|
+
via `timestamptz`, so one nightly ritual cannot land on two different days
|
|
123
|
+
depending on which region's machine called it.
|
|
124
|
+
|
|
125
|
+
## Revision guards
|
|
126
|
+
|
|
127
|
+
Every mutation carries the revision from the read before it:
|
|
128
|
+
|
|
129
|
+
```ruby
|
|
130
|
+
resolved = Jazari.resolve(target: target)
|
|
131
|
+
Jazari.check_item(target: target, expected_revision: resolved.revision,
|
|
132
|
+
item_id: "dump", done: true)
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
A customised runbook uses its `lock_version`; a default uses
|
|
136
|
+
`default:<recipe-digest>`. Editing a recipe changes its digest, so anyone
|
|
137
|
+
holding a stale default gets `revision_conflict` instead of silently writing
|
|
138
|
+
onto ground that moved. This matters most when several automated writers share
|
|
139
|
+
one procedure — last-writer-wins is the same defect class as two people
|
|
140
|
+
force-pushing a branch.
|
|
141
|
+
|
|
142
|
+
## Recipes are data, not code
|
|
143
|
+
|
|
144
|
+
The gem ships **no recipe content** — not one checklist item, only the
|
|
145
|
+
mechanism and an empty fallback. Your procedures are rows: seeded once, then
|
|
146
|
+
operator-owned. Reseeding never overwrites an edit.
|
|
147
|
+
|
|
148
|
+
That means fixing a ritual is a **write, not a deploy** — and a fresh install
|
|
149
|
+
can ship with working procedures instead of an empty text box.
|
|
150
|
+
|
|
151
|
+
## Runs are bound to the canon they opened against
|
|
152
|
+
|
|
153
|
+
A run snapshots its checklist when it opens. Edit the recipe mid-run and the
|
|
154
|
+
in-flight run still ticks its own steps, and refuses steps that did not exist
|
|
155
|
+
when it started. Without this, an operator improving a procedure silently breaks
|
|
156
|
+
every run in progress.
|
|
157
|
+
|
|
158
|
+
## MCP
|
|
159
|
+
|
|
160
|
+
`Jazari::Mcp::Handler` maps action names onto the domain and knows nothing about
|
|
161
|
+
transport, auth, or product naming:
|
|
162
|
+
|
|
163
|
+
```ruby
|
|
164
|
+
Jazari::Mcp::Handler.new.call(action: "get", target: target)
|
|
165
|
+
# => { ok: true, state: "default", topic: "...", progress: {...}, last_run: {...} }
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
**Tool identity stays yours.** Your app exposes its own flat `action`-enum tool
|
|
169
|
+
with its own subject vocabulary and permissions; this handler is the shared
|
|
170
|
+
implementation underneath. Domain failures cross the wire as codes from a closed
|
|
171
|
+
set — `target_not_found`, `invalid_runbook`, `revision_conflict`,
|
|
172
|
+
`item_not_found`, `read_only_target`, `run_closed` — never as messages that
|
|
173
|
+
could disclose a record or whether a target exists.
|
|
174
|
+
|
|
175
|
+
`Handler.actions_for("read")` returns the read-only subset, so a read-scoped
|
|
176
|
+
connection never advertises mutations.
|
|
177
|
+
|
|
178
|
+
## You authorize; Jazari never sees an actor
|
|
179
|
+
|
|
180
|
+
The domain accepts no raw IDs, no arbitrary records, and no actor. Your app
|
|
181
|
+
authorizes first, then constructs exactly one immutable target:
|
|
182
|
+
|
|
183
|
+
```ruby
|
|
184
|
+
Jazari::RecordTarget.new(runbookable: site, public_reference: { kind: "site" },
|
|
185
|
+
recipe_id: "site.maintenance.v1")
|
|
186
|
+
Jazari::QueueTarget.new(queue: "backup-verify", ...) # read-only
|
|
187
|
+
Jazari::AnchorTarget.new(scope_type: "Tree", scope_id: 7, key: "node-x", ...)
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
`AnchorTarget` covers subjects that are not ActiveRecord rows — a JSON-tree
|
|
191
|
+
node, a file path, a DNS zone. Register the scope at boot; unregistered scopes
|
|
192
|
+
fail closed.
|
|
193
|
+
|
|
194
|
+
## Deleting a subject
|
|
195
|
+
|
|
196
|
+
Jazari cannot hook your models — a subject may live in a different logical
|
|
197
|
+
database, so no cross-database foreign key is claimed and no cascade exists.
|
|
198
|
+
Call in from your own `after_commit`:
|
|
199
|
+
|
|
200
|
+
```ruby
|
|
201
|
+
class Site < ApplicationRecord
|
|
202
|
+
after_commit :forget_jazari, on: :destroy
|
|
203
|
+
def forget_jazari = Jazari.forget_subject(self)
|
|
204
|
+
end
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
That removes the subject's runbook. **Runs are deliberately preserved** — a run
|
|
208
|
+
records something that actually happened, and deleting the subject does not
|
|
209
|
+
un-happen it.
|
|
210
|
+
|
|
211
|
+
## PostgreSQL only
|
|
212
|
+
|
|
213
|
+
The guarantees lean on Postgres: `jsonb`, `timestamptz`, four CHECK constraints,
|
|
214
|
+
and a partial unique index over a `COALESCE`d polymorphic subject. Supporting a
|
|
215
|
+
second adapter would make those conditional, which weakens the design. Other
|
|
216
|
+
adapters are additive — open an issue if you need one.
|
|
217
|
+
|
|
218
|
+
The test suite runs **the migration the gem ships**, so the schema cannot drift
|
|
219
|
+
out of coverage.
|
|
220
|
+
|
|
221
|
+
## What this is not
|
|
222
|
+
|
|
223
|
+
Not an execution framework. Jazari holds the *state* of a procedure — the canon,
|
|
224
|
+
the overrides, the runs, the evidence. It does not SSH anywhere or run your
|
|
225
|
+
commands.
|
|
226
|
+
|
|
227
|
+
For the execution half, see [braintree/runbook](https://github.com/braintree/runbook):
|
|
228
|
+
a Ruby DSL for running operational procedures, with resumable state and a dry-run
|
|
229
|
+
mode. It has no data model; Jazari has no executor. They compose.
|
|
230
|
+
|
|
231
|
+
## Why "Jazari"
|
|
232
|
+
|
|
233
|
+
Ismail **al-Jazari** (1136–1206), engineer at the Artuqid court in Diyarbakır,
|
|
234
|
+
built programmable automata — a hand-washing machine that offered you a towel,
|
|
235
|
+
a clock driven by a water wheel, pumps with the earliest known crankshafts.
|
|
236
|
+
|
|
237
|
+
That is not why the gem carries his name.
|
|
238
|
+
|
|
239
|
+
He also wrote *The Book of Knowledge of Ingenious Mechanical Devices*, finished
|
|
240
|
+
the year he died: fifty machines, each with numbered construction steps and
|
|
241
|
+
drawings detailed enough that a stranger who had never met him could rebuild the
|
|
242
|
+
device. He wrote down the *procedure*, not just the result — including, in his
|
|
243
|
+
own words, the steps he had gotten wrong first.
|
|
244
|
+
|
|
245
|
+
Eight hundred years later people have built working machines from those pages.
|
|
246
|
+
|
|
247
|
+
That is the whole idea here. A procedure is not lore in someone's head or prose
|
|
248
|
+
in a document nobody opens. It is a written, versioned, checkable artefact that
|
|
249
|
+
somebody else can execute and prove they executed.
|
|
250
|
+
|
|
251
|
+
`runbook` was taken on RubyGems — by the execution framework above, fittingly.
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
MIT.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jazari
|
|
4
|
+
# A stable relational target for a subject that is not an ActiveRecord row:
|
|
5
|
+
# a JSON-tree node, a file path, a DNS zone, a document. The scope is
|
|
6
|
+
# host-registered; the gem ships no default scope naming any real model.
|
|
7
|
+
class Anchor < ApplicationRecord
|
|
8
|
+
self.table_name = "jazari_anchors"
|
|
9
|
+
|
|
10
|
+
has_one :runbook, as: :runbookable, dependent: :destroy
|
|
11
|
+
|
|
12
|
+
validates :scope_type, presence: true
|
|
13
|
+
validates :scope_id, presence: true
|
|
14
|
+
validates :key, presence: true, uniqueness: { scope: %i[scope_type scope_id] }
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jazari
|
|
4
|
+
# Isolated base class. A host may point this at its own connection; the gem
|
|
5
|
+
# never assumes it shares a database with the host's own models, which is why
|
|
6
|
+
# no cross-database foreign key is ever claimed (spec 02, D9).
|
|
7
|
+
class ApplicationRecord < ActiveRecord::Base
|
|
8
|
+
self.abstract_class = true
|
|
9
|
+
end
|
|
10
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jazari
|
|
4
|
+
# The persisted canon. Deliberately named RecipeRecord, not Recipe: `Recipe`
|
|
5
|
+
# is the immutable value the domain passes around, and letting an ActiveRecord
|
|
6
|
+
# object wear that name is how unsaved records leak into return values.
|
|
7
|
+
class RecipeRecord < ApplicationRecord
|
|
8
|
+
self.table_name = "jazari_recipes"
|
|
9
|
+
|
|
10
|
+
POLICIES = Jazari::RunPolicy::ALL
|
|
11
|
+
|
|
12
|
+
validates :recipe_id, presence: true, uniqueness: true
|
|
13
|
+
validates :version, presence: true
|
|
14
|
+
validates :topic, presence: true, length: { maximum: 120 }
|
|
15
|
+
validates :description, length: { maximum: 20_000 }
|
|
16
|
+
validates :run_policy, inclusion: { in: POLICIES }
|
|
17
|
+
|
|
18
|
+
def to_recipe
|
|
19
|
+
Recipe.new(
|
|
20
|
+
id: recipe_id, version: version, topic: topic, description: description,
|
|
21
|
+
run_policy: run_policy,
|
|
22
|
+
checklist: Array(checklist).map do |item|
|
|
23
|
+
{ id: item["id"].to_s, text: item["text"].to_s,
|
|
24
|
+
done: item["done"] == true, required: item.fetch("required", true) == true }
|
|
25
|
+
end
|
|
26
|
+
)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jazari
|
|
4
|
+
# One execution of a ritual. The layer that makes "did last night's run
|
|
5
|
+
# actually complete?" a query instead of a guess.
|
|
6
|
+
#
|
|
7
|
+
# Immutable once closed. Ticks on a run never touch the subject's checklist —
|
|
8
|
+
# that separation is the whole point: `reset` on a runbook must not be able to
|
|
9
|
+
# destroy the record that the ritual ever ran.
|
|
10
|
+
class Run < ApplicationRecord
|
|
11
|
+
self.table_name = "jazari_runs"
|
|
12
|
+
|
|
13
|
+
belongs_to :subject, polymorphic: true, optional: true
|
|
14
|
+
|
|
15
|
+
OUTCOMES = %w[completed abandoned failed].freeze
|
|
16
|
+
|
|
17
|
+
validates :recipe_id, presence: true
|
|
18
|
+
validates :source_digest, presence: true
|
|
19
|
+
validates :actor_ref, presence: true
|
|
20
|
+
validates :started_at, presence: true
|
|
21
|
+
validates :started_on, presence: true
|
|
22
|
+
validates :outcome, inclusion: { in: OUTCOMES }, allow_nil: true
|
|
23
|
+
validates :idempotency_policy, inclusion: { in: RecipeRecord::POLICIES }
|
|
24
|
+
|
|
25
|
+
def open? = finished_at.nil?
|
|
26
|
+
def closed? = !open?
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jazari
|
|
4
|
+
# One subject's override of the canon. Materialized on first customization —
|
|
5
|
+
# never on read.
|
|
6
|
+
class Runbook < ApplicationRecord
|
|
7
|
+
self.table_name = "jazari_runbooks"
|
|
8
|
+
|
|
9
|
+
belongs_to :runbookable, polymorphic: true
|
|
10
|
+
|
|
11
|
+
validates :topic, presence: true, length: { maximum: 120 }
|
|
12
|
+
validates :description, length: { maximum: 20_000 }
|
|
13
|
+
# Recorded so divergence from the canon is queryable rather than invisible.
|
|
14
|
+
validates :recipe_id, presence: true
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "rails/generators"
|
|
4
|
+
require "rails/generators/active_record"
|
|
5
|
+
|
|
6
|
+
module Jazari
|
|
7
|
+
module Generators
|
|
8
|
+
# Hosts adopt these tables DELIBERATELY. The gem never auto-appends its
|
|
9
|
+
# migrations to a host's schema — a shared operations table appearing in
|
|
10
|
+
# someone's next `db:migrate` without them asking for it is exactly the kind
|
|
11
|
+
# of surprise that makes a gem untrustworthy in a production fleet.
|
|
12
|
+
class InstallGenerator < ::Rails::Generators::Base
|
|
13
|
+
include ::ActiveRecord::Generators::Migration
|
|
14
|
+
|
|
15
|
+
source_root File.expand_path("templates", __dir__)
|
|
16
|
+
|
|
17
|
+
desc "Copies the jazari migration into the host. PostgreSQL only."
|
|
18
|
+
|
|
19
|
+
def copy_migration
|
|
20
|
+
migration_template "create_jazari_tables.rb", "db/migrate/create_jazari_tables.rb"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def report
|
|
24
|
+
say ""
|
|
25
|
+
say "jazari: migration copied. Next:"
|
|
26
|
+
say " 1. bin/rails db:migrate"
|
|
27
|
+
say " 2. Seed your own recipes — the gem ships none by design."
|
|
28
|
+
say " 3. Jazari.configure { |c| c.anchor_scopes = { ... } } at boot."
|
|
29
|
+
say ""
|
|
30
|
+
say "PostgreSQL is required: jsonb, timestamptz, CHECK constraints, and a"
|
|
31
|
+
say "partial unique index over a COALESCEd polymorphic subject."
|
|
32
|
+
say ""
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Host-adopted deliberately: jazari never auto-appends its migrations.
|
|
4
|
+
class CreateJazariTables < ActiveRecord::Migration[7.1]
|
|
5
|
+
def change
|
|
6
|
+
create_table :jazari_recipes do |t|
|
|
7
|
+
t.string :recipe_id, null: false
|
|
8
|
+
t.integer :version, null: false, default: 1
|
|
9
|
+
t.string :topic, null: false
|
|
10
|
+
t.text :description, null: false, default: ""
|
|
11
|
+
t.jsonb :checklist, null: false, default: []
|
|
12
|
+
t.string :run_policy, null: false, default: "unrestricted"
|
|
13
|
+
t.timestamps
|
|
14
|
+
t.index :recipe_id, unique: true
|
|
15
|
+
t.check_constraint "run_policy IN ('unrestricted', 'once_per_calendar_day')",
|
|
16
|
+
name: "jazari_recipes_run_policy_chk"
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
create_table :jazari_anchors do |t|
|
|
20
|
+
t.string :scope_type, null: false
|
|
21
|
+
t.bigint :scope_id, null: false
|
|
22
|
+
t.string :key, null: false
|
|
23
|
+
t.timestamps
|
|
24
|
+
t.index %i[scope_type scope_id key], unique: true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
create_table :jazari_runbooks do |t|
|
|
28
|
+
t.string :runbookable_type, null: false
|
|
29
|
+
t.bigint :runbookable_id, null: false
|
|
30
|
+
t.string :recipe_id, null: false
|
|
31
|
+
t.string :topic, null: false
|
|
32
|
+
t.text :description, null: false, default: ""
|
|
33
|
+
t.jsonb :checklist, null: false, default: []
|
|
34
|
+
t.integer :lock_version, null: false, default: 0
|
|
35
|
+
t.timestamps
|
|
36
|
+
t.index %i[runbookable_type runbookable_id], unique: true
|
|
37
|
+
t.index :recipe_id
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
create_table :jazari_runs do |t|
|
|
41
|
+
t.string :recipe_id, null: false
|
|
42
|
+
t.string :source_digest, null: false
|
|
43
|
+
t.string :subject_type
|
|
44
|
+
t.bigint :subject_id
|
|
45
|
+
t.string :actor_ref, null: false
|
|
46
|
+
t.timestamptz :started_at, null: false
|
|
47
|
+
t.date :started_on, null: false
|
|
48
|
+
t.string :idempotency_policy, null: false, default: "unrestricted"
|
|
49
|
+
t.timestamptz :finished_at
|
|
50
|
+
t.string :outcome
|
|
51
|
+
t.jsonb :checklist_snapshot, null: false # no default: every run carries its opening canon
|
|
52
|
+
t.jsonb :ticks, null: false, default: []
|
|
53
|
+
t.jsonb :evidence, null: false, default: []
|
|
54
|
+
t.integer :lock_version, null: false, default: 0
|
|
55
|
+
t.timestamps
|
|
56
|
+
t.index %i[recipe_id started_at]
|
|
57
|
+
t.index %i[subject_type subject_id started_at]
|
|
58
|
+
t.check_constraint "idempotency_policy IN ('unrestricted', 'once_per_calendar_day')",
|
|
59
|
+
name: "jazari_runs_idempotency_policy_chk"
|
|
60
|
+
t.check_constraint "started_on = (started_at AT TIME ZONE 'UTC')::date",
|
|
61
|
+
name: "jazari_runs_started_on_utc_chk"
|
|
62
|
+
t.check_constraint "(subject_type IS NULL AND subject_id IS NULL) OR " \
|
|
63
|
+
"(subject_type IS NOT NULL AND subject_id IS NOT NULL)",
|
|
64
|
+
name: "jazari_runs_subject_pair_chk"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# The idempotency constraint. COALESCE collapses the nullable polymorphic
|
|
68
|
+
# subject so queue runs (both columns NULL) share one uniqueness key —
|
|
69
|
+
# a plain unique index would not constrain them at all, because NULL != NULL.
|
|
70
|
+
execute <<~SQL
|
|
71
|
+
CREATE UNIQUE INDEX jazari_runs_once_per_day_idx
|
|
72
|
+
ON jazari_runs (
|
|
73
|
+
recipe_id,
|
|
74
|
+
COALESCE(subject_type, ''),
|
|
75
|
+
COALESCE(subject_id, 0),
|
|
76
|
+
started_on
|
|
77
|
+
)
|
|
78
|
+
WHERE idempotency_policy = 'once_per_calendar_day';
|
|
79
|
+
SQL
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jazari
|
|
4
|
+
# Anchor resolution, shared by every path that needs a subject for an
|
|
5
|
+
# AnchorTarget.
|
|
6
|
+
#
|
|
7
|
+
# This exists because the read path and the write path had drifted: runs
|
|
8
|
+
# resolved anchors through the host's configured resolver, while customizing
|
|
9
|
+
# a runbook created one directly. A host whose anchor table carries its own
|
|
10
|
+
# NOT NULL columns — because it adopted jazari onto a table it already had —
|
|
11
|
+
# could therefore read but never write. One resolver, used everywhere.
|
|
12
|
+
module Anchors
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# strict: a write path; an unresolvable anchor fails closed.
|
|
16
|
+
# a read path passes false — an anchor that does not exist yet is
|
|
17
|
+
# the normal state of an uncustomized subject, not an error.
|
|
18
|
+
# create: may this call materialise the anchor? Only true when the caller
|
|
19
|
+
# is about to persist a customization.
|
|
20
|
+
def resolve(target, strict: true, create: false)
|
|
21
|
+
scopes = Jazari.config.anchor_scopes
|
|
22
|
+
unless scopes.key?(target.scope_type)
|
|
23
|
+
raise TargetNotFound, "anchor scope #{target.scope_type.inspect} is not registered"
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
resolver = scopes[target.scope_type]
|
|
27
|
+
subject =
|
|
28
|
+
if resolver.respond_to?(:call)
|
|
29
|
+
# The host owns creation as well as lookup — its table may demand
|
|
30
|
+
# columns the gem knows nothing about — so it MUST be told whether
|
|
31
|
+
# creation is permitted. Without `create`, a host resolver would
|
|
32
|
+
# materialise an anchor on every read, breaking the rule that
|
|
33
|
+
# resolving a default writes nothing.
|
|
34
|
+
resolver.arity == 1 ? resolver.call(target) : resolver.call(target, create)
|
|
35
|
+
elsif create
|
|
36
|
+
Anchor.create_or_find_by!(scope_type: target.scope_type,
|
|
37
|
+
scope_id: target.scope_id, key: target.key)
|
|
38
|
+
else
|
|
39
|
+
Anchor.find_by(scope_type: target.scope_type,
|
|
40
|
+
scope_id: target.scope_id, key: target.key)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
return subject if subject.is_a?(ActiveRecord::Base) && subject.persisted?
|
|
44
|
+
return nil unless strict
|
|
45
|
+
|
|
46
|
+
raise TargetNotFound, "anchor #{target.key.inspect} did not resolve to a persisted record"
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
module Jazari
|
|
6
|
+
# Checklist items are validated as a whole document. Item identity is an
|
|
7
|
+
# opaque token, never array position: MCP has to be able to check one item
|
|
8
|
+
# without knowing where it sits.
|
|
9
|
+
module Checklist
|
|
10
|
+
MAX_ITEMS = 50
|
|
11
|
+
MAX_TEXT = 500
|
|
12
|
+
ID_FORMAT = /\A[A-Za-z0-9_-]{1,64}\z/
|
|
13
|
+
|
|
14
|
+
# `required` is part of the schema so a host with per-step gating can carry
|
|
15
|
+
# it through a migration. Widened from the original three keys deliberately
|
|
16
|
+
# (see spec 02) — a legacy three-key item must still validate.
|
|
17
|
+
KEYS = %i[id text done required].freeze
|
|
18
|
+
|
|
19
|
+
module_function
|
|
20
|
+
|
|
21
|
+
def normalize(items)
|
|
22
|
+
list = validate!(items)
|
|
23
|
+
seen = []
|
|
24
|
+
list.map do |item|
|
|
25
|
+
id = item[:id].to_s
|
|
26
|
+
id = generate_id unless id.match?(ID_FORMAT) && !seen.include?(id)
|
|
27
|
+
seen << id
|
|
28
|
+
{ id: id, text: item[:text].to_s, done: item[:done] == true,
|
|
29
|
+
required: item.fetch(:required, true) == true }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def validate!(items)
|
|
34
|
+
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
|
+
|
|
37
|
+
items.map do |item|
|
|
38
|
+
raise InvalidRunbook, "checklist item must be a hash" unless item.is_a?(Hash)
|
|
39
|
+
|
|
40
|
+
entry = item.to_h { |key, value| [ key.to_sym, value ] }
|
|
41
|
+
unknown = entry.keys - KEYS
|
|
42
|
+
raise InvalidRunbook, "unknown checklist keys: #{unknown.join(', ')}" if unknown.any?
|
|
43
|
+
raise InvalidRunbook, "checklist item text is required" if entry[:text].to_s.empty?
|
|
44
|
+
raise InvalidRunbook, "checklist item text exceeds #{MAX_TEXT}" if entry[:text].to_s.length > MAX_TEXT
|
|
45
|
+
|
|
46
|
+
entry
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def freeze_items(items)
|
|
51
|
+
items.map { |item| item.transform_values(&:freeze).freeze }.freeze
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def progress(items)
|
|
55
|
+
done = items.count { |item| item[:done] }
|
|
56
|
+
total = items.length
|
|
57
|
+
{ done: done, total: total, percent: total.zero? ? 0 : (done * 100) / total }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def generate_id = SecureRandom.urlsafe_base64(12)
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Jazari
|
|
4
|
+
# A closed taxonomy. Hosts translate these into their own transport envelope;
|
|
5
|
+
# unauthorized, unknown, deleted, and type-mismatched input must all collapse
|
|
6
|
+
# to TargetNotFound so that guessing a target cannot disclose its existence.
|
|
7
|
+
class Error < StandardError
|
|
8
|
+
def code = self.class.name.split("::").last.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
class TargetNotFound < Error; end
|
|
12
|
+
class InvalidRunbook < Error; end
|
|
13
|
+
class RevisionConflict < Error; end
|
|
14
|
+
class ItemNotFound < Error; end
|
|
15
|
+
class ReadOnlyTarget < Error; end
|
|
16
|
+
class RunClosed < Error; end
|
|
17
|
+
end
|