@switchbot/openapi-cli 2.3.0 → 2.5.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/dist/api/client.js +33 -1
- package/dist/commands/agent-bootstrap.js +128 -0
- package/dist/commands/batch.js +109 -15
- package/dist/commands/cache.js +1 -0
- package/dist/commands/capabilities.js +203 -62
- package/dist/commands/config.js +132 -9
- package/dist/commands/devices.js +43 -5
- package/dist/commands/doctor.js +105 -14
- package/dist/commands/events.js +60 -4
- package/dist/commands/history.js +227 -2
- package/dist/commands/mcp.js +203 -35
- package/dist/commands/plan.js +6 -1
- package/dist/commands/quota.js +4 -2
- package/dist/commands/scenes.js +43 -1
- package/dist/commands/schema.js +101 -5
- package/dist/config.js +71 -2
- package/dist/devices/history-agg.js +138 -0
- package/dist/devices/history-query.js +181 -0
- package/dist/index.js +19 -2
- package/dist/lib/devices.js +8 -1
- package/dist/lib/idempotency.js +56 -22
- package/dist/mcp/device-history.js +86 -7
- package/dist/utils/audit.js +66 -1
- package/dist/utils/flags.js +18 -0
- package/dist/utils/format.js +9 -1
- package/dist/utils/name-resolver.js +85 -29
- package/dist/utils/output.js +116 -20
- package/dist/utils/quota.js +14 -0
- package/dist/utils/redact.js +68 -0
- package/dist/version.js +4 -0
- package/package.json +1 -1
package/dist/api/client.js
CHANGED
|
@@ -4,7 +4,23 @@ import { buildAuthHeaders } from '../auth.js';
|
|
|
4
4
|
import { loadConfig } from '../config.js';
|
|
5
5
|
import { isVerbose, isDryRun, getTimeout, getRetryOn429, getBackoffStrategy, isQuotaDisabled, } from '../utils/flags.js';
|
|
6
6
|
import { nextRetryDelayMs, sleep } from '../utils/retry.js';
|
|
7
|
-
import { recordRequest } from '../utils/quota.js';
|
|
7
|
+
import { recordRequest, checkDailyCap } from '../utils/quota.js';
|
|
8
|
+
import { readProfileMeta } from '../config.js';
|
|
9
|
+
import { getActiveProfile } from '../lib/request-context.js';
|
|
10
|
+
import { redactHeaders, warnOnceIfUnsafe } from '../utils/redact.js';
|
|
11
|
+
class DailyCapExceededError extends Error {
|
|
12
|
+
cap;
|
|
13
|
+
total;
|
|
14
|
+
profile;
|
|
15
|
+
constructor(cap, total, profile) {
|
|
16
|
+
super(`Local daily cap reached: ${total}/${cap} SwitchBot API calls used today${profile ? ` for profile "${profile}"` : ''}. ` +
|
|
17
|
+
`Raise with: switchbot ${profile ? `--profile ${profile} ` : ''}config set-token --daily-cap <N>`);
|
|
18
|
+
this.cap = cap;
|
|
19
|
+
this.total = total;
|
|
20
|
+
this.profile = profile;
|
|
21
|
+
this.name = 'DailyCapExceededError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
8
24
|
const API_ERROR_MESSAGES = {
|
|
9
25
|
151: 'Device type does not support this command',
|
|
10
26
|
152: 'Device ID does not exist',
|
|
@@ -31,18 +47,34 @@ export function createClient() {
|
|
|
31
47
|
const maxRetries = getRetryOn429();
|
|
32
48
|
const backoff = getBackoffStrategy();
|
|
33
49
|
const quotaEnabled = !isQuotaDisabled();
|
|
50
|
+
const profile = getActiveProfile();
|
|
51
|
+
const profileMeta = readProfileMeta(profile);
|
|
52
|
+
const dailyCap = profileMeta?.limits?.dailyCap;
|
|
34
53
|
const client = axios.create({
|
|
35
54
|
baseURL: 'https://api.switch-bot.com',
|
|
36
55
|
timeout: getTimeout(),
|
|
37
56
|
});
|
|
38
57
|
// Inject auth headers; optionally log the request; short-circuit on --dry-run.
|
|
39
58
|
client.interceptors.request.use((config) => {
|
|
59
|
+
// Pre-flight cap check: refuse the call before it touches the network.
|
|
60
|
+
if (dailyCap) {
|
|
61
|
+
const check = checkDailyCap(dailyCap);
|
|
62
|
+
if (check.over) {
|
|
63
|
+
throw new DailyCapExceededError(dailyCap, check.total, profile);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
40
66
|
const authHeaders = buildAuthHeaders(token, secret);
|
|
41
67
|
Object.assign(config.headers, authHeaders);
|
|
42
68
|
const method = (config.method ?? 'get').toUpperCase();
|
|
43
69
|
const url = `${config.baseURL ?? ''}${config.url ?? ''}`;
|
|
44
70
|
if (verbose) {
|
|
71
|
+
warnOnceIfUnsafe();
|
|
45
72
|
process.stderr.write(chalk.grey(`[verbose] ${method} ${url}\n`));
|
|
73
|
+
const { safe, redactedCount } = redactHeaders(config.headers);
|
|
74
|
+
process.stderr.write(chalk.grey(`[verbose] headers: ${JSON.stringify(safe)}\n`));
|
|
75
|
+
if (redactedCount > 0) {
|
|
76
|
+
process.stderr.write(chalk.grey(`[verbose] 🔒 ${redactedCount} sensitive header(s) redacted.\n`));
|
|
77
|
+
}
|
|
46
78
|
if (config.data !== undefined) {
|
|
47
79
|
process.stderr.write(chalk.grey(`[verbose] body: ${JSON.stringify(config.data)}\n`));
|
|
48
80
|
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { printJson } from '../utils/output.js';
|
|
2
|
+
import { loadCache } from '../devices/cache.js';
|
|
3
|
+
import { getEffectiveCatalog } from '../devices/catalog.js';
|
|
4
|
+
import { readProfileMeta } from '../config.js';
|
|
5
|
+
import { todayUsage, DAILY_QUOTA } from '../utils/quota.js';
|
|
6
|
+
import { createRequire } from 'node:module';
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const { version: pkgVersion } = require('../../package.json');
|
|
9
|
+
const IDENTITY = {
|
|
10
|
+
product: 'SwitchBot',
|
|
11
|
+
domain: 'IoT smart home device control',
|
|
12
|
+
vendor: 'Wonderlabs, Inc.',
|
|
13
|
+
apiVersion: 'v1.1',
|
|
14
|
+
authMethod: 'HMAC-SHA256 token+secret',
|
|
15
|
+
};
|
|
16
|
+
const SAFETY_TIERS = {
|
|
17
|
+
read: 'No state mutation; safe to call freely.',
|
|
18
|
+
action: 'Mutates device/cloud state but reversible (turnOn, setColor).',
|
|
19
|
+
destructive: 'Hard to reverse / physical-world side effects (unlock). Requires confirmation.',
|
|
20
|
+
};
|
|
21
|
+
const QUICK_REFERENCE = {
|
|
22
|
+
discovery: ['devices list', 'devices describe <id>', 'devices status <id>'],
|
|
23
|
+
action: ['devices command <id> <cmd>', 'devices command --name <q> <cmd>', 'scenes execute <id>'],
|
|
24
|
+
safety: ['--dry-run', '--idempotency-key <k>', '--audit-log', '--no-quota'],
|
|
25
|
+
observability: ['doctor --json', 'quota status', 'cache status', 'events mqtt-tail'],
|
|
26
|
+
history: ['history range <id> --since 7d', 'history stats <id>'],
|
|
27
|
+
};
|
|
28
|
+
export function registerAgentBootstrapCommand(program) {
|
|
29
|
+
program
|
|
30
|
+
.command('agent-bootstrap')
|
|
31
|
+
.description('Print a compact, aggregate JSON snapshot for agent onboarding — combines identity, cached devices, catalog summary, quota usage, and profile in a single call. Offline-safe; does not hit the API.')
|
|
32
|
+
.option('--compact', 'Emit an even smaller payload by dropping catalog descriptions and non-essential fields (target: <20 KB).')
|
|
33
|
+
.addHelpText('after', `
|
|
34
|
+
Output is always JSON (this command ignores --format). It is a one-shot
|
|
35
|
+
orientation document for an agent/LLM to understand what's available without
|
|
36
|
+
spending quota. It reads from local cache (devices + quota + profile) and the
|
|
37
|
+
bundled catalog — no network calls.
|
|
38
|
+
|
|
39
|
+
For fresher device state, have the agent follow up with:
|
|
40
|
+
$ switchbot devices list --json # refreshes cache
|
|
41
|
+
$ switchbot devices status <id> --json
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
$ switchbot agent-bootstrap --compact | wc -c # fit in agent context window
|
|
45
|
+
$ switchbot agent-bootstrap | jq '.devices | length'
|
|
46
|
+
$ switchbot agent-bootstrap --compact | jq '.quickReference'
|
|
47
|
+
`)
|
|
48
|
+
.action((opts) => {
|
|
49
|
+
const compact = Boolean(opts.compact);
|
|
50
|
+
const cache = loadCache();
|
|
51
|
+
const catalog = getEffectiveCatalog();
|
|
52
|
+
const usage = todayUsage();
|
|
53
|
+
const meta = readProfileMeta(undefined);
|
|
54
|
+
const cachedDevices = cache
|
|
55
|
+
? Object.entries(cache.devices).map(([id, d]) => ({
|
|
56
|
+
deviceId: id,
|
|
57
|
+
type: d.type,
|
|
58
|
+
name: d.name,
|
|
59
|
+
category: d.category,
|
|
60
|
+
roomName: d.roomName ?? null,
|
|
61
|
+
}))
|
|
62
|
+
: [];
|
|
63
|
+
const usedTypes = new Set(cachedDevices.map((d) => d.type.toLowerCase()));
|
|
64
|
+
const relevantCatalog = cachedDevices.length > 0
|
|
65
|
+
? catalog.filter((e) => usedTypes.has(e.type.toLowerCase()) ||
|
|
66
|
+
(e.aliases ?? []).some((a) => usedTypes.has(a.toLowerCase())))
|
|
67
|
+
: catalog;
|
|
68
|
+
const catalogTypes = relevantCatalog.map((e) => {
|
|
69
|
+
if (compact) {
|
|
70
|
+
return {
|
|
71
|
+
type: e.type,
|
|
72
|
+
category: e.category,
|
|
73
|
+
role: e.role ?? null,
|
|
74
|
+
readOnly: e.readOnly ?? false,
|
|
75
|
+
commands: e.commands.map((c) => c.command),
|
|
76
|
+
statusFields: e.statusFields ?? [],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
type: e.type,
|
|
81
|
+
category: e.category,
|
|
82
|
+
role: e.role ?? null,
|
|
83
|
+
readOnly: e.readOnly ?? false,
|
|
84
|
+
commands: e.commands.map((c) => ({
|
|
85
|
+
command: c.command,
|
|
86
|
+
parameter: c.parameter,
|
|
87
|
+
destructive: Boolean(c.destructive),
|
|
88
|
+
idempotent: Boolean(c.idempotent),
|
|
89
|
+
})),
|
|
90
|
+
statusFields: e.statusFields ?? [],
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
const payload = {
|
|
94
|
+
schemaVersion: '1.0',
|
|
95
|
+
generatedAt: new Date().toISOString(),
|
|
96
|
+
cliVersion: pkgVersion,
|
|
97
|
+
identity: IDENTITY,
|
|
98
|
+
quickReference: QUICK_REFERENCE,
|
|
99
|
+
safetyTiers: SAFETY_TIERS,
|
|
100
|
+
profile: meta
|
|
101
|
+
? {
|
|
102
|
+
label: meta.label ?? null,
|
|
103
|
+
description: meta.description ?? null,
|
|
104
|
+
dailyCap: meta.limits?.dailyCap ?? null,
|
|
105
|
+
defaultFlags: meta.defaults?.flags ?? null,
|
|
106
|
+
}
|
|
107
|
+
: null,
|
|
108
|
+
quota: {
|
|
109
|
+
date: usage.date,
|
|
110
|
+
total: usage.total,
|
|
111
|
+
remaining: usage.remaining,
|
|
112
|
+
dailyLimit: DAILY_QUOTA,
|
|
113
|
+
},
|
|
114
|
+
devices: cachedDevices,
|
|
115
|
+
catalog: {
|
|
116
|
+
scope: cachedDevices.length > 0 ? 'used' : 'all',
|
|
117
|
+
types: catalogTypes,
|
|
118
|
+
},
|
|
119
|
+
// hints: empty array means no hints to report; always emitted, never null.
|
|
120
|
+
// An empty array signals "nothing to act on" — agents should not treat
|
|
121
|
+
// it as a disabled or missing field.
|
|
122
|
+
hints: cachedDevices.length === 0
|
|
123
|
+
? ['Run `switchbot devices list` once to populate the device cache for richer bootstrap output.']
|
|
124
|
+
: [],
|
|
125
|
+
};
|
|
126
|
+
printJson(payload);
|
|
127
|
+
});
|
|
128
|
+
}
|
package/dist/commands/batch.js
CHANGED
|
@@ -8,8 +8,12 @@ import { DryRunSignal } from '../api/client.js';
|
|
|
8
8
|
import { getCachedTypeMap } from '../devices/cache.js';
|
|
9
9
|
const DEFAULT_CONCURRENCY = 5;
|
|
10
10
|
const COMMAND_TYPES = ['command', 'customize'];
|
|
11
|
-
/**
|
|
12
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Run `task(x)` for every element with at most `concurrency` running at once.
|
|
13
|
+
* `staggerMs`: when > 0, delay each task start by this fixed interval (replaces
|
|
14
|
+
* the default 20-60ms jitter). Useful for rate-limited endpoints.
|
|
15
|
+
*/
|
|
16
|
+
async function runPool(items, concurrency, staggerMs, task) {
|
|
13
17
|
const results = new Array(items.length);
|
|
14
18
|
let cursor = 0;
|
|
15
19
|
const workers = [];
|
|
@@ -19,9 +23,10 @@ async function runPool(items, concurrency, task) {
|
|
|
19
23
|
while (cursor < items.length) {
|
|
20
24
|
const idx = cursor++;
|
|
21
25
|
results[idx] = await task(items[idx]);
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
|
|
26
|
+
// Fixed stagger wins over random jitter when set; else keep the
|
|
27
|
+
// default polite spacing so we don't hammer the endpoint.
|
|
28
|
+
const delay = staggerMs > 0 ? staggerMs : 20 + Math.random() * 40;
|
|
29
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
25
30
|
}
|
|
26
31
|
})());
|
|
27
32
|
}
|
|
@@ -89,10 +94,13 @@ export function registerBatchCommand(devices) {
|
|
|
89
94
|
.option('--filter <expr>', 'Target devices matching a filter, e.g. type=Bot,family=Home', stringArg('--filter'))
|
|
90
95
|
.option('--ids <csv>', 'Explicit comma-separated list of deviceIds', stringArg('--ids'))
|
|
91
96
|
.option('--concurrency <n>', 'Max parallel in-flight requests (default 5)', intArg('--concurrency', { min: 1 }), '5')
|
|
97
|
+
.option('--max-concurrent <n>', 'Alias for --concurrency; takes priority when set', intArg('--max-concurrent', { min: 1 }))
|
|
98
|
+
.option('--stagger <ms>', 'Fixed delay between task starts in ms (default 0 = random 20-60ms jitter)', intArg('--stagger', { min: 0 }), '0')
|
|
99
|
+
.option('--plan', 'With --dry-run: emit a plan JSON document instead of executing anything')
|
|
92
100
|
.option('--yes', 'Allow destructive commands (Smart Lock unlock, garage open, ...)')
|
|
93
101
|
.option('--type <commandType>', '"command" (default) or "customize" for user-defined IR buttons', enumArg('--type', COMMAND_TYPES), 'command')
|
|
94
102
|
.option('--stdin', 'Read deviceIds from stdin, one per line (same as trailing "-")')
|
|
95
|
-
.option('--idempotency-key-prefix <prefix>', '
|
|
103
|
+
.option('--idempotency-key-prefix <prefix>', 'Client-supplied prefix for idempotency keys (key per device: <prefix>-<deviceId>). process-local 60s window; cache is per Node process (MCP session, batch run, plan run). Independent CLI invocations do not share cache.', stringArg('--idempotency-key-prefix'))
|
|
96
104
|
.addHelpText('after', `
|
|
97
105
|
Targets are resolved in this priority order:
|
|
98
106
|
1. --ids when present (explicit deviceIds)
|
|
@@ -109,7 +117,16 @@ Supported keys: type, family, room, category (category: physical | ir)
|
|
|
109
117
|
|
|
110
118
|
Output:
|
|
111
119
|
Human mode: one status line per device, summary at the end.
|
|
112
|
-
--json: {succeeded[], failed[{deviceId,error}], summary:{total,ok,failed,skipped,durationMs}}
|
|
120
|
+
--json: {succeeded[], failed[{deviceId,error}], summary:{total,ok,failed,skipped,durationMs,maxConcurrent,staggerMs}}
|
|
121
|
+
Each step includes startedAt / finishedAt / durationMs / replayed (when cached).
|
|
122
|
+
|
|
123
|
+
Concurrency & pacing:
|
|
124
|
+
--max-concurrent <n> Upper bound on in-flight requests (alias for --concurrency).
|
|
125
|
+
--stagger <ms> Fixed delay between task starts; default 0 uses random 20-60ms jitter.
|
|
126
|
+
|
|
127
|
+
Planning:
|
|
128
|
+
--dry-run --plan Print the plan JSON without executing anything. Useful
|
|
129
|
+
for agents that want to show the user what will run.
|
|
113
130
|
|
|
114
131
|
Safety:
|
|
115
132
|
Destructive commands (Smart Lock unlock, Garage Door Opener turnOn/turnOff,
|
|
@@ -212,10 +229,48 @@ Examples:
|
|
|
212
229
|
// keep as string
|
|
213
230
|
}
|
|
214
231
|
}
|
|
215
|
-
const
|
|
232
|
+
const maxConcurrentRaw = options.maxConcurrent ?? options.concurrency;
|
|
233
|
+
const concurrency = Math.max(1, Number.parseInt(maxConcurrentRaw, 10) || DEFAULT_CONCURRENCY);
|
|
234
|
+
const staggerMs = Math.max(0, Number.parseInt(options.stagger, 10) || 0);
|
|
216
235
|
const dryRun = isDryRun();
|
|
236
|
+
// --dry-run --plan: emit a plan document and return without executing.
|
|
237
|
+
if (dryRun && options.plan) {
|
|
238
|
+
const steps = resolved.ids.map((id) => ({
|
|
239
|
+
deviceId: id,
|
|
240
|
+
command: cmd,
|
|
241
|
+
parameter: parsedParam,
|
|
242
|
+
type: effectiveType,
|
|
243
|
+
idempotencyKey: options.idempotencyKeyPrefix
|
|
244
|
+
? `${options.idempotencyKeyPrefix}-${id}`
|
|
245
|
+
: undefined,
|
|
246
|
+
}));
|
|
247
|
+
const planDoc = {
|
|
248
|
+
schemaVersion: '1.1',
|
|
249
|
+
dryRun: true,
|
|
250
|
+
plan: {
|
|
251
|
+
command: cmd,
|
|
252
|
+
parameter: parsedParam,
|
|
253
|
+
type: effectiveType,
|
|
254
|
+
maxConcurrent: concurrency,
|
|
255
|
+
staggerMs,
|
|
256
|
+
stepCount: steps.length,
|
|
257
|
+
steps,
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
if (isJsonMode()) {
|
|
261
|
+
printJson(planDoc);
|
|
262
|
+
}
|
|
263
|
+
else {
|
|
264
|
+
console.log(`Plan: ${steps.length} step(s), command=${cmd}, maxConcurrent=${concurrency}, staggerMs=${staggerMs}`);
|
|
265
|
+
for (const s of steps)
|
|
266
|
+
console.log(` → ${s.deviceId} ${s.type} ${s.command}`);
|
|
267
|
+
}
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
217
270
|
const startedAt = Date.now();
|
|
218
|
-
const outcomes = await runPool(resolved.ids, concurrency, async (id) => {
|
|
271
|
+
const outcomes = await runPool(resolved.ids, concurrency, staggerMs, async (id) => {
|
|
272
|
+
const stepStart = Date.now();
|
|
273
|
+
const startedIso = new Date(stepStart).toISOString();
|
|
219
274
|
try {
|
|
220
275
|
const idempotencyKey = options.idempotencyKeyPrefix
|
|
221
276
|
? `${options.idempotencyKeyPrefix}-${id}`
|
|
@@ -223,30 +278,67 @@ Examples:
|
|
|
223
278
|
const result = await executeCommand(id, cmd, parsedParam, effectiveType, getClient(), {
|
|
224
279
|
idempotencyKey,
|
|
225
280
|
});
|
|
281
|
+
const finishedIso = new Date().toISOString();
|
|
282
|
+
const durationMs = Date.now() - stepStart;
|
|
283
|
+
const replayed = typeof result === 'object' && result !== null && result.replayed === true;
|
|
226
284
|
if (!isJsonMode()) {
|
|
227
|
-
console.log(`✓ ${id}: ${cmd}`);
|
|
285
|
+
console.log(`✓ ${id}: ${cmd}${replayed ? ' (replayed)' : ''}`);
|
|
228
286
|
}
|
|
229
|
-
return {
|
|
287
|
+
return {
|
|
288
|
+
ok: true,
|
|
289
|
+
deviceId: id,
|
|
290
|
+
result,
|
|
291
|
+
startedAt: startedIso,
|
|
292
|
+
finishedAt: finishedIso,
|
|
293
|
+
durationMs,
|
|
294
|
+
replayed,
|
|
295
|
+
};
|
|
230
296
|
}
|
|
231
297
|
catch (err) {
|
|
232
298
|
// --dry-run uses DryRunSignal to short-circuit; surface that as a
|
|
233
299
|
// "skipped" outcome, not a failure.
|
|
234
300
|
if (err instanceof DryRunSignal) {
|
|
235
|
-
return {
|
|
301
|
+
return {
|
|
302
|
+
ok: 'dry-run',
|
|
303
|
+
deviceId: id,
|
|
304
|
+
startedAt: startedIso,
|
|
305
|
+
finishedAt: new Date().toISOString(),
|
|
306
|
+
durationMs: Date.now() - stepStart,
|
|
307
|
+
};
|
|
236
308
|
}
|
|
237
309
|
const errorPayload = buildErrorPayload(err);
|
|
238
310
|
if (!isJsonMode()) {
|
|
239
311
|
console.error(`✗ ${id}: ${errorPayload.message}`);
|
|
240
312
|
}
|
|
241
|
-
return {
|
|
313
|
+
return {
|
|
314
|
+
ok: false,
|
|
315
|
+
deviceId: id,
|
|
316
|
+
error: errorPayload,
|
|
317
|
+
startedAt: startedIso,
|
|
318
|
+
finishedAt: new Date().toISOString(),
|
|
319
|
+
durationMs: Date.now() - stepStart,
|
|
320
|
+
};
|
|
242
321
|
}
|
|
243
322
|
});
|
|
244
323
|
const succeeded = outcomes.filter((o) => o.ok === true);
|
|
245
324
|
const failed = outcomes.filter((o) => o.ok === false);
|
|
246
325
|
const dryRunned = outcomes.filter((o) => o.ok === 'dry-run');
|
|
247
326
|
const result = {
|
|
248
|
-
succeeded: succeeded.map((s) => ({
|
|
249
|
-
|
|
327
|
+
succeeded: succeeded.map((s) => ({
|
|
328
|
+
deviceId: s.deviceId,
|
|
329
|
+
result: s.result,
|
|
330
|
+
startedAt: s.startedAt,
|
|
331
|
+
finishedAt: s.finishedAt,
|
|
332
|
+
durationMs: s.durationMs,
|
|
333
|
+
replayed: s.replayed,
|
|
334
|
+
})),
|
|
335
|
+
failed: failed.map((f) => ({
|
|
336
|
+
deviceId: f.deviceId,
|
|
337
|
+
error: f.error,
|
|
338
|
+
startedAt: f.startedAt,
|
|
339
|
+
finishedAt: f.finishedAt,
|
|
340
|
+
durationMs: f.durationMs,
|
|
341
|
+
})),
|
|
250
342
|
summary: {
|
|
251
343
|
total: resolved.ids.length,
|
|
252
344
|
ok: succeeded.length,
|
|
@@ -254,6 +346,8 @@ Examples:
|
|
|
254
346
|
skipped: dryRunned.length,
|
|
255
347
|
durationMs: Date.now() - startedAt,
|
|
256
348
|
schemaVersion: '1.1',
|
|
349
|
+
maxConcurrent: concurrency,
|
|
350
|
+
staggerMs,
|
|
257
351
|
...(dryRun ? { dryRun: true } : {}),
|
|
258
352
|
},
|
|
259
353
|
};
|