@thinremote/thinr-cli 1.2.0-alpha.1 → 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 +45 -1
- package/README.md +192 -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/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 +1226 -105
- 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 +14 -3
- package/commands/product.js +0 -407
|
@@ -1,16 +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';
|
|
11
|
-
import { getDevices, filterActiveDevices } from '../devices.js';
|
|
12
30
|
import { createDeviceAPI } from '../device-api.js';
|
|
13
|
-
import {
|
|
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';
|
|
14
39
|
import { inputError } from '../errors.js';
|
|
15
40
|
|
|
16
41
|
async function toolProducts(args) {
|
|
@@ -36,6 +61,104 @@ async function toolProducts(args) {
|
|
|
36
61
|
};
|
|
37
62
|
}
|
|
38
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
|
+
|
|
39
162
|
async function toolProductDelete(args) {
|
|
40
163
|
if (!args.product) throw inputError('product is required');
|
|
41
164
|
const { steps } = await deleteProductWithStorage(args.product, args.user, {
|
|
@@ -82,7 +205,8 @@ async function toolProductScriptWrite(args) {
|
|
|
82
205
|
name: args.name,
|
|
83
206
|
content: args.content,
|
|
84
207
|
user: args.user,
|
|
85
|
-
|
|
208
|
+
input: args.input,
|
|
209
|
+
output: args.output,
|
|
86
210
|
});
|
|
87
211
|
const lines = [
|
|
88
212
|
`Installed product script "${args.name}" on "${args.product}".`,
|
|
@@ -96,6 +220,135 @@ async function toolProductScriptWrite(args) {
|
|
|
96
220
|
};
|
|
97
221
|
}
|
|
98
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
|
+
|
|
99
352
|
async function toolProductScriptDelete(args) {
|
|
100
353
|
const { steps } = await removeProductScript({
|
|
101
354
|
product: args.product,
|
|
@@ -113,6 +366,333 @@ async function toolProductScriptDelete(args) {
|
|
|
113
366
|
};
|
|
114
367
|
}
|
|
115
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
|
+
|
|
116
696
|
/**
|
|
117
697
|
* @typedef {{
|
|
118
698
|
* device: string,
|
|
@@ -126,22 +706,52 @@ async function toolProductScriptDelete(args) {
|
|
|
126
706
|
* }} ExecEntry
|
|
127
707
|
*/
|
|
128
708
|
|
|
129
|
-
async function
|
|
709
|
+
async function toolProductWrite(args) {
|
|
130
710
|
if (!args.product) throw inputError('product is required');
|
|
131
|
-
if (!args.
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
? Math.floor(args.concurrency)
|
|
135
|
-
: 10;
|
|
136
|
-
const timeout =
|
|
137
|
-
Number.isFinite(args.timeout) && args.timeout > 0 ? Math.floor(args.timeout) : 30;
|
|
138
|
-
const failFast = !!args.fail_fast;
|
|
139
|
-
const includeOffline = !!args.all;
|
|
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');
|
|
140
714
|
|
|
141
|
-
const
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
+
});
|
|
145
755
|
|
|
146
756
|
if (devices.length === 0) {
|
|
147
757
|
const suffix = args.group ? `, group="${args.group}"` : '';
|
|
@@ -149,101 +759,111 @@ async function toolProductExec(args) {
|
|
|
149
759
|
content: [
|
|
150
760
|
{
|
|
151
761
|
type: 'text',
|
|
152
|
-
text: `No devices to
|
|
762
|
+
text: `No devices to upload to (product="${args.product}"${suffix}).`,
|
|
153
763
|
},
|
|
154
764
|
],
|
|
155
765
|
isError: false,
|
|
156
766
|
};
|
|
157
767
|
}
|
|
158
768
|
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
ok: false,
|
|
175
|
-
exitCode: null,
|
|
176
|
-
timedOut: false,
|
|
177
|
-
durationMs: 0,
|
|
178
|
-
stdout: '',
|
|
179
|
-
stderr: '',
|
|
180
|
-
error: `skipped (fail-fast after ${firstFailureDevice})`,
|
|
181
|
-
};
|
|
182
|
-
return skipped;
|
|
183
|
-
}
|
|
184
|
-
const perStart = Date.now();
|
|
185
|
-
let stdout = '';
|
|
186
|
-
let stderr = '';
|
|
187
|
-
/** @type {ExecEntry} */
|
|
188
|
-
let entry;
|
|
189
|
-
try {
|
|
190
|
-
const api = createDeviceAPI(device.device, { user: args.user || undefined });
|
|
191
|
-
const { exitCode, timedOut } = await api.execStream(args.command, {
|
|
192
|
-
timeout,
|
|
193
|
-
onStdout: (s) => {
|
|
194
|
-
stdout += s;
|
|
195
|
-
},
|
|
196
|
-
onStderr: (s) => {
|
|
197
|
-
stderr += s;
|
|
198
|
-
},
|
|
199
|
-
});
|
|
200
|
-
entry = {
|
|
201
|
-
device: device.device,
|
|
202
|
-
ok: !timedOut && exitCode === 0,
|
|
203
|
-
exitCode: exitCode ?? null,
|
|
204
|
-
timedOut: !!timedOut,
|
|
205
|
-
durationMs: Date.now() - perStart,
|
|
206
|
-
stdout,
|
|
207
|
-
stderr,
|
|
208
|
-
};
|
|
209
|
-
} catch (err) {
|
|
210
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
211
|
-
entry = {
|
|
212
|
-
device: device.device,
|
|
213
|
-
ok: false,
|
|
214
|
-
exitCode: null,
|
|
215
|
-
timedOut: false,
|
|
216
|
-
durationMs: Date.now() - perStart,
|
|
217
|
-
stdout,
|
|
218
|
-
stderr,
|
|
219
|
-
error: msg,
|
|
220
|
-
};
|
|
221
|
-
}
|
|
222
|
-
if (failFast && !entry.ok && !aborted) {
|
|
223
|
-
aborted = true;
|
|
224
|
-
firstFailureDevice = entry.device;
|
|
225
|
-
}
|
|
226
|
-
return entry;
|
|
227
|
-
});
|
|
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
|
+
}
|
|
228
784
|
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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,
|
|
236
843
|
ok: false,
|
|
237
844
|
exitCode: null,
|
|
238
845
|
timedOut: false,
|
|
239
846
|
durationMs: 0,
|
|
240
847
|
stdout: '',
|
|
241
848
|
stderr: '',
|
|
242
|
-
error:
|
|
243
|
-
}
|
|
849
|
+
error: `skipped (fail-fast after ${firstFailure})`,
|
|
850
|
+
}),
|
|
851
|
+
isFailure: (entry) => !entry.ok,
|
|
244
852
|
});
|
|
245
853
|
|
|
246
|
-
|
|
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
|
+
|
|
247
867
|
const okCount = entries.filter((e) => e.ok).length;
|
|
248
868
|
const timedOutCount = entries.filter((e) => e.timedOut).length;
|
|
249
869
|
const errorCount = entries.filter((e) => e.error).length;
|
|
@@ -267,7 +887,7 @@ async function toolProductExec(args) {
|
|
|
267
887
|
if (e.stderr) lines.push(`stderr: ${e.stderr.trimEnd()}`);
|
|
268
888
|
}
|
|
269
889
|
lines.push(
|
|
270
|
-
`\nSummary: ${okCount} ok, ${failCount} failed, ${timedOutCount} timed out, ${errorCount} errored — ${
|
|
890
|
+
`\nSummary: ${okCount} ok, ${failCount} failed, ${timedOutCount} timed out, ${errorCount} errored — ${durationMs}ms total`,
|
|
271
891
|
);
|
|
272
892
|
|
|
273
893
|
const anyFailure = failCount + timedOutCount + errorCount > 0;
|
|
@@ -289,7 +909,122 @@ export const tools = [
|
|
|
289
909
|
},
|
|
290
910
|
required: [],
|
|
291
911
|
},
|
|
292
|
-
handler: toolProducts,
|
|
912
|
+
handler: toolProducts,
|
|
913
|
+
},
|
|
914
|
+
{
|
|
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,
|
|
293
1028
|
},
|
|
294
1029
|
{
|
|
295
1030
|
name: 'thinr_product_delete',
|
|
@@ -345,12 +1080,22 @@ Product scripts are executable files kept in a per-product storage ("<product>")
|
|
|
345
1080
|
},
|
|
346
1081
|
{
|
|
347
1082
|
name: 'thinr_product_script_write',
|
|
348
|
-
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.
|
|
349
1086
|
|
|
350
1087
|
Script contract:
|
|
351
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).
|
|
352
|
-
-
|
|
353
|
-
-
|
|
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.
|
|
354
1099
|
|
|
355
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".`,
|
|
356
1101
|
inputSchema: {
|
|
@@ -367,10 +1112,16 @@ The file extension (.sh, .py, .js, …) is preserved in the storage for editor h
|
|
|
367
1112
|
description:
|
|
368
1113
|
'Full script source code. Include a shebang to use a non-shell interpreter.',
|
|
369
1114
|
},
|
|
370
|
-
|
|
371
|
-
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',
|
|
372
1123
|
description:
|
|
373
|
-
'
|
|
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".',
|
|
374
1125
|
},
|
|
375
1126
|
user: { type: 'string', description: 'API user' },
|
|
376
1127
|
},
|
|
@@ -378,6 +1129,50 @@ The file extension (.sh, .py, .js, …) is preserved in the storage for editor h
|
|
|
378
1129
|
},
|
|
379
1130
|
handler: toolProductScriptWrite,
|
|
380
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
|
+
},
|
|
381
1176
|
{
|
|
382
1177
|
name: 'thinr_product_exec',
|
|
383
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.
|
|
@@ -441,4 +1236,330 @@ Prefer this over iterating \`thinr_exec\` device-by-device when you need to roll
|
|
|
441
1236
|
},
|
|
442
1237
|
handler: toolProductScriptDelete,
|
|
443
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
|
+
},
|
|
444
1565
|
];
|