@thinremote/thinr-cli 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/README.md +230 -16
- package/bin/thinr.js +23 -24
- package/commands/_shared.js +154 -0
- package/commands/dashboard.js +17 -0
- package/commands/device/_shared.js +17 -34
- package/commands/device/console.js +1 -0
- package/commands/device/delete.js +53 -0
- package/commands/device/exec.js +7 -6
- package/commands/device/fs.js +193 -0
- package/commands/device/index.js +9 -0
- package/commands/device/list.js +35 -23
- package/commands/device/logs.js +178 -0
- package/commands/device/property.js +30 -28
- package/commands/device/proxy.js +3 -0
- package/commands/device/resource.js +30 -29
- package/commands/device/status.js +35 -14
- package/commands/device/transfer.js +236 -0
- package/commands/device/update.js +15 -10
- package/commands/fleet/index.js +17 -0
- package/commands/fleet/upgrade.js +275 -0
- package/commands/logout.js +10 -11
- package/commands/playbook/_shared.js +12 -0
- package/commands/playbook/index.js +18 -0
- package/commands/playbook/run.js +302 -0
- package/commands/playbook/validate.js +38 -0
- package/commands/product/_shared.js +8 -0
- package/commands/product/exec.js +243 -0
- package/commands/product/fs.js +218 -0
- package/commands/product/index.js +35 -0
- package/commands/product/list.js +86 -0
- package/commands/product/logs.js +289 -0
- package/commands/product/metric.js +124 -0
- package/commands/product/playbook.js +911 -0
- package/commands/product/property.js +76 -0
- package/commands/product/props.js +125 -0
- package/commands/product/push.js +184 -0
- package/commands/product/resource.js +76 -0
- package/commands/product/status.js +213 -0
- package/commands/profile.js +76 -8
- package/dist/dashboard.js +5644 -0
- package/lib/agent-releases.js +59 -0
- package/lib/agent-versions.js +73 -0
- package/lib/alarms.js +252 -0
- package/lib/api.js +8 -2
- package/lib/auth.js +30 -17
- package/lib/bucket.js +87 -0
- package/lib/command-stream.js +199 -0
- package/lib/concurrency.js +52 -0
- package/lib/config.js +5 -3
- package/lib/console.js +12 -4
- package/lib/constants.js +76 -0
- package/lib/dashboard/event-stream.js +239 -0
- package/lib/dashboard/metric-aggregate.js +64 -0
- package/lib/dashboard/metric-filter.js +39 -0
- package/lib/debug-log.js +93 -0
- package/lib/device-api.js +109 -147
- package/lib/devices.js +18 -21
- package/lib/fleet-upgrade.js +106 -0
- package/lib/format.js +114 -0
- package/lib/mcp/registry.js +14 -0
- package/lib/mcp/tools-alarms.js +510 -0
- package/lib/mcp/tools-buckets.js +103 -0
- package/lib/mcp/tools-devices.js +33 -3
- package/lib/mcp/tools-exec.js +30 -17
- package/lib/mcp/tools-filesystem.js +102 -6
- package/lib/mcp/tools-install.js +153 -0
- package/lib/mcp/tools-logs.js +225 -0
- package/lib/mcp/tools-playbook.js +211 -0
- package/lib/mcp/tools-products.js +1343 -10
- package/lib/mcp/tools-profile.js +634 -0
- package/lib/mcp/tools-tokens.js +371 -0
- package/lib/monitoring.js +26 -1
- package/lib/playbook/actions.js +447 -0
- package/lib/playbook/expression.js +302 -0
- package/lib/playbook/fleet.js +175 -0
- package/lib/playbook/loader.js +247 -0
- package/lib/playbook/runner.js +300 -0
- package/lib/playbook/schema.js +209 -0
- package/lib/playbook/vars.js +209 -0
- package/lib/product/log-presets.js +196 -0
- package/lib/product/logs.js +485 -0
- package/lib/product-orchestrator.js +124 -0
- package/lib/product.js +523 -20
- package/lib/profile.js +665 -0
- package/lib/property.js +7 -40
- package/lib/proxy.js +9 -8
- package/lib/resource.js +10 -14
- package/lib/status.js +78 -37
- package/lib/tokens.js +374 -0
- package/package.json +15 -3
- package/commands/product.js +0 -167
|
@@ -1,13 +1,41 @@
|
|
|
1
1
|
// @ts-check
|
|
2
|
+
import { Buffer } from 'node:buffer';
|
|
2
3
|
import {
|
|
3
4
|
getProducts,
|
|
5
|
+
getProduct,
|
|
6
|
+
createProduct,
|
|
7
|
+
productExists,
|
|
8
|
+
updateProduct,
|
|
4
9
|
deleteProductWithStorage,
|
|
5
10
|
installProductScript,
|
|
6
11
|
removeProductScript,
|
|
7
12
|
listProductScripts,
|
|
8
13
|
readProductScript,
|
|
9
14
|
getProductApi,
|
|
15
|
+
getProductProperties,
|
|
16
|
+
getProductProperty,
|
|
17
|
+
setProductProperty,
|
|
18
|
+
deleteProductProperty,
|
|
19
|
+
listDashboardMetrics,
|
|
20
|
+
upsertDashboardMetric,
|
|
21
|
+
removeDashboardMetric,
|
|
22
|
+
DASHBOARD_METRICS_PROPERTY,
|
|
23
|
+
findProductPlaybook,
|
|
24
|
+
listProductPlaybooks,
|
|
25
|
+
readProductPlaybook,
|
|
26
|
+
uploadProductPlaybook,
|
|
27
|
+
deleteProductPlaybook,
|
|
28
|
+
uploadFleetRunReport,
|
|
10
29
|
} from '../product.js';
|
|
30
|
+
import { createDeviceAPI } from '../device-api.js';
|
|
31
|
+
import { TIMEOUTS } from '../constants.js';
|
|
32
|
+
import { runProductFanOut } from '../product-orchestrator.js';
|
|
33
|
+
import { requireConfig } from '../config.js';
|
|
34
|
+
import { filterActiveDevices, getDevices } from '../devices.js';
|
|
35
|
+
import { parsePlaybook } from '../playbook/loader.js';
|
|
36
|
+
import { buildDryRunPlan, runPlaybook } from '../playbook/runner.js';
|
|
37
|
+
import { DEFAULT_FAILURE_THRESHOLD, runFleetPlaybook } from '../playbook/fleet.js';
|
|
38
|
+
import { listVariables, resolveVarScope } from '../playbook/vars.js';
|
|
11
39
|
import { inputError } from '../errors.js';
|
|
12
40
|
|
|
13
41
|
async function toolProducts(args) {
|
|
@@ -33,6 +61,104 @@ async function toolProducts(args) {
|
|
|
33
61
|
};
|
|
34
62
|
}
|
|
35
63
|
|
|
64
|
+
async function toolProductCreate(args) {
|
|
65
|
+
if (!args.product) throw inputError('product is required');
|
|
66
|
+
if (!/^[A-Za-z0-9._-]+$/.test(args.product)) {
|
|
67
|
+
throw inputError(
|
|
68
|
+
`Invalid product id "${args.product}" (allowed: letters, digits, dot, dash, underscore)`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
if (await productExists(args.product, args.user)) {
|
|
72
|
+
const err = new Error(`Product "${args.product}" already exists`);
|
|
73
|
+
err.code = 'already_exists';
|
|
74
|
+
throw err;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
let iconSpec;
|
|
78
|
+
if (args.icon) {
|
|
79
|
+
if (!args.icon.source) throw inputError('icon.source is required when icon is set');
|
|
80
|
+
const type = args.icon.type || 'fa';
|
|
81
|
+
if (type !== 'fa' && type !== 'svg') {
|
|
82
|
+
throw inputError(`icon.type must be "fa" or "svg" (got "${type}")`);
|
|
83
|
+
}
|
|
84
|
+
iconSpec = {
|
|
85
|
+
type,
|
|
86
|
+
source: args.icon.source,
|
|
87
|
+
color: args.icon.color || '#000000',
|
|
88
|
+
background: args.icon.background || '#ffffff',
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
await createProduct(args.product, args.user, {
|
|
93
|
+
name: args.name,
|
|
94
|
+
description: args.description,
|
|
95
|
+
...(iconSpec ? { icon: iconSpec } : {}),
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
content: [
|
|
100
|
+
{
|
|
101
|
+
type: 'text',
|
|
102
|
+
text: `Created product "${args.product}"${args.name ? ` (${args.name})` : ''}.`,
|
|
103
|
+
},
|
|
104
|
+
],
|
|
105
|
+
isError: false,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function toolProductUpdate(args) {
|
|
110
|
+
if (!args.product) throw inputError('product is required');
|
|
111
|
+
|
|
112
|
+
const patch = {};
|
|
113
|
+
if (args.name !== undefined) patch.name = args.name;
|
|
114
|
+
if (args.description !== undefined) patch.description = args.description;
|
|
115
|
+
if (args.enabled !== undefined) patch.enabled = !!args.enabled;
|
|
116
|
+
|
|
117
|
+
if (args.icon) {
|
|
118
|
+
if (!args.icon.source) throw inputError('icon.source is required when icon is set');
|
|
119
|
+
const type = args.icon.type || 'fa';
|
|
120
|
+
if (type !== 'fa' && type !== 'svg') {
|
|
121
|
+
throw inputError(`icon.type must be "fa" or "svg" (got "${type}")`);
|
|
122
|
+
}
|
|
123
|
+
// Fetch current config so the icon update doesn't clobber unrelated
|
|
124
|
+
// fields (`type`, custom dashboards, profile-side bits the API
|
|
125
|
+
// groups under config).
|
|
126
|
+
const current = await getProduct(args.product, args.user);
|
|
127
|
+
const config = { ...(current?.config || {}) };
|
|
128
|
+
config.icons = [
|
|
129
|
+
{
|
|
130
|
+
conditions: [],
|
|
131
|
+
icon: {
|
|
132
|
+
type,
|
|
133
|
+
source: args.icon.source,
|
|
134
|
+
color: args.icon.color || '#000000',
|
|
135
|
+
background: args.icon.background || '#ffffff',
|
|
136
|
+
},
|
|
137
|
+
},
|
|
138
|
+
];
|
|
139
|
+
patch.config = config;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const fields = Object.keys(patch);
|
|
143
|
+
if (fields.length === 0) {
|
|
144
|
+
throw inputError(
|
|
145
|
+
'at least one of name, description, enabled, icon must be provided',
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
await updateProduct(args.product, args.user, patch);
|
|
150
|
+
|
|
151
|
+
const summary = fields
|
|
152
|
+
.map((f) => (f === 'config' ? 'icon' : f))
|
|
153
|
+
.join(', ');
|
|
154
|
+
return {
|
|
155
|
+
content: [
|
|
156
|
+
{ type: 'text', text: `Updated product "${args.product}" (${summary}).` },
|
|
157
|
+
],
|
|
158
|
+
isError: false,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
36
162
|
async function toolProductDelete(args) {
|
|
37
163
|
if (!args.product) throw inputError('product is required');
|
|
38
164
|
const { steps } = await deleteProductWithStorage(args.product, args.user, {
|
|
@@ -79,7 +205,8 @@ async function toolProductScriptWrite(args) {
|
|
|
79
205
|
name: args.name,
|
|
80
206
|
content: args.content,
|
|
81
207
|
user: args.user,
|
|
82
|
-
|
|
208
|
+
input: args.input,
|
|
209
|
+
output: args.output,
|
|
83
210
|
});
|
|
84
211
|
const lines = [
|
|
85
212
|
`Installed product script "${args.name}" on "${args.product}".`,
|
|
@@ -93,6 +220,135 @@ async function toolProductScriptWrite(args) {
|
|
|
93
220
|
};
|
|
94
221
|
}
|
|
95
222
|
|
|
223
|
+
async function toolProductPropertyGet(args) {
|
|
224
|
+
if (!args.product) throw inputError('product is required');
|
|
225
|
+
if (args.property) {
|
|
226
|
+
const value = await getProductProperty(args.product, args.property, args.user);
|
|
227
|
+
return {
|
|
228
|
+
content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
229
|
+
isError: false,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
const list = await getProductProperties(args.product, args.user);
|
|
233
|
+
const names = Array.isArray(list) ? list.map((p) => p.property).filter(Boolean) : [];
|
|
234
|
+
return {
|
|
235
|
+
content: [{ type: 'text', text: names.length ? names.join('\n') : '(no properties)' }],
|
|
236
|
+
isError: false,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
async function toolProductPropertySet(args) {
|
|
241
|
+
if (!args.product) throw inputError('product is required');
|
|
242
|
+
if (!args.property) throw inputError('property is required');
|
|
243
|
+
if (args.value === undefined) throw inputError('value is required');
|
|
244
|
+
const saved = await setProductProperty(args.product, args.property, args.value, args.user);
|
|
245
|
+
return {
|
|
246
|
+
content: [
|
|
247
|
+
{
|
|
248
|
+
type: 'text',
|
|
249
|
+
text: `Saved property "${saved.property}" on product "${saved.product || args.product}" (value: ${JSON.stringify(saved.value)})`,
|
|
250
|
+
},
|
|
251
|
+
],
|
|
252
|
+
isError: false,
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function toolProductPropertyDelete(args) {
|
|
257
|
+
if (!args.product) throw inputError('product is required');
|
|
258
|
+
if (!args.property) throw inputError('property is required');
|
|
259
|
+
const removed = await deleteProductProperty(args.product, args.property, args.user);
|
|
260
|
+
return {
|
|
261
|
+
content: [
|
|
262
|
+
{
|
|
263
|
+
type: 'text',
|
|
264
|
+
text: removed
|
|
265
|
+
? `Deleted property "${args.property}" from product "${args.product}".`
|
|
266
|
+
: `Property "${args.property}" was not set on product "${args.product}".`,
|
|
267
|
+
},
|
|
268
|
+
],
|
|
269
|
+
isError: false,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
async function toolProductMetricList(args) {
|
|
274
|
+
if (!args.product) throw inputError('product is required');
|
|
275
|
+
const metrics = await listDashboardMetrics(args.product, args.user);
|
|
276
|
+
if (metrics.length === 0) {
|
|
277
|
+
return {
|
|
278
|
+
content: [
|
|
279
|
+
{
|
|
280
|
+
type: 'text',
|
|
281
|
+
text: `No dashboard metrics configured on product "${args.product}" (property "${DASHBOARD_METRICS_PROPERTY}" is empty).`,
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
isError: false,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
const lines = metrics.map((m) => {
|
|
288
|
+
const parts = [
|
|
289
|
+
m.name,
|
|
290
|
+
`resource=${m.resource}`,
|
|
291
|
+
m.field ? `field=${m.field}` : null,
|
|
292
|
+
m.aggregation ? `agg=${m.aggregation}` : null,
|
|
293
|
+
m.visualization ? `viz=${m.visualization}` : null,
|
|
294
|
+
m.interval ? `interval=${m.interval}s` : null,
|
|
295
|
+
].filter(Boolean);
|
|
296
|
+
return ` - ${parts.join(' ')}${m.label ? ` · ${m.label}` : ''}`;
|
|
297
|
+
});
|
|
298
|
+
return {
|
|
299
|
+
content: [
|
|
300
|
+
{
|
|
301
|
+
type: 'text',
|
|
302
|
+
text: `${metrics.length} metric(s) on "${args.product}":\n${lines.join('\n')}`,
|
|
303
|
+
},
|
|
304
|
+
],
|
|
305
|
+
isError: false,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function toolProductMetricSet(args) {
|
|
310
|
+
if (!args.product) throw inputError('product is required');
|
|
311
|
+
const metric = {
|
|
312
|
+
name: args.name,
|
|
313
|
+
label: args.label,
|
|
314
|
+
resource: args.resource,
|
|
315
|
+
field: args.field,
|
|
316
|
+
aggregation: args.aggregation,
|
|
317
|
+
visualization: args.visualization,
|
|
318
|
+
interval: args.interval,
|
|
319
|
+
unit: args.unit,
|
|
320
|
+
};
|
|
321
|
+
for (const k of Object.keys(metric)) if (metric[k] === undefined) delete metric[k];
|
|
322
|
+
const { action, count } = await upsertDashboardMetric(args.product, metric, args.user);
|
|
323
|
+
return {
|
|
324
|
+
content: [
|
|
325
|
+
{
|
|
326
|
+
type: 'text',
|
|
327
|
+
text: `Metric "${metric.name}" ${action} on product "${args.product}" (total: ${count}).`,
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
isError: false,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function toolProductMetricDelete(args) {
|
|
335
|
+
if (!args.product) throw inputError('product is required');
|
|
336
|
+
if (!args.name) throw inputError('name is required');
|
|
337
|
+
const { action, count } = await removeDashboardMetric(args.product, args.name, args.user);
|
|
338
|
+
return {
|
|
339
|
+
content: [
|
|
340
|
+
{
|
|
341
|
+
type: 'text',
|
|
342
|
+
text:
|
|
343
|
+
action === 'removed'
|
|
344
|
+
? `Removed metric "${args.name}" from product "${args.product}" (remaining: ${count}).`
|
|
345
|
+
: `Metric "${args.name}" was not configured on product "${args.product}".`,
|
|
346
|
+
},
|
|
347
|
+
],
|
|
348
|
+
isError: false,
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
96
352
|
async function toolProductScriptDelete(args) {
|
|
97
353
|
const { steps } = await removeProductScript({
|
|
98
354
|
product: args.product,
|
|
@@ -110,6 +366,537 @@ async function toolProductScriptDelete(args) {
|
|
|
110
366
|
};
|
|
111
367
|
}
|
|
112
368
|
|
|
369
|
+
async function toolProductPlaybookList(args) {
|
|
370
|
+
if (!args.product) throw inputError('product is required');
|
|
371
|
+
const entries = await listProductPlaybooks(args.product, args.user);
|
|
372
|
+
if (entries.length === 0) {
|
|
373
|
+
return {
|
|
374
|
+
content: [
|
|
375
|
+
{
|
|
376
|
+
type: 'text',
|
|
377
|
+
text: `No playbooks registered on product "${args.product}".`,
|
|
378
|
+
},
|
|
379
|
+
],
|
|
380
|
+
isError: false,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
const lines = entries.map((e) => {
|
|
384
|
+
const desc = e.description ? ` — ${e.description}` : '';
|
|
385
|
+
return ` - ${e.name}${desc} (${e.path})`;
|
|
386
|
+
});
|
|
387
|
+
return {
|
|
388
|
+
content: [
|
|
389
|
+
{
|
|
390
|
+
type: 'text',
|
|
391
|
+
text: `${entries.length} playbook(s) on "${args.product}":\n${lines.join('\n')}`,
|
|
392
|
+
},
|
|
393
|
+
],
|
|
394
|
+
isError: false,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
async function toolProductPlaybookRead(args) {
|
|
399
|
+
if (!args.product) throw inputError('product is required');
|
|
400
|
+
if (!args.name) throw inputError('name is required');
|
|
401
|
+
const content = await readProductPlaybook(args.product, args.name, args.user);
|
|
402
|
+
return {
|
|
403
|
+
content: [{ type: 'text', text: content }],
|
|
404
|
+
isError: false,
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
async function toolProductPlaybookWrite(args) {
|
|
409
|
+
if (!args.product) throw inputError('product is required');
|
|
410
|
+
if (!args.name) throw inputError('name is required');
|
|
411
|
+
if (typeof args.content !== 'string') throw inputError('content (YAML string) is required');
|
|
412
|
+
const result = await uploadProductPlaybook({
|
|
413
|
+
product: args.product,
|
|
414
|
+
name: args.name,
|
|
415
|
+
content: args.content,
|
|
416
|
+
description: args.description,
|
|
417
|
+
user: args.user,
|
|
418
|
+
skipValidation: !!args.skip_validation,
|
|
419
|
+
});
|
|
420
|
+
const header = `Playbook "${result.entry.name}" ${result.action} on product "${args.product}"${
|
|
421
|
+
result.replaced ? ' (replaced existing entry)' : ''
|
|
422
|
+
}.`;
|
|
423
|
+
const steps = result.steps.map((s) => ` - ${s}`);
|
|
424
|
+
return {
|
|
425
|
+
content: [{ type: 'text', text: [header, ...steps].join('\n') }],
|
|
426
|
+
isError: false,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
async function toolProductPlaybookRun(args) {
|
|
431
|
+
if (!args.product) throw inputError('product is required');
|
|
432
|
+
if (!args.name) throw inputError('name is required');
|
|
433
|
+
if (!args.device) throw inputError('device is required (single-device only)');
|
|
434
|
+
|
|
435
|
+
const entry = await findProductPlaybook(args.product, args.name, args.user);
|
|
436
|
+
if (!entry) {
|
|
437
|
+
const available = await listProductPlaybooks(args.product, args.user);
|
|
438
|
+
throw inputError(
|
|
439
|
+
`Playbook "${args.name}" is not registered on product "${args.product}". Available: ${available.map((e) => e.name).join(', ') || '(none)'}`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const content = await readProductPlaybook(args.product, args.name, args.user);
|
|
444
|
+
const pb = parsePlaybook(content, { sourcePath: `${args.name}.yaml` });
|
|
445
|
+
|
|
446
|
+
const rawVars =
|
|
447
|
+
args.vars && typeof args.vars === 'object' && !Array.isArray(args.vars) ? args.vars : {};
|
|
448
|
+
|
|
449
|
+
let resolvedScope;
|
|
450
|
+
try {
|
|
451
|
+
resolvedScope = resolveVarScope(pb, rawVars);
|
|
452
|
+
} catch (err) {
|
|
453
|
+
throw inputError(err.message);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
pb.target.devices = [args.device];
|
|
457
|
+
pb.target.product = args.product;
|
|
458
|
+
pb.target.group = null;
|
|
459
|
+
|
|
460
|
+
if (args.dry_run) {
|
|
461
|
+
const plan = buildDryRunPlan(pb, { overrides: rawVars });
|
|
462
|
+
const payload = {
|
|
463
|
+
dry_run: true,
|
|
464
|
+
product: args.product,
|
|
465
|
+
name: args.name,
|
|
466
|
+
device: args.device,
|
|
467
|
+
variables: listVariables(pb),
|
|
468
|
+
vars: resolvedScope,
|
|
469
|
+
steps: plan,
|
|
470
|
+
};
|
|
471
|
+
return {
|
|
472
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
473
|
+
isError: false,
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const all = await getDevices({ product: args.product }, args.user);
|
|
478
|
+
const device =
|
|
479
|
+
all.find((d) => d.device === args.device) || {
|
|
480
|
+
device: args.device,
|
|
481
|
+
connection: { active: false },
|
|
482
|
+
};
|
|
483
|
+
|
|
484
|
+
const results = await runPlaybook(pb, [device], {
|
|
485
|
+
user: args.user,
|
|
486
|
+
concurrency: 1,
|
|
487
|
+
failFast: true,
|
|
488
|
+
checkMode: !!args.check,
|
|
489
|
+
overrides: rawVars,
|
|
490
|
+
});
|
|
491
|
+
const result = results[0];
|
|
492
|
+
const payload = {
|
|
493
|
+
product: args.product,
|
|
494
|
+
name: args.name,
|
|
495
|
+
device: args.device,
|
|
496
|
+
mode: args.check ? 'check' : 'apply',
|
|
497
|
+
ok: !!result?.ok,
|
|
498
|
+
result,
|
|
499
|
+
};
|
|
500
|
+
return {
|
|
501
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
502
|
+
isError: !result?.ok,
|
|
503
|
+
};
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
async function toolProductPlaybookRollout(args) {
|
|
507
|
+
if (!args.product) throw inputError('product is required');
|
|
508
|
+
if (!args.name) throw inputError('name is required');
|
|
509
|
+
|
|
510
|
+
const batchSize =
|
|
511
|
+
Number.isFinite(args.batch_size) && args.batch_size > 0
|
|
512
|
+
? Math.floor(args.batch_size)
|
|
513
|
+
: 5;
|
|
514
|
+
const failureThreshold =
|
|
515
|
+
args.failure_threshold === undefined || args.failure_threshold === null
|
|
516
|
+
? DEFAULT_FAILURE_THRESHOLD
|
|
517
|
+
: Number(args.failure_threshold);
|
|
518
|
+
if (!Number.isFinite(failureThreshold) || failureThreshold < 0 || failureThreshold > 100) {
|
|
519
|
+
throw inputError('failure_threshold must be a number between 0 and 100');
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const entry = await findProductPlaybook(args.product, args.name, args.user);
|
|
523
|
+
if (!entry) {
|
|
524
|
+
const available = await listProductPlaybooks(args.product, args.user);
|
|
525
|
+
throw inputError(
|
|
526
|
+
`Playbook "${args.name}" is not registered on product "${args.product}". Available: ${available.map((e) => e.name).join(', ') || '(none)'}`,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const content = await readProductPlaybook(args.product, args.name, args.user);
|
|
531
|
+
const pb = parsePlaybook(content, { sourcePath: `${args.name}.yaml` });
|
|
532
|
+
|
|
533
|
+
const rawVars =
|
|
534
|
+
args.vars && typeof args.vars === 'object' && !Array.isArray(args.vars) ? args.vars : {};
|
|
535
|
+
|
|
536
|
+
let resolvedScope;
|
|
537
|
+
try {
|
|
538
|
+
resolvedScope = resolveVarScope(pb, rawVars);
|
|
539
|
+
} catch (err) {
|
|
540
|
+
throw inputError(err.message);
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
const filters = {};
|
|
544
|
+
if (args.filter && typeof args.filter === 'object' && !Array.isArray(args.filter)) {
|
|
545
|
+
for (const [k, v] of Object.entries(args.filter)) {
|
|
546
|
+
if (!/^[A-Za-z0-9_]+$/.test(k)) {
|
|
547
|
+
throw inputError(`Invalid filter key "${k}"`);
|
|
548
|
+
}
|
|
549
|
+
filters[k] = v;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const baseFilter = { product: args.product };
|
|
554
|
+
if (args.group) baseFilter.asset_group = args.group;
|
|
555
|
+
Object.assign(baseFilter, filters);
|
|
556
|
+
let devices = await getDevices(baseFilter, args.user);
|
|
557
|
+
if (!args.include_offline) devices = filterActiveDevices(devices);
|
|
558
|
+
|
|
559
|
+
pb.target.devices = null;
|
|
560
|
+
pb.target.product = args.product;
|
|
561
|
+
pb.target.group = args.group || null;
|
|
562
|
+
|
|
563
|
+
if (args.dry_run) {
|
|
564
|
+
const plan = buildDryRunPlan(pb, { overrides: rawVars });
|
|
565
|
+
const payload = {
|
|
566
|
+
dry_run: true,
|
|
567
|
+
product: args.product,
|
|
568
|
+
name: args.name,
|
|
569
|
+
fleet: true,
|
|
570
|
+
device_count: devices.length,
|
|
571
|
+
devices: devices.map((d) => d.device),
|
|
572
|
+
batch_size: batchSize,
|
|
573
|
+
failure_threshold: failureThreshold,
|
|
574
|
+
variables: listVariables(pb),
|
|
575
|
+
vars: resolvedScope,
|
|
576
|
+
steps: plan,
|
|
577
|
+
};
|
|
578
|
+
return {
|
|
579
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
580
|
+
isError: false,
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (devices.length === 0) {
|
|
585
|
+
const payload = {
|
|
586
|
+
product: args.product,
|
|
587
|
+
name: args.name,
|
|
588
|
+
fleet: true,
|
|
589
|
+
device_count: 0,
|
|
590
|
+
aborted: false,
|
|
591
|
+
summary: { attempted: 0, succeeded: 0, failed: 0, failureRate: 0 },
|
|
592
|
+
message: 'no matching devices',
|
|
593
|
+
};
|
|
594
|
+
return {
|
|
595
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
596
|
+
isError: false,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
const startedAt = new Date().toISOString();
|
|
601
|
+
const outcome = await runFleetPlaybook(pb, devices, {
|
|
602
|
+
user: args.user,
|
|
603
|
+
batchSize,
|
|
604
|
+
failureThreshold,
|
|
605
|
+
overrides: rawVars,
|
|
606
|
+
checkMode: !!args.check,
|
|
607
|
+
});
|
|
608
|
+
const finishedAt = new Date().toISOString();
|
|
609
|
+
|
|
610
|
+
const summary = {
|
|
611
|
+
attempted: outcome.attempted,
|
|
612
|
+
succeeded: outcome.succeeded,
|
|
613
|
+
failed: outcome.failed,
|
|
614
|
+
failureRate: outcome.failureRate,
|
|
615
|
+
total: devices.length,
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const report = {
|
|
619
|
+
product: args.product,
|
|
620
|
+
name: args.name,
|
|
621
|
+
user: args.user || requireConfig().username,
|
|
622
|
+
startedAt,
|
|
623
|
+
finishedAt,
|
|
624
|
+
durationMs: new Date(finishedAt).getTime() - new Date(startedAt).getTime(),
|
|
625
|
+
mode: args.check ? 'check' : 'apply',
|
|
626
|
+
playbook: {
|
|
627
|
+
name: pb.name || null,
|
|
628
|
+
description: pb.description || null,
|
|
629
|
+
steps: pb.steps.length,
|
|
630
|
+
},
|
|
631
|
+
rollout: {
|
|
632
|
+
batchSize,
|
|
633
|
+
failureThreshold,
|
|
634
|
+
filters: Object.keys(filters).length > 0 ? filters : null,
|
|
635
|
+
group: args.group || null,
|
|
636
|
+
includeOffline: !!args.include_offline,
|
|
637
|
+
},
|
|
638
|
+
vars: resolvedScope,
|
|
639
|
+
summary,
|
|
640
|
+
aborted: outcome.aborted,
|
|
641
|
+
reason: outcome.reason,
|
|
642
|
+
batches: outcome.batches,
|
|
643
|
+
devices: outcome.results.map((r) => ({
|
|
644
|
+
device: r?.device,
|
|
645
|
+
ok: !!r?.ok,
|
|
646
|
+
skipped: !!r?.skipped,
|
|
647
|
+
error: r?.error || null,
|
|
648
|
+
})),
|
|
649
|
+
};
|
|
650
|
+
|
|
651
|
+
const upload = await uploadFleetRunReport({ product: args.product, report, user: args.user });
|
|
652
|
+
|
|
653
|
+
const payload = {
|
|
654
|
+
product: args.product,
|
|
655
|
+
name: args.name,
|
|
656
|
+
fleet: true,
|
|
657
|
+
mode: args.check ? 'check' : 'apply',
|
|
658
|
+
startedAt,
|
|
659
|
+
finishedAt,
|
|
660
|
+
batch_size: batchSize,
|
|
661
|
+
failure_threshold: failureThreshold,
|
|
662
|
+
device_count: devices.length,
|
|
663
|
+
batches: outcome.batches,
|
|
664
|
+
summary,
|
|
665
|
+
aborted: outcome.aborted,
|
|
666
|
+
reason: outcome.reason,
|
|
667
|
+
results: outcome.results,
|
|
668
|
+
report_uploaded: upload.ok,
|
|
669
|
+
report_path: upload.ok ? upload.path : null,
|
|
670
|
+
report_error: upload.ok ? null : upload.reason,
|
|
671
|
+
};
|
|
672
|
+
return {
|
|
673
|
+
content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
|
|
674
|
+
isError: outcome.aborted || summary.failed > 0,
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function toolProductPlaybookDelete(args) {
|
|
679
|
+
if (!args.product) throw inputError('product is required');
|
|
680
|
+
if (!args.name) throw inputError('name is required');
|
|
681
|
+
const result = await deleteProductPlaybook({
|
|
682
|
+
product: args.product,
|
|
683
|
+
name: args.name,
|
|
684
|
+
user: args.user,
|
|
685
|
+
});
|
|
686
|
+
const header = result.removed
|
|
687
|
+
? `Deleted playbook "${args.name}" from product "${args.product}".`
|
|
688
|
+
: `Playbook "${args.name}" was not registered on product "${args.product}".`;
|
|
689
|
+
const steps = result.steps.map((s) => ` - ${s}`);
|
|
690
|
+
return {
|
|
691
|
+
content: [{ type: 'text', text: [header, ...steps].join('\n') }],
|
|
692
|
+
isError: false,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* @typedef {{
|
|
698
|
+
* device: string,
|
|
699
|
+
* ok: boolean,
|
|
700
|
+
* exitCode: number | null,
|
|
701
|
+
* timedOut: boolean,
|
|
702
|
+
* durationMs: number,
|
|
703
|
+
* stdout: string,
|
|
704
|
+
* stderr: string,
|
|
705
|
+
* error?: string,
|
|
706
|
+
* }} ExecEntry
|
|
707
|
+
*/
|
|
708
|
+
|
|
709
|
+
async function toolProductWrite(args) {
|
|
710
|
+
if (!args.product) throw inputError('product is required');
|
|
711
|
+
if (typeof args.path !== 'string' || !args.path) throw inputError('path is required');
|
|
712
|
+
if (typeof args.content !== 'string') throw inputError('content (string) is required');
|
|
713
|
+
const payload = Buffer.from(args.content, 'utf8');
|
|
714
|
+
|
|
715
|
+
const { devices, entries } = await runProductFanOut({
|
|
716
|
+
product: args.product,
|
|
717
|
+
group: args.group,
|
|
718
|
+
includeOffline: !!args.all,
|
|
719
|
+
user: args.user,
|
|
720
|
+
concurrency:
|
|
721
|
+
Number.isFinite(args.concurrency) && args.concurrency > 0
|
|
722
|
+
? Math.floor(args.concurrency)
|
|
723
|
+
: 10,
|
|
724
|
+
failFast: !!args.fail_fast,
|
|
725
|
+
worker: async (device) => {
|
|
726
|
+
const t0 = Date.now();
|
|
727
|
+
try {
|
|
728
|
+
const api = createDeviceAPI(device.device, { user: args.user || undefined });
|
|
729
|
+
await api.writeFile(args.path, payload);
|
|
730
|
+
return {
|
|
731
|
+
device: device.device,
|
|
732
|
+
ok: true,
|
|
733
|
+
bytes: payload.byteLength,
|
|
734
|
+
durationMs: Date.now() - t0,
|
|
735
|
+
};
|
|
736
|
+
} catch (err) {
|
|
737
|
+
return {
|
|
738
|
+
device: device.device,
|
|
739
|
+
ok: false,
|
|
740
|
+
bytes: 0,
|
|
741
|
+
durationMs: Date.now() - t0,
|
|
742
|
+
error: err instanceof Error ? err.message : String(err),
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
},
|
|
746
|
+
skipped: (device, firstFailure) => ({
|
|
747
|
+
device: device.device,
|
|
748
|
+
ok: false,
|
|
749
|
+
bytes: 0,
|
|
750
|
+
durationMs: 0,
|
|
751
|
+
error: `skipped (fail-fast after ${firstFailure})`,
|
|
752
|
+
}),
|
|
753
|
+
isFailure: (entry) => !entry.ok,
|
|
754
|
+
});
|
|
755
|
+
|
|
756
|
+
if (devices.length === 0) {
|
|
757
|
+
const suffix = args.group ? `, group="${args.group}"` : '';
|
|
758
|
+
return {
|
|
759
|
+
content: [
|
|
760
|
+
{
|
|
761
|
+
type: 'text',
|
|
762
|
+
text: `No devices to upload to (product="${args.product}"${suffix}).`,
|
|
763
|
+
},
|
|
764
|
+
],
|
|
765
|
+
isError: false,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
const okCount = entries.filter((e) => e.ok).length;
|
|
770
|
+
const lines = [];
|
|
771
|
+
lines.push(
|
|
772
|
+
`Uploaded to ${okCount}/${entries.length} device(s) of product "${args.product}" (path: ${args.path}, ${payload.byteLength} bytes each).\n`,
|
|
773
|
+
);
|
|
774
|
+
for (const e of entries) {
|
|
775
|
+
lines.push(
|
|
776
|
+
`${e.device} ${e.ok ? 'OK' : 'FAIL'} ${e.durationMs}ms${e.ok ? '' : ` · ${e.error}`}`,
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
return {
|
|
780
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
781
|
+
isError: okCount < entries.length,
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
async function toolProductExec(args) {
|
|
786
|
+
if (!args.product) throw inputError('product is required');
|
|
787
|
+
if (!args.command) throw inputError('command is required');
|
|
788
|
+
const concurrency =
|
|
789
|
+
Number.isFinite(args.concurrency) && args.concurrency > 0
|
|
790
|
+
? Math.floor(args.concurrency)
|
|
791
|
+
: 10;
|
|
792
|
+
const timeout =
|
|
793
|
+
Number.isFinite(args.timeout) && args.timeout > 0
|
|
794
|
+
? Math.floor(args.timeout)
|
|
795
|
+
: TIMEOUTS.DEFAULT_EXEC_SECONDS;
|
|
796
|
+
|
|
797
|
+
const { devices, entries, durationMs } = await runProductFanOut({
|
|
798
|
+
product: args.product,
|
|
799
|
+
group: args.group,
|
|
800
|
+
includeOffline: !!args.all,
|
|
801
|
+
user: args.user,
|
|
802
|
+
concurrency,
|
|
803
|
+
failFast: !!args.fail_fast,
|
|
804
|
+
worker: async (device) => {
|
|
805
|
+
const perStart = Date.now();
|
|
806
|
+
let stdout = '';
|
|
807
|
+
let stderr = '';
|
|
808
|
+
try {
|
|
809
|
+
const api = createDeviceAPI(device.device, { user: args.user || undefined });
|
|
810
|
+
const { exitCode, timedOut } = await api.execStream(args.command, {
|
|
811
|
+
timeout,
|
|
812
|
+
onStdout: (s) => {
|
|
813
|
+
stdout += s;
|
|
814
|
+
},
|
|
815
|
+
onStderr: (s) => {
|
|
816
|
+
stderr += s;
|
|
817
|
+
},
|
|
818
|
+
});
|
|
819
|
+
return {
|
|
820
|
+
device: device.device,
|
|
821
|
+
ok: !timedOut && exitCode === 0,
|
|
822
|
+
exitCode: exitCode ?? null,
|
|
823
|
+
timedOut: !!timedOut,
|
|
824
|
+
durationMs: Date.now() - perStart,
|
|
825
|
+
stdout,
|
|
826
|
+
stderr,
|
|
827
|
+
};
|
|
828
|
+
} catch (err) {
|
|
829
|
+
return {
|
|
830
|
+
device: device.device,
|
|
831
|
+
ok: false,
|
|
832
|
+
exitCode: null,
|
|
833
|
+
timedOut: false,
|
|
834
|
+
durationMs: Date.now() - perStart,
|
|
835
|
+
stdout,
|
|
836
|
+
stderr,
|
|
837
|
+
error: err instanceof Error ? err.message : String(err),
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
},
|
|
841
|
+
skipped: (device, firstFailure) => ({
|
|
842
|
+
device: device.device,
|
|
843
|
+
ok: false,
|
|
844
|
+
exitCode: null,
|
|
845
|
+
timedOut: false,
|
|
846
|
+
durationMs: 0,
|
|
847
|
+
stdout: '',
|
|
848
|
+
stderr: '',
|
|
849
|
+
error: `skipped (fail-fast after ${firstFailure})`,
|
|
850
|
+
}),
|
|
851
|
+
isFailure: (entry) => !entry.ok,
|
|
852
|
+
});
|
|
853
|
+
|
|
854
|
+
if (devices.length === 0) {
|
|
855
|
+
const suffix = args.group ? `, group="${args.group}"` : '';
|
|
856
|
+
return {
|
|
857
|
+
content: [
|
|
858
|
+
{
|
|
859
|
+
type: 'text',
|
|
860
|
+
text: `No devices to run on (product="${args.product}"${suffix}).`,
|
|
861
|
+
},
|
|
862
|
+
],
|
|
863
|
+
isError: false,
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
const okCount = entries.filter((e) => e.ok).length;
|
|
868
|
+
const timedOutCount = entries.filter((e) => e.timedOut).length;
|
|
869
|
+
const errorCount = entries.filter((e) => e.error).length;
|
|
870
|
+
const failCount = entries.length - okCount - timedOutCount - errorCount;
|
|
871
|
+
|
|
872
|
+
const lines = [];
|
|
873
|
+
lines.push(
|
|
874
|
+
`Ran \`${args.command}\` on ${entries.length} device(s) of product "${args.product}" ` +
|
|
875
|
+
`(concurrency=${concurrency}, timeout=${timeout}s).\n`,
|
|
876
|
+
);
|
|
877
|
+
for (const e of entries) {
|
|
878
|
+
const status = e.error
|
|
879
|
+
? `ERROR (${e.error})`
|
|
880
|
+
: e.timedOut
|
|
881
|
+
? `TIMEOUT after ${timeout}s`
|
|
882
|
+
: e.ok
|
|
883
|
+
? `OK exit=0`
|
|
884
|
+
: `FAIL exit=${e.exitCode}`;
|
|
885
|
+
lines.push(`--- ${e.device} [${status}] ${e.durationMs}ms ---`);
|
|
886
|
+
if (e.stdout) lines.push(e.stdout.trimEnd());
|
|
887
|
+
if (e.stderr) lines.push(`stderr: ${e.stderr.trimEnd()}`);
|
|
888
|
+
}
|
|
889
|
+
lines.push(
|
|
890
|
+
`\nSummary: ${okCount} ok, ${failCount} failed, ${timedOutCount} timed out, ${errorCount} errored — ${durationMs}ms total`,
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
const anyFailure = failCount + timedOutCount + errorCount > 0;
|
|
894
|
+
return {
|
|
895
|
+
content: [{ type: 'text', text: lines.join('\n') }],
|
|
896
|
+
isError: anyFailure,
|
|
897
|
+
};
|
|
898
|
+
}
|
|
899
|
+
|
|
113
900
|
export const tools = [
|
|
114
901
|
{
|
|
115
902
|
name: 'thinr_products',
|
|
@@ -125,9 +912,124 @@ export const tools = [
|
|
|
125
912
|
handler: toolProducts,
|
|
126
913
|
},
|
|
127
914
|
{
|
|
128
|
-
name: '
|
|
129
|
-
description:
|
|
130
|
-
|
|
915
|
+
name: 'thinr_product_create',
|
|
916
|
+
description: `Create a new product. The product ID is immutable — pick it carefully (letters, digits, dot, dash, underscore). Optionally set the display name, description and icon at creation time; otherwise sensible defaults apply (name = id, generic description, Font Awesome Linux icon).
|
|
917
|
+
|
|
918
|
+
Failure mode: refuses to overwrite an existing product. Use thinr_product_update to change metadata on a product that already exists.
|
|
919
|
+
|
|
920
|
+
Typical flow: thinr_products to discover what is already there, thinr_product_create to register the new one, thinr_product_script_write to attach scripts, thinr_product_metric_set to register dashboard metrics, thinr_device_set_product to assign devices.`,
|
|
921
|
+
inputSchema: {
|
|
922
|
+
type: 'object',
|
|
923
|
+
properties: {
|
|
924
|
+
product: {
|
|
925
|
+
type: 'string',
|
|
926
|
+
description:
|
|
927
|
+
'Stable product ID (letters, digits, dot, dash, underscore). Cannot be changed later.',
|
|
928
|
+
},
|
|
929
|
+
name: {
|
|
930
|
+
type: 'string',
|
|
931
|
+
description:
|
|
932
|
+
'Display name shown across the platform UI. Defaults to the product ID.',
|
|
933
|
+
},
|
|
934
|
+
description: {
|
|
935
|
+
type: 'string',
|
|
936
|
+
description: 'Free-form description.',
|
|
937
|
+
},
|
|
938
|
+
icon: {
|
|
939
|
+
type: 'object',
|
|
940
|
+
description:
|
|
941
|
+
"Optional icon. Pick `type: 'fa'` for a Font Awesome class or `type: 'svg'` for inline SVG markup. Defaults to a generic Linux icon.",
|
|
942
|
+
properties: {
|
|
943
|
+
type: {
|
|
944
|
+
type: 'string',
|
|
945
|
+
enum: ['fa', 'svg'],
|
|
946
|
+
description:
|
|
947
|
+
"Icon kind: 'fa' (default) for Font Awesome class, 'svg' for inline SVG markup.",
|
|
948
|
+
},
|
|
949
|
+
source: {
|
|
950
|
+
type: 'string',
|
|
951
|
+
description:
|
|
952
|
+
'Font Awesome class (e.g. "fab fa-linux", "fas fa-server") or full SVG markup including the `<svg>` wrapper.',
|
|
953
|
+
},
|
|
954
|
+
color: {
|
|
955
|
+
type: 'string',
|
|
956
|
+
description: 'Hex foreground color. Default: #000000.',
|
|
957
|
+
},
|
|
958
|
+
background: {
|
|
959
|
+
type: 'string',
|
|
960
|
+
description: 'Hex background color. Default: #ffffff.',
|
|
961
|
+
},
|
|
962
|
+
},
|
|
963
|
+
required: ['source'],
|
|
964
|
+
},
|
|
965
|
+
user: { type: 'string', description: 'API user' },
|
|
966
|
+
},
|
|
967
|
+
required: ['product'],
|
|
968
|
+
},
|
|
969
|
+
handler: toolProductCreate,
|
|
970
|
+
},
|
|
971
|
+
{
|
|
972
|
+
name: 'thinr_product_update',
|
|
973
|
+
description: `Update product metadata in place. Pass any subset of fields (\`name\`, \`description\`, \`enabled\`, \`icon\`) — only the supplied ones are modified, the rest are left untouched. Useful to set a friendlier display name shown across the platform UI, change the description, toggle the product on or off, or replace its icon.
|
|
974
|
+
|
|
975
|
+
Icons accept Font Awesome classes (\`{ type: "fa", source: "fab fa-linux" }\`) or inline SVG markup (\`{ type: "svg", source: "<svg ...>...</svg>" }\`). When an icon is supplied, the current product config is fetched first so unrelated fields (type, profile, dashboards) are preserved.
|
|
976
|
+
|
|
977
|
+
Does not change the product ID. To rename the ID itself, the product has to be recreated and devices reassigned.`,
|
|
978
|
+
inputSchema: {
|
|
979
|
+
type: 'object',
|
|
980
|
+
properties: {
|
|
981
|
+
product: { type: 'string', description: 'Product ID to update.' },
|
|
982
|
+
name: {
|
|
983
|
+
type: 'string',
|
|
984
|
+
description:
|
|
985
|
+
'Display name shown across the platform UI (dashboards, lists, etc.).',
|
|
986
|
+
},
|
|
987
|
+
description: {
|
|
988
|
+
type: 'string',
|
|
989
|
+
description: 'Free-form description.',
|
|
990
|
+
},
|
|
991
|
+
enabled: {
|
|
992
|
+
type: 'boolean',
|
|
993
|
+
description:
|
|
994
|
+
'Enable or disable the product. Disabled products do not deliver scripts or buckets to devices.',
|
|
995
|
+
},
|
|
996
|
+
icon: {
|
|
997
|
+
type: 'object',
|
|
998
|
+
description:
|
|
999
|
+
"Replace the product's icon. Pick `type: 'fa'` for a Font Awesome class or `type: 'svg'` for inline SVG markup.",
|
|
1000
|
+
properties: {
|
|
1001
|
+
type: {
|
|
1002
|
+
type: 'string',
|
|
1003
|
+
enum: ['fa', 'svg'],
|
|
1004
|
+
description:
|
|
1005
|
+
"Icon kind: 'fa' (default) for Font Awesome class, 'svg' for inline SVG markup.",
|
|
1006
|
+
},
|
|
1007
|
+
source: {
|
|
1008
|
+
type: 'string',
|
|
1009
|
+
description:
|
|
1010
|
+
'Font Awesome class (e.g. "fab fa-linux", "fas fa-server") or full SVG markup including the `<svg>` wrapper.',
|
|
1011
|
+
},
|
|
1012
|
+
color: {
|
|
1013
|
+
type: 'string',
|
|
1014
|
+
description: 'Hex foreground color. Default: #000000.',
|
|
1015
|
+
},
|
|
1016
|
+
background: {
|
|
1017
|
+
type: 'string',
|
|
1018
|
+
description: 'Hex background color. Default: #ffffff.',
|
|
1019
|
+
},
|
|
1020
|
+
},
|
|
1021
|
+
required: ['source'],
|
|
1022
|
+
},
|
|
1023
|
+
user: { type: 'string', description: 'API user' },
|
|
1024
|
+
},
|
|
1025
|
+
required: ['product'],
|
|
1026
|
+
},
|
|
1027
|
+
handler: toolProductUpdate,
|
|
1028
|
+
},
|
|
1029
|
+
{
|
|
1030
|
+
name: 'thinr_product_delete',
|
|
1031
|
+
description:
|
|
1032
|
+
'Delete a product and, by default, its file storage with all scripts. Destructive: confirm with the user before calling. Any device currently assigned to the product is left as-is (unassigned from it by the server) — this tool does not touch the user-level monitoring bucket, which is shared across products.',
|
|
131
1033
|
inputSchema: {
|
|
132
1034
|
type: 'object',
|
|
133
1035
|
properties: {
|
|
@@ -178,12 +1080,22 @@ Product scripts are executable files kept in a per-product storage ("<product>")
|
|
|
178
1080
|
},
|
|
179
1081
|
{
|
|
180
1082
|
name: 'thinr_product_script_write',
|
|
181
|
-
description: `
|
|
1083
|
+
description: `Upsert a product-level script: installs a new script if the name is unused, replaces the existing one in place otherwise. The product must exist already — create it first with thinr_product_create. This tool only handles the script side: creates the product's storage if missing, writes the generic index.js wrapper on first use, wires profile.code.storage, enables the product, uploads the script under scripts/<name>, and creates/updates the API resource so the script is immediately callable on any device of the product.
|
|
1084
|
+
|
|
1085
|
+
Naming convention: \`*_write\` denotes upsert, shared with every per-product leaf. The product container itself uses split thinr_product_create / thinr_product_update tools to make accidental overwrites of product metadata impossible.
|
|
182
1086
|
|
|
183
1087
|
Script contract:
|
|
184
1088
|
- Any executable source. If the body starts with a shebang (#!/usr/bin/env python3, #!/bin/bash, …) the agent materializes it to a temp file and runs it with the declared interpreter. Without a shebang, the body runs through the shell (pipes, redirects, && all work).
|
|
185
|
-
-
|
|
186
|
-
-
|
|
1089
|
+
- Any object the caller sends when invoking the resource is forwarded to the script on stdin as JSON. Parse with jq / json.load / JSON.parse.
|
|
1090
|
+
- When \`output\` is true (default), stdout must be valid JSON; it is parsed server-side (parse_stdout) so the resource returns the decoded object, not the raw shell output.
|
|
1091
|
+
|
|
1092
|
+
Pick \`input\` and \`output\` according to the resource shape:
|
|
1093
|
+
- output-only (e.g. stats): omit \`input\`, leave \`output\` at its default. Resource classifies as fn=3 (output); frontend renders only the typed output.
|
|
1094
|
+
- input+output (e.g. echo/transform): pass an \`input\` schema, leave \`output\` at its default. Resource classifies as fn=4 (input_output); frontend renders typed input fields AND the output.
|
|
1095
|
+
- input-only (e.g. notify, log): pass an \`input\` schema and set \`output: false\`. Resource classifies as fn=2 (input); frontend renders only the input form and the call replies empty.
|
|
1096
|
+
- run (e.g. ping, restart): omit \`input\` and set \`output: false\`. Resource classifies as fn=1 (run); just a button that fires the script.
|
|
1097
|
+
|
|
1098
|
+
\`input\` schema shape: a plain object mapping field names to default values (e.g. { "threshold": 80, "label": "warm" }). Declaring it registers a template_payload on the request side so the frontend renders typed input fields with those defaults and callers can override each field when invoking the resource. Field names must match /^[A-Za-z_][A-Za-z0-9_]*$/; values may be string, number or boolean. Omit \`input\` for a free-form payload where any object passed by the caller flows straight to stdin without a declared schema.
|
|
187
1099
|
|
|
188
1100
|
The file extension (.sh, .py, .js, …) is preserved in the storage for editor highlighting, but the API resource name is the stem without extension. So scripts/backup.sh is callable as the resource "backup".`,
|
|
189
1101
|
inputSchema: {
|
|
@@ -200,10 +1112,16 @@ The file extension (.sh, .py, .js, …) is preserved in the storage for editor h
|
|
|
200
1112
|
description:
|
|
201
1113
|
'Full script source code. Include a shebang to use a non-shell interpreter.',
|
|
202
1114
|
},
|
|
203
|
-
|
|
204
|
-
type: '
|
|
1115
|
+
input: {
|
|
1116
|
+
type: 'object',
|
|
1117
|
+
description:
|
|
1118
|
+
'Optional input schema: plain object mapping field names to default values. Registers a typed template so the frontend renders input fields. Field names must match /^[A-Za-z_][A-Za-z0-9_]*$/. Values may be string, number or boolean. Omit for a free-form payload.',
|
|
1119
|
+
additionalProperties: true,
|
|
1120
|
+
},
|
|
1121
|
+
output: {
|
|
1122
|
+
type: 'boolean',
|
|
205
1123
|
description:
|
|
206
|
-
'
|
|
1124
|
+
'Whether the resource returns data. Default true: stdout is parsed as JSON and returned (and exposed as the resource output in the frontend). Set to false for fire-and-forget scripts — the resource replies empty and, combined with no input, classifies as "run".',
|
|
207
1125
|
},
|
|
208
1126
|
user: { type: 'string', description: 'API user' },
|
|
209
1127
|
},
|
|
@@ -211,6 +1129,95 @@ The file extension (.sh, .py, .js, …) is preserved in the storage for editor h
|
|
|
211
1129
|
},
|
|
212
1130
|
handler: toolProductScriptWrite,
|
|
213
1131
|
},
|
|
1132
|
+
{
|
|
1133
|
+
name: 'thinr_product_write',
|
|
1134
|
+
description: `Upload a file to every active device of a product in parallel. Content is passed inline as a UTF-8 string — the same contract as thinr_write, just fanned out.
|
|
1135
|
+
|
|
1136
|
+
Typical uses: rolling out a shared configuration file, pushing a small static asset, or seeding a script that devices load at boot. Offline devices are skipped by default; set \`all: true\` to attempt them too (they will typically fail fast with a connection error). \`concurrency\` bounds parallel uploads (default 10) and \`fail_fast\` stops dequeueing new devices after the first failure.`,
|
|
1137
|
+
inputSchema: {
|
|
1138
|
+
type: 'object',
|
|
1139
|
+
properties: {
|
|
1140
|
+
product: {
|
|
1141
|
+
type: 'string',
|
|
1142
|
+
description: 'Product ID (discover with thinr_products).',
|
|
1143
|
+
},
|
|
1144
|
+
path: {
|
|
1145
|
+
type: 'string',
|
|
1146
|
+
description: 'Absolute path on each device where the file will be written.',
|
|
1147
|
+
},
|
|
1148
|
+
content: {
|
|
1149
|
+
type: 'string',
|
|
1150
|
+
description: 'UTF-8 file content to write on every device.',
|
|
1151
|
+
},
|
|
1152
|
+
group: {
|
|
1153
|
+
type: 'string',
|
|
1154
|
+
description: 'Optional asset group filter.',
|
|
1155
|
+
},
|
|
1156
|
+
concurrency: {
|
|
1157
|
+
type: 'number',
|
|
1158
|
+
description: 'Max parallel uploads (default: 10).',
|
|
1159
|
+
},
|
|
1160
|
+
fail_fast: {
|
|
1161
|
+
type: 'boolean',
|
|
1162
|
+
description:
|
|
1163
|
+
'Stop dequeueing new devices as soon as one fails (default: false).',
|
|
1164
|
+
},
|
|
1165
|
+
all: {
|
|
1166
|
+
type: 'boolean',
|
|
1167
|
+
description:
|
|
1168
|
+
'Include offline devices (default: false — only active are targeted).',
|
|
1169
|
+
},
|
|
1170
|
+
user: { type: 'string', description: 'API user (admin impersonation).' },
|
|
1171
|
+
},
|
|
1172
|
+
required: ['product', 'path', 'content'],
|
|
1173
|
+
},
|
|
1174
|
+
handler: toolProductWrite,
|
|
1175
|
+
},
|
|
1176
|
+
{
|
|
1177
|
+
name: 'thinr_product_exec',
|
|
1178
|
+
description: `Execute a shell command in parallel on every active device of a product, and return stdout, stderr, exit code and duration per device plus a consolidated summary.
|
|
1179
|
+
|
|
1180
|
+
Prefer this over iterating \`thinr_exec\` device-by-device when you need to roll out or inspect something across a fleet. Offline devices are skipped by default; pass \`all: true\` to attempt them anyway (they will typically fail fast with a connection error). \`concurrency\` bounds how many devices run at once (default 10), \`timeout\` is per-device in seconds (default 30), and \`fail_fast\` stops dequeueing new devices as soon as one returns a non-zero exit / times out — in-flight work still completes.`,
|
|
1181
|
+
inputSchema: {
|
|
1182
|
+
type: 'object',
|
|
1183
|
+
properties: {
|
|
1184
|
+
product: {
|
|
1185
|
+
type: 'string',
|
|
1186
|
+
description: 'Product ID (discover with thinr_products).',
|
|
1187
|
+
},
|
|
1188
|
+
command: {
|
|
1189
|
+
type: 'string',
|
|
1190
|
+
description: 'Shell command to run on each device.',
|
|
1191
|
+
},
|
|
1192
|
+
group: {
|
|
1193
|
+
type: 'string',
|
|
1194
|
+
description:
|
|
1195
|
+
'Optional asset group filter — restricts execution to devices in this group.',
|
|
1196
|
+
},
|
|
1197
|
+
concurrency: {
|
|
1198
|
+
type: 'number',
|
|
1199
|
+
description: 'Max parallel executions (default: 10).',
|
|
1200
|
+
},
|
|
1201
|
+
timeout: {
|
|
1202
|
+
type: 'number',
|
|
1203
|
+
description: 'Per-device timeout in seconds (default: 30).',
|
|
1204
|
+
},
|
|
1205
|
+
fail_fast: {
|
|
1206
|
+
type: 'boolean',
|
|
1207
|
+
description:
|
|
1208
|
+
'Stop dequeueing new devices as soon as one fails (default: false).',
|
|
1209
|
+
},
|
|
1210
|
+
all: {
|
|
1211
|
+
type: 'boolean',
|
|
1212
|
+
description:
|
|
1213
|
+
'Include offline devices (default: false — only active devices are targeted).',
|
|
1214
|
+
},
|
|
1215
|
+
user: { type: 'string', description: 'API user (admin impersonation).' },
|
|
1216
|
+
},
|
|
1217
|
+
required: ['product', 'command'],
|
|
1218
|
+
},
|
|
1219
|
+
handler: toolProductExec,
|
|
1220
|
+
},
|
|
214
1221
|
{
|
|
215
1222
|
name: 'thinr_product_script_delete',
|
|
216
1223
|
description: `Remove a product script: deletes the file under scripts/ in the product storage and the associated API resource.`,
|
|
@@ -229,4 +1236,330 @@ The file extension (.sh, .py, .js, …) is preserved in the storage for editor h
|
|
|
229
1236
|
},
|
|
230
1237
|
handler: toolProductScriptDelete,
|
|
231
1238
|
},
|
|
1239
|
+
{
|
|
1240
|
+
name: 'thinr_product_property_get',
|
|
1241
|
+
description: `Read a product-level property (not a device property). Product properties are structured JSON values attached to the product itself — typical uses include dashboard configuration, shared feature flags, or the list of dashboard metrics managed by thinr_product_metric_*. Call without \`property\` to list the names of every property configured on the product.`,
|
|
1242
|
+
inputSchema: {
|
|
1243
|
+
type: 'object',
|
|
1244
|
+
properties: {
|
|
1245
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
1246
|
+
property: {
|
|
1247
|
+
type: 'string',
|
|
1248
|
+
description:
|
|
1249
|
+
'Property name. Omit to list the names of all properties on the product.',
|
|
1250
|
+
},
|
|
1251
|
+
user: { type: 'string', description: 'API user' },
|
|
1252
|
+
},
|
|
1253
|
+
required: ['product'],
|
|
1254
|
+
},
|
|
1255
|
+
handler: toolProductPropertyGet,
|
|
1256
|
+
},
|
|
1257
|
+
{
|
|
1258
|
+
name: 'thinr_product_property_set',
|
|
1259
|
+
description: `Upsert a product-level property: creates the property if missing, overwrites it wholesale if it already exists. The value can be any JSON-serialisable shape (string, number, object, array). Prefer the higher-level thinr_product_metric_* tools when managing dashboard metrics — they encapsulate the property shape so you don't have to reconstruct it by hand.
|
|
1260
|
+
|
|
1261
|
+
Naming convention: this tool follows the upsert pattern shared by every per-product leaf (\`*_set\`, \`*_write\`). Container-level operations (the product itself) split create and update into separate tools so a fresh registration cannot accidentally overwrite an existing product's metadata.`,
|
|
1262
|
+
inputSchema: {
|
|
1263
|
+
type: 'object',
|
|
1264
|
+
properties: {
|
|
1265
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
1266
|
+
property: { type: 'string', description: 'Property name to create or overwrite.' },
|
|
1267
|
+
value: {
|
|
1268
|
+
description:
|
|
1269
|
+
'Any JSON-serialisable value (string, number, boolean, object, array).',
|
|
1270
|
+
},
|
|
1271
|
+
user: { type: 'string', description: 'API user' },
|
|
1272
|
+
},
|
|
1273
|
+
required: ['product', 'property', 'value'],
|
|
1274
|
+
},
|
|
1275
|
+
handler: toolProductPropertySet,
|
|
1276
|
+
},
|
|
1277
|
+
{
|
|
1278
|
+
name: 'thinr_product_property_delete',
|
|
1279
|
+
description: `Delete a product-level property. Idempotent — returns success even if the property was not set.`,
|
|
1280
|
+
inputSchema: {
|
|
1281
|
+
type: 'object',
|
|
1282
|
+
properties: {
|
|
1283
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
1284
|
+
property: { type: 'string', description: 'Property name to delete.' },
|
|
1285
|
+
user: { type: 'string', description: 'API user' },
|
|
1286
|
+
},
|
|
1287
|
+
required: ['product', 'property'],
|
|
1288
|
+
},
|
|
1289
|
+
handler: toolProductPropertyDelete,
|
|
1290
|
+
},
|
|
1291
|
+
{
|
|
1292
|
+
name: 'thinr_product_metric_list',
|
|
1293
|
+
description: `List the dashboard metrics configured on a product. Each metric describes a value the dashboard should stream (resource to poll, aggregation, visualization, refresh interval). Metrics are stored under the \`${DASHBOARD_METRICS_PROPERTY}\` product property.`,
|
|
1294
|
+
inputSchema: {
|
|
1295
|
+
type: 'object',
|
|
1296
|
+
properties: {
|
|
1297
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
1298
|
+
user: { type: 'string', description: 'API user' },
|
|
1299
|
+
},
|
|
1300
|
+
required: ['product'],
|
|
1301
|
+
},
|
|
1302
|
+
handler: toolProductMetricList,
|
|
1303
|
+
},
|
|
1304
|
+
{
|
|
1305
|
+
name: 'thinr_product_metric_set',
|
|
1306
|
+
description: `Upsert a dashboard metric on a product: creates a new entry if the \`name\` key is unused, overwrites the existing entry in place otherwise. Under the hood this edits the \`${DASHBOARD_METRICS_PROPERTY}\` product property — prefer this tool over thinr_product_property_set for dashboard metrics so the shape stays consistent.
|
|
1307
|
+
|
|
1308
|
+
Typical flow: create the product first (thinr_product_create), write a script that returns the raw data (thinr_product_script_write), then register the metric so the dashboard knows how to surface it.
|
|
1309
|
+
|
|
1310
|
+
Naming convention: \`*_set\` denotes upsert, shared with every per-product leaf. The product container itself uses split thinr_product_create / thinr_product_update tools to make accidental overwrites of product metadata impossible.`,
|
|
1311
|
+
inputSchema: {
|
|
1312
|
+
type: 'object',
|
|
1313
|
+
properties: {
|
|
1314
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
1315
|
+
name: {
|
|
1316
|
+
type: 'string',
|
|
1317
|
+
description:
|
|
1318
|
+
'Stable identifier for the metric (letters, digits, underscore, dash). Reusing a name updates the existing entry.',
|
|
1319
|
+
},
|
|
1320
|
+
label: {
|
|
1321
|
+
type: 'string',
|
|
1322
|
+
description: 'Human-readable label shown in the dashboard panel.',
|
|
1323
|
+
},
|
|
1324
|
+
resource: {
|
|
1325
|
+
type: 'string',
|
|
1326
|
+
description:
|
|
1327
|
+
'Product API resource the dashboard invokes per device to get the value (e.g. the stem of a product script).',
|
|
1328
|
+
},
|
|
1329
|
+
field: {
|
|
1330
|
+
type: 'string',
|
|
1331
|
+
description:
|
|
1332
|
+
'Dot-path into the resource response pointing at the numeric value to aggregate (e.g. "connections.devices"). Omit for scalar responses.',
|
|
1333
|
+
},
|
|
1334
|
+
aggregation: {
|
|
1335
|
+
type: 'string',
|
|
1336
|
+
description:
|
|
1337
|
+
'How to combine the per-device values across the fleet. Numeric reducers: "sum", "avg", "max", "min". "count" returns the number of devices reporting any non-null value, irrespective of value. "top" lists the values without counting repetitions. "none" returns the raw per-device array. "distribution" returns a flat object mapping each distinct reported value (coerced to string) to the number of devices reporting it — use it for categorical data such as agent version, release/branch, kernel, hostname pattern; it is the right choice when the question is "how is the fleet split across these values?". The output of "distribution" is an object value→count, not a number; the dashboard renders it as a sorted list with per-value percentages and a total row.',
|
|
1338
|
+
enum: ['sum', 'avg', 'max', 'min', 'count', 'top', 'none', 'distribution'],
|
|
1339
|
+
},
|
|
1340
|
+
visualization: {
|
|
1341
|
+
type: 'string',
|
|
1342
|
+
description:
|
|
1343
|
+
'Dashboard rendering hint. "kpi" (default) shows the aggregated value plus its age. "sparkline" renders the same value alongside a ▁▂▃▅▇ bar of the last ~32 samples, useful for metrics where the trend matters (throughput, connected devices). "list" pairs naturally with aggregation "distribution" — it lays out each value as its own row with count and percentage, plus a total. "bar" is reserved for future use and currently falls back to kpi.',
|
|
1344
|
+
enum: ['kpi', 'bar', 'sparkline', 'list'],
|
|
1345
|
+
},
|
|
1346
|
+
interval: {
|
|
1347
|
+
type: 'number',
|
|
1348
|
+
description:
|
|
1349
|
+
'Refresh interval in seconds the dashboard should subscribe with. Minimum: 1. Omit (or set to 0) to subscribe without a server-side poll — the stream then only relays values the device pushes on its own.',
|
|
1350
|
+
},
|
|
1351
|
+
unit: {
|
|
1352
|
+
type: 'string',
|
|
1353
|
+
description: 'Optional unit suffix for display (e.g. "%", "devices").',
|
|
1354
|
+
},
|
|
1355
|
+
user: { type: 'string', description: 'API user' },
|
|
1356
|
+
},
|
|
1357
|
+
required: ['product', 'name', 'resource'],
|
|
1358
|
+
},
|
|
1359
|
+
handler: toolProductMetricSet,
|
|
1360
|
+
},
|
|
1361
|
+
{
|
|
1362
|
+
name: 'thinr_product_metric_delete',
|
|
1363
|
+
description: `Remove a dashboard metric from a product by its \`name\`. If it was the last metric configured, the underlying \`${DASHBOARD_METRICS_PROPERTY}\` product property is removed entirely.`,
|
|
1364
|
+
inputSchema: {
|
|
1365
|
+
type: 'object',
|
|
1366
|
+
properties: {
|
|
1367
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
1368
|
+
name: { type: 'string', description: 'Metric name previously registered.' },
|
|
1369
|
+
user: { type: 'string', description: 'API user' },
|
|
1370
|
+
},
|
|
1371
|
+
required: ['product', 'name'],
|
|
1372
|
+
},
|
|
1373
|
+
handler: toolProductMetricDelete,
|
|
1374
|
+
},
|
|
1375
|
+
{
|
|
1376
|
+
name: 'thinr_product_playbook_list',
|
|
1377
|
+
description: `List the playbooks registered on a product, with their description and storage path. Use this to discover available playbooks before reading or running them; pair with thinr_product_playbook_read when you need the full YAML of a specific entry. Returns an empty listing if the product has no \`playbooks\` index property yet.`,
|
|
1378
|
+
inputSchema: {
|
|
1379
|
+
type: 'object',
|
|
1380
|
+
properties: {
|
|
1381
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
1382
|
+
user: { type: 'string', description: 'API user' },
|
|
1383
|
+
},
|
|
1384
|
+
required: ['product'],
|
|
1385
|
+
},
|
|
1386
|
+
handler: toolProductPlaybookList,
|
|
1387
|
+
},
|
|
1388
|
+
{
|
|
1389
|
+
name: 'thinr_product_playbook_read',
|
|
1390
|
+
description: `Return the raw YAML source of a single playbook stored on a product. Use this when you need the full document (to review it, edit and re-upload, or hand the content to thinr_playbook_run). For just the catalogue of available entries use thinr_product_playbook_list — it is cheaper and does not download each file.`,
|
|
1391
|
+
inputSchema: {
|
|
1392
|
+
type: 'object',
|
|
1393
|
+
properties: {
|
|
1394
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
1395
|
+
name: {
|
|
1396
|
+
type: 'string',
|
|
1397
|
+
description:
|
|
1398
|
+
'Playbook name as registered in the product index (letters, digits, underscore, dash — no extension).',
|
|
1399
|
+
},
|
|
1400
|
+
user: { type: 'string', description: 'API user' },
|
|
1401
|
+
},
|
|
1402
|
+
required: ['product', 'name'],
|
|
1403
|
+
},
|
|
1404
|
+
handler: toolProductPlaybookRead,
|
|
1405
|
+
},
|
|
1406
|
+
{
|
|
1407
|
+
name: 'thinr_product_playbook_write',
|
|
1408
|
+
description: `Upsert a playbook on a product from inline YAML: creates a new playbook if the name is unused, replaces the existing one in place otherwise. This is the upload step of the natural-language authoring flow: after the user describes what they want ("update the nginx package and restart the service on product X"), draft the YAML, iterate against thinr_playbook_validate until it is clean, then call this tool with the validated content.
|
|
1409
|
+
|
|
1410
|
+
The YAML is re-validated here against the playbook schema; an invalid document fails fast with a descriptive error and nothing is written — never pass \`skip_validation: true\` to work around validation errors, fix the YAML instead. Uploads are two-phase and self-healing: the file is written under \`playbooks/<name>.yaml\` in the product storage first, then the entry is registered in the \`playbooks\` product property; if the index update fails on a brand-new playbook, the file is rolled back so the index never points at a missing file.
|
|
1411
|
+
|
|
1412
|
+
Name rules: letters, digits, underscore, dash — no extension, no slashes. \`description\` is optional; when omitted, the playbook's own top-level \`description\` (if any) is recorded in the index. Author from scratch by calling thinr_playbook_schema first so the generated YAML matches the schema on the first draft.
|
|
1413
|
+
|
|
1414
|
+
Naming convention: \`*_write\` denotes upsert, shared with every per-product leaf. The product container itself uses split thinr_product_create / thinr_product_update tools to make accidental overwrites of product metadata impossible.`,
|
|
1415
|
+
inputSchema: {
|
|
1416
|
+
type: 'object',
|
|
1417
|
+
properties: {
|
|
1418
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
1419
|
+
name: {
|
|
1420
|
+
type: 'string',
|
|
1421
|
+
description:
|
|
1422
|
+
'Playbook name (letters, digits, underscore, dash). Used as the key in the product index and as the filename under `playbooks/`.',
|
|
1423
|
+
},
|
|
1424
|
+
content: {
|
|
1425
|
+
type: 'string',
|
|
1426
|
+
description:
|
|
1427
|
+
'Full YAML source of the playbook. Validated before upload unless skip_validation is true.',
|
|
1428
|
+
},
|
|
1429
|
+
description: {
|
|
1430
|
+
type: 'string',
|
|
1431
|
+
description:
|
|
1432
|
+
"Optional description stored alongside the index entry. When omitted, falls back to the playbook's own `description` field.",
|
|
1433
|
+
},
|
|
1434
|
+
skip_validation: {
|
|
1435
|
+
type: 'boolean',
|
|
1436
|
+
description:
|
|
1437
|
+
'Escape hatch for rare cases where the schema cannot yet express a field the runtime already accepts (default: false). Do NOT set this to work around a validation error reported by thinr_playbook_validate — fix the YAML instead. An invalid document will still fail at run time.',
|
|
1438
|
+
},
|
|
1439
|
+
user: { type: 'string', description: 'API user' },
|
|
1440
|
+
},
|
|
1441
|
+
required: ['product', 'name', 'content'],
|
|
1442
|
+
},
|
|
1443
|
+
handler: toolProductPlaybookWrite,
|
|
1444
|
+
},
|
|
1445
|
+
{
|
|
1446
|
+
name: 'thinr_product_playbook_run',
|
|
1447
|
+
description: `Run a playbook stored on a product against a single device — the safe pre-flight before rolling out across a fleet. Downloads the playbook by name, seeds variables from the playbook's declared defaults, applies any overrides supplied in \`vars\`, validates them (unknown / non-overridable / wrong-type / missing-required all surface as errors), and executes every step against \`device\`.
|
|
1448
|
+
|
|
1449
|
+
Pass \`dry_run: true\` to get the resolved plan without contacting the device. Pass \`check: true\` to contact the device read-only and report what each step would change. Overrides never mutate the stored playbook — they are per-invocation only. Use this tool before \`thinr_playbook_run\` (fleet mode) to validate behaviour on a single device first.`,
|
|
1450
|
+
inputSchema: {
|
|
1451
|
+
type: 'object',
|
|
1452
|
+
properties: {
|
|
1453
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
1454
|
+
name: {
|
|
1455
|
+
type: 'string',
|
|
1456
|
+
description: 'Playbook name as registered in the product index.',
|
|
1457
|
+
},
|
|
1458
|
+
device: {
|
|
1459
|
+
type: 'string',
|
|
1460
|
+
description: 'Target device ID (single-device only). Pick any device of the product.',
|
|
1461
|
+
},
|
|
1462
|
+
vars: {
|
|
1463
|
+
type: 'object',
|
|
1464
|
+
description:
|
|
1465
|
+
'Per-invocation variable overrides keyed by variable name. Values must match the declared type; unknown / non-overridable names are rejected.',
|
|
1466
|
+
additionalProperties: true,
|
|
1467
|
+
},
|
|
1468
|
+
dry_run: {
|
|
1469
|
+
type: 'boolean',
|
|
1470
|
+
description:
|
|
1471
|
+
'Return the resolved plan (variables + step summaries) without contacting the device (default: false).',
|
|
1472
|
+
},
|
|
1473
|
+
check: {
|
|
1474
|
+
type: 'boolean',
|
|
1475
|
+
description:
|
|
1476
|
+
'Contact the device read-only and report what each step would change, without writing (default: false).',
|
|
1477
|
+
},
|
|
1478
|
+
user: { type: 'string', description: 'API user (admin impersonation).' },
|
|
1479
|
+
},
|
|
1480
|
+
required: ['product', 'name', 'device'],
|
|
1481
|
+
},
|
|
1482
|
+
handler: toolProductPlaybookRun,
|
|
1483
|
+
},
|
|
1484
|
+
{
|
|
1485
|
+
name: 'thinr_product_playbook_rollout',
|
|
1486
|
+
description: `Roll a playbook stored on a product across every matching device, progressively and with safety rails. Devices are attempted in discrete batches of \`batch_size\`; a batch completes fully before the next starts. After each batch the cumulative failure rate is compared against \`failure_threshold\` — reaching it aborts the rollout without touching the remaining devices.
|
|
1487
|
+
|
|
1488
|
+
Destructive: prefer running \`thinr_product_playbook_run\` on a single device first to validate behaviour. Variables resolve the same way (declared defaults + \`vars\` overrides, validated against declared types; unknown / non-overridable / missing-required all rejected). The rollout writes a persistent report to \`playbooks/runs/<timestamp>-<name>-<user>.json\` in the product file storage, capturing the resolved variables, per-device outcome, and stop reason if aborted — best-effort, a failed upload is reported but does not fail the rollout.
|
|
1489
|
+
|
|
1490
|
+
Filtering: \`group\` restricts to an asset group; \`filter\` passes extra server-side key=value filters (repeatable keys only — letters/digits/underscore). By default only active devices are targeted; pass \`include_offline: true\` to attempt offline ones too.
|
|
1491
|
+
|
|
1492
|
+
Use \`dry_run: true\` to get the resolved plan and the list of devices that would be targeted without contacting any of them. \`check: true\` contacts devices read-only and reports what each step would change, without writing.`,
|
|
1493
|
+
inputSchema: {
|
|
1494
|
+
type: 'object',
|
|
1495
|
+
properties: {
|
|
1496
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
1497
|
+
name: {
|
|
1498
|
+
type: 'string',
|
|
1499
|
+
description: 'Playbook name as registered in the product index.',
|
|
1500
|
+
},
|
|
1501
|
+
batch_size: {
|
|
1502
|
+
type: 'number',
|
|
1503
|
+
description:
|
|
1504
|
+
'Devices attempted in parallel per batch. Next batch starts only after the current one finishes. Default: 5.',
|
|
1505
|
+
},
|
|
1506
|
+
failure_threshold: {
|
|
1507
|
+
type: 'number',
|
|
1508
|
+
description: `Abort rollout when cumulative failure rate (percentage of attempted devices that failed) reaches this value. Must be between 0 and 100. Default: ${DEFAULT_FAILURE_THRESHOLD}.`,
|
|
1509
|
+
},
|
|
1510
|
+
group: {
|
|
1511
|
+
type: 'string',
|
|
1512
|
+
description:
|
|
1513
|
+
'Restrict to devices in this asset group. Combined with `filter` if both are set.',
|
|
1514
|
+
},
|
|
1515
|
+
filter: {
|
|
1516
|
+
type: 'object',
|
|
1517
|
+
description:
|
|
1518
|
+
'Extra server-side filters passed as query params when listing devices. Keys must match [A-Za-z0-9_]+. Typical uses: narrowing by device field.',
|
|
1519
|
+
additionalProperties: { type: 'string' },
|
|
1520
|
+
},
|
|
1521
|
+
include_offline: {
|
|
1522
|
+
type: 'boolean',
|
|
1523
|
+
description:
|
|
1524
|
+
'Include offline devices in the rollout (default: false — only active devices are attempted).',
|
|
1525
|
+
},
|
|
1526
|
+
vars: {
|
|
1527
|
+
type: 'object',
|
|
1528
|
+
description:
|
|
1529
|
+
'Per-invocation variable overrides applied to every device. Values must match the declared type; unknown / non-overridable names are rejected.',
|
|
1530
|
+
additionalProperties: true,
|
|
1531
|
+
},
|
|
1532
|
+
dry_run: {
|
|
1533
|
+
type: 'boolean',
|
|
1534
|
+
description:
|
|
1535
|
+
'Return the resolved plan and target devices without contacting any of them (default: false).',
|
|
1536
|
+
},
|
|
1537
|
+
check: {
|
|
1538
|
+
type: 'boolean',
|
|
1539
|
+
description:
|
|
1540
|
+
'Contact devices read-only and report what each step would change, without writing (default: false).',
|
|
1541
|
+
},
|
|
1542
|
+
user: { type: 'string', description: 'API user (admin impersonation).' },
|
|
1543
|
+
},
|
|
1544
|
+
required: ['product', 'name'],
|
|
1545
|
+
},
|
|
1546
|
+
handler: toolProductPlaybookRollout,
|
|
1547
|
+
},
|
|
1548
|
+
{
|
|
1549
|
+
name: 'thinr_product_playbook_delete',
|
|
1550
|
+
description: `Remove a playbook from a product: first unregisters it from the \`playbooks\` index property, then deletes the underlying \`playbooks/<name>.yaml\` file from the product storage. Idempotent — calling it on a missing playbook reports success without erroring.`,
|
|
1551
|
+
inputSchema: {
|
|
1552
|
+
type: 'object',
|
|
1553
|
+
properties: {
|
|
1554
|
+
product: { type: 'string', description: 'Product ID.' },
|
|
1555
|
+
name: {
|
|
1556
|
+
type: 'string',
|
|
1557
|
+
description: 'Playbook name previously registered on the product.',
|
|
1558
|
+
},
|
|
1559
|
+
user: { type: 'string', description: 'API user' },
|
|
1560
|
+
},
|
|
1561
|
+
required: ['product', 'name'],
|
|
1562
|
+
},
|
|
1563
|
+
handler: toolProductPlaybookDelete,
|
|
1564
|
+
},
|
|
232
1565
|
];
|