janela 0.12.0 → 0.13.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,220 @@
1
+ module Janela
2
+ # What an agent can do to a dashboard, as plain Ruby (ADR 053). A host builds
3
+ # one where it knows who is asking, hands it the answer to "what may this
4
+ # caller read", and registers #all with whatever MCP library it runs. Janela
5
+ # registers nothing and serves nothing, and depends on no MCP library.
6
+ #
7
+ # tools = Janela::Tools.new(scope: ->(model) { policy_scope(model) })
8
+ # tools.all # name, description, input_schema, read_only
9
+ # tools.call("read_pane", pane_id: 4) # a Hash, or a Janela::Error
10
+ #
11
+ # Every read passes the host's relation as on:, because Query#result with no
12
+ # on: reads the model's default scope, and the refusal in ADR 032 lives in the
13
+ # controller. Frames and panes are found through the same scope, so what a
14
+ # caller may change is what it may see.
15
+ class Tools
16
+ Tool = Data.define(:name, :description, :input_schema, :read_only)
17
+
18
+ # The attributes of a pane a caller may set: the ones the README names. The
19
+ # frame, the position and the timestamps are not an agent's to write.
20
+ PANE_ATTRIBUTES = %w[kind model measure dimension renderer granularity limit span title height prominence
21
+ companions heading body link partial].freeze
22
+ DIRECTIONS = %w[up down].freeze
23
+ READS = %w[describe_vocabulary list_frames get_frame read_pane].freeze
24
+ WRITES = %w[add_pane update_pane remove_pane move_pane].freeze
25
+
26
+ def initialize(scope: nil, write: false)
27
+ unless scope.respond_to?(:call)
28
+ raise Unscoped, "Janela::Tools was built without a scope. Pass the answer to what this caller may read: " \
29
+ "Janela::Tools.new(scope: ->(model) { policy_scope(model) }). A tool with no scope would " \
30
+ "read every row of every model, which is what ADR 032 refuses."
31
+ end
32
+
33
+ @scope = scope
34
+ @write = write
35
+ end
36
+
37
+ # What there is to register. Needs no scope, because a definition says
38
+ # nothing about whose data it will read; the scope matters when a tool is
39
+ # called, and a host usually learns who is calling only then (ADR 053).
40
+ def self.all(write: false)
41
+ names = write ? READS + WRITES : READS
42
+ names.map { |name| definitions.fetch(name) }
43
+ end
44
+
45
+ def all
46
+ self.class.all(write: @write)
47
+ end
48
+
49
+ # Arguments may arrive with string keys, as they do from a protocol.
50
+ # Anything a tool refuses is a Janela::Error, which an adapter reports to
51
+ # the agent as the tool's own error rather than a crash.
52
+ def call(name, arguments = {})
53
+ raise NotFound, "no tool named #{name}" unless all.any? { |tool| tool.name == name.to_s }
54
+
55
+ __send__(name, **arguments.to_h.symbolize_keys)
56
+ rescue ArgumentError => error
57
+ raise error unless error.message.match?(/unknown keyword|missing keyword/)
58
+
59
+ raise BadRequest, "#{name}: #{error.message}"
60
+ end
61
+
62
+ private
63
+ def describe_vocabulary
64
+ { models: Janela.definitions.map { |definition| model_hash(definition) },
65
+ renderers: Janela.renderers.map(&:to_s),
66
+ granularities: Janela.granularities.map(&:to_s),
67
+ limits: Pane::OFFERED_LIMITS }
68
+ end
69
+
70
+ def list_frames
71
+ { frames: scoped(Frame).includes(:panes).order(:id).map { |frame| frame_hash(frame).merge(pane_count: frame.panes.size) } }
72
+ end
73
+
74
+ def get_frame(frame_id:)
75
+ frame = frame_in_scope(frame_id)
76
+ frame_hash(frame).merge(panes: frame.panes.map { |pane| pane_hash(pane) })
77
+ end
78
+
79
+ def read_pane(pane_id:, filters: {})
80
+ pane = pane_in_scope(pane_id)
81
+ raise BadRequest, "pane #{pane.id} holds #{pane.kind}, not a query, so it has no values" unless pane.query?
82
+
83
+ query = pane.query(filters: filters.to_h.stringify_keys)
84
+ result = query.result(on: scoped(pane.definition.model))
85
+ reading = { pane_id: pane.id, measure: pane.measure, dimension: pane.dimension }
86
+ return reading.merge(value: result&.to_f, formatted: query.format(result)) if query.single_value?
87
+
88
+ reading.merge(values: result.transform_values(&:to_f), formatted: result.transform_values { |value| query.format(value) })
89
+ end
90
+
91
+ def add_pane(frame_id:, **attributes)
92
+ frame = frame_in_scope(frame_id)
93
+ pane = frame.panes.build(pane_attributes(attributes))
94
+ pane.save || refuse(pane)
95
+ pane_hash(pane)
96
+ end
97
+
98
+ def update_pane(pane_id:, **attributes)
99
+ pane = pane_in_scope(pane_id)
100
+ pane.update(pane_attributes(attributes)) || refuse(pane)
101
+ pane_hash(pane)
102
+ end
103
+
104
+ def remove_pane(pane_id:)
105
+ pane = pane_in_scope(pane_id)
106
+ pane.destroy
107
+ pane.frame.resequence_panes!
108
+ { removed: pane.id }
109
+ end
110
+
111
+ def move_pane(pane_id:, direction:)
112
+ raise BadRequest, "direction is #{DIRECTIONS.join(' or ')}, not #{direction.inspect}" unless DIRECTIONS.include?(direction.to_s)
113
+
114
+ pane = pane_in_scope(pane_id)
115
+ direction.to_s == "up" ? pane.move_up : pane.move_down
116
+ pane_hash(pane.reload)
117
+ end
118
+
119
+ # The host's relation for a model, and a refusal if it is not one. A scope
120
+ # that answers with an Array or nil has not scoped anything.
121
+ def scoped(model)
122
+ relation = @scope.call(model)
123
+ return relation if relation.respond_to?(:where)
124
+
125
+ raise Unscoped, "the scope answered #{relation.class} for #{model.name}, not a relation. " \
126
+ "It should return what policy_scope(#{model.name}) returns."
127
+ end
128
+
129
+ def frame_in_scope(id)
130
+ scoped(Frame).find_by(id: id) || raise(NotFound, "no frame #{id}")
131
+ end
132
+
133
+ # One query, with the scope as a subquery: a pane is reachable only
134
+ # through a frame the caller may see.
135
+ def pane_in_scope(id)
136
+ Pane.where(frame_id: scoped(Frame).select(:id)).find_by(id: id) || raise(NotFound, "no pane #{id}")
137
+ end
138
+
139
+ def pane_attributes(attributes)
140
+ unknown = attributes.keys.map(&:to_s) - PANE_ATTRIBUTES
141
+ raise BadRequest, "not a pane attribute: #{unknown.join(', ')}. A pane takes #{PANE_ATTRIBUTES.join(', ')}." if unknown.any?
142
+
143
+ attributes.transform_keys(&:to_s)
144
+ end
145
+
146
+ def refuse(record)
147
+ raise BadRequest, record.errors.full_messages.to_sentence
148
+ end
149
+
150
+ def model_hash(definition)
151
+ { name: definition.model.model_name.route_key,
152
+ measures: definition.measures.keys.map(&:to_s),
153
+ dimensions: definition.dimensions.values.map { |dimension| { name: dimension.name.to_s, time: dimension.time? } } }
154
+ end
155
+
156
+ def frame_hash(frame)
157
+ frame.attributes.slice("id", "name", "columns", "gap", "key", "owner_type", "owner_id", "default_model", "default_where").compact.symbolize_keys
158
+ end
159
+
160
+ def pane_hash(pane)
161
+ pane.attributes.slice("id", "position", "span", *(PANE_ATTRIBUTES - %w[span])).compact.symbolize_keys
162
+ end
163
+
164
+ class << self
165
+ private
166
+ def definitions
167
+ {
168
+ "describe_vocabulary" => tool("describe_vocabulary", read: true,
169
+ description: "The models, measures, dimensions, renderers and granularities that can be asked for. Offer nothing else.",
170
+ properties: {}),
171
+ "list_frames" => tool("list_frames", read: true,
172
+ description: "The frames the caller may see: id, name, owner and how many panes each holds.",
173
+ properties: {}),
174
+ "get_frame" => tool("get_frame", read: true, required: %w[frame_id],
175
+ description: "One frame with its panes in position order.",
176
+ properties: { frame_id: { type: "integer" } }),
177
+ "read_pane" => tool("read_pane", read: true, required: %w[pane_id],
178
+ description: "The values a query pane shows, read through the caller's scope. Filters are Ransack predicates on declared dimensions.",
179
+ properties: { pane_id: { type: "integer" }, filters: { type: "object" } }),
180
+ "add_pane" => tool("add_pane", required: %w[frame_id],
181
+ description: "Add a pane to the end of a frame. A pane Janela would refuse is refused with its own reason.",
182
+ properties: { frame_id: { type: "integer" } }.merge(pane_properties)),
183
+ "update_pane" => tool("update_pane", required: %w[pane_id],
184
+ description: "Change a pane's attributes. An invalid change leaves the pane as it was.",
185
+ properties: { pane_id: { type: "integer" } }.merge(pane_properties)),
186
+ "remove_pane" => tool("remove_pane", required: %w[pane_id],
187
+ description: "Remove a pane and close the gap it leaves.",
188
+ properties: { pane_id: { type: "integer" } }),
189
+ "move_pane" => tool("move_pane", required: %w[pane_id direction],
190
+ description: "Move a pane one place up or down its frame.",
191
+ properties: { pane_id: { type: "integer" }, direction: { type: "string", enum: DIRECTIONS } })
192
+ }
193
+ end
194
+
195
+ def tool(name, description:, properties:, read: false, required: [])
196
+ Tool.new(name: name, description: description, read_only: read,
197
+ input_schema: { type: "object", properties: properties, required: required, additionalProperties: false })
198
+ end
199
+
200
+ def pane_properties
201
+ { kind: { type: "string", enum: Pane::KINDS },
202
+ model: { type: "string", description: "A model's route key, as describe_vocabulary lists it" },
203
+ measure: { type: "string" },
204
+ dimension: { type: "string" },
205
+ renderer: { type: "string", enum: Janela.renderers.map(&:to_s) },
206
+ granularity: { type: "string", enum: Janela.granularities.map(&:to_s) },
207
+ limit: { type: "integer", minimum: Pane::LIMITS.min, maximum: Pane::LIMITS.max },
208
+ span: { type: "integer", minimum: Pane::SPANS.min, maximum: Pane::SPANS.max },
209
+ title: { type: "string" },
210
+ height: { type: "integer", enum: Pane::HEIGHTS.to_a },
211
+ prominence: { type: "integer", enum: Pane::PROMINENCES.to_a },
212
+ companions: { type: "array", items: { type: "string" } },
213
+ heading: { type: "string" },
214
+ body: { type: "string" },
215
+ link: { type: "string", description: "A path on this site, starting with /" },
216
+ partial: { type: "string" } }
217
+ end
218
+ end
219
+ end
220
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Janela
4
- VERSION = "0.12.0"
4
+ VERSION = "0.13.0"
5
5
  end
data/lib/janela.rb CHANGED
@@ -11,6 +11,7 @@ require "janela/measure"
11
11
  require "janela/dimension"
12
12
  require "janela/doctor"
13
13
  require "janela/host_routes"
14
+ require "janela/tools"
14
15
 
15
16
  module Janela
16
17
  class Error < StandardError; end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: janela
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.0
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jay Killeen
@@ -146,6 +146,7 @@ files:
146
146
  - db/migrate/20260930000001_add_height_to_janela_panes.rb
147
147
  - db/migrate/20260930000002_add_prominence_to_janela_panes.rb
148
148
  - db/migrate/20260930000003_add_companions_to_janela_panes.rb
149
+ - docs/agents.md
149
150
  - docs/composing.md
150
151
  - docs/decisions/001-built-to-be-forked.md
151
152
  - docs/decisions/002-measures-and-dimensions-over-ransack.md
@@ -199,6 +200,7 @@ files:
199
200
  - docs/decisions/050-a-single-values-prominence-is-one-of-three-steps.md
200
201
  - docs/decisions/051-a-table-can-carry-companion-columns.md
201
202
  - docs/decisions/052-how-the-project-is-run.md
203
+ - docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md
202
204
  - docs/decisions/INDEX.md
203
205
  - docs/multi-tenancy.md
204
206
  - docs/naming.md
@@ -212,6 +214,7 @@ files:
212
214
  - lib/janela/host_routes.rb
213
215
  - lib/janela/measure.rb
214
216
  - lib/janela/model.rb
217
+ - lib/janela/tools.rb
215
218
  - lib/janela/version.rb
216
219
  - lib/tasks/janela.rake
217
220
  homepage: https://github.com/retail-tasker/janela
@@ -224,14 +227,15 @@ metadata:
224
227
  bug_tracker_uri: https://github.com/retail-tasker/janela/issues
225
228
  rubygems_mfa_required: 'true'
226
229
  post_install_message: |
227
- Janela 0.12.0: a migration if you use stored frames. Nobody else
228
- has anything to do.
230
+ Janela 0.13.0: nothing you have to do.
229
231
 
230
- A pane can now carry a chart height, a single value a prominence
231
- and a table companion columns (ADR 047, 050, 051):
232
- bin/rails janela:install:migrations && bin/rails db:migrate
233
- janela_panes gains three nullable columns, nil on every existing
234
- pane. Nothing changes unless you set them.
232
+ New, and only if you want it: Janela::Tools gives an agent the
233
+ tools to read and arrange a dashboard, scoped by your own
234
+ policy_scope (ADR 053). Nothing registers itself. See docs/agents.md.
235
+
236
+ Two things you may see: the doctor now warns, rather than errors,
237
+ on Janela::PanesController, and a copy of
238
+ janela/queries/_query.html.erb may need checking on Rails 8.2.
235
239
 
236
240
  Steps: UPGRADING.md in this gem, or
237
241
  https://github.com/retail-tasker/janela/blob/main/UPGRADING.md