jazari 0.6.0 → 0.8.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 +73 -0
- data/README.md +31 -9
- data/exe/jazari +96 -0
- data/lib/generators/jazari/install/install_generator.rb +4 -3
- data/lib/generators/jazari/install/templates/create_jazari_tables.rb +25 -8
- data/lib/generators/jazari/upgrade/upgrade_generator.rb +1 -1
- data/lib/jazari/checklist.rb +68 -3
- data/lib/jazari/mcp/actions.rb +105 -14
- data/lib/jazari/mcp/stdio_server.rb +191 -0
- data/lib/jazari/operations.rb +8 -4
- data/lib/jazari/protocol/dispatcher.rb +271 -0
- data/lib/jazari/runs.rb +11 -3
- data/lib/jazari/standalone/database.rb +54 -0
- data/lib/jazari/standalone.rb +36 -0
- data/lib/jazari/version.rb +1 -1
- data/protocol/v1/capability.schema.json +22 -0
- data/protocol/v1/error.schema.json +18 -0
- data/protocol/v1/fixtures/actor-attribution/scenario.json +8 -0
- data/protocol/v1/fixtures/daily-idempotency/scenario.json +8 -0
- data/protocol/v1/fixtures/evidence/scenario.json +8 -0
- data/protocol/v1/fixtures/operator-edit-wins/scenario.json +10 -0
- data/protocol/v1/fixtures/resolve-default/scenario.json +7 -0
- data/protocol/v1/fixtures/revision-conflict/scenario.json +7 -0
- data/protocol/v1/fixtures/run-closed/scenario.json +9 -0
- data/protocol/v1/fixtures/run-snapshot/scenario.json +9 -0
- data/protocol/v1/request.schema.json +215 -0
- data/protocol/v1/response.schema.json +124 -0
- metadata +37 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b1553aebe3407e26d9ef3813203a9836b0ed5d0dec3169ec2bd4d52153e144d4
|
|
4
|
+
data.tar.gz: f5d8638f94e8bcfa9e1f484962ab7e45c0aff74d3bac6e0627cdf528ec15d4ae
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 374ee2a88370020196c89063900b11f1c8e0bf1e945421094087ad3a4c58efa20d68240e262b4120a00685334287ce9c55dc49b0a346a901d7d59ec3f8dc122e
|
|
7
|
+
data.tar.gz: a44421016fbaf7b9ba237bb5a3bc6936ae5d9f8aa5ae08510f6eff9afe65666341d515a149891098aa2872bc153455a211dfffafbd04795e52e47583dfa6b83b
|
data/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,79 @@ 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.8.0] - 2026-09-05
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- An on-demand `jazari` executable backed by SQLite: JSON calls, recipe seeding,
|
|
16
|
+
capability discovery, and MCP stdio with no daemon or network listener.
|
|
17
|
+
- The versioned `jazari/v1` request, response, error, and capability JSON
|
|
18
|
+
Schemas, plus backend-neutral conformance fixtures for revisions, daily
|
|
19
|
+
idempotency, snapshots, attribution, evidence, operator edits, and closed
|
|
20
|
+
runs.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- The shipped migration now preserves its constraints on PostgreSQL while
|
|
25
|
+
emitting equivalent SQLite types and UTC-date enforcement.
|
|
26
|
+
- Run and runbook optimistic-lock races map to `revision_conflict` on both
|
|
27
|
+
adapters.
|
|
28
|
+
- JSON and MCP schemas now close each action independently, so fields accepted
|
|
29
|
+
by a published schema are accepted by the matching runtime action.
|
|
30
|
+
|
|
31
|
+
### Fixed
|
|
32
|
+
|
|
33
|
+
- Standalone schema creation is atomic under concurrent first use, and local
|
|
34
|
+
anchor targets cannot register or overwrite a Rails host's anchor scopes.
|
|
35
|
+
- The dispatcher and MCP stdio server own their load-time dependencies and keep
|
|
36
|
+
internal failures inside the closed `jazari/v1` error envelope.
|
|
37
|
+
|
|
38
|
+
## [0.7.0] - 2026-08-30
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- **The checklist bound is now BYTES, not rows.** `MAX_PAYLOAD = 65_536`,
|
|
43
|
+
measured on the normalized stored form — the string-keyed rows actually
|
|
44
|
+
written to jsonb, never the caller's input and never an MCP envelope. Input
|
|
45
|
+
arrives in four shapes and envelopes differ per host, so counting either
|
|
46
|
+
would make the same document legal on one path and illegal on another.
|
|
47
|
+
|
|
48
|
+
**`MAX_ITEMS` is removed — there is no row cap at all.** The 50 was inherited
|
|
49
|
+
from a host that already had it and was never benchmarked: a measured 50-step
|
|
50
|
+
procedure serializes to ~16.7 KB at 278 characters a step, so the row cap was
|
|
51
|
+
rejecting documents four times smaller than what actually hurts. An interim
|
|
52
|
+
draft kept a cap at 200 as a "guard against absurdity"; that was as unmeasured
|
|
53
|
+
as the 50, and would have rejected a 201-step document sitting well inside the
|
|
54
|
+
byte bound for exactly the old reason. The absurdity case is already answered
|
|
55
|
+
by measurement — a normalized minimal step is 67 bytes, so `MAX_PAYLOAD`
|
|
56
|
+
admits 992 of them and no more. Hosts referencing `Jazari::Checklist::MAX_ITEMS`
|
|
57
|
+
must switch to `MAX_PAYLOAD`.
|
|
58
|
+
|
|
59
|
+
One validator now covers every path — whole-document writes, recipe files,
|
|
60
|
+
and the 0.6.0 late snapshot widening. `add_item` routes through `normalize`
|
|
61
|
+
rather than `validate!`, closing the hole where a document could be walked
|
|
62
|
+
past the ceiling one step at a time.
|
|
63
|
+
|
|
64
|
+
`MAX_TEXT` is unchanged at 500.
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
|
|
68
|
+
- **The published MCP `checklist` schema was an untyped `array`.** A client
|
|
69
|
+
generating from the descriptor could infer `string[]`, send `["step one"]`,
|
|
70
|
+
and be rejected by the domain with "checklist item must be a hash" — the
|
|
71
|
+
schema and the validator disagreed, and only the client found out. It now
|
|
72
|
+
publishes the object shape the validator enforces — `text` required with
|
|
73
|
+
`minLength`/`maxLength`, `id` carrying the real `pattern`, `additionalProperties:
|
|
74
|
+
false` matching the key allowlist — and discloses the aggregate byte bound in
|
|
75
|
+
its description, since JSON Schema cannot express it and an undocumented limit
|
|
76
|
+
is the same failure as an untyped array.
|
|
77
|
+
|
|
78
|
+
The contract test asserts **containment**: nothing the published schema admits
|
|
79
|
+
may be rejected by the domain. `done` is the one deliberate exception in the
|
|
80
|
+
safe direction — the schema publishes `boolean` while the domain silently
|
|
81
|
+
coerces `"yes"` to `false`, so a client is told up front rather than misled
|
|
82
|
+
afterwards.
|
|
83
|
+
|
|
11
84
|
## [0.6.0] - 2026-08-15
|
|
12
85
|
|
|
13
86
|
### Added
|
data/README.md
CHANGED
|
@@ -6,7 +6,8 @@ Recipes as data, per-subject runbooks, stable names for rituals that outlive any
|
|
|
6
6
|
record, and per-run evidence — so *"did last night's run actually complete?"*
|
|
7
7
|
is a query rather than a guess.
|
|
8
8
|
|
|
9
|
-
Requires Ruby 3.2
|
|
9
|
+
Requires Ruby 3.2+. Embed it in Rails 7.1+ with PostgreSQL, or run the
|
|
10
|
+
on-demand standalone CLI with SQLite.
|
|
10
11
|
|
|
11
12
|
---
|
|
12
13
|
|
|
@@ -18,6 +19,12 @@ is the operating manual. Its source lives in [`guide/`](guide/), including
|
|
|
18
19
|
different than you expect), [adoption](guide/02-adoption.md),
|
|
19
20
|
[anchors](guide/03-anchors.md), [runs and evidence](guide/04-runs.md),
|
|
20
21
|
[MCP](guide/05-mcp.md), and [migrating an existing checklist](guide/06-migrating.md).
|
|
22
|
+
[Standalone SQLite, JSON, and MCP stdio](guide/07-standalone.md) covers the
|
|
23
|
+
on-demand executable.
|
|
24
|
+
|
|
25
|
+
The public [`jazari/v1` JSON contract](protocol/v1/) and its conformance fixtures
|
|
26
|
+
are shipped with the gem. Development plans and agent coordination stay out of
|
|
27
|
+
this public repository.
|
|
21
28
|
|
|
22
29
|
## The problem
|
|
23
30
|
|
|
@@ -65,6 +72,20 @@ The generator copies one migration. **Jazari never auto-appends migrations** to
|
|
|
65
72
|
your schema — a shared operations table appearing in someone's next
|
|
66
73
|
`db:migrate` without them asking is how a gem loses trust in a production fleet.
|
|
67
74
|
|
|
75
|
+
For a local standalone database:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
jazari recipes seed config/recipes --database .jazari/jazari.sqlite3
|
|
79
|
+
jazari capabilities --database .jazari/jazari.sqlite3 --json
|
|
80
|
+
jazari call --database .jazari/jazari.sqlite3 --request request.json
|
|
81
|
+
jazari mcp stdio --database .jazari/jazari.sqlite3 --recipes config/recipes
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
The process starts for the command or MCP stdio session and exits with its
|
|
85
|
+
caller. It opens no port, installs no daemon, and stores no credentials. Queue
|
|
86
|
+
and anchor targets are supported; an embedding host still owns authorization
|
|
87
|
+
and record-backed targets.
|
|
88
|
+
|
|
68
89
|
## Thirty seconds
|
|
69
90
|
|
|
70
91
|
```ruby
|
|
@@ -211,7 +232,7 @@ Jazari::Mcp::Handler.new.call(action: "get", target: target)
|
|
|
211
232
|
with its own subject vocabulary and permissions; this handler is the shared
|
|
212
233
|
implementation underneath. Domain failures cross the wire as codes from a closed
|
|
213
234
|
set — `target_not_found`, `invalid_runbook`, `revision_conflict`,
|
|
214
|
-
`item_not_found`, `read_only_target`, `run_closed` — never as messages that
|
|
235
|
+
`item_not_found`, `item_not_in_snapshot`, `read_only_target`, `run_closed` — never as messages that
|
|
215
236
|
could disclose a record or whether a target exists.
|
|
216
237
|
|
|
217
238
|
`Handler.actions_for("read")` returns the read-only subset, so a read-scoped
|
|
@@ -251,15 +272,16 @@ That removes the subject's runbook. **Runs are deliberately preserved** — a ru
|
|
|
251
272
|
records something that actually happened, and deleting the subject does not
|
|
252
273
|
un-happen it.
|
|
253
274
|
|
|
254
|
-
## PostgreSQL
|
|
275
|
+
## PostgreSQL and SQLite
|
|
255
276
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
277
|
+
Rails hosts keep PostgreSQL's `jsonb`, `timestamptz`, CHECK constraints, and
|
|
278
|
+
partial expression index. Standalone mode uses SQLite JSON columns, UTC date
|
|
279
|
+
checks, foreign keys, WAL, a busy timeout, and the same partial expression
|
|
280
|
+
index. Adapter syntax differs; observable revision, idempotency, snapshot,
|
|
281
|
+
attribution, and evidence behavior does not.
|
|
260
282
|
|
|
261
|
-
The test suite runs **the migration the gem ships
|
|
262
|
-
|
|
283
|
+
The test suite runs **the migration the gem ships** against both adapters and
|
|
284
|
+
replays the same JSON fixtures against PostgreSQL and SQLite.
|
|
263
285
|
|
|
264
286
|
## What this is not
|
|
265
287
|
|
data/exe/jazari
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require "json"
|
|
5
|
+
require "optparse"
|
|
6
|
+
require "jazari/standalone"
|
|
7
|
+
|
|
8
|
+
module Jazari
|
|
9
|
+
module Standalone
|
|
10
|
+
class CLI
|
|
11
|
+
def initialize(argv, input: $stdin, output: $stdout, error: $stderr)
|
|
12
|
+
@argv = argv.dup
|
|
13
|
+
@input = input
|
|
14
|
+
@output = output
|
|
15
|
+
@error = error
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def run
|
|
19
|
+
command = @argv.shift
|
|
20
|
+
case command
|
|
21
|
+
when "capabilities" then capabilities
|
|
22
|
+
when "call" then call
|
|
23
|
+
when "recipes" then recipes
|
|
24
|
+
when "mcp" then mcp
|
|
25
|
+
else usage!("unknown command #{command.inspect}")
|
|
26
|
+
end
|
|
27
|
+
rescue OptionParser::ParseError, ArgumentError, Errno::ENOENT, JSON::ParserError => exception
|
|
28
|
+
@error.puts("jazari: #{exception.message}")
|
|
29
|
+
2
|
|
30
|
+
rescue Jazari::Error => exception
|
|
31
|
+
@error.puts("jazari: #{exception.code}: #{exception.message}")
|
|
32
|
+
1
|
|
33
|
+
rescue ActiveRecord::ActiveRecordError, RuntimeError => exception
|
|
34
|
+
@error.puts("jazari: #{exception.message}")
|
|
35
|
+
1
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def capabilities
|
|
41
|
+
options = parse_common!
|
|
42
|
+
@output.puts(JSON.pretty_generate(app(options).capabilities))
|
|
43
|
+
0
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def call
|
|
47
|
+
options = parse_common!({ request: "-" }) do |parser, values|
|
|
48
|
+
parser.on("--request PATH", "JSON request file, or - for stdin") { |value| values[:request] = value }
|
|
49
|
+
end
|
|
50
|
+
body = options[:request] == "-" ? @input.read : File.read(options[:request])
|
|
51
|
+
response = app(options).call(JSON.parse(body))
|
|
52
|
+
@output.puts(JSON.pretty_generate(response))
|
|
53
|
+
response["ok"] ? 0 : 1
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def recipes
|
|
57
|
+
usage!("expected `recipes seed PATH`") unless @argv.shift == "seed"
|
|
58
|
+
options = parse_common!(positionals: 1)
|
|
59
|
+
path = @argv.shift or usage!("recipe file or directory is required")
|
|
60
|
+
app(options)
|
|
61
|
+
records = RecipeRegistry.seed!(RecipeFiles.load(path))
|
|
62
|
+
@output.puts(JSON.pretty_generate(protocol: Protocol::Dispatcher::VERSION, seeded: records.length))
|
|
63
|
+
0
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def mcp
|
|
67
|
+
usage!("expected `mcp stdio`") unless @argv.shift == "stdio"
|
|
68
|
+
options = parse_common!
|
|
69
|
+
Mcp::StdioServer.new(dispatcher: app(options).dispatcher).serve(input: @input, output: @output)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse_common!(defaults = {}, positionals: 0)
|
|
73
|
+
options = { database: ENV["JAZARI_DATABASE"], recipes: [] }.merge(defaults)
|
|
74
|
+
parser = OptionParser.new do |flags|
|
|
75
|
+
flags.on("--database PATH", "SQLite database path") { |value| options[:database] = value }
|
|
76
|
+
flags.on("--recipes PATH", "Seed recipes from a file or directory") { |value| options[:recipes] << value }
|
|
77
|
+
flags.on("--json", "Emit JSON (machine commands already do)") {}
|
|
78
|
+
yield flags, options if block_given?
|
|
79
|
+
end
|
|
80
|
+
parser.parse!(@argv)
|
|
81
|
+
raise ArgumentError, "--database PATH or JAZARI_DATABASE is required" if options[:database].to_s.empty?
|
|
82
|
+
extras = @argv.drop(positionals)
|
|
83
|
+
raise OptionParser::InvalidArgument, extras.join(" ") if extras.any?
|
|
84
|
+
options
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def app(options) = App.new(database: options.fetch(:database), recipes: options.fetch(:recipes))
|
|
88
|
+
|
|
89
|
+
def usage!(message)
|
|
90
|
+
raise ArgumentError, "#{message}\nusage: jazari capabilities|call|recipes seed|mcp stdio --database PATH"
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
exit Jazari::Standalone::CLI.new(ARGV).run
|
|
@@ -14,7 +14,7 @@ module Jazari
|
|
|
14
14
|
|
|
15
15
|
source_root File.expand_path("templates", __dir__)
|
|
16
16
|
|
|
17
|
-
desc "Copies the jazari migration into
|
|
17
|
+
desc "Copies the jazari migration into a PostgreSQL Rails host."
|
|
18
18
|
|
|
19
19
|
def copy_migration
|
|
20
20
|
migration_template "create_jazari_tables.rb", "db/migrate/create_jazari_tables.rb"
|
|
@@ -27,8 +27,9 @@ module Jazari
|
|
|
27
27
|
say " 2. Seed your own recipes — the gem ships none by design."
|
|
28
28
|
say " 3. Jazari.configure { |c| c.anchor_scopes = { ... } } at boot."
|
|
29
29
|
say ""
|
|
30
|
-
say "
|
|
31
|
-
say "partial unique index over a COALESCEd polymorphic subject."
|
|
30
|
+
say "Rails hosts use PostgreSQL: jsonb, timestamptz, CHECK constraints, and a"
|
|
31
|
+
say "partial unique index over a COALESCEd polymorphic subject. The standalone"
|
|
32
|
+
say "executable manages its own SQLite schema instead."
|
|
32
33
|
say ""
|
|
33
34
|
end
|
|
34
35
|
end
|
|
@@ -3,12 +3,18 @@
|
|
|
3
3
|
# Host-adopted deliberately: jazari never auto-appends its migrations.
|
|
4
4
|
class CreateJazariTables < ActiveRecord::Migration[7.1]
|
|
5
5
|
def change
|
|
6
|
+
adapter = connection.adapter_name
|
|
7
|
+
unless %w[PostgreSQL SQLite].include?(adapter)
|
|
8
|
+
raise ActiveRecord::AdapterNotSpecified, "jazari supports PostgreSQL and SQLite, not #{adapter}"
|
|
9
|
+
end
|
|
10
|
+
postgres = adapter == "PostgreSQL"
|
|
11
|
+
|
|
6
12
|
create_table :jazari_recipes do |t|
|
|
7
13
|
t.string :recipe_id, null: false
|
|
8
14
|
t.integer :version, null: false, default: 1
|
|
9
15
|
t.string :topic, null: false
|
|
10
16
|
t.text :description, null: false, default: ""
|
|
11
|
-
t.jsonb
|
|
17
|
+
postgres ? t.jsonb(:checklist, null: false, default: []) : t.json(:checklist, null: false, default: [])
|
|
12
18
|
t.string :run_policy, null: false, default: "unrestricted"
|
|
13
19
|
t.timestamps
|
|
14
20
|
t.index :recipe_id, unique: true
|
|
@@ -30,7 +36,7 @@ class CreateJazariTables < ActiveRecord::Migration[7.1]
|
|
|
30
36
|
t.string :recipe_id, null: false
|
|
31
37
|
t.string :topic, null: false
|
|
32
38
|
t.text :description, null: false, default: ""
|
|
33
|
-
t.jsonb
|
|
39
|
+
postgres ? t.jsonb(:checklist, null: false, default: []) : t.json(:checklist, null: false, default: [])
|
|
34
40
|
# Provenance, host-defined and nullable. A runbook that exists because a
|
|
35
41
|
# backfill created it is a migration artifact; one that exists because an
|
|
36
42
|
# operator edited it is a decision. Both differ from the canon, so
|
|
@@ -49,21 +55,32 @@ class CreateJazariTables < ActiveRecord::Migration[7.1]
|
|
|
49
55
|
t.string :subject_type
|
|
50
56
|
t.bigint :subject_id
|
|
51
57
|
t.string :actor_ref, null: false
|
|
52
|
-
t.timestamptz :started_at, null: false
|
|
58
|
+
postgres ? t.timestamptz(:started_at, null: false) : t.datetime(:started_at, null: false)
|
|
53
59
|
t.date :started_on, null: false
|
|
54
60
|
t.string :idempotency_policy, null: false, default: "unrestricted"
|
|
55
|
-
t.timestamptz :finished_at
|
|
61
|
+
postgres ? t.timestamptz(:finished_at) : t.datetime(:finished_at)
|
|
56
62
|
t.string :outcome
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
63
|
+
if postgres
|
|
64
|
+
t.jsonb :checklist_snapshot, null: false
|
|
65
|
+
t.jsonb :ticks, null: false, default: []
|
|
66
|
+
t.jsonb :evidence, null: false, default: []
|
|
67
|
+
else
|
|
68
|
+
t.json :checklist_snapshot, null: false
|
|
69
|
+
t.json :ticks, null: false, default: []
|
|
70
|
+
t.json :evidence, null: false, default: []
|
|
71
|
+
end
|
|
60
72
|
t.integer :lock_version, null: false, default: 0
|
|
61
73
|
t.timestamps
|
|
62
74
|
t.index %i[recipe_id started_at]
|
|
63
75
|
t.index %i[subject_type subject_id started_at]
|
|
64
76
|
t.check_constraint "idempotency_policy IN ('unrestricted', 'once_per_calendar_day')",
|
|
65
77
|
name: "jazari_runs_idempotency_policy_chk"
|
|
66
|
-
|
|
78
|
+
utc_date_expression = if postgres
|
|
79
|
+
"started_on = (started_at AT TIME ZONE 'UTC')::date"
|
|
80
|
+
else
|
|
81
|
+
"started_on = date(started_at)"
|
|
82
|
+
end
|
|
83
|
+
t.check_constraint utc_date_expression,
|
|
67
84
|
name: "jazari_runs_started_on_utc_chk"
|
|
68
85
|
t.check_constraint "(subject_type IS NULL AND subject_id IS NULL) OR " \
|
|
69
86
|
"(subject_type IS NOT NULL AND subject_id IS NOT NULL)",
|
|
@@ -14,7 +14,7 @@ module Jazari
|
|
|
14
14
|
|
|
15
15
|
source_root File.expand_path("templates", __dir__)
|
|
16
16
|
|
|
17
|
-
desc "Copies
|
|
17
|
+
desc "Copies schema changes into an already-installed PostgreSQL Rails host."
|
|
18
18
|
|
|
19
19
|
def copy_migration
|
|
20
20
|
migration_template "add_jazari_runbook_origin.rb", "db/migrate/add_jazari_runbook_origin.rb"
|
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 (the suite's synthetic equivalent is 16,691, since
|
|
16
|
+
# 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/mcp/actions.rb
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
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
|
+
require "jazari/operations"
|
|
9
|
+
require "jazari/runs"
|
|
10
|
+
|
|
3
11
|
module Jazari
|
|
4
12
|
module Mcp
|
|
5
13
|
# Declarative descriptors for every action, so a host can absorb jazari into
|
|
@@ -19,18 +27,73 @@ module Jazari
|
|
|
19
27
|
# :additive — writes, but cannot destroy prior state
|
|
20
28
|
# :overwrite — may replace an operator's content; annotate cautiously
|
|
21
29
|
# :destructive — removes something; gate it
|
|
30
|
+
REQUIRED_ARGUMENTS = {
|
|
31
|
+
"get" => [], "last_run" => [],
|
|
32
|
+
"set" => %i[expected_revision topic checklist],
|
|
33
|
+
"add_item" => %i[expected_revision text],
|
|
34
|
+
"remove_item" => %i[expected_revision item_id],
|
|
35
|
+
"check_item" => %i[expected_revision item_id done],
|
|
36
|
+
"reset" => %i[expected_revision confirm],
|
|
37
|
+
"start" => %i[actor_ref],
|
|
38
|
+
"tick" => %i[run_id expected_revision item_id done],
|
|
39
|
+
"evidence" => %i[run_id expected_revision kind value],
|
|
40
|
+
"finish" => %i[run_id expected_revision outcome]
|
|
41
|
+
}.freeze
|
|
42
|
+
|
|
22
43
|
Action = Data.define(:name, :scope, :effect, :summary, :params, :confirm) do
|
|
23
44
|
def read? = scope == :read
|
|
24
45
|
def confirm? = confirm == true
|
|
46
|
+
def required = REQUIRED_ARGUMENTS.fetch(name)
|
|
25
47
|
def to_h = { name: name, scope: scope, effect: effect, summary: summary,
|
|
26
48
|
params: params, confirm: confirm }
|
|
27
49
|
end
|
|
28
50
|
|
|
29
51
|
REVISION = { type: "string",
|
|
52
|
+
minLength: 1,
|
|
30
53
|
description: "The revision from the read immediately before this call." }.freeze
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
54
|
+
# JSON Schema patterns are ECMAScript, which has no `\A`/`\z`. Publishing
|
|
55
|
+
# Ruby's source verbatim hands validators an anchor they may reject or,
|
|
56
|
+
# worse, read as a literal — so translate the anchors and keep everything
|
|
57
|
+
# else derived from ID_FORMAT, which stays the single source of truth.
|
|
58
|
+
ID_PATTERN = Checklist::ID_FORMAT.source.sub('\\A', "^").sub('\\z', "$").freeze
|
|
59
|
+
|
|
60
|
+
ITEM_ID = { type: "string", minLength: 1, description: "Opaque checklist item id." }.freeze
|
|
61
|
+
|
|
62
|
+
# An `array` with no `items` is not a contract, it is a guess. A client
|
|
63
|
+
# generating from that descriptor is free to infer `string[]`, send
|
|
64
|
+
# `["step one"]`, and be rejected by the domain with "checklist item must
|
|
65
|
+
# be a hash" — which is what happened. The schema must publish the shape
|
|
66
|
+
# the validator actually enforces, or the descriptor is documentation
|
|
67
|
+
# that disagrees with the code.
|
|
68
|
+
CHECKLIST_ITEM = {
|
|
69
|
+
type: "object",
|
|
70
|
+
required: [ "text" ],
|
|
71
|
+
properties: {
|
|
72
|
+
id: { type: "string", pattern: ID_PATTERN, maxLength: 64,
|
|
73
|
+
description: "Opaque id. Omit to have one generated; send it back to keep a step " \
|
|
74
|
+
"stable across edits. An id that does not match the pattern is REPLACED " \
|
|
75
|
+
"with a generated one rather than rejected." },
|
|
76
|
+
# minLength/maxLength, not just "string": empty text and 501 characters
|
|
77
|
+
# are both rejected by the domain, and a schema that admits them makes
|
|
78
|
+
# the client discover it at call time.
|
|
79
|
+
text: { type: "string", minLength: 1, maxLength: Checklist::MAX_TEXT,
|
|
80
|
+
description: "The step. Required, non-empty." },
|
|
81
|
+
done: { type: "boolean", description: "Default false." },
|
|
82
|
+
required: { type: "boolean", description: "Whether the step gates completion. Default true." }
|
|
83
|
+
},
|
|
84
|
+
additionalProperties: false
|
|
85
|
+
}.freeze
|
|
86
|
+
|
|
87
|
+
# The one domain rule JSON Schema cannot express is the aggregate byte
|
|
88
|
+
# bound, so it is DISCLOSED rather than left for the client to discover by
|
|
89
|
+
# being rejected. An undocumented limit is the same failure as an untyped
|
|
90
|
+
# array: the schema knows something the caller does not.
|
|
91
|
+
CHECKLIST = { type: "array", items: CHECKLIST_ITEM,
|
|
92
|
+
description: "Full checklist; replaces the existing one. There is no row limit, " \
|
|
93
|
+
"but the serialized checklist must be at most " \
|
|
94
|
+
"#{Checklist::MAX_PAYLOAD} bytes." }.freeze
|
|
95
|
+
RUN_ID = { type: "integer", minimum: 1, description: "The run returned by start." }.freeze
|
|
96
|
+
ACTOR = { type: "string", minLength: 1, description: "Opaque identity of who is acting." }.freeze
|
|
34
97
|
|
|
35
98
|
ALL = [
|
|
36
99
|
Action.new(name: "get", scope: :read, effect: :read, confirm: false,
|
|
@@ -42,13 +105,16 @@ module Jazari
|
|
|
42
105
|
Action.new(name: "set", scope: :write, effect: :overwrite, confirm: false,
|
|
43
106
|
summary: "Replace this subject's procedure. Materialises an override on first use.",
|
|
44
107
|
params: { expected_revision: REVISION,
|
|
45
|
-
topic: { type: "string",
|
|
46
|
-
|
|
47
|
-
|
|
108
|
+
topic: { type: "string", minLength: 1, maxLength: Operations::MAX_TOPIC,
|
|
109
|
+
description: "Short title." },
|
|
110
|
+
description: { type: "string", maxLength: Operations::MAX_DESCRIPTION,
|
|
111
|
+
description: "Markdown body." },
|
|
112
|
+
checklist: CHECKLIST }),
|
|
48
113
|
Action.new(name: "add_item", scope: :write, effect: :additive, confirm: false,
|
|
49
114
|
summary: "Append one checklist step.",
|
|
50
115
|
params: { expected_revision: REVISION,
|
|
51
|
-
text: { type: "string",
|
|
116
|
+
text: { type: "string", minLength: 1, maxLength: Checklist::MAX_TEXT,
|
|
117
|
+
description: "The step." },
|
|
52
118
|
required: { type: "boolean", description: "Whether the step is required. Default true." } }),
|
|
53
119
|
Action.new(name: "remove_item", scope: :write, effect: :destructive, confirm: false,
|
|
54
120
|
summary: "Delete one checklist step.",
|
|
@@ -56,30 +122,35 @@ module Jazari
|
|
|
56
122
|
Action.new(name: "check_item", scope: :write, effect: :additive, confirm: false,
|
|
57
123
|
summary: "Mark a step done or not done.",
|
|
58
124
|
params: { expected_revision: REVISION, item_id: ITEM_ID,
|
|
59
|
-
done: { type: "boolean", description: "
|
|
125
|
+
done: { type: "boolean", description: "Whether the step is done." } }),
|
|
60
126
|
Action.new(name: "reset", scope: :write, effect: :destructive, confirm: true,
|
|
61
127
|
summary: "Discard this subject's override and reveal the current canon.",
|
|
62
128
|
params: { expected_revision: REVISION,
|
|
63
|
-
confirm: { type: "boolean",
|
|
129
|
+
confirm: { type: "boolean", const: true,
|
|
130
|
+
description: "Must be true — this discards operator content." } }),
|
|
64
131
|
Action.new(name: "start", scope: :write, effect: :additive, confirm: false,
|
|
65
132
|
summary: "Open a run. Under a once-per-day recipe this returns the existing run instead of erroring.",
|
|
66
133
|
params: { actor_ref: ACTOR }),
|
|
67
134
|
Action.new(name: "tick", scope: :write, effect: :additive, confirm: false,
|
|
68
135
|
summary: "Record a step done within a run. Does not touch the subject's own checklist.",
|
|
69
136
|
params: { run_id: RUN_ID, expected_revision: REVISION, item_id: ITEM_ID,
|
|
70
|
-
done: { type: "boolean", description: "
|
|
137
|
+
done: { type: "boolean", description: "Whether the step is done." },
|
|
71
138
|
actor_ref: ACTOR,
|
|
72
|
-
note: { type: "string",
|
|
139
|
+
note: { type: "string", maxLength: Checklist::MAX_TEXT,
|
|
140
|
+
description: "Optional free text." } }),
|
|
73
141
|
Action.new(name: "evidence", scope: :write, effect: :additive, confirm: false,
|
|
74
142
|
summary: "Attach evidence to a run: output, url, sha, count, or note.",
|
|
75
143
|
params: { run_id: RUN_ID, expected_revision: REVISION, item_id: ITEM_ID,
|
|
76
|
-
kind: { type: "string",
|
|
77
|
-
|
|
144
|
+
kind: { type: "string", enum: Runs::EVIDENCE_KINDS,
|
|
145
|
+
description: "The evidence kind." },
|
|
146
|
+
value: { type: "string", maxLength: Runs::MAX_EVIDENCE,
|
|
147
|
+
description: "The evidence itself." },
|
|
78
148
|
actor_ref: ACTOR }),
|
|
79
149
|
Action.new(name: "finish", scope: :write, effect: :additive, confirm: false,
|
|
80
150
|
summary: "Close a run with an outcome: completed, abandoned, or failed.",
|
|
81
151
|
params: { run_id: RUN_ID, expected_revision: REVISION,
|
|
82
|
-
outcome: { type: "string",
|
|
152
|
+
outcome: { type: "string", enum: %w[completed abandoned failed],
|
|
153
|
+
description: "How the run ended." } })
|
|
83
154
|
].freeze
|
|
84
155
|
|
|
85
156
|
NAMES = ALL.map(&:name).freeze
|
|
@@ -116,6 +187,26 @@ module Jazari
|
|
|
116
187
|
{ enum: actions.map(&:name), properties: properties }
|
|
117
188
|
end
|
|
118
189
|
|
|
190
|
+
# A complete schema for a flat action-enum tool. Each action is its own
|
|
191
|
+
# closed variant, so a field that belongs to `tick` is not accidentally
|
|
192
|
+
# advertised as legal for `get` merely because both share one tool.
|
|
193
|
+
def input_schema(target_schema:, scope: :write)
|
|
194
|
+
{
|
|
195
|
+
type: "object",
|
|
196
|
+
oneOf: all(scope: scope).map do |action|
|
|
197
|
+
{
|
|
198
|
+
type: "object",
|
|
199
|
+
required: [ :action, :target, *action.required ],
|
|
200
|
+
properties: {
|
|
201
|
+
action: { const: action.name },
|
|
202
|
+
target: target_schema
|
|
203
|
+
}.merge(action.params),
|
|
204
|
+
additionalProperties: false
|
|
205
|
+
}
|
|
206
|
+
end
|
|
207
|
+
}
|
|
208
|
+
end
|
|
209
|
+
|
|
119
210
|
# Human-readable action list for a tool description or a paired skill.
|
|
120
211
|
def summaries(scope: :write)
|
|
121
212
|
all(scope: scope).map { |a| "#{a.name} — #{a.summary}" }
|