camstack 1.2.210 → 1.2.211
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/CHANGELOG.md +8 -0
- package/dist/{chunk-EMTOU2RA.js → chunk-NAUJQNCX.js} +1470 -0
- package/dist/cli.js +6 -6
- package/dist/{launcher-FMNINS4U.js → launcher-UXJ4WU3U.js} +60 -0
- package/dist/server-OYJZ5RCY.js +7407 -0
- package/package.json +1 -1
- package/dist/server-LHBD6ATG.js +0 -2243
package/dist/server-LHBD6ATG.js
DELETED
|
@@ -1,2243 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
DETECTOR_PERF,
|
|
4
|
-
acceleratorKindOf,
|
|
5
|
-
buildViewerLink,
|
|
6
|
-
classifyInferenceHardware,
|
|
7
|
-
findHubs
|
|
8
|
-
} from "./chunk-EMTOU2RA.js";
|
|
9
|
-
import "./chunk-LMMQX4CK.js";
|
|
10
|
-
|
|
11
|
-
// src/mcp/server.ts
|
|
12
|
-
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
13
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
|
-
import { MutationGate, tryConnectHub } from "@camstack/develop";
|
|
15
|
-
|
|
16
|
-
// src/mcp/configure-tools.ts
|
|
17
|
-
import {
|
|
18
|
-
runGated,
|
|
19
|
-
text as text2
|
|
20
|
-
} from "@camstack/develop";
|
|
21
|
-
import { z as z7 } from "zod";
|
|
22
|
-
|
|
23
|
-
// src/mcp/ops-tools.ts
|
|
24
|
-
import { z as z6 } from "zod";
|
|
25
|
-
import { jwtClaims, text } from "@camstack/develop";
|
|
26
|
-
|
|
27
|
-
// src/mcp/ops-hub.ts
|
|
28
|
-
import {
|
|
29
|
-
isSecretBearing
|
|
30
|
-
} from "@camstack/develop";
|
|
31
|
-
function noSessionResult(outcome) {
|
|
32
|
-
return {
|
|
33
|
-
status: "no-session",
|
|
34
|
-
message: outcome.message,
|
|
35
|
-
action: outcome.action
|
|
36
|
-
};
|
|
37
|
-
}
|
|
38
|
-
function reasonOf(err) {
|
|
39
|
-
return err instanceof Error ? err.message : String(err);
|
|
40
|
-
}
|
|
41
|
-
async function readQuery(client, path, input, schema) {
|
|
42
|
-
if (isSecretBearing(path)) {
|
|
43
|
-
return {
|
|
44
|
-
ok: false,
|
|
45
|
-
reason: `${path} returns secret material; the ops toolset never reads it`
|
|
46
|
-
};
|
|
47
|
-
}
|
|
48
|
-
let raw;
|
|
49
|
-
try {
|
|
50
|
-
raw = await client.call("GET", path, input);
|
|
51
|
-
} catch (err) {
|
|
52
|
-
return { ok: false, reason: `${path}: ${reasonOf(err)}` };
|
|
53
|
-
}
|
|
54
|
-
const parsed = schema.safeParse(raw);
|
|
55
|
-
if (!parsed.success) {
|
|
56
|
-
const first = parsed.error.issues[0];
|
|
57
|
-
const where = first ? `${first.path.join(".")}: ${first.message}` : "unexpected shape";
|
|
58
|
-
return {
|
|
59
|
-
ok: false,
|
|
60
|
-
reason: `${path}: unexpected answer (${where}) \u2014 the hub and this tool may be on different versions`
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
return { ok: true, value: parsed.data };
|
|
64
|
-
}
|
|
65
|
-
async function withHub(ctx, body) {
|
|
66
|
-
const outcome = await ctx.hub();
|
|
67
|
-
if (!outcome.ok) return noSessionResult(outcome);
|
|
68
|
-
return body(outcome.connection);
|
|
69
|
-
}
|
|
70
|
-
function cap(items, max) {
|
|
71
|
-
return {
|
|
72
|
-
items: items.slice(0, max),
|
|
73
|
-
truncated: Math.max(0, items.length - max)
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
var ADMIN_ONLY_NOTE = (path, hint) => `${path} is admin-tier and this credential is not admin-scoped.${hint ? ` ${hint}` : ""}`;
|
|
77
|
-
|
|
78
|
-
// src/mcp/ops-accelerators.ts
|
|
79
|
-
import { z } from "zod";
|
|
80
|
-
var NodesSchema = z.array(
|
|
81
|
-
z.looseObject({
|
|
82
|
-
id: z.string(),
|
|
83
|
-
isOnline: z.boolean(),
|
|
84
|
-
name: z.string().optional()
|
|
85
|
-
})
|
|
86
|
-
);
|
|
87
|
-
var HardwareSchema = z.looseObject({
|
|
88
|
-
platform: z.enum(["darwin", "linux", "win32"]),
|
|
89
|
-
arch: z.enum(["arm64", "x64"]),
|
|
90
|
-
cpuModel: z.string(),
|
|
91
|
-
cpuCores: z.number(),
|
|
92
|
-
totalRAM_MB: z.number(),
|
|
93
|
-
availableRAM_MB: z.number(),
|
|
94
|
-
gpu: z.looseObject({
|
|
95
|
-
type: z.enum(["nvidia", "amd", "intel", "apple"]),
|
|
96
|
-
name: z.string()
|
|
97
|
-
}).nullable(),
|
|
98
|
-
npu: z.looseObject({
|
|
99
|
-
type: z.enum(["apple-ane", "intel-npu", "amd-xdna"])
|
|
100
|
-
}).nullable()
|
|
101
|
-
});
|
|
102
|
-
var InferenceDevicesSchema = z.looseObject({
|
|
103
|
-
reachable: z.boolean(),
|
|
104
|
-
devices: z.array(
|
|
105
|
-
z.looseObject({
|
|
106
|
-
key: z.string(),
|
|
107
|
-
backend: z.string(),
|
|
108
|
-
device: z.string(),
|
|
109
|
-
available: z.boolean(),
|
|
110
|
-
presence: z.enum(["present", "absent", "unknown"]).optional(),
|
|
111
|
-
enabled: z.boolean(),
|
|
112
|
-
exclusion: z.string().nullable(),
|
|
113
|
-
defaultModelId: z.string()
|
|
114
|
-
})
|
|
115
|
-
)
|
|
116
|
-
});
|
|
117
|
-
var NODE_LIMIT = 30;
|
|
118
|
-
function benchmarkRowFor(hardwareClass, kind) {
|
|
119
|
-
if (kind === null) return null;
|
|
120
|
-
const column = DETECTOR_PERF[hardwareClass]?.[kind];
|
|
121
|
-
if (column === void 0) return { state: "none" };
|
|
122
|
-
const cells = Object.values(column);
|
|
123
|
-
const measured = cells.filter((c) => c.measured).length;
|
|
124
|
-
const estimated = cells.length - measured;
|
|
125
|
-
if (measured > 0)
|
|
126
|
-
return {
|
|
127
|
-
state: "measured",
|
|
128
|
-
measuredCells: measured,
|
|
129
|
-
estimatedCells: estimated
|
|
130
|
-
};
|
|
131
|
-
if (estimated > 0) return { state: "estimated-only", estimatedCells: estimated };
|
|
132
|
-
return { state: "none" };
|
|
133
|
-
}
|
|
134
|
-
async function nodeAccelerators(client, node) {
|
|
135
|
-
const unavailable = {};
|
|
136
|
-
if (!node.isOnline) {
|
|
137
|
-
return {
|
|
138
|
-
nodeId: node.id,
|
|
139
|
-
online: false,
|
|
140
|
-
hardwareClass: null,
|
|
141
|
-
cpuModel: null,
|
|
142
|
-
devices: null,
|
|
143
|
-
unavailable: { node: "node is offline" }
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
const [hardwareRead, devicesRead] = await Promise.all([
|
|
147
|
-
readQuery(client, "platformProbe.getHardware", { nodeId: node.id }, HardwareSchema),
|
|
148
|
-
readQuery(
|
|
149
|
-
client,
|
|
150
|
-
"pipelineOrchestrator.getNodeInferenceDevices",
|
|
151
|
-
{ nodeId: node.id },
|
|
152
|
-
InferenceDevicesSchema
|
|
153
|
-
)
|
|
154
|
-
]);
|
|
155
|
-
let hardware = null;
|
|
156
|
-
let hardwareClass = null;
|
|
157
|
-
if (hardwareRead.ok) {
|
|
158
|
-
hardware = {
|
|
159
|
-
platform: hardwareRead.value.platform,
|
|
160
|
-
arch: hardwareRead.value.arch,
|
|
161
|
-
cpuModel: hardwareRead.value.cpuModel,
|
|
162
|
-
cpuCores: hardwareRead.value.cpuCores,
|
|
163
|
-
totalRAM_MB: hardwareRead.value.totalRAM_MB,
|
|
164
|
-
availableRAM_MB: hardwareRead.value.availableRAM_MB,
|
|
165
|
-
gpu: hardwareRead.value.gpu ? {
|
|
166
|
-
type: hardwareRead.value.gpu.type,
|
|
167
|
-
name: hardwareRead.value.gpu.name
|
|
168
|
-
} : null,
|
|
169
|
-
npu: hardwareRead.value.npu ? { type: hardwareRead.value.npu.type } : null
|
|
170
|
-
};
|
|
171
|
-
hardwareClass = classifyInferenceHardware(hardware).hardwareClass;
|
|
172
|
-
} else {
|
|
173
|
-
unavailable["hardware"] = hardwareRead.reason;
|
|
174
|
-
}
|
|
175
|
-
let devices = null;
|
|
176
|
-
if (devicesRead.ok) {
|
|
177
|
-
if (!devicesRead.value.reachable) {
|
|
178
|
-
unavailable["devices"] = "the node was unreachable when its devices were probed";
|
|
179
|
-
} else {
|
|
180
|
-
devices = devicesRead.value.devices.map((d) => {
|
|
181
|
-
const kind = hardware ? acceleratorKindOf(d.key, hardware) : null;
|
|
182
|
-
return {
|
|
183
|
-
key: d.key,
|
|
184
|
-
backend: d.backend,
|
|
185
|
-
device: d.device,
|
|
186
|
-
available: d.available,
|
|
187
|
-
presence: d.presence ?? null,
|
|
188
|
-
enabled: d.enabled,
|
|
189
|
-
exclusion: d.exclusion,
|
|
190
|
-
defaultModelId: d.defaultModelId,
|
|
191
|
-
acceleratorKind: kind,
|
|
192
|
-
benchmark: hardwareClass ? benchmarkRowFor(hardwareClass, kind) : null
|
|
193
|
-
};
|
|
194
|
-
});
|
|
195
|
-
}
|
|
196
|
-
} else {
|
|
197
|
-
unavailable["devices"] = devicesRead.reason;
|
|
198
|
-
}
|
|
199
|
-
return {
|
|
200
|
-
nodeId: node.id,
|
|
201
|
-
online: true,
|
|
202
|
-
hardwareClass,
|
|
203
|
-
cpuModel: hardware?.cpuModel ?? null,
|
|
204
|
-
devices,
|
|
205
|
-
unavailable
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
async function opsAccelerators(ctx) {
|
|
209
|
-
return withHub(ctx, async (connection) => {
|
|
210
|
-
if (connection.tier !== "admin") {
|
|
211
|
-
return {
|
|
212
|
-
status: "refused",
|
|
213
|
-
reason: `nodes.topology is admin-tier and this credential is not admin-scoped.${connection.adminHint ? ` ${connection.adminHint}` : ""}`
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
const nodesRead = await readQuery(connection.client, "nodes.topology", void 0, NodesSchema);
|
|
217
|
-
if (!nodesRead.ok) {
|
|
218
|
-
return {
|
|
219
|
-
status: "ok",
|
|
220
|
-
nodes: [],
|
|
221
|
-
nodesTruncated: 0,
|
|
222
|
-
unavailable: { nodes: nodesRead.reason }
|
|
223
|
-
};
|
|
224
|
-
}
|
|
225
|
-
const capped = cap(nodesRead.value, NODE_LIMIT);
|
|
226
|
-
const nodes = await Promise.all(capped.items.map((n) => nodeAccelerators(connection.client, n)));
|
|
227
|
-
return {
|
|
228
|
-
status: "ok",
|
|
229
|
-
nodes,
|
|
230
|
-
nodesTruncated: capped.truncated,
|
|
231
|
-
unavailable: {}
|
|
232
|
-
};
|
|
233
|
-
});
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
// src/mcp/ops-device.ts
|
|
237
|
-
import { z as z2 } from "zod";
|
|
238
|
-
var DeviceSchema = z2.looseObject({
|
|
239
|
-
id: z2.number(),
|
|
240
|
-
name: z2.string(),
|
|
241
|
-
type: z2.string().optional(),
|
|
242
|
-
addonId: z2.string().optional(),
|
|
243
|
-
location: z2.string().nullable().optional(),
|
|
244
|
-
disabled: z2.boolean().optional(),
|
|
245
|
-
online: z2.boolean().optional(),
|
|
246
|
-
isCamera: z2.boolean().optional(),
|
|
247
|
-
features: z2.array(z2.string()).optional()
|
|
248
|
-
});
|
|
249
|
-
var NullableDeviceSchema = DeviceSchema.nullable();
|
|
250
|
-
var DeviceListSchema = z2.array(DeviceSchema);
|
|
251
|
-
var BindingsSchema = z2.looseObject({
|
|
252
|
-
entries: z2.array(
|
|
253
|
-
z2.looseObject({
|
|
254
|
-
capName: z2.string(),
|
|
255
|
-
kind: z2.string(),
|
|
256
|
-
providerAddonId: z2.string(),
|
|
257
|
-
providerNodeId: z2.string()
|
|
258
|
-
})
|
|
259
|
-
)
|
|
260
|
-
});
|
|
261
|
-
var StatusAggregateSchema = z2.record(z2.string(), z2.unknown());
|
|
262
|
-
var DegradationSchema = z2.looseObject({
|
|
263
|
-
stage: z2.string(),
|
|
264
|
-
reason: z2.string(),
|
|
265
|
-
elapsedMs: z2.number().optional()
|
|
266
|
-
});
|
|
267
|
-
var CameraStatusSchema = z2.looseObject({
|
|
268
|
-
deviceId: z2.number(),
|
|
269
|
-
assignment: z2.looseObject({
|
|
270
|
-
detectionNodeId: z2.string().nullable(),
|
|
271
|
-
decoderNodeId: z2.string().nullable(),
|
|
272
|
-
sourceNodeId: z2.string().nullable(),
|
|
273
|
-
/** Why a stage sits where it does; `armed:on-motion` means idle by design. */
|
|
274
|
-
reasons: z2.record(z2.string(), z2.string().optional()).optional()
|
|
275
|
-
}),
|
|
276
|
-
broker: z2.looseObject({
|
|
277
|
-
profiles: z2.array(
|
|
278
|
-
z2.looseObject({
|
|
279
|
-
profile: z2.string(),
|
|
280
|
-
status: z2.string(),
|
|
281
|
-
subscribers: z2.number()
|
|
282
|
-
})
|
|
283
|
-
)
|
|
284
|
-
}).nullable(),
|
|
285
|
-
detection: z2.looseObject({
|
|
286
|
-
nodeId: z2.string(),
|
|
287
|
-
phase: z2.string(),
|
|
288
|
-
actualFps: z2.number(),
|
|
289
|
-
configuredFps: z2.number(),
|
|
290
|
-
provisioning: z2.looseObject({
|
|
291
|
-
state: z2.string(),
|
|
292
|
-
error: z2.string().optional()
|
|
293
|
-
})
|
|
294
|
-
}).nullable(),
|
|
295
|
-
recording: z2.looseObject({
|
|
296
|
-
mode: z2.string(),
|
|
297
|
-
active: z2.boolean(),
|
|
298
|
-
storageBytes: z2.number()
|
|
299
|
-
}).nullable(),
|
|
300
|
-
switchedOff: z2.array(z2.string()),
|
|
301
|
-
degraded: z2.array(DegradationSchema),
|
|
302
|
-
fetchedAt: z2.number()
|
|
303
|
-
});
|
|
304
|
-
var CameraStatusListSchema = z2.array(CameraStatusSchema);
|
|
305
|
-
var ObjectEventSchema = z2.looseObject({
|
|
306
|
-
id: z2.number(),
|
|
307
|
-
timestamp: z2.number(),
|
|
308
|
-
className: z2.string().optional(),
|
|
309
|
-
trackId: z2.string().optional()
|
|
310
|
-
});
|
|
311
|
-
var ObjectEventsSchema = z2.array(ObjectEventSchema);
|
|
312
|
-
var BINDING_LIMIT = 40;
|
|
313
|
-
var EVENT_LIMIT = 5;
|
|
314
|
-
var UNHEALTHY_LIMIT = 50;
|
|
315
|
-
function freshness(aggregate, now) {
|
|
316
|
-
const fetched = [];
|
|
317
|
-
let changed = null;
|
|
318
|
-
for (const [capName, value] of Object.entries(aggregate)) {
|
|
319
|
-
const parsed = z2.looseObject({
|
|
320
|
-
lastFetchedAt: z2.number().optional(),
|
|
321
|
-
lastChangedAt: z2.number().optional()
|
|
322
|
-
}).safeParse(value);
|
|
323
|
-
if (!parsed.success) continue;
|
|
324
|
-
const at = parsed.data.lastFetchedAt;
|
|
325
|
-
if (at !== void 0 && at > 0) fetched.push(at);
|
|
326
|
-
if (capName === "device-status" && parsed.data.lastChangedAt !== void 0) {
|
|
327
|
-
changed = parsed.data.lastChangedAt;
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
return {
|
|
331
|
-
newestFetchAgeMs: fetched.length === 0 ? null : Math.max(0, now - Math.max(...fetched)),
|
|
332
|
-
oldestFetchAgeMs: fetched.length === 0 ? null : Math.max(0, now - Math.min(...fetched)),
|
|
333
|
-
statusChangedAgeMs: changed === null || changed <= 0 ? null : now - changed
|
|
334
|
-
};
|
|
335
|
-
}
|
|
336
|
-
function pipelineOf(status) {
|
|
337
|
-
return {
|
|
338
|
-
detectionNodeId: status.assignment.detectionNodeId,
|
|
339
|
-
decoderNodeId: status.assignment.decoderNodeId,
|
|
340
|
-
sourceNodeId: status.assignment.sourceNodeId,
|
|
341
|
-
detection: status.detection ? {
|
|
342
|
-
phase: status.detection.phase,
|
|
343
|
-
actualFps: status.detection.actualFps,
|
|
344
|
-
provisioning: status.detection.provisioning.state
|
|
345
|
-
} : null,
|
|
346
|
-
brokerProfiles: status.broker ? status.broker.profiles.map((p) => ({
|
|
347
|
-
profile: p.profile,
|
|
348
|
-
status: p.status
|
|
349
|
-
})) : null
|
|
350
|
-
};
|
|
351
|
-
}
|
|
352
|
-
function whyOff(status, deviceDisabled) {
|
|
353
|
-
const out = [];
|
|
354
|
-
if (deviceDisabled)
|
|
355
|
-
out.push({
|
|
356
|
-
function: "device",
|
|
357
|
-
reason: "the device is disabled by an operator"
|
|
358
|
-
});
|
|
359
|
-
for (const id of status.switchedOff) {
|
|
360
|
-
out.push({
|
|
361
|
-
function: id,
|
|
362
|
-
reason: "switched off by an operator (not a fault)"
|
|
363
|
-
});
|
|
364
|
-
}
|
|
365
|
-
return out;
|
|
366
|
-
}
|
|
367
|
-
async function opsDevice(ctx, deviceId, now = Date.now()) {
|
|
368
|
-
return withHub(ctx, async ({ client }) => {
|
|
369
|
-
const unavailable = {};
|
|
370
|
-
const [deviceRead, bindingsRead, aggregateRead] = await Promise.all([
|
|
371
|
-
readQuery(client, "deviceManager.getDevice", { deviceId }, NullableDeviceSchema),
|
|
372
|
-
readQuery(client, "deviceManager.getBindings", { deviceId }, BindingsSchema),
|
|
373
|
-
readQuery(
|
|
374
|
-
client,
|
|
375
|
-
"deviceManager.getDeviceStatusAggregate",
|
|
376
|
-
{ deviceId },
|
|
377
|
-
StatusAggregateSchema
|
|
378
|
-
)
|
|
379
|
-
]);
|
|
380
|
-
if (deviceRead.ok && deviceRead.value === null) {
|
|
381
|
-
return {
|
|
382
|
-
status: "ok",
|
|
383
|
-
deviceId,
|
|
384
|
-
found: false,
|
|
385
|
-
whyOff: [],
|
|
386
|
-
unknownStages: [],
|
|
387
|
-
unavailable
|
|
388
|
-
};
|
|
389
|
-
}
|
|
390
|
-
if (!deviceRead.ok) unavailable["device"] = deviceRead.reason;
|
|
391
|
-
const device = deviceRead.ok ? deviceRead.value : null;
|
|
392
|
-
let bindings;
|
|
393
|
-
let nodes;
|
|
394
|
-
if (bindingsRead.ok) {
|
|
395
|
-
const names = [...new Set(bindingsRead.value.entries.map((e) => e.capName))];
|
|
396
|
-
const capped = cap(names, BINDING_LIMIT);
|
|
397
|
-
bindings = {
|
|
398
|
-
count: names.length,
|
|
399
|
-
caps: capped.items,
|
|
400
|
-
truncated: capped.truncated
|
|
401
|
-
};
|
|
402
|
-
nodes = [...new Set(bindingsRead.value.entries.map((e) => e.providerNodeId))];
|
|
403
|
-
} else {
|
|
404
|
-
unavailable["bindings"] = bindingsRead.reason;
|
|
405
|
-
}
|
|
406
|
-
let runtimeState;
|
|
407
|
-
if (aggregateRead.ok) runtimeState = freshness(aggregateRead.value, now);
|
|
408
|
-
else unavailable["runtimeState"] = aggregateRead.reason;
|
|
409
|
-
let pipeline;
|
|
410
|
-
let recording;
|
|
411
|
-
let switchedOff;
|
|
412
|
-
let switchedOffKnown;
|
|
413
|
-
let why = device?.disabled === true ? [
|
|
414
|
-
{
|
|
415
|
-
function: "device",
|
|
416
|
-
reason: "the device is disabled by an operator"
|
|
417
|
-
}
|
|
418
|
-
] : [];
|
|
419
|
-
let unknownStages = [];
|
|
420
|
-
let recentEvents;
|
|
421
|
-
if (device?.isCamera === true) {
|
|
422
|
-
const [statusRead, eventsRead] = await Promise.all([
|
|
423
|
-
readQuery(client, "pipelineOrchestrator.getCameraStatus", { deviceId }, CameraStatusSchema),
|
|
424
|
-
readQuery(
|
|
425
|
-
client,
|
|
426
|
-
"pipelineAnalytics.getObjectEvents",
|
|
427
|
-
{ deviceId, limit: EVENT_LIMIT, projection: "slim" },
|
|
428
|
-
ObjectEventsSchema
|
|
429
|
-
)
|
|
430
|
-
]);
|
|
431
|
-
if (statusRead.ok) {
|
|
432
|
-
const status = statusRead.value;
|
|
433
|
-
pipeline = pipelineOf(status);
|
|
434
|
-
recording = status.recording ? { mode: status.recording.mode, active: status.recording.active } : null;
|
|
435
|
-
switchedOff = status.switchedOff;
|
|
436
|
-
switchedOffKnown = !status.degraded.some((d) => d.stage === "switches");
|
|
437
|
-
why = whyOff(status, device.disabled === true);
|
|
438
|
-
unknownStages = status.degraded.map((d) => ({
|
|
439
|
-
stage: d.stage,
|
|
440
|
-
reason: d.reason
|
|
441
|
-
}));
|
|
442
|
-
} else {
|
|
443
|
-
unavailable["cameraStatus"] = statusRead.reason;
|
|
444
|
-
}
|
|
445
|
-
if (eventsRead.ok) {
|
|
446
|
-
recentEvents = eventsRead.value.map((e) => ({
|
|
447
|
-
id: e.id,
|
|
448
|
-
timestamp: e.timestamp,
|
|
449
|
-
className: e.className ?? null,
|
|
450
|
-
trackId: e.trackId ?? null
|
|
451
|
-
}));
|
|
452
|
-
} else {
|
|
453
|
-
unavailable["recentEvents"] = eventsRead.reason;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
return {
|
|
457
|
-
status: "ok",
|
|
458
|
-
deviceId,
|
|
459
|
-
found: device === null ? null : true,
|
|
460
|
-
...device ? {
|
|
461
|
-
device: {
|
|
462
|
-
name: device.name,
|
|
463
|
-
type: device.type ?? null,
|
|
464
|
-
addonId: device.addonId ?? null,
|
|
465
|
-
location: device.location ?? null,
|
|
466
|
-
online: device.online ?? null,
|
|
467
|
-
disabled: device.disabled === true
|
|
468
|
-
}
|
|
469
|
-
} : {},
|
|
470
|
-
...nodes ? { nodes } : {},
|
|
471
|
-
...bindings ? { bindings } : {},
|
|
472
|
-
...runtimeState ? { runtimeState } : {},
|
|
473
|
-
...pipeline !== void 0 ? { pipeline } : {},
|
|
474
|
-
...recording !== void 0 ? { recording } : {},
|
|
475
|
-
...switchedOff ? { switchedOff } : {},
|
|
476
|
-
...switchedOffKnown !== void 0 ? { switchedOffKnown } : {},
|
|
477
|
-
whyOff: why,
|
|
478
|
-
unknownStages,
|
|
479
|
-
...recentEvents ? { recentEvents } : {},
|
|
480
|
-
unavailable
|
|
481
|
-
};
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
function cameraReasons(status) {
|
|
485
|
-
const out = [];
|
|
486
|
-
const readable = (stage) => !status.degraded.some((d) => d.stage === stage);
|
|
487
|
-
const off = (id) => status.switchedOff.includes(id);
|
|
488
|
-
if (status.detection?.provisioning.state === "failed" && !off("object-detection")) {
|
|
489
|
-
out.push({
|
|
490
|
-
code: "detection-provisioning-failed",
|
|
491
|
-
detail: status.detection.provisioning.error ?? "detection provisioning failed"
|
|
492
|
-
});
|
|
493
|
-
}
|
|
494
|
-
if (status.assignment.detectionNodeId === null && !(status.assignment.reasons?.["detection"] ?? "").startsWith("armed:") && !off("object-detection") && readable("detection")) {
|
|
495
|
-
out.push({
|
|
496
|
-
code: "detection-not-assigned",
|
|
497
|
-
detail: "no node runs detection for this camera and it is not switched off"
|
|
498
|
-
});
|
|
499
|
-
}
|
|
500
|
-
if (status.recording !== null && status.recording.mode === "continuous" && !status.recording.active && !off("recording") && readable("recording")) {
|
|
501
|
-
out.push({
|
|
502
|
-
code: "recording-inactive",
|
|
503
|
-
detail: "continuous recording is configured but not recording and not switched off"
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
return out;
|
|
507
|
-
}
|
|
508
|
-
function readCameraStatuses(client) {
|
|
509
|
-
return readQuery(client, "pipelineOrchestrator.getCameraStatuses", {}, CameraStatusListSchema);
|
|
510
|
-
}
|
|
511
|
-
async function opsUnhealthyDevices(ctx) {
|
|
512
|
-
return withHub(ctx, async ({ client }) => {
|
|
513
|
-
const unavailable = {};
|
|
514
|
-
const [devicesRead, statusesRead] = await Promise.all([
|
|
515
|
-
readQuery(client, "deviceManager.listAll", { projection: "slim" }, DeviceListSchema),
|
|
516
|
-
readCameraStatuses(client)
|
|
517
|
-
]);
|
|
518
|
-
if (!devicesRead.ok) {
|
|
519
|
-
return {
|
|
520
|
-
status: "ok",
|
|
521
|
-
checked: 0,
|
|
522
|
-
unhealthy: [],
|
|
523
|
-
truncated: 0,
|
|
524
|
-
unknown: [],
|
|
525
|
-
unavailable: { devices: devicesRead.reason }
|
|
526
|
-
};
|
|
527
|
-
}
|
|
528
|
-
if (!statusesRead.ok) unavailable["cameraStatuses"] = statusesRead.reason;
|
|
529
|
-
const statuses = new Map(
|
|
530
|
-
statusesRead.ok ? statusesRead.value.map((s) => [s.deviceId, s]) : []
|
|
531
|
-
);
|
|
532
|
-
const unhealthy = [];
|
|
533
|
-
const unknown = [];
|
|
534
|
-
for (const device of devicesRead.value) {
|
|
535
|
-
if (device.disabled === true) continue;
|
|
536
|
-
const reasons = [];
|
|
537
|
-
if (device.online === false) {
|
|
538
|
-
reasons.push({
|
|
539
|
-
code: "offline",
|
|
540
|
-
detail: "the provider reports the device offline"
|
|
541
|
-
});
|
|
542
|
-
}
|
|
543
|
-
const status = statuses.get(device.id);
|
|
544
|
-
if (status && device.online !== false) {
|
|
545
|
-
reasons.push(...cameraReasons(status));
|
|
546
|
-
if (status.degraded.length > 0) {
|
|
547
|
-
unknown.push({
|
|
548
|
-
deviceId: device.id,
|
|
549
|
-
name: device.name,
|
|
550
|
-
stages: status.degraded.map((d) => ({
|
|
551
|
-
stage: d.stage,
|
|
552
|
-
reason: d.reason
|
|
553
|
-
}))
|
|
554
|
-
});
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
if (reasons.length > 0) unhealthy.push({ deviceId: device.id, name: device.name, reasons });
|
|
558
|
-
}
|
|
559
|
-
const capped = cap(unhealthy, UNHEALTHY_LIMIT);
|
|
560
|
-
return {
|
|
561
|
-
status: "ok",
|
|
562
|
-
checked: devicesRead.value.filter((d) => d.disabled !== true).length,
|
|
563
|
-
unhealthy: capped.items,
|
|
564
|
-
truncated: capped.truncated,
|
|
565
|
-
unknown: unknown.slice(0, UNHEALTHY_LIMIT),
|
|
566
|
-
unavailable
|
|
567
|
-
};
|
|
568
|
-
});
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
// src/mcp/ops-events.ts
|
|
572
|
-
import {
|
|
573
|
-
TrackSchema,
|
|
574
|
-
analyzeTrack
|
|
575
|
-
} from "@camstack/develop";
|
|
576
|
-
import { z as z3 } from "zod";
|
|
577
|
-
var AddressesSchema = z3.looseObject({
|
|
578
|
-
roles: z3.looseObject({
|
|
579
|
-
appConnections: z3.looseObject({ resolved: z3.array(z3.string()) })
|
|
580
|
-
})
|
|
581
|
-
});
|
|
582
|
-
var LOOPBACK = /^(localhost|127\.|\[?::1\]?$|0\.0\.0\.0)/i;
|
|
583
|
-
function isLoopbackUrl(url) {
|
|
584
|
-
try {
|
|
585
|
-
return LOOPBACK.test(new URL(url).hostname);
|
|
586
|
-
} catch {
|
|
587
|
-
return true;
|
|
588
|
-
}
|
|
589
|
-
}
|
|
590
|
-
async function resolveLinkBase(client) {
|
|
591
|
-
const read = await readQuery(client, "localNetwork.getAddresses", void 0, AddressesSchema);
|
|
592
|
-
if (!read.ok) return { base: null, reason: read.reason };
|
|
593
|
-
const usable = read.value.roles.appConnections.resolved.find((u) => !isLoopbackUrl(u));
|
|
594
|
-
if (usable === void 0) {
|
|
595
|
-
return {
|
|
596
|
-
base: null,
|
|
597
|
-
reason: "no non-loopback address is flagged for app connections (Network page)"
|
|
598
|
-
};
|
|
599
|
-
}
|
|
600
|
-
return { base: usable.replace(/\/+$/, "") };
|
|
601
|
-
}
|
|
602
|
-
function linksFor(link, deviceId, atMs) {
|
|
603
|
-
if (link.base === null) return { admin: null, viewer: null };
|
|
604
|
-
return {
|
|
605
|
-
admin: `${link.base}/devices/${String(deviceId)}`,
|
|
606
|
-
viewer: buildViewerLink({
|
|
607
|
-
base: { form: "hub", hubBaseUrl: link.base },
|
|
608
|
-
destination: {
|
|
609
|
-
kind: "camera",
|
|
610
|
-
deviceId,
|
|
611
|
-
...atMs !== void 0 ? { atMs } : {}
|
|
612
|
-
}
|
|
613
|
-
})
|
|
614
|
-
};
|
|
615
|
-
}
|
|
616
|
-
var DeviceNameSchema = z3.array(
|
|
617
|
-
z3.looseObject({
|
|
618
|
-
id: z3.number(),
|
|
619
|
-
name: z3.string(),
|
|
620
|
-
disabled: z3.boolean().optional()
|
|
621
|
-
})
|
|
622
|
-
);
|
|
623
|
-
var SlimTrackSchema = z3.looseObject({
|
|
624
|
-
trackId: z3.string(),
|
|
625
|
-
deviceId: z3.number(),
|
|
626
|
-
className: z3.string(),
|
|
627
|
-
firstSeen: z3.number(),
|
|
628
|
-
lastSeen: z3.number(),
|
|
629
|
-
state: z3.string().optional(),
|
|
630
|
-
active: z3.boolean().optional(),
|
|
631
|
-
classes: z3.array(z3.string()).optional(),
|
|
632
|
-
zonesVisited: z3.array(z3.string()).optional(),
|
|
633
|
-
bestEventId: z3.string().optional()
|
|
634
|
-
});
|
|
635
|
-
var TracksPageSchema = z3.looseObject({
|
|
636
|
-
tracks: z3.array(SlimTrackSchema),
|
|
637
|
-
nextCursor: z3.string().nullable().optional()
|
|
638
|
-
});
|
|
639
|
-
var ObjectEventsSchema2 = z3.array(
|
|
640
|
-
z3.looseObject({
|
|
641
|
-
id: z3.number(),
|
|
642
|
-
timestamp: z3.number(),
|
|
643
|
-
className: z3.string().optional(),
|
|
644
|
-
trackId: z3.string().optional()
|
|
645
|
-
})
|
|
646
|
-
);
|
|
647
|
-
var DEFAULT_EVENT_WINDOW_MINUTES = 60;
|
|
648
|
-
var MAX_EVENT_WINDOW_MINUTES = 7 * 24 * 60;
|
|
649
|
-
var DEFAULT_RESULT_LIMIT = 20;
|
|
650
|
-
var MAX_RESULT_LIMIT = 50;
|
|
651
|
-
var FETCH_TRACKS = 200;
|
|
652
|
-
var matches = (haystack, needle) => {
|
|
653
|
-
const n = needle.toLowerCase();
|
|
654
|
-
return haystack.some((h) => h.toLowerCase().includes(n));
|
|
655
|
-
};
|
|
656
|
-
function trackMatches(track, deviceName, args) {
|
|
657
|
-
const zones = track.zonesVisited ?? [];
|
|
658
|
-
if (args.zone !== void 0 && args.zone !== "" && !matches(zones, args.zone)) return false;
|
|
659
|
-
if (args.text !== void 0 && args.text !== "") {
|
|
660
|
-
const fields = [
|
|
661
|
-
track.trackId,
|
|
662
|
-
track.className,
|
|
663
|
-
...track.classes ?? [],
|
|
664
|
-
...zones,
|
|
665
|
-
...deviceName ? [deviceName] : []
|
|
666
|
-
];
|
|
667
|
-
if (!matches(fields, args.text)) return false;
|
|
668
|
-
}
|
|
669
|
-
return true;
|
|
670
|
-
}
|
|
671
|
-
async function opsEvents(ctx, args, now = Date.now()) {
|
|
672
|
-
return withHub(ctx, async ({ client }) => {
|
|
673
|
-
const unavailable = {};
|
|
674
|
-
const windowMinutes = Math.min(
|
|
675
|
-
args.windowMinutes ?? DEFAULT_EVENT_WINDOW_MINUTES,
|
|
676
|
-
MAX_EVENT_WINDOW_MINUTES
|
|
677
|
-
);
|
|
678
|
-
const limit = Math.min(args.limit ?? DEFAULT_RESULT_LIMIT, MAX_RESULT_LIMIT);
|
|
679
|
-
const since = now - windowMinutes * 6e4;
|
|
680
|
-
const window = { sinceMs: since, untilMs: now };
|
|
681
|
-
const [namesRead, link] = await Promise.all([
|
|
682
|
-
readQuery(
|
|
683
|
-
client,
|
|
684
|
-
"deviceManager.listAll",
|
|
685
|
-
{ projection: "slim", isCamera: true },
|
|
686
|
-
DeviceNameSchema
|
|
687
|
-
),
|
|
688
|
-
resolveLinkBase(client)
|
|
689
|
-
]);
|
|
690
|
-
const names = /* @__PURE__ */ new Map();
|
|
691
|
-
if (namesRead.ok) for (const d of namesRead.value) names.set(d.id, d.name);
|
|
692
|
-
else unavailable["cameras"] = namesRead.reason;
|
|
693
|
-
const deviceIds = args.deviceId !== void 0 ? [args.deviceId] : namesRead.ok ? namesRead.value.filter((d) => d.disabled !== true).map((d) => d.id) : [];
|
|
694
|
-
if (deviceIds.length === 0 && args.deviceId === void 0) {
|
|
695
|
-
return {
|
|
696
|
-
status: "ok",
|
|
697
|
-
window,
|
|
698
|
-
tracks: [],
|
|
699
|
-
tracksTruncated: 0,
|
|
700
|
-
moreTracksAvailable: null,
|
|
701
|
-
unavailable
|
|
702
|
-
};
|
|
703
|
-
}
|
|
704
|
-
const tracksRead = await readQuery(
|
|
705
|
-
client,
|
|
706
|
-
"pipelineAnalytics.listRecentTracks",
|
|
707
|
-
{
|
|
708
|
-
deviceIds,
|
|
709
|
-
since,
|
|
710
|
-
until: now,
|
|
711
|
-
limit: FETCH_TRACKS,
|
|
712
|
-
projection: "slim",
|
|
713
|
-
...args.className ? { classes: [args.className] } : {}
|
|
714
|
-
},
|
|
715
|
-
TracksPageSchema
|
|
716
|
-
);
|
|
717
|
-
let tracks = [];
|
|
718
|
-
let tracksTruncated = 0;
|
|
719
|
-
let more = null;
|
|
720
|
-
if (tracksRead.ok) {
|
|
721
|
-
more = tracksRead.value.nextCursor !== null && tracksRead.value.nextCursor !== void 0;
|
|
722
|
-
const hits = tracksRead.value.tracks.filter((t) => args.className ? t.className === args.className : true).filter((t) => trackMatches(t, names.get(t.deviceId) ?? null, args)).map(
|
|
723
|
-
(t) => ({
|
|
724
|
-
deviceId: t.deviceId,
|
|
725
|
-
deviceName: names.get(t.deviceId) ?? null,
|
|
726
|
-
trackId: t.trackId,
|
|
727
|
-
className: t.className,
|
|
728
|
-
firstSeen: t.firstSeen,
|
|
729
|
-
lastSeen: t.lastSeen,
|
|
730
|
-
durationMs: t.lastSeen - t.firstSeen,
|
|
731
|
-
state: t.state ?? null,
|
|
732
|
-
active: t.active ?? null,
|
|
733
|
-
zonesVisited: t.zonesVisited ?? [],
|
|
734
|
-
links: linksFor(link, t.deviceId, t.firstSeen)
|
|
735
|
-
})
|
|
736
|
-
);
|
|
737
|
-
const capped = cap(hits, limit);
|
|
738
|
-
tracks = capped.items;
|
|
739
|
-
tracksTruncated = capped.truncated;
|
|
740
|
-
} else {
|
|
741
|
-
unavailable["tracks"] = tracksRead.reason;
|
|
742
|
-
}
|
|
743
|
-
let events;
|
|
744
|
-
if (args.deviceId !== void 0) {
|
|
745
|
-
const eventsRead = await readQuery(
|
|
746
|
-
client,
|
|
747
|
-
"pipelineAnalytics.getObjectEvents",
|
|
748
|
-
{
|
|
749
|
-
deviceId: args.deviceId,
|
|
750
|
-
since,
|
|
751
|
-
until: now,
|
|
752
|
-
limit,
|
|
753
|
-
projection: "slim",
|
|
754
|
-
...args.className ? { classFilter: args.className } : {}
|
|
755
|
-
},
|
|
756
|
-
ObjectEventsSchema2
|
|
757
|
-
);
|
|
758
|
-
if (eventsRead.ok) {
|
|
759
|
-
events = eventsRead.value.filter(
|
|
760
|
-
(e) => args.text ? matches([e.trackId ?? "", e.className ?? ""], args.text) : true
|
|
761
|
-
).map(
|
|
762
|
-
(e) => ({
|
|
763
|
-
id: e.id,
|
|
764
|
-
timestamp: e.timestamp,
|
|
765
|
-
className: e.className ?? null,
|
|
766
|
-
trackId: e.trackId ?? null,
|
|
767
|
-
links: linksFor(link, args.deviceId ?? 0, e.timestamp)
|
|
768
|
-
})
|
|
769
|
-
);
|
|
770
|
-
} else {
|
|
771
|
-
unavailable["events"] = eventsRead.reason;
|
|
772
|
-
}
|
|
773
|
-
}
|
|
774
|
-
return {
|
|
775
|
-
status: "ok",
|
|
776
|
-
window,
|
|
777
|
-
tracks,
|
|
778
|
-
tracksTruncated,
|
|
779
|
-
moreTracksAvailable: more,
|
|
780
|
-
...events ? { events } : {},
|
|
781
|
-
...link.base === null && link.reason ? { linksUnavailable: link.reason } : {},
|
|
782
|
-
unavailable
|
|
783
|
-
};
|
|
784
|
-
});
|
|
785
|
-
}
|
|
786
|
-
var TrackExtrasSchema = z3.looseObject({
|
|
787
|
-
bestEventId: z3.string().optional(),
|
|
788
|
-
hasFace: z3.boolean().optional(),
|
|
789
|
-
importance: z3.number().optional(),
|
|
790
|
-
previewMissReason: z3.string().optional()
|
|
791
|
-
});
|
|
792
|
-
var MediaListSchema = z3.array(
|
|
793
|
-
z3.looseObject({
|
|
794
|
-
mediaId: z3.number(),
|
|
795
|
-
kind: z3.string(),
|
|
796
|
-
sizeBytes: z3.number(),
|
|
797
|
-
timestamp: z3.number()
|
|
798
|
-
})
|
|
799
|
-
);
|
|
800
|
-
var EXPECTED_MEDIA_KINDS = ["keyFrame", "firstFrame", "lastFrame", "thumbnail"];
|
|
801
|
-
var FINDING_LIMIT = 15;
|
|
802
|
-
function auditMedia(track, extras, media) {
|
|
803
|
-
const kinds = {};
|
|
804
|
-
for (const m of media) kinds[m.kind] = (kinds[m.kind] ?? 0) + 1;
|
|
805
|
-
const has = (kind) => (kinds[kind] ?? 0) > 0;
|
|
806
|
-
const closed = track.active !== true;
|
|
807
|
-
const missing = [];
|
|
808
|
-
if (closed) {
|
|
809
|
-
for (const kind of EXPECTED_MEDIA_KINDS) {
|
|
810
|
-
if (has(kind)) continue;
|
|
811
|
-
let reason;
|
|
812
|
-
if (kind === "keyFrame") {
|
|
813
|
-
reason = extras.previewMissReason ?? "no reason recorded on the track (it predates the record, or the capture was never attempted)";
|
|
814
|
-
} else if (!has("keyFrame") && (kind === "lastFrame" || kind === "thumbnail")) {
|
|
815
|
-
reason = `follows the missing keyFrame: ${extras.previewMissReason ?? "cause not recorded"}`;
|
|
816
|
-
} else {
|
|
817
|
-
reason = "no reason recorded for this kind";
|
|
818
|
-
}
|
|
819
|
-
missing.push({ kind, reason });
|
|
820
|
-
}
|
|
821
|
-
}
|
|
822
|
-
const findings = analyzeTrack({
|
|
823
|
-
track,
|
|
824
|
-
siblings: [],
|
|
825
|
-
media,
|
|
826
|
-
ffEqualsLf: null,
|
|
827
|
-
enrichment: { face: null, plate: null }
|
|
828
|
-
});
|
|
829
|
-
const cappedFindings = cap(findings, FINDING_LIMIT);
|
|
830
|
-
const at = (kind) => media.find((m) => m.kind === kind)?.timestamp ?? null;
|
|
831
|
-
return {
|
|
832
|
-
kinds,
|
|
833
|
-
bestShot: {
|
|
834
|
-
eventId: extras.bestEventId ?? null,
|
|
835
|
-
thumbnailAt: at("thumbnail"),
|
|
836
|
-
keyFrameAt: at("keyFrame")
|
|
837
|
-
},
|
|
838
|
-
keyFrames: {
|
|
839
|
-
keyFrame: has("keyFrame"),
|
|
840
|
-
keyFrameSmall: has("keyFrameSmall")
|
|
841
|
-
},
|
|
842
|
-
missing,
|
|
843
|
-
findings: cappedFindings.items.map((f) => ({
|
|
844
|
-
code: f.code,
|
|
845
|
-
detail: f.detail
|
|
846
|
-
})),
|
|
847
|
-
findingsTruncated: cappedFindings.truncated
|
|
848
|
-
};
|
|
849
|
-
}
|
|
850
|
-
async function opsTrack(ctx, args) {
|
|
851
|
-
return withHub(ctx, async ({ client }) => {
|
|
852
|
-
const unavailable = {};
|
|
853
|
-
const [trackRead, mediaRead, link] = await Promise.all([
|
|
854
|
-
readQuery(
|
|
855
|
-
client,
|
|
856
|
-
"pipelineAnalytics.getTrack",
|
|
857
|
-
{ deviceId: args.deviceId, trackId: args.trackId },
|
|
858
|
-
z3.unknown()
|
|
859
|
-
),
|
|
860
|
-
readQuery(
|
|
861
|
-
client,
|
|
862
|
-
"pipelineAnalytics.listTrackMedia",
|
|
863
|
-
{ deviceId: args.deviceId, trackId: args.trackId },
|
|
864
|
-
MediaListSchema
|
|
865
|
-
),
|
|
866
|
-
resolveLinkBase(client)
|
|
867
|
-
]);
|
|
868
|
-
const base = {
|
|
869
|
-
status: "ok",
|
|
870
|
-
deviceId: args.deviceId,
|
|
871
|
-
trackId: args.trackId
|
|
872
|
-
};
|
|
873
|
-
if (!trackRead.ok) {
|
|
874
|
-
unavailable["track"] = trackRead.reason;
|
|
875
|
-
return { ...base, found: null, unavailable };
|
|
876
|
-
}
|
|
877
|
-
if (trackRead.value === null) return { ...base, found: false, unavailable };
|
|
878
|
-
const parsed = TrackSchema.safeParse(trackRead.value);
|
|
879
|
-
const extras = TrackExtrasSchema.safeParse(trackRead.value);
|
|
880
|
-
if (!parsed.success || !extras.success) {
|
|
881
|
-
unavailable["track"] = "pipelineAnalytics.getTrack: unexpected answer shape";
|
|
882
|
-
return { ...base, found: null, unavailable };
|
|
883
|
-
}
|
|
884
|
-
const track = parsed.data;
|
|
885
|
-
let media = null;
|
|
886
|
-
if (mediaRead.ok) media = auditMedia(track, extras.data, mediaRead.value);
|
|
887
|
-
else unavailable["media"] = mediaRead.reason;
|
|
888
|
-
return {
|
|
889
|
-
...base,
|
|
890
|
-
found: true,
|
|
891
|
-
track: {
|
|
892
|
-
className: track.className,
|
|
893
|
-
state: track.state ?? null,
|
|
894
|
-
active: track.active ?? null,
|
|
895
|
-
firstSeen: track.firstSeen,
|
|
896
|
-
lastSeen: track.lastSeen,
|
|
897
|
-
durationMs: track.lastSeen - track.firstSeen,
|
|
898
|
-
hasFace: extras.data.hasFace ?? null,
|
|
899
|
-
importance: extras.data.importance ?? null
|
|
900
|
-
},
|
|
901
|
-
links: linksFor(link, args.deviceId, track.firstSeen),
|
|
902
|
-
media,
|
|
903
|
-
...link.base === null && link.reason ? { linksUnavailable: link.reason } : {},
|
|
904
|
-
unavailable
|
|
905
|
-
};
|
|
906
|
-
});
|
|
907
|
-
}
|
|
908
|
-
|
|
909
|
-
// src/mcp/ops-logs.ts
|
|
910
|
-
import { z as z4 } from "zod";
|
|
911
|
-
var LevelSchema = z4.enum(["debug", "info", "warn", "error"]);
|
|
912
|
-
var LogEntrySchema = z4.looseObject({
|
|
913
|
-
timestamp: z4.string(),
|
|
914
|
-
level: z4.string(),
|
|
915
|
-
scope: z4.string().optional(),
|
|
916
|
-
message: z4.string(),
|
|
917
|
-
tags: z4.record(z4.string(), z4.string().optional()).optional(),
|
|
918
|
-
meta: z4.record(z4.string(), z4.unknown()).optional()
|
|
919
|
-
});
|
|
920
|
-
var LogPageSchema = z4.looseObject({
|
|
921
|
-
entries: z4.array(LogEntrySchema),
|
|
922
|
-
done: z4.boolean(),
|
|
923
|
-
source: z4.string()
|
|
924
|
-
});
|
|
925
|
-
var LogSourcesSchema = z4.array(
|
|
926
|
-
z4.looseObject({
|
|
927
|
-
addonId: z4.string(),
|
|
928
|
-
readable: z4.boolean(),
|
|
929
|
-
isDefault: z4.boolean()
|
|
930
|
-
})
|
|
931
|
-
);
|
|
932
|
-
var RING_SOURCE = "ring";
|
|
933
|
-
var DEFAULT_WINDOW_MINUTES = 15;
|
|
934
|
-
var MAX_WINDOW_MINUTES = 24 * 60;
|
|
935
|
-
var FETCH_LIMIT = 500;
|
|
936
|
-
var GROUP_LIMIT = 40;
|
|
937
|
-
var DEFAULT_CONTEXT_LINES = 10;
|
|
938
|
-
var CONTEXT_RADIUS_MS = 3e4;
|
|
939
|
-
var SEVERITY = ["error", "warn", "info", "debug"];
|
|
940
|
-
var severityRank = (level) => {
|
|
941
|
-
const i = SEVERITY.indexOf(level);
|
|
942
|
-
return i === -1 ? SEVERITY.length : i;
|
|
943
|
-
};
|
|
944
|
-
function messageKey(message) {
|
|
945
|
-
return message.replace(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi, "<id>").replace(/\b[0-9a-f]{12,}\b/gi, "<id>").replace(/\b(?=[a-z0-9]*\d)(?=[a-z0-9]*[a-z])[a-z0-9]{8,}\b/gi, "<id>").replace(/\d+/g, "#");
|
|
946
|
-
}
|
|
947
|
-
function groupEntries(entries) {
|
|
948
|
-
const groups = /* @__PURE__ */ new Map();
|
|
949
|
-
for (const e of entries) {
|
|
950
|
-
const key = `${e.level}|${e.scope ?? ""}|${e.tags?.["deviceId"] ?? ""}|${messageKey(e.message)}`;
|
|
951
|
-
const prior = groups.get(key);
|
|
952
|
-
if (prior) {
|
|
953
|
-
groups.set(key, {
|
|
954
|
-
...prior,
|
|
955
|
-
count: prior.count + 1,
|
|
956
|
-
firstAt: e.timestamp < prior.firstAt ? e.timestamp : prior.firstAt,
|
|
957
|
-
lastAt: e.timestamp > prior.lastAt ? e.timestamp : prior.lastAt
|
|
958
|
-
});
|
|
959
|
-
} else {
|
|
960
|
-
groups.set(key, {
|
|
961
|
-
count: 1,
|
|
962
|
-
level: e.level,
|
|
963
|
-
scope: e.scope ?? null,
|
|
964
|
-
message: e.message,
|
|
965
|
-
firstAt: e.timestamp,
|
|
966
|
-
lastAt: e.timestamp,
|
|
967
|
-
deviceId: e.tags?.["deviceId"] ?? null,
|
|
968
|
-
addonId: e.tags?.["addonId"] ?? null,
|
|
969
|
-
nodeId: e.tags?.["nodeId"] ?? null
|
|
970
|
-
});
|
|
971
|
-
}
|
|
972
|
-
}
|
|
973
|
-
return [...groups.values()].sort(
|
|
974
|
-
(a, b) => severityRank(a.level) - severityRank(b.level) || b.count - a.count
|
|
975
|
-
);
|
|
976
|
-
}
|
|
977
|
-
function tagsOf(args) {
|
|
978
|
-
return {
|
|
979
|
-
...args.deviceId !== void 0 ? { deviceId: String(args.deviceId) } : {},
|
|
980
|
-
...args.addonId ? { addonId: args.addonId } : {},
|
|
981
|
-
...args.nodeId ? { nodeId: args.nodeId } : {}
|
|
982
|
-
};
|
|
983
|
-
}
|
|
984
|
-
function pageInput(q) {
|
|
985
|
-
const tags = q.tags ?? tagsOf(q.args);
|
|
986
|
-
return {
|
|
987
|
-
source: q.source,
|
|
988
|
-
since: q.since,
|
|
989
|
-
until: q.until,
|
|
990
|
-
limit: q.limit,
|
|
991
|
-
...q.args.level ? { level: q.args.level } : {},
|
|
992
|
-
...Object.keys(tags).length > 0 ? { tags } : {},
|
|
993
|
-
...q.text ? { text: q.text } : {}
|
|
994
|
-
};
|
|
995
|
-
}
|
|
996
|
-
var toMs = (iso) => Date.parse(iso);
|
|
997
|
-
function ringCovers(entries, since, limit) {
|
|
998
|
-
if (entries.length >= limit) return false;
|
|
999
|
-
if (entries.length === 0) return false;
|
|
1000
|
-
const oldest = Math.min(...entries.map((e) => toMs(e.timestamp)));
|
|
1001
|
-
return oldest <= since;
|
|
1002
|
-
}
|
|
1003
|
-
var lineOf = (e) => ({
|
|
1004
|
-
at: e.timestamp,
|
|
1005
|
-
level: e.level,
|
|
1006
|
-
scope: e.scope ?? null,
|
|
1007
|
-
message: e.message,
|
|
1008
|
-
deviceId: e.tags?.["deviceId"] ?? null
|
|
1009
|
-
});
|
|
1010
|
-
async function opsLogs(ctx, args, now = Date.now()) {
|
|
1011
|
-
return withHub(ctx, async (connection) => {
|
|
1012
|
-
if (connection.tier !== "admin") {
|
|
1013
|
-
return {
|
|
1014
|
-
status: "refused",
|
|
1015
|
-
reason: `logs.query is admin-tier and this credential is not admin-scoped.${connection.adminHint ? ` ${connection.adminHint}` : ""}`
|
|
1016
|
-
};
|
|
1017
|
-
}
|
|
1018
|
-
const client = connection.client;
|
|
1019
|
-
const windowMinutes = Math.min(args.windowMinutes ?? DEFAULT_WINDOW_MINUTES, MAX_WINDOW_MINUTES);
|
|
1020
|
-
const until = now;
|
|
1021
|
-
const since = now - windowMinutes * 6e4;
|
|
1022
|
-
const unavailable = {};
|
|
1023
|
-
const tried = [];
|
|
1024
|
-
const fetchTier = async (source, q) => {
|
|
1025
|
-
tried.push(source === RING_SOURCE ? RING_SOURCE : `persisted:${source}`);
|
|
1026
|
-
const read = await readQuery(client, "logs.query", pageInput({ ...q, source }), LogPageSchema);
|
|
1027
|
-
if (!read.ok) {
|
|
1028
|
-
unavailable[source] = read.reason;
|
|
1029
|
-
return null;
|
|
1030
|
-
}
|
|
1031
|
-
return read.value;
|
|
1032
|
-
};
|
|
1033
|
-
const persistedSource = async () => {
|
|
1034
|
-
const sources = await readQuery(client, "logs.sources", void 0, LogSourcesSchema);
|
|
1035
|
-
if (!sources.ok) {
|
|
1036
|
-
unavailable["logs.sources"] = sources.reason;
|
|
1037
|
-
return null;
|
|
1038
|
-
}
|
|
1039
|
-
return sources.value.find((s) => s.isDefault && s.readable)?.addonId ?? null;
|
|
1040
|
-
};
|
|
1041
|
-
const query = async (q) => {
|
|
1042
|
-
const ring = await fetchTier(RING_SOURCE, q);
|
|
1043
|
-
if (ring && ringCovers(ring.entries, q.since, q.limit))
|
|
1044
|
-
return { tier: RING_SOURCE, page: ring };
|
|
1045
|
-
const persisted = await persistedSource();
|
|
1046
|
-
if (persisted !== null) {
|
|
1047
|
-
const page = await fetchTier(persisted, q);
|
|
1048
|
-
if (page) return { tier: `persisted:${persisted}`, page };
|
|
1049
|
-
}
|
|
1050
|
-
return ring ? { tier: RING_SOURCE, page: ring } : null;
|
|
1051
|
-
};
|
|
1052
|
-
const base = { since, until, args, limit: FETCH_LIMIT };
|
|
1053
|
-
if (args.around !== void 0 && args.around !== "") {
|
|
1054
|
-
const found = await query({ ...base, text: args.around, limit: 20 });
|
|
1055
|
-
if (found === null) {
|
|
1056
|
-
return {
|
|
1057
|
-
status: "ok",
|
|
1058
|
-
tier: "none",
|
|
1059
|
-
tiersTried: tried,
|
|
1060
|
-
window: { sinceMs: since, untilMs: until },
|
|
1061
|
-
scanned: 0,
|
|
1062
|
-
partial: false,
|
|
1063
|
-
unavailable
|
|
1064
|
-
};
|
|
1065
|
-
}
|
|
1066
|
-
const hit = [...found.page.entries].sort((a, b) => toMs(a.timestamp) - toMs(b.timestamp))[0];
|
|
1067
|
-
if (!hit) {
|
|
1068
|
-
return {
|
|
1069
|
-
status: "ok",
|
|
1070
|
-
tier: found.tier,
|
|
1071
|
-
tiersTried: tried,
|
|
1072
|
-
window: { sinceMs: since, untilMs: until },
|
|
1073
|
-
scanned: 0,
|
|
1074
|
-
partial: false,
|
|
1075
|
-
context: { around: args.around, matchedAt: null, lines: [] },
|
|
1076
|
-
unavailable
|
|
1077
|
-
};
|
|
1078
|
-
}
|
|
1079
|
-
const at = toMs(hit.timestamp);
|
|
1080
|
-
const hitTags = hit.tags?.["deviceId"];
|
|
1081
|
-
const around = await query({
|
|
1082
|
-
since: at - CONTEXT_RADIUS_MS,
|
|
1083
|
-
until: at + CONTEXT_RADIUS_MS,
|
|
1084
|
-
args,
|
|
1085
|
-
limit: FETCH_LIMIT,
|
|
1086
|
-
...args.deviceId === void 0 && hitTags ? { tags: { deviceId: hitTags } } : {}
|
|
1087
|
-
});
|
|
1088
|
-
const n = Math.max(1, args.contextLines ?? DEFAULT_CONTEXT_LINES);
|
|
1089
|
-
const ordered = (around?.page.entries ?? []).sort(
|
|
1090
|
-
(a, b) => toMs(a.timestamp) - toMs(b.timestamp)
|
|
1091
|
-
);
|
|
1092
|
-
const index = ordered.findIndex(
|
|
1093
|
-
(e) => e.timestamp === hit.timestamp && e.message === hit.message
|
|
1094
|
-
);
|
|
1095
|
-
const centre = index === -1 ? 0 : index;
|
|
1096
|
-
const lines = ordered.slice(Math.max(0, centre - n), centre + n + 1).map(lineOf);
|
|
1097
|
-
const out = {
|
|
1098
|
-
status: "ok",
|
|
1099
|
-
tier: around?.tier ?? found.tier,
|
|
1100
|
-
tiersTried: tried,
|
|
1101
|
-
window: { sinceMs: since, untilMs: until },
|
|
1102
|
-
scanned: ordered.length,
|
|
1103
|
-
partial: false,
|
|
1104
|
-
context: {
|
|
1105
|
-
around: args.around,
|
|
1106
|
-
matchedAt: hit.timestamp,
|
|
1107
|
-
lines
|
|
1108
|
-
},
|
|
1109
|
-
unavailable
|
|
1110
|
-
};
|
|
1111
|
-
return out;
|
|
1112
|
-
}
|
|
1113
|
-
const answered = await query({
|
|
1114
|
-
...base,
|
|
1115
|
-
...args.text ? { text: args.text } : {}
|
|
1116
|
-
});
|
|
1117
|
-
if (answered === null) {
|
|
1118
|
-
return {
|
|
1119
|
-
status: "ok",
|
|
1120
|
-
tier: "none",
|
|
1121
|
-
tiersTried: tried,
|
|
1122
|
-
window: { sinceMs: since, untilMs: until },
|
|
1123
|
-
scanned: 0,
|
|
1124
|
-
partial: false,
|
|
1125
|
-
unavailable
|
|
1126
|
-
};
|
|
1127
|
-
}
|
|
1128
|
-
const groups = cap(groupEntries(answered.page.entries), GROUP_LIMIT);
|
|
1129
|
-
return {
|
|
1130
|
-
status: "ok",
|
|
1131
|
-
tier: answered.tier,
|
|
1132
|
-
tiersTried: tried,
|
|
1133
|
-
window: { sinceMs: since, untilMs: until },
|
|
1134
|
-
scanned: answered.page.entries.length,
|
|
1135
|
-
partial: !answered.page.done || answered.page.entries.length >= FETCH_LIMIT,
|
|
1136
|
-
groups: groups.items,
|
|
1137
|
-
groupsTruncated: groups.truncated,
|
|
1138
|
-
unavailable
|
|
1139
|
-
};
|
|
1140
|
-
});
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
// src/mcp/ops-status.ts
|
|
1144
|
-
import { z as z5 } from "zod";
|
|
1145
|
-
var TopologyNodeSchema = z5.looseObject({
|
|
1146
|
-
id: z5.string(),
|
|
1147
|
-
name: z5.string().optional(),
|
|
1148
|
-
platform: z5.string().optional(),
|
|
1149
|
-
isHub: z5.boolean().optional(),
|
|
1150
|
-
isOnline: z5.boolean(),
|
|
1151
|
-
addons: z5.array(z5.looseObject({ capabilities: z5.array(z5.string()) })).optional(),
|
|
1152
|
-
/** Agents report it; the hub does not. Absent means "not claimed". */
|
|
1153
|
-
capabilitiesRegistered: z5.boolean().optional()
|
|
1154
|
-
});
|
|
1155
|
-
var TopologySchema = z5.array(TopologyNodeSchema);
|
|
1156
|
-
var ServerStatusSchema = z5.looseObject({
|
|
1157
|
-
runningVersion: z5.string(),
|
|
1158
|
-
restartRequired: z5.boolean().optional(),
|
|
1159
|
-
updateAvailable: z5.boolean().optional(),
|
|
1160
|
-
updateState: z5.string().optional()
|
|
1161
|
-
});
|
|
1162
|
-
var AlertSchema = z5.looseObject({
|
|
1163
|
-
severity: z5.string(),
|
|
1164
|
-
title: z5.string(),
|
|
1165
|
-
message: z5.string().optional(),
|
|
1166
|
-
status: z5.string()
|
|
1167
|
-
});
|
|
1168
|
-
var AlertsSchema = z5.array(AlertSchema);
|
|
1169
|
-
var AddonUpdateSchema = z5.looseObject({
|
|
1170
|
-
name: z5.string(),
|
|
1171
|
-
currentVersion: z5.string(),
|
|
1172
|
-
latestVersion: z5.string()
|
|
1173
|
-
});
|
|
1174
|
-
var AddonUpdatesSchema = z5.array(AddonUpdateSchema);
|
|
1175
|
-
var NODE_LIMIT2 = 40;
|
|
1176
|
-
var ALERT_LIMIT = 10;
|
|
1177
|
-
var UPDATE_LIMIT = 20;
|
|
1178
|
-
function distinctCaps(node) {
|
|
1179
|
-
const names = /* @__PURE__ */ new Set();
|
|
1180
|
-
for (const addon of node.addons ?? []) for (const c of addon.capabilities) names.add(c);
|
|
1181
|
-
return names.size;
|
|
1182
|
-
}
|
|
1183
|
-
function isCapless(node, count) {
|
|
1184
|
-
if (!node.isOnline) return false;
|
|
1185
|
-
return node.capabilitiesRegistered === false || node.capabilitiesRegistered === void 0 && count === 0;
|
|
1186
|
-
}
|
|
1187
|
-
async function nodeSummary(connection, node) {
|
|
1188
|
-
const count = distinctCaps(node);
|
|
1189
|
-
const base = {
|
|
1190
|
-
id: node.id,
|
|
1191
|
-
name: node.name ?? null,
|
|
1192
|
-
online: node.isOnline,
|
|
1193
|
-
platform: node.platform ?? null,
|
|
1194
|
-
isHub: node.isHub === true,
|
|
1195
|
-
capabilityCount: count,
|
|
1196
|
-
flags: isCapless(node, count) ? ["online-but-capless"] : []
|
|
1197
|
-
};
|
|
1198
|
-
if (!node.isOnline) {
|
|
1199
|
-
return {
|
|
1200
|
-
...base,
|
|
1201
|
-
serverVersion: null,
|
|
1202
|
-
versionReason: "node is offline",
|
|
1203
|
-
restartRequired: null,
|
|
1204
|
-
updateAvailable: null
|
|
1205
|
-
};
|
|
1206
|
-
}
|
|
1207
|
-
const input = node.isHub === true ? void 0 : { nodeId: node.id };
|
|
1208
|
-
const status = await readQuery(
|
|
1209
|
-
connection.client,
|
|
1210
|
-
"serverManagement.getServerPackageStatus",
|
|
1211
|
-
input,
|
|
1212
|
-
ServerStatusSchema
|
|
1213
|
-
);
|
|
1214
|
-
if (!status.ok) {
|
|
1215
|
-
return {
|
|
1216
|
-
...base,
|
|
1217
|
-
serverVersion: null,
|
|
1218
|
-
versionReason: status.reason,
|
|
1219
|
-
restartRequired: null,
|
|
1220
|
-
updateAvailable: null
|
|
1221
|
-
};
|
|
1222
|
-
}
|
|
1223
|
-
return {
|
|
1224
|
-
...base,
|
|
1225
|
-
serverVersion: status.value.runningVersion,
|
|
1226
|
-
restartRequired: status.value.restartRequired ?? null,
|
|
1227
|
-
updateAvailable: status.value.updateAvailable ?? null
|
|
1228
|
-
};
|
|
1229
|
-
}
|
|
1230
|
-
function summariseAlerts(alerts) {
|
|
1231
|
-
const active = alerts.filter((a) => a.status === "active");
|
|
1232
|
-
const bySeverity = {};
|
|
1233
|
-
for (const a of active) bySeverity[a.severity] = (bySeverity[a.severity] ?? 0) + 1;
|
|
1234
|
-
const order = ["critical", "error", "warning", "warn", "info"];
|
|
1235
|
-
const rank = (s) => {
|
|
1236
|
-
const i = order.indexOf(s);
|
|
1237
|
-
return i === -1 ? order.length : i;
|
|
1238
|
-
};
|
|
1239
|
-
const sorted = [...active].sort((a, b) => rank(a.severity) - rank(b.severity));
|
|
1240
|
-
const top = cap(sorted, ALERT_LIMIT);
|
|
1241
|
-
return {
|
|
1242
|
-
active: active.length,
|
|
1243
|
-
bySeverity,
|
|
1244
|
-
top: top.items.map((a) => ({ severity: a.severity, title: a.title })),
|
|
1245
|
-
truncated: top.truncated
|
|
1246
|
-
};
|
|
1247
|
-
}
|
|
1248
|
-
async function opsStatus(ctx) {
|
|
1249
|
-
return withHub(ctx, async (connection) => {
|
|
1250
|
-
const unavailable = {};
|
|
1251
|
-
const admin = connection.tier === "admin";
|
|
1252
|
-
const client = connection.client;
|
|
1253
|
-
const topology = admin ? await readQuery(client, "nodes.topology", void 0, TopologySchema) : {
|
|
1254
|
-
ok: false,
|
|
1255
|
-
reason: ADMIN_ONLY_NOTE("nodes.topology", connection.adminHint)
|
|
1256
|
-
};
|
|
1257
|
-
const [alertsRead, updatesRead] = await Promise.all([
|
|
1258
|
-
readQuery(client, "alerts.list", {}, AlertsSchema),
|
|
1259
|
-
admin ? readQuery(client, "addons.listUpdates", {}, AddonUpdatesSchema) : Promise.resolve({
|
|
1260
|
-
ok: false,
|
|
1261
|
-
reason: ADMIN_ONLY_NOTE("addons.listUpdates", connection.adminHint)
|
|
1262
|
-
})
|
|
1263
|
-
]);
|
|
1264
|
-
let nodes = null;
|
|
1265
|
-
let nodesTruncated = 0;
|
|
1266
|
-
if (topology.ok) {
|
|
1267
|
-
const capped = cap(topology.value, NODE_LIMIT2);
|
|
1268
|
-
nodesTruncated = capped.truncated;
|
|
1269
|
-
nodes = await Promise.all(capped.items.map((n) => nodeSummary(connection, n)));
|
|
1270
|
-
} else {
|
|
1271
|
-
unavailable["nodes"] = topology.reason;
|
|
1272
|
-
}
|
|
1273
|
-
let alerts = null;
|
|
1274
|
-
if (alertsRead.ok) alerts = summariseAlerts(alertsRead.value);
|
|
1275
|
-
else unavailable["alerts"] = alertsRead.reason;
|
|
1276
|
-
let addonsNeedingUpdate = null;
|
|
1277
|
-
if (updatesRead.ok) {
|
|
1278
|
-
const capped = cap(updatesRead.value, UPDATE_LIMIT);
|
|
1279
|
-
addonsNeedingUpdate = {
|
|
1280
|
-
items: capped.items.map((u) => ({
|
|
1281
|
-
name: u.name,
|
|
1282
|
-
from: u.currentVersion,
|
|
1283
|
-
to: u.latestVersion
|
|
1284
|
-
})),
|
|
1285
|
-
truncated: capped.truncated
|
|
1286
|
-
};
|
|
1287
|
-
} else {
|
|
1288
|
-
unavailable["addonsNeedingUpdate"] = updatesRead.reason;
|
|
1289
|
-
}
|
|
1290
|
-
return {
|
|
1291
|
-
status: "ok",
|
|
1292
|
-
hub: connection.server,
|
|
1293
|
-
tier: connection.tier,
|
|
1294
|
-
nodes,
|
|
1295
|
-
nodesTruncated,
|
|
1296
|
-
alerts,
|
|
1297
|
-
addonsNeedingUpdate,
|
|
1298
|
-
unavailable
|
|
1299
|
-
};
|
|
1300
|
-
});
|
|
1301
|
-
}
|
|
1302
|
-
|
|
1303
|
-
// src/mcp/ops-tools.ts
|
|
1304
|
-
var IDENTITY_CLAIMS = ["sub", "username", "name", "role"];
|
|
1305
|
-
function identityOf(token) {
|
|
1306
|
-
const claims = jwtClaims(token);
|
|
1307
|
-
const out = {};
|
|
1308
|
-
if (claims === null) return out;
|
|
1309
|
-
for (const key of IDENTITY_CLAIMS) {
|
|
1310
|
-
const value = claims[key];
|
|
1311
|
-
if (typeof value === "string" && value !== "") out[key] = value;
|
|
1312
|
-
}
|
|
1313
|
-
return out;
|
|
1314
|
-
}
|
|
1315
|
-
async function whoami(ctx) {
|
|
1316
|
-
const outcome = await ctx.hub();
|
|
1317
|
-
if (!outcome.ok) return noSessionResult(outcome);
|
|
1318
|
-
const c = outcome.connection;
|
|
1319
|
-
return {
|
|
1320
|
-
status: "ok",
|
|
1321
|
-
hub: c.server,
|
|
1322
|
-
tier: c.tier,
|
|
1323
|
-
source: c.source,
|
|
1324
|
-
identity: identityOf(c.token)
|
|
1325
|
-
};
|
|
1326
|
-
}
|
|
1327
|
-
function registerOpsTools(server, ctx) {
|
|
1328
|
-
server.tool(
|
|
1329
|
-
"ops_whoami",
|
|
1330
|
-
"Which hub this MCP talks to and as whom, from the `camstack login` session (no network call). If there is no session, says to run `camstack login <host>` in a terminal.",
|
|
1331
|
-
{},
|
|
1332
|
-
async () => text(await whoami(ctx))
|
|
1333
|
-
);
|
|
1334
|
-
server.tool(
|
|
1335
|
-
"ops_status",
|
|
1336
|
-
'Cluster summary: every node (online, platform, server version, capability count, "online-but-capless" flag), the active alerts and the addons with an update. A part the hub did not answer is null with its reason in `unavailable`, never zero. Node list and updates need an admin `camstack login`.',
|
|
1337
|
-
{},
|
|
1338
|
-
async () => text(await opsStatus(ctx))
|
|
1339
|
-
);
|
|
1340
|
-
server.tool(
|
|
1341
|
-
"ops_device",
|
|
1342
|
-
"One device: online state, provider addon and node, bound capabilities, runtime-state age and, for a camera, its pipeline, recording state, the functions an operator switched off (not faults) and its latest object events.",
|
|
1343
|
-
{ deviceId: z6.number().int() },
|
|
1344
|
-
async ({ deviceId }) => text(await opsDevice(ctx, deviceId))
|
|
1345
|
-
);
|
|
1346
|
-
server.tool(
|
|
1347
|
-
"ops_unhealthy_devices",
|
|
1348
|
-
"Every enabled device that is unhealthy, each with a typed reason (offline, detection-provisioning-failed, detection-not-assigned, recording-inactive). A function an operator switched off is never listed; a camera whose status could not be fully read is listed under `unknown`.",
|
|
1349
|
-
{},
|
|
1350
|
-
async () => text(await opsUnhealthyDevices(ctx))
|
|
1351
|
-
);
|
|
1352
|
-
server.tool(
|
|
1353
|
-
"ops_logs",
|
|
1354
|
-
"Hub logs scoped by deviceId, addon, node, level, window and free text, grouped and deduplicated with counts. Says which tier answered (ring = recent in-RAM; persisted = the default log destination). With `around` (a track or event id) returns `contextLines` lines on each side of its first mention. Needs an admin `camstack login`.",
|
|
1355
|
-
{
|
|
1356
|
-
deviceId: z6.number().int().optional(),
|
|
1357
|
-
addonId: z6.string().optional(),
|
|
1358
|
-
nodeId: z6.string().optional(),
|
|
1359
|
-
level: z6.enum(["debug", "info", "warn", "error"]).optional(),
|
|
1360
|
-
windowMinutes: z6.number().int().min(1).max(1440).optional(),
|
|
1361
|
-
text: z6.string().max(200).optional(),
|
|
1362
|
-
around: z6.string().max(200).optional(),
|
|
1363
|
-
contextLines: z6.number().int().min(1).max(50).optional()
|
|
1364
|
-
},
|
|
1365
|
-
async (args) => text(
|
|
1366
|
-
await opsLogs(ctx, {
|
|
1367
|
-
...args.deviceId !== void 0 ? { deviceId: args.deviceId } : {},
|
|
1368
|
-
...args.addonId ? { addonId: args.addonId } : {},
|
|
1369
|
-
...args.nodeId ? { nodeId: args.nodeId } : {},
|
|
1370
|
-
...args.level ? { level: args.level } : {},
|
|
1371
|
-
...args.windowMinutes !== void 0 ? { windowMinutes: args.windowMinutes } : {},
|
|
1372
|
-
...args.text ? { text: args.text } : {},
|
|
1373
|
-
...args.around ? { around: args.around } : {},
|
|
1374
|
-
...args.contextLines !== void 0 ? { contextLines: args.contextLines } : {}
|
|
1375
|
-
})
|
|
1376
|
-
)
|
|
1377
|
-
);
|
|
1378
|
-
server.tool(
|
|
1379
|
-
"ops_events",
|
|
1380
|
-
"Find tracks (and, for one camera, object events) by camera, class, zone, time window and free text. Each result carries an admin-UI link and a viewer link built on the hub address flagged for app connections.",
|
|
1381
|
-
{
|
|
1382
|
-
deviceId: z6.number().int().optional(),
|
|
1383
|
-
className: z6.string().optional(),
|
|
1384
|
-
zone: z6.string().optional(),
|
|
1385
|
-
text: z6.string().max(200).optional(),
|
|
1386
|
-
windowMinutes: z6.number().int().min(1).max(10080).optional(),
|
|
1387
|
-
limit: z6.number().int().min(1).max(50).optional()
|
|
1388
|
-
},
|
|
1389
|
-
async (args) => text(
|
|
1390
|
-
await opsEvents(ctx, {
|
|
1391
|
-
...args.deviceId !== void 0 ? { deviceId: args.deviceId } : {},
|
|
1392
|
-
...args.className ? { className: args.className } : {},
|
|
1393
|
-
...args.zone ? { zone: args.zone } : {},
|
|
1394
|
-
...args.text ? { text: args.text } : {},
|
|
1395
|
-
...args.windowMinutes !== void 0 ? { windowMinutes: args.windowMinutes } : {},
|
|
1396
|
-
...args.limit !== void 0 ? { limit: args.limit } : {}
|
|
1397
|
-
})
|
|
1398
|
-
)
|
|
1399
|
-
);
|
|
1400
|
-
server.tool(
|
|
1401
|
-
"ops_track",
|
|
1402
|
-
"One track with its media audit: best shot, keyframes, every missing media kind with the reason the track recorded for it, and the audit findings. Links to the admin UI and the viewer.",
|
|
1403
|
-
{ deviceId: z6.number().int(), trackId: z6.string().min(1) },
|
|
1404
|
-
async ({ deviceId, trackId }) => text(await opsTrack(ctx, { deviceId, trackId }))
|
|
1405
|
-
);
|
|
1406
|
-
server.tool(
|
|
1407
|
-
"ops_accelerators",
|
|
1408
|
-
'Per node: the inference devices it has, the Auto-tune hardware class the node maps to, and whether the measured detector-latency table has a row for that class and accelerator. A node that could not be read is null with a reason, never "no accelerator". Needs an admin `camstack login`.',
|
|
1409
|
-
{},
|
|
1410
|
-
async () => text(await opsAccelerators(ctx))
|
|
1411
|
-
);
|
|
1412
|
-
}
|
|
1413
|
-
|
|
1414
|
-
// src/mcp/configure-tools.ts
|
|
1415
|
-
var CurrentDeviceSchema = z7.object({ name: z7.string() }).nullable();
|
|
1416
|
-
var RENAME_DEVICE_TOOL = "configure_rename_device";
|
|
1417
|
-
function renameDeviceSpec(client) {
|
|
1418
|
-
return {
|
|
1419
|
-
tool: RENAME_DEVICE_TOOL,
|
|
1420
|
-
async dryRun(args) {
|
|
1421
|
-
const current = CurrentDeviceSchema.safeParse(
|
|
1422
|
-
await client.call("GET", "deviceManager.getDevice", { deviceId: args.deviceId })
|
|
1423
|
-
);
|
|
1424
|
-
if (!current.success || current.data === null) {
|
|
1425
|
-
return {
|
|
1426
|
-
confirmable: false,
|
|
1427
|
-
describe: { problem: `device ${args.deviceId} was not found on the hub` }
|
|
1428
|
-
};
|
|
1429
|
-
}
|
|
1430
|
-
if (current.data.name === args.name) {
|
|
1431
|
-
return {
|
|
1432
|
-
confirmable: false,
|
|
1433
|
-
describe: { problem: `device ${args.deviceId} is already named "${args.name}"` }
|
|
1434
|
-
};
|
|
1435
|
-
}
|
|
1436
|
-
return {
|
|
1437
|
-
confirmable: true,
|
|
1438
|
-
describe: {
|
|
1439
|
-
action: "rename device",
|
|
1440
|
-
deviceId: args.deviceId,
|
|
1441
|
-
from: current.data.name,
|
|
1442
|
-
to: args.name
|
|
1443
|
-
}
|
|
1444
|
-
};
|
|
1445
|
-
},
|
|
1446
|
-
async execute(args) {
|
|
1447
|
-
await client.call("POST", "deviceManager.setName", {
|
|
1448
|
-
deviceId: args.deviceId,
|
|
1449
|
-
name: args.name
|
|
1450
|
-
});
|
|
1451
|
-
return { deviceId: args.deviceId, name: args.name };
|
|
1452
|
-
}
|
|
1453
|
-
};
|
|
1454
|
-
}
|
|
1455
|
-
async function renameDevice(ctx, args, confirmToken) {
|
|
1456
|
-
const outcome = await ctx.hub();
|
|
1457
|
-
if (!outcome.ok) return noSessionResult(outcome);
|
|
1458
|
-
if (outcome.connection.tier !== "admin") {
|
|
1459
|
-
return {
|
|
1460
|
-
status: "refused",
|
|
1461
|
-
reason: outcome.connection.adminHint ?? "this credential is not admin-scoped"
|
|
1462
|
-
};
|
|
1463
|
-
}
|
|
1464
|
-
return runGated(ctx.gate, renameDeviceSpec(outcome.connection.client), args, confirmToken);
|
|
1465
|
-
}
|
|
1466
|
-
function registerConfigureTools(server, ctx) {
|
|
1467
|
-
server.tool(
|
|
1468
|
-
RENAME_DEVICE_TOOL,
|
|
1469
|
-
"Rename a device. Two steps: call without confirm_token for a preview and a one-shot token, then call again with the SAME arguments and that token to apply. Needs an admin `camstack login` session.",
|
|
1470
|
-
{
|
|
1471
|
-
deviceId: z7.number().int(),
|
|
1472
|
-
name: z7.string().min(1),
|
|
1473
|
-
confirm_token: z7.string().optional()
|
|
1474
|
-
},
|
|
1475
|
-
async ({ deviceId, name, confirm_token }) => text2(await renameDevice(ctx, { deviceId, name }, confirm_token))
|
|
1476
|
-
);
|
|
1477
|
-
}
|
|
1478
|
-
|
|
1479
|
-
// src/mcp/setup-tools.ts
|
|
1480
|
-
import { text as text3 } from "@camstack/develop";
|
|
1481
|
-
import { z as z11 } from "zod";
|
|
1482
|
-
|
|
1483
|
-
// src/mcp/setup-hubs.ts
|
|
1484
|
-
import * as https from "https";
|
|
1485
|
-
import {
|
|
1486
|
-
createHubAgent,
|
|
1487
|
-
explainTlsFailure,
|
|
1488
|
-
HubAddressError,
|
|
1489
|
-
hubCaFileForServer,
|
|
1490
|
-
loadSession,
|
|
1491
|
-
normalizeHubAddress,
|
|
1492
|
-
resolveHubTls
|
|
1493
|
-
} from "@camstack/develop";
|
|
1494
|
-
import { z as z10 } from "zod";
|
|
1495
|
-
|
|
1496
|
-
// src/mcp/setup-install.ts
|
|
1497
|
-
import { z as z9 } from "zod";
|
|
1498
|
-
|
|
1499
|
-
// src/mcp/setup-install-rules.ts
|
|
1500
|
-
import { z as z8 } from "zod";
|
|
1501
|
-
var HOST_KINDS = [
|
|
1502
|
-
"docker-compose",
|
|
1503
|
-
"docker",
|
|
1504
|
-
"unraid",
|
|
1505
|
-
"proxmox-lxc",
|
|
1506
|
-
"macos-desktop"
|
|
1507
|
-
];
|
|
1508
|
-
var HostKindSchema = z8.enum(HOST_KINDS);
|
|
1509
|
-
var ROLES = ["hub", "agent"];
|
|
1510
|
-
var RoleSchema = z8.enum(ROLES);
|
|
1511
|
-
var ACCELERATORS = ["intel", "amd", "coral", "apple", "cpu"];
|
|
1512
|
-
var AcceleratorSchema = z8.enum(ACCELERATORS);
|
|
1513
|
-
var InstallPathsSchema = z8.object({
|
|
1514
|
-
data: z8.string().min(1),
|
|
1515
|
-
recordings: z8.string().min(1),
|
|
1516
|
-
config: z8.string().min(1).optional(),
|
|
1517
|
-
backups: z8.string().min(1).optional()
|
|
1518
|
-
});
|
|
1519
|
-
var InstallGuideInputSchema = z8.object({
|
|
1520
|
-
hostKind: HostKindSchema.optional(),
|
|
1521
|
-
role: RoleSchema.optional(),
|
|
1522
|
-
accelerator: AcceleratorSchema.optional(),
|
|
1523
|
-
paths: InstallPathsSchema.optional(),
|
|
1524
|
-
/** Agents only: the hub the agent should dial (host or IP). */
|
|
1525
|
-
hubAddress: z8.string().min(1).optional()
|
|
1526
|
-
});
|
|
1527
|
-
var RAW = "https://raw.githubusercontent.com/camstack";
|
|
1528
|
-
var TEMPLATE_URLS = {
|
|
1529
|
-
compose: {
|
|
1530
|
-
hub: `${RAW}/proxmox/main/compose/hub.yml`,
|
|
1531
|
-
agent: `${RAW}/proxmox/main/compose/agent.yml`
|
|
1532
|
-
},
|
|
1533
|
-
proxmoxInstaller: `${RAW}/proxmox/main/install-camstack-proxmox.sh`,
|
|
1534
|
-
unraid: {
|
|
1535
|
-
hub: `${RAW}/unraid-templates/main/templates/camstack.xml`,
|
|
1536
|
-
agent: `${RAW}/unraid-templates/main/templates/camstack-agent.xml`
|
|
1537
|
-
},
|
|
1538
|
-
latestRelease: "https://api.github.com/repos/camstack/server/releases/latest"
|
|
1539
|
-
};
|
|
1540
|
-
var IMAGE_REPOSITORY = "ghcr.io/camstack/camstack";
|
|
1541
|
-
var IMAGE_TAG_BY_ACCELERATOR = {
|
|
1542
|
-
intel: "intel-latest",
|
|
1543
|
-
amd: "amd-latest",
|
|
1544
|
-
// No Coral- or CPU-specific image: Intel hosts and the Unraid templates track intel-latest.
|
|
1545
|
-
coral: "intel-latest",
|
|
1546
|
-
cpu: "intel-latest",
|
|
1547
|
-
apple: "intel-latest"
|
|
1548
|
-
};
|
|
1549
|
-
var NO_BARE_LATEST = "There is no bare :latest tag: use intel-<version>/intel-latest or amd-<version>/amd-latest, and never mix them across vendors.";
|
|
1550
|
-
var MUST_NOT_OMIT = [
|
|
1551
|
-
"--pids-limit=8192 (pids_limit: 8192): the default 2048 counts threads and silently stops media on a busy hub.",
|
|
1552
|
-
"CAMSTACK_MEDIA_ROOT=/recordings: without it recordings land in /data and fill the cache drive.",
|
|
1553
|
-
"/config mapped: it is a VOLUME, so an unmapped path mints an anonymous volume on every re-create.",
|
|
1554
|
-
"--restart unless-stopped: the NVR must come back after a host reboot.",
|
|
1555
|
-
"CAMSTACK_MOLECULER_USE_HOSTNAME=false: otherwise a multi-machine cluster never forms.",
|
|
1556
|
-
"Coral USB: /dev/bus/usb AND the device-cgroup rule c 189:* rmw."
|
|
1557
|
-
];
|
|
1558
|
-
var COMPOSE_MARKERS_BY_ACCELERATOR = {
|
|
1559
|
-
intel: ["devices", "dri", "accel"],
|
|
1560
|
-
amd: ["devices", "dri"],
|
|
1561
|
-
coral: ["devices", "usb"],
|
|
1562
|
-
cpu: [],
|
|
1563
|
-
apple: []
|
|
1564
|
-
};
|
|
1565
|
-
function firstRunHub(host) {
|
|
1566
|
-
return [
|
|
1567
|
-
`Open https://${host}:4443 (a self-signed certificate: accept the browser warning).`,
|
|
1568
|
-
"Sign in as admin / changeme; CamStack asks for a new password at once. Change it now.",
|
|
1569
|
-
`Then, where your MCP client runs: camstack login ${host}`
|
|
1570
|
-
];
|
|
1571
|
-
}
|
|
1572
|
-
|
|
1573
|
-
// src/mcp/setup-compose.ts
|
|
1574
|
-
var MARKER_LINE = /^(\s*)# (.*#@([A-Za-z0-9_]+)\s*)$/;
|
|
1575
|
-
var VOLUME_HOST_PATHS = [
|
|
1576
|
-
{ container: "/data", key: "data" },
|
|
1577
|
-
{ container: "/recordings", key: "recordings" },
|
|
1578
|
-
{ container: "/config", key: "config" },
|
|
1579
|
-
{ container: "/backups", key: "backups" }
|
|
1580
|
-
];
|
|
1581
|
-
function swapImageTag(body, imageTag) {
|
|
1582
|
-
const escaped = IMAGE_REPOSITORY.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1583
|
-
return body.replace(new RegExp(`(${escaped}):[A-Za-z0-9._-]+`), `$1:${imageTag}`);
|
|
1584
|
-
}
|
|
1585
|
-
function enableMarkers(body, markers) {
|
|
1586
|
-
return body.split("\n").map((line) => {
|
|
1587
|
-
const match = MARKER_LINE.exec(line);
|
|
1588
|
-
if (match === null) return line;
|
|
1589
|
-
const tag = match[3];
|
|
1590
|
-
return tag !== void 0 && markers.includes(tag) ? `${match[1] ?? ""}${match[2] ?? ""}` : line;
|
|
1591
|
-
}).join("\n");
|
|
1592
|
-
}
|
|
1593
|
-
function setVolumeHosts(body, paths) {
|
|
1594
|
-
const edits = [];
|
|
1595
|
-
let out = body;
|
|
1596
|
-
for (const { container, key } of VOLUME_HOST_PATHS) {
|
|
1597
|
-
const wanted = paths?.[key];
|
|
1598
|
-
const volume = new RegExp(`^(\\s*- )([^\\s:]+)(:${container}\\s*)$`, "m");
|
|
1599
|
-
if (!volume.test(out)) {
|
|
1600
|
-
if (wanted !== void 0) {
|
|
1601
|
-
edits.push(
|
|
1602
|
-
`${container}: no volume line found in the template; map ${wanted} to it yourself`
|
|
1603
|
-
);
|
|
1604
|
-
}
|
|
1605
|
-
continue;
|
|
1606
|
-
}
|
|
1607
|
-
if (wanted === void 0) {
|
|
1608
|
-
edits.push(
|
|
1609
|
-
`${container}: left at the template's host path, change it if that is not where you want it`
|
|
1610
|
-
);
|
|
1611
|
-
continue;
|
|
1612
|
-
}
|
|
1613
|
-
out = out.replace(
|
|
1614
|
-
volume,
|
|
1615
|
-
(_m, pre, _host, post) => `${pre}${wanted}${post}`
|
|
1616
|
-
);
|
|
1617
|
-
}
|
|
1618
|
-
return { body: out, edits };
|
|
1619
|
-
}
|
|
1620
|
-
function setHubAddress(body, hubAddress) {
|
|
1621
|
-
const line = /^(\s*- CAMSTACK_HUB_ADDRESS=).*$/m;
|
|
1622
|
-
if (!line.test(body)) return { body, edits: [] };
|
|
1623
|
-
if (hubAddress === void 0) {
|
|
1624
|
-
return {
|
|
1625
|
-
body,
|
|
1626
|
-
edits: [
|
|
1627
|
-
"CAMSTACK_HUB_ADDRESS: export it before `docker compose up` (or edit the line) with the hub IP or hostname"
|
|
1628
|
-
]
|
|
1629
|
-
};
|
|
1630
|
-
}
|
|
1631
|
-
return {
|
|
1632
|
-
body: body.replace(line, (_m, pre) => `${pre}${hubAddress}`),
|
|
1633
|
-
edits: []
|
|
1634
|
-
};
|
|
1635
|
-
}
|
|
1636
|
-
function customizeCompose(source, c) {
|
|
1637
|
-
const tagged = enableMarkers(swapImageTag(source, c.imageTag), c.markers);
|
|
1638
|
-
const volumes = setVolumeHosts(tagged, c.paths);
|
|
1639
|
-
const hub = setHubAddress(volumes.body, c.hubAddress);
|
|
1640
|
-
return { body: hub.body, edits: [...volumes.edits, ...hub.edits] };
|
|
1641
|
-
}
|
|
1642
|
-
function unquote(value) {
|
|
1643
|
-
const v = value.trim();
|
|
1644
|
-
if (v.length >= 2 && (v.startsWith('"') || v.startsWith("'")) && v.endsWith(v[0] ?? "")) {
|
|
1645
|
-
return v.slice(1, -1);
|
|
1646
|
-
}
|
|
1647
|
-
return v;
|
|
1648
|
-
}
|
|
1649
|
-
function stripComment(line) {
|
|
1650
|
-
return line.replace(/\s+#.*$/, "");
|
|
1651
|
-
}
|
|
1652
|
-
function readService(body) {
|
|
1653
|
-
const fields = {
|
|
1654
|
-
scalars: /* @__PURE__ */ new Map(),
|
|
1655
|
-
lists: /* @__PURE__ */ new Map(),
|
|
1656
|
-
maps: /* @__PURE__ */ new Map()
|
|
1657
|
-
};
|
|
1658
|
-
let key = null;
|
|
1659
|
-
let sub = null;
|
|
1660
|
-
for (const raw of body.split("\n")) {
|
|
1661
|
-
if (raw.trim() === "" || raw.trim().startsWith("#")) continue;
|
|
1662
|
-
const line = stripComment(raw);
|
|
1663
|
-
const indent = line.length - line.trimStart().length;
|
|
1664
|
-
const text4 = line.trim();
|
|
1665
|
-
if (indent === 4) {
|
|
1666
|
-
sub = null;
|
|
1667
|
-
const m = /^([A-Za-z_]+):\s*(.*)$/.exec(text4);
|
|
1668
|
-
if (m === null) continue;
|
|
1669
|
-
key = m[1] ?? null;
|
|
1670
|
-
if (key !== null && (m[2] ?? "") !== "") fields.scalars.set(key, unquote(m[2] ?? ""));
|
|
1671
|
-
} else if (indent === 6 && key !== null) {
|
|
1672
|
-
if (text4.startsWith("- ")) {
|
|
1673
|
-
const list = fields.lists.get(key) ?? [];
|
|
1674
|
-
list.push(unquote(text4.slice(2)));
|
|
1675
|
-
fields.lists.set(key, list);
|
|
1676
|
-
} else {
|
|
1677
|
-
const m = /^([A-Za-z_-]+):\s*(.*)$/.exec(text4);
|
|
1678
|
-
if (m === null) continue;
|
|
1679
|
-
sub = m[1] ?? null;
|
|
1680
|
-
const map = fields.maps.get(key) ?? /* @__PURE__ */ new Map();
|
|
1681
|
-
if ((m[2] ?? "") !== "" && sub !== null) map.set(sub, unquote(m[2] ?? ""));
|
|
1682
|
-
fields.maps.set(key, map);
|
|
1683
|
-
}
|
|
1684
|
-
} else if (indent === 8 && key !== null && sub !== null) {
|
|
1685
|
-
const m = /^([A-Za-z_-]+):\s*(.*)$/.exec(text4);
|
|
1686
|
-
if (m === null) continue;
|
|
1687
|
-
const map = fields.maps.get(`${key}.${sub}`) ?? /* @__PURE__ */ new Map();
|
|
1688
|
-
map.set(m[1] ?? "", unquote(m[2] ?? ""));
|
|
1689
|
-
fields.maps.set(`${key}.${sub}`, map);
|
|
1690
|
-
}
|
|
1691
|
-
}
|
|
1692
|
-
return fields;
|
|
1693
|
-
}
|
|
1694
|
-
function shellQuote(value) {
|
|
1695
|
-
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replace(/'/g, `'\\''`)}'`;
|
|
1696
|
-
}
|
|
1697
|
-
function composeToDockerRun(body) {
|
|
1698
|
-
const f = readService(body);
|
|
1699
|
-
const image = f.scalars.get("image");
|
|
1700
|
-
if (image === void 0) return null;
|
|
1701
|
-
const args = ["docker run -d"];
|
|
1702
|
-
const name = f.scalars.get("container_name");
|
|
1703
|
-
if (name !== void 0) args.push(`--name ${shellQuote(name)}`);
|
|
1704
|
-
const restart = f.scalars.get("restart");
|
|
1705
|
-
if (restart !== void 0) args.push(`--restart ${shellQuote(restart)}`);
|
|
1706
|
-
const network = f.scalars.get("network_mode");
|
|
1707
|
-
if (network !== void 0) args.push(`--network ${shellQuote(network)}`);
|
|
1708
|
-
const pids = f.scalars.get("pids_limit");
|
|
1709
|
-
if (pids !== void 0) args.push(`--pids-limit=${pids}`);
|
|
1710
|
-
for (const d of f.lists.get("devices") ?? []) args.push(`--device ${shellQuote(d)}`);
|
|
1711
|
-
for (const r of f.lists.get("device_cgroup_rules") ?? []) {
|
|
1712
|
-
args.push(`--device-cgroup-rule ${shellQuote(r)}`);
|
|
1713
|
-
}
|
|
1714
|
-
for (const g of f.lists.get("group_add") ?? []) args.push(`--group-add ${shellQuote(g)}`);
|
|
1715
|
-
for (const v of f.lists.get("volumes") ?? []) args.push(`-v ${shellQuote(v)}`);
|
|
1716
|
-
for (const e of f.lists.get("environment") ?? []) {
|
|
1717
|
-
if (!e.includes("${")) args.push(`-e ${shellQuote(e)}`);
|
|
1718
|
-
}
|
|
1719
|
-
const driver = f.maps.get("logging")?.get("driver");
|
|
1720
|
-
if (driver !== void 0) args.push(`--log-driver ${shellQuote(driver)}`);
|
|
1721
|
-
for (const [k, v] of f.maps.get("logging.options") ?? []) {
|
|
1722
|
-
args.push(`--log-opt ${shellQuote(`${k}=${v}`)}`);
|
|
1723
|
-
}
|
|
1724
|
-
args.push(shellQuote(image));
|
|
1725
|
-
return args.join(" \\\n ");
|
|
1726
|
-
}
|
|
1727
|
-
|
|
1728
|
-
// src/mcp/setup-install.ts
|
|
1729
|
-
var FETCH_TIMEOUT_MS = 1e4;
|
|
1730
|
-
var CONTAINER_ROUTES = ["docker-compose", "docker"];
|
|
1731
|
-
var Q_HOST = {
|
|
1732
|
-
id: "hostKind",
|
|
1733
|
-
question: "Where will CamStack run?",
|
|
1734
|
-
options: ["docker-compose", "docker", "unraid", "proxmox-lxc", "macos-desktop"]
|
|
1735
|
-
};
|
|
1736
|
-
var Q_ROLE = {
|
|
1737
|
-
id: "role",
|
|
1738
|
-
question: "Which role? Start with a hub; an agent only adds a second machine to an existing hub.",
|
|
1739
|
-
options: ["hub", "agent"]
|
|
1740
|
-
};
|
|
1741
|
-
var Q_ACCEL = {
|
|
1742
|
-
id: "accelerator",
|
|
1743
|
-
question: "Which accelerator does this host have?",
|
|
1744
|
-
options: ["intel", "amd", "coral", "cpu"]
|
|
1745
|
-
};
|
|
1746
|
-
var Q_PATHS = {
|
|
1747
|
-
id: "paths",
|
|
1748
|
-
question: "Host paths as {data, recordings} (optionally config, backups). Recordings is the one that grows: put it on a roomy disk.",
|
|
1749
|
-
options: []
|
|
1750
|
-
};
|
|
1751
|
-
function missingQuestions(input) {
|
|
1752
|
-
const out = [];
|
|
1753
|
-
if (input.hostKind === void 0) out.push(Q_HOST);
|
|
1754
|
-
if (input.role === void 0) out.push(Q_ROLE);
|
|
1755
|
-
const desktop = input.hostKind === "macos-desktop";
|
|
1756
|
-
if (!desktop && (input.accelerator === void 0 || input.accelerator === "apple")) {
|
|
1757
|
-
out.push(Q_ACCEL);
|
|
1758
|
-
}
|
|
1759
|
-
if (input.hostKind !== void 0 && CONTAINER_ROUTES.includes(input.hostKind) && input.paths === void 0) {
|
|
1760
|
-
out.push(Q_PATHS);
|
|
1761
|
-
}
|
|
1762
|
-
return out;
|
|
1763
|
-
}
|
|
1764
|
-
async function fetchText(url, fetchFn) {
|
|
1765
|
-
try {
|
|
1766
|
-
const res = await fetchFn(url, {
|
|
1767
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
|
|
1768
|
-
});
|
|
1769
|
-
if (!res.ok) {
|
|
1770
|
-
return {
|
|
1771
|
-
ok: false,
|
|
1772
|
-
failure: {
|
|
1773
|
-
status: "fetch-failed",
|
|
1774
|
-
url,
|
|
1775
|
-
error: `answered ${res.status}`
|
|
1776
|
-
}
|
|
1777
|
-
};
|
|
1778
|
-
}
|
|
1779
|
-
return { ok: true, body: await res.text() };
|
|
1780
|
-
} catch (err) {
|
|
1781
|
-
const error = err instanceof Error ? err.message : String(err);
|
|
1782
|
-
return { ok: false, failure: { status: "fetch-failed", url, error } };
|
|
1783
|
-
}
|
|
1784
|
-
}
|
|
1785
|
-
var ReleaseSchema = z9.object({
|
|
1786
|
-
tag_name: z9.string(),
|
|
1787
|
-
assets: z9.array(z9.object({ name: z9.string(), browser_download_url: z9.string() }))
|
|
1788
|
-
});
|
|
1789
|
-
var DMG = /^CamStack-[0-9][0-9A-Za-z.-]*-(arm64|x64)\.dmg$/;
|
|
1790
|
-
function acceleratorEdits(accelerator, role) {
|
|
1791
|
-
const note = `Image tag ${IMAGE_TAG_BY_ACCELERATOR[accelerator]} for ${accelerator}. ${NO_BARE_LATEST}`;
|
|
1792
|
-
return role === "agent" ? [note, "Do not run a hub and an agent on the same host."] : [note];
|
|
1793
|
-
}
|
|
1794
|
-
async function composeGuide(input, fetchFn, asDockerRun) {
|
|
1795
|
-
const url = TEMPLATE_URLS.compose[input.role];
|
|
1796
|
-
const fetched = await fetchText(url, fetchFn);
|
|
1797
|
-
if (!fetched.ok) return fetched.failure;
|
|
1798
|
-
const imageTag = IMAGE_TAG_BY_ACCELERATOR[input.accelerator];
|
|
1799
|
-
const custom = customizeCompose(fetched.body, {
|
|
1800
|
-
imageTag,
|
|
1801
|
-
markers: COMPOSE_MARKERS_BY_ACCELERATOR[input.accelerator],
|
|
1802
|
-
paths: input.paths,
|
|
1803
|
-
hubAddress: input.hubAddress
|
|
1804
|
-
});
|
|
1805
|
-
const edits = [...custom.edits, ...acceleratorEdits(input.accelerator, input.role)];
|
|
1806
|
-
const first = firstRun(input);
|
|
1807
|
-
if (!asDockerRun) {
|
|
1808
|
-
return ready(input, imageTag, {
|
|
1809
|
-
steps: [
|
|
1810
|
-
"Save docker-compose.yml (below) in an empty directory on the host.",
|
|
1811
|
-
"docker compose up -d && docker compose logs -f camstack",
|
|
1812
|
-
"The first boot seeds addons and pulls models: allow a few minutes.",
|
|
1813
|
-
...first
|
|
1814
|
-
],
|
|
1815
|
-
files: [{ path: "docker-compose.yml", body: custom.body, source: url }],
|
|
1816
|
-
edits
|
|
1817
|
-
});
|
|
1818
|
-
}
|
|
1819
|
-
const run = composeToDockerRun(custom.body);
|
|
1820
|
-
if (run === null) {
|
|
1821
|
-
return {
|
|
1822
|
-
status: "fetch-failed",
|
|
1823
|
-
url,
|
|
1824
|
-
error: "the official compose file has no image line; cannot derive docker run"
|
|
1825
|
-
};
|
|
1826
|
-
}
|
|
1827
|
-
return ready(input, imageTag, {
|
|
1828
|
-
steps: [
|
|
1829
|
-
"Create the host directories named by the -v lines, then run the file below as a shell script.",
|
|
1830
|
-
"docker logs -f camstack (the first boot seeds addons and pulls models).",
|
|
1831
|
-
...first
|
|
1832
|
-
],
|
|
1833
|
-
files: [{ path: "run-camstack.sh", body: `#!/bin/sh
|
|
1834
|
-
${run}
|
|
1835
|
-
`, source: url }],
|
|
1836
|
-
edits
|
|
1837
|
-
});
|
|
1838
|
-
}
|
|
1839
|
-
async function unraidGuide(input, fetchFn) {
|
|
1840
|
-
const url = TEMPLATE_URLS.unraid[input.role];
|
|
1841
|
-
const fetched = await fetchText(url, fetchFn);
|
|
1842
|
-
if (!fetched.ok) return fetched.failure;
|
|
1843
|
-
const imageTag = IMAGE_TAG_BY_ACCELERATOR[input.accelerator];
|
|
1844
|
-
const edits = [
|
|
1845
|
-
`In the template form, set Repository to ${IMAGE_REPOSITORY}:${imageTag} (the template ships intel-latest). ${NO_BARE_LATEST}`,
|
|
1846
|
-
"Recordings: point it at a roomy array share, not the cache; leave Media root as /recordings.",
|
|
1847
|
-
"Keep ExtraParams as shipped: --device-cgroup-rule and --pids-limit=8192.",
|
|
1848
|
-
...unraidDeviceEdits(input.accelerator)
|
|
1849
|
-
];
|
|
1850
|
-
const first = firstRun(input);
|
|
1851
|
-
return ready(input, imageTag, {
|
|
1852
|
-
steps: [
|
|
1853
|
-
`Unraid: Docker > Add Container > Template, paste ${url}`,
|
|
1854
|
-
"Review the paths and Repository (edits below), then Apply.",
|
|
1855
|
-
...first
|
|
1856
|
-
],
|
|
1857
|
-
files: [{ path: urlName(url), body: fetched.body, source: url }],
|
|
1858
|
-
edits
|
|
1859
|
-
});
|
|
1860
|
-
}
|
|
1861
|
-
function unraidDeviceEdits(accelerator) {
|
|
1862
|
-
if (accelerator === "intel" || accelerator === "amd") {
|
|
1863
|
-
return ["Intel/AMD iGPU: enter /dev/dri in that field. Intel NPU: enter /dev/accel."];
|
|
1864
|
-
}
|
|
1865
|
-
if (accelerator === "coral") {
|
|
1866
|
-
return ["Coral USB: enter /dev/bus/usb (the whole bus). Coral PCIe/M.2: /dev/apex_0 (and _1)."];
|
|
1867
|
-
}
|
|
1868
|
-
return [];
|
|
1869
|
-
}
|
|
1870
|
-
function urlName(url) {
|
|
1871
|
-
return url.slice(url.lastIndexOf("/") + 1);
|
|
1872
|
-
}
|
|
1873
|
-
async function proxmoxGuide(input, fetchFn) {
|
|
1874
|
-
const url = TEMPLATE_URLS.proxmoxInstaller;
|
|
1875
|
-
const fetched = await fetchText(url, fetchFn);
|
|
1876
|
-
if (!fetched.ok) return fetched.failure;
|
|
1877
|
-
const imageTag = IMAGE_TAG_BY_ACCELERATOR[input.accelerator];
|
|
1878
|
-
const vars = [`ROLE=${input.role}`];
|
|
1879
|
-
vars.push(`IMAGE_TAG=${IMAGE_REPOSITORY}:${imageTag}`);
|
|
1880
|
-
if (input.accelerator === "cpu") vars.push("PASS_ACCEL=none");
|
|
1881
|
-
if (input.role === "agent") vars.push(`CAMSTACK_HUB_ADDRESS=${input.hubAddress ?? "<hub-ip>"}`);
|
|
1882
|
-
if (input.paths !== void 0) vars.push(`RECORDINGS_HOST_PATH=${input.paths.recordings}`);
|
|
1883
|
-
const header = fetched.body.split("\n").slice(1, 20).filter((l) => l.startsWith("#")).join("\n");
|
|
1884
|
-
const first = firstRun(input);
|
|
1885
|
-
return ready(input, imageTag, {
|
|
1886
|
-
steps: [
|
|
1887
|
-
"Run on the Proxmox HOST (needs `pct`), not inside a container. It asks nothing: everything is an environment variable (defaults: VMID 200 hub / 201 agent, 8 cores, 8 GB, 64 GB disk, bridge vmbr0).",
|
|
1888
|
-
`${vars.join(" ")} bash -c "$(curl -fsSL ${url})"`,
|
|
1889
|
-
"It builds an Ubuntu 24.04 LXC with Docker, passes through the accelerators the host has (PASS_ACCEL=none skips it) and starts CamStack.",
|
|
1890
|
-
...first
|
|
1891
|
-
],
|
|
1892
|
-
files: [
|
|
1893
|
-
{
|
|
1894
|
-
path: "install-camstack-proxmox.sh (header)",
|
|
1895
|
-
body: header,
|
|
1896
|
-
source: url
|
|
1897
|
-
}
|
|
1898
|
-
],
|
|
1899
|
-
edits: [
|
|
1900
|
-
`The script defaults to the amd image; IMAGE_TAG above sets ${imageTag}. ${NO_BARE_LATEST}`,
|
|
1901
|
-
"A hub and an agent on one machine need two CTs (host networking collides otherwise)."
|
|
1902
|
-
]
|
|
1903
|
-
});
|
|
1904
|
-
}
|
|
1905
|
-
async function macGuide(input, fetchFn) {
|
|
1906
|
-
const url = TEMPLATE_URLS.latestRelease;
|
|
1907
|
-
const fetched = await fetchText(url, fetchFn);
|
|
1908
|
-
if (!fetched.ok) return fetched.failure;
|
|
1909
|
-
let release;
|
|
1910
|
-
try {
|
|
1911
|
-
const parsed = ReleaseSchema.safeParse(JSON.parse(fetched.body));
|
|
1912
|
-
if (!parsed.success) {
|
|
1913
|
-
return {
|
|
1914
|
-
status: "fetch-failed",
|
|
1915
|
-
url,
|
|
1916
|
-
error: "the release answer had no assets"
|
|
1917
|
-
};
|
|
1918
|
-
}
|
|
1919
|
-
release = parsed.data;
|
|
1920
|
-
} catch {
|
|
1921
|
-
return {
|
|
1922
|
-
status: "fetch-failed",
|
|
1923
|
-
url,
|
|
1924
|
-
error: "the release answer was not JSON"
|
|
1925
|
-
};
|
|
1926
|
-
}
|
|
1927
|
-
const dmgs = release.assets.filter((a) => DMG.test(a.name));
|
|
1928
|
-
const first = firstRun(input);
|
|
1929
|
-
return ready(input, null, {
|
|
1930
|
-
steps: [
|
|
1931
|
-
`Download the disk image for your Mac from release ${release.tag_name}: ${dmgs.map((a) => `${a.name} (${a.browser_download_url})`).join(" or ")}. Apple silicon (M1 and later) takes arm64; Intel takes x64. The CamStackViewer files are a different app.`,
|
|
1932
|
-
"Drag CamStack into Applications and open it.",
|
|
1933
|
-
`The setup window asks what this Mac should be: choose ${input.role === "hub" ? "Hub (full server)" : "Agent (worker)"}, then Save and start.`,
|
|
1934
|
-
...first
|
|
1935
|
-
],
|
|
1936
|
-
files: [],
|
|
1937
|
-
edits: ["A Mac uses the app, not Docker: a container there cannot reach the Neural Engine."]
|
|
1938
|
-
});
|
|
1939
|
-
}
|
|
1940
|
-
function firstRun(input) {
|
|
1941
|
-
const host = input.hostKind === "macos-desktop" ? "localhost" : "<host>";
|
|
1942
|
-
if (input.role === "hub") return firstRunHub(host);
|
|
1943
|
-
return [
|
|
1944
|
-
"The agent status dashboard answers on http://<agent-host>:4444.",
|
|
1945
|
-
"Pair it from the hub: run setup_add_node (it lists agents found on the network), then in the admin UI Cluster > Nodes > Agents found on the network. Type the six digits shown on the other screen on BOTH sides."
|
|
1946
|
-
];
|
|
1947
|
-
}
|
|
1948
|
-
function ready(input, imageTag, body) {
|
|
1949
|
-
return {
|
|
1950
|
-
status: "ready",
|
|
1951
|
-
hostKind: input.hostKind,
|
|
1952
|
-
role: input.role,
|
|
1953
|
-
imageTag,
|
|
1954
|
-
steps: body.steps,
|
|
1955
|
-
files: body.files,
|
|
1956
|
-
edits: body.edits,
|
|
1957
|
-
mustNotOmit: input.hostKind === "macos-desktop" ? [] : MUST_NOT_OMIT
|
|
1958
|
-
};
|
|
1959
|
-
}
|
|
1960
|
-
async function installGuide(input, fetchFn) {
|
|
1961
|
-
const questions = missingQuestions(input);
|
|
1962
|
-
if (questions.length > 0) return { status: "need-answers", questions };
|
|
1963
|
-
const { hostKind, role } = input;
|
|
1964
|
-
if (hostKind === void 0 || role === void 0) {
|
|
1965
|
-
return { status: "need-answers", questions: missingQuestions({}) };
|
|
1966
|
-
}
|
|
1967
|
-
const resolved = {
|
|
1968
|
-
hostKind,
|
|
1969
|
-
role,
|
|
1970
|
-
accelerator: hostKind === "macos-desktop" ? "apple" : input.accelerator ?? "cpu",
|
|
1971
|
-
paths: input.paths,
|
|
1972
|
-
hubAddress: input.hubAddress
|
|
1973
|
-
};
|
|
1974
|
-
switch (hostKind) {
|
|
1975
|
-
case "docker-compose":
|
|
1976
|
-
return composeGuide(resolved, fetchFn, false);
|
|
1977
|
-
case "docker":
|
|
1978
|
-
return composeGuide(resolved, fetchFn, true);
|
|
1979
|
-
case "unraid":
|
|
1980
|
-
return unraidGuide(resolved, fetchFn);
|
|
1981
|
-
case "proxmox-lxc":
|
|
1982
|
-
return proxmoxGuide(resolved, fetchFn);
|
|
1983
|
-
case "macos-desktop":
|
|
1984
|
-
return macGuide(resolved, fetchFn);
|
|
1985
|
-
}
|
|
1986
|
-
}
|
|
1987
|
-
|
|
1988
|
-
// src/mcp/setup-hubs.ts
|
|
1989
|
-
var DEFAULT_DISCOVER_TIMEOUT_MS = 2500;
|
|
1990
|
-
var MAX_DISCOVER_TIMEOUT_MS = 15e3;
|
|
1991
|
-
var HEALTH_TIMEOUT_MS = 5e3;
|
|
1992
|
-
var LOGIN = (server) => `run \`camstack login ${server}\` in a terminal`;
|
|
1993
|
-
var httpsHealth = (url, tls) => new Promise((resolve) => {
|
|
1994
|
-
const req = https.get(
|
|
1995
|
-
`${url}/health`,
|
|
1996
|
-
{ agent: createHubAgent(tls), timeout: HEALTH_TIMEOUT_MS },
|
|
1997
|
-
(res) => {
|
|
1998
|
-
res.resume();
|
|
1999
|
-
resolve({ ok: true, status: res.statusCode ?? 0 });
|
|
2000
|
-
}
|
|
2001
|
-
);
|
|
2002
|
-
req.on("timeout", () => req.destroy(new Error("timed out")));
|
|
2003
|
-
req.on("error", (err) => {
|
|
2004
|
-
resolve({
|
|
2005
|
-
ok: false,
|
|
2006
|
-
error: err.message,
|
|
2007
|
-
tlsHint: explainTlsFailure(url, err)
|
|
2008
|
-
});
|
|
2009
|
-
});
|
|
2010
|
-
});
|
|
2011
|
-
async function discoverHubsTool(timeoutMs, deps) {
|
|
2012
|
-
const wait = Math.min(timeoutMs ?? DEFAULT_DISCOVER_TIMEOUT_MS, MAX_DISCOVER_TIMEOUT_MS);
|
|
2013
|
-
const finder = deps.findHubs ?? ((ms) => findHubs({
|
|
2014
|
-
mdnsTimeoutMs: ms,
|
|
2015
|
-
...deps.sessionDir !== void 0 ? { sessionDir: deps.sessionDir } : {},
|
|
2016
|
-
...deps.env !== void 0 ? { env: deps.env } : {}
|
|
2017
|
-
}));
|
|
2018
|
-
const found = await finder(wait);
|
|
2019
|
-
const hubs = found.map((h) => ({
|
|
2020
|
-
host: h.host,
|
|
2021
|
-
port: h.port,
|
|
2022
|
-
name: h.nodeId,
|
|
2023
|
-
version: h.version,
|
|
2024
|
-
sources: h.sources
|
|
2025
|
-
}));
|
|
2026
|
-
return {
|
|
2027
|
-
status: "ok",
|
|
2028
|
-
hubs,
|
|
2029
|
-
next: hubs.length === 0 ? "no hub answered: install one with setup_install_guide, or pass its address to setup_connect" : "pass the host of the hub you want to setup_connect"
|
|
2030
|
-
};
|
|
2031
|
-
}
|
|
2032
|
-
function pinnedTls(url, deps) {
|
|
2033
|
-
const caFile = hubCaFileForServer(url, deps.sessionDir);
|
|
2034
|
-
try {
|
|
2035
|
-
return {
|
|
2036
|
-
tls: resolveHubTls(url, deps.env ?? process.env, deps.sessionDir),
|
|
2037
|
-
caFile
|
|
2038
|
-
};
|
|
2039
|
-
} catch {
|
|
2040
|
-
return { tls: {}, caFile };
|
|
2041
|
-
}
|
|
2042
|
-
}
|
|
2043
|
-
async function connectTool(server, deps) {
|
|
2044
|
-
let url;
|
|
2045
|
-
try {
|
|
2046
|
-
url = normalizeHubAddress(server);
|
|
2047
|
-
} catch (err) {
|
|
2048
|
-
const message = err instanceof HubAddressError ? err.message : String(err);
|
|
2049
|
-
return { status: "invalid-address", server, message };
|
|
2050
|
-
}
|
|
2051
|
-
const { tls, caFile } = pinnedTls(url, deps);
|
|
2052
|
-
const outcome = await (deps.health ?? httpsHealth)(url, tls);
|
|
2053
|
-
const caPinned = tls.ca !== void 0;
|
|
2054
|
-
const session = loadSession(url, deps.sessionDir);
|
|
2055
|
-
const sessionInfo = {
|
|
2056
|
-
exists: session !== null,
|
|
2057
|
-
username: session?.username ?? null
|
|
2058
|
-
};
|
|
2059
|
-
if (!outcome.ok) {
|
|
2060
|
-
return {
|
|
2061
|
-
status: "unreachable",
|
|
2062
|
-
server: url,
|
|
2063
|
-
message: outcome.error,
|
|
2064
|
-
tls: { caPinned, caFile, hint: outcome.tlsHint },
|
|
2065
|
-
session: sessionInfo,
|
|
2066
|
-
next: outcome.tlsHint === null ? "check the host, that the hub is running, and that port 4443 is reachable" : `the hub answers but its certificate is not trusted: ${LOGIN(url)}; login pins the CA (never disable verification)`
|
|
2067
|
-
};
|
|
2068
|
-
}
|
|
2069
|
-
return {
|
|
2070
|
-
status: "reachable",
|
|
2071
|
-
server: url,
|
|
2072
|
-
health: { status: outcome.status },
|
|
2073
|
-
tls: { caPinned, caFile, hint: null },
|
|
2074
|
-
session: sessionInfo,
|
|
2075
|
-
next: session === null ? LOGIN(url) : "a session exists: the ops tools can use it"
|
|
2076
|
-
};
|
|
2077
|
-
}
|
|
2078
|
-
var DiscoveredAgentsSchema = z10.object({
|
|
2079
|
-
agents: z10.array(
|
|
2080
|
-
z10.object({
|
|
2081
|
-
nodeId: z10.string(),
|
|
2082
|
-
host: z10.string(),
|
|
2083
|
-
port: z10.number(),
|
|
2084
|
-
version: z10.string().nullable(),
|
|
2085
|
-
name: z10.string().nullable(),
|
|
2086
|
-
state: z10.enum(["unpaired", "pending", "paired"])
|
|
2087
|
-
})
|
|
2088
|
-
)
|
|
2089
|
-
});
|
|
2090
|
-
var PAIRING_STEPS = [
|
|
2091
|
-
"Pairing is started from the HUB (D857): admin UI, Cluster > Nodes > Agents found on the network. Pick the agent, or add it by address (host and port 4444).",
|
|
2092
|
-
"Both screens now show six digits. Type the digits shown on the OTHER screen, on BOTH sides: the hub operator types the code the agent shows, and the agent operator confirms on the agent.",
|
|
2093
|
-
"The agent installs its certificate only after its own operator confirmed. Nothing here (and no tool of this MCP) confirms a pairing for you.",
|
|
2094
|
-
"The agent then appears under Cluster > Nodes."
|
|
2095
|
-
];
|
|
2096
|
-
async function addNodeTool(input, ctx) {
|
|
2097
|
-
const guide = await installGuide({ ...input, role: "agent" }, ctx.fetch);
|
|
2098
|
-
const outcome = await ctx.hub();
|
|
2099
|
-
if (!outcome.ok) {
|
|
2100
|
-
return {
|
|
2101
|
-
guide,
|
|
2102
|
-
pairing: PAIRING_STEPS,
|
|
2103
|
-
agentsOnNetwork: { status: "no-session", action: outcome.action }
|
|
2104
|
-
};
|
|
2105
|
-
}
|
|
2106
|
-
try {
|
|
2107
|
-
const raw = await outcome.connection.client.call("GET", "agentPairing.listDiscoveredAgents", {});
|
|
2108
|
-
const parsed = DiscoveredAgentsSchema.safeParse(raw);
|
|
2109
|
-
return {
|
|
2110
|
-
guide,
|
|
2111
|
-
pairing: PAIRING_STEPS,
|
|
2112
|
-
agentsOnNetwork: parsed.success ? { status: "ok", agents: parsed.data.agents } : {
|
|
2113
|
-
status: "unavailable",
|
|
2114
|
-
error: "the hub answered in an unexpected shape"
|
|
2115
|
-
}
|
|
2116
|
-
};
|
|
2117
|
-
} catch (err) {
|
|
2118
|
-
return {
|
|
2119
|
-
guide,
|
|
2120
|
-
pairing: PAIRING_STEPS,
|
|
2121
|
-
agentsOnNetwork: {
|
|
2122
|
-
status: "unavailable",
|
|
2123
|
-
error: err instanceof Error ? err.message : String(err)
|
|
2124
|
-
}
|
|
2125
|
-
};
|
|
2126
|
-
}
|
|
2127
|
-
}
|
|
2128
|
-
|
|
2129
|
-
// src/mcp/setup-tools.ts
|
|
2130
|
-
var SUPPORTED_HOST_KINDS = HOST_KINDS;
|
|
2131
|
-
var REGISTRY = "https://registry.npmjs.org";
|
|
2132
|
-
var REGISTRY_TIMEOUT_MS = 5e3;
|
|
2133
|
-
var RegistryLatestSchema = z11.object({ version: z11.string() });
|
|
2134
|
-
async function latestOnNpm(name, fetchFn) {
|
|
2135
|
-
const url = `${REGISTRY}/${name.replace("/", "%2f")}/latest`;
|
|
2136
|
-
try {
|
|
2137
|
-
const res = await fetchFn(url, {
|
|
2138
|
-
signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS)
|
|
2139
|
-
});
|
|
2140
|
-
if (!res.ok) return { latest: null, error: `registry answered ${res.status}` };
|
|
2141
|
-
const parsed = RegistryLatestSchema.safeParse(await res.json());
|
|
2142
|
-
if (!parsed.success) return { latest: null, error: "registry answer had no version" };
|
|
2143
|
-
return { latest: parsed.data.version };
|
|
2144
|
-
} catch (err) {
|
|
2145
|
-
return {
|
|
2146
|
-
latest: null,
|
|
2147
|
-
error: err instanceof Error ? err.message : String(err)
|
|
2148
|
-
};
|
|
2149
|
-
}
|
|
2150
|
-
}
|
|
2151
|
-
async function about(ctx) {
|
|
2152
|
-
const [cli, server] = await Promise.all([
|
|
2153
|
-
latestOnNpm("camstack", ctx.fetch),
|
|
2154
|
-
latestOnNpm("@camstack/server", ctx.fetch)
|
|
2155
|
-
]);
|
|
2156
|
-
return {
|
|
2157
|
-
cli: { installed: ctx.cliVersion, ...cli },
|
|
2158
|
-
server,
|
|
2159
|
-
supportedHostKinds: SUPPORTED_HOST_KINDS
|
|
2160
|
-
};
|
|
2161
|
-
}
|
|
2162
|
-
function registerSetupTools(server, ctx, deps = {}) {
|
|
2163
|
-
server.tool(
|
|
2164
|
-
"setup_about",
|
|
2165
|
-
"Versions of the camstack CLI (installed and latest on npm), the latest @camstack/server on npm, and the host kinds CamStack supports. Needs no hub and no login.",
|
|
2166
|
-
{},
|
|
2167
|
-
async () => text3(await about(ctx))
|
|
2168
|
-
);
|
|
2169
|
-
server.tool(
|
|
2170
|
-
"setup_install_guide",
|
|
2171
|
-
'Interview-style install guide. Call it with whatever you know; while an answer is missing it returns {status:"need-answers", questions} listing only what is still unknown. When complete it returns the steps and the OFFICIAL template files (fetched live) for that route: docker-compose, docker, unraid, proxmox-lxc or macos-desktop. Start with a hub.',
|
|
2172
|
-
{
|
|
2173
|
-
hostKind: HostKindSchema.optional(),
|
|
2174
|
-
role: RoleSchema.optional(),
|
|
2175
|
-
accelerator: AcceleratorSchema.optional(),
|
|
2176
|
-
paths: InstallPathsSchema.optional(),
|
|
2177
|
-
hubAddress: z11.string().min(1).optional().describe("Agents only: the hub to dial.")
|
|
2178
|
-
},
|
|
2179
|
-
async (input) => text3(await installGuide(input, ctx.fetch))
|
|
2180
|
-
);
|
|
2181
|
-
server.tool(
|
|
2182
|
-
"setup_discover_hubs",
|
|
2183
|
-
"Find CamStack hubs on the local network (mDNS) plus the hubs this machine already knows, with host, port, name and version. Needs no login.",
|
|
2184
|
-
{ timeoutMs: z11.number().int().min(200).max(15e3).optional() },
|
|
2185
|
-
async ({ timeoutMs }) => text3(await discoverHubsTool(timeoutMs, deps))
|
|
2186
|
-
);
|
|
2187
|
-
server.tool(
|
|
2188
|
-
"setup_connect",
|
|
2189
|
-
"Check that a hub answers on https://<server>:4443/health, whether its CA is already pinned for TLS, and whether a `camstack login` session exists. Never takes a password: when there is no session it says to run `camstack login <server>` in a terminal.",
|
|
2190
|
-
{ server: z11.string().min(1) },
|
|
2191
|
-
async ({ server: address }) => text3(await connectTool(address, deps))
|
|
2192
|
-
);
|
|
2193
|
-
server.tool(
|
|
2194
|
-
"setup_add_node",
|
|
2195
|
-
"Guide to add an AGENT (a second machine) to a hub: the install route for that host, then the pairing flow where the operator types the six digits shown on the other screen on both sides. With a login session it also lists the agents found on the network. It never confirms a pairing.",
|
|
2196
|
-
{
|
|
2197
|
-
hostKind: HostKindSchema.optional(),
|
|
2198
|
-
accelerator: AcceleratorSchema.optional(),
|
|
2199
|
-
paths: InstallPathsSchema.optional(),
|
|
2200
|
-
hubAddress: z11.string().min(1).optional()
|
|
2201
|
-
},
|
|
2202
|
-
async (input) => text3(await addNodeTool(input, ctx))
|
|
2203
|
-
);
|
|
2204
|
-
}
|
|
2205
|
-
|
|
2206
|
-
// src/mcp/server.ts
|
|
2207
|
-
function createPublicServer(options) {
|
|
2208
|
-
const server = new McpServer({ name: "camstack", version: options.context.cliVersion });
|
|
2209
|
-
const { toolsets, context } = options;
|
|
2210
|
-
if (toolsets.includes("setup")) registerSetupTools(server, context);
|
|
2211
|
-
if (toolsets.includes("ops")) registerOpsTools(server, context);
|
|
2212
|
-
if (toolsets.includes("configure")) registerConfigureTools(server, context);
|
|
2213
|
-
if (toolsets.includes("develop") && options.registerDevelop) options.registerDevelop(server);
|
|
2214
|
-
return server;
|
|
2215
|
-
}
|
|
2216
|
-
function sessionHub(options) {
|
|
2217
|
-
return () => tryConnectHub({
|
|
2218
|
-
workspaceRoot: process.cwd(),
|
|
2219
|
-
...options.server ? { server: options.server } : {},
|
|
2220
|
-
...options.tokenFile ? { tokenFile: options.tokenFile } : {}
|
|
2221
|
-
});
|
|
2222
|
-
}
|
|
2223
|
-
var registryFetch = (url, init) => fetch(url, init);
|
|
2224
|
-
async function runPublicMcp(options) {
|
|
2225
|
-
const gate = new MutationGate({ allowMutations: true });
|
|
2226
|
-
const context = {
|
|
2227
|
-
cliVersion: options.cliVersion,
|
|
2228
|
-
hub: sessionHub(options),
|
|
2229
|
-
gate,
|
|
2230
|
-
fetch: registryFetch
|
|
2231
|
-
};
|
|
2232
|
-
const registerDevelop = options.toolsets.includes("develop") ? (await import("@camstack/mcp-develop")).registerDevelopTools : void 0;
|
|
2233
|
-
const server = createPublicServer({
|
|
2234
|
-
toolsets: options.toolsets,
|
|
2235
|
-
context,
|
|
2236
|
-
...registerDevelop ? { registerDevelop } : {}
|
|
2237
|
-
});
|
|
2238
|
-
await server.connect(new StdioServerTransport());
|
|
2239
|
-
}
|
|
2240
|
-
export {
|
|
2241
|
-
createPublicServer,
|
|
2242
|
-
runPublicMcp
|
|
2243
|
-
};
|