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.
@@ -0,0 +1,179 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "time"
5
+
6
+ module Jazari
7
+ # The Run layer.
8
+ #
9
+ # Idempotency is a property of the RECIPE, not a global rule: verifying a
10
+ # backup should happen once a day; triaging an incident may happen five times.
11
+ # Under `once_per_calendar_day` a partial unique index over
12
+ # (recipe_id, COALESCE(subject_type,''), COALESCE(subject_id,0), started_on)
13
+ # enforces it — the COALESCE matters because a bare NULL subject (a queue run)
14
+ # would otherwise be unconstrained, since NULL != NULL in a unique index.
15
+ module Runs
16
+ module_function
17
+
18
+ # Insert FIRST, then rescue the unique violation and select the winner.
19
+ # A find-then-insert races under concurrent writers — the same defect class
20
+ # the revision guard exists to prevent.
21
+ def open(target:, actor_ref:, now: Time.now.utc)
22
+ recipe = RecipeRegistry.fetch(target.recipe_id)
23
+ subject = subject_for(target)
24
+ started_at = now.utc
25
+
26
+ attributes = {
27
+ recipe_id: recipe.id,
28
+ source_digest: recipe.digest,
29
+ subject_type: subject&.class&.name,
30
+ subject_id: subject&.id,
31
+ actor_ref: actor_ref.to_s,
32
+ started_at: started_at,
33
+ # The UTC calendar day. Never server-local: one nightly ritual must not
34
+ # land on two different days depending on which region's box called it.
35
+ started_on: started_at.to_date,
36
+ idempotency_policy: recipe.run_policy,
37
+ # The run is bound to the canon it opened against. Without this, an
38
+ # operator editing a recipe mid-run makes the in-flight run unable to
39
+ # tick its own steps, and able to tick steps that did not exist when it
40
+ # started. source_digest alone is provenance, not protection.
41
+ checklist_snapshot: recipe.checklist.map { |i| i.transform_keys(&:to_s) },
42
+ ticks: [], evidence: []
43
+ }
44
+
45
+ attempts = 0
46
+ begin
47
+ attempts += 1
48
+ run = Run.create!(attributes)
49
+ { run: run, created: true, idempotent_reuse: false }
50
+ rescue ActiveRecord::RecordNotUnique => error
51
+ # Only a once-per-day recipe can collide on the idempotency index. Any
52
+ # other unique violation belongs to a constraint we do not own — a host
53
+ # index, say — and must never be reinterpreted as reuse.
54
+ raise unless recipe.once_per_calendar_day?
55
+
56
+ existing = find_days_run(attributes)
57
+ # The winner can be deleted between our failed INSERT and this SELECT.
58
+ # Retry once: on the second pass the row is gone and the INSERT wins.
59
+ retry if existing.nil? && attempts < 2
60
+
61
+ # No row on the idempotency key means this violation came from some
62
+ # OTHER constraint. Surfacing our own error here would hide the host's
63
+ # real one, so re-raise theirs untouched.
64
+ raise error if existing.nil?
65
+
66
+ { run: existing, created: false, idempotent_reuse: true }
67
+ end
68
+ end
69
+
70
+ def tick(run:, expected_revision:, item_id:, done:, actor_ref:, note: nil, now: Time.now.utc)
71
+ mutate(run, expected_revision) do |record|
72
+ raise RunClosed, "run #{record.id} is already closed" if record.closed?
73
+
74
+ snapshot = stored(record.checklist_snapshot)
75
+ if snapshot.empty?
76
+ # Every run written by this gem carries its opening checklist. An
77
+ # empty one means the row predates the snapshot column, so any
78
+ # migration that adds it must backfill rather than default to [].
79
+ raise InvalidRunbook, "run #{record.id} has no checklist snapshot; backfill required"
80
+ end
81
+
82
+ known = snapshot.map { |item| item["id"] }
83
+ raise ItemNotFound, "unknown checklist item #{item_id}" unless known.include?(item_id.to_s)
84
+
85
+ ticks = stored(record.ticks).reject { |t| t["id"] == item_id.to_s }
86
+ ticks << { "id" => item_id.to_s, "done" => done == true, "at" => now.utc.iso8601,
87
+ "actor_ref" => actor_ref.to_s, "note" => note&.to_s }
88
+ record.ticks = ticks
89
+ end
90
+ end
91
+
92
+ def attach_evidence(run:, expected_revision:, item_id:, kind:, value:, now: Time.now.utc)
93
+ raise InvalidRunbook, "unknown evidence kind #{kind.inspect}" unless EVIDENCE_KINDS.include?(kind.to_s)
94
+
95
+ mutate(run, expected_revision) do |record|
96
+ raise RunClosed, "run #{record.id} is already closed" if record.closed?
97
+
98
+ record.evidence = stored(record.evidence) + [
99
+ { "item_id" => item_id&.to_s, "kind" => kind.to_s,
100
+ "value" => value.to_s[0, MAX_EVIDENCE], "at" => now.utc.iso8601 }
101
+ ]
102
+ end
103
+ end
104
+
105
+ def close(run:, expected_revision:, outcome:, now: Time.now.utc)
106
+ mutate(run, expected_revision) do |record|
107
+ raise RunClosed, "run #{record.id} is already closed" if record.closed?
108
+ raise InvalidRunbook, "unknown outcome #{outcome.inspect}" unless Run::OUTCOMES.include?(outcome.to_s)
109
+
110
+ record.outcome = outcome.to_s
111
+ record.finished_at = now.utc
112
+ end
113
+ end
114
+
115
+ def last(target:)
116
+ recipe_id = target.recipe_id.to_s
117
+ subject = subject_for(target, strict: false)
118
+ # An anchor target whose anchor does not exist yet cannot have runs. It
119
+ # must NOT fall through to the nil-subject branch, which would return
120
+ # queue runs belonging to a different target entirely.
121
+ return nil if target.is_a?(AnchorTarget) && subject.nil?
122
+
123
+ scope = Run.where(recipe_id: recipe_id)
124
+ scope = if subject
125
+ scope.where(subject_type: subject.class.name, subject_id: subject.id)
126
+ else
127
+ scope.where(subject_type: nil, subject_id: nil)
128
+ end
129
+ scope.order(started_at: :desc).first
130
+ end
131
+
132
+ # -- internals ---------------------------------------------------------
133
+
134
+ EVIDENCE_KINDS = %w[output url sha count note].freeze
135
+ MAX_EVIDENCE = 2_000
136
+
137
+ def mutate(run, expected_revision)
138
+ record = run.is_a?(Run) ? run : Run.find_by(id: run)
139
+ raise TargetNotFound, "unknown run" unless record
140
+
141
+ record.with_lock do
142
+ unless record.lock_version.to_s == expected_revision.to_s
143
+ raise RevisionConflict, "expected revision #{expected_revision}, actual #{record.lock_version}"
144
+ end
145
+
146
+ yield record
147
+ record.save!
148
+ end
149
+ record
150
+ end
151
+ private_class_method :mutate
152
+
153
+ def find_days_run(attributes)
154
+ Run.where(
155
+ recipe_id: attributes[:recipe_id],
156
+ subject_type: attributes[:subject_type],
157
+ subject_id: attributes[:subject_id],
158
+ started_on: attributes[:started_on],
159
+ idempotency_policy: attributes[:idempotency_policy]
160
+ ).order(started_at: :desc).first
161
+ end
162
+ private_class_method :find_days_run
163
+
164
+ # strict: true — a write path; an unresolvable anchor fails closed.
165
+ # strict: false — a read path; a not-yet-created anchor is simply absent.
166
+ def subject_for(target, strict: true)
167
+ case target
168
+ when RecordTarget then target.runbookable
169
+ when AnchorTarget then Anchors.resolve(target, strict: strict)
170
+ when QueueTarget then nil
171
+ else raise TargetNotFound, "unsupported target"
172
+ end
173
+ end
174
+ private_class_method :subject_for
175
+
176
+ def stored(value) = Array(value).map { |h| h.to_h.transform_keys(&:to_s) }
177
+ private_class_method :stored
178
+ end
179
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jazari
4
+ # Hosts authorize the actor FIRST, then construct exactly one of these. The
5
+ # domain never accepts a raw id, an arbitrary record, or an actor.
6
+ RecordTarget = Data.define(:runbookable, :public_reference, :recipe_id)
7
+
8
+ # Any subject that is not an ActiveRecord row: a JSON-tree node, a file path,
9
+ # a DNS zone, a document. `scope` is host-registered.
10
+ AnchorTarget = Data.define(:scope_type, :scope_id, :key, :public_reference, :recipe_id)
11
+
12
+ # A stable name for a ritual that outlives any record. Read-only by design:
13
+ # a ritual has exactly one editable home, and that is its recipe.
14
+ QueueTarget = Data.define(:queue, :public_reference, :recipe_id)
15
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Jazari
4
+ VERSION = "0.1.0"
5
+ end
data/lib/jazari.rb ADDED
@@ -0,0 +1,143 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "jazari/version"
4
+ require "jazari/errors"
5
+ require "jazari/checklist"
6
+ require "jazari/recipe"
7
+ require "jazari/targets"
8
+ require "jazari/anchors"
9
+ require "jazari/resolved_runbook"
10
+ require "jazari/recipe_registry"
11
+ require "jazari/runs"
12
+ require "jazari/operations"
13
+ require "jazari/mcp/handler"
14
+ require "jazari/railtie" if defined?(::Rails::Railtie)
15
+
16
+ # Addressable operating procedures.
17
+ #
18
+ # RECIPE the canon - how this ritual is done. Data, not code.
19
+ # RUNBOOK one subject's override of it.
20
+ # QUEUE a stable name for a ritual that outlives any record.
21
+ # RUN one execution - who, when, which ticks, what evidence.
22
+ #
23
+ # This module is the only public mutation interface. It never accepts a raw id,
24
+ # an arbitrary record, or an actor: hosts authorize first, then pass exactly one
25
+ # immutable target value.
26
+ module Jazari
27
+ class << self
28
+ attr_accessor :configuration
29
+ end
30
+
31
+ Configuration = Struct.new(:actor_ref, :on_subject_destroyed, :anchor_scopes,
32
+ :table_prefix, :table_names) do
33
+ def initialize(*)
34
+ super
35
+ self.anchor_scopes ||= {}
36
+ self.table_prefix ||= "jazari_"
37
+ # Per-table overrides. A prefix alone is not enough for a host adopting
38
+ # tables it already has: existing names rarely follow one scheme, and
39
+ # renaming live tables in the same deploy as a cut-over is exactly what
40
+ # the adoption plan forbids. Any key omitted falls back to the prefix.
41
+ self.table_names ||= {}
42
+ self.actor_ref ||= ->(actor) { "actor:#{actor.object_id}" }
43
+ end
44
+
45
+ # Fail at boot, not at first call.
46
+ def validate!
47
+ anchor_scopes.each_key do |scope|
48
+ raise ArgumentError, "anchor scope #{scope.inspect} must be a String" unless scope.is_a?(String)
49
+ end
50
+ true
51
+ end
52
+ end
53
+
54
+ TABLES = { recipes: "recipes", runbooks: "runbooks", anchors: "anchors", runs: "runs" }.freeze
55
+
56
+ def self.configure
57
+ self.configuration ||= Configuration.new
58
+ yield configuration if block_given?
59
+ configuration.validate!
60
+ apply_table_names!
61
+ configuration
62
+ end
63
+
64
+ def self.table_name_for(key)
65
+ TABLES.fetch(key) # raise early on an unknown key
66
+ config.table_names[key]&.to_s || "#{config.table_prefix}#{TABLES.fetch(key)}"
67
+ end
68
+
69
+ # A host may adopt these tables under existing names rather than renaming
70
+ # live tables in the same deploy as the cut-over.
71
+ #
72
+ # CONSTRAINT: ActiveRecord table names are process-global class state, so the
73
+ # prefix is a BOOT-TIME setting for the whole process. It is not per-request,
74
+ # per-thread, or per-tenant, and two hosts in one process cannot hold
75
+ # different prefixes. Call `configure` once, at boot, after the models are
76
+ # loaded; `models_loaded?` reports whether the binding actually took effect
77
+ # so a host can assert it instead of silently running on default names.
78
+ def self.models_loaded? = const_defined?(:RecipeRecord)
79
+
80
+ def self.apply_table_names!
81
+ return false unless models_loaded?
82
+
83
+ { RecipeRecord: :recipes, Runbook: :runbooks, Anchor: :anchors, Run: :runs }.each do |klass, key|
84
+ const_get(klass).table_name = table_name_for(key)
85
+ end
86
+ true
87
+ end
88
+
89
+ def self.config = configuration || configure
90
+
91
+ # The documented public interface (spec 02 section 3). `Runs` is the
92
+ # implementation; these are the names hosts and the MCP handler call.
93
+ class << self
94
+ def open_run(target:, actor_ref:, now: Time.now.utc)
95
+ Runs.open(target: target, actor_ref: actor_ref, now: now)
96
+ end
97
+
98
+ def tick(run:, expected_revision:, item_id:, done:, actor_ref:, note: nil)
99
+ Runs.tick(run: run, expected_revision: expected_revision, item_id: item_id,
100
+ done: done, actor_ref: actor_ref, note: note)
101
+ end
102
+
103
+ def attach_evidence(run:, expected_revision:, item_id:, kind:, value:)
104
+ Runs.attach_evidence(run: run, expected_revision: expected_revision,
105
+ item_id: item_id, kind: kind, value: value)
106
+ end
107
+
108
+ def close_run(run:, expected_revision:, outcome:)
109
+ Runs.close(run: run, expected_revision: expected_revision, outcome: outcome)
110
+ end
111
+
112
+ def last_run(target:) = Runs.last(target: target)
113
+
114
+ def resolve(target:) = Operations.resolve(target: target)
115
+
116
+ def customize(target:, expected_revision:, topic:, description:, checklist:)
117
+ Operations.customize(target: target, expected_revision: expected_revision,
118
+ topic: topic, description: description, checklist: checklist)
119
+ end
120
+
121
+ def add_item(target:, expected_revision:, text:, required: true)
122
+ Operations.add_item(target: target, expected_revision: expected_revision,
123
+ text: text, required: required)
124
+ end
125
+
126
+ def remove_item(target:, expected_revision:, item_id:)
127
+ Operations.remove_item(target: target, expected_revision: expected_revision, item_id: item_id)
128
+ end
129
+
130
+ def check_item(target:, expected_revision:, item_id:, done:)
131
+ Operations.check_item(target: target, expected_revision: expected_revision,
132
+ item_id: item_id, done: done)
133
+ end
134
+
135
+ def reset(target:, expected_revision:)
136
+ Operations.reset(target: target, expected_revision: expected_revision)
137
+ end
138
+
139
+ # Call from the host's after-commit when a subject is destroyed. See
140
+ # Operations.forget_subject — runs survive on purpose.
141
+ def forget_subject(subject) = Operations.forget_subject(subject)
142
+ end
143
+ end
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: jazari
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Nauman Tariq
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: activerecord
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '7.1'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '7.1'
26
+ - !ruby/object:Gem::Dependency
27
+ name: activesupport
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '7.1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '7.1'
40
+ description: A procedure you can call instead of a document you hope someone reads.
41
+ Recipes as data, per-subject overrides, stable queue names for rituals that outlive
42
+ any record, and per-run evidence so "did last night's run complete?" is a query.
43
+ email:
44
+ - 90499+nauman@users.noreply.github.com
45
+ executables: []
46
+ extensions: []
47
+ extra_rdoc_files: []
48
+ files:
49
+ - CHANGELOG.md
50
+ - LICENSE
51
+ - README.md
52
+ - app/models/jazari/anchor.rb
53
+ - app/models/jazari/application_record.rb
54
+ - app/models/jazari/recipe_record.rb
55
+ - app/models/jazari/run.rb
56
+ - app/models/jazari/runbook.rb
57
+ - lib/generators/jazari/install/install_generator.rb
58
+ - lib/generators/jazari/install/templates/create_jazari_tables.rb
59
+ - lib/jazari.rb
60
+ - lib/jazari/anchors.rb
61
+ - lib/jazari/checklist.rb
62
+ - lib/jazari/errors.rb
63
+ - lib/jazari/mcp/handler.rb
64
+ - lib/jazari/operations.rb
65
+ - lib/jazari/railtie.rb
66
+ - lib/jazari/recipe.rb
67
+ - lib/jazari/recipe_registry.rb
68
+ - lib/jazari/resolved_runbook.rb
69
+ - lib/jazari/runs.rb
70
+ - lib/jazari/targets.rb
71
+ - lib/jazari/version.rb
72
+ homepage: https://github.com/nauman/jazari
73
+ licenses:
74
+ - MIT
75
+ metadata:
76
+ homepage_uri: https://github.com/nauman/jazari
77
+ source_code_uri: https://github.com/nauman/jazari
78
+ changelog_uri: https://github.com/nauman/jazari/blob/main/CHANGELOG.md
79
+ rdoc_options: []
80
+ require_paths:
81
+ - lib
82
+ required_ruby_version: !ruby/object:Gem::Requirement
83
+ requirements:
84
+ - - ">="
85
+ - !ruby/object:Gem::Version
86
+ version: '3.2'
87
+ required_rubygems_version: !ruby/object:Gem::Requirement
88
+ requirements:
89
+ - - ">="
90
+ - !ruby/object:Gem::Version
91
+ version: '0'
92
+ requirements: []
93
+ rubygems_version: 3.6.9
94
+ specification_version: 4
95
+ summary: 'Addressable operating procedures: recipes, runbooks, queues, and per-run
96
+ evidence.'
97
+ test_files: []