@buoy-gg/agent-core 7.0.33
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/LICENSE +58 -0
- package/README.md +42 -0
- package/lib/commonjs/blocks/receipts.js +702 -0
- package/lib/commonjs/blocks/receipts.js.map +1 -0
- package/lib/commonjs/blocks/types.js +119 -0
- package/lib/commonjs/blocks/types.js.map +1 -0
- package/lib/commonjs/blocks/uiTool.js +994 -0
- package/lib/commonjs/blocks/uiTool.js.map +1 -0
- package/lib/commonjs/catalog/catalog.g.js +4044 -0
- package/lib/commonjs/catalog/catalog.g.js.map +1 -0
- package/lib/commonjs/catalog/catalog.source.json +4956 -0
- package/lib/commonjs/catalog/snapshotReads.js +256 -0
- package/lib/commonjs/catalog/snapshotReads.js.map +1 -0
- package/lib/commonjs/catalog/toProviderTools.js +107 -0
- package/lib/commonjs/catalog/toProviderTools.js.map +1 -0
- package/lib/commonjs/catalog/validateParams.js +129 -0
- package/lib/commonjs/catalog/validateParams.js.map +1 -0
- package/lib/commonjs/context/buildContextPack.js +141 -0
- package/lib/commonjs/context/buildContextPack.js.map +1 -0
- package/lib/commonjs/effects/ledger.js +229 -0
- package/lib/commonjs/effects/ledger.js.map +1 -0
- package/lib/commonjs/engine/effectFor.js +95 -0
- package/lib/commonjs/engine/effectFor.js.map +1 -0
- package/lib/commonjs/engine/runAgentTurn.js +667 -0
- package/lib/commonjs/engine/runAgentTurn.js.map +1 -0
- package/lib/commonjs/engine/systemPrompt.js +86 -0
- package/lib/commonjs/engine/systemPrompt.js.map +1 -0
- package/lib/commonjs/index.js +288 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/policy/labels.js +303 -0
- package/lib/commonjs/policy/labels.js.map +1 -0
- package/lib/commonjs/policy/policy.js +143 -0
- package/lib/commonjs/policy/policy.js.map +1 -0
- package/lib/commonjs/policy/redact.js +135 -0
- package/lib/commonjs/policy/redact.js.map +1 -0
- package/lib/commonjs/providers/anthropic.js +272 -0
- package/lib/commonjs/providers/anthropic.js.map +1 -0
- package/lib/commonjs/providers/openai.js +193 -0
- package/lib/commonjs/providers/openai.js.map +1 -0
- package/lib/commonjs/providers/sse.js +104 -0
- package/lib/commonjs/providers/sse.js.map +1 -0
- package/lib/commonjs/providers/types.js +6 -0
- package/lib/commonjs/providers/types.js.map +1 -0
- package/lib/commonjs/session.js +173 -0
- package/lib/commonjs/session.js.map +1 -0
- package/lib/commonjs/types.js +2 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/blocks/receipts.js +696 -0
- package/lib/module/blocks/receipts.js.map +1 -0
- package/lib/module/blocks/types.js +113 -0
- package/lib/module/blocks/types.js.map +1 -0
- package/lib/module/blocks/uiTool.js +987 -0
- package/lib/module/blocks/uiTool.js.map +1 -0
- package/lib/module/catalog/catalog.g.js +4040 -0
- package/lib/module/catalog/catalog.g.js.map +1 -0
- package/lib/module/catalog/catalog.source.json +4956 -0
- package/lib/module/catalog/snapshotReads.js +252 -0
- package/lib/module/catalog/snapshotReads.js.map +1 -0
- package/lib/module/catalog/toProviderTools.js +101 -0
- package/lib/module/catalog/toProviderTools.js.map +1 -0
- package/lib/module/catalog/validateParams.js +124 -0
- package/lib/module/catalog/validateParams.js.map +1 -0
- package/lib/module/context/buildContextPack.js +138 -0
- package/lib/module/context/buildContextPack.js.map +1 -0
- package/lib/module/effects/ledger.js +223 -0
- package/lib/module/effects/ledger.js.map +1 -0
- package/lib/module/engine/effectFor.js +91 -0
- package/lib/module/engine/effectFor.js.map +1 -0
- package/lib/module/engine/runAgentTurn.js +663 -0
- package/lib/module/engine/runAgentTurn.js.map +1 -0
- package/lib/module/engine/systemPrompt.js +82 -0
- package/lib/module/engine/systemPrompt.js.map +1 -0
- package/lib/module/index.js +30 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/policy/labels.js +296 -0
- package/lib/module/policy/labels.js.map +1 -0
- package/lib/module/policy/policy.js +137 -0
- package/lib/module/policy/policy.js.map +1 -0
- package/lib/module/policy/redact.js +128 -0
- package/lib/module/policy/redact.js.map +1 -0
- package/lib/module/providers/anthropic.js +268 -0
- package/lib/module/providers/anthropic.js.map +1 -0
- package/lib/module/providers/openai.js +189 -0
- package/lib/module/providers/openai.js.map +1 -0
- package/lib/module/providers/sse.js +99 -0
- package/lib/module/providers/sse.js.map +1 -0
- package/lib/module/providers/types.js +4 -0
- package/lib/module/providers/types.js.map +1 -0
- package/lib/module/session.js +168 -0
- package/lib/module/session.js.map +1 -0
- package/lib/module/types.js +2 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/blocks/receipts.d.ts +32 -0
- package/lib/typescript/blocks/receipts.d.ts.map +1 -0
- package/lib/typescript/blocks/types.d.ts +576 -0
- package/lib/typescript/blocks/types.d.ts.map +1 -0
- package/lib/typescript/blocks/uiTool.d.ts +15 -0
- package/lib/typescript/blocks/uiTool.d.ts.map +1 -0
- package/lib/typescript/catalog/catalog.g.d.ts +11 -0
- package/lib/typescript/catalog/catalog.g.d.ts.map +1 -0
- package/lib/typescript/catalog/snapshotReads.d.ts +37 -0
- package/lib/typescript/catalog/snapshotReads.d.ts.map +1 -0
- package/lib/typescript/catalog/toProviderTools.d.ts +52 -0
- package/lib/typescript/catalog/toProviderTools.d.ts.map +1 -0
- package/lib/typescript/catalog/validateParams.d.ts +33 -0
- package/lib/typescript/catalog/validateParams.d.ts.map +1 -0
- package/lib/typescript/context/buildContextPack.d.ts +32 -0
- package/lib/typescript/context/buildContextPack.d.ts.map +1 -0
- package/lib/typescript/effects/ledger.d.ts +119 -0
- package/lib/typescript/effects/ledger.d.ts.map +1 -0
- package/lib/typescript/engine/effectFor.d.ts +21 -0
- package/lib/typescript/engine/effectFor.d.ts.map +1 -0
- package/lib/typescript/engine/runAgentTurn.d.ts +140 -0
- package/lib/typescript/engine/runAgentTurn.d.ts.map +1 -0
- package/lib/typescript/engine/systemPrompt.d.ts +49 -0
- package/lib/typescript/engine/systemPrompt.d.ts.map +1 -0
- package/lib/typescript/index.d.ts +29 -0
- package/lib/typescript/index.d.ts.map +1 -0
- package/lib/typescript/policy/labels.d.ts +10 -0
- package/lib/typescript/policy/labels.d.ts.map +1 -0
- package/lib/typescript/policy/policy.d.ts +106 -0
- package/lib/typescript/policy/policy.d.ts.map +1 -0
- package/lib/typescript/policy/redact.d.ts +37 -0
- package/lib/typescript/policy/redact.d.ts.map +1 -0
- package/lib/typescript/providers/anthropic.d.ts +22 -0
- package/lib/typescript/providers/anthropic.d.ts.map +1 -0
- package/lib/typescript/providers/openai.d.ts +19 -0
- package/lib/typescript/providers/openai.d.ts.map +1 -0
- package/lib/typescript/providers/sse.d.ts +18 -0
- package/lib/typescript/providers/sse.d.ts.map +1 -0
- package/lib/typescript/providers/types.d.ts +129 -0
- package/lib/typescript/providers/types.d.ts.map +1 -0
- package/lib/typescript/session.d.ts +58 -0
- package/lib/typescript/session.d.ts.map +1 -0
- package/lib/typescript/types.d.ts +119 -0
- package/lib/typescript/types.d.ts.map +1 -0
- package/package.json +58 -0
|
@@ -0,0 +1,4044 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
Object.defineProperty(exports, "__esModule", {
|
|
4
|
+
value: true
|
|
5
|
+
});
|
|
6
|
+
exports.CATALOG_TOOL_COUNT = exports.CATALOG_ACTION_COUNT = exports.CATALOG = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* GENERATED by scripts/gen-agent-catalog.mjs — do not edit.
|
|
9
|
+
* Source: packages/agent-core/src/catalog/catalog.source.json
|
|
10
|
+
*
|
|
11
|
+
* 24 Buoy tools, 192 actions.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const CATALOG = exports.CATALOG = [{
|
|
15
|
+
"toolId": "env",
|
|
16
|
+
"title": "Env",
|
|
17
|
+
"summary": "Env is a read-only snapshot tool with exactly one action, getSnapshot. The payload is `{ env: Record<string,string>, requiredEnvVars: (string | {key, expectedValue, description?} | {key, expectedType, description?})[] }` where `expectedType` is one of string|number|boolean|array|object|url. Reach for it to answer \"what API URL / feature flag / environment is this build pointed at\" and \"which required env vars are missing, empty, or the wrong value/type\". Values are baked into the JS bundle at build time and the adapter's `subscribe` is a no-op, so the snapshot is static for the life of the app — there is no way to set, change, or reload an env var from here, and only EXPO_PUBLIC_-style vars exist in the RN runtime at all (secrets are not present).",
|
|
18
|
+
"actions": [{
|
|
19
|
+
"action": "getSnapshot",
|
|
20
|
+
"summary": "Read this build's environment: every EXPO_PUBLIC_-style variable it was compiled with, plus the app's required-env-var checks.",
|
|
21
|
+
"description": "Returns `{ env: Record<string,string>, requiredEnvVars: [...] }`. This is the ONLY read env has — it exposes no other action. Values are baked into the JS bundle at build time and never change at runtime, so one read is good for the life of the app, and real secrets are not present (only EXPO_PUBLIC_-style vars exist in the RN runtime at all).",
|
|
22
|
+
"params": {
|
|
23
|
+
"type": "object",
|
|
24
|
+
"properties": {},
|
|
25
|
+
"additionalProperties": false
|
|
26
|
+
},
|
|
27
|
+
"effect": "read",
|
|
28
|
+
"release": "works"
|
|
29
|
+
}],
|
|
30
|
+
"unavailableWhen": "The whole tool is absent unless @buoy-gg/env resolves at runtime (autoExternalSync only registers `map.env` when the optional require succeeds) — and, separately, no env snapshot reaches the agent at all in a release bundle: the device only dials the broker when `__DEV__` is true, or when the app opts in with `enableInRelease: true` plus a valid Pro license."
|
|
31
|
+
}, {
|
|
32
|
+
"toolId": "console",
|
|
33
|
+
"title": "Console",
|
|
34
|
+
"summary": "Reach for this when the app crashed, redboxed, or logged something you need to see: the device's captured console.log/info/warn/error/debug/trace/dir/table/assert/group output plus uncaught JS errors tagged [FATAL]/[UNCAUGHT]/[RENDER ERROR] with component stacks. Read it with getSnapshot (a compact time·level·message tail, filterable by level/pattern and capped by limit). The only other action is clearEntries, and it is destructive: it wipes the crash evidence. Capture is a 1000-entry in-memory ring buffer that starts when Buoy mounts, so anything logged before that is absent, and it resets on every JS reload unless the user turned on \"Preserve log\" (@react_buoy_console_preserve).",
|
|
35
|
+
"actions": [{
|
|
36
|
+
"action": "getSnapshot",
|
|
37
|
+
"summary": "Read the app's captured console output — log/info/warn/error plus uncaught JS errors tagged [FATAL]. Start here when the app crashed, redboxed, or logged something.",
|
|
38
|
+
"description": "Returns `{ totalCaptured, shown, entries: [{at, level, message}] }`, newest last. Narrow with `level` (severity threshold), `pattern` (substring) and `limit` — the raw buffer holds up to 1000 entries at roughly 1KB each, so an unfiltered read is the most expensive thing you can ask for. Only the pre-rendered message is returned; full args and stacks stay on the device. Capture is an in-memory ring buffer that starts when Buoy mounts and resets on every JS reload unless the user turned on \"Preserve log\", so anything logged before that is genuinely absent — say \"nothing was captured\", never \"nothing happened\".",
|
|
39
|
+
"params": {
|
|
40
|
+
"type": "object",
|
|
41
|
+
"properties": {
|
|
42
|
+
"limit": {
|
|
43
|
+
"type": "number",
|
|
44
|
+
"description": "Most-recent N entries after filtering. Default 40."
|
|
45
|
+
},
|
|
46
|
+
"level": {
|
|
47
|
+
"type": "string",
|
|
48
|
+
"enum": ["verbose", "debug", "info", "log", "warn", "error"],
|
|
49
|
+
"description": "Minimum severity, e.g. 'warn' for warnings and errors only."
|
|
50
|
+
},
|
|
51
|
+
"pattern": {
|
|
52
|
+
"type": "string",
|
|
53
|
+
"description": "Case-insensitive substring the message must contain."
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"additionalProperties": false
|
|
57
|
+
},
|
|
58
|
+
"effect": "read",
|
|
59
|
+
"release": "works"
|
|
60
|
+
}, {
|
|
61
|
+
"action": "clearEntries",
|
|
62
|
+
"summary": "Permanently wipe every captured console entry on the device, including [FATAL] crash records and the preserved-log buffer on disk.",
|
|
63
|
+
"params": {
|
|
64
|
+
"type": "object",
|
|
65
|
+
"properties": {},
|
|
66
|
+
"additionalProperties": false,
|
|
67
|
+
"description": "No parameters. Any params object is ignored by the handler."
|
|
68
|
+
},
|
|
69
|
+
"effect": "destructive",
|
|
70
|
+
"release": "works",
|
|
71
|
+
"description": "Takes no params — the handler ignores anything passed. Calls consoleLogStore.clearEntries(), which (1) fires the onClear listeners so a desktop dashboard in mirror mode forwards the clear down to the device, (2) removes the persisted key `@react_buoy_console_buffer` from storage, and (3) empties the in-memory 1000-entry ring buffer and notifies subscribers. Always returns {cleared:true}, even if the buffer was already empty — a true result is NOT evidence anything existed. There is no undo and no re-capture: a crash entry ([FATAL]/[UNCAUGHT]/[RENDER ERROR], the app's last words before it died) is gone for good, and a dead app will never re-log it. Read with get_console / get_snapshot('console') BEFORE clearing. Legitimate use is narrow: zeroing the log right before reproducing a bug so the next read contains only that repro.",
|
|
72
|
+
"requires": ["@buoy-gg/console installed in the app", "<FloatingDevTools /> rendered — it auto-mounts ConsoleRoot (capture) and registers consoleSyncAdapter as the \"console\" capability", "external-sync connection to the broker (:42831)", "in a release build capture starts only when ConsoleRoot mounts — the import-time install is __DEV__-gated, so pre-mount/boot console output is never captured"]
|
|
73
|
+
}],
|
|
74
|
+
"unavailableWhen": "The app doesn't depend on @buoy-gg/console, or FloatingDevTools never renders — in a release build it bails out for anonymous/free users (only a Pro license renders it), so neither the console capability nor its snapshot is announced at all."
|
|
75
|
+
}, {
|
|
76
|
+
"toolId": "sentry",
|
|
77
|
+
"title": "Sentry",
|
|
78
|
+
"summary": "What the app is SENDING to Sentry — errors, transactions, logs, sessions — captured at the SDK's beforeEnvelope tee on their way out of the device. Reach for it when the user asks whether an error/crash was reported to Sentry, what Sentry traffic the app produces, or why the Sentry bill is big (transaction items carry spanCount — spans are Sentry's tracing billing unit). Read with getSnapshot; clearEnvelopes wipes the captured list on the device only (copies already sent to Sentry are unaffected). The snapshot's `status` matters: \"sdk-not-found\" or \"no-client\" means the app has no live Sentry client, so say that plainly — an empty list is NOT evidence that nothing errored. For crash stack traces themselves, the console tool's [FATAL] entries are usually the better read.",
|
|
79
|
+
"actions": [{
|
|
80
|
+
"action": "getSnapshot",
|
|
81
|
+
"summary": "Read the envelopes the app has sent to Sentry — newest first, summaries only. Start here for \"was that error reported?\" and \"what is the app sending to Sentry?\".",
|
|
82
|
+
"params": {
|
|
83
|
+
"type": "object",
|
|
84
|
+
"properties": {
|
|
85
|
+
"limit": {
|
|
86
|
+
"type": "number",
|
|
87
|
+
"description": "Most-recent N envelopes after filtering. Default 10."
|
|
88
|
+
},
|
|
89
|
+
"type": {
|
|
90
|
+
"type": "string",
|
|
91
|
+
"description": "Only envelopes carrying an item of this Sentry type, e.g. 'event' (errors), 'transaction', 'log', 'session'."
|
|
92
|
+
},
|
|
93
|
+
"pattern": {
|
|
94
|
+
"type": "string",
|
|
95
|
+
"description": "Case-insensitive substring an item summary must contain."
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
"additionalProperties": false
|
|
99
|
+
},
|
|
100
|
+
"effect": "read",
|
|
101
|
+
"release": "works",
|
|
102
|
+
"description": "Returns `{status, totalCaptured, shown, envelopes:[{id, at, origin, eventId, totalBytes, items:[{type, summary, spanCount, bytes}]}]}`, newest first. Item payloads stay on the device — the summary line is the one-line human read (error headline, transaction name, log count). `status` is \"attached\" when capture is live; \"searching\" right after launch; \"sdk-not-found\"/\"no-client\" when the app has no Sentry client, in which case nothing can ever appear here. Capture starts when Buoy mounts, so envelopes sent before that are absent — \"nothing was captured\", never \"nothing was sent\"."
|
|
103
|
+
}, {
|
|
104
|
+
"action": "clearEnvelopes",
|
|
105
|
+
"summary": "Wipe the captured envelope list on the device. Does not affect anything already delivered to Sentry's servers.",
|
|
106
|
+
"params": {
|
|
107
|
+
"type": "object",
|
|
108
|
+
"properties": {},
|
|
109
|
+
"additionalProperties": false,
|
|
110
|
+
"description": "No parameters."
|
|
111
|
+
},
|
|
112
|
+
"effect": "destructive",
|
|
113
|
+
"release": "works",
|
|
114
|
+
"description": "Empties the on-device capture buffer and returns {cleared:true} even if it was already empty. There is no undo; read what you need first. Legitimate use is narrow: zeroing the list right before reproducing a bug so the next read contains only that repro's traffic."
|
|
115
|
+
}],
|
|
116
|
+
"unavailableWhen": "@buoy-gg/sentry is not installed in the app, or the app does not use @sentry/react-native at all. Separately, in a release build the sync transport is off unless the app passes externalSync={{enableInRelease:true}} with a real Pro license, in which case no action reaches the device at all."
|
|
117
|
+
}, {
|
|
118
|
+
"toolId": "jotai",
|
|
119
|
+
"title": "Jotai",
|
|
120
|
+
"summary": "Reads and writes the app's Jotai atoms: list registered atoms with change counts and writability, fetch one atom's current value, fetch the real prev/next values behind one recorded change, wipe the change timeline, and set a writable atom. Reach for it when on-screen data disagrees with the API or a value looks stale/wrong and the app uses Jotai. Only atoms the app explicitly passed to watchAtoms()/watchDefaultStoreAtoms() exist here — coverage is opt-in, so an empty list means nothing was registered, not that the app has no state. For the HISTORY of atom changes use get_events with sources:['jotai']; this tool's snapshot deliberately ships value-free markers and you fetch values on demand.",
|
|
121
|
+
"actions": [{
|
|
122
|
+
"action": "listAtoms",
|
|
123
|
+
"summary": "List every watched Jotai atom with label, change count, and whether it can be written.",
|
|
124
|
+
"params": {
|
|
125
|
+
"type": "object",
|
|
126
|
+
"properties": {
|
|
127
|
+
"includeValues": {
|
|
128
|
+
"type": "boolean",
|
|
129
|
+
"description": "Include each atom's full current value (HEAVY, no size cap). Default false."
|
|
130
|
+
},
|
|
131
|
+
"limit": {
|
|
132
|
+
"type": "number",
|
|
133
|
+
"description": "Return only the first N registered atoms. Applied only when > 0; otherwise all atoms are returned."
|
|
134
|
+
}
|
|
135
|
+
},
|
|
136
|
+
"additionalProperties": false
|
|
137
|
+
},
|
|
138
|
+
"effect": "read",
|
|
139
|
+
"release": "works",
|
|
140
|
+
"description": "Start here. Returns { atoms: [{ label, changes, writable, currentValue? }], total, returned, includedValues }. Compact by default — values are omitted, so it is token-cheap. `writable:true` means the atom has a write function and the store exposes set(), i.e. setAtom will work on it; derived/read-only atoms report writable:false. Labels are the object keys the app passed to watchAtoms(store, { countAtom, authAtom }), so they are the app's own variable names. includeValues:true inlines the full current value of every atom with NO size cap — prefer getAtomValue for a single big atom.",
|
|
141
|
+
"requires": ["@buoy-gg/jotai installed in the app", "watchAtoms(store, atoms) or watchDefaultStoreAtoms(atoms) called at app startup"]
|
|
142
|
+
}, {
|
|
143
|
+
"action": "getAtomValue",
|
|
144
|
+
"summary": "Fetch one atom's current value on demand, by label.",
|
|
145
|
+
"params": {
|
|
146
|
+
"type": "object",
|
|
147
|
+
"properties": {
|
|
148
|
+
"label": {
|
|
149
|
+
"type": "string",
|
|
150
|
+
"description": "Atom label exactly as registered — the object key in watchAtoms(store, { countAtom }), e.g. \"countAtom\". Get exact labels from listAtoms."
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
"required": ["label"],
|
|
154
|
+
"additionalProperties": false
|
|
155
|
+
},
|
|
156
|
+
"effect": "read",
|
|
157
|
+
"release": "works",
|
|
158
|
+
"description": "Returns { found:true, label, currentValue } or { found:false, reason:'missing label' | 'unknown label' }. Use this after listAtoms when the snapshot showed the placeholder {__buoyValueOnDevice:true} — the sync snapshot swaps in that marker for any atom value over 16KB or that fails JSON.stringify. Values over 8MB come back as {__buoyTruncated:true}; unserializable values (cycles, functions) come back as the on-device marker. Reading never throws: a getter that throws yields currentValue undefined.",
|
|
159
|
+
"requires": ["The label must already be registered via watchAtoms — call listAtoms first for exact labels"]
|
|
160
|
+
}, {
|
|
161
|
+
"action": "getChangeDetail",
|
|
162
|
+
"summary": "Fetch the real prevValue/nextValue behind one recorded atom change.",
|
|
163
|
+
"params": {
|
|
164
|
+
"type": "object",
|
|
165
|
+
"properties": {
|
|
166
|
+
"id": {
|
|
167
|
+
"type": "string",
|
|
168
|
+
"description": "Change id from a jotai snapshot row or get_events sources:['jotai'], shaped \"<epochMs>-<counter>\" e.g. \"1755102003123-42\". Omitting it does not throw — it returns {found:false, reason:'missing id'}."
|
|
169
|
+
}
|
|
170
|
+
},
|
|
171
|
+
"required": ["id"],
|
|
172
|
+
"additionalProperties": false
|
|
173
|
+
},
|
|
174
|
+
"effect": "read",
|
|
175
|
+
"release": "works",
|
|
176
|
+
"description": "Returns { found:true, id, prevValue, nextValue } or { found:false, reason:'missing id' | 'unknown id' }. The streamed change timeline carries {__buoyValueOnDevice:true} in place of both values (up to 200 changes x 2 values per snapshot would blow the wire budget), so this is the only way to see what a change actually contained. `id` comes from a jotai snapshot row or a get_events sources:['jotai'] row and has the shape \"<epochMs>-<counter>\" (e.g. \"1755102003123-42\"). Only the newest 200 changes are retained — older ids return found:false, and clearEvents drops them all. Values over 8MB are replaced with {__buoyTruncated:true}.",
|
|
177
|
+
"requires": ["A change id from the jotai snapshot or get_events sources:['jotai']"]
|
|
178
|
+
}, {
|
|
179
|
+
"action": "setAtom",
|
|
180
|
+
"summary": "Write a new value to a writable Jotai atom — this mutates the running app's state.",
|
|
181
|
+
"params": {
|
|
182
|
+
"type": "object",
|
|
183
|
+
"properties": {
|
|
184
|
+
"label": {
|
|
185
|
+
"type": "string",
|
|
186
|
+
"description": "Atom label from listAtoms; must be writable:true. The wire param is `label` (the MCP tool calls it `atom`)."
|
|
187
|
+
},
|
|
188
|
+
"value": {
|
|
189
|
+
"description": "The new value — any JSON (number, string, boolean, object, array, null). Passed straight to store.set(atom, value); it REPLACES the value, no merging."
|
|
190
|
+
}
|
|
191
|
+
},
|
|
192
|
+
"required": ["label", "value"],
|
|
193
|
+
"additionalProperties": false
|
|
194
|
+
},
|
|
195
|
+
"effect": "write",
|
|
196
|
+
"release": "works",
|
|
197
|
+
"description": "Calls store.set(atom, value) — the app re-renders exactly as if user code had written the atom. Replaces the whole value; there is no merge, so pass a complete object for object atoms. THROWS (no silent failure) with 'setAtom requires a `label`' when label is missing, 'Atom \"X\" is not registered' for an unknown label, and 'Atom \"X\" is read-only (derived) — cannot set' for a derived atom — check writable:true in listAtoms first. Returns no payload; confirm the write with getAtomValue. Note the param is `label` on the wire even though the MCP wrapper names it `atom`.",
|
|
198
|
+
"requires": ["listAtoms reports writable:true for this label (atom has a write fn AND the store exposes set())"]
|
|
199
|
+
}, {
|
|
200
|
+
"action": "clearEvents",
|
|
201
|
+
"summary": "Wipe the recorded atom-change timeline and reset every atom's change count to 0.",
|
|
202
|
+
"params": {
|
|
203
|
+
"type": "object",
|
|
204
|
+
"properties": {},
|
|
205
|
+
"additionalProperties": false
|
|
206
|
+
},
|
|
207
|
+
"effect": "destructive",
|
|
208
|
+
"release": "works",
|
|
209
|
+
"description": "Empties the 200-entry change ring buffer and sets changeCount to 0 on every registered atom, then notifies listeners. Irreversible — the discarded prev/next values are gone, so getChangeDetail on any prior id will return found:false and get_events sources:['jotai'] will show nothing until new writes land. Atom REGISTRATION and current values are untouched; only history is destroyed. Use it deliberately to get a clean baseline before reproducing a bug, not as housekeeping."
|
|
210
|
+
}],
|
|
211
|
+
"unavailableWhen": "The app never calls watchAtoms(store, atoms) or watchDefaultStoreAtoms(atoms) — the registry is then empty and every action succeeds but returns nothing (listAtoms → total 0, getAtomValue → found:false \"unknown label\"). Also inert against a store in remote-mirror mode (jotaiStateStore.disableCapture(), used by the desktop dashboard's own copy)."
|
|
212
|
+
}, {
|
|
213
|
+
"toolId": "route-events",
|
|
214
|
+
"title": "Routes",
|
|
215
|
+
"summary": "Reads and drives app navigation: the snapshot carries recorded route-change events, the expo-router sitemap (paths are TEMPLATES like /pokemon/[id]), and the live navigation stack (top-most last); the actions navigate the device to a path and manipulate that stack. Reach for it to answer \"what screen am I on / what routes exist\" and to move a QA user to a screen before exercising another tool. Unlike several Buoy tools, nothing here is __DEV__-gated — all six actions really run in a release build.",
|
|
216
|
+
"actions": [{
|
|
217
|
+
"action": "getSnapshot",
|
|
218
|
+
"summary": "Read where the app is: the current screen, the live navigation stack, every route the app declares, and recent navigations. Answers \"what screen am I on\" and \"what routes exist\".",
|
|
219
|
+
"description": "Returns `{ currentRoute, stack, routes, sitemapSource, recentNavigations }`. `routes` are expo-router TEMPLATES like /pokemon/[id] — resolve dynamic segments yourself before passing a path to `navigate`, which takes a concrete path only. `stack` and `recentNavigations` stay empty unless the app mounts <RouteTracker />; `routes` does not depend on it.",
|
|
220
|
+
"params": {
|
|
221
|
+
"type": "object",
|
|
222
|
+
"properties": {
|
|
223
|
+
"limit": {
|
|
224
|
+
"type": "number",
|
|
225
|
+
"description": "Most-recent N navigation events. Default 15."
|
|
226
|
+
}
|
|
227
|
+
},
|
|
228
|
+
"additionalProperties": false
|
|
229
|
+
},
|
|
230
|
+
"effect": "read",
|
|
231
|
+
"release": "works"
|
|
232
|
+
}, {
|
|
233
|
+
"action": "getCurrentRoute",
|
|
234
|
+
"summary": "Just the current route — {path, params, at, source} — without the sitemap or history. The cheap way to confirm a navigation landed.",
|
|
235
|
+
"params": {
|
|
236
|
+
"type": "object",
|
|
237
|
+
"properties": {},
|
|
238
|
+
"additionalProperties": false,
|
|
239
|
+
"description": "No parameters."
|
|
240
|
+
},
|
|
241
|
+
"effect": "read",
|
|
242
|
+
"release": "works",
|
|
243
|
+
"description": "Returns `{path, params, at, source}` where `source` is \"event\" (the newest navigation event won) or \"stack\" (a freshly launched app that has not navigated yet — the focused stack item answered). Returns `{path:null}` when the app has no <RouteTracker/> and no live navigation stack: a null path means \"unknown\", never \"/\". Prefer this over the full getSnapshot when all you need is where the app is right now."
|
|
244
|
+
}, {
|
|
245
|
+
"action": "navigate",
|
|
246
|
+
"summary": "Navigate the device to a concrete path (pushes by default; replace:true swaps the current screen).",
|
|
247
|
+
"params": {
|
|
248
|
+
"type": "object",
|
|
249
|
+
"properties": {
|
|
250
|
+
"path": {
|
|
251
|
+
"type": "string",
|
|
252
|
+
"description": "Concrete route path, e.g. '/settings' or '/pokemon/25'. Dynamic segments must already be resolved — '/pokemon/[id]' navigates to a literal '[id]' screen or nowhere."
|
|
253
|
+
},
|
|
254
|
+
"replace": {
|
|
255
|
+
"type": "boolean",
|
|
256
|
+
"description": "Replace the current screen instead of pushing it on top. Default false. Use true when resetting the app to a base route."
|
|
257
|
+
}
|
|
258
|
+
},
|
|
259
|
+
"required": ["path"],
|
|
260
|
+
"additionalProperties": false
|
|
261
|
+
},
|
|
262
|
+
"effect": "write",
|
|
263
|
+
"release": "works",
|
|
264
|
+
"description": "Calls expo-router's router.navigate(path), or router.replace(path) when replace:true. On bare React Navigation (no expo-router) it falls back to navigating by SCREEN NAME through the captured container ref — pass the screen's name ('Settings' or '/Settings'; replace is ignored there), and nested navigators are resolved automatically. The fallback needs <RouteTracker/> mounted inside the NavigationContainer. Pass a CONCRETE path — sitemap entries are templates ('/pokemon/[id]'), so resolve dynamic segments to real values ('/pokemon/25') first; a query string is allowed ('/pokemon/25?tab=stats'). Push is the default so navigate-then-back flows keep working; pass replace:true when you mean 'leave this screen' (e.g. resetting to '/'), otherwise repeated 'go to home' stacks another '/' on top. Throws 'navigate requires a path param' when path is missing/empty, and 'expo-router is not available on this device' on React-Navigation-only apps. Returns { navigated: path, replaced: boolean }. The return only proves the router call was made, not that the screen rendered — re-read the snapshot's stack to confirm.",
|
|
265
|
+
"requires": ["expo-router installed and initialized in the app", "<FloatingDevTools> mounted (adapter registered)"]
|
|
266
|
+
}, {
|
|
267
|
+
"action": "stackGoBack",
|
|
268
|
+
"summary": "Pop one screen off the navigation stack (the hardware/back-gesture equivalent).",
|
|
269
|
+
"params": {
|
|
270
|
+
"type": "object",
|
|
271
|
+
"properties": {},
|
|
272
|
+
"additionalProperties": false
|
|
273
|
+
},
|
|
274
|
+
"effect": "write",
|
|
275
|
+
"release": "works",
|
|
276
|
+
"description": "Delegates to the live navigation actions captured by <RouteTracker />: expo-router's router.back(), or containerRef.goBack() on React Navigation. IMPORTANT: it silently does nothing when the stack is already at its root (depth <= 1) yet still returns { wentBack: true } — never report 'went back' from the return value alone; re-read the snapshot's stack (or get_routes) and compare the focused pathname. Throws 'navigation stack is not available' when <RouteTracker /> is not mounted.",
|
|
277
|
+
"requires": ["<RouteTracker /> mounted inside the navigation tree"]
|
|
278
|
+
}, {
|
|
279
|
+
"action": "stackNavigateToIndex",
|
|
280
|
+
"summary": "Jump to the screen at a 0-based index of the current navigation stack — note this PUSHES on expo-router, it does not pop.",
|
|
281
|
+
"params": {
|
|
282
|
+
"type": "object",
|
|
283
|
+
"properties": {
|
|
284
|
+
"index": {
|
|
285
|
+
"type": "number",
|
|
286
|
+
"description": "0-based index into the synced `stack` array (top-most last; 0 = root). Must be a number — strings throw."
|
|
287
|
+
}
|
|
288
|
+
},
|
|
289
|
+
"required": ["index"],
|
|
290
|
+
"additionalProperties": false
|
|
291
|
+
},
|
|
292
|
+
"effect": "write",
|
|
293
|
+
"release": "works",
|
|
294
|
+
"description": "Reads stack[index] from the live stack and navigates to its pathname. On expo-router this is router.navigate(pathname), which PUSHES that path — the stack gets deeper, it does not rewind (use stackPopToIndex to actually rewind). On React Navigation it dispatches a nested CommonActions.navigate built by walking the root state. Index is 0-based into the same `stack` array the snapshot sends (top-most last), so index 0 is the root. Out-of-range indexes (index < 0 or >= stack.length) are silently ignored while the action still returns { navigatedToIndex: index } — verify by re-reading the stack. Throws 'stackNavigateToIndex requires a numeric index' for a missing/non-number index, and 'navigation stack is not available' without <RouteTracker />.",
|
|
295
|
+
"requires": ["<RouteTracker /> mounted inside the navigation tree"]
|
|
296
|
+
}, {
|
|
297
|
+
"action": "stackPopToIndex",
|
|
298
|
+
"summary": "Rewind the navigation stack down to a 0-based index, discarding every screen above it.",
|
|
299
|
+
"params": {
|
|
300
|
+
"type": "object",
|
|
301
|
+
"properties": {
|
|
302
|
+
"index": {
|
|
303
|
+
"type": "number",
|
|
304
|
+
"description": "0-based index into the synced `stack` array to rewind DOWN to (top-most last; 0 = root). Screens above it are popped and their state discarded."
|
|
305
|
+
}
|
|
306
|
+
},
|
|
307
|
+
"required": ["index"],
|
|
308
|
+
"additionalProperties": false
|
|
309
|
+
},
|
|
310
|
+
"effect": "destructive",
|
|
311
|
+
"release": "works",
|
|
312
|
+
"description": "Pops (stack.length - 1 - index) screens: expo-router calls router.back() that many times in a loop; React Navigation dispatches StackActions.pop(count). Index is 0-based into the synced `stack` array (top-most last). Everything above `index` is destroyed along with its in-memory screen state — unsaved form input on those screens is gone and cannot be restored. No-ops silently when index is already at or above the top (popCount <= 0) or out of range, while still returning { poppedToIndex: index }; confirm by re-reading the stack. Throws 'stackPopToIndex requires a numeric index' or 'navigation stack is not available'.",
|
|
313
|
+
"requires": ["<RouteTracker /> mounted inside the navigation tree"]
|
|
314
|
+
}, {
|
|
315
|
+
"action": "stackPopToTop",
|
|
316
|
+
"summary": "Reset the navigation stack to its root screen, discarding every screen above it.",
|
|
317
|
+
"params": {
|
|
318
|
+
"type": "object",
|
|
319
|
+
"properties": {},
|
|
320
|
+
"additionalProperties": false
|
|
321
|
+
},
|
|
322
|
+
"effect": "destructive",
|
|
323
|
+
"release": "works",
|
|
324
|
+
"description": "Delegates to the captured actions: on expo-router it is popToIndex(0) (a loop of router.back() calls); on React Navigation it dispatches StackActions.popToTop(). Discards every screen above the root along with its in-memory state — call this only when the user asked to reset, not to 'tidy up' mid-flow, because an in-progress form or checkout is lost. Silently does nothing when already at root (depth <= 1) yet still returns { poppedToTop: true }; verify with a fresh stack read. Throws 'navigation stack is not available' without <RouteTracker />.",
|
|
325
|
+
"requires": ["<RouteTracker /> mounted inside the navigation tree"]
|
|
326
|
+
}, {
|
|
327
|
+
"action": "clearEvents",
|
|
328
|
+
"summary": "Wipe the recorded route-change history buffer (max 500 events) on the device.",
|
|
329
|
+
"params": {
|
|
330
|
+
"type": "object",
|
|
331
|
+
"properties": {},
|
|
332
|
+
"additionalProperties": false
|
|
333
|
+
},
|
|
334
|
+
"effect": "destructive",
|
|
335
|
+
"release": "works",
|
|
336
|
+
"description": "Calls routeEventStore.clearEvents(), emptying the in-memory RouteChangeEvent ring buffer (pathname, params, segments, timestamp, previousPathname, timeSincePrevious) that feeds the snapshot's `events` array and get_events(sources:['route']). Irreversible — the history is memory-only and not persisted anywhere. Useful as a 'start clean' marker before driving a reproduction. Does NOT touch the navigation stack, the sitemap, or the current screen. Takes no params and returns undefined.",
|
|
337
|
+
"requires": ["<RouteTracker /> mounted inside the navigation tree (for events to exist at all)"],
|
|
338
|
+
"armsCapture": true
|
|
339
|
+
}],
|
|
340
|
+
"unavailableWhen": "The app has no `<RouteTracker />` mounted inside its navigation tree: the four `stack*` actions then throw \"navigation stack is not available\", and the synced `events`/`stack` arrays stay empty. `navigate` additionally needs expo-router — in a bare React Navigation (RN CLI) app `getSafeRouter()` returns null and it throws \"expo-router is not available on this device\", though the `stack*` actions still work there via the React Navigation container ref."
|
|
341
|
+
}, {
|
|
342
|
+
"toolId": "debug-borders",
|
|
343
|
+
"title": "Debug Borders",
|
|
344
|
+
"summary": "Remote control for the on-device layout debugger: draws colored outlines (and, on Pro, tappable labels) around every native view in the running app. It is a pure remote control — there is no dashboard mirror, so the ONLY readable state is the snapshot field `mode` (\"off\" | \"borders\" | \"labels\"). Reach for it when a QA/support user asks \"why is this misaligned / what component is this / what's the testID of that button\", not for reading data. Both actions are visual-only and in-memory: the mode resets to \"off\" on reload, and in a release bundle they flip the flag while nothing is ever drawn on screen.",
|
|
345
|
+
"actions": [{
|
|
346
|
+
"action": "cycleMode",
|
|
347
|
+
"summary": "Advance the border overlay one step: off -> borders -> labels -> off (Pro), or off -> borders -> off without a Pro license.",
|
|
348
|
+
"params": {
|
|
349
|
+
"type": "object",
|
|
350
|
+
"properties": {},
|
|
351
|
+
"additionalProperties": false
|
|
352
|
+
},
|
|
353
|
+
"effect": "write",
|
|
354
|
+
"release": "noop",
|
|
355
|
+
"description": "Calls DebugBordersManager.cycle(). The cycle list is license-dependent: Pro gets [off, borders, labels], free/anonymous gets [off, borders] only — so on a free device this is a plain on/off toggle and will NEVER reach labels no matter how many times it is called. If the current mode isn't in the available list (e.g. the device was in labels and the license lapsed) it resets to \"off\" instead of advancing. Returns undefined, so the wire result is ok:true with no data — read the new mode from the tool snapshot, never assume it. Prefer setMode when you know the mode you want; use cycleMode only for a literal \"toggle it\" request. Once enabled, the overlay draws its first pass ~500ms later and re-measures every 2s, and it hides itself entirely while any Buoy modal or the dial is open, so a screenshot taken with the Buoy UI open shows no borders.",
|
|
356
|
+
"releaseNote": "packages/debug-borders/src/debug-borders/utils/fiberTreeTraversal.js:30 — the overlay enumerates views through global.__REACT_DEVTOOLS_GLOBAL_HOOK__, which React Native installs only when __DEV__ is true. In a release bundle getFiberRoots() returns [], the overlay bails on `instances.length === 0`, and zero borders are drawn even though the mode changed and the snapshot reports \"borders\". Do not tell the user borders are on screen in a release build.",
|
|
357
|
+
"requires": ["@buoy-gg/debug-borders installed in the app", "<FloatingDevTools> mounted non-headless (it auto-renders DebugBordersStandaloneOverlay), or DebugBordersStandaloneOverlay rendered manually at the app root", "a Pro license (@buoy-gg/license isPro()) for the cycle to include \"labels\""]
|
|
358
|
+
}, {
|
|
359
|
+
"action": "setMode",
|
|
360
|
+
"summary": "Set the border overlay directly to \"off\", \"borders\", or \"labels\". \"labels\" is Pro-only and silently does nothing on a free device.",
|
|
361
|
+
"params": {
|
|
362
|
+
"type": "object",
|
|
363
|
+
"properties": {
|
|
364
|
+
"mode": {
|
|
365
|
+
"type": "string",
|
|
366
|
+
"enum": ["off", "borders", "labels"],
|
|
367
|
+
"description": "off = clear the overlay; borders = outline every native view, colored by depth; labels = Pro-only, outline + tappable chip for views that have a testID or accessibilityLabel. Required; not validated by the adapter, so any other string is stored as-is and leaves the overlay in a broken state."
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
"required": ["mode"],
|
|
371
|
+
"additionalProperties": false
|
|
372
|
+
},
|
|
373
|
+
"effect": "write",
|
|
374
|
+
"release": "noop",
|
|
375
|
+
"description": "Calls DebugBordersManager.setMode(params.mode). `mode` is REQUIRED — the handler hand-casts `(params as {mode}).mode` with no optional chaining and no validation, so omitting params entirely throws (ok:false, \"Cannot read property 'mode' of undefined\"), while an object with a missing or misspelled mode is written verbatim into global state: the snapshot then reports that junk value and, because the overlay only checks `mode !== \"off\"`, borders keep drawing in an unnamed mode. Always pass one of the three literals. \"borders\" outlines every native view, colored by tree depth. \"labels\" (Pro) outlines only views that have a testID or accessibilityLabel and puts a tappable colored chip above each one; tapping a chip opens a device-side sheet with testID / nativeID / component name / x,y,w,h / accessibility props / styles. Those chips sit at zIndex 9000 and DO swallow taps aimed at the app underneath, so set the mode back to \"off\" before driving the UI with taps. Pro gate: setMode(\"labels\") without a license logs \"[DebugBorders] Labels mode requires React Buoy Pro\" and returns false, but the adapter discards that boolean — the action still resolves ok:true with the mode unchanged. Confirm the result in the snapshot before reporting success. Mode is a module-level variable, not persisted: a reload or app restart returns it to \"off\".",
|
|
376
|
+
"releaseNote": "Same path as cycleMode: packages/debug-borders/src/debug-borders/utils/fiberTreeTraversal.js:30 depends on global.__REACT_DEVTOOLS_GLOBAL_HOOK__, which exists only under __DEV__, so no rectangles are ever measured in a release bundle. Additionally, in a release build FloatingDevTools returns null unless a real Pro license is present (FloatingDevTools.tsx:708) and the headless branch (FloatingDevTools.tsx:752+) never mounts the overlay at all — three independent reasons nothing appears, while the action still reports ok:true.",
|
|
377
|
+
"requires": ["@buoy-gg/debug-borders installed in the app", "<FloatingDevTools> mounted non-headless (it auto-renders DebugBordersStandaloneOverlay), or DebugBordersStandaloneOverlay rendered manually at the app root", "a Pro license (@buoy-gg/license isPro()) for mode:\"labels\" to take effect"]
|
|
378
|
+
}],
|
|
379
|
+
"unavailableWhen": "The app doesn't have `@buoy-gg/debug-borders` installed (autoExternalSync's optional require fails at packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:125, so \"debug-borders\" never appears in the device's tool list). It is also present-but-inert when the app renders `<FloatingDevTools headless />`: the headless branch returns before the overlay at FloatingDevTools.tsx:785, so the actions succeed and change `mode` with no overlay mounted to draw anything."
|
|
380
|
+
}, {
|
|
381
|
+
"toolId": "zustand",
|
|
382
|
+
"title": "Zustand",
|
|
383
|
+
"summary": "Reads and writes the app's live Zustand stores: list registered stores with their top-level keys or full state, fetch one store's current state, fetch the real before/after trees for one recorded state change, and setState a store for time-travel/reset. Reach for it when on-screen data disagrees with the API, or when a QA user needs the app put into a specific state. The change TIMELINE (history over time) is better read via get_events sources:['zustand']; this tool is for CURRENT state plus on-demand detail. Everything here works in release builds — but setState defaults to replace:true, which wipes the store's action functions.",
|
|
384
|
+
"actions": [{
|
|
385
|
+
"action": "listStores",
|
|
386
|
+
"summary": "List every registered Zustand store with its name, change count, persistence, and (by default) just its top-level keys.",
|
|
387
|
+
"params": {
|
|
388
|
+
"type": "object",
|
|
389
|
+
"properties": {
|
|
390
|
+
"includeValues": {
|
|
391
|
+
"type": "boolean",
|
|
392
|
+
"description": "Include each store's full currentState object (HEAVY — whole state trees). Default false, which returns only top-level `keys` so the shape is visible cheaply."
|
|
393
|
+
},
|
|
394
|
+
"limit": {
|
|
395
|
+
"type": "number",
|
|
396
|
+
"description": "Return only the first N stores, in registration order. Ignored unless greater than 0; default is all stores."
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
"additionalProperties": false
|
|
400
|
+
},
|
|
401
|
+
"effect": "read",
|
|
402
|
+
"release": "works",
|
|
403
|
+
"description": "The entry point — call this first to learn valid storeName values for getStoreState/setState. Returns {stores:[{name, changes, isPersisted, persistName?, keys?|currentState?}], total, returned, includedValues}. Compact by default: `keys` is Object.keys() of the state (undefined when the state isn't a plain object). includeValues:true swaps `keys` for the full `currentState` object and can be very large — the state objects here are NOT wire-budget-capped the way the streaming snapshot is. `changes` is that store's recorded state-change count, reset to 0 by clearEvents. Names come from the app: the object keys passed to watchStores({counterStore: useCounterStore}) or the `name` option of buoyDevTools(). An empty stores array means the app never instrumented its stores, not that it has none.",
|
|
404
|
+
"requires": ["@buoy-gg/zustand installed and the zustand tool registered with FloatingDevTools", "the app calls watchStores({...}) or wraps stores with buoyDevTools() — otherwise the registry is empty"]
|
|
405
|
+
}, {
|
|
406
|
+
"action": "getStoreState",
|
|
407
|
+
"summary": "Fetch one store's full current state object on demand, by store name.",
|
|
408
|
+
"params": {
|
|
409
|
+
"type": "object",
|
|
410
|
+
"properties": {
|
|
411
|
+
"storeName": {
|
|
412
|
+
"type": "string",
|
|
413
|
+
"description": "Registered store name exactly as listStores reports it (e.g. \"counterStore\", \"authStore\", \"cartStore\"). Case-sensitive; no fuzzy matching."
|
|
414
|
+
}
|
|
415
|
+
},
|
|
416
|
+
"required": ["storeName"],
|
|
417
|
+
"additionalProperties": false
|
|
418
|
+
},
|
|
419
|
+
"effect": "read",
|
|
420
|
+
"release": "works",
|
|
421
|
+
"description": "Use when listStores' compact `keys` view isn't enough, or when the streaming snapshot showed the {__buoyStateOnDevice:true} marker (states over 16KB are withheld from the per-snapshot wire). Returns {found:true, storeName, currentState} — or {found:false, reason:\"missing storeName\"} / {found:false, reason:\"unknown storeName\"}, which is a plain answer, not an error. currentState is read live via store.api.getState(); if that throws it comes back undefined. Over the 8MB detail cap it returns {__buoyTruncated:true, note} instead, or the STATE_ON_DEVICE marker when the state isn't JSON-serializable. Note that action functions living in state (increment, reset, …) do not survive the JSON wire — what you read back is the data half of the store only.",
|
|
422
|
+
"requires": ["the store must already be registered — get the exact name from listStores"]
|
|
423
|
+
}, {
|
|
424
|
+
"action": "getChangeDetail",
|
|
425
|
+
"summary": "Fetch the real prevState / nextState / partial for one recorded state change, by change id.",
|
|
426
|
+
"params": {
|
|
427
|
+
"type": "object",
|
|
428
|
+
"properties": {
|
|
429
|
+
"id": {
|
|
430
|
+
"type": "string",
|
|
431
|
+
"description": "Change id from a zustand change row, format `<epochMs>-<counter>` (e.g. \"1761580000123-42\"). Only the most recent 200 changes are retained."
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
"required": ["id"],
|
|
435
|
+
"additionalProperties": false
|
|
436
|
+
},
|
|
437
|
+
"effect": "read",
|
|
438
|
+
"release": "works",
|
|
439
|
+
"description": "The streaming change log deliberately carries no state trees — every change's prevState/nextState arrive as the {__buoyStateOnDevice:true} marker, and an oversized `partial` as {__buoyPayloadOnDevice:true}. This is the explicit channel that fetches the real trees for one change so you can diff before/after. `id` comes from a change row (format `<epochMs>-<counter>`, e.g. \"1761580000123-42\"). Returns {found:true, id, prevState, nextState, partial} or {found:false, reason:\"missing id\"|\"unknown id\"}. Each value over 8MB is replaced by {__buoyTruncated:true, note}. Only the newest 200 changes are retained (ring buffer), and clearEvents empties it — an id that scrolled off returns \"unknown id\". `partial` is undefined for stores instrumented with watchStores (subscribe-only mode can't see the setState argument); only the buoyDevTools middleware records partial and duration.",
|
|
440
|
+
"requires": ["a change id from the zustand change log (the tool snapshot, or get_events sources:['zustand'])"]
|
|
441
|
+
}, {
|
|
442
|
+
"action": "setState",
|
|
443
|
+
"summary": "Write new state into a live Zustand store. Buoy defaults this to a partial MERGE (replace:false) so a write cannot silently wipe the store's other fields and action functions.",
|
|
444
|
+
"params": {
|
|
445
|
+
"type": "object",
|
|
446
|
+
"properties": {
|
|
447
|
+
"storeName": {
|
|
448
|
+
"type": "string",
|
|
449
|
+
"description": "Registered store name exactly as listStores reports it (e.g. \"counterStore\"). Unknown names are rejected with ok:false."
|
|
450
|
+
},
|
|
451
|
+
"state": {
|
|
452
|
+
"type": "object",
|
|
453
|
+
"description": "The state to write. With replace true (the default) this becomes the store's ENTIRE state and every absent key — including action functions — is removed. With replace false it is merged as a partial, so pass only the fields you intend to change (e.g. {\"count\": 5}).",
|
|
454
|
+
"additionalProperties": true
|
|
455
|
+
},
|
|
456
|
+
"replace": {
|
|
457
|
+
"type": "boolean",
|
|
458
|
+
"description": "true replaces the whole state — including the store's action functions, which breaks every button wired to them until the app reloads. Buoy sends false for you unless you explicitly pass true, so a partial merge is the safe path: pass only the fields you intend to change (e.g. {\"count\": 5}). The ADAPTER's own default is true; this is a deliberately safer default for agent calls.",
|
|
459
|
+
"default": false
|
|
460
|
+
}
|
|
461
|
+
},
|
|
462
|
+
"required": ["storeName", "state"],
|
|
463
|
+
"additionalProperties": false
|
|
464
|
+
},
|
|
465
|
+
"effect": "destructive",
|
|
466
|
+
"release": "works",
|
|
467
|
+
"description": "Powers time-travel / reset / 'put the app in this state' from a dashboard. Calls the store's real setState(state, replace ?? true). THE DEFAULT IS DESTRUCTIVE: `replace` defaults to TRUE, so any key missing from `state` is deleted — and Zustand stores conventionally keep their action functions in state (increment, login, addToCart). Functions cannot cross the sync wire, so a JSON `state` object can never carry them back; a default-replace leaves every `useStore(s => s.increment)` call site reading undefined and the app broken until reload. Buoy's own Time Machine restore avoids this by re-grafting the live store's functions onto the snapshot before replacing (packages/zustand/src/zustand/utils/snapshotProvider.ts:8-12) — this raw action does NOT do that. For a QA-facing tweak always pass replace:false to merge just the fields you're changing. Returns {ok:true}, or {ok:false, error:\"Missing storeName.\"} / {ok:false, error:'No store named \"X\".'}. The write also lands in the change log as a normal recorded change.",
|
|
468
|
+
"requires": ["the store must be registered — get the exact name from listStores", "the store's registered setState handle: watchStores registers the raw setState, buoyDevTools registers its instrumented set (both apply the write)"]
|
|
469
|
+
}, {
|
|
470
|
+
"action": "rehydrate",
|
|
471
|
+
"summary": "Re-read a persisted store's saved value from storage and merge it into the live store. Use it after anything wrote that storage key directly.",
|
|
472
|
+
"description": "Returns {ok, storeName, persistName}. THIS IS HOW A STORAGE EDIT TAKES EFFECT. A persisted store reads its key once at startup and then holds the state in memory, so writing the key with storage.async.setItem / mmkv.set changes the disk and nothing else — the screen does not move, and the next time the store saves it writes its own copy back over the edit. Rehydrating closes that loop. It merges the saved value OVER current state, so the store's action functions survive, and it does not rewrite storage unless a version migration ran. Two limits, both inherent to how persist works: a field the store's `partialize` excludes is not in the saved value and cannot be applied, and because the merge is shallow, DELETING a key from the saved JSON does not remove it from the live store. Prefer setState for an ordinary change; this is for when the storage key is what changed.",
|
|
473
|
+
"params": {
|
|
474
|
+
"type": "object",
|
|
475
|
+
"properties": {
|
|
476
|
+
"storeName": {
|
|
477
|
+
"type": "string",
|
|
478
|
+
"description": "Registered store name exactly as listStores reports it. It must be a persisted store (listStores shows isPersisted and persistName)."
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
"required": ["storeName"],
|
|
482
|
+
"additionalProperties": false
|
|
483
|
+
},
|
|
484
|
+
"effect": "write",
|
|
485
|
+
"release": "works",
|
|
486
|
+
"requires": ["the store uses zustand's persist middleware and was registered with watchStores/the Buoy middleware"]
|
|
487
|
+
}, {
|
|
488
|
+
"action": "clearEvents",
|
|
489
|
+
"summary": "Wipe the recorded Zustand state-change timeline and reset every store's change count to 0.",
|
|
490
|
+
"params": {
|
|
491
|
+
"type": "object",
|
|
492
|
+
"properties": {},
|
|
493
|
+
"additionalProperties": false
|
|
494
|
+
},
|
|
495
|
+
"effect": "destructive",
|
|
496
|
+
"release": "works",
|
|
497
|
+
"description": "Empties the in-memory change log for ALL stores (not one store) and sets each registered store's stateChangeCount to 0, then notifies listeners. Irreversible — the discarded changes are not persisted anywhere, and any change id you were holding becomes \"unknown id\" for getChangeDetail. Does NOT touch app state: the stores keep their current values, only the history is destroyed. Useful to get a clean baseline before reproducing a bug. Returns nothing (undefined) on success. Capture continues afterwards without needing a re-subscribe."
|
|
498
|
+
}],
|
|
499
|
+
"unavailableWhen": "The app doesn't depend on @buoy-gg/zustand or the zustand tool isn't registered with FloatingDevTools — the adapter is never mapped in (packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:335) and the tool id \"zustand\" is absent from the device's action inventory. Installed but never instrumented (no watchStores() / buoyDevTools() call) is different: every action still answers, but listStores returns zero stores and the change log is empty. On a desktop mirror the store runs with capture suppressed (zustandStateStore.disableCapture()), so it only reflects what the device sent."
|
|
500
|
+
}, {
|
|
501
|
+
"toolId": "redux",
|
|
502
|
+
"title": "Redux",
|
|
503
|
+
"summary": "Reads and drives the app's LIVE Redux store, plus the captured action log. Use getState for what is in the store right now (Redux is action-based, so current state is NOT in the action-log snapshot — that history comes from get_events sources:['redux']), dispatch to push a plain action into the running app, getActionDetail to pull one action's real prevState/nextState trees (snapshots ship markers, not trees), and clearEvents to wipe the recorded log. All four need a Redux store bound to Buoy; when none is, getState/dispatch answer available:false with a specific reason instead of failing generically.",
|
|
504
|
+
"actions": [{
|
|
505
|
+
"action": "getState",
|
|
506
|
+
"summary": "Read the app's CURRENT Redux state — top-level slice names by default, full state tree with includeValues:true.",
|
|
507
|
+
"params": {
|
|
508
|
+
"type": "object",
|
|
509
|
+
"properties": {
|
|
510
|
+
"includeValues": {
|
|
511
|
+
"type": "boolean",
|
|
512
|
+
"description": "Include the full current state tree (HEAVY — the whole store is serialized over the wire). Default false: slice names only."
|
|
513
|
+
}
|
|
514
|
+
},
|
|
515
|
+
"additionalProperties": false
|
|
516
|
+
},
|
|
517
|
+
"effect": "read",
|
|
518
|
+
"release": "works",
|
|
519
|
+
"description": "Compact by DEFAULT and token-cheap: returns {available:true, slices:string[], capture:'full'|'top-level-only', mechanism:'enhancer'|'middleware'|'patch'}. Pass includeValues:true to add `state` — the entire store tree, which can be megabytes on a real app. Use for 'what's in my redux store / current auth state'. When no store is bound it returns {available:false, slices:[], reason:'no-react-redux'|'no-provider'|'not-instrumented'} — report that specific reason: no-react-redux means the app lacks react-redux, no-provider means <FloatingDevTools /> is not inside <Provider store={store}>, not-instrumented usually means an older @buoy-gg/core or the app should call registerReduxStore(store). If `capture` is 'top-level-only', warn the user that thunk-internal and RTK Query actions are NOT in the action log (the fix is `import '@buoy-gg/redux';` first in the app entry, or adding buoyReduxMiddleware) — this does not affect the state values you just read, which are always live and correct.",
|
|
520
|
+
"requires": ["react-redux installed in the app", "a Redux <Provider> above <FloatingDevTools /> — or an explicit registerReduxStore(store) from @buoy-gg/redux"]
|
|
521
|
+
}, {
|
|
522
|
+
"action": "getActionDetail",
|
|
523
|
+
"summary": "Fetch one recorded action's real prevState/nextState trees, payload, meta and error by action id.",
|
|
524
|
+
"params": {
|
|
525
|
+
"type": "object",
|
|
526
|
+
"properties": {
|
|
527
|
+
"id": {
|
|
528
|
+
"type": "string",
|
|
529
|
+
"description": "Action id from the redux action log, formatted \"<epochMillis>-<counter>\" (e.g. \"1724612345678-42\"). Omitting it returns {found:false, reason:\"missing id\"}."
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
"required": ["id"],
|
|
533
|
+
"additionalProperties": false
|
|
534
|
+
},
|
|
535
|
+
"effect": "read",
|
|
536
|
+
"release": "works",
|
|
537
|
+
"description": "The per-snapshot action stream deliberately carries markers ({__buoyStateOnDevice:true}, {__buoyPayloadOnDevice:true}) instead of state trees — shipping them froze and OOM-killed large apps — so this is the ONLY way to see an action's before/after state. `id` comes from a redux action row and is formatted \"<epochMillis>-<counter>\" (e.g. \"1724612345678-42\"). Returns {found:true, id, prevState, nextState, payload, action, meta, error}; any single field over 8MB is replaced by {__buoyTruncated:true, note} rather than failing the whole call. Two negative shapes to relay verbatim: {found:false, reason:'unknown id'|'missing id'} (id not in the current log, or omitted), and {found:true, evicted:true, reason:...} — raw trees are retained for the 25 MOST RECENT actions only, so an older action keeps its diff metadata but its trees are gone forever. Do not retry an evicted action; reproduce the behavior again and read the fresh entry.",
|
|
538
|
+
"requires": ["an instrumented store that has already recorded the action (log holds the 200 most recent actions; raw state trees only the 25 most recent)"]
|
|
539
|
+
}, {
|
|
540
|
+
"action": "dispatch",
|
|
541
|
+
"summary": "Dispatch a plain action object into the app's live Redux store — really changes app state.",
|
|
542
|
+
"params": {
|
|
543
|
+
"type": "object",
|
|
544
|
+
"properties": {
|
|
545
|
+
"action": {
|
|
546
|
+
"type": "object",
|
|
547
|
+
"description": "The plain Redux action object, dispatched as-is. Extra keys (payload, meta, error) are passed straight through.",
|
|
548
|
+
"properties": {
|
|
549
|
+
"type": {
|
|
550
|
+
"type": "string",
|
|
551
|
+
"description": "Action type, e.g. \"counter/increment\" or \"auth/logout\"."
|
|
552
|
+
},
|
|
553
|
+
"payload": {
|
|
554
|
+
"description": "Optional action payload (any JSON)."
|
|
555
|
+
}
|
|
556
|
+
},
|
|
557
|
+
"required": ["type"]
|
|
558
|
+
}
|
|
559
|
+
},
|
|
560
|
+
"required": ["action"],
|
|
561
|
+
"additionalProperties": false
|
|
562
|
+
},
|
|
563
|
+
"effect": "destructive",
|
|
564
|
+
"release": "works",
|
|
565
|
+
"description": "Sends the given plain action straight to store.dispatch, e.g. {\"action\":{\"type\":\"counter/increment\",\"payload\":1}} or {\"action\":{\"type\":\"auth/logout\"}}. (The MCP redux_dispatch tool takes flat {type, payload} and wraps it into this shape for you.) `action` is REQUIRED and must be a plain object with a string `type` — Redux itself throws on a missing/undefined type, and thunk functions cannot be sent over the wire. Returns {dispatched:true, type} on success, or {dispatched:false, available:false, reason:'no-react-redux'|'no-provider'|'not-instrumented'} when no store is bound — never claim a dispatch landed unless dispatched is true. Treat as destructive: this mutates the real app the user is looking at, and a type like auth/logout, cart/clear or a rehydrate action is irreversible from here — the adapter exposes no time travel (jumpToState is NOT a sync action). Confirm the exact action type with the user before dispatching anything that resets, clears, or logs out.",
|
|
566
|
+
"requires": ["react-redux installed in the app", "a Redux <Provider> above <FloatingDevTools /> — or an explicit registerReduxStore(store)", "the app must actually handle the action type; an unknown type dispatches successfully and changes nothing"]
|
|
567
|
+
}, {
|
|
568
|
+
"action": "clearEvents",
|
|
569
|
+
"summary": "Wipe the recorded Redux action log on the device (state trees and all). Irreversible.",
|
|
570
|
+
"params": {
|
|
571
|
+
"type": "object",
|
|
572
|
+
"properties": {},
|
|
573
|
+
"additionalProperties": false
|
|
574
|
+
},
|
|
575
|
+
"effect": "destructive",
|
|
576
|
+
"release": "works",
|
|
577
|
+
"description": "Empties reduxActionStore — every captured action, its payload and its retained prevState/nextState trees are gone, and any pending getActionDetail id becomes 'unknown id'. Does NOT touch the app's actual Redux state: the store keeps whatever it currently holds, only the recording is cleared. Returns nothing (undefined) — success is the absence of an error. Useful to get a clean baseline right before reproducing a bug; never call it before you have read anything the user might still need, since there is no export or restore."
|
|
578
|
+
}],
|
|
579
|
+
"unavailableWhen": "No Redux store is bound to Buoy — react-redux not installed (\"no-react-redux\"), no <Provider> above <FloatingDevTools /> (\"no-provider\"), or nothing instrumented the store yet (\"not-instrumented\"). getState/dispatch then return available:false with that reason and dispatched:false; the log actions still respond but the log stays empty. Separately, in a release build (__DEV__ === false) the whole sync channel only exists if the app opted in with externalSync.enableInRelease AND holds a real Pro license (packages/devtools-floating-menu/src/floatingMenu/externalSyncGate.ts:49-60) — otherwise no action on this tool is reachable at all."
|
|
580
|
+
}, {
|
|
581
|
+
"toolId": "impersonate",
|
|
582
|
+
"title": "Impersonate",
|
|
583
|
+
"summary": "Become another user inside the running app without logging out: it injects an impersonation header (default `x-impersonate-user-id: <user.id>`) into every outgoing globalThis.fetch and XMLHttpRequest, so the backend returns that user's data. Reach for it to reproduce a specific customer's bug (\"show me what account 8812 sees\"), then stop/pause to return to the real login. Actions only mutate state — they all resolve to void, so read the tool snapshot (isActive / isPaused / currentUser / history) to confirm anything took effect. Note that switching or stopping also CLEARS app caches per dataNukeSettings (react-query + redux on by default), so it is not a passive read-only view.",
|
|
584
|
+
"actions": [{
|
|
585
|
+
"action": "searchUsers",
|
|
586
|
+
"summary": "Search the app's own user directory and get back User objects you can feed to startImpersonation.",
|
|
587
|
+
"params": {
|
|
588
|
+
"type": "object",
|
|
589
|
+
"properties": {
|
|
590
|
+
"query": {
|
|
591
|
+
"type": "string",
|
|
592
|
+
"description": "Search text handed verbatim to the app's onSearchUsers — usually an email, name, or user id. Coerced via String(); omitting it sends an empty string, which most apps treat as 'list everything'."
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
"required": [],
|
|
596
|
+
"additionalProperties": false
|
|
597
|
+
},
|
|
598
|
+
"effect": "read",
|
|
599
|
+
"release": "works",
|
|
600
|
+
"description": "Proxies straight to the host app's onSearchUsers(query) callback — a real request to the company's own admin/user API, so results and latency are entirely the app's. Returns an array of User objects: { id, displayName?, email?, avatarUrl?, metadata? }. Wire-shrinking is applied before it reaches you: any avatarUrl that is a data: URI or longer than 2048 chars is replaced by a stub string like \"data:image/png;base64,[3145728 chars]\", and a metadata object over 16KB is replaced by { role, __buoyOmitted: \"user-metadata\" } — the device keeps the real values. ALWAYS call this before startImpersonation instead of hand-constructing a user; the app's real user id is what the backend checks. Throws \"No onSearchUsers configured\" if the app never passed onSearchUsers to createImpersonateTool().",
|
|
601
|
+
"requires": ["createImpersonateTool({ onSearchUsers }) called by the host app"]
|
|
602
|
+
}, {
|
|
603
|
+
"action": "startImpersonation",
|
|
604
|
+
"summary": "Begin impersonating a user — every subsequent fetch/XHR carries the impersonation header, and app caches are wiped per dataNukeSettings.",
|
|
605
|
+
"params": {
|
|
606
|
+
"type": "object",
|
|
607
|
+
"properties": {
|
|
608
|
+
"user": {
|
|
609
|
+
"type": "object",
|
|
610
|
+
"description": "The full User object to impersonate. Pass one returned by searchUsers; a bare object with only an id also works. Required — a missing user throws a TypeError on the device.",
|
|
611
|
+
"properties": {
|
|
612
|
+
"id": {
|
|
613
|
+
"type": "string",
|
|
614
|
+
"description": "Required. This exact string becomes the impersonation header value (default header x-impersonate-user-id)."
|
|
615
|
+
},
|
|
616
|
+
"displayName": {
|
|
617
|
+
"type": "string",
|
|
618
|
+
"description": "Shown on the user card and banner; falls back to email, then id."
|
|
619
|
+
},
|
|
620
|
+
"email": {
|
|
621
|
+
"type": "string"
|
|
622
|
+
},
|
|
623
|
+
"avatarUrl": {
|
|
624
|
+
"type": "string"
|
|
625
|
+
},
|
|
626
|
+
"metadata": {
|
|
627
|
+
"type": "object",
|
|
628
|
+
"description": "Free-form key/value shown on the user card; a string metadata.role is rendered as a badge."
|
|
629
|
+
}
|
|
630
|
+
},
|
|
631
|
+
"required": ["id"]
|
|
632
|
+
}
|
|
633
|
+
},
|
|
634
|
+
"required": ["user"],
|
|
635
|
+
"additionalProperties": false
|
|
636
|
+
},
|
|
637
|
+
"effect": "destructive",
|
|
638
|
+
"release": "works",
|
|
639
|
+
"description": "Sets isActive=true and currentUser=user, points the fetch/XHR interceptor at user.id, prepends the user to history (deduped by id, capped at 10, persisted to @buoy/impersonate/state), THEN runs the data nuke and persists. The nuke clears react-query and resets redux by default (dataNukeSettings.reactQuery/redux default true) and can also wipe AsyncStorage and MMKV when those settings were turned on — that part is irreversible. Calling this while already impersonating switches users (that is the 'quick switch' path). Two ways it can look successful but change nothing on screen: (1) the nuke callbacks are only registered once the Impersonate panel has been opened at least once in this app session, so caches may keep the previous user's data and the UI won't refresh — tell the user to open the Impersonate tool once, or reload the app; (2) the header is injected only into globalThis.fetch and XMLHttpRequest.prototype, so a client that bypasses both (e.g. Expo's native expo/fetch) sends no header. Metro/dev URLs (localhost:8081, /symbolicate, /logs, .hot-update., __metro) are always excluded. Resolves to void — read the snapshot's isActive/currentUser to confirm."
|
|
640
|
+
}, {
|
|
641
|
+
"action": "stopImpersonation",
|
|
642
|
+
"summary": "End impersonation and go back to the real logged-in account; also runs the data nuke.",
|
|
643
|
+
"params": {
|
|
644
|
+
"type": "object",
|
|
645
|
+
"properties": {},
|
|
646
|
+
"additionalProperties": false
|
|
647
|
+
},
|
|
648
|
+
"effect": "destructive",
|
|
649
|
+
"release": "works",
|
|
650
|
+
"description": "Clears isActive, isPaused and currentUser, stops header injection, then runs the same data nuke as startImpersonation (react-query + redux by default; AsyncStorage/MMKV if enabled) and persists. Use this to return the device to its real identity — it is the correct 'undo' after any impersonation session. History is untouched. Safe to call when not impersonating (state is already clear), but note the nuke still fires. Resolves to void."
|
|
651
|
+
}, {
|
|
652
|
+
"action": "pauseImpersonation",
|
|
653
|
+
"summary": "Temporarily stop injecting the header while keeping the session and current user.",
|
|
654
|
+
"params": {
|
|
655
|
+
"type": "object",
|
|
656
|
+
"properties": {},
|
|
657
|
+
"additionalProperties": false
|
|
658
|
+
},
|
|
659
|
+
"effect": "write",
|
|
660
|
+
"release": "works",
|
|
661
|
+
"description": "Sets isPaused=true and passes a null userId to the interceptor, so requests go out as the real account again while currentUser is remembered. No cache nuke runs, which is exactly why it is the safer A/B toggle: pause, check the screen as yourself, resume. IMPORTANT — it silently returns and does nothing if isActive is false or isPaused is already true, and it still resolves successfully, so verify isPaused in the snapshot rather than assuming. Because no cache is cleared, already-fetched data on screen will not change until something refetches."
|
|
662
|
+
}, {
|
|
663
|
+
"action": "resumeImpersonation",
|
|
664
|
+
"summary": "Resume header injection for the already-selected user after a pause.",
|
|
665
|
+
"params": {
|
|
666
|
+
"type": "object",
|
|
667
|
+
"properties": {},
|
|
668
|
+
"additionalProperties": false
|
|
669
|
+
},
|
|
670
|
+
"effect": "destructive",
|
|
671
|
+
"release": "works",
|
|
672
|
+
"description": "Sets isPaused=false and re-points the interceptor at currentUser.id. No cache nuke runs. Silently does nothing (while still reporting success) when isActive is false or isPaused is already false — check isPaused in the snapshot to confirm. Stale on-screen data from the paused window persists until a refetch."
|
|
673
|
+
}, {
|
|
674
|
+
"action": "updateSettings",
|
|
675
|
+
"summary": "Change the header key, URL ignore patterns, banner visibility, or which caches get wiped on every user switch.",
|
|
676
|
+
"params": {
|
|
677
|
+
"type": "object",
|
|
678
|
+
"properties": {
|
|
679
|
+
"settings": {
|
|
680
|
+
"type": "object",
|
|
681
|
+
"description": "Required wrapper. Partial patch — omitted keys keep their current value.",
|
|
682
|
+
"properties": {
|
|
683
|
+
"headerKey": {
|
|
684
|
+
"type": "string",
|
|
685
|
+
"description": "HTTP header name injected on every request. Default 'x-impersonate-user-id'."
|
|
686
|
+
},
|
|
687
|
+
"ignorePatterns": {
|
|
688
|
+
"type": "array",
|
|
689
|
+
"items": {
|
|
690
|
+
"type": "string"
|
|
691
|
+
},
|
|
692
|
+
"description": "Regex SOURCE strings (e.g. '/health$', 'analytics\\\\.example\\\\.com') for URLs that must not get the header. Replaces the whole list; Metro/dev URLs are always excluded regardless."
|
|
693
|
+
},
|
|
694
|
+
"showBanner": {
|
|
695
|
+
"type": "boolean",
|
|
696
|
+
"description": "Show the floating on-device banner while impersonating. Default true — leave it on so a QA user can see they are not themselves."
|
|
697
|
+
},
|
|
698
|
+
"dataNukeSettings": {
|
|
699
|
+
"type": "object",
|
|
700
|
+
"description": "Which stores are cleared on every start/stop of impersonation.",
|
|
701
|
+
"properties": {
|
|
702
|
+
"reactQuery": {
|
|
703
|
+
"type": "boolean",
|
|
704
|
+
"description": "Clear the react-query cache. Default true."
|
|
705
|
+
},
|
|
706
|
+
"redux": {
|
|
707
|
+
"type": "boolean",
|
|
708
|
+
"description": "Reset redux state. Default true."
|
|
709
|
+
},
|
|
710
|
+
"asyncStorage": {
|
|
711
|
+
"type": "boolean",
|
|
712
|
+
"description": "DANGEROUS: wipe app AsyncStorage on every switch. Default false."
|
|
713
|
+
},
|
|
714
|
+
"mmkv": {
|
|
715
|
+
"type": "boolean",
|
|
716
|
+
"description": "DANGEROUS: wipe app MMKV storage on every switch. Default false."
|
|
717
|
+
}
|
|
718
|
+
},
|
|
719
|
+
"required": []
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
"required": []
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
"required": ["settings"],
|
|
726
|
+
"additionalProperties": false
|
|
727
|
+
},
|
|
728
|
+
"effect": "write",
|
|
729
|
+
"release": "works",
|
|
730
|
+
"description": "Shallow-merges the given settings into state and persists them to @buoy/impersonate/state. Only the keys you send change. headerKey is the HTTP header name used for injection (default x-impersonate-user-id) — change it only if the backend expects a different one, since a wrong key means the backend silently ignores impersonation. ignorePatterns are REGEX SOURCE STRINGS (compiled with new RegExp) for URLs that must never receive the header; an invalid pattern throws on the device. dataNukeSettings is itself merged key-by-key. DANGER: setting dataNukeSettings.asyncStorage or .mmkv to true arms a full app-storage wipe that fires on the NEXT startImpersonation/stopImpersonation — both default to false for that reason, so do not enable them without the user explicitly asking. Changing headerKey or ignorePatterns takes effect on the very next request."
|
|
731
|
+
}, {
|
|
732
|
+
"action": "removeFromHistory",
|
|
733
|
+
"summary": "Delete one user from the recently-impersonated history list.",
|
|
734
|
+
"params": {
|
|
735
|
+
"type": "object",
|
|
736
|
+
"properties": {
|
|
737
|
+
"userId": {
|
|
738
|
+
"type": "string",
|
|
739
|
+
"description": "The User.id to drop, exactly as it appears in the snapshot's history[].user.id. Required."
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
"required": ["userId"],
|
|
743
|
+
"additionalProperties": false
|
|
744
|
+
},
|
|
745
|
+
"effect": "destructive",
|
|
746
|
+
"release": "works",
|
|
747
|
+
"description": "Filters the persisted history down to entries whose user.id !== userId, then writes @buoy/impersonate/state. Permanent — there is no undo and the entry can only come back by impersonating that user again. Does not stop an active impersonation of that same user; call stopImpersonation for that. A userId that matches nothing is a silent no-op that still reports success, so compare history length in the snapshot before and after."
|
|
748
|
+
}, {
|
|
749
|
+
"action": "clearHistory",
|
|
750
|
+
"summary": "Wipe the entire recently-impersonated user list.",
|
|
751
|
+
"params": {
|
|
752
|
+
"type": "object",
|
|
753
|
+
"properties": {},
|
|
754
|
+
"additionalProperties": false
|
|
755
|
+
},
|
|
756
|
+
"effect": "destructive",
|
|
757
|
+
"release": "works",
|
|
758
|
+
"description": "Empties history (max 10 entries) and persists the empty list to @buoy/impersonate/state. Permanent and unrecoverable — every quick-switch shortcut the user built up is gone. Does not stop an active impersonation and does not touch settings or app caches. Only call when the user explicitly asks to clear the list."
|
|
759
|
+
}],
|
|
760
|
+
"unavailableWhen": "The app doesn't depend on @buoy-gg/impersonate (the adapter is absent from the device's tool list). Note the adapter self-registers whenever the package merely resolves, even if the app never called createImpersonateTool() — in that state every action still works except searchUsers, which throws \"No onSearchUsers configured — pass it to createImpersonateTool()\"."
|
|
761
|
+
}, {
|
|
762
|
+
"toolId": "query",
|
|
763
|
+
"title": "React Query",
|
|
764
|
+
"summary": "Reads and mutates the app's live TanStack Query (React Query) cache on the device: list every query with status/staleness/observers/error, pull one query's real cached data, and then refetch / invalidate / reset / remove / overwrite it, simulate a query error or a perpetual loading state, clear the whole query or mutation cache, and flip TanStack's onlineManager to fake offline. Reach for it when data on screen is stale, wrong, or missing and you need to know whether the CACHE or the API is at fault (get_network_requests answers the API half). Everything here is the current cache — for the history of query updates over time use the events tool with sources:['react-query'].",
|
|
765
|
+
"actions": [{
|
|
766
|
+
"action": "listQueries",
|
|
767
|
+
"summary": "List every query in the cache — hash, key, status, staleness, observer count, last-updated, error message. The token-cheap cache reader; start here.",
|
|
768
|
+
"params": {
|
|
769
|
+
"type": "object",
|
|
770
|
+
"properties": {
|
|
771
|
+
"includeData": {
|
|
772
|
+
"type": "boolean",
|
|
773
|
+
"description": "Include each query's full cached data payload verbatim and UNCAPPED. Default false. Heavy — one cached list can be megabytes."
|
|
774
|
+
},
|
|
775
|
+
"staleOnly": {
|
|
776
|
+
"type": "boolean",
|
|
777
|
+
"description": "Only queries whose isStale() is true. Default false."
|
|
778
|
+
},
|
|
779
|
+
"limit": {
|
|
780
|
+
"type": "number",
|
|
781
|
+
"description": "Cap to the N most-recently-updated queries. NO default in the adapter — omit and every query is returned. Values <= 0 are ignored. 25 is a sane value."
|
|
782
|
+
}
|
|
783
|
+
},
|
|
784
|
+
"additionalProperties": false
|
|
785
|
+
},
|
|
786
|
+
"effect": "read",
|
|
787
|
+
"release": "works",
|
|
788
|
+
"description": "Projects each live query to light fields: queryHash, queryKey, status (one of fresh/stale/fetching/error/inactive/paused/disabled, from getQueryStatusLabel), fetchStatus, isStale, observers, updatedAt (state.dataUpdatedAt), and error.message when present. Returns {queries, total, returned, includedData}. Sorted most-recently-updated first. Safe to call repeatedly — unlike the full dehydrated snapshot, the heavy cached data stays on the device by default. TWO TRAPS: (1) the adapter has NO default limit — omit it and you get every query in the cache; (2) includeData:true returns q.state.data RAW and UNCAPPED (no 16KB wire marker, no 8MB cap like getQueryData), so it can blow the wire/token budget on a big cache — prefer getQueryData for one query's payload. The queryHash of each row is the handle every other action takes.",
|
|
789
|
+
"requires": ["QueryClientProvider above <FloatingDevTools/>", "@tanstack/react-query v5"]
|
|
790
|
+
}, {
|
|
791
|
+
"action": "getQueryData",
|
|
792
|
+
"summary": "Get ONE query's real cached data by queryHash — the size-guarded channel for a payload the snapshot replaced with a marker.",
|
|
793
|
+
"params": {
|
|
794
|
+
"type": "object",
|
|
795
|
+
"properties": {
|
|
796
|
+
"queryHash": {
|
|
797
|
+
"type": "string",
|
|
798
|
+
"description": "The target query's hash from listQueries — TanStack's default hash is the JSON-stringified key, e.g. '[\"todos\",{\"page\":1}]'. Tolerated if omitted (returns found:false) but then the call does nothing useful."
|
|
799
|
+
}
|
|
800
|
+
},
|
|
801
|
+
"required": ["queryHash"],
|
|
802
|
+
"additionalProperties": false
|
|
803
|
+
},
|
|
804
|
+
"effect": "read",
|
|
805
|
+
"release": "works",
|
|
806
|
+
"description": "Returns {found:true, queryHash, data} where data is query.state.data capped at 8MB (over that you get {__buoyTruncated:true}; non-JSON-serializable values such as circular refs or bigint come back as {__buoyUnserializable:true}). Use this when a snapshot or detail pane shows the {__buoyDataOnDevice:true} marker — streamed snapshots strip anything over 16KB. Never throws: an unknown or missing hash returns {found:false, reason:'unknown queryHash'|'missing queryHash'}.",
|
|
807
|
+
"requires": ["QueryClientProvider above <FloatingDevTools/>"]
|
|
808
|
+
}, {
|
|
809
|
+
"action": "refetch",
|
|
810
|
+
"summary": "Force one query to re-run its queryFn right now (a real network request), by queryHash.",
|
|
811
|
+
"params": {
|
|
812
|
+
"type": "object",
|
|
813
|
+
"properties": {
|
|
814
|
+
"queryHash": {
|
|
815
|
+
"type": "string",
|
|
816
|
+
"description": "Target query's hash from listQueries, e.g. '[\"todos\",{\"page\":1}]'. Required — an unknown hash throws."
|
|
817
|
+
}
|
|
818
|
+
},
|
|
819
|
+
"required": ["queryHash"],
|
|
820
|
+
"additionalProperties": false
|
|
821
|
+
},
|
|
822
|
+
"effect": "write",
|
|
823
|
+
"release": "works",
|
|
824
|
+
"description": "Calls query.fetch() on the single query with that hash and SWALLOWS the rejection — it resolves ok even when the fetch fails, because the resulting error state syncs anyway. So never report 'refetch succeeded' from the return value: call listQueries afterwards and read that row's status/error. If the query has no queryFn (e.g. it was created by setQueryData) the fetch fails and the query lands in error status. THROWS 'Query with hash \"X\" not found' for an unknown hash.",
|
|
825
|
+
"requires": ["QueryClientProvider above <FloatingDevTools/>", "the query must have a queryFn to actually fetch"]
|
|
826
|
+
}, {
|
|
827
|
+
"action": "invalidate",
|
|
828
|
+
"summary": "Mark a query stale and refetch it if it has active observers — the normal 'this data is out of date' fix.",
|
|
829
|
+
"params": {
|
|
830
|
+
"type": "object",
|
|
831
|
+
"properties": {
|
|
832
|
+
"queryHash": {
|
|
833
|
+
"type": "string",
|
|
834
|
+
"description": "Target query's hash from listQueries. Note the prefix-match blast radius described above."
|
|
835
|
+
}
|
|
836
|
+
},
|
|
837
|
+
"required": ["queryHash"],
|
|
838
|
+
"additionalProperties": false
|
|
839
|
+
},
|
|
840
|
+
"effect": "write",
|
|
841
|
+
"release": "works",
|
|
842
|
+
"description": "Looks the query up by hash, then passes the Query itself to queryClient.invalidateQueries() as the filter. Because the filter carries only queryKey with no exact:true, matching is a NON-EXACT prefix match: invalidating '[\\\"todos\\\"]' also invalidates '[\\\"todos\\\",{\\\"page\\\":1}]' and any other key that extends it. Mounted (observed) queries refetch immediately, so this can fire real API calls; inactive ones just go stale. Prefer this over refetch when you want the app's own screens to re-render with fresh data. THROWS for an unknown hash.",
|
|
843
|
+
"requires": ["QueryClientProvider above <FloatingDevTools/>"]
|
|
844
|
+
}, {
|
|
845
|
+
"action": "reset",
|
|
846
|
+
"summary": "Reset a query to its initial state — DISCARDS its cached data, then refetches if it is active.",
|
|
847
|
+
"params": {
|
|
848
|
+
"type": "object",
|
|
849
|
+
"properties": {
|
|
850
|
+
"queryHash": {
|
|
851
|
+
"type": "string",
|
|
852
|
+
"description": "Target query's hash from listQueries. Prefix-matches, so it can reset sibling/nested keys too."
|
|
853
|
+
}
|
|
854
|
+
},
|
|
855
|
+
"required": ["queryHash"],
|
|
856
|
+
"additionalProperties": false
|
|
857
|
+
},
|
|
858
|
+
"effect": "destructive",
|
|
859
|
+
"release": "works",
|
|
860
|
+
"description": "queryClient.resetQueries() with the Query as filter, so the same NON-EXACT prefix match as invalidate applies (resetting '[\\\"todos\\\"]' also resets deeper todos keys). Unlike invalidate this throws the cached value away and reverts to initialData/pending — screens bound to it will flash their loading state. There is no undo: the data only comes back if the query has a queryFn and an active observer to refetch it. THROWS for an unknown hash.",
|
|
861
|
+
"requires": ["QueryClientProvider above <FloatingDevTools/>"]
|
|
862
|
+
}, {
|
|
863
|
+
"action": "remove",
|
|
864
|
+
"summary": "Delete a query from the cache entirely — the entry, its data, and its state are gone.",
|
|
865
|
+
"params": {
|
|
866
|
+
"type": "object",
|
|
867
|
+
"properties": {
|
|
868
|
+
"queryHash": {
|
|
869
|
+
"type": "string",
|
|
870
|
+
"description": "Target query's hash from listQueries. Prefix-matches — it can delete more than the one row you picked."
|
|
871
|
+
}
|
|
872
|
+
},
|
|
873
|
+
"required": ["queryHash"],
|
|
874
|
+
"additionalProperties": false
|
|
875
|
+
},
|
|
876
|
+
"effect": "destructive",
|
|
877
|
+
"release": "works",
|
|
878
|
+
"description": "queryClient.removeQueries() with the Query as filter — again a NON-EXACT prefix match, so removing '[\\\"todos\\\"]' removes every key that extends it. Harsher than reset: the cache entry itself disappears rather than reverting to pending. Irreversible; a mounted component will create a brand-new entry and fetch from scratch on its next render. THROWS for an unknown hash. Returns nothing.",
|
|
879
|
+
"requires": ["QueryClientProvider above <FloatingDevTools/>"]
|
|
880
|
+
}, {
|
|
881
|
+
"action": "setQueryData",
|
|
882
|
+
"summary": "Overwrite a query's cached data with a value you supply — takes the queryKey ARRAY, not the hash.",
|
|
883
|
+
"params": {
|
|
884
|
+
"type": "object",
|
|
885
|
+
"properties": {
|
|
886
|
+
"queryKey": {
|
|
887
|
+
"type": "array",
|
|
888
|
+
"description": "The query's key ARRAY exactly as listQueries returns it, e.g. [\"todos\",{\"page\":1}] — NOT the queryHash string. An unknown key creates a new cache entry."
|
|
889
|
+
},
|
|
890
|
+
"data": {
|
|
891
|
+
"description": "The value to write as the query's cached data. Any JSON value; shape must match what the app's components expect or they will crash on render."
|
|
892
|
+
},
|
|
893
|
+
"queryHash": {
|
|
894
|
+
"type": "string",
|
|
895
|
+
"description": "Declared by the adapter's param type but never read by the handler — passing it has no effect."
|
|
896
|
+
}
|
|
897
|
+
},
|
|
898
|
+
"required": ["queryKey", "data"],
|
|
899
|
+
"additionalProperties": false
|
|
900
|
+
},
|
|
901
|
+
"effect": "write",
|
|
902
|
+
"release": "works",
|
|
903
|
+
"description": "queryClient.setQueryData(queryKey, data, {updatedAt: Date.now()}). Use it to force the UI into a specific state (empty list, error-ish payload, a boundary value) without touching the API. Two gotchas: (1) it keys off queryKey, the actual array from a listQueries row (e.g. ['todos',{page:1}]) — the declared param type also mentions queryHash but the handler IGNORES it; (2) if that key is not in the cache, setQueryData CREATES a new entry with no queryFn, which then errors if anything refetches it. No validation — a bad queryKey silently writes to the wrong place. The injected value is overwritten by the next successful refetch.",
|
|
904
|
+
"requires": ["QueryClientProvider above <FloatingDevTools/>"]
|
|
905
|
+
}, {
|
|
906
|
+
"action": "triggerError",
|
|
907
|
+
"summary": "Force a query into error status with a fake Error, to exercise the app's error UI.",
|
|
908
|
+
"params": {
|
|
909
|
+
"type": "object",
|
|
910
|
+
"properties": {
|
|
911
|
+
"queryHash": {
|
|
912
|
+
"type": "string",
|
|
913
|
+
"description": "Target query's hash from listQueries."
|
|
914
|
+
}
|
|
915
|
+
},
|
|
916
|
+
"required": ["queryHash"],
|
|
917
|
+
"additionalProperties": false
|
|
918
|
+
},
|
|
919
|
+
"effect": "write",
|
|
920
|
+
"release": "works",
|
|
921
|
+
"description": "Sets the query's state to {status:'error', error: new Error('Unknown error from devtools')} and stashes the real options in fetchMeta.__previousQueryOptions. The app's error boundary / error view for that screen should appear. Nothing is fetched and the network is untouched — this is a pure cache-state simulation. Always undo it with restoreError when you're done, or that screen stays broken for the person holding the device. THROWS for an unknown hash.",
|
|
922
|
+
"requires": ["QueryClientProvider above <FloatingDevTools/>"]
|
|
923
|
+
}, {
|
|
924
|
+
"action": "restoreError",
|
|
925
|
+
"summary": "Undo triggerError — clears the fake error. Implemented as resetQueries, so it also discards cached data.",
|
|
926
|
+
"params": {
|
|
927
|
+
"type": "object",
|
|
928
|
+
"properties": {
|
|
929
|
+
"queryHash": {
|
|
930
|
+
"type": "string",
|
|
931
|
+
"description": "The hash you passed to triggerError."
|
|
932
|
+
}
|
|
933
|
+
},
|
|
934
|
+
"required": ["queryHash"],
|
|
935
|
+
"additionalProperties": false
|
|
936
|
+
},
|
|
937
|
+
"effect": "destructive",
|
|
938
|
+
"release": "works",
|
|
939
|
+
"description": "The undo for triggerError, but the handler is literally queryClient.resetQueries(query) — identical to the reset action. That means it clears the query's cached data and reverts it to pending as well as clearing the fake error, and it prefix-matches on queryKey. Active queries refetch and recover; an inactive query is left empty until something observes it. THROWS for an unknown hash.",
|
|
940
|
+
"requires": ["QueryClientProvider above <FloatingDevTools/>"]
|
|
941
|
+
}, {
|
|
942
|
+
"action": "triggerLoading",
|
|
943
|
+
"summary": "Pin a query in a permanent loading/suspense state to exercise skeletons and spinners. MUST be undone.",
|
|
944
|
+
"params": {
|
|
945
|
+
"type": "object",
|
|
946
|
+
"properties": {
|
|
947
|
+
"queryHash": {
|
|
948
|
+
"type": "string",
|
|
949
|
+
"description": "Target query's hash from listQueries."
|
|
950
|
+
}
|
|
951
|
+
},
|
|
952
|
+
"required": ["queryHash"],
|
|
953
|
+
"additionalProperties": false
|
|
954
|
+
},
|
|
955
|
+
"effect": "destructive",
|
|
956
|
+
"release": "works",
|
|
957
|
+
"description": "Clears state.data, sets status 'pending', and starts a fetch whose queryFn is a promise that NEVER resolves (with gcTime:-1), stashing the real options in fetchMeta.__previousQueryOptions. The app's skeleton/spinner/suspense fallback for that screen stays up FOREVER until you call restoreLoading — nothing times out and a reload of the app is the only other escape. Tell the user this is a simulation, and always pair it with restoreLoading. THROWS for an unknown hash.",
|
|
958
|
+
"requires": ["QueryClientProvider above <FloatingDevTools/>"]
|
|
959
|
+
}, {
|
|
960
|
+
"action": "restoreLoading",
|
|
961
|
+
"summary": "Undo triggerLoading — cancel the never-resolving fetch and refetch with the query's real options.",
|
|
962
|
+
"params": {
|
|
963
|
+
"type": "object",
|
|
964
|
+
"properties": {
|
|
965
|
+
"queryHash": {
|
|
966
|
+
"type": "string",
|
|
967
|
+
"description": "The hash you passed to triggerLoading."
|
|
968
|
+
}
|
|
969
|
+
},
|
|
970
|
+
"required": ["queryHash"],
|
|
971
|
+
"additionalProperties": false
|
|
972
|
+
},
|
|
973
|
+
"effect": "write",
|
|
974
|
+
"release": "works",
|
|
975
|
+
"description": "Silently cancels the fake fetch, restores the previous state with fetchStatus:'idle' and fetchMeta cleared, then re-runs query.fetch(__previousQueryOptions) if those stashed options exist (fetch rejections are swallowed). If triggerLoading was never called for this query there is nothing stashed, so it just cancels any in-flight fetch and idles the query. THROWS for an unknown hash.",
|
|
976
|
+
"requires": ["QueryClientProvider above <FloatingDevTools/>"]
|
|
977
|
+
}, {
|
|
978
|
+
"action": "clearQueryCache",
|
|
979
|
+
"summary": "Wipe the ENTIRE query cache — every query on the device, not just one.",
|
|
980
|
+
"params": {
|
|
981
|
+
"type": "object",
|
|
982
|
+
"properties": {},
|
|
983
|
+
"additionalProperties": false
|
|
984
|
+
},
|
|
985
|
+
"effect": "destructive",
|
|
986
|
+
"release": "works",
|
|
987
|
+
"description": "queryClient.getQueryCache().clear(). Nukes all cached data app-wide and is irreversible; mounted screens will refetch from scratch and briefly show loading or empty states. Only reach for this when the user explicitly asks to clear the cache or to reproduce a cold-start — for one bad query use remove or invalidate instead. Takes no parameters.",
|
|
988
|
+
"requires": ["QueryClientProvider above <FloatingDevTools/>"]
|
|
989
|
+
}, {
|
|
990
|
+
"action": "clearMutationCache",
|
|
991
|
+
"summary": "Wipe the entire mutation cache — all recorded mutations and their states.",
|
|
992
|
+
"params": {
|
|
993
|
+
"type": "object",
|
|
994
|
+
"properties": {},
|
|
995
|
+
"additionalProperties": false
|
|
996
|
+
},
|
|
997
|
+
"effect": "destructive",
|
|
998
|
+
"release": "works",
|
|
999
|
+
"description": "queryClient.getMutationCache().clear(). Drops the record of every mutation (pending, success, error) so the mutations list goes empty; it does NOT cancel work already in flight on the server. Irreversible — the mutation history you were reading disappears. Takes no parameters.",
|
|
1000
|
+
"requires": ["QueryClientProvider above <FloatingDevTools/>"]
|
|
1001
|
+
}, {
|
|
1002
|
+
"action": "setOnline",
|
|
1003
|
+
"summary": "Flip TanStack's onlineManager to simulate offline mode app-wide (the WiFi toggle).",
|
|
1004
|
+
"params": {
|
|
1005
|
+
"type": "object",
|
|
1006
|
+
"properties": {
|
|
1007
|
+
"online": {
|
|
1008
|
+
"type": "boolean",
|
|
1009
|
+
"description": "true = online (normal), false = simulate offline: React Query pauses fetches and queues mutations app-wide."
|
|
1010
|
+
}
|
|
1011
|
+
},
|
|
1012
|
+
"required": ["online"],
|
|
1013
|
+
"additionalProperties": false
|
|
1014
|
+
},
|
|
1015
|
+
"effect": "write",
|
|
1016
|
+
"release": "works",
|
|
1017
|
+
"description": "onlineManager.setOnline(online). With false, React Query treats the device as offline: fetches go to fetchStatus 'paused' instead of running, and mutations queue — perfect for testing offline UI. It does NOT touch the real network stack, so plain fetch/axios calls outside React Query still go through. BLAST RADIUS IS THE WHOLE APP: leave it false and every query looks hung, so always restore it with online:true when you're done and say so to the user. Persistence is unreliable — the value only gets saved when the on-device React Query panel is open, and a reload restores whatever the WiFi toggle last saved.",
|
|
1018
|
+
"requires": ["QueryClientProvider above <FloatingDevTools/>"]
|
|
1019
|
+
}],
|
|
1020
|
+
"unavailableWhen": "There is no QueryClientProvider above <FloatingDevTools/> — the adapter factory returns null and the \"query\" tool is never registered (older apps advertise it under the legacy id \"react-query\"). Separately, in a RELEASE JS bundle (__DEV__ === false) the whole external-sync socket only mounts when the app passed externalSync.enableInRelease AND holds a real Pro license, so no query action is reachable at all in a normal shipped build — that gate is on the transport (FloatingDevTools.tsx / externalSyncGate), not on these handlers."
|
|
1021
|
+
}, {
|
|
1022
|
+
"toolId": "events",
|
|
1023
|
+
"title": "Events",
|
|
1024
|
+
"summary": "The cross-tool activity timeline: one chronological ring buffer (max 200, newest-first) that aggregates events from every other installed Buoy tool — network requests, redux/zustand/jotai state changes, react-query query/mutation updates, AsyncStorage/MMKV writes, route navigations, and component renders. Reach for it first for any \"what just happened in the app?\" question, before drilling into a single-tool reader. It exposes 3 actions: exportEvents (formatted read), setEnabledSources (choose what the device records), clearEvents (wipe the timeline). CRITICAL: it only records while something is watching — a cold exportEvents on a freshly connected session usually returns 0 events because nothing has ever armed capture, which is NOT the same as \"the app did nothing\".",
|
|
1025
|
+
"actions": [{
|
|
1026
|
+
"action": "exportEvents",
|
|
1027
|
+
"summary": "Read the recorded cross-tool timeline as a formatted string (markdown/json/plaintext/mermaid), optionally filtered by source and status.",
|
|
1028
|
+
"params": {
|
|
1029
|
+
"type": "object",
|
|
1030
|
+
"properties": {
|
|
1031
|
+
"preset": {
|
|
1032
|
+
"type": "string",
|
|
1033
|
+
"enum": ["llm", "bugReport", "json", "errors", "minimal", "mermaid"],
|
|
1034
|
+
"description": "Named Copy-Settings preset used as the BASE (settings is merged over it). llm = compact markdown, no payloads. bugReport = markdown + full payloads (10KB cap). json = machine-readable, unlimited payloads. errors = failed events only + payloads. minimal = one plaintext line per event. mermaid = sequence diagram. Unknown names silently fall back to the compact default."
|
|
1035
|
+
},
|
|
1036
|
+
"settings": {
|
|
1037
|
+
"type": "object",
|
|
1038
|
+
"description": "Partial EventsCopySettings merged over the preset/default. Every field optional.",
|
|
1039
|
+
"properties": {
|
|
1040
|
+
"filterSources": {
|
|
1041
|
+
"type": "array",
|
|
1042
|
+
"items": {
|
|
1043
|
+
"type": "string",
|
|
1044
|
+
"enum": ["storage-async", "storage-mmkv", "redux", "network", "react-query", "react-query-query", "react-query-mutation", "route", "zustand", "jotai", "render"]
|
|
1045
|
+
},
|
|
1046
|
+
"description": "Only these sources reach the output. Empty/omitted = all. GRANULAR values only: no bare 'storage' or friendly aliases. Applied AFTER limit — pair with a large limit."
|
|
1047
|
+
},
|
|
1048
|
+
"filterMode": {
|
|
1049
|
+
"type": "string",
|
|
1050
|
+
"enum": ["all", "errors", "success", "pending"],
|
|
1051
|
+
"description": "Status filter. 'errors' keeps only status==='error' (HTTP >=400 or thrown, rejected redux thunks, query/mutation errors). Applied AFTER limit."
|
|
1052
|
+
},
|
|
1053
|
+
"includeEventData": {
|
|
1054
|
+
"type": "boolean",
|
|
1055
|
+
"description": "Include each event's raw originalEvent payload. HEAVY (network bodies, redux state trees). Default false."
|
|
1056
|
+
},
|
|
1057
|
+
"dataSizeThreshold": {
|
|
1058
|
+
"type": "number",
|
|
1059
|
+
"enum": [1, 5, 10, 50, -1],
|
|
1060
|
+
"description": "KB cap per embedded payload when includeEventData is true; -1 = unlimited. Default 5."
|
|
1061
|
+
},
|
|
1062
|
+
"format": {
|
|
1063
|
+
"type": "string",
|
|
1064
|
+
"enum": ["markdown", "json", "plaintext", "mermaid"],
|
|
1065
|
+
"description": "Output format. Note the on-wire value is 'plaintext', not 'text'. Default markdown."
|
|
1066
|
+
},
|
|
1067
|
+
"timestampFormat": {
|
|
1068
|
+
"type": "string",
|
|
1069
|
+
"enum": ["relative", "absolute", "both"],
|
|
1070
|
+
"description": "Default relative (+120ms from the first event)."
|
|
1071
|
+
},
|
|
1072
|
+
"compactMode": {
|
|
1073
|
+
"type": "boolean",
|
|
1074
|
+
"description": "One line per event, no JSON blocks. Default false."
|
|
1075
|
+
},
|
|
1076
|
+
"includeSource": {
|
|
1077
|
+
"type": "boolean",
|
|
1078
|
+
"description": "Show the source label (Network/Redux/Query/...). Default true."
|
|
1079
|
+
},
|
|
1080
|
+
"includeStatus": {
|
|
1081
|
+
"type": "boolean",
|
|
1082
|
+
"description": "Show the status icon. Default true."
|
|
1083
|
+
},
|
|
1084
|
+
"includeTitle": {
|
|
1085
|
+
"type": "boolean",
|
|
1086
|
+
"description": "Show the event title (URL, action type, query key, atom label). Default true."
|
|
1087
|
+
},
|
|
1088
|
+
"includeSubtitle": {
|
|
1089
|
+
"type": "boolean",
|
|
1090
|
+
"description": "Show the secondary line (status code, duration, changed keys). Default true."
|
|
1091
|
+
},
|
|
1092
|
+
"includeCorrelation": {
|
|
1093
|
+
"type": "boolean",
|
|
1094
|
+
"description": "Group correlated events (e.g. rq-query-<hash> start/settle pairs). Default true."
|
|
1095
|
+
},
|
|
1096
|
+
"includeDuration": {
|
|
1097
|
+
"type": "boolean",
|
|
1098
|
+
"description": "Show per-event duration. Default true."
|
|
1099
|
+
},
|
|
1100
|
+
"includeSummaryHeader": {
|
|
1101
|
+
"type": "boolean",
|
|
1102
|
+
"description": "Prepend the counts-by-status summary block. Default true."
|
|
1103
|
+
},
|
|
1104
|
+
"includeTotalDuration": {
|
|
1105
|
+
"type": "boolean",
|
|
1106
|
+
"description": "Show total elapsed time across the window. Default true."
|
|
1107
|
+
},
|
|
1108
|
+
"smartJsonParsing": {
|
|
1109
|
+
"type": "boolean",
|
|
1110
|
+
"description": "Parse JSON-looking strings so payloads aren't double-escaped. Default true."
|
|
1111
|
+
},
|
|
1112
|
+
"reduxChangedOnly": {
|
|
1113
|
+
"type": "boolean",
|
|
1114
|
+
"description": "For redux, print only changed slices instead of whole state. Default true."
|
|
1115
|
+
},
|
|
1116
|
+
"showStorageDiff": {
|
|
1117
|
+
"type": "boolean",
|
|
1118
|
+
"description": "For storage writes, show prevValue -> value diff. Default true."
|
|
1119
|
+
},
|
|
1120
|
+
"stripVerboseFields": {
|
|
1121
|
+
"type": "boolean",
|
|
1122
|
+
"description": "Drop noise fields (imageUrl, thumbnail, icon, description...) from payloads. Default true."
|
|
1123
|
+
}
|
|
1124
|
+
},
|
|
1125
|
+
"additionalProperties": false
|
|
1126
|
+
},
|
|
1127
|
+
"limit": {
|
|
1128
|
+
"type": "number",
|
|
1129
|
+
"description": "Cap to the most-recent N events BEFORE filtering and formatting. Omitted or <=0 means the whole buffer (max 200). Because it runs before filterSources/filterMode, a small limit plus a source filter can return nothing."
|
|
1130
|
+
}
|
|
1131
|
+
},
|
|
1132
|
+
"additionalProperties": false
|
|
1133
|
+
},
|
|
1134
|
+
"effect": "read",
|
|
1135
|
+
"release": "works",
|
|
1136
|
+
"description": "Runs the device's own \"Copy Settings\" formatter over the unified store and returns {output: string, returned: number, totalAvailable: number, includedData: boolean, format: string}. The store is a 200-event ring, newest-first. Defaults are compact: includeEventData=false, so heavy raw payloads (network bodies, redux state trees, query data) stay on the device — pass settings.includeEventData=true only when you actually need them.\n\nTHREE traps a caller must know:\n(1) COLD READS ARE EMPTY. Sources are only subscribed while a consumer holds a watch on toolId \"events\" (the adapter's subscribe() calls ensureRemoteSourcesDefault) or the on-device Events modal is open. A bare call_action does NOT arm capture — the MCP SyncClient only watches on getSnapshot, never on callAction (packages/mcp/src/tools/packs/events.ts:80). If totalAvailable is 0, say \"nothing was being recorded\", not \"nothing happened\": arm it first (read the events snapshot, or call setEnabledSources), reproduce, then export.\n(2) limit IS APPLIED BEFORE FILTERING. The adapter does all.slice(0, limit) and only then does generateExport filter by settings.filterSources / filterMode (packages/events/src/sync/eventsSyncAdapter.ts:378-381 + eventExportFormatter.ts:616). Filtering to one source with limit:25 can produce an EMPTY output while `returned` still reports 25 — `returned` counts events handed to the formatter, not events in the output. When filtering by source, pass a large limit (or omit it, which uses the whole buffer).\n(3) filterSources takes GRANULAR EventSource values, not friendly names. \"storage\" matches nothing — use [\"storage-async\",\"storage-mmkv\"]; react-query is three values ([\"react-query\",\"react-query-query\",\"react-query-mutation\"]). The MCP get_events wrapper expands those aliases for you; a direct call_action does not.\n\nAn unrecognized preset name silently falls back to DEFAULT_COPY_SETTINGS instead of erroring. settings is shallow-merged OVER the preset.",
|
|
1137
|
+
"requires": ["@buoy-gg/events installed and auto-discovered by FloatingDevTools", "a live watch on toolId \"events\" (an events snapshot read, a desktop Events panel, or a prior setEnabledSources call) before the window you want recorded — otherwise the buffer is empty", "the source packages you want in the timeline (@buoy-gg/network, /redux, /storage, /react-query, /route-events, /zustand, /jotai); the /highlight-updates 'render' source produces NOTHING in a release build"],
|
|
1138
|
+
"armsCapture": true
|
|
1139
|
+
}, {
|
|
1140
|
+
"action": "setEnabledSources",
|
|
1141
|
+
"summary": "Arm/narrow which event sources the device records for this dashboard — the way to START capture before reproducing a bug.",
|
|
1142
|
+
"params": {
|
|
1143
|
+
"type": "object",
|
|
1144
|
+
"properties": {
|
|
1145
|
+
"sources": {
|
|
1146
|
+
"type": "array",
|
|
1147
|
+
"items": {
|
|
1148
|
+
"type": "string",
|
|
1149
|
+
"enum": ["storage-async", "storage-mmkv", "redux", "network", "react-query", "react-query-query", "react-query-mutation", "route", "zustand", "jotai", "render"]
|
|
1150
|
+
},
|
|
1151
|
+
"description": "Sources this dashboard wants the device to capture. REQUIRED in practice: omitting it (or passing []) unsubscribes everything and stops recording. Use granular values — 'storage' alone is not valid; react-query needs its three variants. 'render' is high-frequency and floods the 200-event buffer (and does nothing in a release build)."
|
|
1152
|
+
}
|
|
1153
|
+
},
|
|
1154
|
+
"additionalProperties": false
|
|
1155
|
+
},
|
|
1156
|
+
"effect": "write",
|
|
1157
|
+
"release": "works",
|
|
1158
|
+
"description": "Declares this remote consumer's wanted source set. Subscriptions are ref-counted in unifiedEventStore, so narrowing here never tears down what the on-device Events modal is also using. Returns {enabledSources: <exactly what you passed>} — a pure echo, NOT a confirmation that the source exists or actually subscribed: an uninstalled or misspelled source silently subscribes nothing and still comes back in the echo. Verify by exporting and seeing events appear.\n\nDANGER: sources is `?? []`. Calling it with no params (or sources: []) unsubscribes EVERY source this consumer had requested and the device stops recording (packages/events/src/sync/eventsSyncAdapter.ts:357-362; the adapter test at __tests__/eventsSyncAdapter.test.ts:351 pins the {enabledSources: []} echo). Never call it bare to 'reset' — pass the full list you want.\n\nPass GRANULAR EventSource values; they are mapped to parent discovery ids by EVENT_SOURCE_TO_DISCOVERY_ID (storage-async and storage-mmkv both -> 'storage'; the three react-query values -> 'react-query'; route -> 'route-events'). The default when a dashboard starts watching is 'all' minus the high-frequency 'render' source, which is deliberately excluded because it fires per component render and would flush the 200-event buffer (packages/events/src/stores/unifiedEventStore.ts:35). The underlying store also accepts the literal string \"all\" even though the adapter types this as an array — prefer an explicit list.\n\nRELEASE CAVEAT: asking for \"render\" is inert in a release build. HighlightUpdatesController.enableBackgroundTracking() returns immediately when !__DEV__ (packages/highlight-updates/src/highlight-updates/utils/HighlightUpdatesController.ts:1905), and React Native only installs __REACT_DEVTOOLS_GLOBAL_HOOK__ under __DEV__, so no render events are ever produced — the echo still lists it. Every other source (network, storage, redux, zustand, jotai, route, react-query) records normally in release.",
|
|
1159
|
+
"requires": ["@buoy-gg/events installed and auto-discovered by FloatingDevTools", "the package behind each requested source installed in the app (otherwise the request is silently a no-op)"],
|
|
1160
|
+
"armsCapture": true
|
|
1161
|
+
}, {
|
|
1162
|
+
"action": "clearEvents",
|
|
1163
|
+
"summary": "Wipe the entire unified events timeline on the device (all 200 buffered events, every source). Irreversible.",
|
|
1164
|
+
"params": {
|
|
1165
|
+
"type": "object",
|
|
1166
|
+
"properties": {},
|
|
1167
|
+
"additionalProperties": false
|
|
1168
|
+
},
|
|
1169
|
+
"effect": "destructive",
|
|
1170
|
+
"release": "works",
|
|
1171
|
+
"description": "Calls unifiedEventStore.clearEvents(): empties the event array, clears the activeSources set and the network request->unified id map, then notifies listeners and any onClear subscribers (packages/events/src/stores/unifiedEventStore.ts:440). Returns nothing. There is no undo and no snapshot to restore from — export first if the buffer might matter.\n\nScope: it clears only the AGGREGATED timeline. The per-tool stores keep their own logs, so network requests, redux actions and storage writes are still readable through their own tools after this. Conversely, clearing a source tool does not clear this timeline.\n\nUse it to get a clean baseline right before reproducing a bug (clearEvents -> reproduce -> exportEvents), not as routine cleanup.",
|
|
1172
|
+
"requires": ["@buoy-gg/events installed and auto-discovered by FloatingDevTools"]
|
|
1173
|
+
}],
|
|
1174
|
+
"unavailableWhen": "The app doesn't have @buoy-gg/events installed/mounted (then toolId \"events\" is absent from the device's adapter map), or the device is not connected to the broker at all. In a release bundle (__DEV__ === false) the whole sync channel is off unless the app passed externalSync.enableInRelease AND holds a real Pro license (packages/devtools-floating-menu/src/floatingMenu/FloatingDevTools.tsx:707) — that gates every Buoy action, not just these. Individual sources are also absent when their package isn't installed: auto-discovery require()s @buoy-gg/storage, /redux, /network, /react-query, /route-events, /zustand, /jotai, /highlight-updates and silently skips whichever are missing (packages/events/src/utils/autoDiscoverEventSources.ts:935)."
|
|
1175
|
+
}, {
|
|
1176
|
+
"toolId": "network",
|
|
1177
|
+
"title": "Network",
|
|
1178
|
+
"summary": "Read and act on the app's captured HTTP traffic — getSnapshot lists the requests, getEventBody fetches one body, and author response-override rules that force matching requests to return a chosen status/body, fail, or arrive late. 17 actions. Two things gate honesty: (1) the device only RECORDS while something holds a capture subscription, so a cold read can be legitimately empty — call getCaptureStatus before telling anyone \"no requests happened\"; (2) in a RELEASE build every override write still returns ok:true and the rule still persists and appears in listOverrideRules, but engine.ts:74 refuses to apply it to real traffic, so nothing changes — call debugOverrides and read engine.devFlag before claiming an override took effect. Boot-time capture (requests fired before anything subscribed) is also DEV-only (preset.tsx:52).",
|
|
1179
|
+
"actions": [{
|
|
1180
|
+
"action": "getSnapshot",
|
|
1181
|
+
"summary": "List the app's captured HTTP requests — method, url, status, duration, error. The read half of the network tool; there is no action that lists requests.",
|
|
1182
|
+
"description": "Returns `{ totalCaptured, shown, requests: [{id, method, url, status, durationMs, error}] }`, newest last. Bodies are NOT included — take an `id` from here and call `getEventBody` for one response. Narrow with `failedOnly`, `pattern` (substring of the url) and `limit`. The device only RECORDS while something holds a capture subscription, so an empty list can be legitimate: call `getCaptureStatus` before telling anyone no requests happened.",
|
|
1183
|
+
"params": {
|
|
1184
|
+
"type": "object",
|
|
1185
|
+
"properties": {
|
|
1186
|
+
"limit": {
|
|
1187
|
+
"type": "number",
|
|
1188
|
+
"description": "Most-recent N requests after filtering. Default 25."
|
|
1189
|
+
},
|
|
1190
|
+
"failedOnly": {
|
|
1191
|
+
"type": "boolean",
|
|
1192
|
+
"description": "Only requests that errored or returned status >= 400. Default false."
|
|
1193
|
+
},
|
|
1194
|
+
"pattern": {
|
|
1195
|
+
"type": "string",
|
|
1196
|
+
"description": "Case-insensitive substring the url must contain."
|
|
1197
|
+
}
|
|
1198
|
+
},
|
|
1199
|
+
"additionalProperties": false
|
|
1200
|
+
},
|
|
1201
|
+
"effect": "read",
|
|
1202
|
+
"release": "works"
|
|
1203
|
+
}, {
|
|
1204
|
+
"action": "getCaptureStatus",
|
|
1205
|
+
"summary": "Is the device actually recording right now, and why not — call this before reporting an empty request list.",
|
|
1206
|
+
"params": {
|
|
1207
|
+
"type": "object",
|
|
1208
|
+
"properties": {},
|
|
1209
|
+
"additionalProperties": false
|
|
1210
|
+
},
|
|
1211
|
+
"effect": "read",
|
|
1212
|
+
"release": "works",
|
|
1213
|
+
"description": "Returns {capturing:boolean, subscribers:number, interceptorInstalled:boolean|null, interceptorLive:boolean|null, listenerCount:number|null, eventCount:number}. `capturing` is driven by the event store's subscriber count, NOT by whether the interceptor is installed: an enabled override rule pins the interceptor without anything recording, so installed:true + capturing:false is a real and common state. installed:true + live:false means something re-assigned fetch/XHR over the interceptor (it self-heals on the next snapshot). null for the interceptor fields means there is no interceptable runtime here. Nothing is recorded while no one is subscribed, so an empty list usually means capture was not armed — reading arms it, so trigger the traffic and read again. Separately, requests fired before anything subscribed (session bootstrap, first queries) are only buffered in a DEV build.",
|
|
1214
|
+
"requires": ["@buoy-gg/network mounted in the app", "in a release build there is NO boot capture — packages/network/src/preset.tsx:52 gates startBootCapture on __DEV__, so requests fired before the first watch are unrecoverable"]
|
|
1215
|
+
}, {
|
|
1216
|
+
"action": "getEventBody",
|
|
1217
|
+
"summary": "Full un-stripped request/response bodies and headers for one request id.",
|
|
1218
|
+
"params": {
|
|
1219
|
+
"type": "object",
|
|
1220
|
+
"properties": {
|
|
1221
|
+
"id": {
|
|
1222
|
+
"type": "string",
|
|
1223
|
+
"description": "Request id from the network snapshot. Live ids, or `saved:<key>` ids from an earlier app run."
|
|
1224
|
+
}
|
|
1225
|
+
},
|
|
1226
|
+
"required": ["id"],
|
|
1227
|
+
"additionalProperties": false
|
|
1228
|
+
},
|
|
1229
|
+
"effect": "read",
|
|
1230
|
+
"release": "works",
|
|
1231
|
+
"description": "Returns {requestData, responseData, requestHeaders, responseHeaders} (each null when absent), or null when no event with that id exists in either the live store or the saved store. Needed because the snapshot withholds bodies over 16KB, header values over 64 chars, and (past a 1.25MB per-snapshot budget) the bodies of older rows — those rows carry requestBodyOmitted/responseBodyOmitted/headersOmitted:true, which is the signal to call this. Accepts a live id or a `saved:<key>` id from a previous app run.",
|
|
1232
|
+
"requires": ["the event must still exist — live capture list (500-request cap) or the saved store"],
|
|
1233
|
+
"armsCapture": true
|
|
1234
|
+
}, {
|
|
1235
|
+
"action": "setPinned",
|
|
1236
|
+
"summary": "Pin or unpin one request; a pinned request is a full snapshot that survives Clear, the 500-cap and app restarts.",
|
|
1237
|
+
"params": {
|
|
1238
|
+
"type": "object",
|
|
1239
|
+
"properties": {
|
|
1240
|
+
"id": {
|
|
1241
|
+
"type": "string",
|
|
1242
|
+
"description": "Request id from the network snapshot."
|
|
1243
|
+
},
|
|
1244
|
+
"pinned": {
|
|
1245
|
+
"type": "boolean",
|
|
1246
|
+
"description": "true pins. false OR OMITTED unpins — always send this explicitly."
|
|
1247
|
+
}
|
|
1248
|
+
},
|
|
1249
|
+
"required": ["id"],
|
|
1250
|
+
"additionalProperties": false
|
|
1251
|
+
},
|
|
1252
|
+
"effect": "write",
|
|
1253
|
+
"release": "works",
|
|
1254
|
+
"description": "Pinning writes a persisted snapshot of the request, held at the top of the developer's Network list regardless of their filters. This is the human<->agent handoff channel: a human pins the call they think is broken, or you pin one for them to look at. WARNING on optionality: the handler reads `pinned` with a truthiness cast, so omitting it (or sending false) means UNPIN. Returns null when no request with that id exists (cleared or aged out — re-list), {ok:true} when already in the requested state, {ok:true,active:boolean} on a real toggle, or {ok:false,reason:\"pin-cap\"|\"invalid-event\"} — pin-cap is the 25-pin ceiling (lower on some license tiers); clear some first.",
|
|
1255
|
+
"requires": ["the event must still exist in the live or saved store"],
|
|
1256
|
+
"armsCapture": true
|
|
1257
|
+
}, {
|
|
1258
|
+
"action": "setSaved",
|
|
1259
|
+
"summary": "Save or unsave one request to the developer's favorites; persisted, survives Clear and app restarts.",
|
|
1260
|
+
"params": {
|
|
1261
|
+
"type": "object",
|
|
1262
|
+
"properties": {
|
|
1263
|
+
"id": {
|
|
1264
|
+
"type": "string",
|
|
1265
|
+
"description": "Request id from the network snapshot."
|
|
1266
|
+
},
|
|
1267
|
+
"saved": {
|
|
1268
|
+
"type": "boolean",
|
|
1269
|
+
"description": "true saves. false OR OMITTED unsaves — always send this explicitly."
|
|
1270
|
+
}
|
|
1271
|
+
},
|
|
1272
|
+
"required": ["id"],
|
|
1273
|
+
"additionalProperties": false
|
|
1274
|
+
},
|
|
1275
|
+
"effect": "write",
|
|
1276
|
+
"release": "works",
|
|
1277
|
+
"description": "Same mechanics as setPinned but writes the 'saved'/bookmark flag instead of the pin. Same optionality hazard: omitting `saved` (or sending false) means UNSAVE. Returns null when no request with that id exists, {ok:true} when already in the requested state, {ok:true,active:boolean} on a toggle, or {ok:false,reason:\"saved-cap\"|\"invalid-event\"} — saved-cap is the 50-record ceiling (lower on some license tiers).",
|
|
1278
|
+
"requires": ["the event must still exist in the live or saved store"],
|
|
1279
|
+
"armsCapture": true
|
|
1280
|
+
}, {
|
|
1281
|
+
"action": "removeSavedRecord",
|
|
1282
|
+
"summary": "Permanently delete one pinned/saved record by its stable `key` (not a request id).",
|
|
1283
|
+
"params": {
|
|
1284
|
+
"type": "object",
|
|
1285
|
+
"properties": {
|
|
1286
|
+
"key": {
|
|
1287
|
+
"type": "string",
|
|
1288
|
+
"description": "Kept-record key from the snapshot's `saved` array (e.g. `<sessionId>_3`). Not a request id."
|
|
1289
|
+
}
|
|
1290
|
+
},
|
|
1291
|
+
"required": ["key"],
|
|
1292
|
+
"additionalProperties": false
|
|
1293
|
+
},
|
|
1294
|
+
"effect": "destructive",
|
|
1295
|
+
"release": "works",
|
|
1296
|
+
"description": "Takes the record `key` from the snapshot's `saved` list — NOT a request id; passing a request id silently matches nothing. Deletes the persisted snapshot outright whatever its pin/save flags, and it cannot be recovered: this may be the only remaining copy of a request the live list already dropped. Returns {ok:true} (also when the key matched nothing), or null when `key` is missing."
|
|
1297
|
+
}, {
|
|
1298
|
+
"action": "clearSavedRequests",
|
|
1299
|
+
"summary": "Unsave every saved request; records that are also pinned stay (still pinned).",
|
|
1300
|
+
"params": {
|
|
1301
|
+
"type": "object",
|
|
1302
|
+
"properties": {},
|
|
1303
|
+
"additionalProperties": false
|
|
1304
|
+
},
|
|
1305
|
+
"effect": "destructive",
|
|
1306
|
+
"release": "works",
|
|
1307
|
+
"description": "Clears the 'saved'/favorites flag across the persisted store. Records that carry only the saved flag are deleted for good — those snapshots may be the last copy of requests the live list already dropped, and there is no undo. Records that are also pinned survive with saved:false. Returns {ok:true}. This is the developer's curated list, not scratch data — do not call it to tidy up."
|
|
1308
|
+
}, {
|
|
1309
|
+
"action": "clearPinnedRequests",
|
|
1310
|
+
"summary": "Unpin every pinned request; records that are also saved stay (still saved).",
|
|
1311
|
+
"params": {
|
|
1312
|
+
"type": "object",
|
|
1313
|
+
"properties": {},
|
|
1314
|
+
"additionalProperties": false
|
|
1315
|
+
},
|
|
1316
|
+
"effect": "destructive",
|
|
1317
|
+
"release": "works",
|
|
1318
|
+
"description": "Clears the pin flag across the persisted store. Pin-only records are deleted permanently, with no undo — and a pin is how a human flags 'this is the broken call', so clearing them destroys that signal. Records that are also saved survive with pinned:false. Returns {ok:true}. Use this only when told to, or to free room after a pin-cap rejection."
|
|
1319
|
+
}, {
|
|
1320
|
+
"action": "clearEvents",
|
|
1321
|
+
"summary": "Wipe the live captured-request list (pinned/saved snapshots are unaffected).",
|
|
1322
|
+
"params": {
|
|
1323
|
+
"type": "object",
|
|
1324
|
+
"properties": {},
|
|
1325
|
+
"additionalProperties": false
|
|
1326
|
+
},
|
|
1327
|
+
"effect": "destructive",
|
|
1328
|
+
"release": "works",
|
|
1329
|
+
"description": "Empties the in-memory event list and its pending-request map. Irreversible — anything not pinned or saved is gone. Useful to get a clean baseline before reproducing a bug: clear, drive the app, then read. Returns undefined (no receipt)."
|
|
1330
|
+
}, {
|
|
1331
|
+
"action": "listOverrideRules",
|
|
1332
|
+
"summary": "Read every override rule back in full, with hit counts, bodies, and the master-switch state.",
|
|
1333
|
+
"params": {
|
|
1334
|
+
"type": "object",
|
|
1335
|
+
"properties": {},
|
|
1336
|
+
"additionalProperties": false
|
|
1337
|
+
},
|
|
1338
|
+
"effect": "read",
|
|
1339
|
+
"release": "works",
|
|
1340
|
+
"description": "Returns the whole OverrideRulesState: {enabled:boolean, autoPaused:boolean, rules:[{id, name, urlPattern, methods, kind, status, statusText, headers, body, failKind, delayMs, times, alternate, enabled, hits, seen, createdAt}]}. Unlike the periodic snapshot, this includes full rule bodies and un-coalesced hit counts. Read `enabled:false` carefully: `autoPaused:true` means the launch-safety guard turned overrides off by ITSELF after 3 launches with rules armed and untouched — nobody flipped that switch, and it must be re-armed with setOverridesEnabled({enabled:true}). A rule where `seen` > `hits` matched but did not apply (an `alternate` rule in its off phase, or a spent `times` budget) — that is the explanation for 'it matched but nothing happened'."
|
|
1341
|
+
}, {
|
|
1342
|
+
"action": "getOverrideRuleBody",
|
|
1343
|
+
"summary": "The full response body of one override rule, for surfaces the snapshot withheld it from.",
|
|
1344
|
+
"params": {
|
|
1345
|
+
"type": "object",
|
|
1346
|
+
"properties": {
|
|
1347
|
+
"id": {
|
|
1348
|
+
"type": "string",
|
|
1349
|
+
"description": "Override rule id (e.g. `ovr_ltx4k2_1`), from listOverrideRules."
|
|
1350
|
+
}
|
|
1351
|
+
},
|
|
1352
|
+
"required": ["id"],
|
|
1353
|
+
"additionalProperties": false
|
|
1354
|
+
},
|
|
1355
|
+
"effect": "read",
|
|
1356
|
+
"release": "works",
|
|
1357
|
+
"description": "Returns {body:string|null}, or null when no rule has that id. Needed because rule bodies over 16KB are stripped from every snapshot and flagged with bodyOmitted:true — a rule seeded from a real response (fromRequestId) is routinely hundreds of KB. Reads the raw rule list, so it works even while the master switch is off."
|
|
1358
|
+
}, {
|
|
1359
|
+
"action": "debugOverrides",
|
|
1360
|
+
"summary": "Why a rule is or isn't firing — and the ONLY action that reveals whether overrides can work in this build at all.",
|
|
1361
|
+
"params": {
|
|
1362
|
+
"type": "object",
|
|
1363
|
+
"properties": {
|
|
1364
|
+
"url": {
|
|
1365
|
+
"type": "string",
|
|
1366
|
+
"description": "Concrete URL to test the rules against, query string included. Defaults to 'https://example.com/', which matches nothing useful — always pass the real URL you expect to be overridden."
|
|
1367
|
+
}
|
|
1368
|
+
},
|
|
1369
|
+
"additionalProperties": false
|
|
1370
|
+
},
|
|
1371
|
+
"effect": "read",
|
|
1372
|
+
"release": "works",
|
|
1373
|
+
"description": "Probes the engine against one URL (method is always GET) without recording a hit, so it never burns a rule's `times` budget or nudges an alternating rule's phase. Returns {interceptorInstalled, listenerCount, engine:{hooksInstalled, devFlag, ruleCount, matches}, store:{enabled, ruleCount, rulesVisibleToEngine}}. READ engine.devFlag FIRST: if it is `false`, this is a release build and NO rule will ever be applied to real traffic no matter what the add/enable actions reported. `rulesVisibleToEngine` < `ruleCount` means the master switch is off. `matches:false` almost always means the glob missed the query string — a pattern must match the WHOLE url. Caution: unlike getCaptureStatus this probes the listener unguarded, so it throws in a runtime with no XMLHttpRequest."
|
|
1374
|
+
}, {
|
|
1375
|
+
"action": "upsertOverrideRule",
|
|
1376
|
+
"summary": "Create (or replace by id) a rule that forces matching requests to return a chosen status/body, fail outright, or arrive late.",
|
|
1377
|
+
"params": {
|
|
1378
|
+
"type": "object",
|
|
1379
|
+
"properties": {
|
|
1380
|
+
"fromRequestId": {
|
|
1381
|
+
"type": "string",
|
|
1382
|
+
"description": "Build the rule from a captured request id — seeds urlPattern (query string replaced with `*`), method, status and the REAL response body. Anything you send explicitly wins over the prefill."
|
|
1383
|
+
},
|
|
1384
|
+
"rule": {
|
|
1385
|
+
"type": "object",
|
|
1386
|
+
"description": "The rule itself. Same fields are accepted flat at the top level, but send them here.",
|
|
1387
|
+
"properties": {
|
|
1388
|
+
"id": {
|
|
1389
|
+
"type": "string",
|
|
1390
|
+
"description": "Replace this existing rule instead of creating one. Omit to create."
|
|
1391
|
+
},
|
|
1392
|
+
"urlPattern": {
|
|
1393
|
+
"type": "string",
|
|
1394
|
+
"description": "Glob matched against the WHOLE request URL, query string included. `*` is the only wildcard, e.g. '*pokeapi.co*'. Required unless fromRequestId supplies it."
|
|
1395
|
+
},
|
|
1396
|
+
"enabled": {
|
|
1397
|
+
"type": "boolean",
|
|
1398
|
+
"description": "Default true; only an explicit false creates the rule switched off. An enabled rule also forces the master switch ON."
|
|
1399
|
+
},
|
|
1400
|
+
"name": {
|
|
1401
|
+
"type": "string",
|
|
1402
|
+
"description": "Label shown in the developer's rule list."
|
|
1403
|
+
},
|
|
1404
|
+
"methods": {
|
|
1405
|
+
"type": "array",
|
|
1406
|
+
"items": {
|
|
1407
|
+
"type": "string"
|
|
1408
|
+
},
|
|
1409
|
+
"description": "Restrict to these HTTP methods (upper-cased for you). Omit for any method."
|
|
1410
|
+
},
|
|
1411
|
+
"kind": {
|
|
1412
|
+
"type": "string",
|
|
1413
|
+
"enum": ["respond", "fail", "delay"],
|
|
1414
|
+
"description": "respond = return your status/body, never hitting the network (default). fail = transport failure, as if offline. delay = run the real request, just late. Anything else falls back to respond."
|
|
1415
|
+
},
|
|
1416
|
+
"status": {
|
|
1417
|
+
"type": "number",
|
|
1418
|
+
"description": "kind 'respond': HTTP status, clamped to 200-599. Use kind 'fail' for connection errors, not status 0."
|
|
1419
|
+
},
|
|
1420
|
+
"statusText": {
|
|
1421
|
+
"type": "string",
|
|
1422
|
+
"description": "kind 'respond': cosmetic only — React Native's XMLHttpRequest has no statusText, so the app never sees it."
|
|
1423
|
+
},
|
|
1424
|
+
"headers": {
|
|
1425
|
+
"type": "object",
|
|
1426
|
+
"description": "kind 'respond': response headers, string values."
|
|
1427
|
+
},
|
|
1428
|
+
"body": {
|
|
1429
|
+
"description": "kind 'respond': the response body. A string is used as-is; an object or array is JSON.stringify'd for you — do not pre-stringify."
|
|
1430
|
+
},
|
|
1431
|
+
"failKind": {
|
|
1432
|
+
"type": "string",
|
|
1433
|
+
"enum": ["timeout"],
|
|
1434
|
+
"description": "kind 'fail': ONLY the literal 'timeout' is honored; anything else (including 'network') means a plain network error."
|
|
1435
|
+
},
|
|
1436
|
+
"delayMs": {
|
|
1437
|
+
"type": "number",
|
|
1438
|
+
"description": "Latency before the outcome, in ms. Works with every kind."
|
|
1439
|
+
},
|
|
1440
|
+
"times": {
|
|
1441
|
+
"type": "number",
|
|
1442
|
+
"description": "Auto-disable after N matches. Use 1 for 'make the next call fail' so it cannot linger. Omitted = unlimited."
|
|
1443
|
+
},
|
|
1444
|
+
"alternate": {
|
|
1445
|
+
"type": "boolean",
|
|
1446
|
+
"description": "Apply to every OTHER matching request: the first goes through, the second is overridden, and so on. How you reach retry/flaky-endpoint paths."
|
|
1447
|
+
},
|
|
1448
|
+
"bodyOmitted": {
|
|
1449
|
+
"type": "boolean",
|
|
1450
|
+
"description": "Wire flag: send true WITH an `id` when re-saving a rule whose body the snapshot withheld, so the stored body is preserved instead of erased."
|
|
1451
|
+
}
|
|
1452
|
+
},
|
|
1453
|
+
"additionalProperties": false
|
|
1454
|
+
}
|
|
1455
|
+
},
|
|
1456
|
+
"additionalProperties": false
|
|
1457
|
+
},
|
|
1458
|
+
"effect": "write",
|
|
1459
|
+
"release": "noop",
|
|
1460
|
+
"description": "The way to drive an app into a state it cannot reach on its own: force a 500 and check the error UI, return [] and check the empty state, simulate offline or a slow connection. Rules run INSIDE the app, are persisted, and re-apply on every launch — so they cover the boot path too. Send the fields under `rule` (the adapter also accepts them flat, reading `params.rule ?? params`). `urlPattern` is the one field with no default: without it, and without a `fromRequestId` that resolves, the call is rejected with {ok:false,error}. Prefer `fromRequestId` over pasting a URL — it rewrites the query string to `*`, which is the difference between a rule that fires and one that sits at 0 hits against a cache-busting client. Returns a RECEIPT, not the rule: {ok:true, rule:{id, urlPattern, enabled, kind, status, bodySize}}, or {ok:false,error} on a bad pattern, an unresolvable fromRequestId, or the 50-rule ceiling. Side effect: if the new rule is enabled, the MASTER SWITCH is forced ON, arming every other enabled rule too. ALWAYS delete your rules when done — a forgotten rule is indistinguishable from a real bug, and a body the app cannot render can wedge it across restarts (the 3-launch auto-pause guard exists because that once required deleting the app's data).",
|
|
1461
|
+
"releaseNote": "packages/network/src/network/overrides/engine.ts:74 — overrideForRequest returns null when __DEV__ === false. The rule is still created, persisted and listed, and this action still returns ok:true, but it is NEVER applied to real traffic in a release build. Confirm with debugOverrides → engine.devFlag before telling anyone the override took effect.",
|
|
1462
|
+
"requires": ["a __DEV__ build for the rule to actually apply", "something holding the interceptor open (an enabled rule pins it itself)", "Buoy Pro only to keep more than 1 rule — and only in the on-device UI; this action is not license-gated"]
|
|
1463
|
+
}, {
|
|
1464
|
+
"action": "setOverrideRuleEnabled",
|
|
1465
|
+
"summary": "Arm or silence ONE override rule by id, keeping the rule.",
|
|
1466
|
+
"params": {
|
|
1467
|
+
"type": "object",
|
|
1468
|
+
"properties": {
|
|
1469
|
+
"id": {
|
|
1470
|
+
"type": "string",
|
|
1471
|
+
"description": "Override rule id from listOverrideRules."
|
|
1472
|
+
},
|
|
1473
|
+
"enabled": {
|
|
1474
|
+
"type": "boolean",
|
|
1475
|
+
"description": "true arms this rule. false OR OMITTED disables it — always send this explicitly."
|
|
1476
|
+
}
|
|
1477
|
+
},
|
|
1478
|
+
"required": ["id"],
|
|
1479
|
+
"additionalProperties": false
|
|
1480
|
+
},
|
|
1481
|
+
"effect": "write",
|
|
1482
|
+
"release": "noop",
|
|
1483
|
+
"description": "Flips a single rule's enabled flag without touching the other rules or the master switch. Use it to stage a rule and arm it later, or to silence one you want to keep. WARNING on optionality: `enabled` is read with a truthiness cast, so omitting it means DISABLE. Returns {ok:true} even when no rule has that id — it does not verify; confirm with listOverrideRules. Returns {ok:false,error:\"Missing rule id.\"} when `id` is absent. Note that arming a rule here does NOT turn the master switch on (unlike upsertOverrideRule), so a rule can read as enabled and still be dark.",
|
|
1484
|
+
"releaseNote": "packages/network/src/network/overrides/engine.ts:74 — overrideForRequest returns null when __DEV__ === false, so arming a rule in a release build changes the flag and nothing else. The action still returns ok:true.",
|
|
1485
|
+
"requires": ["a __DEV__ build for the rule to actually apply"],
|
|
1486
|
+
"undo": {
|
|
1487
|
+
"action": "setOverrideRuleEnabled",
|
|
1488
|
+
"note": "Call again with the same id and the inverted `enabled` value."
|
|
1489
|
+
}
|
|
1490
|
+
}, {
|
|
1491
|
+
"action": "setOverridesEnabled",
|
|
1492
|
+
"summary": "Master switch for ALL override rules — off silences every rule without deleting any.",
|
|
1493
|
+
"params": {
|
|
1494
|
+
"type": "object",
|
|
1495
|
+
"properties": {
|
|
1496
|
+
"enabled": {
|
|
1497
|
+
"type": "boolean",
|
|
1498
|
+
"description": "true arms all enabled rules. false OR OMITTED silences every rule (rules are kept) — always send this explicitly."
|
|
1499
|
+
}
|
|
1500
|
+
},
|
|
1501
|
+
"required": [],
|
|
1502
|
+
"additionalProperties": false
|
|
1503
|
+
},
|
|
1504
|
+
"effect": "write",
|
|
1505
|
+
"release": "noop",
|
|
1506
|
+
"description": "One call changes how every matching request in the app behaves, so treat it as a broad-blast action rather than a toggle. WARNING on optionality: `enabled` is read with a truthiness cast, so calling this with no params DISABLES all overrides. Returns {ok:true, enabled:<the resulting state>} — read that back rather than assuming. This is also the re-arm for the auto-pause state: when listOverrideRules reports autoPaused:true the launch guard turned overrides off by itself after 3 launches with rules armed and untouched, and only this puts them back. Individual rules keep their own enabled flags underneath.",
|
|
1507
|
+
"releaseNote": "packages/network/src/network/overrides/engine.ts:74 — overrideForRequest returns null when __DEV__ === false, so flipping the master switch in a release build changes nothing about real traffic in either direction. The action still returns ok:true with the new flag.",
|
|
1508
|
+
"requires": ["a __DEV__ build for rules to actually apply"],
|
|
1509
|
+
"undo": {
|
|
1510
|
+
"action": "setOverridesEnabled",
|
|
1511
|
+
"note": "Call again with the inverted `enabled` value. Per-rule enabled flags are untouched either way."
|
|
1512
|
+
}
|
|
1513
|
+
}, {
|
|
1514
|
+
"action": "deleteOverrideRule",
|
|
1515
|
+
"summary": "Delete one override rule by id — the undo for upsertOverrideRule.",
|
|
1516
|
+
"params": {
|
|
1517
|
+
"type": "object",
|
|
1518
|
+
"properties": {
|
|
1519
|
+
"id": {
|
|
1520
|
+
"type": "string",
|
|
1521
|
+
"description": "Override rule id from listOverrideRules or from an upsertOverrideRule receipt."
|
|
1522
|
+
}
|
|
1523
|
+
},
|
|
1524
|
+
"required": ["id"],
|
|
1525
|
+
"additionalProperties": false
|
|
1526
|
+
},
|
|
1527
|
+
"effect": "destructive",
|
|
1528
|
+
"release": "works",
|
|
1529
|
+
"description": "Removes the rule from the persisted list permanently; there is no recovery, and a rule seeded from a real response cannot be reconstructed without that request still being in the store. This is what you call to clean up after driving a test — always remove rules you added, since a forgotten rule looks exactly like a real bug. Returns {ok:true} even when no rule has that id (it does not verify), or {ok:false,error:\"Missing rule id.\"} when `id` is absent."
|
|
1530
|
+
}, {
|
|
1531
|
+
"action": "clearOverrideRules",
|
|
1532
|
+
"summary": "Delete EVERY override rule; traffic becomes real again.",
|
|
1533
|
+
"params": {
|
|
1534
|
+
"type": "object",
|
|
1535
|
+
"properties": {},
|
|
1536
|
+
"additionalProperties": false
|
|
1537
|
+
},
|
|
1538
|
+
"effect": "destructive",
|
|
1539
|
+
"release": "works",
|
|
1540
|
+
"description": "Wipes the whole persisted rule list at once — including rules a human authored and is mid-investigation on, which cannot be recovered. Prefer deleteOverrideRule({id}) for rules you created yourself, or setOverridesEnabled({enabled:false}) when you only need to silence them while keeping them. Returns {ok:true}."
|
|
1541
|
+
}],
|
|
1542
|
+
"unavailableWhen": "The app does not mount the network tool (the @buoy-gg/network preset is absent from the FloatingDevTools apps list), or the runtime has no XMLHttpRequest/fetch to intercept (Node/headless/desktop-mirror — there getCaptureStatus reports interceptorInstalled:null and debugOverrides throws). The override actions are additionally Pro-gated by the MCP/dashboard layer (requireProDevice)."
|
|
1543
|
+
}, {
|
|
1544
|
+
"toolId": "js-top",
|
|
1545
|
+
"title": "JS TOP (JS-thread task manager)",
|
|
1546
|
+
"summary": "Task Manager for the React Native JS thread: it wraps timers/rAF/microtasks/Promise reactions/legacy-bridge call-ins and books exclusive ms per scheduling origin, plus a calibrated busy probe (thread busy%) and PerformanceObserver('longtask') freeze attribution (blocks >=50ms). Reach for it when the app feels laggy/janky and you need to know WHAT is eating the JS thread (a forgotten setInterval, a hot rAF loop, a chatty Promise chain). `sample` is the main entry point — everything else is control (setEnabled/pause/resume/clear) or drill-down (getOriginDetail). NOT for \"components re-render too often\" — this measures TASKS, not renders.",
|
|
1547
|
+
"actions": [{
|
|
1548
|
+
"action": "sample",
|
|
1549
|
+
"summary": "Run the engine for a few seconds and return the ranked JS-thread task table (busy%, per-origin ms, freezes). This is the action to start with.",
|
|
1550
|
+
"params": {
|
|
1551
|
+
"type": "object",
|
|
1552
|
+
"properties": {
|
|
1553
|
+
"durationMs": {
|
|
1554
|
+
"type": "number",
|
|
1555
|
+
"description": "How long to observe before returning the table. Default 3000; clamped to 250-30000. The call blocks for this long."
|
|
1556
|
+
},
|
|
1557
|
+
"clearFirst": {
|
|
1558
|
+
"type": "boolean",
|
|
1559
|
+
"description": "Reset the whole task table, busy history and freeze history before sampling, so the result attributes exactly this window/interaction. Destructive: prior measurements are gone. Default false."
|
|
1560
|
+
}
|
|
1561
|
+
},
|
|
1562
|
+
"required": [],
|
|
1563
|
+
"additionalProperties": false
|
|
1564
|
+
},
|
|
1565
|
+
"effect": "write",
|
|
1566
|
+
"release": "works",
|
|
1567
|
+
"description": "Self-arming one-shot measurement — the only action that works on a cold device. Ensures sampling is on (turning it on itself if nobody is watching), waits durationMs, returns a JsTopSnapshot, then restores the previous sampling state. Snapshot shape: { ts, paused, busyPct, busyHistory, tiers:{timers,promises,bridge,longtask}, rows[], totals:{attributedWindowMs,unattributedWindowMs,windowMs}, freezeSummary:{count,worstMs}, longtasks[] }. Each row = { key, label, api, windowMs, pctOfBusy, totalMs, calls, avgMs, maxMs, lastSeenAgoMs, freezes, worstFreezeMs } with api one of timeout|interval|immediate|raf|microtask|then|bridge|other|unattributed. IMPORTANT window semantics: busyPct, windowMs and pctOfBusy describe only the TRAILING 5s (20 x 250ms buckets), so a durationMs above 5000 does not widen them — only totalMs/calls accumulate across the whole sample (since engine start or last clear). A pinned \"unattributed\" row absorbs event/React/native-call-in work; on New Architecture devices tiers.bridge is false and that row is expected to be large. freezeSummary covers a trailing 60s window. To profile one specific interaction, pass clearFirst:true and drive the UI (tap_element / run_flow) while the sample runs. Blocks for the full durationMs.",
|
|
1568
|
+
"requires": ["@buoy-gg/js-top installed and imported in the app", "device connected to the Buoy broker (external sync)"]
|
|
1569
|
+
}, {
|
|
1570
|
+
"action": "getOriginDetail",
|
|
1571
|
+
"summary": "Drill into one task origin: schedules vs runs, avg/max/total ms, a 5s 250ms-bucket activity histogram, freeze attribution, and the captured scheduling stack.",
|
|
1572
|
+
"params": {
|
|
1573
|
+
"type": "object",
|
|
1574
|
+
"properties": {
|
|
1575
|
+
"key": {
|
|
1576
|
+
"type": "string",
|
|
1577
|
+
"description": "Origin key from a snapshot row, e.g. \"interval|pollFeed\", \"raf|anonymous\", or the literal \"unattributed\" system row. Required — a missing key throws."
|
|
1578
|
+
}
|
|
1579
|
+
},
|
|
1580
|
+
"required": ["key"],
|
|
1581
|
+
"additionalProperties": false
|
|
1582
|
+
},
|
|
1583
|
+
"effect": "read",
|
|
1584
|
+
"release": "works",
|
|
1585
|
+
"description": "Pass a `key` copied from a snapshot row. Keys are either the cheap form `${api}|${functionName}` (e.g. \"interval|pollFeed\", \"timeout|anonymous\") or the refined form `${api}|${name}@${caller}:${file}:${line}`, plus the literal \"unattributed\" for the pinned system row (which returns a synthetic detail built from busy-probe residuals). Returns { key, label, api, caller?, file?, line?, frames?, promoted, scheduleCount, calls, totalMs, avgMs, maxMs, windowMs, pctOfBusy, lastSeenAgoMs, buckets[20], freezes, worstFreezeMs }, or NULL when the key was never tracked, was cleared, or was evicted (the registry caps at 500 origins and evicts the coldest) — re-sample for current keys. caller/file/line only appear once an origin is \"promoted\" (25+ schedules or 50ms+ total; intervals capture eagerly). In a RELEASE build the file/line/component labels are absent: source symbolication posts to Metro's /symbolicate and is hard-gated on __DEV__ (packages/js-top/src/js-top/engine/symbolicate.ts:42 `if (!isDev()) return;`), so labels stay as raw Hermes function names. All numeric stats are still correct.",
|
|
1586
|
+
"requires": ["@buoy-gg/js-top installed and imported in the app", "the engine must have been recording — call `sample` (or setEnabled true) first", "a __DEV__ build with Metro reachable for symbolicated labels/file/line — in a release build origins come back as raw unsymbolicated frames"],
|
|
1587
|
+
"armsCapture": true
|
|
1588
|
+
}, {
|
|
1589
|
+
"action": "setEnabled",
|
|
1590
|
+
"summary": "Turn silent remote sampling on/off — runs the accounting engine with no visible change on the device.",
|
|
1591
|
+
"params": {
|
|
1592
|
+
"type": "object",
|
|
1593
|
+
"properties": {
|
|
1594
|
+
"enabled": {
|
|
1595
|
+
"type": "boolean",
|
|
1596
|
+
"description": "true = start silent remote sampling (engine runs, device UI unchanged); false = stop it. Required — the handler reads params.enabled with no guard, so omitting params throws."
|
|
1597
|
+
}
|
|
1598
|
+
},
|
|
1599
|
+
"required": ["enabled"],
|
|
1600
|
+
"additionalProperties": false
|
|
1601
|
+
},
|
|
1602
|
+
"effect": "write",
|
|
1603
|
+
"release": "works",
|
|
1604
|
+
"description": "Calls JsTopController.setRemoteSampling(enabled). enabled:true starts the engine (installs timer/microtask/Promise/bridge patches on first start, starts the busy probe) and keeps it running across calls so snapshots stay live; enabled:false stops it. This does NOT show or hide the device's own HUD pill — that is a separate on-device toggle not exposed over the wire. Use this when you want the engine warm across several MCP calls; prefer `sample` for a single measurement, since it arms and disarms itself. Leaving remote sampling on costs continuous instrumentation, so turn it back off when done.",
|
|
1605
|
+
"requires": ["@buoy-gg/js-top installed and imported in the app"],
|
|
1606
|
+
"armsCapture": true
|
|
1607
|
+
}, {
|
|
1608
|
+
"action": "pause",
|
|
1609
|
+
"summary": "Freeze task accounting while keeping the collected table intact.",
|
|
1610
|
+
"params": {
|
|
1611
|
+
"type": "object",
|
|
1612
|
+
"properties": {},
|
|
1613
|
+
"additionalProperties": false
|
|
1614
|
+
},
|
|
1615
|
+
"effect": "write",
|
|
1616
|
+
"release": "works",
|
|
1617
|
+
"description": "Sets the engine inactive and stops the busy probe, so no new time is booked to any origin; the existing rows, totals and freeze history are preserved and the snapshot reports paused:true. No-op if already paused. Use it to hold a table steady while reading it. Note: a paused engine also means a subsequent `sample` observes nothing until you `resume` — `sample` does not auto-resume.",
|
|
1618
|
+
"requires": ["@buoy-gg/js-top installed and imported in the app"]
|
|
1619
|
+
}, {
|
|
1620
|
+
"action": "resume",
|
|
1621
|
+
"summary": "Resume task accounting after a pause.",
|
|
1622
|
+
"params": {
|
|
1623
|
+
"type": "object",
|
|
1624
|
+
"properties": {},
|
|
1625
|
+
"additionalProperties": false
|
|
1626
|
+
},
|
|
1627
|
+
"effect": "write",
|
|
1628
|
+
"release": "works",
|
|
1629
|
+
"description": "Reactivates booking and restarts the busy probe if the engine is running (i.e. something is subscribed or remote sampling is on). No-op if not currently paused. Accumulated totals continue from where they stopped — the pause gap is not counted as busy time.",
|
|
1630
|
+
"requires": ["@buoy-gg/js-top installed and imported in the app"]
|
|
1631
|
+
}, {
|
|
1632
|
+
"action": "clear",
|
|
1633
|
+
"summary": "Wipe the whole task table, busy history and freeze history — starts a fresh measurement window.",
|
|
1634
|
+
"params": {
|
|
1635
|
+
"type": "object",
|
|
1636
|
+
"properties": {},
|
|
1637
|
+
"additionalProperties": false
|
|
1638
|
+
},
|
|
1639
|
+
"effect": "destructive",
|
|
1640
|
+
"release": "works",
|
|
1641
|
+
"description": "Resets the origin registry (all rows, keys, captured scheduling stacks and totals), the busy/attribution aggregator, and the recorded longtask/freeze entries, then emits an empty snapshot. Irreversible — there is no saved copy, so read anything you need before calling it. Any `key` you were holding for getOriginDetail becomes invalid. Prefer `sample` with clearFirst:true when the goal is to scope a measurement to one interaction, since that clears and re-measures in a single call.",
|
|
1642
|
+
"requires": ["@buoy-gg/js-top installed and imported in the app"]
|
|
1643
|
+
}],
|
|
1644
|
+
"unavailableWhen": "@buoy-gg/js-top is not installed/imported in the app (the adapter is never registered under \"js-top\", so every action is unroutable), or the app is a release build that has not explicitly opted into desktop sync — FloatingDevTools does not dial the broker when __DEV__ is false unless the app passes the allow-in-release externalSync option, so the device never connects and no action arrives at all."
|
|
1645
|
+
}, {
|
|
1646
|
+
"toolId": "app",
|
|
1647
|
+
"title": "App",
|
|
1648
|
+
"summary": "Device-level pseudo-tool from @buoy-gg/core itself (packages/devtools-floating-menu/src/floatingMenu/sync/appSyncAdapter.ts) — registered unconditionally for EVERY connected Buoy app, so it works with no tool package and no native deps installed. Two actions: `ping` (cheap liveness probe that also returns which JS realm is answering) and `reloadApp` (restart the JS bundle). Reach for it to check the app is alive, to learn the dev-server origin, or to reload after an edit / to clear leaked in-memory state before a measurement. Its static snapshot (v3) also reports `{startedAt, devServerOrigin, device:{platform,isTV,isTVOS}, reload:{available,strategy}}` — read `reload.available` before promising a user a reload will work.",
|
|
1649
|
+
"actions": [{
|
|
1650
|
+
"action": "ping",
|
|
1651
|
+
"summary": "Liveness probe: returns {ok:true, startedAt, devServerOrigin}. `startedAt` identifies WHICH JS realm answered — the only reliable way to confirm a reload really happened.",
|
|
1652
|
+
"params": {
|
|
1653
|
+
"type": "object",
|
|
1654
|
+
"properties": {},
|
|
1655
|
+
"required": [],
|
|
1656
|
+
"additionalProperties": false
|
|
1657
|
+
},
|
|
1658
|
+
"effect": "read",
|
|
1659
|
+
"release": "works",
|
|
1660
|
+
"description": "Takes no params. Only a live JS realm can answer, so a timeout means the app is unresponsive or crashed. `startedAt` is the module-load timestamp of this JS realm; it CHANGES across a reload, so the pattern is: ping (remember startedAt) -> reloadApp -> wait ~1.2s -> ping until startedAt differs. A reply carrying the OLD startedAt means the doomed realm answered and the app is not back yet. `devServerOrigin` is the Metro/Expo dev server that served the bundle (e.g. \"http://192.168.1.20:8081\"), or null in a release bundle (file:// scriptURL) and on web — capture it while the app is healthy, because after a crash it is the only remaining way to recover the app.",
|
|
1661
|
+
"requires": ["@buoy-gg/core's <FloatingDevTools /> mounted (headless is fine)", "external-sync socket connected to the broker on :42831"]
|
|
1662
|
+
}, {
|
|
1663
|
+
"action": "reloadApp",
|
|
1664
|
+
"summary": "Restart the app's JS bundle (DevSettings.reload() in dev, expo-updates otherwise). ALL in-memory state is destroyed — read anything you need first.",
|
|
1665
|
+
"params": {
|
|
1666
|
+
"type": "object",
|
|
1667
|
+
"properties": {
|
|
1668
|
+
"strategy": {
|
|
1669
|
+
"type": "string",
|
|
1670
|
+
"enum": ["auto", "dev-settings", "expo-updates"],
|
|
1671
|
+
"description": "Reload mechanism. 'auto' (default) uses React Native's DevSettings.reload() in dev builds and expo-updates otherwise. Force one only when debugging the reload itself; 'dev-settings' throws in a release bundle, 'expo-updates' throws unless the host app installed expo-updates (and always fails in Expo Go)."
|
|
1672
|
+
},
|
|
1673
|
+
"delayMs": {
|
|
1674
|
+
"type": "number",
|
|
1675
|
+
"description": "Milliseconds to wait before the reload actually fires, so this action's result can be flushed over the sync socket first. Default 250 (REMOTE_RELOAD_DELAY_MS); negative values are clamped to 0. Leave unset unless the ack is being lost."
|
|
1676
|
+
}
|
|
1677
|
+
},
|
|
1678
|
+
"required": [],
|
|
1679
|
+
"additionalProperties": false
|
|
1680
|
+
},
|
|
1681
|
+
"effect": "destructive",
|
|
1682
|
+
"release": "throws",
|
|
1683
|
+
"description": "Arms a reload and returns {scheduled:true, strategy, delayMs} BEFORE it fires (default 250ms later), because the realm dies the instant it does. The ack is NOT proof the app came back — confirm with `ping` and a changed `startedAt`. Destroys every in-memory store (redux/zustand/jotai/react-query caches, console buffer, network log, unsaved form state); persisted storage survives. Read get_console / get_network_requests / get_storage BEFORE calling. Params: `strategy` (\"auto\" default — dev-settings in dev builds, expo-updates otherwise; force one only when debugging the reload itself) and `delayMs` (ack-flush window, default 250, clamped to >= 0). Check the snapshot's `reload.available` first: when no mechanism exists this THROWS rather than silently no-op'ing, which is the correct and honest outcome to report.",
|
|
1684
|
+
"releaseNote": "packages/shared/src/utils/reloadApp.ts:80 — `if (__DEV__ && loadDevSettings())` means \"auto\" can only resolve to dev-settings in a dev build; in release it falls through to `loadExpoUpdates()`, which returns null unless the host app installed `expo-updates`. resolveReloadStrategy then returns null and scheduleReloadApp throws at line 140 WITHOUT scheduling anything. So in a release build: works only if expo-updates is installed, otherwise a hard error — never a fake success. Explicit strategy \"dev-settings\" throws in release regardless.",
|
|
1685
|
+
"requires": ["@buoy-gg/core's <FloatingDevTools /> mounted (headless is fine)", "A dev build (for DevSettings.reload) OR the host app has `expo-updates` installed", "Snapshot `reload.available === true` — check before promising a reload"]
|
|
1686
|
+
}],
|
|
1687
|
+
"unavailableWhen": "The app's bundle is a release build (`__DEV__ === false`) that did not opt into `externalSync.enableInRelease` AND hold a real Pro license — FloatingDevTools.tsx:719 then never mounts AutoExternalSync, so no `app` action reaches the device at all. Also effectively unavailable after a fatal render error: the React tree that answers sync actions is gone, so both actions time out (recovery is the dev-server reload path, using a `devServerOrigin` learned from an earlier successful ping). Remote drivers going through the Buoy MCP server additionally hit a Pro gate (SyncClient.requireProDevice: \"Buoy Pro is required to use the MCP\")."
|
|
1688
|
+
}, {
|
|
1689
|
+
"toolId": "time-machine",
|
|
1690
|
+
"title": "Time Machine",
|
|
1691
|
+
"summary": "Save and restore the app's entire client-side state (device storage, redux, zustand, jotai, react-query) as named snapshots (\"restore points\"), plus the route it was captured on. Reach for it to put the app back into a known state before re-running a flow, to preview exactly what a restore would change before committing, or to wipe the app to fresh-install state. Snapshot payloads stay on-device — `list` returns metadata only; use `inspect` for one source's actual captured data and `preview` for item-level diffs.",
|
|
1692
|
+
"actions": [{
|
|
1693
|
+
"action": "list",
|
|
1694
|
+
"summary": "List all restore points plus which state sources can currently be captured/restored and whether the device can navigate routes.",
|
|
1695
|
+
"params": {
|
|
1696
|
+
"type": "object",
|
|
1697
|
+
"properties": {},
|
|
1698
|
+
"additionalProperties": false
|
|
1699
|
+
},
|
|
1700
|
+
"effect": "read",
|
|
1701
|
+
"release": "works",
|
|
1702
|
+
"description": "Same payload as the tool's synced snapshot. Returns { snapshots, busy, lastOutcome, providers, route }. `snapshots[]` = { id (e.g. \"snap_m1x2y3_4\"), name, createdAt, sizeBytes, baseline?, route?:{pathname,href}, restoreRoute?, scope?, excludedKeys?, sources:{ [sourceId]: { warnings:string[] } } } — METADATA ONLY, no captured values. `providers[]` = { id, label, canCapture, canRestore, detail } for the registered sources: \"storage\", \"redux\", \"zustand\", \"jotai\", \"query\" (plus any custom ones the app registered). Read `detail` before reporting a source as broken — e.g. redux says \"Auto-instrumented store cannot be restored\" when the app didn't wrap its root reducer. `route.available` is false on apps without expo-router (those snapshots record no route). `lastOutcome` is the previous restore's per-source result. Call this first — every other action needs an `id` from here.",
|
|
1703
|
+
"requires": ["@buoy-gg/time-machine registered in FloatingDevTools"]
|
|
1704
|
+
}, {
|
|
1705
|
+
"action": "capture",
|
|
1706
|
+
"summary": "Save the app's current state as a new restore point and return its metadata.",
|
|
1707
|
+
"params": {
|
|
1708
|
+
"type": "object",
|
|
1709
|
+
"properties": {
|
|
1710
|
+
"name": {
|
|
1711
|
+
"type": "string",
|
|
1712
|
+
"description": "Label for the restore point. Omit or pass \"\" to auto-name it \"<current pathname> · HH:MM\"."
|
|
1713
|
+
},
|
|
1714
|
+
"sourceIds": {
|
|
1715
|
+
"type": "array",
|
|
1716
|
+
"items": {
|
|
1717
|
+
"type": "string"
|
|
1718
|
+
},
|
|
1719
|
+
"description": "Capture only these source ids (\"storage\", \"redux\", \"zustand\", \"jotai\", \"query\"). Omit to capture every capturable source."
|
|
1720
|
+
}
|
|
1721
|
+
},
|
|
1722
|
+
"additionalProperties": false
|
|
1723
|
+
},
|
|
1724
|
+
"effect": "write",
|
|
1725
|
+
"release": "works",
|
|
1726
|
+
"description": "Captures every source whose provider reports canCapture (or only `sourceIds` if given), serializes it into the on-device vault (@react_buoy_time_machine:snap:<id>), and stamps the current route on it. Returns { captured:true, snapshot: { id, name, sizeBytes, route?, sources:{[id]:{warnings}} } }. ALWAYS surface `sources[*].warnings` — capture is lossy by design in known places (MMKV ArrayBuffer values, biometric-protected SecureStore keys, unregistered SecureStore keys) and a per-source `capture failed: ...` warning is how a silently-empty source shows up. An empty/omitted `name` auto-names it \"<pathname> · HH:MM\". Does NOT modify app state. Do this before driving a flow you want to re-run.",
|
|
1727
|
+
"requires": ["at least one state source registered (storage/redux/zustand/jotai/react-query package installed and instrumented)", "expo-router for the route stamp (optional)"]
|
|
1728
|
+
}, {
|
|
1729
|
+
"action": "captureBaseline",
|
|
1730
|
+
"summary": "Create an EMPTY 'Fresh install' restore point whose later restore wipes app state and reloads.",
|
|
1731
|
+
"params": {
|
|
1732
|
+
"type": "object",
|
|
1733
|
+
"properties": {},
|
|
1734
|
+
"additionalProperties": false
|
|
1735
|
+
},
|
|
1736
|
+
"effect": "write",
|
|
1737
|
+
"release": "works",
|
|
1738
|
+
"description": "Takes no reading of current state — it writes a zero-byte snapshot named \"Fresh install\" with baseline:true. Safe and non-destructive by itself; the destruction happens later when you `restore` its id (which clears every clearable source and reloads). Returns { captured:true, snapshot }. Use it to give a QA user a one-tap 'back to fresh install' point.",
|
|
1739
|
+
"requires": ["@buoy-gg/time-machine registered in FloatingDevTools"]
|
|
1740
|
+
}, {
|
|
1741
|
+
"action": "wipeAll",
|
|
1742
|
+
"summary": "DESTRUCTIVE: clear every clearable state source to fresh-install state, then reload the app. No snapshot involved and no undo.",
|
|
1743
|
+
"params": {
|
|
1744
|
+
"type": "object",
|
|
1745
|
+
"properties": {},
|
|
1746
|
+
"additionalProperties": false
|
|
1747
|
+
},
|
|
1748
|
+
"effect": "destructive",
|
|
1749
|
+
"release": "works",
|
|
1750
|
+
"description": "Calls clear() on every registered provider (app AsyncStorage/MMKV/SecureStore keys, redux, zustand, jotai, react-query cache — Buoy's own @react_buoy* keys are excluded) and then schedules a JS bundle reload. Returns a RestoreOutcome { snapshotId:\"wipe-all\", willReload, results:{[sourceId]:{ok,applied,skipped,warnings}} }. There is NO undo — take a `capture` first if the state matters. CHECK `willReload` in the result before telling anyone the app restarted (see releaseNote).",
|
|
1751
|
+
"requires": ["expo-updates installed for the reload leg to work in a release build"]
|
|
1752
|
+
}, {
|
|
1753
|
+
"action": "restore",
|
|
1754
|
+
"summary": "DESTRUCTIVE: put the app back into a snapshot's state, overwriting and deleting live state. Run `preview` first.",
|
|
1755
|
+
"params": {
|
|
1756
|
+
"type": "object",
|
|
1757
|
+
"properties": {
|
|
1758
|
+
"id": {
|
|
1759
|
+
"type": "string",
|
|
1760
|
+
"description": "Snapshot id from `list`, e.g. \"snap_m1x2y3_4\". Required."
|
|
1761
|
+
},
|
|
1762
|
+
"mode": {
|
|
1763
|
+
"type": "string",
|
|
1764
|
+
"enum": ["live", "reload"],
|
|
1765
|
+
"description": "\"live\" (default) replaces state in place; \"reload\" restores persisted storage only and reloads the JS bundle. Baseline snapshots always reload regardless."
|
|
1766
|
+
},
|
|
1767
|
+
"excludeKeys": {
|
|
1768
|
+
"type": "array",
|
|
1769
|
+
"items": {
|
|
1770
|
+
"type": "string"
|
|
1771
|
+
},
|
|
1772
|
+
"description": "Items to LEAVE UNTOUCHED, as compound \"sourceId::itemKey\" strings using the item keys from `preview`: storage::async:@cart, storage::mmkv:<instanceId>/<key>, storage::secure:<key>, redux::<sliceName>, zustand::<storeName>, jotai::<atomLabel>, query::<queryHash> (e.g. query::[\"pokemon\"]). Passing this array overrides — and disables — the snapshot's saved exclusions and saved scope."
|
|
1773
|
+
},
|
|
1774
|
+
"restoreRoute": {
|
|
1775
|
+
"type": "boolean",
|
|
1776
|
+
"description": "Also navigate back to the route the snapshot was captured on. OMITTING THIS IS NOT false — it falls back to the snapshot's saved setting, which is ON whenever the snapshot has a route. Pass false to force the app to stay on the current screen. With mode \"reload\" the navigation is deferred to the next boot (status \"deferred\", 60s TTL)."
|
|
1777
|
+
}
|
|
1778
|
+
},
|
|
1779
|
+
"required": ["id"],
|
|
1780
|
+
"additionalProperties": false
|
|
1781
|
+
},
|
|
1782
|
+
"effect": "destructive",
|
|
1783
|
+
"release": "works",
|
|
1784
|
+
"description": "Requires `id` (throws \"restore requires a snapshot `id`\" without it). mode \"live\" (default) replaces state in place source by source in a fixed order (storage → redux → zustand → jotai → query); mode \"reload\" restores ONLY persisted storage and then reloads the JS bundle so in-memory stores rebuild themselves. A baseline snapshot ALWAYS takes the clear+reload path regardless of mode. Storage restore is a diff — app keys missing from the snapshot are DELETED. Returns a RestoreOutcome: read `results[sourceId].applied` / `.skipped[].reason` / `.warnings` rather than assuming success (a source with canRestore:false comes back skipped with a reason, not an error), and read `willReload` and `route.status` (\"navigated\" | \"deferred\" | \"failed\" | \"unavailable\"). TWO GOTCHAS: (1) omitting `excludeKeys` makes the snapshot's own SAVED excludedKeys apply, and a saved `scope` narrows the restore to only the scoped items — passing an explicit `excludeKeys` array disables that saved scope. (2) omitting `restoreRoute` does NOT mean false: it falls back to the snapshot's own setting, which is ON whenever the snapshot captured a route. Pass restoreRoute:false to keep the app on the current screen.",
|
|
1785
|
+
"requires": ["a source's provider must report canRestore:true or that source is skipped with a reason", "expo-router for the route leg", "expo-updates for the reload leg in a release build"]
|
|
1786
|
+
}, {
|
|
1787
|
+
"action": "preview",
|
|
1788
|
+
"summary": "Compute the exact blast radius of restoring a snapshot — per-item added/removed/changed/wont-apply verdicts — WITHOUT changing anything.",
|
|
1789
|
+
"params": {
|
|
1790
|
+
"type": "object",
|
|
1791
|
+
"properties": {
|
|
1792
|
+
"id": {
|
|
1793
|
+
"type": "string",
|
|
1794
|
+
"description": "The snapshot being previewed (the restore target). Required."
|
|
1795
|
+
},
|
|
1796
|
+
"compareTo": {
|
|
1797
|
+
"type": "string",
|
|
1798
|
+
"description": "Another snapshot id to diff against instead of the app's live current state. Omit to compare against live state — which is what a restore would actually overwrite."
|
|
1799
|
+
}
|
|
1800
|
+
},
|
|
1801
|
+
"required": ["id"],
|
|
1802
|
+
"additionalProperties": false
|
|
1803
|
+
},
|
|
1804
|
+
"effect": "read",
|
|
1805
|
+
"release": "works",
|
|
1806
|
+
"description": "Requires `id` (throws \"preview requires a snapshot `id`\"). Diffs the snapshot against the LIVE current state, or against another snapshot when `compareTo` is given. Returns { left, right, builtAt, sources:[{ id, label, notes:string[], error?, items:[{ key, label, verdict, reason?, oldValue?, newValue?, valueOmitted? }] }] }. Verdicts are the whole point: \"added\"/\"removed\"/\"changed\"/\"too-large\" WILL be applied by a restore; \"unchanged\" and \"recomputes\" won't; \"wont-apply\" means a real difference exists that restore CANNOT apply and `reason` says why (e.g. an auto-instrumented redux store, a tool that isn't installed) — surfacing that reason is more useful than the count. `key` values are exactly what `restore`'s excludeKeys / `setScope` / `setExclusions` want, once prefixed with \"<sourceId>::\". Values larger than ~4KB are stripped and flagged valueOmitted:true, so an empty oldValue/newValue does not mean the value was empty. Side effect: it rebuilds the on-device preview panel, replacing whatever a human has open in the Time Machine UI. Always run this before a `restore` you cannot undo.",
|
|
1807
|
+
"requires": ["@buoy-gg/time-machine registered in FloatingDevTools"]
|
|
1808
|
+
}, {
|
|
1809
|
+
"action": "inspect",
|
|
1810
|
+
"summary": "Read back one source's actual captured payload from a snapshot, deserialized.",
|
|
1811
|
+
"params": {
|
|
1812
|
+
"type": "object",
|
|
1813
|
+
"properties": {
|
|
1814
|
+
"id": {
|
|
1815
|
+
"type": "string",
|
|
1816
|
+
"description": "Snapshot id from `list`. Required."
|
|
1817
|
+
},
|
|
1818
|
+
"sourceId": {
|
|
1819
|
+
"type": "string",
|
|
1820
|
+
"description": "Which captured source to read: \"storage\", \"redux\", \"zustand\", \"jotai\", \"query\", or a custom provider id. Must be a key of that snapshot's `sources` — required."
|
|
1821
|
+
}
|
|
1822
|
+
},
|
|
1823
|
+
"required": ["id", "sourceId"],
|
|
1824
|
+
"additionalProperties": false
|
|
1825
|
+
},
|
|
1826
|
+
"effect": "read",
|
|
1827
|
+
"release": "works",
|
|
1828
|
+
"description": "Requires both `id` and `sourceId` (throws \"inspect requires `id` and `sourceId`\"; also throws when the snapshot doesn't exist or never captured that source). Returns { id, sourceId, warnings:string[], data } where `data` is the source's full captured tree with Date/Map/Set rehydrated. This is the only way to see actual snapshot VALUES — `list` is metadata-only. Storage `data` is shaped { async:[key,value][], mmkv:{[instanceId]:[{key,value,valueType}]}, secure:{[key]:string|null} }. Payloads can be large (up to the 8MB action budget), so ask for one source at a time.",
|
|
1829
|
+
"requires": ["@buoy-gg/time-machine registered in FloatingDevTools"]
|
|
1830
|
+
}, {
|
|
1831
|
+
"action": "delete",
|
|
1832
|
+
"summary": "DESTRUCTIVE: permanently remove a restore point from the device vault. No remote undo.",
|
|
1833
|
+
"params": {
|
|
1834
|
+
"type": "object",
|
|
1835
|
+
"properties": {
|
|
1836
|
+
"id": {
|
|
1837
|
+
"type": "string",
|
|
1838
|
+
"description": "Snapshot id from `list`. Required."
|
|
1839
|
+
}
|
|
1840
|
+
},
|
|
1841
|
+
"required": ["id"],
|
|
1842
|
+
"additionalProperties": false
|
|
1843
|
+
},
|
|
1844
|
+
"effect": "destructive",
|
|
1845
|
+
"release": "works",
|
|
1846
|
+
"description": "Requires `id` (throws \"delete requires a snapshot `id`\"). Removes the snapshot's vault row and index entry. Returns { deleted:true, id }. The device keeps one in-memory copy so a HUMAN can tap Undo in the Time Machine UI, but that undo is NOT exposed as an action — over the wire this is irreversible. A restore point can represent state that took ten minutes and a cooperative backend to build; confirm with the user before calling.",
|
|
1847
|
+
"requires": ["@buoy-gg/time-machine registered in FloatingDevTools"]
|
|
1848
|
+
}, {
|
|
1849
|
+
"action": "rename",
|
|
1850
|
+
"summary": "Change a restore point's display name.",
|
|
1851
|
+
"params": {
|
|
1852
|
+
"type": "object",
|
|
1853
|
+
"properties": {
|
|
1854
|
+
"id": {
|
|
1855
|
+
"type": "string",
|
|
1856
|
+
"description": "Snapshot id from `list`. Required."
|
|
1857
|
+
},
|
|
1858
|
+
"name": {
|
|
1859
|
+
"type": "string",
|
|
1860
|
+
"description": "New display name. Required and must be non-empty."
|
|
1861
|
+
}
|
|
1862
|
+
},
|
|
1863
|
+
"required": ["id", "name"],
|
|
1864
|
+
"additionalProperties": false
|
|
1865
|
+
},
|
|
1866
|
+
"effect": "write",
|
|
1867
|
+
"release": "works",
|
|
1868
|
+
"description": "Requires BOTH `id` and a non-empty `name` (throws \"rename requires `id` and `name`\"). Metadata only — the captured payload is untouched. Returns { renamed:true, id, name }.",
|
|
1869
|
+
"requires": ["@buoy-gg/time-machine registered in FloatingDevTools"]
|
|
1870
|
+
}, {
|
|
1871
|
+
"action": "duplicate",
|
|
1872
|
+
"summary": "Copy a restore point (payload included) so a variation can branch off a known-good base.",
|
|
1873
|
+
"params": {
|
|
1874
|
+
"type": "object",
|
|
1875
|
+
"properties": {
|
|
1876
|
+
"id": {
|
|
1877
|
+
"type": "string",
|
|
1878
|
+
"description": "Snapshot id to copy, from `list`. Required."
|
|
1879
|
+
}
|
|
1880
|
+
},
|
|
1881
|
+
"required": ["id"],
|
|
1882
|
+
"additionalProperties": false
|
|
1883
|
+
},
|
|
1884
|
+
"effect": "write",
|
|
1885
|
+
"release": "works",
|
|
1886
|
+
"description": "Requires `id` (throws \"duplicate requires `id`\"; throws `No snapshot with id \"...\"` if it isn't in the vault). Creates a full copy named \"<original name> copy\" with a NEW id and returns { snapshot }. Does not touch app state. Use it before editing a point's scope/exclusions so the original stays intact.",
|
|
1887
|
+
"requires": ["@buoy-gg/time-machine registered in FloatingDevTools"]
|
|
1888
|
+
}, {
|
|
1889
|
+
"action": "setExclusions",
|
|
1890
|
+
"summary": "Persist the items every FUTURE restore of this snapshot must leave untouched (the saved form of the preview's unchecked boxes).",
|
|
1891
|
+
"params": {
|
|
1892
|
+
"type": "object",
|
|
1893
|
+
"properties": {
|
|
1894
|
+
"id": {
|
|
1895
|
+
"type": "string",
|
|
1896
|
+
"description": "Snapshot id from `list`. Required."
|
|
1897
|
+
},
|
|
1898
|
+
"excludedKeys": {
|
|
1899
|
+
"items": {
|
|
1900
|
+
"type": "string"
|
|
1901
|
+
},
|
|
1902
|
+
"description": "Compound \"sourceId::itemKey\" strings from `preview` (e.g. storage::async:@cart, query::[\"pokemon\"], redux::cart). null or an empty array clears the saved exclusions.",
|
|
1903
|
+
"anyOf": [{
|
|
1904
|
+
"type": "array"
|
|
1905
|
+
}, {
|
|
1906
|
+
"type": "null"
|
|
1907
|
+
}]
|
|
1908
|
+
}
|
|
1909
|
+
},
|
|
1910
|
+
"required": ["id"],
|
|
1911
|
+
"additionalProperties": false
|
|
1912
|
+
},
|
|
1913
|
+
"effect": "write",
|
|
1914
|
+
"release": "works",
|
|
1915
|
+
"description": "Requires `id` (throws \"setExclusions requires `id`\"). Saves compound \"sourceId::itemKey\" strings onto the snapshot's metadata; a later `restore` that omits its own excludeKeys will honour them. Pass null or an empty array to clear. Returns { id, excluded:<count> }. Metadata only — nothing in the app changes now; the effect lands on the next restore.",
|
|
1916
|
+
"requires": ["@buoy-gg/time-machine registered in FloatingDevTools"]
|
|
1917
|
+
}, {
|
|
1918
|
+
"action": "setScope",
|
|
1919
|
+
"summary": "Persist a targeted-restore scope — the ONLY items this restore point will ever touch.",
|
|
1920
|
+
"params": {
|
|
1921
|
+
"type": "object",
|
|
1922
|
+
"properties": {
|
|
1923
|
+
"id": {
|
|
1924
|
+
"type": "string",
|
|
1925
|
+
"description": "Snapshot id from `list`. Required."
|
|
1926
|
+
},
|
|
1927
|
+
"scope": {
|
|
1928
|
+
"items": {
|
|
1929
|
+
"type": "string"
|
|
1930
|
+
},
|
|
1931
|
+
"description": "Compound \"sourceId::itemKey\" strings from `preview` — the only items a restore may touch. null or an empty array clears the scope (full restore).",
|
|
1932
|
+
"anyOf": [{
|
|
1933
|
+
"type": "array"
|
|
1934
|
+
}, {
|
|
1935
|
+
"type": "null"
|
|
1936
|
+
}]
|
|
1937
|
+
}
|
|
1938
|
+
},
|
|
1939
|
+
"required": ["id"],
|
|
1940
|
+
"additionalProperties": false
|
|
1941
|
+
},
|
|
1942
|
+
"effect": "write",
|
|
1943
|
+
"release": "works",
|
|
1944
|
+
"description": "Requires `id` (throws \"setScope requires a snapshot `id`\"). The inverse of setExclusions: a scoped point restores nothing outside its list, and sources entirely outside the scope are left untouched and unreported in the outcome. Pass null or an empty array to clear the scope and go back to a full restore. Returns { scoped:true, id, count }. Note that a `restore` call passing an explicit excludeKeys array IGNORES the saved scope.",
|
|
1945
|
+
"requires": ["@buoy-gg/time-machine registered in FloatingDevTools"]
|
|
1946
|
+
}, {
|
|
1947
|
+
"action": "setRestoreRoute",
|
|
1948
|
+
"summary": "Turn a restore point's 'also navigate back to the captured screen' behaviour on or off.",
|
|
1949
|
+
"params": {
|
|
1950
|
+
"type": "object",
|
|
1951
|
+
"properties": {
|
|
1952
|
+
"id": {
|
|
1953
|
+
"type": "string",
|
|
1954
|
+
"description": "Snapshot id from `list`. Required."
|
|
1955
|
+
},
|
|
1956
|
+
"restoreRoute": {
|
|
1957
|
+
"type": "boolean",
|
|
1958
|
+
"description": "true to navigate back to the captured route on restore, false to skip it. Only an explicit false turns it off — omitting it turns it ON."
|
|
1959
|
+
}
|
|
1960
|
+
},
|
|
1961
|
+
"required": ["id"],
|
|
1962
|
+
"additionalProperties": false
|
|
1963
|
+
},
|
|
1964
|
+
"effect": "write",
|
|
1965
|
+
"release": "works",
|
|
1966
|
+
"description": "Requires `id` (throws \"setRestoreRoute requires `id`\"). Persists the flag onto the snapshot. NOTE THE COERCION: the handler stores `restoreRoute !== false`, so omitting the param — or sending anything other than exactly false — turns it ON. Returns { id, restoreRoute }. Only meaningful for snapshots that captured a route (see `route` on the snapshot and `route.available` from `list`).",
|
|
1967
|
+
"requires": ["expo-router on the device for the flag to have any effect"]
|
|
1968
|
+
}],
|
|
1969
|
+
"unavailableWhen": "The `@buoy-gg/time-machine` tool is not in the app's FloatingDevTools tool list, or the app is a release build that has not opted into `externalSync.enableInRelease` with a real Pro license (packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:190 — no socket, so no action reaches the device at all). Individual state sources are also absent unless their tool package is installed and instrumented: check `providers[].canCapture`/`canRestore` from `list` before assuming a source is covered."
|
|
1970
|
+
}, {
|
|
1971
|
+
"toolId": "storage",
|
|
1972
|
+
"title": "Storage",
|
|
1973
|
+
"summary": "Read and write the app's persisted state across all three backends — AsyncStorage, every registered MMKV instance, and registered Expo SecureStore keys — plus a recorded timeline of storage writes with per-event undo/jump. Reach for this first when a value is wrong, stale, missing, or only broken after a restart/upgrade: the bad value is usually sitting in storage. Buoy's own devtool keys (@react_buoy*, @buoy*, buoy-*) are stripped from every read path, so results are app data only. Nothing here is __DEV__-gated — every action really runs in a release build.",
|
|
1974
|
+
"actions": [{
|
|
1975
|
+
"action": "async.getAllKeys",
|
|
1976
|
+
"summary": "List every AsyncStorage key in the app (Buoy's own devtool keys stripped).",
|
|
1977
|
+
"params": {
|
|
1978
|
+
"type": "object",
|
|
1979
|
+
"properties": {},
|
|
1980
|
+
"additionalProperties": false
|
|
1981
|
+
},
|
|
1982
|
+
"effect": "read",
|
|
1983
|
+
"release": "works",
|
|
1984
|
+
"description": "Returns string[]. The cheapest first call: get the key names, then read the ones that matter with async.multiGet. Keys matching @react_buoy*, @buoy*, buoy-*, or legacy dev prefixes are filtered out at the source, so the count can be lower than the app's real key count.",
|
|
1985
|
+
"requires": ["@react-native-async-storage/async-storage installed in the app"]
|
|
1986
|
+
}, {
|
|
1987
|
+
"action": "async.multiGet",
|
|
1988
|
+
"summary": "Batch-read AsyncStorage values; returns [key, value|null][] tuples.",
|
|
1989
|
+
"params": {
|
|
1990
|
+
"type": "object",
|
|
1991
|
+
"properties": {
|
|
1992
|
+
"keys": {
|
|
1993
|
+
"type": "array",
|
|
1994
|
+
"items": {
|
|
1995
|
+
"type": "string"
|
|
1996
|
+
},
|
|
1997
|
+
"description": "Keys to read, e.g. [\"session\",\"user.prefs\"]. Required — the handler reads params.keys with no guard and throws if params is missing."
|
|
1998
|
+
}
|
|
1999
|
+
},
|
|
2000
|
+
"required": ["keys"],
|
|
2001
|
+
"additionalProperties": false
|
|
2002
|
+
},
|
|
2003
|
+
"effect": "read",
|
|
2004
|
+
"release": "works",
|
|
2005
|
+
"description": "Values are always strings (or null when unset) — JSON.parse them yourself. Works on async-storage v2 and v3 (translated to getMany internally) and always preserves the requested key order. Any requested key that is a Buoy devtool key is dropped from the RESULT entirely, so the returned array can be shorter than `keys`.",
|
|
2006
|
+
"requires": ["@react-native-async-storage/async-storage installed in the app"]
|
|
2007
|
+
}, {
|
|
2008
|
+
"action": "async.getItem",
|
|
2009
|
+
"summary": "Read one AsyncStorage key; returns the string value or null.",
|
|
2010
|
+
"params": {
|
|
2011
|
+
"type": "object",
|
|
2012
|
+
"properties": {
|
|
2013
|
+
"key": {
|
|
2014
|
+
"type": "string",
|
|
2015
|
+
"description": "The AsyncStorage key to read."
|
|
2016
|
+
}
|
|
2017
|
+
},
|
|
2018
|
+
"required": ["key"],
|
|
2019
|
+
"additionalProperties": false
|
|
2020
|
+
},
|
|
2021
|
+
"effect": "read",
|
|
2022
|
+
"release": "works",
|
|
2023
|
+
"description": "Prefer async.multiGet when you want more than one key. Returns null (never an error) for a key that is unset AND for any Buoy devtool key (@react_buoy*, @buoy*, buoy-*) even when that key really is set — so a null here does not prove the app never wrote it if the key is Buoy-prefixed.",
|
|
2024
|
+
"requires": ["@react-native-async-storage/async-storage installed in the app"]
|
|
2025
|
+
}, {
|
|
2026
|
+
"action": "async.setItem",
|
|
2027
|
+
"summary": "Write one AsyncStorage key to a string value.",
|
|
2028
|
+
"params": {
|
|
2029
|
+
"type": "object",
|
|
2030
|
+
"properties": {
|
|
2031
|
+
"key": {
|
|
2032
|
+
"type": "string",
|
|
2033
|
+
"description": "The AsyncStorage key to write."
|
|
2034
|
+
},
|
|
2035
|
+
"value": {
|
|
2036
|
+
"type": "string",
|
|
2037
|
+
"description": "The string value to store. JSON-encode objects/arrays yourself."
|
|
2038
|
+
}
|
|
2039
|
+
},
|
|
2040
|
+
"required": ["key", "value"],
|
|
2041
|
+
"additionalProperties": false
|
|
2042
|
+
},
|
|
2043
|
+
"effect": "write",
|
|
2044
|
+
"release": "works",
|
|
2045
|
+
"description": "Values are strings only — JSON.stringify objects yourself, or the app will read back garbage. Unlike the read paths this is NOT key-filtered: it will happily overwrite Buoy's own @react_buoy*/@buoy*/buoy-* settings keys, so never point it at one. Emits a setItem event (with the old value as prevValue) while a dashboard is subscribed, which makes it undoable via timeTravel.undo. TRAP: if this key is a live store's saved copy (the grounding marks these as \"persists to <key>\", and zustand's listStores reports it as persistName), do NOT write it here. The app keeps that state in memory and only reads the key at startup, so the screen will not change and the store will overwrite you the next time it saves. Use the store's own tool instead — zustand.setState, redux.dispatch, jotai.setAtom.",
|
|
2046
|
+
"requires": ["@react-native-async-storage/async-storage installed in the app"]
|
|
2047
|
+
}, {
|
|
2048
|
+
"action": "async.removeItem",
|
|
2049
|
+
"summary": "Delete one AsyncStorage key.",
|
|
2050
|
+
"params": {
|
|
2051
|
+
"type": "object",
|
|
2052
|
+
"properties": {
|
|
2053
|
+
"key": {
|
|
2054
|
+
"type": "string",
|
|
2055
|
+
"description": "The AsyncStorage key to delete."
|
|
2056
|
+
}
|
|
2057
|
+
},
|
|
2058
|
+
"required": ["key"],
|
|
2059
|
+
"additionalProperties": false
|
|
2060
|
+
},
|
|
2061
|
+
"effect": "destructive",
|
|
2062
|
+
"release": "works",
|
|
2063
|
+
"description": "Permanently removes the key from the device. No confirmation and no result payload — it resolves to undefined whether or not the key existed. Read the value first if you might need it back.",
|
|
2064
|
+
"requires": ["@react-native-async-storage/async-storage installed in the app"]
|
|
2065
|
+
}, {
|
|
2066
|
+
"action": "async.multiRemove",
|
|
2067
|
+
"summary": "Delete several AsyncStorage keys in one call.",
|
|
2068
|
+
"params": {
|
|
2069
|
+
"type": "object",
|
|
2070
|
+
"properties": {
|
|
2071
|
+
"keys": {
|
|
2072
|
+
"type": "array",
|
|
2073
|
+
"items": {
|
|
2074
|
+
"type": "string"
|
|
2075
|
+
},
|
|
2076
|
+
"description": "Keys to delete."
|
|
2077
|
+
}
|
|
2078
|
+
},
|
|
2079
|
+
"required": ["keys"],
|
|
2080
|
+
"additionalProperties": false
|
|
2081
|
+
},
|
|
2082
|
+
"effect": "destructive",
|
|
2083
|
+
"release": "works",
|
|
2084
|
+
"description": "Batch form of async.removeItem (translated to removeMany on async-storage v3). Deletes exactly the keys you name — it does NOT filter Buoy devtool keys, so do not pass @react_buoy*/@buoy*/buoy-* keys. To wipe app data wholesale use clearAppStorage instead, which protects those.",
|
|
2085
|
+
"requires": ["@react-native-async-storage/async-storage installed in the app", "for undoAction timeTravel.undo: storage capture must be held open at the moment of the write, or no prevPairs event exists to undo"]
|
|
2086
|
+
}, {
|
|
2087
|
+
"action": "async.multiSet",
|
|
2088
|
+
"summary": "Write several AsyncStorage key/value pairs in one call.",
|
|
2089
|
+
"params": {
|
|
2090
|
+
"type": "object",
|
|
2091
|
+
"properties": {
|
|
2092
|
+
"pairs": {
|
|
2093
|
+
"type": "array",
|
|
2094
|
+
"description": "Array of two-element [key, value] arrays, e.g. [[\"session\",\"abc\"],[\"count\",\"3\"]].",
|
|
2095
|
+
"items": {
|
|
2096
|
+
"type": "array",
|
|
2097
|
+
"items": {
|
|
2098
|
+
"type": "string"
|
|
2099
|
+
},
|
|
2100
|
+
"minItems": 2,
|
|
2101
|
+
"maxItems": 2
|
|
2102
|
+
}
|
|
2103
|
+
}
|
|
2104
|
+
},
|
|
2105
|
+
"required": ["pairs"],
|
|
2106
|
+
"additionalProperties": false
|
|
2107
|
+
},
|
|
2108
|
+
"effect": "write",
|
|
2109
|
+
"release": "works",
|
|
2110
|
+
"description": "Takes v2-shaped tuples [[key, value], ...] and translates to setMany on async-storage v3. All values must be strings. Same caveat as async.setItem: not key-filtered, so never include a Buoy devtool key. TRAP: if this key is a live store's saved copy (the grounding marks these as \"persists to <key>\", and zustand's listStores reports it as persistName), do NOT write it here. The app keeps that state in memory and only reads the key at startup, so the screen will not change and the store will overwrite you the next time it saves. Use the store's own tool instead — zustand.setState, redux.dispatch, jotai.setAtom.",
|
|
2111
|
+
"requires": ["@react-native-async-storage/async-storage installed in the app", "for undoAction timeTravel.undo: storage capture must be held open at the moment of the write, or no prevPairs event exists to undo"]
|
|
2112
|
+
}, {
|
|
2113
|
+
"action": "async.clear",
|
|
2114
|
+
"summary": "Wipe ALL of AsyncStorage, including Buoy's own devtool settings.",
|
|
2115
|
+
"params": {
|
|
2116
|
+
"type": "object",
|
|
2117
|
+
"properties": {},
|
|
2118
|
+
"additionalProperties": false
|
|
2119
|
+
},
|
|
2120
|
+
"effect": "destructive",
|
|
2121
|
+
"release": "works",
|
|
2122
|
+
"description": "Raw AsyncStorage.clear() — the nuclear option. It destroys Buoy's own @react_buoy*/@buoy*/buoy-* keys too, resetting the dev tools' own settings along with app data. Almost always the wrong choice: use clearAppStorage, which does the same thing to app data while preserving Buoy's keys. Prefer it unless someone explicitly asked to reset the dev tools as well.",
|
|
2123
|
+
"requires": ["@react-native-async-storage/async-storage installed in the app", "for undoAction timeTravel.undo: storage capture must be held open (a storage events read/watch) AT THE MOMENT OF THE WRITE — otherwise no event with prevPairs is recorded and the undo is impossible"]
|
|
2124
|
+
}, {
|
|
2125
|
+
"action": "clearAppStorage",
|
|
2126
|
+
"summary": "Delete every app AsyncStorage key while preserving Buoy's own devtool keys.",
|
|
2127
|
+
"params": {
|
|
2128
|
+
"type": "object",
|
|
2129
|
+
"properties": {},
|
|
2130
|
+
"additionalProperties": false
|
|
2131
|
+
},
|
|
2132
|
+
"effect": "destructive",
|
|
2133
|
+
"release": "works",
|
|
2134
|
+
"description": "The safe reset: getAllKeys, drop anything matching @react_buoy*/@buoy*/buoy-*/legacy dev prefixes, removeMany the rest. This is what a 'clear all storage' / 'reset the app's data' request means. Resolves to undefined and reports no count. Use it instead of async.clear.",
|
|
2135
|
+
"requires": ["@react-native-async-storage/async-storage installed in the app", "for undoAction timeTravel.undo: storage capture must be held open at the moment of the call — the wipe goes through removeMany, so with no subscriber there is no multiRemove event and the wipe is unrecoverable"]
|
|
2136
|
+
}, {
|
|
2137
|
+
"action": "getEventDetail",
|
|
2138
|
+
"summary": "Fetch one recorded storage event's full value/prevValue/pairs by event id.",
|
|
2139
|
+
"params": {
|
|
2140
|
+
"type": "object",
|
|
2141
|
+
"properties": {
|
|
2142
|
+
"id": {
|
|
2143
|
+
"type": "string",
|
|
2144
|
+
"description": "Event id from the storage event timeline, format se-<epochMs>-<counter>."
|
|
2145
|
+
}
|
|
2146
|
+
},
|
|
2147
|
+
"required": ["id"],
|
|
2148
|
+
"additionalProperties": false
|
|
2149
|
+
},
|
|
2150
|
+
"effect": "read",
|
|
2151
|
+
"release": "works",
|
|
2152
|
+
"description": "The streamed event timeline replaces any value over 16KB with the marker object {__buoyValueOnDevice:true}; this is the on-demand channel for the real payload (itself capped at 8MB, above which it returns {__buoyTruncated:true}). Call it before timeTravel.undo/jump on an event whose values are still markers — those actions refuse to replay markers. Never throws: returns {found:false, reason:\"missing id\"} or {found:false, reason:\"unknown id\"}. Event ids look like \"se-1755000000000-42\".",
|
|
2153
|
+
"armsCapture": true
|
|
2154
|
+
}, {
|
|
2155
|
+
"action": "clearEvents",
|
|
2156
|
+
"summary": "Wipe the recorded storage-event timeline (in-memory only; app storage untouched).",
|
|
2157
|
+
"params": {
|
|
2158
|
+
"type": "object",
|
|
2159
|
+
"properties": {},
|
|
2160
|
+
"additionalProperties": false
|
|
2161
|
+
},
|
|
2162
|
+
"effect": "destructive",
|
|
2163
|
+
"release": "works",
|
|
2164
|
+
"description": "Empties the 500-event ring buffer. No device storage is written or deleted — but every event id disappears, so getEventDetail and timeTravel.undo/jump lose all their targets, permanently. Worth knowing: the initial key scan that seeds the timeline with the app's PRE-EXISTING keys runs only once per store lifetime, so after clearEvents the timeline does not re-list existing keys — it only refills with new writes. Use async.getAllKeys to see current keys instead.",
|
|
2165
|
+
"armsCapture": true
|
|
2166
|
+
}, {
|
|
2167
|
+
"action": "timeTravel.undo",
|
|
2168
|
+
"summary": "Revert one recorded AsyncStorage write, restoring the value it overwrote.",
|
|
2169
|
+
"params": {
|
|
2170
|
+
"type": "object",
|
|
2171
|
+
"properties": {
|
|
2172
|
+
"id": {
|
|
2173
|
+
"type": "string",
|
|
2174
|
+
"description": "Id of the AsyncStorage event to undo (se-<epochMs>-<counter>)."
|
|
2175
|
+
}
|
|
2176
|
+
},
|
|
2177
|
+
"required": ["id"],
|
|
2178
|
+
"additionalProperties": false
|
|
2179
|
+
},
|
|
2180
|
+
"effect": "destructive",
|
|
2181
|
+
"release": "works",
|
|
2182
|
+
"description": "Addresses a single event by id and restores its prevValue/prevPairs (removing the key when it did not exist before). AsyncStorage events only — throws \"Time travel supports AsyncStorage events only\" for MMKV events, throws when the event has no captured previous value, and throws when prevValue is still an on-device marker (fetch getEventDetail first). Unlike the in-app UNDO button, failures throw with the real reason instead of silently doing nothing. The restore itself emits a normal storage event, so it is visible and re-undoable.",
|
|
2183
|
+
"armsCapture": true
|
|
2184
|
+
}, {
|
|
2185
|
+
"action": "timeTravel.jump",
|
|
2186
|
+
"summary": "Rewind storage to its state as of one recorded event by replaying history.",
|
|
2187
|
+
"params": {
|
|
2188
|
+
"type": "object",
|
|
2189
|
+
"properties": {
|
|
2190
|
+
"id": {
|
|
2191
|
+
"type": "string",
|
|
2192
|
+
"description": "Id of the AsyncStorage event to jump to (se-<epochMs>-<counter>)."
|
|
2193
|
+
},
|
|
2194
|
+
"scope": {
|
|
2195
|
+
"type": "string",
|
|
2196
|
+
"enum": ["key", "all"],
|
|
2197
|
+
"description": "\"key\" (default) replays only that key's history; \"all\" replays every captured AsyncStorage event and can rewrite/delete many keys."
|
|
2198
|
+
}
|
|
2199
|
+
},
|
|
2200
|
+
"required": ["id"],
|
|
2201
|
+
"additionalProperties": false
|
|
2202
|
+
},
|
|
2203
|
+
"effect": "destructive",
|
|
2204
|
+
"release": "works",
|
|
2205
|
+
"description": "Replays the captured AsyncStorage timeline up to and including `id`, then reconciles the keys that timeline governs — writing the replayed values and DELETING keys that only exist later than the target. scope:\"key\" (the default, matching the in-app JUMP button) replays only the target event's own key. scope:\"all\" replays every captured AsyncStorage event, so it can rewrite and delete many unrelated keys at once — treat that as a bulk data change and confirm before using it. Buoy's own devtool keys are never touched. Throws for an unknown id, for an id not in the chosen timeline, or when any replayed event still holds an on-device value marker (fetch getEventDetail first). Returns {jumped, scope, replayed, of, key}.",
|
|
2206
|
+
"armsCapture": true
|
|
2207
|
+
}, {
|
|
2208
|
+
"action": "mmkv.snapshot",
|
|
2209
|
+
"summary": "Dump every registered MMKV instance with all its keys, values, and value types.",
|
|
2210
|
+
"params": {
|
|
2211
|
+
"type": "object",
|
|
2212
|
+
"properties": {},
|
|
2213
|
+
"additionalProperties": false
|
|
2214
|
+
},
|
|
2215
|
+
"effect": "read",
|
|
2216
|
+
"release": "works",
|
|
2217
|
+
"description": "Returns [{id, encrypted, readOnly, entries:[{key, value, valueType}]}] where valueType is string|number|boolean|buffer. One round trip for all instances — the right MMKV read unless you need a single oversized value. Values over 16KB are replaced with {__buoyValueOnDevice:true}; fetch those via mmkv.get. Buoy devtool keys are stripped. Returns [] when the app never called registerMMKVInstance(); an instance that throws on read comes back with entries: [] rather than failing the call.",
|
|
2218
|
+
"requires": ["registerMMKVInstance(...) called by the app (react-native-mmkv)"]
|
|
2219
|
+
}, {
|
|
2220
|
+
"action": "mmkv.get",
|
|
2221
|
+
"summary": "Read one MMKV key's full value from a named instance.",
|
|
2222
|
+
"params": {
|
|
2223
|
+
"type": "object",
|
|
2224
|
+
"properties": {
|
|
2225
|
+
"instanceId": {
|
|
2226
|
+
"type": "string",
|
|
2227
|
+
"description": "Registered MMKV instance id, as listed by mmkv.snapshot."
|
|
2228
|
+
},
|
|
2229
|
+
"key": {
|
|
2230
|
+
"type": "string",
|
|
2231
|
+
"description": "The key to read."
|
|
2232
|
+
}
|
|
2233
|
+
},
|
|
2234
|
+
"required": ["instanceId", "key"],
|
|
2235
|
+
"additionalProperties": false
|
|
2236
|
+
},
|
|
2237
|
+
"effect": "read",
|
|
2238
|
+
"release": "works",
|
|
2239
|
+
"description": "The size-guarded single-key channel for values mmkv.snapshot omitted. Returns {found:true, instanceId, key, value, valueType} — or, without throwing, {found:false, reason:\"missing instanceId/key\"} or {found:false, reason:\"unknown instance\"}. valueType is auto-detected as string|number|boolean|buffer. instanceId is the id the app registered, e.g. \"mmkv.default\" or \"user-prefs\" — get the exact ids from mmkv.snapshot.",
|
|
2240
|
+
"requires": ["registerMMKVInstance(...) called by the app (react-native-mmkv)"]
|
|
2241
|
+
}, {
|
|
2242
|
+
"action": "mmkv.set",
|
|
2243
|
+
"summary": "Write one key in a registered MMKV instance (string, number, or boolean).",
|
|
2244
|
+
"params": {
|
|
2245
|
+
"type": "object",
|
|
2246
|
+
"properties": {
|
|
2247
|
+
"instanceId": {
|
|
2248
|
+
"type": "string",
|
|
2249
|
+
"description": "Registered MMKV instance id (see mmkv.snapshot)."
|
|
2250
|
+
},
|
|
2251
|
+
"key": {
|
|
2252
|
+
"type": "string",
|
|
2253
|
+
"description": "The key to write."
|
|
2254
|
+
},
|
|
2255
|
+
"value": {
|
|
2256
|
+
"description": "Typed value — stored as string, number, or boolean exactly as passed.",
|
|
2257
|
+
"anyOf": [{
|
|
2258
|
+
"type": "string"
|
|
2259
|
+
}, {
|
|
2260
|
+
"type": "number"
|
|
2261
|
+
}, {
|
|
2262
|
+
"type": "boolean"
|
|
2263
|
+
}]
|
|
2264
|
+
}
|
|
2265
|
+
},
|
|
2266
|
+
"required": ["instanceId", "key", "value"],
|
|
2267
|
+
"additionalProperties": false
|
|
2268
|
+
},
|
|
2269
|
+
"effect": "write",
|
|
2270
|
+
"release": "works",
|
|
2271
|
+
"description": "Unlike AsyncStorage, MMKV is typed: pass a real number or boolean and it is stored as that type — do not stringify. Throws 'No MMKV instance registered as \"<id>\"' for an unknown instance and 'MMKV instance \"<id>\" is registered read-only' for one the app declared immutable, so a success means the write really landed. Returns nothing. MMKV writes are recorded in the event timeline but are NOT undoable via timeTravel (AsyncStorage only). TRAP: if this key is a live store's saved copy (the grounding marks these as \"persists to <key>\", and zustand's listStores reports it as persistName), do NOT write it here. The app keeps that state in memory and only reads the key at startup, so the screen will not change and the store will overwrite you the next time it saves. Use the store's own tool instead — zustand.setState, redux.dispatch, jotai.setAtom.",
|
|
2272
|
+
"requires": ["registerMMKVInstance(...) called by the app (react-native-mmkv)"]
|
|
2273
|
+
}, {
|
|
2274
|
+
"action": "mmkv.remove",
|
|
2275
|
+
"summary": "Delete one key from a registered MMKV instance.",
|
|
2276
|
+
"params": {
|
|
2277
|
+
"type": "object",
|
|
2278
|
+
"properties": {
|
|
2279
|
+
"instanceId": {
|
|
2280
|
+
"type": "string",
|
|
2281
|
+
"description": "Registered MMKV instance id (see mmkv.snapshot)."
|
|
2282
|
+
},
|
|
2283
|
+
"key": {
|
|
2284
|
+
"type": "string",
|
|
2285
|
+
"description": "The key to delete."
|
|
2286
|
+
}
|
|
2287
|
+
},
|
|
2288
|
+
"required": ["instanceId", "key"],
|
|
2289
|
+
"additionalProperties": false
|
|
2290
|
+
},
|
|
2291
|
+
"effect": "destructive",
|
|
2292
|
+
"release": "works",
|
|
2293
|
+
"description": "Calls remove() on react-native-mmkv v4 and falls back to delete() on older versions. Throws for an unknown instance id or one registered read-only; otherwise returns nothing, whether or not the key existed. Not recoverable through timeTravel — that covers AsyncStorage only.",
|
|
2294
|
+
"requires": ["registerMMKVInstance(...) called by the app (react-native-mmkv)"]
|
|
2295
|
+
}, {
|
|
2296
|
+
"action": "secure.keys",
|
|
2297
|
+
"summary": "List the registered Expo SecureStore keys (names and flags, no values).",
|
|
2298
|
+
"params": {
|
|
2299
|
+
"type": "object",
|
|
2300
|
+
"properties": {},
|
|
2301
|
+
"additionalProperties": false
|
|
2302
|
+
},
|
|
2303
|
+
"effect": "read",
|
|
2304
|
+
"release": "works",
|
|
2305
|
+
"description": "Returns [{key, description, keychainService, requireAuthentication}]. SecureStore has no key-enumeration API, so only keys the app declared via registerSecureStoreKeys(...) are visible — an empty [] means nothing was registered, NOT that the keychain is empty. requireAuthentication:true marks a biometric-protected key whose value Buoy never reads.",
|
|
2306
|
+
"requires": ["registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"]
|
|
2307
|
+
}, {
|
|
2308
|
+
"action": "secure.snapshot",
|
|
2309
|
+
"summary": "List every registered SecureStore key WITH its value in one round trip.",
|
|
2310
|
+
"params": {
|
|
2311
|
+
"type": "object",
|
|
2312
|
+
"properties": {},
|
|
2313
|
+
"additionalProperties": false
|
|
2314
|
+
},
|
|
2315
|
+
"effect": "read",
|
|
2316
|
+
"release": "works",
|
|
2317
|
+
"description": "The SecureStore counterpart to mmkv.snapshot, and the right read when you want values — one call instead of secure.keys plus N secure.get. Returns [{key, description, keychainService, requireAuthentication, value}]. value is null when unset, when the read failed, and always for biometric-protected keys (reading those would fire an auth prompt on the user's device, so they are skipped). Values over 16KB come back as {__buoyValueOnDevice:true}.",
|
|
2318
|
+
"requires": ["registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"]
|
|
2319
|
+
}, {
|
|
2320
|
+
"action": "secure.get",
|
|
2321
|
+
"summary": "Read one registered SecureStore key's value.",
|
|
2322
|
+
"params": {
|
|
2323
|
+
"type": "object",
|
|
2324
|
+
"properties": {
|
|
2325
|
+
"key": {
|
|
2326
|
+
"type": "string",
|
|
2327
|
+
"description": "A registered SecureStore key (see secure.keys)."
|
|
2328
|
+
}
|
|
2329
|
+
},
|
|
2330
|
+
"required": ["key"],
|
|
2331
|
+
"additionalProperties": false
|
|
2332
|
+
},
|
|
2333
|
+
"effect": "read",
|
|
2334
|
+
"release": "works",
|
|
2335
|
+
"description": "Reads with the exact options (keychainService) the key was registered with, which is required or the read returns null. Resolves to null — never an error — in four different cases: the value is unset, the key is not registered, no SecureStore module was registered, or the key is biometric-protected (requireAuthentication, deliberately never read). Check secure.keys before concluding from a null that the app never stored anything. Prefer secure.snapshot for more than one key.",
|
|
2336
|
+
"requires": ["registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"]
|
|
2337
|
+
}, {
|
|
2338
|
+
"action": "secure.set",
|
|
2339
|
+
"summary": "Write a registered SecureStore key (string value).",
|
|
2340
|
+
"params": {
|
|
2341
|
+
"type": "object",
|
|
2342
|
+
"properties": {
|
|
2343
|
+
"key": {
|
|
2344
|
+
"type": "string",
|
|
2345
|
+
"description": "A registered, non-biometric SecureStore key."
|
|
2346
|
+
},
|
|
2347
|
+
"value": {
|
|
2348
|
+
"type": "string",
|
|
2349
|
+
"description": "The string value to store. JSON-encode objects yourself."
|
|
2350
|
+
}
|
|
2351
|
+
},
|
|
2352
|
+
"required": ["key", "value"],
|
|
2353
|
+
"additionalProperties": false
|
|
2354
|
+
},
|
|
2355
|
+
"effect": "write",
|
|
2356
|
+
"release": "works",
|
|
2357
|
+
"description": "Goes through the registry so the value is written with the SAME options it was registered with — writing with a different keychainService silently creates a second entry the app cannot read. Throws rather than no-ops: 'No SecureStore module is registered', 'SecureStore key \"<k>\" is not registered', or '...is biometric-protected — DevTools never writes it'. Values are strings; JSON-encode objects yourself. SecureStore changes are NOT in the event timeline, so there is no timeTravel undo.",
|
|
2358
|
+
"requires": ["registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"]
|
|
2359
|
+
}, {
|
|
2360
|
+
"action": "secure.delete",
|
|
2361
|
+
"summary": "Delete a registered SecureStore key's value from the keychain.",
|
|
2362
|
+
"params": {
|
|
2363
|
+
"type": "object",
|
|
2364
|
+
"properties": {
|
|
2365
|
+
"key": {
|
|
2366
|
+
"type": "string",
|
|
2367
|
+
"description": "A registered SecureStore key (see secure.keys)."
|
|
2368
|
+
}
|
|
2369
|
+
},
|
|
2370
|
+
"required": ["key"],
|
|
2371
|
+
"additionalProperties": false
|
|
2372
|
+
},
|
|
2373
|
+
"effect": "destructive",
|
|
2374
|
+
"release": "works",
|
|
2375
|
+
"description": "Permanently deletes the keychain entry using the options the key was registered with. Beware the silent path: if the key is not registered or no SecureStore module was registered it resolves with NO error and NO deletion — a success here is not proof anything was deleted, so verify with secure.get/secure.snapshot afterwards. Deleting an auth token or session key logs the user out; not recoverable (SecureStore is not in the event timeline).",
|
|
2376
|
+
"requires": ["registerSecureStoreKeys(SecureStore, [...]) called by the app (expo-secure-store)"]
|
|
2377
|
+
}],
|
|
2378
|
+
"unavailableWhen": "The app does not have @buoy-gg/storage installed alongside <FloatingDevTools/> — autoExternalSync only registers the \"storage\" adapter when that module resolves (packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:320). Individual backends degrade instead of erroring: with no registerMMKVInstance() call mmkv.snapshot returns [], and with no registerSecureStoreKeys() call secure.keys/secure.snapshot return []."
|
|
2379
|
+
}, {
|
|
2380
|
+
"toolId": "highlight-updates",
|
|
2381
|
+
"title": "Highlight Updates",
|
|
2382
|
+
"summary": "Two different jobs share one tool: (1) DRIVING and READING the live screen — describeScreen lists every on-screen element with tap points, tapElement invokes its real handler in JS (works on physical devices, no screenshots), and locateComponent resolves a component to an on-screen rect; (2) MEASURING re-renders — beginMeasurement/endMeasurement wrap an interaction and return per-component render cost + cause, while toggle/setEnabled draw the user-visible colored highlight boxes on the device. Reach for describeScreen+tapElement to see or operate the app, and beginMeasurement/endMeasurement to answer \"why is this screen slow\". CRITICAL: every action here depends on React's DevTools global hook, which React Native installs ONLY in dev builds — in a release/production build five of these actions return ok:true and silently do nothing, so never report a highlight toggle as applied without confirming it's a dev build.",
|
|
2383
|
+
"actions": [{
|
|
2384
|
+
"action": "describeScreen",
|
|
2385
|
+
"summary": "List every meaningful/interactive element currently on screen, with normalized tap points — the read half of driving the app.",
|
|
2386
|
+
"params": {
|
|
2387
|
+
"type": "object",
|
|
2388
|
+
"properties": {},
|
|
2389
|
+
"additionalProperties": false
|
|
2390
|
+
},
|
|
2391
|
+
"effect": "read",
|
|
2392
|
+
"release": "empty",
|
|
2393
|
+
"description": "Walks the live React fiber tree across ALL renderers and measures each candidate. Returns {screen:{width,height}, count, elements[]} where each element has: nativeTag, name (owning component, not the host View), role, testID, label, text, interactive, control ('toggle'|'slider'|'text', omitted for a plain press), value (current value for toggle/slider/text), longPressable, tap:{x,y} and frame:{x,y,width,height} both normalized to 0-1. Sorted top-to-bottom then left-to-right. Prunes offscreen/inactive react-native-screens and hidden Offscreen subtrees, so it reflects the CURRENT screen only — re-run after every navigation, positions and tags move. No screenshot and no tracking needed; it does not require the highlight overlay to be enabled. Call this before tapElement to get an exact testID/nativeTag instead of guessing a fuzzy query.",
|
|
2394
|
+
"releaseNote": "packages/highlight-updates/src/highlight-updates/utils/screenElements.ts:57 — getReactDevToolsHook() reads __REACT_DEVTOOLS_GLOBAL_HOOK__, which RN installs only under __DEV__; getAllFiberRoots() then returns [] and collectCandidates() short-circuits at line 539, so the result is a valid-looking {count:0, elements:[]} rather than an error.",
|
|
2395
|
+
"requires": ["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>", "dev build (__DEV__ === true)"]
|
|
2396
|
+
}, {
|
|
2397
|
+
"action": "tapElement",
|
|
2398
|
+
"summary": "Interact with one on-screen element by invoking its real handler in JS: press, long-press, toggle/slider, or type into a text input.",
|
|
2399
|
+
"params": {
|
|
2400
|
+
"type": "object",
|
|
2401
|
+
"properties": {
|
|
2402
|
+
"nativeTag": {
|
|
2403
|
+
"type": "number",
|
|
2404
|
+
"description": "Exact nativeTag from describeScreen. Most precise; wins over testID/query."
|
|
2405
|
+
},
|
|
2406
|
+
"testID": {
|
|
2407
|
+
"type": "string",
|
|
2408
|
+
"description": "Exact testID of the element."
|
|
2409
|
+
},
|
|
2410
|
+
"query": {
|
|
2411
|
+
"type": "string",
|
|
2412
|
+
"description": "Fuzzy match against testID / accessibilityLabel / visible text / component name, e.g. 'Sign in'. Least precise — verify with describeScreen first."
|
|
2413
|
+
},
|
|
2414
|
+
"value": {
|
|
2415
|
+
"description": "For a toggle/switch: true/false (omit to flip the current value). For a slider: the numeric value, clamped to minimumValue/maximumValue (omit to jump to the far end).",
|
|
2416
|
+
"anyOf": [{
|
|
2417
|
+
"type": "number"
|
|
2418
|
+
}, {
|
|
2419
|
+
"type": "boolean"
|
|
2420
|
+
}]
|
|
2421
|
+
},
|
|
2422
|
+
"text": {
|
|
2423
|
+
"type": "string",
|
|
2424
|
+
"description": "For a text input: the string to set via onChangeText."
|
|
2425
|
+
},
|
|
2426
|
+
"longPress": {
|
|
2427
|
+
"type": "boolean",
|
|
2428
|
+
"description": "Invoke onLongPress instead of onPress. Returns tapped:false if the element has no onLongPress."
|
|
2429
|
+
},
|
|
2430
|
+
"scrollIntoView": {
|
|
2431
|
+
"type": "boolean",
|
|
2432
|
+
"description": "Scroll an ancestor ScrollView so the target is visible before acting. Default true — set false to avoid moving the user's screen."
|
|
2433
|
+
}
|
|
2434
|
+
},
|
|
2435
|
+
"additionalProperties": false
|
|
2436
|
+
},
|
|
2437
|
+
"effect": "write",
|
|
2438
|
+
"release": "empty",
|
|
2439
|
+
"description": "WARNING: this fires the app's ACTUAL handler, so it can trigger irreversible flows (delete, purchase, logout, submit). Matching by fuzzy `query` can select the wrong element — prefer an exact nativeTag or testID from describeScreen, and confirm the target before firing anything consequential. Resolution order: the element's OWN onValueChange (toggle/slider) or onChangeText (text input) wins; otherwise the nearest onPress walking up the fiber chain (up to 25 levels). Returns {tapped, reason?, scrolled?, matched:{nativeTag,name,testID,label,text,via,value}, candidates?} — `via` is onPress|onLongPress|onValueChange|onChangeText. Trap: passing `text` to something that is not a text input silently runs its onPress instead, so check `matched.via` in the result. Elements driven only by react-native-gesture-handler GestureDetector have no JS handler and return tapped:false with a clear reason. If the target is off-screen it is scrolled into view first (this moves the user's screen). Provide exactly one of nativeTag / testID / query.",
|
|
2440
|
+
"releaseNote": "packages/highlight-updates/src/highlight-updates/utils/screenElements.ts:793 — collectCandidates() returns [] with no DevTools hook, so it returns {tapped:false, reason:\"No React fiber roots / elements found on screen.\"}; it reports the failure honestly rather than claiming a tap.",
|
|
2441
|
+
"requires": ["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>", "dev build (__DEV__ === true)"]
|
|
2442
|
+
}, {
|
|
2443
|
+
"action": "waitFor",
|
|
2444
|
+
"summary": "Block until an element is on screen (or gone), then report how long it took. Use it between navigating and tapping.",
|
|
2445
|
+
"description": "Returns {ok, waitedMs, polls, matched?, reason?}. USE THIS AFTER ANY ACTION THAT STARTS A LOAD — `route-events.navigate` returns the moment the route is pushed, not when the screen has data, so tapping straight after it hits a loading skeleton and fails for a reason that looks like a bad selector. Matching is `tapElement`'s exactly (same fields, same ranking), so a wait can never resolve on an element the tap then cannot find. Presence means ON SCREEN: a candidate matching by name is measured before it counts, so a previous screen still mounted behind this one does not satisfy the wait. `gone:true` waits for absence instead — and returns immediately when nothing matched in the first place, which is a real answer, not a failure. Defaults: timeoutMs 5000 (capped at 30000), pollMs 150 (floored at 50, because this polls on the JS thread the app renders on). Provide exactly one of nativeTag / testID / query.",
|
|
2446
|
+
"params": {
|
|
2447
|
+
"type": "object",
|
|
2448
|
+
"properties": {
|
|
2449
|
+
"nativeTag": {
|
|
2450
|
+
"type": "number",
|
|
2451
|
+
"description": "Exact nativeTag from describeScreen. Most precise."
|
|
2452
|
+
},
|
|
2453
|
+
"testID": {
|
|
2454
|
+
"type": "string",
|
|
2455
|
+
"description": "Exact testID of the element."
|
|
2456
|
+
},
|
|
2457
|
+
"query": {
|
|
2458
|
+
"type": "string",
|
|
2459
|
+
"description": "Fuzzy match against testID / accessibilityLabel / visible text / component name, e.g. 'Fire Grill'."
|
|
2460
|
+
},
|
|
2461
|
+
"gone": {
|
|
2462
|
+
"type": "boolean",
|
|
2463
|
+
"description": "Wait for the element to DISAPPEAR instead of appear — a spinner, a skeleton, a modal. Default false."
|
|
2464
|
+
},
|
|
2465
|
+
"timeoutMs": {
|
|
2466
|
+
"type": "number",
|
|
2467
|
+
"description": "Give up after this long. Default 5000, capped at 30000."
|
|
2468
|
+
},
|
|
2469
|
+
"pollMs": {
|
|
2470
|
+
"type": "number",
|
|
2471
|
+
"description": "Gap between scans. Default 150, floored at 50."
|
|
2472
|
+
}
|
|
2473
|
+
},
|
|
2474
|
+
"additionalProperties": false
|
|
2475
|
+
},
|
|
2476
|
+
"effect": "read",
|
|
2477
|
+
"release": "empty",
|
|
2478
|
+
"releaseNote": "Walks the same fiber tree describeScreen does, and React Native installs __REACT_DEVTOOLS_GLOBAL_HOOK__ only under `if (__DEV__)`, so outside a dev build nothing is ever found and every wait runs to its timeout.",
|
|
2479
|
+
"requires": ["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>", "dev build (__DEV__ === true)"]
|
|
2480
|
+
}, {
|
|
2481
|
+
"action": "beginMeasurement",
|
|
2482
|
+
"summary": "Open an invisible render-capture window (CommitProfiler) — pair with endMeasurement around the interaction you want to measure.",
|
|
2483
|
+
"params": {
|
|
2484
|
+
"type": "object",
|
|
2485
|
+
"properties": {},
|
|
2486
|
+
"additionalProperties": false
|
|
2487
|
+
},
|
|
2488
|
+
"effect": "write",
|
|
2489
|
+
"release": "empty",
|
|
2490
|
+
"description": "Starts a commit-level capture with detail:true. Backed by CommitProfiler, NOT the highlight overlay's RenderTracker, and that difference is the point: it walks COMPOSITE fibers, so it answers about the component you named (which of ITS props changed, by value or identity only, and which parent dragged it along) instead of about the native View inside it. Touches none of the user's state — nothing is enabled, nothing is cleared, nothing is drawn on screen; the user sees no change. Discards any previous unclaimed capture. Returns {ok:true} or {ok:false, reason} — always check `ok` before driving the interaction. Usage: beginMeasurement -> wait ~400ms to settle -> drive the UI with tapElement -> endMeasurement.",
|
|
2491
|
+
"releaseNote": "packages/highlight-updates/src/highlight-updates/utils/CommitProfiler.ts:458 — isSupported() returns false when !__DEV__, so this returns {ok:false, reason:\"render capture needs a dev build with the React DevTools hook available\"}. Honest self-report, unlike the toggle actions.",
|
|
2492
|
+
"requires": ["@buoy-gg/highlight-updates at adapter version 3+ (older apps have no beginMeasurement)", "dev build (__DEV__ === true)"]
|
|
2493
|
+
}, {
|
|
2494
|
+
"action": "endMeasurement",
|
|
2495
|
+
"summary": "Close the capture window and return the per-component render summary (cost in ms, cause, wasted renders).",
|
|
2496
|
+
"params": {
|
|
2497
|
+
"type": "object",
|
|
2498
|
+
"properties": {},
|
|
2499
|
+
"additionalProperties": false
|
|
2500
|
+
},
|
|
2501
|
+
"effect": "write",
|
|
2502
|
+
"release": "empty",
|
|
2503
|
+
"description": "Returns {summary: RenderCaptureSummary | null}. The summary carries totalCommits, totalRenders, totalRenderMs, wastedRenders (parent cascades + identity-only prop churn — the removable share), aggregate `causes`, and topComponents[] (capped) with {name, renders, totalMs, maxMs, avgMs, causes, changedProps, parentName}. Cause labels: mount (first render), hooks (own state changed), props (props changed by value), propsUnstable (props changed by IDENTITY only — a fresh function/object from the parent, fix upstream), parent (pure cascade, nothing of its own changed). Read topComponents sorted by totalMs, not by render count: a component rendering 40x for 0.4ms is noise; one rendering 4x for 38ms is the answer. summary is null when beginMeasurement was never called or the app reloaded mid-window.",
|
|
2504
|
+
"releaseNote": "packages/highlight-updates/src/highlight-updates/utils/CommitProfiler.ts — stopCapture() returns null when the window was never started, and beginMeasurement can never start one in release (isSupported() false at line 458), so this always yields {summary:null}.",
|
|
2505
|
+
"requires": ["@buoy-gg/highlight-updates at adapter version 3+", "a prior successful beginMeasurement on the same device"]
|
|
2506
|
+
}, {
|
|
2507
|
+
"action": "locateComponent",
|
|
2508
|
+
"summary": "Resolve a component to a fresh on-screen rectangle (in points) plus pixel scale — used to crop a simulator screenshot to exactly that component.",
|
|
2509
|
+
"params": {
|
|
2510
|
+
"type": "object",
|
|
2511
|
+
"properties": {
|
|
2512
|
+
"query": {
|
|
2513
|
+
"type": "string",
|
|
2514
|
+
"description": "Fuzzy match against testID / nativeID / accessibilityLabel / component name / view type, e.g. 'submit-button'. Exact field match wins, then prefix, then substring."
|
|
2515
|
+
},
|
|
2516
|
+
"nativeTag": {
|
|
2517
|
+
"type": "number",
|
|
2518
|
+
"description": "Exact native tag — skips fuzzy matching and wins over query."
|
|
2519
|
+
},
|
|
2520
|
+
"scrollIntoView": {
|
|
2521
|
+
"type": "boolean",
|
|
2522
|
+
"description": "Scroll an ancestor ScrollView to bring the component into view before measuring. Default true — this visibly moves the user's screen."
|
|
2523
|
+
},
|
|
2524
|
+
"margin": {
|
|
2525
|
+
"type": "number",
|
|
2526
|
+
"description": "Gap in points to leave above the component when scrolling it in. Default 12."
|
|
2527
|
+
}
|
|
2528
|
+
},
|
|
2529
|
+
"additionalProperties": false
|
|
2530
|
+
},
|
|
2531
|
+
"effect": "write",
|
|
2532
|
+
"release": "empty",
|
|
2533
|
+
"description": "BEWARE the side effect: by default this SCROLLS the user's app so the target sits near the top of its ScrollView before measuring. Pass scrollIntoView:false for a pure read. Returns {matched, reason?, live?, scrolled?, inView?, rect:{x,y,width,height}, scale (PixelRatio, multiply points by it for pixels), screen:{width,height}, nativeTag, componentName, testID, candidates[]}. Two resolution paths: if the render tracker has data (highlights or silent tracking were on) it ranks tracked components by testID/nativeID/accessibilityLabel/componentName/viewType/nativeTag and re-measures the live node; if the tracker is empty it falls back to the same fiber walk as describeScreen, so it still works cold. `reason` is one of no-renders | no-match | no-measurement. When matched is false, read `candidates` and retry with an exact nativeTag.",
|
|
2534
|
+
"releaseNote": "HighlightUpdatesController.ts:1744 — initialize() bails when !__DEV__ so the tracker is empty, and the describeScreen fallback finds no fiber roots; result is {matched:false, reason:\"no-match\", candidates:[]}.",
|
|
2535
|
+
"requires": ["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>", "dev build (__DEV__ === true)"]
|
|
2536
|
+
}, {
|
|
2537
|
+
"action": "getRenderDetail",
|
|
2538
|
+
"summary": "Fetch one tracked component's full renderHistory and lastRenderCause (including hook-change values), which the synced snapshot omits.",
|
|
2539
|
+
"params": {
|
|
2540
|
+
"type": "object",
|
|
2541
|
+
"properties": {
|
|
2542
|
+
"nativeTag": {
|
|
2543
|
+
"type": "number",
|
|
2544
|
+
"description": "The component's native tag, from the synced renders[] list or describeScreen. Omitting it returns {found:false, reason:'missing nativeTag'}."
|
|
2545
|
+
}
|
|
2546
|
+
},
|
|
2547
|
+
"required": ["nativeTag"],
|
|
2548
|
+
"additionalProperties": false
|
|
2549
|
+
},
|
|
2550
|
+
"effect": "read",
|
|
2551
|
+
"release": "empty",
|
|
2552
|
+
"description": "Snapshots strip the per-component history buffer (up to 20 events) and hookChanges to keep the ~5x/sec sync small; this is the on-demand fetch for a single row the user clicked. Returns {found:true, nativeTag, render} or {found:false, reason} where reason is 'missing nativeTag' or 'unknown nativeTag'. Keyed by nativeTag — get one from the snapshot's renders[] or from describeScreen. Only returns data for components the RenderTracker has actually seen, which means highlights (setEnabled/toggle) or setSilentTracking must have been on while the component rendered; a cold call on a fresh app returns found:false even though the component exists on screen.",
|
|
2553
|
+
"releaseNote": "HighlightUpdatesController.ts:1819/1854 — enable()/disable() no-op when !__DEV__, so RenderTracker never records and every lookup returns {found:false, reason:\"unknown nativeTag\"}.",
|
|
2554
|
+
"requires": ["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>", "dev build (__DEV__ === true)", "render tracking was on while the component rendered"]
|
|
2555
|
+
}, {
|
|
2556
|
+
"action": "setEnabled",
|
|
2557
|
+
"summary": "Turn the user-visible render highlighting on or off explicitly (colored boxes flash on the device around every re-rendering component).",
|
|
2558
|
+
"params": {
|
|
2559
|
+
"type": "object",
|
|
2560
|
+
"properties": {
|
|
2561
|
+
"enabled": {
|
|
2562
|
+
"type": "boolean",
|
|
2563
|
+
"description": "true draws the highlight boxes on the device and starts tracking; false stops both."
|
|
2564
|
+
}
|
|
2565
|
+
},
|
|
2566
|
+
"required": ["enabled"],
|
|
2567
|
+
"additionalProperties": false
|
|
2568
|
+
},
|
|
2569
|
+
"effect": "write",
|
|
2570
|
+
"release": "noop",
|
|
2571
|
+
"description": "Prefer this over `toggle` — it is idempotent, so the agent never has to know the current state. Enabling starts RenderTracker (populating the synced renders[] list and enabling getRenderDetail) AND draws colored boxes on the DEVICE screen: cyan for few renders through yellow for many, with a count badge. It also clears any leftover highlight suppression from a silent-tracking session. This is visible to whoever is holding the phone — say so before enabling. Returns undefined on success. Always send a params object: the handler reads params.enabled without a null guard, so calling with no params throws (surfaced as ok:false with a TypeError message).",
|
|
2572
|
+
"releaseNote": "HighlightUpdatesController.ts:1819 (enable) and :1854 (disable) both `if (!__DEV__) return;` before doing anything. The wire still reports ok:true, so this action LIES in a release build — never tell a QA user highlighting is on without confirming a dev build.",
|
|
2573
|
+
"requires": ["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>", "dev build (__DEV__ === true)"]
|
|
2574
|
+
}, {
|
|
2575
|
+
"action": "toggle",
|
|
2576
|
+
"summary": "Flip render highlighting on/off — same visible effect as setEnabled but state-dependent.",
|
|
2577
|
+
"params": {
|
|
2578
|
+
"type": "object",
|
|
2579
|
+
"properties": {},
|
|
2580
|
+
"additionalProperties": false
|
|
2581
|
+
},
|
|
2582
|
+
"effect": "write",
|
|
2583
|
+
"release": "noop",
|
|
2584
|
+
"description": "Calls enable() when off, disable() when on. Prefer setEnabled({enabled}) unless you genuinely want a flip, because this action's outcome depends on state you may not have read. Read the synced snapshot's `enabled` flag first if the distinction matters. Initializes the controller on first use. Draws colored boxes on the DEVICE screen — visible to the person holding the phone. Takes no params. Returns undefined.",
|
|
2585
|
+
"releaseNote": "HighlightUpdatesController.ts:1954 — `if (!__DEV__) return;` at the top of toggle(). Reports ok:true and does nothing.",
|
|
2586
|
+
"requires": ["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>", "dev build (__DEV__ === true)"]
|
|
2587
|
+
}, {
|
|
2588
|
+
"action": "setSilentTracking",
|
|
2589
|
+
"summary": "Track and measure renders WITHOUT drawing any highlight boxes — invisible tracking for screenshot/locate flows.",
|
|
2590
|
+
"params": {
|
|
2591
|
+
"type": "object",
|
|
2592
|
+
"properties": {
|
|
2593
|
+
"enabled": {
|
|
2594
|
+
"type": "boolean",
|
|
2595
|
+
"description": "true = track + measure with the overlay hidden; false = stop tracking and restore normal state. Omitted defaults to false (disable)."
|
|
2596
|
+
}
|
|
2597
|
+
},
|
|
2598
|
+
"additionalProperties": false
|
|
2599
|
+
},
|
|
2600
|
+
"effect": "write",
|
|
2601
|
+
"release": "noop",
|
|
2602
|
+
"description": "Turns the render tracker on (so renders[] populates, measurements sync, and getRenderDetail/locateComponent have data) while keeping the visual overlay hidden, so nothing appears on the user's screen. This is the right way to arm tracking when you only need data — use setEnabled only when the user actually wants to SEE the boxes. Idempotent and safe to call before every locateComponent. Disabling restores normal state (also disables tracking). Note: enabling any visual highlighting afterwards clears the suppression, so the boxes come back. Returns undefined. Always send a params object — the handler reads params.enabled without a null guard and throws if params is omitted; `{}` is treated as enabled:false.",
|
|
2603
|
+
"releaseNote": "Reaches HighlightUpdatesController.setSilentTracking -> enable(), which returns early at HighlightUpdatesController.ts:1819 when !__DEV__. Reports ok:true; no tracking is armed and every later getRenderDetail/locateComponent comes back empty.",
|
|
2604
|
+
"requires": ["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>", "dev build (__DEV__ === true)"]
|
|
2605
|
+
}, {
|
|
2606
|
+
"action": "toggleFreeze",
|
|
2607
|
+
"summary": "Freeze the highlight boxes on screen so they stop fading, or unfreeze to resume normal fade-out.",
|
|
2608
|
+
"params": {
|
|
2609
|
+
"type": "object",
|
|
2610
|
+
"properties": {},
|
|
2611
|
+
"additionalProperties": false
|
|
2612
|
+
},
|
|
2613
|
+
"effect": "write",
|
|
2614
|
+
"release": "noop",
|
|
2615
|
+
"description": "Freeze mode keeps existing highlight boxes on the device screen instead of letting them fade, so a human can read which components lit up during a burst. New renders are still captured. Unfreezing clears whatever boxes are currently drawn. State-dependent: read the snapshot's `frozen` flag before calling if you need a specific end state. Freeze is a view state on a LIVE overlay — disabling highlighting entirely also clears it. Takes no params. Returns undefined.",
|
|
2616
|
+
"releaseNote": "toggleFreeze() itself has no gate, but both branches do: freeze() at HighlightUpdatesController.ts:2068 and unfreeze() at :2082 return early when !__DEV__. Reports ok:true and nothing changes.",
|
|
2617
|
+
"requires": ["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>", "dev build (__DEV__ === true)", "highlighting already enabled (there are no boxes to freeze otherwise)"]
|
|
2618
|
+
}, {
|
|
2619
|
+
"action": "setSpotlight",
|
|
2620
|
+
"summary": "Draw a distinct spotlight highlight around one component on the device screen (or clear it with null).",
|
|
2621
|
+
"params": {
|
|
2622
|
+
"type": "object",
|
|
2623
|
+
"properties": {
|
|
2624
|
+
"nativeTag": {
|
|
2625
|
+
"description": "Native tag of the component to spotlight, or null to clear the current spotlight.",
|
|
2626
|
+
"anyOf": [{
|
|
2627
|
+
"type": "number"
|
|
2628
|
+
}, {
|
|
2629
|
+
"type": "null"
|
|
2630
|
+
}]
|
|
2631
|
+
}
|
|
2632
|
+
},
|
|
2633
|
+
"required": ["nativeTag"],
|
|
2634
|
+
"additionalProperties": false
|
|
2635
|
+
},
|
|
2636
|
+
"effect": "write",
|
|
2637
|
+
"release": "noop",
|
|
2638
|
+
"description": "Used when someone is browsing a component's detail from the desktop dashboard and wants to see WHICH component that row is, physically on the phone. Draws on the DEVICE screen — visible to whoever is holding it. Pass nativeTag:null to clear the spotlight; always clear it when you're done, or a stale box stays on the user's screen. Requires the highlight overlay to be mounted (i.e. highlighting enabled) for anything to appear. Returns undefined. Always send a params object — the handler reads params.nativeTag without a null guard and throws if params is omitted entirely.",
|
|
2639
|
+
"releaseNote": "setSpotlight has no __DEV__ gate of its own, but the overlay that renders the spotlight only exists once the tool is enabled, and enable() is gated at HighlightUpdatesController.ts:1819. It sets a variable, draws nothing, and reports ok:true.",
|
|
2640
|
+
"requires": ["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>", "dev build (__DEV__ === true)", "highlighting enabled so the overlay is mounted"]
|
|
2641
|
+
}, {
|
|
2642
|
+
"action": "clearRenderCounts",
|
|
2643
|
+
"summary": "Wipe ALL tracked render data and per-component counters — irreversible, and it destroys data someone may be collecting.",
|
|
2644
|
+
"params": {
|
|
2645
|
+
"type": "object",
|
|
2646
|
+
"properties": {},
|
|
2647
|
+
"additionalProperties": false
|
|
2648
|
+
},
|
|
2649
|
+
"effect": "destructive",
|
|
2650
|
+
"release": "noop",
|
|
2651
|
+
"description": "Clears the nativeTag->count map and empties RenderTracker's entire renders store. There is no undo and no snapshot: any render history a human was watching accumulate, or that a colleague armed tracking to collect, is gone. Only call this when the user explicitly asks to reset counts, typically to get a clean baseline before measuring an interaction. Prefer beginMeasurement/endMeasurement for measuring — that opens its own window and never touches the user's overlay data. Takes no params. Returns undefined.",
|
|
2652
|
+
"releaseNote": "packages/highlight-updates/src/highlight-updates/utils/HighlightUpdatesController.ts:1744 (initialize), :1819 (enable), :1905 (enableBackgroundTracking) all return early when __DEV__===false, and the only writers of nodeRenderCounts/RenderTracker are the DevTools-hook interceptors (ProfilerInterceptor.ts:107, CommitProfiler.ts:74). In a release build those stores are permanently empty, so clearRen",
|
|
2653
|
+
"requires": ["@buoy-gg/highlight-updates installed and registered with <FloatingDevTools>"]
|
|
2654
|
+
}],
|
|
2655
|
+
"unavailableWhen": "The app doesn't mount <FloatingDevTools> with @buoy-gg/highlight-updates registered. Also effectively inert in any release/production build: React Native installs __REACT_DEVTOOLS_GLOBAL_HOOK__ only under __DEV__, so the fiber walk finds zero roots and the controller's initialize() bails at HighlightUpdatesController.ts:1744 — read each action's release field before claiming it worked."
|
|
2656
|
+
}, {
|
|
2657
|
+
"toolId": "scenarios",
|
|
2658
|
+
"title": "Scenarios",
|
|
2659
|
+
"summary": "Named, parameterized app states (\"out of stock at store 220\", \"expired token\") stored as a list of steps that each call one other Buoy tool action; running one bends the app into that state deterministically and `deactivate` reverses what is reversible. Reach for it to put the app in a known state BEFORE driving a flow, and to check whether what is on screen is real or simulated — if `active` is non-null, the data the user is looking at is partly fake. Authoring flow: save (always lands as an inert DRAFT) → a human accepts it on the device (or acceptDraft) → preview → run → deactivate.",
|
|
2660
|
+
"actions": [{
|
|
2661
|
+
"action": "listScenarios",
|
|
2662
|
+
"summary": "List every scenario on the device — code, device-saved, and inert drafts — plus which one is currently active.",
|
|
2663
|
+
"params": {
|
|
2664
|
+
"type": "object",
|
|
2665
|
+
"properties": {},
|
|
2666
|
+
"additionalProperties": false
|
|
2667
|
+
},
|
|
2668
|
+
"effect": "read",
|
|
2669
|
+
"release": "works",
|
|
2670
|
+
"description": "Returns {code[], device[], drafts[], active, runState, hasLoaded}. Each entry has id, name, description, version, source, author, tags, vars, stepCount, expectedOutcome, authoredAgainst, usage.runs, plus `steps` (raw {tool,action,params} objects; any step whose params exceed 8KB is sent with params dropped and paramsOmitted:true) and `stepLines` [{label, durability}] — the same plain-English sentences the device UI shows. durability is one of durable | session | transient | one-shot. `active` non-null means the app is showing SIMULATED state: say so before reporting anything observed on the device. `drafts` are INERT — they cannot run until accepted. Identical payload to the tool's sync snapshot. HYDRATION TRAP: the store loads @react_buoy_scenarios_library lazily on first subscribe and this handler does not await it, so if hasLoaded is false, empty `device`/`drafts` means \"not loaded yet\", not \"none saved\" — read again once the Scenarios panel or a dashboard has subscribed.",
|
|
2671
|
+
"requires": ["Scenarios tool installed in the app"]
|
|
2672
|
+
}, {
|
|
2673
|
+
"action": "getScenario",
|
|
2674
|
+
"summary": "Full record for one scenario id, including untruncated step params and undoSteps.",
|
|
2675
|
+
"params": {
|
|
2676
|
+
"type": "object",
|
|
2677
|
+
"properties": {
|
|
2678
|
+
"id": {
|
|
2679
|
+
"type": "string",
|
|
2680
|
+
"description": "Scenario id, from listScenarios. Blank/whitespace counts as missing and throws."
|
|
2681
|
+
}
|
|
2682
|
+
},
|
|
2683
|
+
"required": ["id"],
|
|
2684
|
+
"additionalProperties": false
|
|
2685
|
+
},
|
|
2686
|
+
"effect": "read",
|
|
2687
|
+
"release": "works",
|
|
2688
|
+
"description": "Returns {scenario}. Looks in code scenarios first, then device-saved, then drafts (a code scenario wins an id clash). Use this when listScenarios truncated a step (paramsOmitted:true) or you need undoSteps/vars in full. THROWS `No scenario \"<id>\"` if the id is unknown — and can throw that on a cold device purely because the persisted library has not hydrated yet (see listScenarios).",
|
|
2689
|
+
"requires": ["Scenarios tool installed in the app"]
|
|
2690
|
+
}, {
|
|
2691
|
+
"action": "save",
|
|
2692
|
+
"summary": "Write a new scenario onto the device — it ALWAYS lands in the draft inbox and cannot run until accepted.",
|
|
2693
|
+
"params": {
|
|
2694
|
+
"type": "object",
|
|
2695
|
+
"properties": {
|
|
2696
|
+
"scenario": {
|
|
2697
|
+
"type": "object",
|
|
2698
|
+
"description": "The scenario to save. Always stored as a draft.",
|
|
2699
|
+
"properties": {
|
|
2700
|
+
"id": {
|
|
2701
|
+
"type": "string",
|
|
2702
|
+
"description": "Stable kebab-case id, e.g. 'out-of-stock-item'."
|
|
2703
|
+
},
|
|
2704
|
+
"name": {
|
|
2705
|
+
"type": "string",
|
|
2706
|
+
"description": "Human name; may contain {{variable}} placeholders."
|
|
2707
|
+
},
|
|
2708
|
+
"description": {
|
|
2709
|
+
"type": "string"
|
|
2710
|
+
},
|
|
2711
|
+
"expectedOutcome": {
|
|
2712
|
+
"type": "string",
|
|
2713
|
+
"description": "What SHOULD happen once applied — how a non-developer tells a bug from expected behavior without asking an engineer. Write it."
|
|
2714
|
+
},
|
|
2715
|
+
"version": {
|
|
2716
|
+
"type": "number",
|
|
2717
|
+
"description": "Defaults to 1; bumped automatically when replacing an existing draft."
|
|
2718
|
+
},
|
|
2719
|
+
"author": {
|
|
2720
|
+
"type": "string",
|
|
2721
|
+
"description": "Defaults to 'remote'."
|
|
2722
|
+
},
|
|
2723
|
+
"authoredAgainst": {
|
|
2724
|
+
"type": "string",
|
|
2725
|
+
"description": "App version this was authored against, shown for staleness."
|
|
2726
|
+
},
|
|
2727
|
+
"tags": {
|
|
2728
|
+
"type": "array",
|
|
2729
|
+
"items": {
|
|
2730
|
+
"type": "string"
|
|
2731
|
+
}
|
|
2732
|
+
},
|
|
2733
|
+
"vars": {
|
|
2734
|
+
"type": "array",
|
|
2735
|
+
"description": "Declare a variable for every value a tester might change (store id, user, status code) instead of freezing it into steps. Referenced in step params as {{key}}.",
|
|
2736
|
+
"items": {
|
|
2737
|
+
"type": "object",
|
|
2738
|
+
"properties": {
|
|
2739
|
+
"key": {
|
|
2740
|
+
"type": "string"
|
|
2741
|
+
},
|
|
2742
|
+
"label": {
|
|
2743
|
+
"type": "string"
|
|
2744
|
+
},
|
|
2745
|
+
"type": {
|
|
2746
|
+
"type": "string",
|
|
2747
|
+
"enum": ["string", "number", "boolean", "enum"]
|
|
2748
|
+
},
|
|
2749
|
+
"options": {
|
|
2750
|
+
"type": "array",
|
|
2751
|
+
"description": "enum only.",
|
|
2752
|
+
"items": {
|
|
2753
|
+
"anyOf": [{
|
|
2754
|
+
"type": "string"
|
|
2755
|
+
}, {
|
|
2756
|
+
"type": "number"
|
|
2757
|
+
}, {
|
|
2758
|
+
"type": "boolean"
|
|
2759
|
+
}]
|
|
2760
|
+
}
|
|
2761
|
+
},
|
|
2762
|
+
"default": {
|
|
2763
|
+
"anyOf": [{
|
|
2764
|
+
"type": "string"
|
|
2765
|
+
}, {
|
|
2766
|
+
"type": "number"
|
|
2767
|
+
}, {
|
|
2768
|
+
"type": "boolean"
|
|
2769
|
+
}]
|
|
2770
|
+
}
|
|
2771
|
+
},
|
|
2772
|
+
"required": ["key", "type"]
|
|
2773
|
+
}
|
|
2774
|
+
},
|
|
2775
|
+
"steps": {
|
|
2776
|
+
"type": "array",
|
|
2777
|
+
"description": "At least one, executed in order.",
|
|
2778
|
+
"items": {
|
|
2779
|
+
"type": "object",
|
|
2780
|
+
"properties": {
|
|
2781
|
+
"id": {
|
|
2782
|
+
"type": "string"
|
|
2783
|
+
},
|
|
2784
|
+
"tool": {
|
|
2785
|
+
"type": "string",
|
|
2786
|
+
"description": "Adapter tool id: network | storage | impersonate | route-events | query | time-machine, or 'scenario' for the engine-local 'wait' step. Never 'scenarios' — recursion is refused at pre-flight."
|
|
2787
|
+
},
|
|
2788
|
+
"action": {
|
|
2789
|
+
"type": "string",
|
|
2790
|
+
"description": "Action on that tool, e.g. upsertOverrideRule, deleteOverrideRule, setOverridesEnabled, async.setItem, async.removeItem, async.multiSet, mmkv.set, startImpersonation, stopImpersonation, navigate, setQueryData, invalidate, restore. A reloadApp/reload step is only allowed as the LAST step."
|
|
2791
|
+
},
|
|
2792
|
+
"params": {
|
|
2793
|
+
"type": "object",
|
|
2794
|
+
"description": "JSON params for that action; string leaves may contain {{variable}}. Max 64KB serialized per step.",
|
|
2795
|
+
"additionalProperties": true
|
|
2796
|
+
},
|
|
2797
|
+
"label": {
|
|
2798
|
+
"type": "string",
|
|
2799
|
+
"description": "Plain-English override for the humanized sentence."
|
|
2800
|
+
},
|
|
2801
|
+
"timeoutMs": {
|
|
2802
|
+
"type": "number",
|
|
2803
|
+
"description": "Per-step budget; default 10000."
|
|
2804
|
+
}
|
|
2805
|
+
},
|
|
2806
|
+
"required": ["tool", "action"]
|
|
2807
|
+
}
|
|
2808
|
+
},
|
|
2809
|
+
"undoSteps": {
|
|
2810
|
+
"type": "array",
|
|
2811
|
+
"description": "How to reverse effects with no automatic inverse (any storage write/remove). They run on deactivate and clear the unrestored-keys warning.",
|
|
2812
|
+
"items": {
|
|
2813
|
+
"type": "object",
|
|
2814
|
+
"properties": {
|
|
2815
|
+
"id": {
|
|
2816
|
+
"type": "string"
|
|
2817
|
+
},
|
|
2818
|
+
"tool": {
|
|
2819
|
+
"type": "string"
|
|
2820
|
+
},
|
|
2821
|
+
"action": {
|
|
2822
|
+
"type": "string"
|
|
2823
|
+
},
|
|
2824
|
+
"params": {
|
|
2825
|
+
"type": "object",
|
|
2826
|
+
"additionalProperties": true
|
|
2827
|
+
},
|
|
2828
|
+
"label": {
|
|
2829
|
+
"type": "string"
|
|
2830
|
+
},
|
|
2831
|
+
"timeoutMs": {
|
|
2832
|
+
"type": "number"
|
|
2833
|
+
}
|
|
2834
|
+
},
|
|
2835
|
+
"required": ["tool", "action"]
|
|
2836
|
+
}
|
|
2837
|
+
},
|
|
2838
|
+
"folder": {
|
|
2839
|
+
"type": "string",
|
|
2840
|
+
"description": "The flow this belongs to — \"Checkout\", \"Login\". Reuse a name from `folders` in listScenarios; a scenario nobody can find is a scenario nobody runs."
|
|
2841
|
+
}
|
|
2842
|
+
},
|
|
2843
|
+
"required": ["id", "name", "steps"]
|
|
2844
|
+
},
|
|
2845
|
+
"accept": {
|
|
2846
|
+
"type": "boolean",
|
|
2847
|
+
"description": "IGNORED — the handler never reads it. The save always lands as a draft."
|
|
2848
|
+
}
|
|
2849
|
+
},
|
|
2850
|
+
"required": ["scenario"],
|
|
2851
|
+
"additionalProperties": false
|
|
2852
|
+
},
|
|
2853
|
+
"effect": "write",
|
|
2854
|
+
"release": "works",
|
|
2855
|
+
"description": "Returns {ok:true, savedAsDraft:true, id, message} on success, or {ok:false, error} / {ok:false, errors:[...]} on rejection — it does not throw for validation. Rejects unless: `id` is a string, `name` is a string, `steps` is a non-empty array, every step has both `tool` and `action`, `vars` (if present) is an array, each step's params serialize under 64KB, and the whole scenario is under 256KB. Source is forced to \"draft\", author defaults to \"remote\", unsavedToRepo is set true; saving over an existing DRAFT id replaces it and bumps its version. Tell the user the draft is inert and someone must open Scenarios on the device and tap Accept to library (or call acceptDraft). The `accept` field is read into the params type but never used by the handler — passing it does nothing. Prefer a network `upsertOverrideRule` step over a `query.setQueryData` poke: the override survives refetch and restart, the poke dies on the next fetch. Supply `undoSteps` for anything that writes or removes storage, or deactivation will honestly report the key as unrestored.",
|
|
2856
|
+
"requires": ["Scenarios tool installed in the app"]
|
|
2857
|
+
}, {
|
|
2858
|
+
"action": "acceptDraft",
|
|
2859
|
+
"summary": "Promote a reviewed draft into the runnable device library — this is the human-consent gate, so only do it when the user says to.",
|
|
2860
|
+
"params": {
|
|
2861
|
+
"type": "object",
|
|
2862
|
+
"properties": {
|
|
2863
|
+
"id": {
|
|
2864
|
+
"type": "string",
|
|
2865
|
+
"description": "Draft id, from listScenarios `drafts`."
|
|
2866
|
+
}
|
|
2867
|
+
},
|
|
2868
|
+
"required": ["id"],
|
|
2869
|
+
"additionalProperties": false
|
|
2870
|
+
},
|
|
2871
|
+
"effect": "write",
|
|
2872
|
+
"release": "works",
|
|
2873
|
+
"description": "Moves the draft out of the inbox with source:\"device\" and unsavedToRepo:true, making it runnable. Returns {ok:true, id}. THROWS `No draft \"<id>\"`. Drafts exist precisely so a scenario pushed from chat never becomes runnable on someone's phone without them seeing what it will do — accepting on their behalf skips that review. Ask first, or tell them to tap \"Accept to library\" on the device instead.",
|
|
2874
|
+
"requires": ["Scenarios tool installed in the app"]
|
|
2875
|
+
}, {
|
|
2876
|
+
"action": "discardDraft",
|
|
2877
|
+
"summary": "Permanently delete a draft from the review inbox.",
|
|
2878
|
+
"params": {
|
|
2879
|
+
"type": "object",
|
|
2880
|
+
"properties": {
|
|
2881
|
+
"id": {
|
|
2882
|
+
"type": "string",
|
|
2883
|
+
"description": "Draft id, from listScenarios `drafts`."
|
|
2884
|
+
}
|
|
2885
|
+
},
|
|
2886
|
+
"required": ["id"],
|
|
2887
|
+
"additionalProperties": false
|
|
2888
|
+
},
|
|
2889
|
+
"effect": "destructive",
|
|
2890
|
+
"release": "works",
|
|
2891
|
+
"description": "Removes the draft and rewrites the persisted library. Returns {ok:true, id} even when no draft with that id existed — a success here is not proof anything was deleted. There is no undo: the steps are gone unless you exported the JSON first.",
|
|
2892
|
+
"requires": ["Scenarios tool installed in the app"]
|
|
2893
|
+
}, {
|
|
2894
|
+
"action": "preview",
|
|
2895
|
+
"summary": "Dry-run a scenario: what it WOULD change, with variables resolved, without touching the app.",
|
|
2896
|
+
"params": {
|
|
2897
|
+
"type": "object",
|
|
2898
|
+
"properties": {
|
|
2899
|
+
"id": {
|
|
2900
|
+
"type": "string",
|
|
2901
|
+
"description": "Scenario id, from listScenarios."
|
|
2902
|
+
},
|
|
2903
|
+
"params": {
|
|
2904
|
+
"type": "object",
|
|
2905
|
+
"description": "Variable values, e.g. {storeId:'220', outOfStock:true}. Only string/number/boolean values are kept — objects and arrays are silently dropped. Declared defaults fill in the rest.",
|
|
2906
|
+
"additionalProperties": true
|
|
2907
|
+
}
|
|
2908
|
+
},
|
|
2909
|
+
"required": ["id"],
|
|
2910
|
+
"additionalProperties": false
|
|
2911
|
+
},
|
|
2912
|
+
"effect": "read",
|
|
2913
|
+
"release": "works",
|
|
2914
|
+
"description": "Returns {willDo:[{label,durability}], values, ok, errors, warnings, conflict, expectedOutcome}. `values` is declared defaults merged with the values you supplied (supplied wins), and the labels are rendered THROUGH those values, so no {{placeholder}} survives. `conflict` is non-null when the currently-active scenario touches an overlapping URL pattern / storage key / the impersonation session — running anyway SWAPS (the active one is deactivated first). ok:false lists every blocking reason at once: draft not yet accepted, a step whose tool is not installed in this app, an unknown action, an undeclared or unset {{variable}}, a reload that is not the last step. Always run this before running an unfamiliar scenario. Changes nothing.",
|
|
2915
|
+
"requires": ["Scenarios tool installed in the app"]
|
|
2916
|
+
}, {
|
|
2917
|
+
"action": "run",
|
|
2918
|
+
"summary": "Apply a scenario for real — installs network overrides, writes storage, starts impersonation, navigates — and resolve only once every step has landed.",
|
|
2919
|
+
"params": {
|
|
2920
|
+
"type": "object",
|
|
2921
|
+
"properties": {
|
|
2922
|
+
"id": {
|
|
2923
|
+
"type": "string",
|
|
2924
|
+
"description": "Scenario id, from listScenarios. Must not be a draft."
|
|
2925
|
+
},
|
|
2926
|
+
"params": {
|
|
2927
|
+
"type": "object",
|
|
2928
|
+
"description": "Variable values, e.g. {storeId:'220'}. Only string/number/boolean values are kept — objects and arrays are silently dropped. Declared defaults fill in the rest.",
|
|
2929
|
+
"additionalProperties": true
|
|
2930
|
+
}
|
|
2931
|
+
},
|
|
2932
|
+
"required": ["id"],
|
|
2933
|
+
"additionalProperties": false
|
|
2934
|
+
},
|
|
2935
|
+
"effect": "destructive",
|
|
2936
|
+
"release": "throws",
|
|
2937
|
+
"description": "Atomic pre-flight runs first: on failure it returns {ok:false, preflightErrors:[...], conflict} and NOTHING is applied. Otherwise returns a RunReport {ok, scenarioId, name, steps:[{label, ok, error, skipped, ms}], effects:[{kind,label,...}]}. Steps run in order and STOP at the first failure — later steps are marked skipped, and the steps before it already applied, leaving the device in a partial state (call deactivate). Exclusive activation: running a different scenario while one is active deactivates the active one first. Budgets are 10s per step and 60s overall. Effects vary in reversibility — a network override rule is removed on deactivate, but a storage write has NO automatic inverse and will be reported as unrestored. Drafts cannot run. After this, the device shows a SIMULATED banner: anything the user observes is partly fake until deactivate.",
|
|
2938
|
+
"releaseNote": "packages/scenarios/src/store/scenariosStore.ts:98-104 (isRunnableInThisBuild) — in a release bundle checkBeforeRun injects \"Scenarios cannot run in a production build.\", so the adapter returns {ok:false, preflightErrors:[...]} and applies nothing; scenariosStore.run() at :346-348 throws the same message. Verified by packages/scenarios/src/__tests__/runGate.release.test.ts. Report the refusal — never claim the scenario was applied.",
|
|
2939
|
+
"requires": ["Scenarios tool installed in the app", "every tool a step targets must be installed on the device (network, storage, impersonate, route-events, query, time-machine)", "a development build — release builds refuse"]
|
|
2940
|
+
}, {
|
|
2941
|
+
"action": "deactivate",
|
|
2942
|
+
"summary": "Reverse the active scenario and report honestly what could NOT be restored.",
|
|
2943
|
+
"params": {
|
|
2944
|
+
"type": "object",
|
|
2945
|
+
"properties": {},
|
|
2946
|
+
"additionalProperties": false
|
|
2947
|
+
},
|
|
2948
|
+
"effect": "write",
|
|
2949
|
+
"release": "empty",
|
|
2950
|
+
"description": "Returns {ok:true, report:{removedRules, stoppedImpersonation, clearedTransients, unrestoredStorageKeys[], undoStepsRan, errors[]}}. Deletes the override rules the run created, stops impersonation, and runs the author's undoSteps if any (which clears unrestoredStorageKeys). `unrestoredStorageKeys` names keys the app still holds scenario values for — surface those verbatim to the user, they are real leftover state. Safe when nothing is active: returns an all-zero report. Also the right call after a failed/partial run.",
|
|
2951
|
+
"releaseNote": "Not itself __DEV__-gated, but run is (packages/scenarios/src/store/scenariosStore.ts:98-104), so a release build can never have an active scenario — expect an all-zero report rather than a real reversal.",
|
|
2952
|
+
"requires": ["Scenarios tool installed in the app"]
|
|
2953
|
+
}, {
|
|
2954
|
+
"action": "delete",
|
|
2955
|
+
"summary": "Permanently remove a device-saved scenario from the library.",
|
|
2956
|
+
"params": {
|
|
2957
|
+
"type": "object",
|
|
2958
|
+
"properties": {
|
|
2959
|
+
"id": {
|
|
2960
|
+
"type": "string",
|
|
2961
|
+
"description": "Scenario id from listScenarios `device` — code scenarios and drafts are unaffected."
|
|
2962
|
+
}
|
|
2963
|
+
},
|
|
2964
|
+
"required": ["id"],
|
|
2965
|
+
"additionalProperties": false
|
|
2966
|
+
},
|
|
2967
|
+
"effect": "destructive",
|
|
2968
|
+
"release": "works",
|
|
2969
|
+
"description": "Deactivates it first if it happens to be the active scenario, then drops it from the device list and rewrites persistent storage. Returns {ok:true, id}. Only touches DEVICE-saved scenarios: an id that is a code scenario (registered via defineScenario in app source) or a draft still returns ok:true while deleting nothing — code scenarios come back from the app bundle, drafts need discardDraft. Not recoverable; call export first if the definition matters.",
|
|
2970
|
+
"requires": ["Scenarios tool installed in the app"]
|
|
2971
|
+
}, {
|
|
2972
|
+
"action": "setFolder",
|
|
2973
|
+
"summary": "File a saved scenario under a flow folder (\"Checkout\", \"Login\"), or clear its folder.",
|
|
2974
|
+
"params": {
|
|
2975
|
+
"type": "object",
|
|
2976
|
+
"properties": {
|
|
2977
|
+
"id": {
|
|
2978
|
+
"type": "string",
|
|
2979
|
+
"description": "Scenario id from listScenarios `device` or `drafts`."
|
|
2980
|
+
},
|
|
2981
|
+
"folder": {
|
|
2982
|
+
"type": "string",
|
|
2983
|
+
"description": "Folder name. Omit or pass an empty string to un-file it (it then shows under Ungrouped). Trimmed, inner whitespace collapsed, capped at 32 characters."
|
|
2984
|
+
}
|
|
2985
|
+
},
|
|
2986
|
+
"required": ["id"],
|
|
2987
|
+
"additionalProperties": false
|
|
2988
|
+
},
|
|
2989
|
+
"effect": "write",
|
|
2990
|
+
"release": "works",
|
|
2991
|
+
"description": "Returns {ok:true, id, folder} where folder is the name it actually landed in, or null. A folder is just a string on the scenario — there is no folder record, so it exists exactly as long as something is filed in it and un-filing the last member makes it disappear. A name matching an existing folder case-insensitively snaps to that folder's spelling, so \"checkout\" joins \"Checkout\" instead of splitting it; read `folders` from listScenarios and reuse a name rather than inventing a near-duplicate. THROWS for a `code` scenario: those are filed by the `folder` field in defineScenario() in app source, so the QA menu looks the same on every device. Does not bump `version` — filing is organization, not an edit to what the scenario does.",
|
|
2992
|
+
"requires": ["Scenarios tool installed in the app"]
|
|
2993
|
+
}, {
|
|
2994
|
+
"action": "listFolders",
|
|
2995
|
+
"summary": "Every folder currently in use, alphabetical.",
|
|
2996
|
+
"params": {
|
|
2997
|
+
"type": "object",
|
|
2998
|
+
"properties": {},
|
|
2999
|
+
"additionalProperties": false
|
|
3000
|
+
},
|
|
3001
|
+
"effect": "read",
|
|
3002
|
+
"release": "works",
|
|
3003
|
+
"description": "Returns {folders:[...]} — the same derived, case-insensitively deduped list the device's folder bar renders, across code, device AND draft scenarios. listScenarios already includes this as `folders`; call this only when the folder names are all you need.",
|
|
3004
|
+
"requires": ["Scenarios tool installed in the app"]
|
|
3005
|
+
}, {
|
|
3006
|
+
"action": "getActive",
|
|
3007
|
+
"summary": "Cheap check of whether the app is currently showing simulated state.",
|
|
3008
|
+
"params": {
|
|
3009
|
+
"type": "object",
|
|
3010
|
+
"properties": {},
|
|
3011
|
+
"additionalProperties": false
|
|
3012
|
+
},
|
|
3013
|
+
"effect": "read",
|
|
3014
|
+
"release": "works",
|
|
3015
|
+
"description": "Returns {active} — null, or {scenarioId, name, version, source, runAt, varValues, effects[{kind,label,...}], expiresAt}. Call this before trusting any data read off the device: if active is non-null, prices/inventory/user/session may be bent by the listed effects, and the answer to \"is this a bug?\" is probably \"a scenario is running\". Same hydration caveat as listScenarios — a cold read before the store has loaded @react_buoy_scenarios_active reports null.",
|
|
3016
|
+
"requires": ["Scenarios tool installed in the app"]
|
|
3017
|
+
}, {
|
|
3018
|
+
"action": "export",
|
|
3019
|
+
"summary": "Get one scenario as pretty-printed JSON, to paste into the repo.",
|
|
3020
|
+
"params": {
|
|
3021
|
+
"type": "object",
|
|
3022
|
+
"properties": {
|
|
3023
|
+
"id": {
|
|
3024
|
+
"type": "string",
|
|
3025
|
+
"description": "Scenario id from listScenarios (code, device, or draft)."
|
|
3026
|
+
}
|
|
3027
|
+
},
|
|
3028
|
+
"required": ["id"],
|
|
3029
|
+
"additionalProperties": false
|
|
3030
|
+
},
|
|
3031
|
+
"effect": "read",
|
|
3032
|
+
"release": "works",
|
|
3033
|
+
"description": "Returns {json} — JSON.stringify of the full Scenario (steps, undoSteps, vars, metadata) with 2-space indent. Use it to move a device-authored scenario into app source as a defineScenario(...) entry, or to back one up before delete/discardDraft. THROWS `No scenario \"<id>\"` for an unknown id, including when the library has not hydrated yet.",
|
|
3034
|
+
"requires": ["Scenarios tool installed in the app"]
|
|
3035
|
+
}],
|
|
3036
|
+
"unavailableWhen": "The app does not install the Scenarios tool (no `createScenariosTool` / scenarios preset passed to `<FloatingDevTools />`), so \"scenarios\" is absent from the device's capability list. Driven through MCP or the desktop dashboard, the device must also be on a Pro license (`requireProDevice`)."
|
|
3037
|
+
}, {
|
|
3038
|
+
"toolId": "perf-monitor",
|
|
3039
|
+
"title": "Perf Monitor",
|
|
3040
|
+
"summary": "Measures runtime performance on the device: live JS/UI FPS, CPU and memory sampled every 250ms, plus recorded \"benchmark runs\" saved to disk and an automation mode that navigates a screen with different query params and ranks the variants. Reach for it to answer \"is this screen slow / which variant is faster / did my fix land\", not to inspect data — it reads no app state. Two traps: live metrics are all zeros until `setEnabled {enabled:true}` arms sampling, and `startAutomation` swallows every validation error, so a bad config returns ok and simply never runs.",
|
|
3041
|
+
"actions": [{
|
|
3042
|
+
"action": "setEnabled",
|
|
3043
|
+
"summary": "Arm/disarm live perf sampling (JS FPS, UI FPS, CPU, memory) on the device.",
|
|
3044
|
+
"params": {
|
|
3045
|
+
"type": "object",
|
|
3046
|
+
"properties": {
|
|
3047
|
+
"enabled": {
|
|
3048
|
+
"type": "boolean",
|
|
3049
|
+
"description": "true starts silent sampling for the remote viewer; false stops it (unless the on-device HUD or a recording is holding it open)."
|
|
3050
|
+
}
|
|
3051
|
+
},
|
|
3052
|
+
"required": ["enabled"],
|
|
3053
|
+
"additionalProperties": false
|
|
3054
|
+
},
|
|
3055
|
+
"effect": "write",
|
|
3056
|
+
"release": "works",
|
|
3057
|
+
"description": "Starts SILENT remote sampling: the device samples every 250ms and streams live.snapshot (current values + a 120-sample / 30s history ring) but its own on-device HUD stays hidden. This is the arming step for every live read — before it, live.snapshot is zeros with an empty history, and after setEnabled{false} the values freeze at their last tick. Always call with enabled:true before reading live metrics, and turn it back off when done (sampling costs a 250ms JS timer). Works with or without react-native-performance-toolkit; without the native module it falls back to a pure-JS sampler and cpuUsage reads 0."
|
|
3058
|
+
}, {
|
|
3059
|
+
"action": "startRecording",
|
|
3060
|
+
"summary": "Start a manual benchmark recording (auto-named, current route captured).",
|
|
3061
|
+
"params": {
|
|
3062
|
+
"type": "object",
|
|
3063
|
+
"properties": {},
|
|
3064
|
+
"additionalProperties": false
|
|
3065
|
+
},
|
|
3066
|
+
"effect": "write",
|
|
3067
|
+
"release": "works",
|
|
3068
|
+
"description": "Begins accumulating perf samples into an in-memory run. The device auto-names it and tags the current route (when @buoy-gg/route-events is installed); the desktop/agent cannot pass a name here — naming happens at savePending. Self-arms sampling, so setEnabled is not required first. No-op if a recording is already active. Per-component render capture rides along only when @buoy-gg/highlight-updates is installed AND the build is a dev build — in a release build CommitProfiler.isSupported() returns false (packages/highlight-updates/src/highlight-updates/utils/CommitProfiler.ts:458), so the saved report has FPS/CPU/memory but renders:null plus a diagnostic explaining why. Pair with stopRecording + savePending.",
|
|
3069
|
+
"requires": ["@buoy-gg/highlight-updates for render-commit data (dev builds only)"]
|
|
3070
|
+
}, {
|
|
3071
|
+
"action": "stopRecording",
|
|
3072
|
+
"summary": "Stop the active recording and hold it unsaved, awaiting a name.",
|
|
3073
|
+
"params": {
|
|
3074
|
+
"type": "object",
|
|
3075
|
+
"properties": {},
|
|
3076
|
+
"additionalProperties": false
|
|
3077
|
+
},
|
|
3078
|
+
"effect": "write",
|
|
3079
|
+
"release": "works",
|
|
3080
|
+
"description": "Stops sampling for the run and parks the report in memory instead of persisting it. Returns { defaultName, sampleCount } so you can prompt for a name, or null when there was nothing to keep (no active recording, or zero samples because it was stopped almost immediately). The held run also shows up as live.pendingSave on the next snapshot. Nothing is on disk until savePending — a stopRecording followed by neither savePending nor discardPending leaves the run dangling and it is lost on reload."
|
|
3081
|
+
}, {
|
|
3082
|
+
"action": "savePending",
|
|
3083
|
+
"summary": "Persist the run held by stopRecording under a chosen name.",
|
|
3084
|
+
"params": {
|
|
3085
|
+
"type": "object",
|
|
3086
|
+
"properties": {
|
|
3087
|
+
"name": {
|
|
3088
|
+
"type": "string",
|
|
3089
|
+
"description": "Name for the saved run. Empty or whitespace-only falls back to the device's auto-generated name."
|
|
3090
|
+
}
|
|
3091
|
+
},
|
|
3092
|
+
"additionalProperties": false
|
|
3093
|
+
},
|
|
3094
|
+
"effect": "write",
|
|
3095
|
+
"release": "works",
|
|
3096
|
+
"description": "Writes the held report to persistent storage (@react_buoy/perf-monitor/report/<id>) and appends it to the index at @react_buoy/perf-monitor/index, then clears live.pendingSave. An empty/whitespace name falls back to the auto-generated one. Silently does nothing if no run is held (stopRecording was never called, or it returned null). The saved id is what loadReport / deleteReport / the MCP compare_reports tool take."
|
|
3097
|
+
}, {
|
|
3098
|
+
"action": "discardPending",
|
|
3099
|
+
"summary": "Throw away the just-stopped recording without saving it.",
|
|
3100
|
+
"params": {
|
|
3101
|
+
"type": "object",
|
|
3102
|
+
"properties": {
|
|
3103
|
+
"label": {
|
|
3104
|
+
"type": "string"
|
|
3105
|
+
}
|
|
3106
|
+
},
|
|
3107
|
+
"additionalProperties": false
|
|
3108
|
+
},
|
|
3109
|
+
"effect": "destructive",
|
|
3110
|
+
"release": "works",
|
|
3111
|
+
"description": "Drops the report held by stopRecording and clears live.pendingSave. The samples are gone — they were never written to disk and cannot be recovered. Use when the user cancels the name prompt or the run was junk."
|
|
3112
|
+
}, {
|
|
3113
|
+
"action": "mark",
|
|
3114
|
+
"summary": "Drop a labelled timeline marker into the in-flight recording.",
|
|
3115
|
+
"params": {
|
|
3116
|
+
"type": "object",
|
|
3117
|
+
"properties": {
|
|
3118
|
+
"label": {
|
|
3119
|
+
"type": "string",
|
|
3120
|
+
"description": "Human label for this moment, e.g. \"tapped checkout\". Optional; an unlabelled marker is just a timestamp."
|
|
3121
|
+
}
|
|
3122
|
+
},
|
|
3123
|
+
"additionalProperties": false
|
|
3124
|
+
},
|
|
3125
|
+
"effect": "write",
|
|
3126
|
+
"release": "works",
|
|
3127
|
+
"description": "Appends { timestamp, label } to the active run so the timeline view can line a spike up with an action (\"tapped submit\", \"list scrolled\"). SILENT NO-OP when no recording is active — BenchmarkRecorder.mark returns immediately if !isRecording, so this returns ok even when nothing was recorded. Check live.isRecording first before telling a user a marker landed."
|
|
3128
|
+
}, {
|
|
3129
|
+
"action": "startAutomation",
|
|
3130
|
+
"summary": "Run a multi-case benchmark batch: navigate, record, and save a run per variant.",
|
|
3131
|
+
"params": {
|
|
3132
|
+
"type": "object",
|
|
3133
|
+
"properties": {
|
|
3134
|
+
"config": {
|
|
3135
|
+
"type": "object",
|
|
3136
|
+
"description": "Full batch config. Not merged with the device's saved settings — whatever you omit takes the runner's own fallback, so read getAutomationConfig first if you want the user's tuned profile.",
|
|
3137
|
+
"properties": {
|
|
3138
|
+
"targetRoute": {
|
|
3139
|
+
"type": "string",
|
|
3140
|
+
"description": "Pathname every case navigates to, e.g. \"/perf-test\". Required unless every case sets its own route. The screen must render its variant from the query params."
|
|
3141
|
+
},
|
|
3142
|
+
"bounceRoute": {
|
|
3143
|
+
"type": "string",
|
|
3144
|
+
"description": "Pathname visited between runs to force the target screen to remount. MUST differ from every case route or the whole batch is rejected silently. Typically \"/\"."
|
|
3145
|
+
},
|
|
3146
|
+
"cases": {
|
|
3147
|
+
"type": "array",
|
|
3148
|
+
"description": "The variants to compare, in order; case #0 is the baseline column. At least one required.",
|
|
3149
|
+
"items": {
|
|
3150
|
+
"type": "object",
|
|
3151
|
+
"properties": {
|
|
3152
|
+
"id": {
|
|
3153
|
+
"type": "string",
|
|
3154
|
+
"description": "Optional editor-stable id; the runner derives its own case id from batchId+index if omitted."
|
|
3155
|
+
},
|
|
3156
|
+
"name": {
|
|
3157
|
+
"type": "string",
|
|
3158
|
+
"description": "Display name; becomes the saved run's name."
|
|
3159
|
+
},
|
|
3160
|
+
"params": {
|
|
3161
|
+
"type": "object",
|
|
3162
|
+
"description": "Query params applied via router.replace, e.g. { \"renderer\": \"v2\", \"count\": \"100\" }. String values only.",
|
|
3163
|
+
"additionalProperties": true
|
|
3164
|
+
},
|
|
3165
|
+
"route": {
|
|
3166
|
+
"type": "string",
|
|
3167
|
+
"description": "Per-case route override; defaults to targetRoute."
|
|
3168
|
+
}
|
|
3169
|
+
},
|
|
3170
|
+
"required": ["name"]
|
|
3171
|
+
}
|
|
3172
|
+
},
|
|
3173
|
+
"perCaseDurationMs": {
|
|
3174
|
+
"type": "number",
|
|
3175
|
+
"description": "Recording length per run. Device default 5000 (clamped 500-120000 when persisted)."
|
|
3176
|
+
},
|
|
3177
|
+
"settleMs": {
|
|
3178
|
+
"type": "number",
|
|
3179
|
+
"description": "Idle gap after navigation lands before recording starts. Default 600."
|
|
3180
|
+
},
|
|
3181
|
+
"navTimeoutMs": {
|
|
3182
|
+
"type": "number",
|
|
3183
|
+
"description": "Max wait for the route-change event after replace(). Default 5000."
|
|
3184
|
+
},
|
|
3185
|
+
"runsPerCase": {
|
|
3186
|
+
"type": "number",
|
|
3187
|
+
"description": "Runs per case; the median becomes the canonical result. Default 3, range 1-10."
|
|
3188
|
+
},
|
|
3189
|
+
"coolDownMs": {
|
|
3190
|
+
"type": "number",
|
|
3191
|
+
"description": "Idle between runs and cases so thermals recover. Device default 8000 — raising this is the fix when later cases score worse than earlier ones."
|
|
3192
|
+
},
|
|
3193
|
+
"discardWarmupRuns": {
|
|
3194
|
+
"type": "number",
|
|
3195
|
+
"description": "Drop the first N runs of each case before taking the median. Default 0."
|
|
3196
|
+
},
|
|
3197
|
+
"discardWarmupCase": {
|
|
3198
|
+
"type": "boolean",
|
|
3199
|
+
"description": "Insert one throwaway case at the front and drop it, so the batch's cold start does not poison whichever case runs first. Acts only on an explicit true."
|
|
3200
|
+
},
|
|
3201
|
+
"shuffleCases": {
|
|
3202
|
+
"type": "boolean",
|
|
3203
|
+
"description": "Interleave and shuffle case order (deterministic per batchId) so thermal drift cannot systematically favour early cases."
|
|
3204
|
+
},
|
|
3205
|
+
"reloadBetweenCases": {
|
|
3206
|
+
"type": "boolean",
|
|
3207
|
+
"description": "Fully reload the JS bundle between cases. Kills all in-memory app state and needs <AutomationResumer/> mounted; adds ~1-3s per case."
|
|
3208
|
+
},
|
|
3209
|
+
"reloadStrategy": {
|
|
3210
|
+
"type": "string",
|
|
3211
|
+
"enum": ["auto", "dev-settings", "expo-updates"],
|
|
3212
|
+
"description": "How to reload. auto = DevSettings.reload() in dev, Updates.reloadAsync() otherwise."
|
|
3213
|
+
},
|
|
3214
|
+
"postReloadSettleMs": {
|
|
3215
|
+
"type": "number",
|
|
3216
|
+
"description": "Extra settle for the first case after a reload. Defaults to settleMs * 2."
|
|
3217
|
+
},
|
|
3218
|
+
"captureRenders": {
|
|
3219
|
+
"type": "boolean",
|
|
3220
|
+
"description": "Capture per-component render counts/durations per run. Needs @buoy-gg/highlight-updates and a dev build; produces nothing in release."
|
|
3221
|
+
},
|
|
3222
|
+
"captureRenderDetail": {
|
|
3223
|
+
"type": "boolean",
|
|
3224
|
+
"description": "Also capture changed prop keys and parent names. Heavier; default false."
|
|
3225
|
+
}
|
|
3226
|
+
},
|
|
3227
|
+
"required": ["targetRoute", "bounceRoute", "cases"]
|
|
3228
|
+
}
|
|
3229
|
+
},
|
|
3230
|
+
"required": ["config"],
|
|
3231
|
+
"additionalProperties": false
|
|
3232
|
+
},
|
|
3233
|
+
"effect": "destructive",
|
|
3234
|
+
"release": "works",
|
|
3235
|
+
"description": "Fire-and-forget. The DEVICE owns the loop: for each case it bounces to bounceRoute, navigates to targetRoute with that case's query params, settles, records for perCaseDurationMs, saves the run tagged with a shared batchId, cools down, and repeats runsPerCase times. Returns immediately with no result — poll live.automation.phase (idle/navigating/recording/reloading/done/cancelled), live.automationCompleted, or the growing index to follow it; a batch takes minutes. THE BIG TRAP: every failure is swallowed by the adapter (`void AutomationRunner.start(config).catch(() => {})`), so it returns ok and nothing happens when config is missing, cases is empty, expo-router is absent, targetRoute is empty, or any case route equals bounceRoute (validateConfig throws on that — no remount would happen). If live.automation.phase never leaves \"idle\", the config was rejected, not slow. reloadBetweenCases tears down the JS realm between cases and needs <AutomationResumer/> mounted at the router root; in a release build without expo-updates the reload throws and the batch quietly finishes in-process instead. captureRenders yields no render data in a release build (React DevTools hook is dev-only).",
|
|
3236
|
+
"requires": ["expo-router (navigation) — without it this is a silent no-op", "@buoy-gg/route-events for reliable navigation waits (otherwise it falls back to a fixed sleep)", "<AutomationResumer/> mounted at the router root when reloadBetweenCases is true", "a dev build (DevSettings) OR expo-updates installed when reloadBetweenCases is true — in a release build without expo-updates the reload silently fails and the batch continues in-process with leak risk", "@buoy-gg/highlight-updates + a dev build for captureRenders data"]
|
|
3237
|
+
}, {
|
|
3238
|
+
"action": "cancelAutomation",
|
|
3239
|
+
"summary": "Request cancellation of the in-flight benchmark batch.",
|
|
3240
|
+
"params": {
|
|
3241
|
+
"type": "object",
|
|
3242
|
+
"properties": {},
|
|
3243
|
+
"additionalProperties": false
|
|
3244
|
+
},
|
|
3245
|
+
"effect": "write",
|
|
3246
|
+
"release": "works",
|
|
3247
|
+
"description": "Asks the runner to stop after the current step and clears the persisted pending-batch state so a reload cannot resume it. No-op when no batch is running. Runs already saved remain on disk under the batch's batchId — delete them with deleteBatch if you want the batch gone. The status settles to phase \"cancelled\" and stays sticky in live.automationCompleted until acknowledgeAutomation."
|
|
3248
|
+
}, {
|
|
3249
|
+
"action": "acknowledgeAutomation",
|
|
3250
|
+
"summary": "Reset a finished/cancelled batch to idle and clear the sticky completion flag.",
|
|
3251
|
+
"params": {
|
|
3252
|
+
"type": "object",
|
|
3253
|
+
"properties": {},
|
|
3254
|
+
"additionalProperties": false
|
|
3255
|
+
},
|
|
3256
|
+
"effect": "write",
|
|
3257
|
+
"release": "works",
|
|
3258
|
+
"description": "The handshake that says \"I have seen this batch finish\": clears live.automationCompleted and puts live.automation back to phase \"idle\" so the same batch cannot re-trigger a navigation. Call it once after you have read the results, and on tool-open to discard a stale prior completion. Careful: it is global — clearing it also blinds any other connected dashboard that was waiting on that flag, which is why index-based completion detection (watching new batchIds appear) is more reliable than polling automationCompleted."
|
|
3259
|
+
}, {
|
|
3260
|
+
"action": "refreshIndex",
|
|
3261
|
+
"summary": "Force a re-read of the saved-recordings index and push a fresh snapshot.",
|
|
3262
|
+
"params": {
|
|
3263
|
+
"type": "object",
|
|
3264
|
+
"properties": {},
|
|
3265
|
+
"additionalProperties": false
|
|
3266
|
+
},
|
|
3267
|
+
"effect": "read",
|
|
3268
|
+
"release": "works",
|
|
3269
|
+
"description": "Re-reads @react_buoy/perf-monitor/index from disk and re-emits the snapshot. Returns nothing — read the result from snapshot.index (newest first: id, name, createdAt, route, durationMs, sampleCount, jsFpsAvg, uiFpsAvg, cpuAvg, memMaxMb, jank counts, plus batchId/batchIndex/caseId/runIndex/isMedianRun and renderCommits/renderWasted/topRenderers for batch runs). Call it on tool-open: if the device booted before storage was ready the cached index can be empty, and without this it stays empty until the next save or delete."
|
|
3270
|
+
}, {
|
|
3271
|
+
"action": "getAutomationConfig",
|
|
3272
|
+
"summary": "Read the device's saved benchmark profile (durations, runs, cooldown, toggles).",
|
|
3273
|
+
"params": {
|
|
3274
|
+
"type": "object",
|
|
3275
|
+
"properties": {},
|
|
3276
|
+
"additionalProperties": false
|
|
3277
|
+
},
|
|
3278
|
+
"effect": "read",
|
|
3279
|
+
"release": "works",
|
|
3280
|
+
"description": "Loads and returns the persisted AutomationConfig from @react_buoy/perf-monitor/automation — perCaseDurationMs, settleMs, navTimeoutMs, runsPerCase, coolDownMs, discardWarmupRuns/Case, shuffleCases, reloadBetweenCases, reloadStrategy, captureRenders, plus the user's last cases and targetRoute. Do this before startAutomation so a run inherits the user's tuned pacing instead of invented defaults, and ALWAYS before setAutomationConfig so you can merge rather than clobber. Note the snapshot's own automationConfig field omits `cases`; this action returns them."
|
|
3281
|
+
}, {
|
|
3282
|
+
"action": "setAutomationConfig",
|
|
3283
|
+
"summary": "Overwrite the device's persisted benchmark profile (FULL REPLACE, not a merge).",
|
|
3284
|
+
"params": {
|
|
3285
|
+
"type": "object",
|
|
3286
|
+
"properties": {
|
|
3287
|
+
"config": {
|
|
3288
|
+
"type": "object",
|
|
3289
|
+
"description": "The COMPLETE config to persist — merge your changes onto the getAutomationConfig result, since omitted fields are reset to defaults (and omitted `cases` are erased).",
|
|
3290
|
+
"properties": {
|
|
3291
|
+
"targetRoute": {
|
|
3292
|
+
"type": "string",
|
|
3293
|
+
"description": "Saved target pathname. Omitting it blanks the user's saved route."
|
|
3294
|
+
},
|
|
3295
|
+
"bounceRoute": {
|
|
3296
|
+
"type": "string",
|
|
3297
|
+
"description": "Saved bounce pathname. Defaults to \"/\" when omitted."
|
|
3298
|
+
},
|
|
3299
|
+
"cases": {
|
|
3300
|
+
"type": "array",
|
|
3301
|
+
"description": "The user's saved case list. OMITTING THIS DELETES IT.",
|
|
3302
|
+
"items": {
|
|
3303
|
+
"type": "object",
|
|
3304
|
+
"properties": {
|
|
3305
|
+
"id": {
|
|
3306
|
+
"type": "string"
|
|
3307
|
+
},
|
|
3308
|
+
"name": {
|
|
3309
|
+
"type": "string",
|
|
3310
|
+
"description": "Blank names are stored as \"Untitled\"."
|
|
3311
|
+
},
|
|
3312
|
+
"params": {
|
|
3313
|
+
"type": "object",
|
|
3314
|
+
"description": "Query params; non-string values are coerced to strings.",
|
|
3315
|
+
"additionalProperties": true
|
|
3316
|
+
},
|
|
3317
|
+
"route": {
|
|
3318
|
+
"type": "string",
|
|
3319
|
+
"description": "Per-case override; ignored unless it starts with \"/\"."
|
|
3320
|
+
}
|
|
3321
|
+
},
|
|
3322
|
+
"required": ["name"]
|
|
3323
|
+
}
|
|
3324
|
+
},
|
|
3325
|
+
"perCaseDurationMs": {
|
|
3326
|
+
"type": "number",
|
|
3327
|
+
"description": "Recording length per run (clamped 500-120000, default 5000)."
|
|
3328
|
+
},
|
|
3329
|
+
"settleMs": {
|
|
3330
|
+
"type": "number",
|
|
3331
|
+
"description": "Idle after navigation before recording (0-30000, default 600)."
|
|
3332
|
+
},
|
|
3333
|
+
"navTimeoutMs": {
|
|
3334
|
+
"type": "number",
|
|
3335
|
+
"description": "Navigation wait cap (500-60000, default 5000)."
|
|
3336
|
+
},
|
|
3337
|
+
"runsPerCase": {
|
|
3338
|
+
"type": "number",
|
|
3339
|
+
"description": "Runs per case (1-10, default 3)."
|
|
3340
|
+
},
|
|
3341
|
+
"coolDownMs": {
|
|
3342
|
+
"type": "number",
|
|
3343
|
+
"description": "Idle between runs/cases (0-30000, default 8000)."
|
|
3344
|
+
},
|
|
3345
|
+
"discardWarmupRuns": {
|
|
3346
|
+
"type": "number",
|
|
3347
|
+
"description": "Warmup runs discarded per case (0-9, default 0)."
|
|
3348
|
+
},
|
|
3349
|
+
"discardWarmupCase": {
|
|
3350
|
+
"type": "boolean",
|
|
3351
|
+
"description": "Prepend a throwaway case and drop it. Default true in the shipped profile."
|
|
3352
|
+
},
|
|
3353
|
+
"shuffleCases": {
|
|
3354
|
+
"type": "boolean",
|
|
3355
|
+
"description": "Interleave + shuffle case order. Default true."
|
|
3356
|
+
},
|
|
3357
|
+
"reloadBetweenCases": {
|
|
3358
|
+
"type": "boolean",
|
|
3359
|
+
"description": "Reload the JS bundle between cases. Default true in the shipped profile."
|
|
3360
|
+
},
|
|
3361
|
+
"reloadStrategy": {
|
|
3362
|
+
"type": "string",
|
|
3363
|
+
"enum": ["auto", "dev-settings", "expo-updates"],
|
|
3364
|
+
"description": "Anything else is coerced to \"auto\"."
|
|
3365
|
+
},
|
|
3366
|
+
"postReloadSettleMs": {
|
|
3367
|
+
"type": "number",
|
|
3368
|
+
"description": "Extra settle after a reload (0-60000)."
|
|
3369
|
+
},
|
|
3370
|
+
"captureRenders": {
|
|
3371
|
+
"type": "boolean",
|
|
3372
|
+
"description": "Per-component render capture. Default true; produces no data in release builds."
|
|
3373
|
+
},
|
|
3374
|
+
"captureRenderDetail": {
|
|
3375
|
+
"type": "boolean",
|
|
3376
|
+
"description": "Changed prop keys + parent names. Default false."
|
|
3377
|
+
}
|
|
3378
|
+
}
|
|
3379
|
+
}
|
|
3380
|
+
},
|
|
3381
|
+
"additionalProperties": false
|
|
3382
|
+
},
|
|
3383
|
+
"effect": "destructive",
|
|
3384
|
+
"release": "works",
|
|
3385
|
+
"description": "Sanitizes and writes the whole config to @react_buoy/perf-monitor/automation, then returns the stored result. THIS IS A FULL REPLACE: sanitize() rebuilds every field, so a config passed without `cases` wipes the user's saved case matrix and one without `targetRoute` blanks it (packages/perf-monitor/src/perf-monitor/utils/automationSettings.ts sanitize + saveAutomationConfig). Read getAutomationConfig, spread your overrides onto it, and send the merged object. Numbers are clamped (perCaseDurationMs 500-120000, runsPerCase 1-10, coolDownMs 0-30000, settleMs 0-30000, navTimeoutMs 500-60000). Only for changes the user wants to STICK — for a one-off, pass overrides to startAutomation instead. Passing no config is a no-op that just returns the current one."
|
|
3386
|
+
}, {
|
|
3387
|
+
"action": "loadReport",
|
|
3388
|
+
"summary": "Fetch one full saved benchmark report (all samples + aggregate stats) by id.",
|
|
3389
|
+
"params": {
|
|
3390
|
+
"type": "object",
|
|
3391
|
+
"properties": {
|
|
3392
|
+
"id": {
|
|
3393
|
+
"type": "string",
|
|
3394
|
+
"description": "Report id from snapshot.index, e.g. \"bench-1779574310712-a1b2c3\"."
|
|
3395
|
+
}
|
|
3396
|
+
},
|
|
3397
|
+
"required": ["id"],
|
|
3398
|
+
"additionalProperties": false
|
|
3399
|
+
},
|
|
3400
|
+
"effect": "read",
|
|
3401
|
+
"release": "works",
|
|
3402
|
+
"description": "Reads @react_buoy/perf-monitor/report/<id> and returns the whole BenchmarkReport: metadata (name, route, batch fields, environmentSignals like thermalState/battery/network), every 250ms sample, markers, diagnostics, aggregate stats (avg/p95 FPS-CPU-memory, jank counts) and `renders` when render capture ran. Returns null for an unknown id. Payloads are large — use the index summary fields for lists and only load a report when you need the timeline or a head-to-head comparison."
|
|
3403
|
+
}, {
|
|
3404
|
+
"action": "deleteReport",
|
|
3405
|
+
"summary": "Permanently delete one saved benchmark run.",
|
|
3406
|
+
"params": {
|
|
3407
|
+
"type": "object",
|
|
3408
|
+
"properties": {
|
|
3409
|
+
"id": {
|
|
3410
|
+
"type": "string",
|
|
3411
|
+
"description": "Report id from snapshot.index."
|
|
3412
|
+
}
|
|
3413
|
+
},
|
|
3414
|
+
"required": ["id"],
|
|
3415
|
+
"additionalProperties": false
|
|
3416
|
+
},
|
|
3417
|
+
"effect": "destructive",
|
|
3418
|
+
"release": "works",
|
|
3419
|
+
"description": "Removes @react_buoy/perf-monitor/report/<id> and drops its index entry, then re-emits the index. Irreversible — the samples are gone. Deleting one run of a multi-run case leaves the rest of the batch in place, which can skew a later re-ranking of that batchId; prefer deleteBatch for a whole batch."
|
|
3420
|
+
}, {
|
|
3421
|
+
"action": "deleteBatch",
|
|
3422
|
+
"summary": "Permanently delete every run belonging to one automation batch.",
|
|
3423
|
+
"params": {
|
|
3424
|
+
"type": "object",
|
|
3425
|
+
"properties": {
|
|
3426
|
+
"batchId": {
|
|
3427
|
+
"type": "string",
|
|
3428
|
+
"description": "Batch id from an index entry's batchId or from live.automation/automationCompleted, e.g. \"batch-1779574310712-ghww\"."
|
|
3429
|
+
}
|
|
3430
|
+
},
|
|
3431
|
+
"required": ["batchId"],
|
|
3432
|
+
"additionalProperties": false
|
|
3433
|
+
},
|
|
3434
|
+
"effect": "destructive",
|
|
3435
|
+
"release": "works",
|
|
3436
|
+
"description": "Cascade-deletes all reports whose index entry carries this batchId and returns how many were removed (0 when the batchId matches nothing). Irreversible. This is the right cleanup after a botched or cancelled batch — it removes every run and failure placeholder in one serialized index mutation, instead of fanning out parallel deleteReport calls."
|
|
3437
|
+
}, {
|
|
3438
|
+
"action": "clearAll",
|
|
3439
|
+
"summary": "Delete EVERY saved benchmark recording on the device.",
|
|
3440
|
+
"params": {
|
|
3441
|
+
"type": "object",
|
|
3442
|
+
"properties": {},
|
|
3443
|
+
"additionalProperties": false
|
|
3444
|
+
},
|
|
3445
|
+
"effect": "destructive",
|
|
3446
|
+
"release": "works",
|
|
3447
|
+
"description": "Wipes all @react_buoy/perf-monitor/report/* blobs and empties the index. Irreversible and total — every manual recording and every batch, including baselines someone may be comparing against. Only run on an explicit, unambiguous request to clear all recordings; deleteReport or deleteBatch cover every narrower case."
|
|
3448
|
+
}],
|
|
3449
|
+
"unavailableWhen": "The host app does not have @buoy-gg/perf-monitor installed — FloatingDevTools only registers this adapter when the package resolves (packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:382). Automation actions additionally need expo-router; without it startAutomation is a silent no-op."
|
|
3450
|
+
}, {
|
|
3451
|
+
"toolId": "assets",
|
|
3452
|
+
"title": "Assets",
|
|
3453
|
+
"summary": "Inventory of what the app SHIPS — every bundled asset (images, fonts, video, audio, data) with dimensions, @1x/@2x/@3x scale coverage, content hash, byte size, loaded-vs-never-loaded state, audit findings (duplicates, unused, WebP candidates, over-decode, heavy GIFs/fonts, scale gaps), and a diff vs a saved baseline. Reach for it for bundle-size questions (\"what's making the app big\", \"did this branch add megabytes\", \"which images ship but are never used\"); use the images tool instead for what the app actually RENDERS. Nothing here is __DEV__-gated (zero `__DEV__` in packages/assets/src), but the Metro dev server is what supplies the full-bundle graph and real byte sizes — in an embedded/release bundle the inventory shrinks to registry-only records with estimated sizes.",
|
|
3454
|
+
"actions": [{
|
|
3455
|
+
"action": "list",
|
|
3456
|
+
"summary": "The whole asset inventory, largest-first, plus stats, scan status, audit insights and the baseline diff in one call.",
|
|
3457
|
+
"params": {
|
|
3458
|
+
"type": "object",
|
|
3459
|
+
"properties": {
|
|
3460
|
+
"limit": {
|
|
3461
|
+
"type": "number",
|
|
3462
|
+
"description": "Max records to return, largest first. Default 50, clamped to 1..500."
|
|
3463
|
+
},
|
|
3464
|
+
"kind": {
|
|
3465
|
+
"type": "string",
|
|
3466
|
+
"enum": ["image", "font", "video", "audio", "data", "other"],
|
|
3467
|
+
"description": "Only assets of this kind. Any other value throws 'kind must be one of: image, font, video, audio, data, other'."
|
|
3468
|
+
},
|
|
3469
|
+
"loadedOnly": {
|
|
3470
|
+
"type": "boolean",
|
|
3471
|
+
"description": "true keeps ONLY assets loaded at runtime. There is no 'unusedOnly' param here — for shipped-but-never-loaded assets read insights.neverLoaded (ids) or fetch with a high limit and filter loaded===false."
|
|
3472
|
+
}
|
|
3473
|
+
},
|
|
3474
|
+
"required": [],
|
|
3475
|
+
"additionalProperties": false
|
|
3476
|
+
},
|
|
3477
|
+
"effect": "read",
|
|
3478
|
+
"release": "works",
|
|
3479
|
+
"description": "Returns { stats, scanStatus, insights, diff, total, records }. `records` is sorted by size descending and sliced to `limit`; `total` is the count AFTER kind/loadedOnly filtering but BEFORE the slice. Each record: { id, key, registryId, name, type, kind, hash, location, width, height, scales, uri, loaded, sizeBytes, sizeSource }. `sizeSource` is \"measured\" (real bytes from the Metro dev server) or \"estimate\" (decoded RGBA memory, width*scale*height*scale*4) — never present them as the same number. `insights` carries id lists: duplicates (grouped by content hash, with wastedBytes), neverLoaded, webpCandidates (png/jpg >= 50KB), overDecode (decodes to >2 screenfuls), heavyGifs (>= 100KB), heavyFonts (>= 150KB), scaleGaps, scaleAnomalies. If `scanStatus.lastScanAt` is null or `graphCount` is null, run `rescan` first — the inventory is registry-only until the Metro graph is merged. If `scanStatus.measuring` is true, byte totals are partial; re-run shortly.",
|
|
3480
|
+
"requires": ["@buoy-gg/assets imported in the app", "Metro dev server (for full-bundle coverage, never-loaded detection and measured bytes)"]
|
|
3481
|
+
}, {
|
|
3482
|
+
"action": "getDetail",
|
|
3483
|
+
"summary": "Full metadata for one asset id: source dir, per-file graph paths, per-scale byte sizes, resolved URI, decoded-memory estimate.",
|
|
3484
|
+
"params": {
|
|
3485
|
+
"type": "object",
|
|
3486
|
+
"properties": {
|
|
3487
|
+
"id": {
|
|
3488
|
+
"type": "number",
|
|
3489
|
+
"description": "Numeric record id from a `list` response. A non-number throws 'Missing numeric `id` param'; an unknown id throws 'Asset record N not found — re-run the list action for current ids.'"
|
|
3490
|
+
}
|
|
3491
|
+
},
|
|
3492
|
+
"required": ["id"],
|
|
3493
|
+
"additionalProperties": false
|
|
3494
|
+
},
|
|
3495
|
+
"effect": "read",
|
|
3496
|
+
"release": "works",
|
|
3497
|
+
"description": "Everything `list` returns for that record plus origin (\"registry\" | \"graph\"), fileSystemLocation (the source directory on the dev machine — Metro-graph only), files (per-scale source file paths), sizesByScale ({ \"1\": bytes, \"2\": bytes, ... }), and estDecodedBytes. Use it to answer \"where does this file live\" and \"which scale variant is the fat one\". Ids come from `list` and are per-session: they are stable across a `rescan` (records are keyed by location/name.type) but NOT across `clearRecords`, which renumbers — re-run `list` before calling this if anything was cleared.",
|
|
3498
|
+
"requires": ["@buoy-gg/assets imported in the app", "a prior `list` call for a valid id"]
|
|
3499
|
+
}, {
|
|
3500
|
+
"action": "rescan",
|
|
3501
|
+
"summary": "Full refresh: re-walk the runtime registry, merge the Metro bundle graph, refresh Expo fonts, then kick off size measurement in the background.",
|
|
3502
|
+
"params": {
|
|
3503
|
+
"type": "object",
|
|
3504
|
+
"properties": {},
|
|
3505
|
+
"additionalProperties": false
|
|
3506
|
+
},
|
|
3507
|
+
"effect": "write",
|
|
3508
|
+
"release": "works",
|
|
3509
|
+
"description": "Run this FIRST when the inventory looks empty, stale, or has no never-loaded data. Returns { ok: true, scanStatus } immediately; the measurement pass is fired with `void measureSizes()` and keeps running after the response, so scanStatus.measuring is usually true on return — call `list` or `getScanStatus` again a moment later for final byte totals. Merges into existing records (ids and any measured sizes are preserved); it never wipes. `ok:true` only means the scan ran — read scanStatus.graphError and graphCount to see whether the bundle graph actually loaded."
|
|
3510
|
+
}, {
|
|
3511
|
+
"action": "measureSizes",
|
|
3512
|
+
"summary": "Re-measure real byte sizes by fetching each scale variant from the Metro dev server; skips already-measured records.",
|
|
3513
|
+
"params": {
|
|
3514
|
+
"type": "object",
|
|
3515
|
+
"properties": {},
|
|
3516
|
+
"additionalProperties": false
|
|
3517
|
+
},
|
|
3518
|
+
"effect": "write",
|
|
3519
|
+
"release": "empty",
|
|
3520
|
+
"description": "Returns { ok, measured, total } where ok is `measured > 0`. Fetches every scale variant (HEAD for Content-Length, GET-blob fallback) 4 at a time and sums them onto the record as sizeSource:\"measured\". Records it cannot fetch are marked sizeSource:\"estimate\". Safe to re-run — it only visits records that aren't measured yet. If a call is already in flight it returns the current counts immediately without starting a second pass.",
|
|
3521
|
+
"releaseNote": "packages/assets/src/capture/measure.ts:74-75 — variantURLs() needs an http(s) resolved URI or a live dev-server origin; in a release bundle assets resolve to file:// paths or Android resource ids, so it returns [] and measure.ts:118 marks every record \"estimate\". The result is { ok:false, measured:0, total:N }. Byte sizes are simply not obtainable from JS in a release build — say that instead of retrying.",
|
|
3522
|
+
"requires": ["Metro dev server reachable from the device"]
|
|
3523
|
+
}, {
|
|
3524
|
+
"action": "saveBaseline",
|
|
3525
|
+
"summary": "Persist the current inventory as the before/after comparison point; OVERWRITES any existing baseline.",
|
|
3526
|
+
"params": {
|
|
3527
|
+
"type": "object",
|
|
3528
|
+
"properties": {},
|
|
3529
|
+
"additionalProperties": false
|
|
3530
|
+
},
|
|
3531
|
+
"effect": "write",
|
|
3532
|
+
"release": "works",
|
|
3533
|
+
"description": "Returns { ok: true, savedAt, assets }. Writes a slim {hash, sizeBytes, name, type} snapshot per asset to persistentStorage under '@react_buoy/assets/baseline' (FileSystem -> AsyncStorage -> memory). The workflow is: measure sizes, save baseline, make the change, then `list`/`getDiff` reports added / removed / grown (>=1KB) / netBytes. Two warnings worth surfacing before calling: (1) it silently replaces a previously saved baseline and the old one cannot be recovered — ask first if a comparison may already be in progress; (2) only sizeSource===\"measured\" bytes are stored, so saving before a measurement pass finishes produces a baseline whose later netBytes is null."
|
|
3534
|
+
}, {
|
|
3535
|
+
"action": "clearBaseline",
|
|
3536
|
+
"summary": "Delete the saved baseline permanently, so diffs stop being reported.",
|
|
3537
|
+
"params": {
|
|
3538
|
+
"type": "object",
|
|
3539
|
+
"properties": {},
|
|
3540
|
+
"additionalProperties": false
|
|
3541
|
+
},
|
|
3542
|
+
"effect": "destructive",
|
|
3543
|
+
"release": "works",
|
|
3544
|
+
"description": "Returns { ok: true, message: \"Baseline cleared\" }. Removes '@react_buoy/assets/baseline' from persistent storage and nulls it in memory; after this `getDiff` returns null and `list().diff` is null. Irreversible — the saved snapshot is gone, and re-saving captures TODAY's inventory, not the one you deleted. Only call it when the user explicitly wants to stop comparing or start a fresh comparison."
|
|
3545
|
+
}, {
|
|
3546
|
+
"action": "getDiff",
|
|
3547
|
+
"summary": "Change vs the saved baseline: added ids, removed assets, grown assets, net bytes. Null when no baseline exists.",
|
|
3548
|
+
"params": {
|
|
3549
|
+
"type": "object",
|
|
3550
|
+
"properties": {},
|
|
3551
|
+
"additionalProperties": false
|
|
3552
|
+
},
|
|
3553
|
+
"effect": "read",
|
|
3554
|
+
"release": "works",
|
|
3555
|
+
"description": "Returns { baselineSavedAt, added: number[], removed: [{key, name, type}], grown: [{id, deltaBytes}] sorted biggest-first, netBytes } or null if nothing was ever saved with saveBaseline. `grown` only counts increases of >=1KB (below that is codec noise) and only for assets measured both then and now. netBytes is null whenever no asset pair was comparable — that means 'sizes unknown', NOT 'no change'; `list` already embeds this same object as `diff`, so calling both is redundant."
|
|
3556
|
+
}, {
|
|
3557
|
+
"action": "clearRecords",
|
|
3558
|
+
"summary": "Wipe the entire in-memory inventory, including all measured byte sizes; a rescan rebuilds it.",
|
|
3559
|
+
"params": {
|
|
3560
|
+
"type": "object",
|
|
3561
|
+
"properties": {},
|
|
3562
|
+
"additionalProperties": false
|
|
3563
|
+
},
|
|
3564
|
+
"effect": "destructive",
|
|
3565
|
+
"release": "works",
|
|
3566
|
+
"description": "Returns { ok: true, message: \"Inventory cleared — rescan to rebuild\" }. Empties records/byKey/byId and resets measuredCount and graphCount. Everything measured is lost and must be re-fetched from the dev server, and because the id counter is NOT reset, a following `rescan` assigns brand-new ids — any id from an earlier `list` is dead afterwards. Never a diagnostic step; only run it when the user asks for a clean slate. The saved baseline survives (use clearBaseline for that)."
|
|
3567
|
+
}, {
|
|
3568
|
+
"action": "getScanStatus",
|
|
3569
|
+
"summary": "Cheap health check for the capture: is the registry patched, did the Metro graph load, how many sizes are measured, is measurement still running.",
|
|
3570
|
+
"params": {
|
|
3571
|
+
"type": "object",
|
|
3572
|
+
"properties": {},
|
|
3573
|
+
"additionalProperties": false
|
|
3574
|
+
},
|
|
3575
|
+
"effect": "read",
|
|
3576
|
+
"release": "works",
|
|
3577
|
+
"description": "Returns { registryAvailable, patched, registryCount, graphSupported, graphCount, graphError, lastScanAt, measuring, measuredCount, fontFamilies: string[], localAssetCount }. Use it to explain a thin or surprising inventory before drawing conclusions: registryAvailable:false means the RN asset registry could not be required at all; graphCount:null with a graphError means only runtime-registered assets are known (so 'never loaded' is unanswerable); measuring:true means byte totals are still landing. lastScanAt:null means nothing has scanned yet — call rescan."
|
|
3578
|
+
}],
|
|
3579
|
+
"unavailableWhen": "The app doesn't import @buoy-gg/assets (autoExternalSync only registers the adapter when the module resolves — packages/devtools-floating-menu/src/floatingMenu/autoExternalSync.tsx:391), or the app is a release/production JS bundle that didn't opt in with `externalSync.enableInRelease` plus a real Pro license, in which case the device never connects to the broker at all."
|
|
3580
|
+
}, {
|
|
3581
|
+
"toolId": "tv-remote",
|
|
3582
|
+
"title": "TV Remote",
|
|
3583
|
+
"summary": "Capture-only observer for Apple TV / Android TV apps: it reports the D-pad, select, menu, media and long-press events the app ACTUALLY received, which is how you tell \"the app handled that press\" apart from \"something swallowed it\". It cannot press anything — presses are injected host-side by Buoy Desktop (`adb shell input keyevent` / `idb ui key`), so never tell a user this tool moved focus. Reach for it to arm capture before a remote press happens, then poll getEventsSince for the echo. Inert on phones/tablets/web.",
|
|
3584
|
+
"actions": [{
|
|
3585
|
+
"action": "arm",
|
|
3586
|
+
"summary": "Start capturing remote events into the 50-event ring buffer. Idempotent. Nothing is recorded until this runs.",
|
|
3587
|
+
"params": {
|
|
3588
|
+
"type": "object",
|
|
3589
|
+
"properties": {},
|
|
3590
|
+
"required": [],
|
|
3591
|
+
"additionalProperties": false
|
|
3592
|
+
},
|
|
3593
|
+
"effect": "write",
|
|
3594
|
+
"release": "works",
|
|
3595
|
+
"description": "Attaches the native TVEventHandler listener and returns the fresh TvRemoteState: { supported, platform, captureArmed, listening, menuCaptureArmed, seq, recent[] }. CHECK `listening`, not `captureArmed`: on a non-TV build or an RN build without TVEventHandler, arm() still sets captureArmed:true while listening stays false and no event will ever arrive — reporting 'armed' there would make every replay step look swallowed. Buffer holds the last 50 events; key-DOWN (eventKeyAction 0) and continuous gestures (pan/swipeUp/swipeDown/swipeLeft/swipeRight) are dropped so one press = exactly one echoed event. Arming renders nothing on screen and costs one listener. This does NOT press any button.",
|
|
3596
|
+
"requires": ["@buoy-gg/tv-remote installed in the app", "FloatingDevTools mounted (auto-discovery registers the adapter)", "react-native-tvos build with Platform.isTV === true for listening to become true"]
|
|
3597
|
+
}, {
|
|
3598
|
+
"action": "getEventsSince",
|
|
3599
|
+
"summary": "Cursor-paged read of captured remote events newer than `seq`. The echo poll — use this, not the snapshot, to time whether a press landed.",
|
|
3600
|
+
"params": {
|
|
3601
|
+
"type": "object",
|
|
3602
|
+
"properties": {
|
|
3603
|
+
"seq": {
|
|
3604
|
+
"type": "number",
|
|
3605
|
+
"description": "Cursor: return only events whose seq is strictly greater. Omit or 0 for the whole buffer; 9007199254740991 for the current cursor with no events."
|
|
3606
|
+
}
|
|
3607
|
+
},
|
|
3608
|
+
"required": [],
|
|
3609
|
+
"additionalProperties": false
|
|
3610
|
+
},
|
|
3611
|
+
"effect": "read",
|
|
3612
|
+
"release": "works",
|
|
3613
|
+
"description": "Returns { seq, events: [{ seq, t, eventType, eventKeyAction }] }, where `events` are strictly newer than the passed cursor and `seq` is the store's current counter. eventType is the raw RN name: up | down | left | right | select | playPause | menu | longSelect | longLeft | … . Pure read — it never consumes or advances the buffer. Pattern: call with seq: 9007199254740991 (Number.MAX_SAFE_INTEGER) to grab the current cursor with zero events, have the press injected, then poll with that cursor. Omitted seq (or 0) returns everything buffered, max 50. TRAPS: a non-number (a string \"5\") silently falls back to 0 and returns EVERYTHING; a non-finite value (Infinity/NaN) also returns everything rather than nothing. Injected TEXT and Home never echo at all (they ride the platform keyboard/button path, not the TV event pipe) — absence of an event for those is expected, not a bug.",
|
|
3614
|
+
"requires": ["arm() must have been called first — an unarmed store records nothing and returns an empty list"]
|
|
3615
|
+
}, {
|
|
3616
|
+
"action": "setMenuCapture",
|
|
3617
|
+
"summary": "tvOS only: route the Menu key to JS so it can be recorded, instead of letting it pop the nav stack. Changes how the app behaves.",
|
|
3618
|
+
"params": {
|
|
3619
|
+
"type": "object",
|
|
3620
|
+
"properties": {
|
|
3621
|
+
"on": {
|
|
3622
|
+
"type": "boolean",
|
|
3623
|
+
"description": "true routes the Menu key to JS (Menu stops popping the nav stack). Anything else hands Menu back to the platform."
|
|
3624
|
+
}
|
|
3625
|
+
},
|
|
3626
|
+
"required": [],
|
|
3627
|
+
"additionalProperties": false
|
|
3628
|
+
},
|
|
3629
|
+
"effect": "write",
|
|
3630
|
+
"release": "works",
|
|
3631
|
+
"description": "Calls TVEventControl.enableTVMenuKey/disableTVMenuKey and returns the fresh TvRemoteState. Pass { on: true } to capture Menu; ANY other value (omitted, \"true\", 1, null) disables it — the handler is a strict `params?.on === true`. WARN THE USER BEFORE ENABLING: with capture on, Menu no longer navigates back, so at the root of the app the remote's Back/Menu button stops working the way they expect until it is turned off. Hard no-op (returns ok, changes nothing) on Android TV, on non-TV builds, and on RN builds without TVEventControl — check `menuCaptureArmed` in the returned state to see whether it actually took. Always undone automatically by disarm().",
|
|
3632
|
+
"requires": ["tvOS (Platform.OS === \"ios\") with Platform.isTV === true", "react-native-tvos TVEventControl present"]
|
|
3633
|
+
}, {
|
|
3634
|
+
"action": "disarm",
|
|
3635
|
+
"summary": "Stop capturing and hand the Menu key back to the platform. Any in-flight recording or replay stops echoing.",
|
|
3636
|
+
"params": {
|
|
3637
|
+
"type": "object",
|
|
3638
|
+
"properties": {},
|
|
3639
|
+
"required": [],
|
|
3640
|
+
"additionalProperties": false
|
|
3641
|
+
},
|
|
3642
|
+
"effect": "write",
|
|
3643
|
+
"release": "works",
|
|
3644
|
+
"description": "Removes the native listener, turns menu capture off if it was on, and returns the fresh TvRemoteState. The 50-event buffer is NOT wiped — previously captured events stay readable via getEventsSince. Do not call this while someone is recording a macro or a replay is running on this device: an unarmed device echoes nothing, which reads as every press being swallowed. Idempotent and safe to call on a non-TV app."
|
|
3645
|
+
}, {
|
|
3646
|
+
"action": "clear",
|
|
3647
|
+
"summary": "Wipe the captured-event ring buffer. Irreversible — the recorded presses are gone.",
|
|
3648
|
+
"params": {
|
|
3649
|
+
"type": "object",
|
|
3650
|
+
"properties": {},
|
|
3651
|
+
"required": [],
|
|
3652
|
+
"additionalProperties": false
|
|
3653
|
+
},
|
|
3654
|
+
"effect": "destructive",
|
|
3655
|
+
"release": "works",
|
|
3656
|
+
"description": "Empties the in-memory buffer and returns the fresh TvRemoteState (recent: []). The `seq` counter is NOT reset, so cursors held by an in-flight replay stay valid but their events vanish. This destroys the evidence a recording-from-the-real-remote session just collected — never call it to 'tidy up' while a macro is being recorded or a replay is polling for echoes; ask first. Capture state is untouched: still armed after clearing. No-ops when the buffer is already empty."
|
|
3657
|
+
}],
|
|
3658
|
+
"unavailableWhen": "The app is not a react-native-tvos TV build (`Platform.isTV !== true`): every action still returns ok, but the snapshot says `supported: false`, `listening` stays false, and no event is ever captured. Also absent if `@buoy-gg/tv-remote` isn't installed, or if FloatingDevTools isn't mounted (auto-discovery registers the adapter). In a release build the whole Buoy sync transport only connects when the app passes `externalSync={{ enableInRelease: true }}` AND holds a real Pro license (packages/devtools-floating-menu/src/floatingMenu/externalSyncGate.ts:38) — but if an action is reachable at all, its code path works in release."
|
|
3659
|
+
}, {
|
|
3660
|
+
"toolId": "focus-inspector",
|
|
3661
|
+
"title": "TV Focus Inspector",
|
|
3662
|
+
"summary": "Debugs D-pad/remote focus on Android TV and tvOS: what holds focus right now, the observed focus-transition history, and detected dead ends, traps, invisible stops (TVFocusGuideView) and traversal coverage. Reach for it when a user says the remote won't move, focus is stuck inside one row, focus skips a button, or focus \"disappeared\". Unlike most timeline tools it needs NO arming — the focus/key observers attach at app launch, so history is already there when you first read it; but it is TV-ONLY (on a phone/tablet/web build snapshot.supported is false and nothing is ever observed), and in a RELEASE build the fiber half dies while the observation half survives (see per-action release notes).",
|
|
3663
|
+
"actions": [{
|
|
3664
|
+
"action": "rescan",
|
|
3665
|
+
"summary": "Re-walk the fiber tree and rebuild the on-screen focusable inventory (names, testIDs, frames, guide props).",
|
|
3666
|
+
"params": {
|
|
3667
|
+
"type": "object",
|
|
3668
|
+
"properties": {},
|
|
3669
|
+
"required": [],
|
|
3670
|
+
"additionalProperties": false
|
|
3671
|
+
},
|
|
3672
|
+
"effect": "read",
|
|
3673
|
+
"release": "empty",
|
|
3674
|
+
"description": "Returns {ok:true, focusables:N}. Refreshes snapshot.focusables / screen / scannedAt and the internal nativeTag->instance table that focusElement depends on. Call it after the app navigates to a new screen, and always before a focusElement. Inventory is capped at 400 nodes, sorted in reading order, frames normalized to [0,1]; off-window nodes are KEPT (a tile below a ScrollView fold is still a real D-pad target). Two gotchas: (1) it CLEARS the inventory and instance table before rebuilding, so a rescan fired while nothing is mounted leaves focusables:0 and breaks focusElement until you rescan again; (2) detected flags are judged against this scan's timestamp, so rescanning discards trap/invisible-stop evidence collected before it. Concurrent calls are coalesced — a rescan while one is in flight returns the previous scan.",
|
|
3675
|
+
"releaseNote": "packages/focus-inspector/src/scan/focusableScanner.ts:109 reads global.__REACT_DEVTOOLS_GLOBAL_HOOK__, which React Native installs ONLY under __DEV__ (node_modules/react-native/Libraries/Core/setUpReactDevTools.js:32). In a release build getAllFiberRoots() returns [], collectCandidates() returns [], and the action still answers {ok:true, focusables:0} — do not report that as an app with no focusable elements. Knock-on effects in release: snapshot.focusables is empty, current.node is null (only the raw tag is known), coverage.focusables is 0, and flags.traps / flags.invisibleStops are always empty because both need frames or a non-empty inventory.",
|
|
3676
|
+
"requires": ["Platform.isTV === true for the results to mean anything (the scan itself runs on a phone but nothing is ever observed there)", "A __DEV__ build — a release build returns focusables:0"]
|
|
3677
|
+
}, {
|
|
3678
|
+
"action": "focusElement",
|
|
3679
|
+
"summary": "Move focus onto a specific element by native tag, then watch where the D-pad goes from there.",
|
|
3680
|
+
"params": {
|
|
3681
|
+
"type": "object",
|
|
3682
|
+
"properties": {
|
|
3683
|
+
"nativeTag": {
|
|
3684
|
+
"type": "number",
|
|
3685
|
+
"description": "Native view tag of the element to focus, taken from snapshot.focusables[].nativeTag or snapshot.current.tag in the LATEST scan. Absent or non-number returns {ok:false, reason:'nativeTag is required.'}"
|
|
3686
|
+
}
|
|
3687
|
+
},
|
|
3688
|
+
"required": ["nativeTag"],
|
|
3689
|
+
"additionalProperties": false
|
|
3690
|
+
},
|
|
3691
|
+
"effect": "write",
|
|
3692
|
+
"release": "empty",
|
|
3693
|
+
"description": "The tool's ONLY write into the host app — it calls the element's requestTVFocus(). Send {nativeTag: 166}, where the tag comes from snapshot.focusables[].nativeTag or snapshot.current.tag AND from the most recent rescan (the instance table is rebuilt on every scan, so a tag from an older scan is stale). Returns {ok:true} or {ok:false, reason} and the reason strings are exact and worth relaying verbatim: 'nativeTag is required.' (missing or non-number param), 'Not a TV build — there is no focus engine.' (Platform.isTV false), 'Unknown tag — rescan and try again.' (tag not in the current scan's instance table), or 'This element exposes no requestTVFocus().' (the host node is not a View — Text and Image never get one). Note the adapter hand-casts this as (params as {nativeTag?: number}), i.e. structurally optional on the wire, but the handler hard-rejects when it is absent.",
|
|
3694
|
+
"releaseNote": "packages/focus-inspector/src/scan/focusableScanner.ts:550 looks the tag up in instancesByTag, which is populated ONLY by the DEV-gated fiber scan (focusableScanner.ts:454, reached via the __REACT_DEVTOOLS_GLOBAL_HOOK__ read at :109). In a release build that map is permanently empty, so every call returns {ok:false, reason:'Unknown tag — rescan and try again.'} no matter how many times you rescan. Do not loop on the rescan advice in the reason string — in release it can never succeed; say so and drive the app with the actual remote instead.",
|
|
3695
|
+
"requires": ["Platform.isTV === true", "A __DEV__ build", "A rescan must have run and included this tag"]
|
|
3696
|
+
}, {
|
|
3697
|
+
"action": "setTracking",
|
|
3698
|
+
"summary": "Pause or resume recording of focus transitions and D-pad probes, without detaching the native listeners.",
|
|
3699
|
+
"params": {
|
|
3700
|
+
"type": "object",
|
|
3701
|
+
"properties": {
|
|
3702
|
+
"enabled": {
|
|
3703
|
+
"type": "boolean",
|
|
3704
|
+
"description": "true resumes recording, false pauses it. OMITTING THIS MEANS TRUE (resume) — always send it explicitly."
|
|
3705
|
+
}
|
|
3706
|
+
},
|
|
3707
|
+
"required": [],
|
|
3708
|
+
"additionalProperties": false
|
|
3709
|
+
},
|
|
3710
|
+
"effect": "write",
|
|
3711
|
+
"release": "works",
|
|
3712
|
+
"description": "Returns {tracking:boolean} — the value actually in effect. GOTCHA THAT WILL BITE: `enabled` defaults to TRUE. The handler is `setTracking(params?.enabled !== false)`, so calling setTracking with no params, or with anything other than exactly false, RESUMES recording. To pause you must explicitly send {enabled:false}. Pausing leaves the RawEventEmitter and TVEventHandler listeners attached and keeps the currently-focused element, so nothing is forgotten; it only stops new focus/blur/key events from being appended to the history. Use it when a person is driving the app by hand and does not want that traversal scored. The current value is also visible as snapshot.tracking."
|
|
3713
|
+
}, {
|
|
3714
|
+
"action": "clearHistory",
|
|
3715
|
+
"summary": "Permanently wipe the recorded focus history — transitions, D-pad probes, visited tags and counters.",
|
|
3716
|
+
"params": {
|
|
3717
|
+
"type": "object",
|
|
3718
|
+
"properties": {},
|
|
3719
|
+
"required": [],
|
|
3720
|
+
"additionalProperties": false
|
|
3721
|
+
},
|
|
3722
|
+
"effect": "destructive",
|
|
3723
|
+
"release": "works",
|
|
3724
|
+
"description": "Returns {ok:true}. Resets transitions, probes, visited, lostCount and the seq counters to zero, which also blanks the desktop timeline and zeroes every derived number (coverage.visited, stats.transitions, all dead-end/trap/invisible-stop flags, since they are computed from that stream). IRREVERSIBLE — there is no snapshot of the old history anywhere. It deliberately KEEPS the currently focused element as the only visited tag, so the Now card does not blank out. The legitimate use is starting a clean traversal run: clear, then have the QA user walk the screen with the remote, then read the flags. Do not call it just to tidy up — you are destroying the evidence the tool exists to collect, and focus history cannot be re-derived because focus can only be watched arriving, never queried."
|
|
3725
|
+
}],
|
|
3726
|
+
"unavailableWhen": "The app is not a TV build — `Platform.isTV !== true` means the focus and D-pad observers are never attached (focusInspectorSyncAdapter.ts:117), so `snapshot.supported` is false, `presence` stays \"never-observed\", transitions/current stay empty forever, and focusElement refuses. Also absent entirely if `@buoy-gg/focus-inspector` isn't installed, since @buoy-gg/core only registers the \"focus-inspector\" capability when that optional require resolves (autoExternalSync.tsx:397). There is deliberately NO on-device UI — a focusable overlay would insert itself into the host app's focus order and corrupt the measurement — so everything is read through this adapter."
|
|
3727
|
+
}, {
|
|
3728
|
+
"toolId": "images",
|
|
3729
|
+
"title": "Images",
|
|
3730
|
+
"summary": "Live registry of every image the app has loaded — RN core <Image> and expo-image — with cache verdict (memory/disk/network), load ms, decoded-vs-displayed pixel size plus oversize/wasted-KB math, error codes, and cross-record insights (duplicate URLs, retry storms, missing alt text, layout shifters). Also drives per-image and app-wide simulation: force error / force loading / blank / URL swap / offline / cold-start, plus locate-flash and cache clears. Reach for it whenever an image is broken, blank, blurry, slow, or suspected of memory bloat: image HTTP never passes through the JS network stack, so this is the ONLY visibility into image loading.",
|
|
3731
|
+
"actions": [{
|
|
3732
|
+
"action": "list",
|
|
3733
|
+
"summary": "List captured image loads, newest first, with stats, insights, capture status and active simulation modes.",
|
|
3734
|
+
"params": {
|
|
3735
|
+
"type": "object",
|
|
3736
|
+
"properties": {
|
|
3737
|
+
"limit": {
|
|
3738
|
+
"type": "number",
|
|
3739
|
+
"description": "Max records to return, newest first. Defaults to 50; clamped to 1-200."
|
|
3740
|
+
},
|
|
3741
|
+
"status": {
|
|
3742
|
+
"type": "string",
|
|
3743
|
+
"enum": ["pending", "loading", "loaded", "error"],
|
|
3744
|
+
"description": "Return only records in this state. 'error' gives the failure log. Any other string matches nothing and returns zero records."
|
|
3745
|
+
}
|
|
3746
|
+
},
|
|
3747
|
+
"required": [],
|
|
3748
|
+
"additionalProperties": false
|
|
3749
|
+
},
|
|
3750
|
+
"effect": "read",
|
|
3751
|
+
"release": "works",
|
|
3752
|
+
"description": "Returns { stats, captureStatus, globalModes, insights, total, records }. stats = {total, loading, loaded, errors, networkLoads, estDecodedBytes, estWastedBytes}. Each record carries id, lib ('rn'|'expo'), uri, kind ('network'|'asset'|'file'|'data'|'other'), status, mounted, cache verdict, ms, intrinsic px, layout dp, neededPx, oversizeFactor, decodedKB, wastedKB, error/errorCode, loadCount, overrideLabel, hasAltText, layoutShifts, ageMs. `data:` URIs and URIs over 2KB arrive truncated to a stub, never in full. insights flags duplicate URLs, retry storms (loadCount >= 5), iOS queue saturation (>4 RN images loading), missing alt text and layout shifters. The registry keeps the last 500 records and only holds images that mounted AFTER capture installed — an empty result means either nothing rendered yet or capture is not wired (call getCaptureStatus). Use the returned ids for every other action.",
|
|
3753
|
+
"requires": ["@buoy-gg/images installed in the app", "capture installed (import \"@buoy-gg/images/register\" as the first entry import, or <ImagesRoot/> mounted)"]
|
|
3754
|
+
}, {
|
|
3755
|
+
"action": "getDetail",
|
|
3756
|
+
"summary": "Full wire detail for one image record, including the iOS error response headers that `list` omits.",
|
|
3757
|
+
"params": {
|
|
3758
|
+
"type": "object",
|
|
3759
|
+
"properties": {
|
|
3760
|
+
"id": {
|
|
3761
|
+
"type": "number",
|
|
3762
|
+
"description": "Record id from `list`. Must be a number; a string throws."
|
|
3763
|
+
}
|
|
3764
|
+
},
|
|
3765
|
+
"required": ["id"],
|
|
3766
|
+
"additionalProperties": false
|
|
3767
|
+
},
|
|
3768
|
+
"effect": "read",
|
|
3769
|
+
"release": "works",
|
|
3770
|
+
"description": "Same fields as a `list` record plus `errorHeaders` (iOS RN core only — the HTTP response headers captured from onError, the way to see a 403 body/auth header on a failing CDN image). Throws 'Missing numeric `id` param' if id is not a number, and throws 'Image record <id> not found (cleared or evicted)' when the id aged out of the 500-record buffer or was dropped by clearRecords — re-run `list` for current ids.",
|
|
3771
|
+
"requires": ["@buoy-gg/images installed in the app"]
|
|
3772
|
+
}, {
|
|
3773
|
+
"action": "getCaptureStatus",
|
|
3774
|
+
"summary": "Whether image capture actually installed, and whether the RN <Image> hook landed in time. Call this first when `list` looks empty.",
|
|
3775
|
+
"params": {
|
|
3776
|
+
"type": "object",
|
|
3777
|
+
"properties": {},
|
|
3778
|
+
"additionalProperties": false
|
|
3779
|
+
},
|
|
3780
|
+
"effect": "read",
|
|
3781
|
+
"release": "works",
|
|
3782
|
+
"description": "Returns { installed, rnDecoratorActive, rnDecoratorTooLate, expoPatched, expoAvailable }. rnDecoratorTooLate === true is the single most common cause of a missing/partial registry: RN latches its Image decorator at module evaluation, so `import \"@buoy-gg/images/register\"` must be the FIRST import of the app entry file. expo-image capture is timing-immune and unaffected. Never report 'the app loads no images' without checking this.",
|
|
3783
|
+
"requires": ["@buoy-gg/images installed in the app"]
|
|
3784
|
+
}, {
|
|
3785
|
+
"action": "retry",
|
|
3786
|
+
"summary": "Plain fresh load attempt for one mounted image — no cache bypass, nothing cleared.",
|
|
3787
|
+
"params": {
|
|
3788
|
+
"type": "object",
|
|
3789
|
+
"properties": {
|
|
3790
|
+
"id": {
|
|
3791
|
+
"type": "number",
|
|
3792
|
+
"description": "Record id from `list`."
|
|
3793
|
+
}
|
|
3794
|
+
},
|
|
3795
|
+
"required": ["id"],
|
|
3796
|
+
"additionalProperties": false
|
|
3797
|
+
},
|
|
3798
|
+
"effect": "write",
|
|
3799
|
+
"release": "works",
|
|
3800
|
+
"description": "RN core: bumps the wrapper's key so the native view remounts and re-runs the load. expo-image: calls the live instance's reloadAsync(). The safe 'try loading it again' action — prefer it over hardReload unless you specifically need to prove a cache is stale. Returns { ok:false, message:'Instance unmounted — cannot reload' } when the image left the screen, or 'Live instance not reachable' for an expo record whose instance was not registered. Give it ~1.5s before re-reading the record.",
|
|
3801
|
+
"requires": ["@buoy-gg/images installed in the app", "the image must still be mounted on screen"]
|
|
3802
|
+
}, {
|
|
3803
|
+
"action": "flash",
|
|
3804
|
+
"summary": "Draw a 3px red border on the on-screen image for 2.5s so a human can find it.",
|
|
3805
|
+
"params": {
|
|
3806
|
+
"type": "object",
|
|
3807
|
+
"properties": {
|
|
3808
|
+
"id": {
|
|
3809
|
+
"type": "number",
|
|
3810
|
+
"description": "Record id from `list`."
|
|
3811
|
+
}
|
|
3812
|
+
},
|
|
3813
|
+
"required": ["id"],
|
|
3814
|
+
"additionalProperties": false
|
|
3815
|
+
},
|
|
3816
|
+
"effect": "write",
|
|
3817
|
+
"release": "works",
|
|
3818
|
+
"description": "Purely visual and self-reverting (the border clears itself after ~2.5s, no undo needed). Returns { ok:true, message:'Flashing for 2.5s' } unconditionally — including for an unmounted record, where nothing will actually be visible; check `mounted` on the record first. The right way to answer 'which image on screen is #42?'.",
|
|
3819
|
+
"requires": ["@buoy-gg/images installed in the app", "the image must be mounted and on screen to be visible"]
|
|
3820
|
+
}, {
|
|
3821
|
+
"action": "proveSavings",
|
|
3822
|
+
"summary": "Re-encode the image at its displayed size as WebP ON DEVICE and return real byte savings plus a previewable file:// URI.",
|
|
3823
|
+
"params": {
|
|
3824
|
+
"type": "object",
|
|
3825
|
+
"properties": {
|
|
3826
|
+
"id": {
|
|
3827
|
+
"type": "number",
|
|
3828
|
+
"description": "Record id from `list`. Best used on a record whose oversizeFactor is well above 1."
|
|
3829
|
+
}
|
|
3830
|
+
},
|
|
3831
|
+
"required": ["id"],
|
|
3832
|
+
"additionalProperties": false
|
|
3833
|
+
},
|
|
3834
|
+
"effect": "write",
|
|
3835
|
+
"release": "works",
|
|
3836
|
+
"description": "Turns the estimated wastedKB into measured bytes: resizes to neededPx (layout dp x device pixel ratio, capped at the intrinsic width) and saves WebP at quality 0.8 via expo-image-manipulator, then stats the output. Original size is resolved in tiers: observed download bytes -> expo disk-cache file size -> an HTTP HEAD Content-Length (so it may issue one network request). Returns { ok, message, originalBytes, originalBytesSource ('download'|'cacheFile'|'head'), optimizedBytes, optimizedUri, optimizedDims, savedBytes, savedPct }. Feed optimizedUri into setOverride {kind:'url'} to A/B the optimized variant in place. Fails with ok:false when expo-image-manipulator is missing, when the record has no loadable URI (kind must be network/asset/file), or when the image has not laid out yet.",
|
|
3837
|
+
"requires": ["expo-image-manipulator installed", "expo-file-system (to stat the output; without it optimizedBytes is unknown)", "the image must have rendered at least once so its layout size is known"]
|
|
3838
|
+
}, {
|
|
3839
|
+
"action": "setOverride",
|
|
3840
|
+
"summary": "Simulate a failure state on ONE image: force error, force forever-loading, blank it, or swap in a different URL.",
|
|
3841
|
+
"params": {
|
|
3842
|
+
"type": "object",
|
|
3843
|
+
"properties": {
|
|
3844
|
+
"id": {
|
|
3845
|
+
"type": "number",
|
|
3846
|
+
"description": "Record id from `list`."
|
|
3847
|
+
},
|
|
3848
|
+
"kind": {
|
|
3849
|
+
"type": "string",
|
|
3850
|
+
"enum": ["error", "hang", "blank", "url"],
|
|
3851
|
+
"description": "error = instant load failure; hang = loads forever; blank = no image rendered; url = replace the source with `uri`."
|
|
3852
|
+
},
|
|
3853
|
+
"uri": {
|
|
3854
|
+
"type": "string",
|
|
3855
|
+
"description": "Replacement source URL. Required when kind is 'url' (throws without it); ignored otherwise."
|
|
3856
|
+
}
|
|
3857
|
+
},
|
|
3858
|
+
"required": ["id", "kind"],
|
|
3859
|
+
"additionalProperties": false
|
|
3860
|
+
},
|
|
3861
|
+
"effect": "write",
|
|
3862
|
+
"release": "works",
|
|
3863
|
+
"description": "kind:'error' points the source at a nonexistent file:// so native fires onError instantly (offline-safe). kind:'hang' points at a blackhole IP so the load never settles (permanent skeleton/spinner state). kind:'blank' renders expo-image with source=null; RN core has no safe empty source, so it blanks via opacity:0 (visual only — the decoded bitmap stays resident). kind:'url' requires `uri` and swaps the source in place. IMPORTANT: for error/hang/blank the action returns { ok:false, message:'Instance unmounted — overrides need the image on screen' } and does nothing when the image is not mounted; the 'url' path does NOT perform that check and reports ok:true even for an unmounted record. Unknown kinds throw. The override sticks until clearOverride (or massAction 'restore') — always tell the user how to undo it.",
|
|
3864
|
+
"requires": ["@buoy-gg/images installed in the app", "the image must be mounted on screen for kinds error/hang/blank"]
|
|
3865
|
+
}, {
|
|
3866
|
+
"action": "clearOverride",
|
|
3867
|
+
"summary": "Remove the simulation override from one image and restore its original source.",
|
|
3868
|
+
"params": {
|
|
3869
|
+
"type": "object",
|
|
3870
|
+
"properties": {
|
|
3871
|
+
"id": {
|
|
3872
|
+
"type": "number",
|
|
3873
|
+
"description": "Record id from `list`."
|
|
3874
|
+
}
|
|
3875
|
+
},
|
|
3876
|
+
"required": ["id"],
|
|
3877
|
+
"additionalProperties": false
|
|
3878
|
+
},
|
|
3879
|
+
"effect": "write",
|
|
3880
|
+
"release": "works",
|
|
3881
|
+
"description": "Deletes the per-record override and clears the record's overrideLabel, then re-renders just that image. Always returns { ok:true, message:'Override removed — original source restored' }, even for an id that had no override. This is the undo for setOverride.",
|
|
3882
|
+
"requires": ["@buoy-gg/images installed in the app"]
|
|
3883
|
+
}, {
|
|
3884
|
+
"action": "setNetworkMode",
|
|
3885
|
+
"summary": "App-wide image network simulation: 'offline' (every network image fails), 'cold' (every image bypasses caches), 'normal' to reset.",
|
|
3886
|
+
"params": {
|
|
3887
|
+
"type": "object",
|
|
3888
|
+
"properties": {
|
|
3889
|
+
"mode": {
|
|
3890
|
+
"type": "string",
|
|
3891
|
+
"enum": ["normal", "offline", "cold"],
|
|
3892
|
+
"description": "normal = simulation off; offline = every network image load fails instantly; cold = every image bypasses memory+disk caches."
|
|
3893
|
+
}
|
|
3894
|
+
},
|
|
3895
|
+
"required": ["mode"],
|
|
3896
|
+
"additionalProperties": false
|
|
3897
|
+
},
|
|
3898
|
+
"effect": "write",
|
|
3899
|
+
"release": "works",
|
|
3900
|
+
"description": "'offline' swaps every NETWORK-kind source for a nonexistent file:// so it fails immediately on both libs — bundled/local assets keep loading, exactly like a real offline device showing shipped images. 'cold' injects RN source.cache:'reload' / expo cachePolicy:'none' so every load refetches: first-launch behavior WITHOUT clearing any cache. 'normal' resets. Any other value throws. Applies to every image in the app, persists until reset, and already-displayed images need a remount/navigation (or massAction 'reload') before the effect is visible. Returns { ok:true, modes:{ network, blank } }. list/getSnapshot surface the active mode in globalModes — say so out loud, since a stuck 'offline' looks exactly like a real app bug.",
|
|
3901
|
+
"requires": ["@buoy-gg/images installed in the app"]
|
|
3902
|
+
}, {
|
|
3903
|
+
"action": "setBlankImages",
|
|
3904
|
+
"summary": "Chrome-style 'disable images' app-wide: render every image with no source.",
|
|
3905
|
+
"params": {
|
|
3906
|
+
"type": "object",
|
|
3907
|
+
"properties": {
|
|
3908
|
+
"enabled": {
|
|
3909
|
+
"type": "boolean",
|
|
3910
|
+
"description": "true blanks every image app-wide. Omitted / anything but true turns it off."
|
|
3911
|
+
}
|
|
3912
|
+
},
|
|
3913
|
+
"required": [],
|
|
3914
|
+
"additionalProperties": false
|
|
3915
|
+
},
|
|
3916
|
+
"effect": "write",
|
|
3917
|
+
"release": "works",
|
|
3918
|
+
"description": "expo-image gets source=null (its placeholder keeps showing); RN core gets opacity:0 because RN has no safe empty source (visual only — the bitmap is still decoded and resident, so this does NOT prove memory savings). Good for checking layout/alt-text without imagery. Returns { ok:true, modes:{ network, blank } }. NOTE the param cast is `enabled === true`: calling with no params, or with anything other than boolean true, turns the mode OFF — always pass `enabled` explicitly.",
|
|
3919
|
+
"requires": ["@buoy-gg/images installed in the app"]
|
|
3920
|
+
}, {
|
|
3921
|
+
"action": "hardReload",
|
|
3922
|
+
"summary": "Cache-busting reload of one image — for expo records this ALSO clears the app's entire expo-image memory cache.",
|
|
3923
|
+
"params": {
|
|
3924
|
+
"type": "object",
|
|
3925
|
+
"properties": {
|
|
3926
|
+
"id": {
|
|
3927
|
+
"type": "number",
|
|
3928
|
+
"description": "Record id from `list`."
|
|
3929
|
+
}
|
|
3930
|
+
},
|
|
3931
|
+
"required": ["id"],
|
|
3932
|
+
"additionalProperties": false
|
|
3933
|
+
},
|
|
3934
|
+
"effect": "destructive",
|
|
3935
|
+
"release": "works",
|
|
3936
|
+
"description": "RN core: remounts with source.cache:'reload' injected (honored on both platforms) so the HTTP cache is bypassed. expo-image: deletes that URI's disk-cache file, then calls Image.clearMemoryCache() which wipes the WHOLE app's expo-image memory cache (no per-entry memory eviction exists), then reloads. Non-network sources (asset/file/data) have no HTTP cache and silently fall back to a plain `retry`. Returns { ok:false, message:'Instance unmounted — cannot reload' } when the image is off screen. Prefer `retry` unless the point is to prove a stale cache; re-read the record after ~1.5s to see the new cache verdict.",
|
|
3937
|
+
"requires": ["@buoy-gg/images installed in the app", "the image must be mounted on screen", "expo-image + expo-file-system for the disk-entry eviction half (expo records only)"]
|
|
3938
|
+
}, {
|
|
3939
|
+
"action": "evictDisk",
|
|
3940
|
+
"summary": "Delete this image's expo-image disk-cache file. Cache data only — irreversible, the image refetches next load.",
|
|
3941
|
+
"params": {
|
|
3942
|
+
"type": "object",
|
|
3943
|
+
"properties": {
|
|
3944
|
+
"id": {
|
|
3945
|
+
"type": "number",
|
|
3946
|
+
"description": "Record id from `list`."
|
|
3947
|
+
}
|
|
3948
|
+
},
|
|
3949
|
+
"required": ["id"],
|
|
3950
|
+
"additionalProperties": false
|
|
3951
|
+
},
|
|
3952
|
+
"effect": "destructive",
|
|
3953
|
+
"release": "works",
|
|
3954
|
+
"description": "Resolves the entry via expo-image's getCachePathAsync and deletes the file with expo-file-system. Returns { ok:true, message:'Disk cache entry deleted' } or { ok:false, message:'No disk entry found (expo-image + expo-file-system required)' } — that same ok:false covers 'neither package installed', 'not an expo-image record', and 'nothing was cached', so do not read it as a hard error. No effect at all on RN core <Image> records or on the memory cache.",
|
|
3955
|
+
"requires": ["expo-image installed", "expo-file-system installed (legacy or main entry)", "record must be an expo-image load with a cached disk entry"]
|
|
3956
|
+
}, {
|
|
3957
|
+
"action": "massAction",
|
|
3958
|
+
"summary": "Apply one action to EVERY mounted image at once: force error/loading/blank, hard-reload all, flash all, or restore all.",
|
|
3959
|
+
"params": {
|
|
3960
|
+
"type": "object",
|
|
3961
|
+
"properties": {
|
|
3962
|
+
"kind": {
|
|
3963
|
+
"type": "string",
|
|
3964
|
+
"enum": ["error", "loading", "blank", "reload", "flash", "restore"],
|
|
3965
|
+
"description": "error/loading/blank = mass simulation override; reload = cache-busting hard reload of all mounted images; flash = red-border all; restore = clear every override."
|
|
3966
|
+
}
|
|
3967
|
+
},
|
|
3968
|
+
"required": ["kind"],
|
|
3969
|
+
"additionalProperties": false
|
|
3970
|
+
},
|
|
3971
|
+
"effect": "destructive",
|
|
3972
|
+
"release": "works",
|
|
3973
|
+
"description": "kind 'error'|'loading'|'blank' set that override on every mounted record (note: per-record 'hang' is spelled 'loading' here) and return { ok: n>0, message:'... on N images' }. 'flash' red-borders everything the tool tracks for 2.5s. 'restore' clears every active override and is the undo for the three simulation kinds. 'reload' fans `hardReload` out across every mounted image — which for expo records means disk-entry evictions plus a full expo-image memory-cache clear, hence the destructive rating on this whole action. Unknown kinds throw. ok:false just means zero images were mounted. Whole-screen effect: confirm with the user before firing, and always report how to restore.",
|
|
3974
|
+
"requires": ["@buoy-gg/images installed in the app", "images must be mounted on screen — a background screen yields ok:false with 0 affected"]
|
|
3975
|
+
}, {
|
|
3976
|
+
"action": "clearRecords",
|
|
3977
|
+
"summary": "Wipe the captured image registry (keeps only still-in-flight loads). Irreversible evidence loss.",
|
|
3978
|
+
"params": {
|
|
3979
|
+
"type": "object",
|
|
3980
|
+
"properties": {},
|
|
3981
|
+
"additionalProperties": false
|
|
3982
|
+
},
|
|
3983
|
+
"effect": "destructive",
|
|
3984
|
+
"release": "works",
|
|
3985
|
+
"description": "Drops every record that is unmounted or already settled (loaded/error); records that are still mounted and still loading survive so in-flight events do not orphan. Returns { ok:true, message:'Registry cleared' }. The captured history cannot be recovered — take a `list` first if the user might need it. Legitimate use: clear, then have the user re-do the broken step, so the registry contains only the repro.",
|
|
3986
|
+
"requires": ["@buoy-gg/images installed in the app"]
|
|
3987
|
+
}, {
|
|
3988
|
+
"action": "clearExpoCaches",
|
|
3989
|
+
"summary": "Clear expo-image's memory and/or disk caches app-wide. Cache data only, but irreversible.",
|
|
3990
|
+
"params": {
|
|
3991
|
+
"type": "object",
|
|
3992
|
+
"properties": {
|
|
3993
|
+
"memory": {
|
|
3994
|
+
"type": "boolean",
|
|
3995
|
+
"description": "Clear the memory cache. Defaults to true; only an explicit false skips it."
|
|
3996
|
+
},
|
|
3997
|
+
"disk": {
|
|
3998
|
+
"type": "boolean",
|
|
3999
|
+
"description": "Clear the disk cache. Defaults to true; only an explicit false skips it."
|
|
4000
|
+
}
|
|
4001
|
+
},
|
|
4002
|
+
"required": [],
|
|
4003
|
+
"additionalProperties": false
|
|
4004
|
+
},
|
|
4005
|
+
"effect": "destructive",
|
|
4006
|
+
"release": "works",
|
|
4007
|
+
"description": "Calls expo-image's Image.clearMemoryCache() and Image.clearDiskCache(). Both default to true — the cast is `p.memory !== false`, so omitting params clears BOTH; pass false explicitly to skip one. Returns { ok:true, message:'Cleared expo-image memory + disk cache' } or { ok:false, message:'expo-image not installed' }. Affects the whole app, not one image, and does nothing for RN core <Image> (whose caches are native/Fresco/NSURLCache and unreachable from JS). Use it to make the next loads genuinely cold; `setNetworkMode {mode:'cold'}` simulates the same thing without deleting anything.",
|
|
4008
|
+
"requires": ["expo-image installed (otherwise returns ok:false and does nothing)"]
|
|
4009
|
+
}],
|
|
4010
|
+
"unavailableWhen": "@buoy-gg/images is not installed in the app, or capture never installed (no `import \"@buoy-gg/images/register\"`, no <ImagesRoot/>, and the Images tool UI never opened). RN core <Image> specifically goes uncaptured when that register import is not the FIRST import of the entry file — expo-image is captured regardless; check getCaptureStatus.rnDecoratorTooLate before concluding \"no images\". Separately, in a release build the sync transport itself is off unless the app passes externalSync={{enableInRelease:true}} with a real Pro license (packages/devtools-floating-menu/src/floatingMenu/externalSyncGate.ts), in which case no action reaches the device at all."
|
|
4011
|
+
}, {
|
|
4012
|
+
"toolId": "ask-buoy",
|
|
4013
|
+
"title": "Ask Buoy",
|
|
4014
|
+
"summary": "Ask Buoy's OWN session — the changes it has made in this conversation, and the undo for them. Call listChanges when the user asks \"what did you change?\"; call undoAll when they say \"undo that\" / \"put it back\" / \"clean up\". Undo restores exactly what the ledger captured at write time: override rules Ask Buoy created are deleted, impersonation is stopped, storage keys are restored to their pre-write values. Changes listed with reversible:false (state writes, wipes, one-shot actions) cannot be automatically reversed — say so honestly. NEVER improvise a reverse-write with a guessed shape instead of calling undoAll; a hand-rolled \"undo\" that writes invented data is worse than telling the user a change is permanent.",
|
|
4015
|
+
"actions": [{
|
|
4016
|
+
"action": "listChanges",
|
|
4017
|
+
"summary": "List what Ask Buoy has changed in this conversation and whether each change can be undone. Use it to answer \"what did you change?\" before offering undo.",
|
|
4018
|
+
"params": {
|
|
4019
|
+
"type": "object",
|
|
4020
|
+
"properties": {},
|
|
4021
|
+
"additionalProperties": false,
|
|
4022
|
+
"description": "No parameters."
|
|
4023
|
+
},
|
|
4024
|
+
"effect": "read",
|
|
4025
|
+
"release": "works",
|
|
4026
|
+
"description": "Returns `{changes:[{toolId, action, kind, label, reversible}], returned}` — outstanding (not yet undone) changes only, oldest first. `reversible:true` means undoAll can restore that change exactly. `kind` \"transient\" is a one-shot action (a navigation, a tap) that changed no persistent state."
|
|
4027
|
+
}, {
|
|
4028
|
+
"action": "undoAll",
|
|
4029
|
+
"summary": "Undo every reversible change Ask Buoy made this conversation — deletes override rules it created, stops impersonation, restores storage keys to their prior values. The safe direction: use it whenever the user asks to undo or clean up.",
|
|
4030
|
+
"params": {
|
|
4031
|
+
"type": "object",
|
|
4032
|
+
"properties": {},
|
|
4033
|
+
"additionalProperties": false,
|
|
4034
|
+
"description": "No parameters."
|
|
4035
|
+
},
|
|
4036
|
+
"effect": "write",
|
|
4037
|
+
"release": "works",
|
|
4038
|
+
"description": "Returns `{ok, reverted, failed:[{label, error}], skippedTransients}`. Report the numbers honestly: `failed` entries could not be restored (tell the user which, using the labels), and `skippedTransients` are one-shot actions that never needed undoing. This undoes ALL reversible changes from this conversation, newest first — there is no per-change undo action, so if the user wants to keep one change, say so instead of calling this."
|
|
4039
|
+
}],
|
|
4040
|
+
"unavailableWhen": "Only present while an Ask Buoy session is running — which is exactly when this catalog is in use, so in practice always available to you."
|
|
4041
|
+
}];
|
|
4042
|
+
const CATALOG_TOOL_COUNT = exports.CATALOG_TOOL_COUNT = 24;
|
|
4043
|
+
const CATALOG_ACTION_COUNT = exports.CATALOG_ACTION_COUNT = 192;
|
|
4044
|
+
//# sourceMappingURL=catalog.g.js.map
|