janela 0.12.0 → 0.14.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,221 @@
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
+ value_labels 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
+ value_labels: { type: "boolean", description: "Draw each bar's value on a bar chart; other renderers ignore it" },
213
+ companions: { type: "array", items: { type: "string" } },
214
+ heading: { type: "string" },
215
+ body: { type: "string" },
216
+ link: { type: "string", description: "A path on this site, starting with /" },
217
+ partial: { type: "string" } }
218
+ end
219
+ end
220
+ end
221
+ 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.14.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
@@ -165,4 +166,19 @@ module Janela
165
166
  def self.offered_limits
166
167
  Pane::OFFERED_LIMITS
167
168
  end
169
+
170
+ # The steps a pane's height and prominence take, and how many companion
171
+ # columns a table may carry, for a page that offers them as controls
172
+ # (ADR 027, ADR 047, ADR 050, ADR 051).
173
+ def self.heights
174
+ Pane::HEIGHTS.to_a
175
+ end
176
+
177
+ def self.prominences
178
+ Pane::PROMINENCES.to_a
179
+ end
180
+
181
+ def self.max_companions
182
+ Query::MAX_COMPANIONS
183
+ end
168
184
  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.14.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jay Killeen
@@ -146,6 +146,8 @@ 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
+ - db/migrate/20261006000001_add_value_labels_to_janela_panes.rb
150
+ - docs/agents.md
149
151
  - docs/composing.md
150
152
  - docs/decisions/001-built-to-be-forked.md
151
153
  - docs/decisions/002-measures-and-dimensions-over-ransack.md
@@ -199,6 +201,8 @@ files:
199
201
  - docs/decisions/050-a-single-values-prominence-is-one-of-three-steps.md
200
202
  - docs/decisions/051-a-table-can-carry-companion-columns.md
201
203
  - docs/decisions/052-how-the-project-is-run.md
204
+ - docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md
205
+ - docs/decisions/054-a-chart-reads-numbers-the-way-its-measure-formats-them.md
202
206
  - docs/decisions/INDEX.md
203
207
  - docs/multi-tenancy.md
204
208
  - docs/naming.md
@@ -212,6 +216,7 @@ files:
212
216
  - lib/janela/host_routes.rb
213
217
  - lib/janela/measure.rb
214
218
  - lib/janela/model.rb
219
+ - lib/janela/tools.rb
215
220
  - lib/janela/version.rb
216
221
  - lib/tasks/janela.rake
217
222
  homepage: https://github.com/retail-tasker/janela
@@ -224,14 +229,17 @@ metadata:
224
229
  bug_tracker_uri: https://github.com/retail-tasker/janela/issues
225
230
  rubygems_mfa_required: 'true'
226
231
  post_install_message: |
227
- Janela 0.12.0: a migration if you use stored frames. Nobody else
228
- has anything to do.
232
+ Janela 0.14.0: one migration, if you use stored frames.
229
233
 
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.
234
+ A pane can now draw each bar's value on a bar chart (ADR 054), which
235
+ adds a nullable value_labels column to janela_panes. Run
236
+ bin/rails janela:install:migrations and then bin/rails db:migrate. If
237
+ you never use stored frames there is nothing to do.
238
+
239
+ Also: a chart's axis now reads in the measure's own format, so a
240
+ currency chart gains its prefix and a ratio reads as percentages. A
241
+ pane Janela will not draw is now that pane's own sentence and no
242
+ longer an error for the whole host page.
235
243
 
236
244
  Steps: UPGRADING.md in this gem, or
237
245
  https://github.com/retail-tasker/janela/blob/main/UPGRADING.md