@butlr/butlr-mcp-server 0.1.2 → 0.4.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.
- package/README.md +27 -1
- package/dist/cache/topology-cache.d.ts +18 -3
- package/dist/cache/topology-cache.d.ts.map +1 -1
- package/dist/cache/topology-cache.js +19 -3
- package/dist/cache/topology-cache.js.map +1 -1
- package/dist/clients/queries/tags.d.ts +96 -0
- package/dist/clients/queries/tags.d.ts.map +1 -0
- package/dist/clients/queries/tags.js +64 -0
- package/dist/clients/queries/tags.js.map +1 -0
- package/dist/clients/types.d.ts +6 -2
- package/dist/clients/types.d.ts.map +1 -1
- package/dist/errors/mcp-errors.d.ts +34 -0
- package/dist/errors/mcp-errors.d.ts.map +1 -1
- package/dist/errors/mcp-errors.js +71 -5
- package/dist/errors/mcp-errors.js.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/tools/butlr-available-rooms.d.ts +10 -4
- package/dist/tools/butlr-available-rooms.d.ts.map +1 -1
- package/dist/tools/butlr-available-rooms.js +177 -45
- package/dist/tools/butlr-available-rooms.js.map +1 -1
- package/dist/tools/butlr-fetch-entity-details.d.ts.map +1 -1
- package/dist/tools/butlr-fetch-entity-details.js +7 -1
- package/dist/tools/butlr-fetch-entity-details.js.map +1 -1
- package/dist/tools/butlr-get-asset-details.d.ts.map +1 -1
- package/dist/tools/butlr-get-asset-details.js +29 -4
- package/dist/tools/butlr-get-asset-details.js.map +1 -1
- package/dist/tools/butlr-get-current-occupancy.d.ts.map +1 -1
- package/dist/tools/butlr-get-current-occupancy.js +27 -6
- package/dist/tools/butlr-get-current-occupancy.js.map +1 -1
- package/dist/tools/butlr-get-occupancy-timeseries.d.ts.map +1 -1
- package/dist/tools/butlr-get-occupancy-timeseries.js +32 -11
- package/dist/tools/butlr-get-occupancy-timeseries.js.map +1 -1
- package/dist/tools/butlr-list-tags.d.ts +50 -0
- package/dist/tools/butlr-list-tags.d.ts.map +1 -0
- package/dist/tools/butlr-list-tags.js +165 -0
- package/dist/tools/butlr-list-tags.js.map +1 -0
- package/dist/tools/butlr-list-topology.d.ts +7 -1
- package/dist/tools/butlr-list-topology.d.ts.map +1 -1
- package/dist/tools/butlr-list-topology.js +845 -35
- package/dist/tools/butlr-list-topology.js.map +1 -1
- package/dist/tools/butlr-search-assets.d.ts.map +1 -1
- package/dist/tools/butlr-search-assets.js +7 -1
- package/dist/tools/butlr-search-assets.js.map +1 -1
- package/dist/tools/butlr-space-busyness.d.ts.map +1 -1
- package/dist/tools/butlr-space-busyness.js +30 -5
- package/dist/tools/butlr-space-busyness.js.map +1 -1
- package/dist/tools/butlr-traffic-flow.d.ts.map +1 -1
- package/dist/tools/butlr-traffic-flow.js +14 -9
- package/dist/tools/butlr-traffic-flow.js.map +1 -1
- package/dist/types/responses.d.ts +126 -4
- package/dist/types/responses.d.ts.map +1 -1
- package/dist/utils/asset-flattener.js +2 -2
- package/dist/utils/asset-flattener.js.map +1 -1
- package/dist/utils/field-validator.d.ts.map +1 -1
- package/dist/utils/field-validator.js +3 -0
- package/dist/utils/field-validator.js.map +1 -1
- package/dist/utils/graphql-helpers.d.ts +13 -0
- package/dist/utils/graphql-helpers.d.ts.map +1 -1
- package/dist/utils/graphql-helpers.js +25 -0
- package/dist/utils/graphql-helpers.js.map +1 -1
- package/dist/utils/occupancy-helpers.d.ts +2 -0
- package/dist/utils/occupancy-helpers.d.ts.map +1 -1
- package/dist/utils/occupancy-helpers.js +26 -9
- package/dist/utils/occupancy-helpers.js.map +1 -1
- package/dist/utils/tag-resolver.d.ts +99 -0
- package/dist/utils/tag-resolver.d.ts.map +1 -0
- package/dist/utils/tag-resolver.js +108 -0
- package/dist/utils/tag-resolver.js.map +1 -0
- package/dist/utils/timezone-helpers.d.ts +8 -3
- package/dist/utils/timezone-helpers.d.ts.map +1 -1
- package/dist/utils/timezone-helpers.js +22 -14
- package/dist/utils/timezone-helpers.js.map +1 -1
- package/package.json +1 -1
|
@@ -1,48 +1,458 @@
|
|
|
1
1
|
import { apolloClient } from "../clients/graphql-client.js";
|
|
2
2
|
import { GET_FULL_TOPOLOGY, GET_ALL_SENSORS, GET_ALL_HIVES } from "../clients/queries/topology.js";
|
|
3
|
+
import { GET_TAGS_WITH_USAGE, asTagName, } from "../clients/queries/tags.js";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { getCachedTopology, setCachedTopology, generateTopologyCacheKey, } from "../cache/topology-cache.js";
|
|
5
6
|
import { formatTopologyTree } from "../utils/tree-formatter.js";
|
|
6
|
-
import { isProductionSensor, isProductionHive, rethrowIfGraphQLError, } from "../utils/graphql-helpers.js";
|
|
7
|
+
import { isProductionSensor, isProductionHive, rethrowIfGraphQLError, throwIfGraphQLErrors, } from "../utils/graphql-helpers.js";
|
|
8
|
+
import { resolveTagNames, projectValidRefs } from "../utils/tag-resolver.js";
|
|
7
9
|
import { debug } from "../utils/debug.js";
|
|
8
|
-
import { withToolErrorHandling } from "../errors/mcp-errors.js";
|
|
10
|
+
import { withToolErrorHandling, throwInternalError } from "../errors/mcp-errors.js";
|
|
9
11
|
const LIST_TOPOLOGY_DESCRIPTION = "Display org hierarchy tree with flexible depth control. Can show full tree, specific subtrees, or flat lists. " +
|
|
10
|
-
"Supports filtering by parent asset IDs. Depth levels: 0=sites, 1=buildings, 2=floors, 3=rooms/zones, 4=hives, 5=sensors. " +
|
|
12
|
+
"Supports filtering by parent asset IDs and by tag names. Depth levels: 0=sites, 1=buildings, 2=floors, 3=rooms/zones, 4=hives, 5=sensors. " +
|
|
11
13
|
"Use starting_depth to choose which level to show, and traversal_depth to control how many levels below to include.\n\n" +
|
|
14
|
+
"Tag filter:\n" +
|
|
15
|
+
"- Pass tag_names (case-insensitive) to scope the tree to subtrees containing rooms, zones, or floors with those tags. Combines AND-style with asset_ids when both are supplied.\n" +
|
|
16
|
+
"- tag_match defaults to 'any' (entity is in any tagged subtree). 'all' intersects per-tag SUBTREES — e.g. a tag on Floor 1 AND a tag on a room inside Floor 1 yields the room (it's in both subtrees), not the empty set. Note this 'any' default differs from butlr_available_rooms, which defaults to 'all' because it filters a single entity type.\n" +
|
|
17
|
+
"- Use butlr_list_tags to discover what tag vocabulary exists in this org.\n\n" +
|
|
18
|
+
"Diagnostics:\n" +
|
|
19
|
+
"- The response includes a `warning` field when a filter input doesn't fully resolve — typo'd asset_ids, unknown tag_names, asset_ids and tag_names scoping disjoint subtrees, or tag associations pointing at deleted entities. Read it before retrying.\n" +
|
|
20
|
+
"- `unknown_tags` lists any tag names that didn't resolve, for the caller to surface or correct.\n\n" +
|
|
12
21
|
"When NOT to Use:\n" +
|
|
13
22
|
"- Searching for assets by name or keyword → use butlr_search_assets for fuzzy name-based lookups\n" +
|
|
14
23
|
"- Need detailed info for a specific asset you already have an ID for → use butlr_get_asset_details instead\n" +
|
|
15
|
-
"- Need only specific fields for known entity IDs → use butlr_fetch_entity_details for selective field fetching"
|
|
24
|
+
"- Need only specific fields for known entity IDs → use butlr_fetch_entity_details for selective field fetching\n" +
|
|
25
|
+
"- Want every tagged entity (not the surrounding hierarchy) → use butlr_list_tags with include_entities=true";
|
|
16
26
|
/** Shared shape — used by both registerTool (SDK schema) and full validation */
|
|
17
27
|
const listTopologyInputShape = {
|
|
18
28
|
asset_ids: z
|
|
19
|
-
.array(z.string())
|
|
29
|
+
.array(z.string().trim().min(1, "asset_ids entries cannot be empty"))
|
|
20
30
|
.optional()
|
|
21
31
|
.describe("Optional: Parent asset IDs to show tree for. If empty, shows all sites. " +
|
|
22
32
|
"Examples: ['site_123'], ['building_456'], ['floor_789']"),
|
|
23
33
|
starting_depth: z
|
|
24
34
|
.number()
|
|
35
|
+
.int("starting_depth must be an integer")
|
|
36
|
+
.min(0, "starting_depth must be >= 0 (0=sites)")
|
|
37
|
+
.max(5, "starting_depth must be <= 5 (5=sensors)")
|
|
25
38
|
.default(0)
|
|
26
39
|
.describe("Depth level to start showing assets. 0=sites, 1=buildings, 2=floors, 3=rooms/zones, 4=hives, 5=sensors. " +
|
|
27
40
|
"Use with traversal_depth=0 to show only assets at this level (flat list)."),
|
|
28
41
|
traversal_depth: z
|
|
29
42
|
.number()
|
|
43
|
+
.int("traversal_depth must be an integer")
|
|
44
|
+
.min(0, "traversal_depth must be >= 0")
|
|
45
|
+
.max(10, "traversal_depth must be <= 10")
|
|
30
46
|
.default(0)
|
|
31
47
|
.describe("How many levels below starting_depth to traverse. 0=starting level only, 1=one level below, etc. " +
|
|
32
48
|
"Default is 0 to minimize token usage. Use 10 for full tree."),
|
|
49
|
+
tag_names: z
|
|
50
|
+
.array(z.string().trim().min(1, "Tag cannot be empty"))
|
|
51
|
+
.min(1, "tag_names array cannot be empty")
|
|
52
|
+
.optional()
|
|
53
|
+
.describe("Filter tree to subtrees containing rooms, zones, or floors with these tag names (case-insensitive). " +
|
|
54
|
+
"Combines AND-style with asset_ids. Use butlr_list_tags to discover available tag names."),
|
|
55
|
+
tag_match: z
|
|
56
|
+
.enum(["all", "any"])
|
|
57
|
+
.default("any")
|
|
58
|
+
.describe("Multi-tag semantics when tag_names has more than one entry: 'any' (default) keeps entities whose subtree is covered by at least one of the named tags. 'all' keeps entities whose subtree is covered by EVERY named tag — e.g. a floor-tag combined with a room-tag on a room inside that floor yields the room (it's in both subtrees), not the empty intersection. Defaults to 'any' here, unlike butlr_available_rooms which defaults to 'all' because it filters a single entity type."),
|
|
33
59
|
};
|
|
34
60
|
export const ListTopologyArgsSchema = z.object(listTopologyInputShape).strict();
|
|
61
|
+
/**
|
|
62
|
+
* Union of every directly-tagged entity ID across the resolved tag rows,
|
|
63
|
+
* regardless of `match`. Used for:
|
|
64
|
+
* 1. Early "tag_no_associations" empty check (before topology fetch)
|
|
65
|
+
* 2. Ghost-association detection (count of direct IDs absent from topology)
|
|
66
|
+
*
|
|
67
|
+
* `match`-aware filtering — including the closure-of-each-tag intersection
|
|
68
|
+
* needed for `match='all'` across hierarchically-related tags (e.g. a tag
|
|
69
|
+
* on a floor and a tag on a room inside that floor) — happens later in
|
|
70
|
+
* `collectMatchAwareClosure`, which requires the topology to already be
|
|
71
|
+
* fetched.
|
|
72
|
+
*/
|
|
73
|
+
function collectDirectTaggedIds(resolvedRows) {
|
|
74
|
+
const matched = new Set();
|
|
75
|
+
// `projectValidRefs` drops refs whose `id` is null/undefined (stale
|
|
76
|
+
// tag→entity associations after a hard delete, or partial GraphQL
|
|
77
|
+
// responses) so they can't pollute the matched-id Set. Sharing the
|
|
78
|
+
// helper with `butlr_list_tags` keeps the validity predicate in one
|
|
79
|
+
// place — counts and entity arrays produced from the same filtered
|
|
80
|
+
// list cannot disagree across tools.
|
|
81
|
+
for (const row of resolvedRows) {
|
|
82
|
+
for (const r of projectValidRefs(row.rooms))
|
|
83
|
+
matched.add(r.id);
|
|
84
|
+
for (const z of projectValidRefs(row.zones))
|
|
85
|
+
matched.add(z.id);
|
|
86
|
+
for (const f of projectValidRefs(row.floors))
|
|
87
|
+
matched.add(f.id);
|
|
88
|
+
}
|
|
89
|
+
return matched;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Compute the match-aware closure of the resolved tag rows
|
|
93
|
+
* against the active topology. Each tag's directly-tagged entity IDs are
|
|
94
|
+
* expanded to their full subtree closure (rooms→sensors-via-room_id,
|
|
95
|
+
* floors→all descendants, etc.) BEFORE applying the match operator. This
|
|
96
|
+
* makes `tag_match='all'` work across hierarchical levels — a tag on
|
|
97
|
+
* Floor 1 and a tag on Room A inside Floor 1 now intersect to
|
|
98
|
+
* {Room A + its descendants}, not the empty set the per-type literal
|
|
99
|
+
* intersection used to produce.
|
|
100
|
+
*
|
|
101
|
+
* Why split from `collectDirectTaggedIds`: the direct IDs are needed
|
|
102
|
+
* before the topology is fetched (to short-circuit on no-associations
|
|
103
|
+
* and to drive ghost detection); the closure-aware set requires sites.
|
|
104
|
+
* Splitting also keeps the ghost-count stable — closure entities are
|
|
105
|
+
* by construction in the topology walk, so basing ghosts on closure
|
|
106
|
+
* would always report zero.
|
|
107
|
+
*/
|
|
108
|
+
function collectMatchAwareClosure(sites, resolvedRows, match) {
|
|
109
|
+
if (resolvedRows.length === 0)
|
|
110
|
+
return new Set();
|
|
111
|
+
const perTagClosures = resolvedRows.map((row) => {
|
|
112
|
+
const rootIds = [];
|
|
113
|
+
for (const r of projectValidRefs(row.rooms))
|
|
114
|
+
rootIds.push(r.id);
|
|
115
|
+
for (const z of projectValidRefs(row.zones))
|
|
116
|
+
rootIds.push(z.id);
|
|
117
|
+
for (const f of projectValidRefs(row.floors))
|
|
118
|
+
rootIds.push(f.id);
|
|
119
|
+
return expandToSubtreeClosure(sites, rootIds);
|
|
120
|
+
});
|
|
121
|
+
if (match === "all") {
|
|
122
|
+
// Build a fresh Set per fold step rather than mutating `acc` mid-
|
|
123
|
+
// iteration. JS allows Set.delete during iteration and the previous
|
|
124
|
+
// implementation was correct, but the immutable form is more obvious
|
|
125
|
+
// to a future reader and removes a fragile language-spec dependency.
|
|
126
|
+
let acc = new Set(perTagClosures[0]);
|
|
127
|
+
for (let i = 1; i < perTagClosures.length; i++) {
|
|
128
|
+
const next = perTagClosures[i];
|
|
129
|
+
const intersected = new Set();
|
|
130
|
+
for (const id of acc)
|
|
131
|
+
if (next.has(id))
|
|
132
|
+
intersected.add(id);
|
|
133
|
+
acc = intersected;
|
|
134
|
+
}
|
|
135
|
+
return acc;
|
|
136
|
+
}
|
|
137
|
+
const acc = new Set();
|
|
138
|
+
for (const c of perTagClosures)
|
|
139
|
+
for (const id of c)
|
|
140
|
+
acc.add(id);
|
|
141
|
+
return acc;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Walk the topology and collect every entity ID that is descendant of (or
|
|
145
|
+
* equal to) the supplied `rootIds`. Used to compose `asset_ids` with
|
|
146
|
+
* `tag_names` as a true subtree-overlap AND.
|
|
147
|
+
*
|
|
148
|
+
* The closure is applied to BOTH asset_ids AND tagged-entity IDs because a
|
|
149
|
+
* tag can sit on an ancestor of an asset (e.g. tag on Floor 1,
|
|
150
|
+
* `asset_ids=[room_001]`) — the room is then inside the tagged subtree even
|
|
151
|
+
* though their raw IDs don't intersect. A literal-id intersection would
|
|
152
|
+
* silently miss this overlap; closure-vs-closure intersection catches it.
|
|
153
|
+
*
|
|
154
|
+
* Coverage by entity type:
|
|
155
|
+
* site → site + every building/floor/room/zone/hive/sensor under it
|
|
156
|
+
* building → building + every floor/room/zone/hive/sensor under it
|
|
157
|
+
* floor → floor + its rooms, zones, hives, sensors
|
|
158
|
+
* room → room + room_id-bound zones, sensors, hives — and
|
|
159
|
+
* transitively, every sensor whose `hive_serial` matches a
|
|
160
|
+
* room-bound hive's `serialNumber` (devices live in floor
|
|
161
|
+
* arrays but logically belong to their room when bound)
|
|
162
|
+
* zone → zone alone (zones are leaves under rooms in the
|
|
163
|
+
* topology and don't own descendants in our schema)
|
|
164
|
+
* hive → hive + sensors with matching `hive_serial`
|
|
165
|
+
* sensor → sensor alone
|
|
166
|
+
*/
|
|
167
|
+
function expandToSubtreeClosure(sites, rootIds) {
|
|
168
|
+
const target = new Set(rootIds);
|
|
169
|
+
const closure = new Set();
|
|
170
|
+
const addFloor = (floor) => {
|
|
171
|
+
closure.add(floor.id);
|
|
172
|
+
for (const room of floor.rooms ?? [])
|
|
173
|
+
closure.add(room.id);
|
|
174
|
+
for (const zone of floor.zones ?? [])
|
|
175
|
+
closure.add(zone.id);
|
|
176
|
+
for (const hive of floor.hives ?? [])
|
|
177
|
+
closure.add(hive.id);
|
|
178
|
+
for (const sensor of floor.sensors ?? [])
|
|
179
|
+
closure.add(sensor.id);
|
|
180
|
+
};
|
|
181
|
+
const addBuilding = (building) => {
|
|
182
|
+
closure.add(building.id);
|
|
183
|
+
for (const floor of building.floors ?? [])
|
|
184
|
+
addFloor(floor);
|
|
185
|
+
};
|
|
186
|
+
for (const site of sites) {
|
|
187
|
+
if (target.has(site.id)) {
|
|
188
|
+
closure.add(site.id);
|
|
189
|
+
for (const building of site.buildings ?? [])
|
|
190
|
+
addBuilding(building);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
for (const building of site.buildings ?? []) {
|
|
194
|
+
if (target.has(building.id)) {
|
|
195
|
+
addBuilding(building);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
for (const floor of building.floors ?? []) {
|
|
199
|
+
if (target.has(floor.id)) {
|
|
200
|
+
addFloor(floor);
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
// Floor-level leaf scan: rooms, zones, hives, sensors. A targeted
|
|
204
|
+
// room also pulls in its room_id-bound zones, sensors, and hives —
|
|
205
|
+
// a tag-on-room implicitly applies to children of that room, and
|
|
206
|
+
// the formatter renders those entities under the room. Both
|
|
207
|
+
// snake_case (`room_id`) and camelCase (`roomID`) link fields are
|
|
208
|
+
// checked because the upstream API and cached payloads can carry
|
|
209
|
+
// either shape (mirrors `formatRoom` in src/utils/tree-formatter.ts).
|
|
210
|
+
for (const room of floor.rooms ?? []) {
|
|
211
|
+
if (!target.has(room.id))
|
|
212
|
+
continue;
|
|
213
|
+
closure.add(room.id);
|
|
214
|
+
for (const zone of floor.zones ?? []) {
|
|
215
|
+
if ((zone.room_id ?? zone.roomID) === room.id)
|
|
216
|
+
closure.add(zone.id);
|
|
217
|
+
}
|
|
218
|
+
for (const sensor of floor.sensors ?? []) {
|
|
219
|
+
if ((sensor.room_id ?? sensor.roomID) === room.id)
|
|
220
|
+
closure.add(sensor.id);
|
|
221
|
+
}
|
|
222
|
+
// Sensors reach a room two ways: directly via room_id, or
|
|
223
|
+
// transitively through a room-bound hive (sensor.hive_serial ===
|
|
224
|
+
// hive.serialNumber). The formatter renders both shapes under the
|
|
225
|
+
// room (`formatHive`'s `hiveSensors` filter nests sensors under
|
|
226
|
+
// hives by `hive_serial`, and a hive nests under its room by
|
|
227
|
+
// `room_id`), so the closure must follow the same chain.
|
|
228
|
+
// Otherwise a sensor with no direct room link but attached to a
|
|
229
|
+
// room-bound hive falls out of the room's tag closure entirely.
|
|
230
|
+
for (const hive of floor.hives ?? []) {
|
|
231
|
+
if ((hive.room_id ?? hive.roomID) !== room.id)
|
|
232
|
+
continue;
|
|
233
|
+
closure.add(hive.id);
|
|
234
|
+
if (!hive.serialNumber)
|
|
235
|
+
continue;
|
|
236
|
+
for (const sensor of floor.sensors ?? []) {
|
|
237
|
+
if (sensor.hive_serial === hive.serialNumber)
|
|
238
|
+
closure.add(sensor.id);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
for (const zone of floor.zones ?? []) {
|
|
243
|
+
if (target.has(zone.id))
|
|
244
|
+
closure.add(zone.id);
|
|
245
|
+
}
|
|
246
|
+
for (const hive of floor.hives ?? []) {
|
|
247
|
+
if (!target.has(hive.id))
|
|
248
|
+
continue;
|
|
249
|
+
closure.add(hive.id);
|
|
250
|
+
// Defensive symmetry with the room→hive→sensor chain above.
|
|
251
|
+
// Tags can't currently attach to sensors per the GraphQL schema,
|
|
252
|
+
// so this branch has no observable effect on current data — but
|
|
253
|
+
// it closes the symmetric-closure invariant claimed in the
|
|
254
|
+
// doc-block and matches what `formatHive` renders under a hive.
|
|
255
|
+
// Do NOT delete thinking it's vestigial.
|
|
256
|
+
//
|
|
257
|
+
// If the GraphQL schema later supports sensor-level tags, drop
|
|
258
|
+
// the "no observable effect" phrasing — the branch becomes
|
|
259
|
+
// load-bearing and a regression here would silently miss
|
|
260
|
+
// sensor-targeted tag composition.
|
|
261
|
+
if (!hive.serialNumber)
|
|
262
|
+
continue;
|
|
263
|
+
for (const sensor of floor.sensors ?? []) {
|
|
264
|
+
if (sensor.hive_serial === hive.serialNumber)
|
|
265
|
+
closure.add(sensor.id);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
for (const sensor of floor.sensors ?? []) {
|
|
269
|
+
if (target.has(sensor.id))
|
|
270
|
+
closure.add(sensor.id);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return closure;
|
|
276
|
+
}
|
|
35
277
|
/**
|
|
36
278
|
* Execute butlr_list_topology tool
|
|
37
279
|
*/
|
|
38
|
-
export async function executeListTopology(args
|
|
280
|
+
export async function executeListTopology(args) {
|
|
39
281
|
const startingDepth = args.starting_depth ?? 0;
|
|
40
282
|
const traversalDepth = args.traversal_depth ?? 0;
|
|
41
283
|
const assetIds = args.asset_ids ?? [];
|
|
42
|
-
|
|
43
|
-
//
|
|
284
|
+
// Brand the input tag names at the boundary so the response surface
|
|
285
|
+
// keeps the `TagName` brand. Zod gives us plain strings; downstream
|
|
286
|
+
// consumers benefit from the brand surviving onto `tag_filter.names`,
|
|
287
|
+
// `unknown_tags`, and the diagnostic union.
|
|
288
|
+
const tagNames = (args.tag_names ?? []).map((n) => asTagName(n));
|
|
289
|
+
// Default is "any" here, in contrast to butlr_available_rooms which
|
|
290
|
+
// defaults to "all". The asymmetry is intentional — list-topology
|
|
291
|
+
// filters across rooms/zones/floors where intersection is rarely
|
|
292
|
+
// satisfied; available-rooms filters a single entity type. Do not
|
|
293
|
+
// unify these defaults via a shared helper.
|
|
294
|
+
const tagMatch = args.tag_match ?? "any";
|
|
295
|
+
debug("butlr-list-topology", `Fetching topology: starting_depth=${startingDepth}, traversal_depth=${traversalDepth}, assets=${assetIds.length || "all"}, tags=${tagNames.length ? `${tagMatch}:${tagNames.join(",")}` : "none"}`);
|
|
296
|
+
// Resolve tag filter (if any) up-front so we can short-circuit on
|
|
297
|
+
// unsatisfiable / no-match cases without paying for the topology fetch.
|
|
298
|
+
let taggedEntityIds;
|
|
299
|
+
// Carry resolvedRows through to the composition phase so collectMatchAwareClosure
|
|
300
|
+
// can expand each tag's subtree before applying the match operator (per R7 §I1).
|
|
301
|
+
// The previous flow computed a literal per-type intersection up-front, which
|
|
302
|
+
// missed hierarchically-related tag pairs.
|
|
303
|
+
let resolvedTagRows;
|
|
304
|
+
// Defaults to [] for the no-tag-filter path; only the live tag-resolution
|
|
305
|
+
// branch reads from `resolution.unknownNames` (passed directly into
|
|
306
|
+
// buildTopologyResponse on early-return) so we don't keep an alias here.
|
|
307
|
+
// Typed as `TagName[]` to preserve the brand from the resolver through
|
|
308
|
+
// to the response surface. `ListTopologyResponse.unknown_tags` declares
|
|
309
|
+
// ReadonlyArray<string> so the brand erodes at the boundary today (a
|
|
310
|
+
// separate type-design item), but at least the in-flight binding doesn't
|
|
311
|
+
// launder it any earlier than necessary.
|
|
312
|
+
let unknownTagNames = [];
|
|
313
|
+
let tagWarning;
|
|
314
|
+
let malformedTagRowCount = 0;
|
|
315
|
+
let malformedTagSampleNames = [];
|
|
316
|
+
if (tagNames.length > 0) {
|
|
317
|
+
let tagsRaw = [];
|
|
318
|
+
try {
|
|
319
|
+
const tagsResult = await apolloClient.query({
|
|
320
|
+
query: GET_TAGS_WITH_USAGE,
|
|
321
|
+
fetchPolicy: "network-only",
|
|
322
|
+
});
|
|
323
|
+
throwIfGraphQLErrors(tagsResult);
|
|
324
|
+
// Same null-is-violation rule as butlr-list-tags — see comment there.
|
|
325
|
+
const tags = tagsResult.data?.tags;
|
|
326
|
+
if (!Array.isArray(tags)) {
|
|
327
|
+
throwInternalError("Unexpected response shape from tags query (expected array, got " +
|
|
328
|
+
`${tags === null ? "null" : typeof tags}). Please retry; if persistent, the upstream API contract may have changed.`);
|
|
329
|
+
}
|
|
330
|
+
tagsRaw = tags;
|
|
331
|
+
}
|
|
332
|
+
catch (error) {
|
|
333
|
+
rethrowIfGraphQLError(error);
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
const resolution = resolveTagNames({
|
|
337
|
+
allTags: tagsRaw,
|
|
338
|
+
requestedNames: tagNames,
|
|
339
|
+
match: tagMatch,
|
|
340
|
+
});
|
|
341
|
+
const baseQueryParams = {
|
|
342
|
+
starting_depth: startingDepth,
|
|
343
|
+
traversal_depth: traversalDepth,
|
|
344
|
+
asset_filter: assetIds.length > 0 ? assetIds : "all",
|
|
345
|
+
tag_filter: { names: tagNames, match: tagMatch },
|
|
346
|
+
};
|
|
347
|
+
// Surface upstream contract violations (rows missing a usable id/name)
|
|
348
|
+
// alongside whichever primary diagnostic fires. Threading it through a
|
|
349
|
+
// shared starting list ensures it cannot be silently dropped on any
|
|
350
|
+
// early-return branch.
|
|
351
|
+
const earlyDiagnostics = [];
|
|
352
|
+
if (resolution.droppedRowCount > 0) {
|
|
353
|
+
earlyDiagnostics.push({
|
|
354
|
+
kind: "malformed_tag_rows",
|
|
355
|
+
count: resolution.droppedRowCount,
|
|
356
|
+
...(resolution.droppedSampleNames.length > 0
|
|
357
|
+
? { sample_names: resolution.droppedSampleNames }
|
|
358
|
+
: {}),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
malformedTagRowCount = resolution.droppedRowCount;
|
|
362
|
+
malformedTagSampleNames = resolution.droppedSampleNames;
|
|
363
|
+
if (resolution.kind === "no_match") {
|
|
364
|
+
const diagnostics = [
|
|
365
|
+
{ kind: "tag_no_match", unknown_names: resolution.unknownNames },
|
|
366
|
+
];
|
|
367
|
+
// When asset_ids was also supplied, opportunistically validate it
|
|
368
|
+
// against a warm topology cache so the user sees both diagnostics in
|
|
369
|
+
// one round-trip. The lookup reads only from the merged-devices cache
|
|
370
|
+
// (the key `butlr_list_topology` writes) so it is authoritative for
|
|
371
|
+
// device ids; `butlr_search_assets` writes a separate device-incomplete
|
|
372
|
+
// shape under a different key. Cache miss → emit `asset_ids_unverified`
|
|
373
|
+
// so the caller knows the asset typo (if any) wasn't checked. Paying
|
|
374
|
+
// for a full topology fetch here would dwarf the actual short-circuit.
|
|
375
|
+
//
|
|
376
|
+
// Skip the probe entirely when BUTLR_ORG_ID is unset: the cache key
|
|
377
|
+
// would fall back to "default", which is shared across every org a
|
|
378
|
+
// multi-tenant process talks to. Reading authoritative asset-scope
|
|
379
|
+
// diagnostics against a tree from a different org would emit
|
|
380
|
+
// `asset_scope_empty` for IDs that exist in the caller's actual org.
|
|
381
|
+
if (assetIds.length > 0) {
|
|
382
|
+
const orgId = process.env.BUTLR_ORG_ID;
|
|
383
|
+
if (!orgId) {
|
|
384
|
+
diagnostics.push({ kind: "asset_ids_unverified" });
|
|
385
|
+
}
|
|
386
|
+
else {
|
|
387
|
+
const cached = getCachedTopology(generateTopologyCacheKey(orgId, true, true, true, undefined));
|
|
388
|
+
const cachedSites = cached?.data?.sites;
|
|
389
|
+
if (cachedSites) {
|
|
390
|
+
if (expandToSubtreeClosure(cachedSites, assetIds).size === 0) {
|
|
391
|
+
diagnostics.push({ kind: "asset_scope_empty", asset_ids: assetIds });
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
else {
|
|
395
|
+
diagnostics.push({ kind: "asset_ids_unverified" });
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
diagnostics.push(...earlyDiagnostics);
|
|
400
|
+
return buildTopologyResponse({
|
|
401
|
+
tree: [],
|
|
402
|
+
queryParams: baseQueryParams,
|
|
403
|
+
diagnostics,
|
|
404
|
+
unknownTagNames: resolution.unknownNames,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
if (resolution.kind === "unsatisfiable") {
|
|
408
|
+
return buildTopologyResponse({
|
|
409
|
+
tree: [],
|
|
410
|
+
queryParams: baseQueryParams,
|
|
411
|
+
diagnostics: [
|
|
412
|
+
{
|
|
413
|
+
kind: "tag_match_all_unsatisfiable",
|
|
414
|
+
unknown_names: resolution.unknownNames,
|
|
415
|
+
partial_resolved_count: resolution.partialResolvedCount,
|
|
416
|
+
},
|
|
417
|
+
...earlyDiagnostics,
|
|
418
|
+
],
|
|
419
|
+
unknownTagNames: resolution.unknownNames,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
// resolution.kind === "ok" — safe to read resolvedRows / resolvedIds.
|
|
423
|
+
const { resolvedRows, unknownNames } = resolution;
|
|
424
|
+
unknownTagNames = unknownNames;
|
|
425
|
+
resolvedTagRows = resolvedRows;
|
|
426
|
+
taggedEntityIds = collectDirectTaggedIds(resolvedRows);
|
|
427
|
+
if (taggedEntityIds.size === 0) {
|
|
428
|
+
return buildTopologyResponse({
|
|
429
|
+
tree: [],
|
|
430
|
+
queryParams: baseQueryParams,
|
|
431
|
+
diagnostics: [
|
|
432
|
+
{ kind: "tag_no_associations", tag_match: tagMatch, tag_names: tagNames },
|
|
433
|
+
...earlyDiagnostics,
|
|
434
|
+
],
|
|
435
|
+
unknownTagNames: unknownNames,
|
|
436
|
+
});
|
|
437
|
+
}
|
|
438
|
+
if (unknownNames.length > 0) {
|
|
439
|
+
tagWarning = { kind: "unknown_tags", names: unknownNames };
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
// tag_filter (tag_names / tag_match) and asset_ids are intentionally
|
|
443
|
+
// excluded from the cache key. The cache stores raw org-scoped topology
|
|
444
|
+
// only; both filters are applied client-side post-fetch via
|
|
445
|
+
// filterTopologyByAssets, so different filter shapes share one cached
|
|
446
|
+
// tree. Do not extend this key with filter inputs without first
|
|
447
|
+
// separating the cache layers.
|
|
448
|
+
//
|
|
449
|
+
// `devicesMerged: true` because this read path requires every floor to
|
|
450
|
+
// carry its `sensors`/`hives` arrays (post-mergeSensorsAndHivesIntoTopology).
|
|
451
|
+
// `butlr_search_assets` writes a separate device-incomplete shape under a
|
|
452
|
+
// distinct key — the two consumers can never collide.
|
|
44
453
|
const cacheKey = generateTopologyCacheKey(process.env.BUTLR_ORG_ID || "default", true, // include devices
|
|
45
454
|
true, // include zones
|
|
455
|
+
true, // devicesMerged: list-topology requires merged sensors/hives
|
|
46
456
|
undefined);
|
|
47
457
|
// Try to get cached topology
|
|
48
458
|
let sites = [];
|
|
@@ -60,13 +470,14 @@ export async function executeListTopology(args = {}) {
|
|
|
60
470
|
query: GET_FULL_TOPOLOGY,
|
|
61
471
|
fetchPolicy: "network-only",
|
|
62
472
|
});
|
|
63
|
-
// Apollo can return both data and errors - only fail if we have no
|
|
473
|
+
// Apollo can return both data and errors - only fail if we have no
|
|
474
|
+
// data. Route the error through `throwIfGraphQLErrors` so it lands
|
|
475
|
+
// as a translated MCP error (AUTH_EXPIRED, RATE_LIMITED, etc.)
|
|
476
|
+
// rather than as a raw `result.error` shape that downstream
|
|
477
|
+
// duck-typing might or might not recognise.
|
|
64
478
|
if (!result.data || !result.data.sites || !result.data.sites.data) {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
throw result.error;
|
|
68
|
-
}
|
|
69
|
-
throw new Error("Invalid response structure from API");
|
|
479
|
+
throwIfGraphQLErrors(result);
|
|
480
|
+
throwInternalError("Invalid response structure from API (missing data.sites.data)");
|
|
70
481
|
}
|
|
71
482
|
// Track whether the topology data is partial (errors alongside data)
|
|
72
483
|
partialData = !!result.error;
|
|
@@ -86,10 +497,26 @@ export async function executeListTopology(args = {}) {
|
|
|
86
497
|
fetchPolicy: "network-only",
|
|
87
498
|
}),
|
|
88
499
|
]);
|
|
500
|
+
throwIfGraphQLErrors(sensorsResult);
|
|
501
|
+
throwIfGraphQLErrors(hivesResult);
|
|
502
|
+
// Mirror the tags-fetch shape contract: only an explicit array survives;
|
|
503
|
+
// `null` / `undefined` / non-array shapes throw as upstream contract
|
|
504
|
+
// violations. `|| []` here would silently launder a serialisation
|
|
505
|
+
// regression into an empty-devices topology.
|
|
506
|
+
const rawSensors = sensorsResult.data?.sensors?.data;
|
|
507
|
+
if (!Array.isArray(rawSensors)) {
|
|
508
|
+
throwInternalError("Unexpected response shape from sensors query (expected array, got " +
|
|
509
|
+
`${rawSensors === null ? "null" : typeof rawSensors}). Please retry; if persistent, the upstream API contract may have changed.`);
|
|
510
|
+
}
|
|
511
|
+
const rawHives = hivesResult.data?.hives?.data;
|
|
512
|
+
if (!Array.isArray(rawHives)) {
|
|
513
|
+
throwInternalError("Unexpected response shape from hives query (expected array, got " +
|
|
514
|
+
`${rawHives === null ? "null" : typeof rawHives}). Please retry; if persistent, the upstream API contract may have changed.`);
|
|
515
|
+
}
|
|
89
516
|
// Filter out test/placeholder devices from topology listing
|
|
90
517
|
// (Note: These filters are ONLY for topology display, not occupancy queries)
|
|
91
|
-
const allSensors =
|
|
92
|
-
const allHives =
|
|
518
|
+
const allSensors = rawSensors.filter(isProductionSensor);
|
|
519
|
+
const allHives = rawHives.filter(isProductionHive);
|
|
93
520
|
debug("butlr-list-topology", `Got ${allSensors.length} production sensors, ${allHives.length} production hives (test/placeholder devices filtered)`);
|
|
94
521
|
// Merge sensors and hives into topology by floor_id
|
|
95
522
|
sites = mergeSensorsAndHivesIntoTopology(sites, allSensors, allHives);
|
|
@@ -107,31 +534,347 @@ export async function executeListTopology(args = {}) {
|
|
|
107
534
|
throw error;
|
|
108
535
|
}
|
|
109
536
|
}
|
|
110
|
-
//
|
|
111
|
-
|
|
537
|
+
// Compose asset_ids and tag_names as a true AND via subtree-overlap
|
|
538
|
+
// intersection. Both sides are expanded to their full descendant closure
|
|
539
|
+
// (including sensors/hives, plus devices bound to a targeted room via
|
|
540
|
+
// room_id). The two surviving closures are intersected — this catches
|
|
541
|
+
// the case where a tag sits on an ancestor of an asset (e.g. tag on a
|
|
542
|
+
// floor, `asset_ids=[room_001]` within that floor) which a raw-ID
|
|
543
|
+
// intersection would miss.
|
|
544
|
+
//
|
|
545
|
+
// `assetScopeEmpty` and `assetTagDisjoint` are tracked separately so
|
|
546
|
+
// the empty-tree warning can distinguish "asset_ids didn't resolve in
|
|
547
|
+
// this org" from "filters scope disjoint subtrees" — the former is a
|
|
548
|
+
// typo, the latter is a legitimate disagreement.
|
|
549
|
+
let filterIds;
|
|
550
|
+
let assetScopeEmpty = false;
|
|
551
|
+
let assetTagDisjoint = false;
|
|
552
|
+
// Both single-filter paths use the closure too — `pruneFloorToMatches`
|
|
553
|
+
// strict-prunes by raw IDs, so passing only `room_001` would return the
|
|
554
|
+
// room with empty zones/hives/sensors. Closure-expand to include the
|
|
555
|
+
// descendants the formatter would render (zones bound to the room,
|
|
556
|
+
// sensors via room_id, sensors via the hive_serial chain, etc.).
|
|
557
|
+
//
|
|
558
|
+
// Tag-side closure uses `collectMatchAwareClosure` — for tag_match='all'
|
|
559
|
+
// it intersects per-tag subtree closures (R7 §I1), which makes
|
|
560
|
+
// hierarchical AND work correctly. For 'any' it unions them.
|
|
112
561
|
if (assetIds.length > 0) {
|
|
113
|
-
|
|
562
|
+
const assetClosure = expandToSubtreeClosure(sites, assetIds);
|
|
563
|
+
assetScopeEmpty = assetClosure.size === 0;
|
|
564
|
+
if (resolvedTagRows && resolvedTagRows.length > 0) {
|
|
565
|
+
const tagClosure = collectMatchAwareClosure(sites, resolvedTagRows, tagMatch);
|
|
566
|
+
const intersection = [];
|
|
567
|
+
for (const id of assetClosure)
|
|
568
|
+
if (tagClosure.has(id))
|
|
569
|
+
intersection.push(id);
|
|
570
|
+
if (!assetScopeEmpty && intersection.length === 0) {
|
|
571
|
+
assetTagDisjoint = true;
|
|
572
|
+
}
|
|
573
|
+
filterIds = intersection;
|
|
574
|
+
}
|
|
575
|
+
else {
|
|
576
|
+
filterIds = [...assetClosure];
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
else if (resolvedTagRows && resolvedTagRows.length > 0) {
|
|
580
|
+
filterIds = [...collectMatchAwareClosure(sites, resolvedTagRows, tagMatch)];
|
|
581
|
+
}
|
|
582
|
+
let filteredSites = sites;
|
|
583
|
+
if (filterIds !== undefined) {
|
|
584
|
+
filteredSites = filterTopologyByAssets(filteredSites, filterIds);
|
|
114
585
|
}
|
|
115
586
|
// Format as tree with depth controls
|
|
116
587
|
const tree = formatTopologyTree(filteredSites, startingDepth, traversalDepth);
|
|
117
|
-
const
|
|
588
|
+
const diagnostics = [];
|
|
589
|
+
if (partialData)
|
|
590
|
+
diagnostics.push({ kind: "partial_topology" });
|
|
591
|
+
if (tagWarning)
|
|
592
|
+
diagnostics.push(tagWarning);
|
|
593
|
+
if (malformedTagRowCount > 0) {
|
|
594
|
+
diagnostics.push({
|
|
595
|
+
kind: "malformed_tag_rows",
|
|
596
|
+
count: malformedTagRowCount,
|
|
597
|
+
...(malformedTagSampleNames.length > 0 ? { sample_names: malformedTagSampleNames } : {}),
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
// Compute how many tagged-entity IDs aren't present in the active
|
|
601
|
+
// topology. Used both for the all-ghost diagnostic (tree empty, every
|
|
602
|
+
// association dangling) and for the partial-ghost diagnostic (tree
|
|
603
|
+
// non-empty, some associations dangling). Without this a partially
|
|
604
|
+
// dangling tag silently includes the real entries and hides the ghosts.
|
|
605
|
+
let ghostTagCount = 0;
|
|
606
|
+
if (taggedEntityIds && taggedEntityIds.size > 0) {
|
|
607
|
+
const presentIds = collectAllTopologyIds(sites);
|
|
608
|
+
for (const id of taggedEntityIds) {
|
|
609
|
+
if (!presentIds.has(id))
|
|
610
|
+
ghostTagCount++;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
// Tag-side ghost diagnostic — evaluated independently of the asset-side
|
|
614
|
+
// branch so a dual-cause empty tree (bad asset_ids AND ghost tag) doesn't
|
|
615
|
+
// mask the underlying ghost-association problem behind a misleading
|
|
616
|
+
// "disjoint subtrees" warning.
|
|
617
|
+
//
|
|
618
|
+
// Gate emission on `!partialData`: when the topology fetch returned
|
|
619
|
+
// partial results, `presentIds` is built off a truncated tree, so an
|
|
620
|
+
// entity could appear "ghost" purely because its enclosing site/floor
|
|
621
|
+
// was missing from this response. Surfacing tag_associations_*_ghost
|
|
622
|
+
// in that state would be a false positive — the partial_topology
|
|
623
|
+
// diagnostic already alerts the caller that the data is incomplete.
|
|
624
|
+
// Hoist into a single narrowed binding so neither the gate nor the
|
|
625
|
+
// emission needs `!` non-null assertions. TS narrows `taggedSize`
|
|
626
|
+
// structurally — a future refactor that drops the early return on
|
|
627
|
+
// `taggedEntityIds.size === 0` cannot accidentally re-introduce the
|
|
628
|
+
// unsound `!.size` reads.
|
|
629
|
+
const taggedSize = taggedEntityIds?.size ?? 0;
|
|
630
|
+
const ghostKind = taggedSize > 0
|
|
631
|
+
? ghostTagCount === taggedSize
|
|
632
|
+
? "all"
|
|
633
|
+
: ghostTagCount > 0
|
|
634
|
+
? "partial"
|
|
635
|
+
: "none"
|
|
636
|
+
: "none";
|
|
637
|
+
if (!partialData) {
|
|
638
|
+
if (ghostKind === "all") {
|
|
639
|
+
diagnostics.push({
|
|
640
|
+
kind: "tag_associations_all_ghost",
|
|
641
|
+
total: taggedSize,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
else if (ghostKind === "partial") {
|
|
645
|
+
diagnostics.push({
|
|
646
|
+
kind: "tag_associations_partial_ghost",
|
|
647
|
+
ghost: ghostTagCount,
|
|
648
|
+
total: taggedSize,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// Asset-side diagnostics — `asset_scope_empty` and `asset_tag_disjoint`
|
|
653
|
+
// are mutually exclusive within `assetIds.length>0`.
|
|
654
|
+
//
|
|
655
|
+
// `asset_scope_empty` is an INDEPENDENT root cause: a typo'd asset_id
|
|
656
|
+
// is still wrong even if the tag is also dangling. Surface both so the
|
|
657
|
+
// user can fix them in one round-trip.
|
|
658
|
+
//
|
|
659
|
+
// `asset_tag_disjoint` IS a downstream symptom of an all-ghost tag —
|
|
660
|
+
// the disjointness only exists because the tag's closure has nothing
|
|
661
|
+
// to intersect with. Suppress it under all-ghost so the user gets the
|
|
662
|
+
// actionable root cause ("your tag is dangling"), not the symptom.
|
|
663
|
+
//
|
|
664
|
+
// Suppress disjoint also under `partialData`: the disjointness was
|
|
665
|
+
// computed against a truncated topology, so it's a false signal — the
|
|
666
|
+
// tag's subtree might exist outside this partial fetch. The
|
|
667
|
+
// `partial_topology` diagnostic already alerts the caller that the
|
|
668
|
+
// data is incomplete; routing them to "remove a filter" would be
|
|
669
|
+
// misdirection.
|
|
670
|
+
const allGhostEmitted = !partialData && ghostKind === "all";
|
|
671
|
+
if (tree.length === 0 && assetIds.length > 0) {
|
|
672
|
+
if (assetScopeEmpty) {
|
|
673
|
+
diagnostics.push({ kind: "asset_scope_empty", asset_ids: assetIds });
|
|
674
|
+
}
|
|
675
|
+
else if (assetTagDisjoint && !allGhostEmitted && !partialData) {
|
|
676
|
+
diagnostics.push({ kind: "asset_tag_disjoint" });
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
// Tag-match='all' no-overlap diagnostic: every requested tag resolved
|
|
680
|
+
// and each has associations, but the per-tag SUBTREE intersection is
|
|
681
|
+
// empty (e.g. tag A on Room X, tag B on Room Y in different floors).
|
|
682
|
+
// Without this branch the user gets `tree: []` and no diagnostic when
|
|
683
|
+
// no asset_ids filter is in play. Gate on the absence of higher-
|
|
684
|
+
// priority diagnostics so we don't double-report.
|
|
685
|
+
if (tagMatch === "all" &&
|
|
686
|
+
resolvedTagRows &&
|
|
687
|
+
resolvedTagRows.length > 1 &&
|
|
688
|
+
filterIds !== undefined &&
|
|
689
|
+
filterIds.length === 0 &&
|
|
690
|
+
!partialData &&
|
|
691
|
+
!allGhostEmitted &&
|
|
692
|
+
diagnostics.length === 0) {
|
|
693
|
+
diagnostics.push({
|
|
694
|
+
kind: "tag_match_all_no_overlap",
|
|
695
|
+
resolved_names: resolvedTagRows.map((r) => asTagName(r.name)),
|
|
696
|
+
});
|
|
697
|
+
}
|
|
698
|
+
// Depth-slicing diagnostic: filter resolved to entities (filterIds and
|
|
699
|
+
// filteredSites both non-empty) but the formatter's depth window
|
|
700
|
+
// sliced them out. Without this branch the user gets `tree: []` and no
|
|
701
|
+
// signal — a real correctness gap pointed out by the silent-failure
|
|
702
|
+
// review. Gate on the absence of the higher-priority diagnostics so
|
|
703
|
+
// we don't double-report.
|
|
704
|
+
const haveOtherEmptyTreeDiagnostic = diagnostics.some((d) => d.kind === "asset_scope_empty" ||
|
|
705
|
+
d.kind === "asset_tag_disjoint" ||
|
|
706
|
+
d.kind === "tag_associations_all_ghost" ||
|
|
707
|
+
d.kind === "tag_no_associations" ||
|
|
708
|
+
d.kind === "tag_no_match" ||
|
|
709
|
+
d.kind === "tag_match_all_unsatisfiable" ||
|
|
710
|
+
d.kind === "tag_match_all_no_overlap");
|
|
711
|
+
if (tree.length === 0 &&
|
|
712
|
+
!haveOtherEmptyTreeDiagnostic &&
|
|
713
|
+
!partialData &&
|
|
714
|
+
filterIds !== undefined &&
|
|
715
|
+
filterIds.length > 0 &&
|
|
716
|
+
filteredSites.length > 0) {
|
|
717
|
+
diagnostics.push({
|
|
718
|
+
kind: "depth_excludes_matches",
|
|
719
|
+
starting_depth: startingDepth,
|
|
720
|
+
traversal_depth: traversalDepth,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
return buildTopologyResponse({
|
|
118
724
|
tree,
|
|
119
|
-
|
|
725
|
+
queryParams: {
|
|
120
726
|
starting_depth: startingDepth,
|
|
121
727
|
traversal_depth: traversalDepth,
|
|
122
728
|
asset_filter: assetIds.length > 0 ? assetIds : "all",
|
|
729
|
+
...(tagNames.length > 0 ? { tag_filter: { names: tagNames, match: tagMatch } } : {}),
|
|
123
730
|
},
|
|
731
|
+
diagnostics,
|
|
732
|
+
unknownTagNames,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Collect every entity id present in the active topology so we can detect
|
|
737
|
+
* tag associations pointing at deleted (or otherwise filtered-out) entities.
|
|
738
|
+
*/
|
|
739
|
+
function collectAllTopologyIds(sites) {
|
|
740
|
+
const ids = new Set();
|
|
741
|
+
for (const site of sites) {
|
|
742
|
+
ids.add(site.id);
|
|
743
|
+
for (const building of site.buildings ?? []) {
|
|
744
|
+
ids.add(building.id);
|
|
745
|
+
for (const floor of building.floors ?? []) {
|
|
746
|
+
ids.add(floor.id);
|
|
747
|
+
for (const room of floor.rooms ?? [])
|
|
748
|
+
ids.add(room.id);
|
|
749
|
+
for (const zone of floor.zones ?? [])
|
|
750
|
+
ids.add(zone.id);
|
|
751
|
+
for (const hive of floor.hives ?? [])
|
|
752
|
+
ids.add(hive.id);
|
|
753
|
+
for (const sensor of floor.sensors ?? [])
|
|
754
|
+
ids.add(sensor.id);
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
return ids;
|
|
759
|
+
}
|
|
760
|
+
/** Render a structured diagnostic as the human-readable prose used in `warning`. */
|
|
761
|
+
function renderDiagnostic(d) {
|
|
762
|
+
switch (d.kind) {
|
|
763
|
+
case "partial_topology":
|
|
764
|
+
return "Topology data may be incomplete — the API returned partial results due to upstream errors.";
|
|
765
|
+
case "tag_no_match":
|
|
766
|
+
return (`No matching tags found in this org for: ${d.unknown_names.join(", ")}. ` +
|
|
767
|
+
"Use butlr_list_tags to see available tag names.");
|
|
768
|
+
case "unknown_tags":
|
|
769
|
+
return (`Unknown tag(s) ignored: ${d.names.join(", ")}. ` +
|
|
770
|
+
"Use butlr_list_tags to see available tag names.");
|
|
771
|
+
case "tag_match_all_unsatisfiable":
|
|
772
|
+
return (`Cannot satisfy tag_match='all': ${d.partial_resolved_count} tag(s) ` +
|
|
773
|
+
`resolved but unknown tag(s) ${d.unknown_names.join(", ")} prevent the AND. ` +
|
|
774
|
+
"Use butlr_list_tags to see available tag names, or pass tag_match='any' to match entities tagged with any of the supplied tags.");
|
|
775
|
+
case "tag_no_associations": {
|
|
776
|
+
// Single-tag case reads cleanly without the all/any preamble.
|
|
777
|
+
const tagList = d.tag_names.length === 1
|
|
778
|
+
? `"${d.tag_names[0]}"`
|
|
779
|
+
: `${d.tag_match === "all" ? "all of" : "any of"} [${d.tag_names.join(", ")}]`;
|
|
780
|
+
return (`No rooms, zones, or floors are currently tagged with ${tagList}. ` +
|
|
781
|
+
"Use butlr_list_tags { include_entities: true } to see what is tagged.");
|
|
782
|
+
}
|
|
783
|
+
case "asset_scope_empty":
|
|
784
|
+
return ("asset_ids matched no entities in the org — verify the IDs exist " +
|
|
785
|
+
"(use butlr_search_assets if unsure).");
|
|
786
|
+
case "asset_tag_disjoint":
|
|
787
|
+
return ("No tree node satisfies both asset_ids and tag_names — the two filters scope disjoint subtrees. " +
|
|
788
|
+
"Try removing one filter or use butlr_list_tags { include_entities: true } to see where the tags live.");
|
|
789
|
+
case "tag_associations_all_ghost":
|
|
790
|
+
return (`Tag matched ${d.total} entit${d.total === 1 ? "y" : "ies"} ` +
|
|
791
|
+
"in tag associations, but none are present in the active topology — " +
|
|
792
|
+
"they may have been deleted. " +
|
|
793
|
+
"Use butlr_list_tags { include_entities: true } to inspect the raw associations.");
|
|
794
|
+
case "tag_associations_partial_ghost":
|
|
795
|
+
return (`${d.ghost} of ${d.total} tag associations point at ` +
|
|
796
|
+
"entities outside the active topology (likely deleted) and were skipped. " +
|
|
797
|
+
"Use butlr_list_tags { include_entities: true } to inspect the raw associations.");
|
|
798
|
+
case "asset_ids_unverified":
|
|
799
|
+
return ("asset_ids were not validated (topology not yet cached) — " +
|
|
800
|
+
"re-run after correcting the tag names to confirm they exist.");
|
|
801
|
+
case "malformed_tag_rows": {
|
|
802
|
+
const sample = d.sample_names && d.sample_names.length > 0
|
|
803
|
+
? ` (sample: ${d.sample_names.join(", ")})`
|
|
804
|
+
: "";
|
|
805
|
+
return (`${d.count} tag row(s) skipped — upstream returned entries with ` +
|
|
806
|
+
`missing or empty id/name fields, or duplicate canonical names${sample}. ` +
|
|
807
|
+
"If unexpected, contact support.");
|
|
808
|
+
}
|
|
809
|
+
case "depth_excludes_matches":
|
|
810
|
+
return ("Filter matched entities, but every match sits outside the " +
|
|
811
|
+
`current depth window (starting_depth=${d.starting_depth}, ` +
|
|
812
|
+
`traversal_depth=${d.traversal_depth}). Lower starting_depth or ` +
|
|
813
|
+
"raise traversal_depth to surface the matches.");
|
|
814
|
+
case "tag_match_all_no_overlap":
|
|
815
|
+
return (`tag_match='all' but the subtree intersection of ` +
|
|
816
|
+
`[${d.resolved_names.join(", ")}] is empty — the tags apply to ` +
|
|
817
|
+
"entities under different parents. Try tag_match='any', drop a " +
|
|
818
|
+
"tag, or use butlr_list_tags { include_entities: true } to inspect " +
|
|
819
|
+
"where each tag lives.");
|
|
820
|
+
default: {
|
|
821
|
+
// Exhaustiveness guard — adding a new TopologyDiagnostic arm without
|
|
822
|
+
// a matching case here fails to compile because `assertNever` only
|
|
823
|
+
// accepts `never`. At runtime we degrade gracefully with a fallback
|
|
824
|
+
// string rather than throw, so version skew (e.g. an older bundle
|
|
825
|
+
// reads a diagnostic kind written by a newer one over a shared
|
|
826
|
+
// cache or transport) doesn't crash the response renderer.
|
|
827
|
+
assertNever(d);
|
|
828
|
+
const unknownKind = d.kind ?? "unspecified";
|
|
829
|
+
// Surface version-skew explicitly in logs so an operator notices
|
|
830
|
+
// the drift without needing to spelunk the response shape.
|
|
831
|
+
debug("topology-diagnostic-unknown-kind", unknownKind);
|
|
832
|
+
return `Unknown diagnostic kind: ${unknownKind}`;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Compile-time exhaustiveness assertion. The argument's type must be
|
|
838
|
+
* `never`; if any caller's narrowing leaves an unhandled case, TS rejects
|
|
839
|
+
* the call. Returns nothing so the surrounding switch can still fall
|
|
840
|
+
* through to a runtime fallback (see `renderDiagnostic`).
|
|
841
|
+
*/
|
|
842
|
+
function assertNever(_value) {
|
|
843
|
+
/* compile-time guard only */
|
|
844
|
+
}
|
|
845
|
+
/**
|
|
846
|
+
* Assemble the response, deriving the legacy `warning` string from the
|
|
847
|
+
* structured diagnostics so the two stay in lock-step. Either field on its
|
|
848
|
+
* own carries the full diagnostic surface; consumers can safely branch on
|
|
849
|
+
* `warnings[].kind` and ignore `warning`.
|
|
850
|
+
*/
|
|
851
|
+
function buildTopologyResponse(args) {
|
|
852
|
+
const response = {
|
|
853
|
+
tree: args.tree,
|
|
854
|
+
query_params: args.queryParams,
|
|
124
855
|
timestamp: new Date().toISOString(),
|
|
125
856
|
};
|
|
126
|
-
if (
|
|
127
|
-
response.
|
|
128
|
-
|
|
857
|
+
if (args.diagnostics.length > 0) {
|
|
858
|
+
response.warnings = args.diagnostics;
|
|
859
|
+
response.warning = args.diagnostics.map(renderDiagnostic).join(" ");
|
|
860
|
+
}
|
|
861
|
+
if (args.unknownTagNames.length > 0) {
|
|
862
|
+
response.unknown_tags = args.unknownTagNames;
|
|
129
863
|
}
|
|
130
864
|
return response;
|
|
131
865
|
}
|
|
132
866
|
/**
|
|
133
|
-
* Merge sensors and hives into topology structure
|
|
134
|
-
* Groups by floor_id and nests under appropriate floors
|
|
867
|
+
* Merge sensors and hives into topology structure.
|
|
868
|
+
* Groups by floor_id and nests under appropriate floors.
|
|
869
|
+
*
|
|
870
|
+
* IN-PLACE MUTATION: assigns `floor.sensors` and `floor.hives` directly on
|
|
871
|
+
* the input site tree (returned for chaining; no new array is allocated).
|
|
872
|
+
* `setCachedTopology` reads from the same `sites` reference that this
|
|
873
|
+
* function mutates — any caller that captures the pre-merge `sites` (via
|
|
874
|
+
* Apollo cache-restore, structural clone, or fork-then-await) would
|
|
875
|
+
* observe the un-merged shape and could write a device-incomplete tree
|
|
876
|
+
* to the cache. If you change this to immutable assignment, audit the
|
|
877
|
+
* cache write site to confirm it sees the merged result.
|
|
135
878
|
*/
|
|
136
879
|
function mergeSensorsAndHivesIntoTopology(sites, allSensors, allHives) {
|
|
137
880
|
// Group sensors by floor_id
|
|
@@ -169,8 +912,24 @@ function mergeSensorsAndHivesIntoTopology(sites, allSensors, allHives) {
|
|
|
169
912
|
return sites;
|
|
170
913
|
}
|
|
171
914
|
/**
|
|
172
|
-
* Filter topology to only include assets matching the provided IDs
|
|
173
|
-
*
|
|
915
|
+
* Filter topology to only include assets matching the provided IDs.
|
|
916
|
+
*
|
|
917
|
+
* Pruning is strict at every level so untargeted siblings do not leak
|
|
918
|
+
* through (a regression vector for `asset_ids` ∩ `tag_names` composition,
|
|
919
|
+
* where `expandToSubtreeClosure` precomputes a leaf-level intersection):
|
|
920
|
+
* - If a site/building/floor id is itself in `assetIds`, the whole
|
|
921
|
+
* subtree is preserved (caller asked for that branch as a whole).
|
|
922
|
+
* - Otherwise the floor is shallow-cloned with each child collection
|
|
923
|
+
* filtered to ids in `assetIds`.
|
|
924
|
+
*
|
|
925
|
+
* Rendering ancestors are pulled back in after pruning so the tree
|
|
926
|
+
* formatter can place each matched leaf at its expected position. Without
|
|
927
|
+
* this, a matched zone/sensor whose parent room wasn't itself targeted
|
|
928
|
+
* would silently fall out of the tree (the formatter renders zones and
|
|
929
|
+
* room-bound sensors under their parent room, and hive-bound sensors
|
|
930
|
+
* under their hive — none of which appear if the parent isn't present).
|
|
931
|
+
* The added ancestors are always parents of an already-matched node, so
|
|
932
|
+
* sibling leakage cannot occur.
|
|
174
933
|
*/
|
|
175
934
|
function filterTopologyByAssets(sites, assetIds) {
|
|
176
935
|
const idSet = new Set(assetIds);
|
|
@@ -192,13 +951,9 @@ function filterTopologyByAssets(sites, assetIds) {
|
|
|
192
951
|
matchedFloors.push(floor);
|
|
193
952
|
continue;
|
|
194
953
|
}
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const hasMatchedSensor = floor.sensors?.some((s) => idSet.has(s.id));
|
|
199
|
-
if (hasMatchedRoom || hasMatchedZone || hasMatchedHive || hasMatchedSensor) {
|
|
200
|
-
matchedFloors.push(floor);
|
|
201
|
-
}
|
|
954
|
+
const prunedFloor = pruneFloorToMatches(floor, idSet);
|
|
955
|
+
if (prunedFloor)
|
|
956
|
+
matchedFloors.push(prunedFloor);
|
|
202
957
|
}
|
|
203
958
|
if (matchedFloors.length > 0) {
|
|
204
959
|
matchedBuildings.push({ ...building, floors: matchedFloors });
|
|
@@ -210,6 +965,61 @@ function filterTopologyByAssets(sites, assetIds) {
|
|
|
210
965
|
}
|
|
211
966
|
return filtered;
|
|
212
967
|
}
|
|
968
|
+
/**
|
|
969
|
+
* Build a shallow-cloned floor containing only matched leaves and their
|
|
970
|
+
* rendering ancestors. Returns `undefined` when nothing on the floor matched.
|
|
971
|
+
*/
|
|
972
|
+
function pruneFloorToMatches(floor, idSet) {
|
|
973
|
+
const matchedRooms = (floor.rooms ?? []).filter((r) => idSet.has(r.id));
|
|
974
|
+
const matchedZones = (floor.zones ?? []).filter((z) => idSet.has(z.id));
|
|
975
|
+
const matchedHives = (floor.hives ?? []).filter((h) => idSet.has(h.id));
|
|
976
|
+
const matchedSensors = (floor.sensors ?? []).filter((s) => idSet.has(s.id));
|
|
977
|
+
if (matchedRooms.length === 0 &&
|
|
978
|
+
matchedZones.length === 0 &&
|
|
979
|
+
matchedHives.length === 0 &&
|
|
980
|
+
matchedSensors.length === 0) {
|
|
981
|
+
return undefined;
|
|
982
|
+
}
|
|
983
|
+
const ancestorRoomIds = new Set();
|
|
984
|
+
const collectRoomAncestor = (entity) => {
|
|
985
|
+
const roomId = entity.roomID ?? entity.room_id;
|
|
986
|
+
if (roomId)
|
|
987
|
+
ancestorRoomIds.add(roomId);
|
|
988
|
+
};
|
|
989
|
+
matchedZones.forEach(collectRoomAncestor);
|
|
990
|
+
matchedHives.forEach(collectRoomAncestor);
|
|
991
|
+
matchedSensors.forEach(collectRoomAncestor);
|
|
992
|
+
const matchedHiveIds = new Set(matchedHives.map((h) => h.id));
|
|
993
|
+
const ancestorHiveSerials = new Set();
|
|
994
|
+
for (const sensor of matchedSensors) {
|
|
995
|
+
if (sensor.hive_serial)
|
|
996
|
+
ancestorHiveSerials.add(sensor.hive_serial);
|
|
997
|
+
}
|
|
998
|
+
const ancestorHives = [];
|
|
999
|
+
for (const hive of floor.hives ?? []) {
|
|
1000
|
+
if (matchedHiveIds.has(hive.id))
|
|
1001
|
+
continue;
|
|
1002
|
+
if (hive.serialNumber && ancestorHiveSerials.has(hive.serialNumber)) {
|
|
1003
|
+
ancestorHives.push(hive);
|
|
1004
|
+
collectRoomAncestor(hive);
|
|
1005
|
+
}
|
|
1006
|
+
}
|
|
1007
|
+
const matchedRoomIds = new Set(matchedRooms.map((r) => r.id));
|
|
1008
|
+
const ancestorRooms = [];
|
|
1009
|
+
for (const room of floor.rooms ?? []) {
|
|
1010
|
+
if (matchedRoomIds.has(room.id))
|
|
1011
|
+
continue;
|
|
1012
|
+
if (ancestorRoomIds.has(room.id))
|
|
1013
|
+
ancestorRooms.push(room);
|
|
1014
|
+
}
|
|
1015
|
+
return {
|
|
1016
|
+
...floor,
|
|
1017
|
+
rooms: [...matchedRooms, ...ancestorRooms],
|
|
1018
|
+
zones: matchedZones,
|
|
1019
|
+
hives: [...matchedHives, ...ancestorHives],
|
|
1020
|
+
sensors: matchedSensors,
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
213
1023
|
/**
|
|
214
1024
|
* Register butlr_list_topology with an McpServer instance
|
|
215
1025
|
*/
|