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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +19 -1
- data/README.md +28 -25
- data/UPGRADING.md +38 -1
- data/app/assets/stylesheets/vitral.css +21 -0
- data/app/views/janela/queries/_query.html.erb +15 -15
- data/docs/agents.md +97 -0
- data/docs/composing.md +14 -14
- data/docs/decisions/053-an-agent-reaches-janela-through-tools-the-host-scopes.md +187 -0
- data/docs/decisions/INDEX.md +7 -6
- data/docs/multi-tenancy.md +22 -4
- data/docs/roadmap.md +27 -13
- data/docs/theming.md +6 -4
- data/lib/janela/doctor.rb +66 -8
- data/lib/janela/tools.rb +220 -0
- data/lib/janela/version.rb +1 -1
- data/lib/janela.rb +1 -0
- metadata +12 -8
data/lib/janela/tools.rb
ADDED
|
@@ -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
|
data/lib/janela/version.rb
CHANGED
data/lib/janela.rb
CHANGED
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.
|
|
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.
|
|
228
|
-
has anything to do.
|
|
230
|
+
Janela 0.13.0: nothing you have to do.
|
|
229
231
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|