@avasapp/agent-bridge 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -11
- package/dist/adapters/ably.d.cts +1 -1
- package/dist/adapters/expo-router.d.cts +1 -1
- package/dist/adapters/react-native-mmkv.d.cts +1 -1
- package/dist/adapters/socket-io.d.cts +1 -1
- package/dist/adapters/tanstack-query.d.cts +1 -1
- package/dist/adapters/zustand.d.cts +1 -1
- package/dist/{chunk-LBYZ5JHF.js → chunk-S2FYN2R5.js} +117 -0
- package/dist/cli.js +34 -45
- package/dist/client/index.cjs +104 -2
- package/dist/client/index.d.ts +44 -1
- package/dist/client/index.js +5 -3
- package/dist/expo/index.d.cts +1 -1
- package/dist/network/index.cjs +186 -21
- package/dist/network/index.d.cts +45 -2
- package/dist/noop/index.cjs +9 -0
- package/dist/noop/network.cjs +7 -1
- package/dist/realtime/index.d.cts +1 -1
- package/dist/runtime/index.cjs +209 -13
- package/dist/runtime/index.d.cts +23 -3
- package/dist/{types-C6DUUHnB.d.cts → types-6kKQsLC1.d.cts} +40 -1
- package/package.json +2 -1
- package/skills/agent-bridge/SKILL.md +30 -3
package/README.md
CHANGED
|
@@ -101,6 +101,8 @@ Errors come back on their own: each reply carries what the app logged with `cons
|
|
|
101
101
|
```sh
|
|
102
102
|
npx agent-bridge session start # hold one connection; call/tools/run reuse it
|
|
103
103
|
npx agent-bridge tools
|
|
104
|
+
npx agent-bridge scenarios # setups the app defines, e.g. signedIn
|
|
105
|
+
npx agent-bridge call scenario.apply '["signedIn", {"user": {"name": "Ada"}}]'
|
|
104
106
|
npx agent-bridge call query.pin '[["features"], {"beta": false}]'
|
|
105
107
|
npx agent-bridge call screen.press '"add-plant"'
|
|
106
108
|
npx agent-bridge call screen.fill '["plant-name", "Fiddle leaf fig"]'
|
|
@@ -123,6 +125,8 @@ await app.call('screen.press', 'save-plant')
|
|
|
123
125
|
A flow is a module the CLI runs without a model in the loop:
|
|
124
126
|
|
|
125
127
|
```js
|
|
128
|
+
export const scenario = 'signedIn' // optional: see Scenarios
|
|
129
|
+
|
|
126
130
|
export default async ({ step }) => {
|
|
127
131
|
await step('flag: beta off', 'query.pin', ['features'], { beta: false })
|
|
128
132
|
await step('save empty form', 'screen.press', 'save-plant')
|
|
@@ -131,8 +135,71 @@ export default async ({ step }) => {
|
|
|
131
135
|
}
|
|
132
136
|
```
|
|
133
137
|
|
|
138
|
+
To run flows from your own script or CI job, import them and pass them to `runFlow`. It applies declared scenarios, restores afterwards, and throws what the flow threw:
|
|
139
|
+
|
|
140
|
+
```ts
|
|
141
|
+
import { connect, runFlow } from '@avasapp/agent-bridge/client'
|
|
142
|
+
import * as mainTabs from './flows/main-tabs.flow.mjs'
|
|
143
|
+
|
|
144
|
+
const app = await connect({ metro: 'localhost:8081' })
|
|
145
|
+
const { errors, restoreErrors } = await runFlow(app, mainTabs, { scenarios: [{ name: 'signedIn' }] })
|
|
146
|
+
app.close()
|
|
147
|
+
if (errors || restoreErrors.length) process.exit(1)
|
|
148
|
+
```
|
|
149
|
+
|
|
134
150
|
No device tool is needed. Pair one (such as agent-device) with the bridge for what it can't reach: system alerts, permission prompts, the keyboard, screenshots, and one real tap per flow.
|
|
135
151
|
|
|
152
|
+
## Scenarios
|
|
153
|
+
|
|
154
|
+
Most flows need the app in a known state first, above all a signed-in user. A scenario is a named setup the app defines once, for example a user who is signed in **locally**: a fake token, a fake user, and every request answered in the app, never by the real server.
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
import { type Scenarios, useAgentBridge } from '@avasapp/agent-bridge'
|
|
158
|
+
import { mockApi, strictNetwork } from '@avasapp/agent-bridge/network'
|
|
159
|
+
|
|
160
|
+
const scenarios: Scenarios = {
|
|
161
|
+
signedIn: {
|
|
162
|
+
description: 'Signed in locally; no request reaches a server.',
|
|
163
|
+
// JSON Schema: bad options fail before apply runs, and say why.
|
|
164
|
+
options: {
|
|
165
|
+
type: 'object',
|
|
166
|
+
properties: {
|
|
167
|
+
user: { type: 'object', properties: { name: { type: 'string' }, email: { type: 'string', format: 'email' } } },
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
apply: async ({ options, call, onUndo }) => {
|
|
171
|
+
const user = { ...defaultUser, ...options?.user }
|
|
172
|
+
// Guards first, so nothing leaves the app with the fake token.
|
|
173
|
+
onUndo(realtimeGate.close())
|
|
174
|
+
onUndo(strictNetwork({ allow: ['cdn.example.com'] }))
|
|
175
|
+
onUndo(mockApi(API_URL, {
|
|
176
|
+
'GET /me': { json: user },
|
|
177
|
+
'GET /orders/:id': ({ params }) => ({ json: orders[params.id] }),
|
|
178
|
+
}))
|
|
179
|
+
onUndo(await addNativeRewriteRule(tilesHost, localTiles)) // any app code
|
|
180
|
+
await call('store.set', 'auth', { token: 'local', user }) // store.restore undoes it
|
|
181
|
+
return { user }
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
useAgentBridge({ tools, scenarios })
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
- `options` is a JSON Schema (2020-12, checked with [`@cfworker/json-schema`](https://github.com/cfworker/cfworker/tree/main/packages/json-schema), which needs no `eval` and runs on Hermes). `scenario.apply` checks the options before anything changes, and lists every problem at once, such as `options/user/email: String does not match format "email"`. An object schema that lists `properties` rejects other keys (`options: unknown option "usr". Known: user`) unless it sets `additionalProperties`. So a typo fails instead of being ignored. No options are checked as `{}`, so `required` ones are reported.
|
|
190
|
+
- `apply` is ordinary app code. `call(tool, ...args)` runs a bridge tool, and that tool's own restorer undoes the change. `onUndo(fn)` covers everything else: app mocks, gates, native state such as a URL-rewrite rule.
|
|
191
|
+
- `bridge.restore` runs every scenario's undo callbacks **last**, newest first, after the store, query and network restorers. The guards stay up until the app is back in its real state. If `apply` throws, the callbacks it had registered run straight away.
|
|
192
|
+
- Tools: `scenario.list`, `scenario.apply [name, options?]` (applying an active one again undoes it first) and `scenario.restore`. `npx agent-bridge scenarios` lists them.
|
|
193
|
+
- A flow declares what it needs with `export const scenario = 'signedIn'`, or `export const scenarios = ['signedIn', { name: 'cart', options: { items: 2 } }]`. `agent-bridge run` applies them before the flow and runs `bridge.restore` afterwards, even when the flow fails. The flow gets what each `apply` returned as `scenarios.signedIn`. A failed restorer makes the run exit non-zero. `run --scenario signedIn` adds one to any flow, with JSON options after `=` (`--scenario 'signedIn={"user":{"name":"Ada"}}'`); it replaces a declared one of the same name.
|
|
194
|
+
|
|
195
|
+
**Strict network.** `strictNetwork({ allow?, status?, offline? })` from `@avasapp/agent-bridge/network` (or `net.strict` from the agent) fails every `fetch` or `XMLHttpRequest` that no mock answers. The request gets a 501 with `{ error: "agent-bridge strict network: no mock for GET https://…" }` and a `console.error`, so the agent sees it with the reply. With `offline: true` it fails like a network failure instead. `net.strict` lists the requests it blocked. `allow` lets hosts through (substrings or RegExps); Metro always gets through. It covers JS requests only: images, native SDKs (a map's tile fetches, say), and WebSockets go around it, so a scenario handles those with app code and `onUndo`.
|
|
196
|
+
|
|
197
|
+
**Fixtures.** `mockApi(baseUrl, routes, options?)` answers `'METHOD /path'` routes, or `'/path'` for any method. `:name` matches one path segment and `*` the rest; the query string is ignored. A route's value is a response (`{ json }`, `{ status, body }`…) or a handler that gets the request with `params` and `query`. It returns a function that removes the routes.
|
|
198
|
+
|
|
199
|
+
**Gates.** Some side effects must wait while a scenario runs, such as a realtime client connecting with the fake token and signing the user out when auth fails. `createGate(name)` from `@avasapp/agent-bridge` is a counted switch: `gate.close()` returns the function that reopens it (hand that to `onUndo`), `gate.closed` is what app code checks, and `gate.subscribe(fn)` hears it change. In a release build it's always open. `realtime.connection` isn't enough on its own: it hides the state from the app, but the client still connects.
|
|
200
|
+
|
|
201
|
+
The example app's [`src/dev/scenarios.ts`](example/src/dev/scenarios.ts) is a complete local `signedIn`, and [`flows/checks/signed-in.mjs`](example/flows/checks/signed-in.mjs) declares it.
|
|
202
|
+
|
|
136
203
|
## Adapters
|
|
137
204
|
|
|
138
205
|
| Import | Tools | Needs |
|
|
@@ -141,12 +208,12 @@ No device tool is needed. Pair one (such as agent-device) with the bridge for wh
|
|
|
141
208
|
| `@avasapp/agent-bridge/zustand` | `store.list` `get` `set` `call` `restore` | your stores |
|
|
142
209
|
| `@avasapp/agent-bridge/react-native-mmkv` | `mmkv.list` `keys` `get` `set` `delete` `restore` | your MMKV instances |
|
|
143
210
|
| `@avasapp/agent-bridge/expo-router` | `router.navigate` `push` `replace` `back` `current` | `router` and `useNavigationContainerRef()` from expo-router |
|
|
144
|
-
| `@avasapp/agent-bridge/network` | `net.log` `mock` `mocks` `unmock` `clear` `restore` | nothing: patches `fetch` and `XMLHttpRequest` in dev |
|
|
211
|
+
| `@avasapp/agent-bridge/network` | `net.log` `mock` `mocks` `unmock` `strict` `clear` `restore` | nothing: patches `fetch` and `XMLHttpRequest` in dev |
|
|
145
212
|
| `@avasapp/agent-bridge/ably` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your Ably `Realtime` client |
|
|
146
213
|
| `@avasapp/agent-bridge/socket.io` | `realtime.channels` `log` `emit` `mute` `unmute` `connection` `restore` | your socket.io `Socket` |
|
|
147
214
|
| `@avasapp/agent-bridge/realtime` | `realtime.channels` `log` `emit` `mute` `unmute` `restore` | two lines in your own subscribe function |
|
|
148
215
|
|
|
149
|
-
A pin keeps seeded data in place through refetches until you unpin it. `net.mock('/inbox', { status: 500 })` or `{ offline: true }` fails a route; apps can fake a whole backend with `mockRequests` from the same import
|
|
216
|
+
A pin keeps seeded data in place through refetches until you unpin it. `net.mock('/inbox', { status: 500 })` or `{ offline: true }` fails a route; apps can fake a whole backend with `mockRequests` or `mockApi` from the same import, and turn on strict mode with `strictNetwork`.
|
|
150
217
|
|
|
151
218
|
## Realtime
|
|
152
219
|
|
|
@@ -231,16 +298,11 @@ Every entry point is gated on `process.env.NODE_ENV`, like `react/index.js`: Met
|
|
|
231
298
|
npx agent-bridge assert-absent path/to/main.jsbundle
|
|
232
299
|
```
|
|
233
300
|
|
|
234
|
-
##
|
|
301
|
+
## What runs where
|
|
235
302
|
|
|
236
|
-
- **
|
|
237
|
-
- **
|
|
238
|
-
- **
|
|
239
|
-
- **Expo Go on Android has no CDP `Runtime.evaluate`.** Use the Expo socket there (the default); CDP works in dev builds and in Expo Go on iOS.
|
|
240
|
-
- **Expo's socket broadcasts to every app.** Calls are addressed to one device; pick it with `--device` when several are connected.
|
|
241
|
-
- **Screen checks through the accessibility tree are slow** (hundreds of ms each). Check in-app, and keep one real UI check per flow.
|
|
242
|
-
- **`screen.fill` skips the keyboard.** It runs the input's handlers, so validation and state are real, but autocorrect, native `maxLength` and uncontrolled inputs' native text are not.
|
|
243
|
-
- **Keep your `QueryClient` in state** (`useState(() => new QueryClient())`). Created at module level, a Fast Refresh can leave the bridge holding a different client than the screen.
|
|
303
|
+
- **On your machine:** `agent-bridge run` and `runFlow` run the flow module you point them at, with your permissions, the same as `node flow.mjs`. Only run flows you'd run as scripts. In CI, don't run flows from untrusted forks with secrets in the environment, as with any test.
|
|
304
|
+
- **In the app:** the bridge only calls tools and scenarios the app registered. Arguments and scenario options arrive as JSON data. Over CDP the client evaluates one fixed call with the message as a JSON string; the Expo transport sends plain JSON. Nothing the agent sends is evaluated as code.
|
|
305
|
+
- **Who can reach it:** anyone who can reach Metro's debugger can already run any code in a dev build. The bridge adds no new way in, and release builds carry none of it (`assert-absent` checks).
|
|
244
306
|
|
|
245
307
|
## License
|
|
246
308
|
|
package/dist/adapters/ably.d.cts
CHANGED
|
@@ -291,6 +291,119 @@ async function openConnection(options = {}) {
|
|
|
291
291
|
}
|
|
292
292
|
}
|
|
293
293
|
|
|
294
|
+
// src/client/log-lines.ts
|
|
295
|
+
function logLine(entry) {
|
|
296
|
+
const where = entry.during ? ` during ${entry.during}` : entry.after ? ` after ${entry.after}` : "";
|
|
297
|
+
const first = entry.message.split("\n")[0] ?? "";
|
|
298
|
+
const text = first.length > 300 ? `${first.slice(0, 300)}...` : first;
|
|
299
|
+
return `! ${entry.level}${where}: ${text}`;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// src/client/flow.ts
|
|
303
|
+
function flowScenarios(flow) {
|
|
304
|
+
const declared = [
|
|
305
|
+
...flow.scenario === void 0 ? [] : [flow.scenario],
|
|
306
|
+
...flow.scenarios ?? []
|
|
307
|
+
];
|
|
308
|
+
return declared.map((s) => {
|
|
309
|
+
if (typeof s === "string") return { name: s };
|
|
310
|
+
if (s && typeof s === "object" && typeof s.name === "string") return s;
|
|
311
|
+
throw new Error(
|
|
312
|
+
`A flow's scenario is a name or { name, options }, not ${JSON.stringify(s)}`
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
function parseScenarioFlag(value) {
|
|
317
|
+
const at = value.indexOf("=");
|
|
318
|
+
if (at === -1) return { name: value };
|
|
319
|
+
const name = value.slice(0, at);
|
|
320
|
+
const raw = value.slice(at + 1);
|
|
321
|
+
try {
|
|
322
|
+
return { name, options: JSON.parse(raw) };
|
|
323
|
+
} catch {
|
|
324
|
+
throw new Error(
|
|
325
|
+
`--scenario ${name}: the options after "=" must be JSON, got ${raw}`
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function mergeScenarios(declared, extra) {
|
|
330
|
+
const names = new Set(extra.map((s) => s.name));
|
|
331
|
+
return [...declared.filter((s) => !names.has(s.name)), ...extra];
|
|
332
|
+
}
|
|
333
|
+
var failedLogs = (error) => error instanceof AgentBridgeCallError ? error.logs : [];
|
|
334
|
+
async function runFlow(bridge, flow, options = {}) {
|
|
335
|
+
const print = options.print ?? console.log;
|
|
336
|
+
const needs = mergeScenarios(flowScenarios(flow), options.scenarios ?? []);
|
|
337
|
+
let n = 0;
|
|
338
|
+
let total = 0;
|
|
339
|
+
let errors = 0;
|
|
340
|
+
const t0 = performance.now();
|
|
341
|
+
const report = (logs) => {
|
|
342
|
+
errors += logs.length;
|
|
343
|
+
for (const e of logs) print(` ${logLine(e)}`);
|
|
344
|
+
};
|
|
345
|
+
const timed = async (tool, ...args) => {
|
|
346
|
+
try {
|
|
347
|
+
const result = await bridge.timed(tool, ...args);
|
|
348
|
+
report(result.logs);
|
|
349
|
+
return result;
|
|
350
|
+
} catch (error) {
|
|
351
|
+
report(failedLogs(error));
|
|
352
|
+
throw error;
|
|
353
|
+
}
|
|
354
|
+
};
|
|
355
|
+
const call = async (tool, ...args) => (await timed(tool, ...args)).value;
|
|
356
|
+
const step = async (label, tool, ...args) => {
|
|
357
|
+
const { value, ms, logs } = await bridge.timed(tool, ...args).catch((error) => {
|
|
358
|
+
report(failedLogs(error));
|
|
359
|
+
throw error;
|
|
360
|
+
});
|
|
361
|
+
total += ms;
|
|
362
|
+
print(
|
|
363
|
+
`${String(++n).padStart(2, "0")} ${label.padEnd(30)} ${ms.toFixed(1).padStart(7)} ms`
|
|
364
|
+
);
|
|
365
|
+
report(logs);
|
|
366
|
+
return value;
|
|
367
|
+
};
|
|
368
|
+
const restoreErrors = [];
|
|
369
|
+
const applied = {};
|
|
370
|
+
if (needs.length && !bridge.tools().some((t) => t.name === "scenario.apply"))
|
|
371
|
+
throw new Error(
|
|
372
|
+
`The flow needs scenario "${needs[0].name}", but the app defines none: pass \`scenarios\` to useAgentBridge.`
|
|
373
|
+
);
|
|
374
|
+
try {
|
|
375
|
+
for (const { name, options: options2 } of needs) {
|
|
376
|
+
const args = options2 === void 0 ? [name] : [name, options2];
|
|
377
|
+
applied[name] = await step(`scenario ${name}`, "scenario.apply", ...args);
|
|
378
|
+
}
|
|
379
|
+
await flow.default({
|
|
380
|
+
bridge: { ...bridge, timed, call },
|
|
381
|
+
call,
|
|
382
|
+
step,
|
|
383
|
+
scenarios: applied
|
|
384
|
+
});
|
|
385
|
+
} finally {
|
|
386
|
+
if (needs.length) {
|
|
387
|
+
try {
|
|
388
|
+
const result = await step("restore", "bridge.restore");
|
|
389
|
+
for (const [name, value] of Object.entries(result ?? {})) {
|
|
390
|
+
const error = value?.error;
|
|
391
|
+
if (error !== void 0) restoreErrors.push(`${name}: ${String(error)}`);
|
|
392
|
+
}
|
|
393
|
+
} catch (error) {
|
|
394
|
+
restoreErrors.push(
|
|
395
|
+
`bridge.restore: ${error instanceof Error ? error.message : String(error)}`
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
for (const e of restoreErrors) print(` ! restore failed: ${e}`);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
print(
|
|
402
|
+
`${n} steps, ${total.toFixed(1)} ms in calls, ${(performance.now() - t0).toFixed(0)} ms wall (${bridge.transport})${errors ? `, ${errors} error${errors === 1 ? "" : "s"}` : ""}`
|
|
403
|
+
);
|
|
404
|
+
return { steps: n, errors, restoreErrors };
|
|
405
|
+
}
|
|
406
|
+
|
|
294
407
|
// src/client/session/client.ts
|
|
295
408
|
import { createConnection } from "net";
|
|
296
409
|
import { createInterface } from "readline";
|
|
@@ -559,6 +672,10 @@ export {
|
|
|
559
672
|
metroHost,
|
|
560
673
|
RUNTIME_MARKER,
|
|
561
674
|
openConnection,
|
|
675
|
+
logLine,
|
|
676
|
+
flowScenarios,
|
|
677
|
+
parseScenarioFlag,
|
|
678
|
+
runFlow,
|
|
562
679
|
stateDir,
|
|
563
680
|
sessionFiles,
|
|
564
681
|
checkName,
|
package/dist/cli.js
CHANGED
|
@@ -5,21 +5,25 @@ import {
|
|
|
5
5
|
checkName,
|
|
6
6
|
connect,
|
|
7
7
|
connectSession,
|
|
8
|
+
flowScenarios,
|
|
8
9
|
formatDuration,
|
|
9
10
|
isAlive,
|
|
10
11
|
listDevices,
|
|
11
12
|
listSessions,
|
|
13
|
+
logLine,
|
|
12
14
|
metroHost,
|
|
13
15
|
openConnection,
|
|
14
16
|
parseDuration,
|
|
17
|
+
parseScenarioFlag,
|
|
15
18
|
pickSession,
|
|
16
19
|
readSession,
|
|
17
20
|
removeSessionFiles,
|
|
21
|
+
runFlow,
|
|
18
22
|
sessionFiles,
|
|
19
23
|
sessionRequest,
|
|
20
24
|
stateDir,
|
|
21
25
|
writePrivate
|
|
22
|
-
} from "./chunk-
|
|
26
|
+
} from "./chunk-S2FYN2R5.js";
|
|
23
27
|
|
|
24
28
|
// src/cli.ts
|
|
25
29
|
import { readFile } from "fs/promises";
|
|
@@ -27,14 +31,6 @@ import { resolve } from "path";
|
|
|
27
31
|
import { pathToFileURL } from "url";
|
|
28
32
|
import { parseArgs } from "util";
|
|
29
33
|
|
|
30
|
-
// src/client/log-lines.ts
|
|
31
|
-
function logLine(entry) {
|
|
32
|
-
const where = entry.during ? ` during ${entry.during}` : entry.after ? ` after ${entry.after}` : "";
|
|
33
|
-
const first = entry.message.split("\n")[0] ?? "";
|
|
34
|
-
const text = first.length > 300 ? `${first.slice(0, 300)}...` : first;
|
|
35
|
-
return `! ${entry.level}${where}: ${text}`;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
34
|
// src/client/session/cli.ts
|
|
39
35
|
import { spawn } from "child_process";
|
|
40
36
|
import { existsSync, readFileSync, rmSync as rmSync2 } from "fs";
|
|
@@ -480,6 +476,9 @@ Usage
|
|
|
480
476
|
agent-bridge tools Tools the app exposes
|
|
481
477
|
agent-bridge call <tool> [args] Call a tool. args: a JSON array, or one JSON value
|
|
482
478
|
agent-bridge run <flow.mjs|.ts> Run a flow: export default async ({ step, call }) => {}
|
|
479
|
+
export const scenario = 'signedIn' applies it first
|
|
480
|
+
and runs bridge.restore after, even on failure
|
|
481
|
+
agent-bridge scenarios Scenarios the app defines (scenario.apply applies one)
|
|
483
482
|
agent-bridge assert-absent <files...> Fail if a release bundle contains the bridge
|
|
484
483
|
|
|
485
484
|
Sessions: one connection for all of an agent's calls
|
|
@@ -494,6 +493,8 @@ Options
|
|
|
494
493
|
--transport <name> auto (default), expo or cdp
|
|
495
494
|
--timeout <ms> Per-call timeout (default 10000)
|
|
496
495
|
--strict run: exit non-zero if the app logged an error
|
|
496
|
+
--scenario <name> run: also apply this scenario (repeatable). Options as
|
|
497
|
+
JSON after "=": --scenario 'signedIn={"user":{"name":"Ada"}}'
|
|
497
498
|
--session <name> Use this session (env AGENT_BRIDGE_SESSION)
|
|
498
499
|
--no-session Connect directly even if a session is running
|
|
499
500
|
`;
|
|
@@ -517,6 +518,7 @@ async function main() {
|
|
|
517
518
|
transport: { type: "string" },
|
|
518
519
|
timeout: { type: "string" },
|
|
519
520
|
strict: { type: "boolean" },
|
|
521
|
+
scenario: { type: "string", multiple: true },
|
|
520
522
|
name: { type: "string" },
|
|
521
523
|
idle: { type: "string" },
|
|
522
524
|
keep: { type: "boolean" },
|
|
@@ -583,47 +585,34 @@ async function main() {
|
|
|
583
585
|
const [file] = rest;
|
|
584
586
|
if (!file) throw new Error("Usage: agent-bridge run <flow file>");
|
|
585
587
|
const flow = await import(pathToFileURL(resolve(file)).href);
|
|
588
|
+
flowScenarios(flow);
|
|
589
|
+
const extra = (values.scenario ?? []).map(parseScenarioFlag);
|
|
586
590
|
return withBridge(async (bridge) => {
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
return result;
|
|
600
|
-
} catch (error) {
|
|
601
|
-
report(failedLogs(error));
|
|
602
|
-
throw error;
|
|
603
|
-
}
|
|
604
|
-
};
|
|
605
|
-
const call = async (tool, ...args) => (await timed(tool, ...args)).value;
|
|
606
|
-
const step = async (label, tool, ...args) => {
|
|
607
|
-
const { value, ms, logs } = await bridge.timed(tool, ...args).catch(
|
|
608
|
-
(error) => {
|
|
609
|
-
report(failedLogs(error));
|
|
610
|
-
throw error;
|
|
611
|
-
}
|
|
591
|
+
const { errors, restoreErrors } = await runFlow(bridge, flow, {
|
|
592
|
+
scenarios: extra
|
|
593
|
+
});
|
|
594
|
+
if (values.strict && errors || restoreErrors.length)
|
|
595
|
+
process.exitCode = 1;
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
case "scenarios":
|
|
599
|
+
return withBridge(async (bridge) => {
|
|
600
|
+
if (!bridge.tools().some((t) => t.name === "scenario.list")) {
|
|
601
|
+
console.log(
|
|
602
|
+
`${bridge.device.name} defines no scenarios (pass \`scenarios\` to useAgentBridge).`
|
|
612
603
|
);
|
|
613
|
-
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
const list2 = await bridge.call("scenario.list");
|
|
607
|
+
console.log(`${bridge.device.name} via ${bridge.transport}`);
|
|
608
|
+
for (const s of list2) {
|
|
614
609
|
console.log(
|
|
615
|
-
|
|
610
|
+
` ${s.name.padEnd(20)} ${s.active ? "(active) " : ""}${s.description ?? ""}`
|
|
616
611
|
);
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
}
|
|
620
|
-
await flow.default({ bridge: { ...bridge, timed, call }, call, step });
|
|
621
|
-
console.log(
|
|
622
|
-
`${n} steps, ${total.toFixed(1)} ms in calls, ${(performance.now() - t0).toFixed(0)} ms wall (${bridge.transport})${errors ? `, ${errors} error${errors === 1 ? "" : "s"}` : ""}`
|
|
623
|
-
);
|
|
624
|
-
if (values.strict && errors) process.exitCode = 1;
|
|
612
|
+
if (s.options)
|
|
613
|
+
console.log(` ${"".padEnd(20)} options: ${JSON.stringify(s.options)}`);
|
|
614
|
+
}
|
|
625
615
|
});
|
|
626
|
-
}
|
|
627
616
|
case "session":
|
|
628
617
|
return sessionCommand(rest[0], values);
|
|
629
618
|
case DAEMON_COMMAND:
|
package/dist/client/index.cjs
CHANGED
|
@@ -34,7 +34,8 @@ __export(client_exports, {
|
|
|
34
34
|
connect: () => connect,
|
|
35
35
|
connectSession: () => connectSession,
|
|
36
36
|
listDevices: () => listDevices,
|
|
37
|
-
listSessions: () => listSessions
|
|
37
|
+
listSessions: () => listSessions,
|
|
38
|
+
runFlow: () => runFlow
|
|
38
39
|
});
|
|
39
40
|
module.exports = __toCommonJS(client_exports);
|
|
40
41
|
|
|
@@ -330,6 +331,106 @@ async function openConnection(options = {}) {
|
|
|
330
331
|
}
|
|
331
332
|
}
|
|
332
333
|
|
|
334
|
+
// src/client/log-lines.ts
|
|
335
|
+
function logLine(entry) {
|
|
336
|
+
const where = entry.during ? ` during ${entry.during}` : entry.after ? ` after ${entry.after}` : "";
|
|
337
|
+
const first = entry.message.split("\n")[0] ?? "";
|
|
338
|
+
const text = first.length > 300 ? `${first.slice(0, 300)}...` : first;
|
|
339
|
+
return `! ${entry.level}${where}: ${text}`;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// src/client/flow.ts
|
|
343
|
+
function flowScenarios(flow) {
|
|
344
|
+
const declared = [
|
|
345
|
+
...flow.scenario === void 0 ? [] : [flow.scenario],
|
|
346
|
+
...flow.scenarios ?? []
|
|
347
|
+
];
|
|
348
|
+
return declared.map((s) => {
|
|
349
|
+
if (typeof s === "string") return { name: s };
|
|
350
|
+
if (s && typeof s === "object" && typeof s.name === "string") return s;
|
|
351
|
+
throw new Error(
|
|
352
|
+
`A flow's scenario is a name or { name, options }, not ${JSON.stringify(s)}`
|
|
353
|
+
);
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
function mergeScenarios(declared, extra) {
|
|
357
|
+
const names = new Set(extra.map((s) => s.name));
|
|
358
|
+
return [...declared.filter((s) => !names.has(s.name)), ...extra];
|
|
359
|
+
}
|
|
360
|
+
var failedLogs = (error) => error instanceof AgentBridgeCallError ? error.logs : [];
|
|
361
|
+
async function runFlow(bridge, flow, options = {}) {
|
|
362
|
+
const print = options.print ?? console.log;
|
|
363
|
+
const needs = mergeScenarios(flowScenarios(flow), options.scenarios ?? []);
|
|
364
|
+
let n = 0;
|
|
365
|
+
let total = 0;
|
|
366
|
+
let errors = 0;
|
|
367
|
+
const t0 = performance.now();
|
|
368
|
+
const report = (logs) => {
|
|
369
|
+
errors += logs.length;
|
|
370
|
+
for (const e of logs) print(` ${logLine(e)}`);
|
|
371
|
+
};
|
|
372
|
+
const timed = async (tool, ...args) => {
|
|
373
|
+
try {
|
|
374
|
+
const result = await bridge.timed(tool, ...args);
|
|
375
|
+
report(result.logs);
|
|
376
|
+
return result;
|
|
377
|
+
} catch (error) {
|
|
378
|
+
report(failedLogs(error));
|
|
379
|
+
throw error;
|
|
380
|
+
}
|
|
381
|
+
};
|
|
382
|
+
const call = async (tool, ...args) => (await timed(tool, ...args)).value;
|
|
383
|
+
const step = async (label, tool, ...args) => {
|
|
384
|
+
const { value, ms, logs } = await bridge.timed(tool, ...args).catch((error) => {
|
|
385
|
+
report(failedLogs(error));
|
|
386
|
+
throw error;
|
|
387
|
+
});
|
|
388
|
+
total += ms;
|
|
389
|
+
print(
|
|
390
|
+
`${String(++n).padStart(2, "0")} ${label.padEnd(30)} ${ms.toFixed(1).padStart(7)} ms`
|
|
391
|
+
);
|
|
392
|
+
report(logs);
|
|
393
|
+
return value;
|
|
394
|
+
};
|
|
395
|
+
const restoreErrors = [];
|
|
396
|
+
const applied = {};
|
|
397
|
+
if (needs.length && !bridge.tools().some((t) => t.name === "scenario.apply"))
|
|
398
|
+
throw new Error(
|
|
399
|
+
`The flow needs scenario "${needs[0].name}", but the app defines none: pass \`scenarios\` to useAgentBridge.`
|
|
400
|
+
);
|
|
401
|
+
try {
|
|
402
|
+
for (const { name, options: options2 } of needs) {
|
|
403
|
+
const args = options2 === void 0 ? [name] : [name, options2];
|
|
404
|
+
applied[name] = await step(`scenario ${name}`, "scenario.apply", ...args);
|
|
405
|
+
}
|
|
406
|
+
await flow.default({
|
|
407
|
+
bridge: { ...bridge, timed, call },
|
|
408
|
+
call,
|
|
409
|
+
step,
|
|
410
|
+
scenarios: applied
|
|
411
|
+
});
|
|
412
|
+
} finally {
|
|
413
|
+
if (needs.length) {
|
|
414
|
+
try {
|
|
415
|
+
const result = await step("restore", "bridge.restore");
|
|
416
|
+
for (const [name, value] of Object.entries(result ?? {})) {
|
|
417
|
+
const error = value?.error;
|
|
418
|
+
if (error !== void 0) restoreErrors.push(`${name}: ${String(error)}`);
|
|
419
|
+
}
|
|
420
|
+
} catch (error) {
|
|
421
|
+
restoreErrors.push(
|
|
422
|
+
`bridge.restore: ${error instanceof Error ? error.message : String(error)}`
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
for (const e of restoreErrors) print(` ! restore failed: ${e}`);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
print(
|
|
429
|
+
`${n} steps, ${total.toFixed(1)} ms in calls, ${(performance.now() - t0).toFixed(0)} ms wall (${bridge.transport})${errors ? `, ${errors} error${errors === 1 ? "" : "s"}` : ""}`
|
|
430
|
+
);
|
|
431
|
+
return { steps: n, errors, restoreErrors };
|
|
432
|
+
}
|
|
433
|
+
|
|
333
434
|
// src/client/session/client.ts
|
|
334
435
|
var import_node_net = require("net");
|
|
335
436
|
var import_node_readline = require("readline");
|
|
@@ -567,5 +668,6 @@ async function listDevices(options = {}) {
|
|
|
567
668
|
connect,
|
|
568
669
|
connectSession,
|
|
569
670
|
listDevices,
|
|
570
|
-
listSessions
|
|
671
|
+
listSessions,
|
|
672
|
+
runFlow
|
|
571
673
|
});
|
package/dist/client/index.d.ts
CHANGED
|
@@ -25,6 +25,49 @@ type DeviceInfo = {
|
|
|
25
25
|
|
|
26
26
|
type TransportName = 'expo' | 'cdp';
|
|
27
27
|
|
|
28
|
+
/** A scenario a flow needs: a name, or a name with options. */
|
|
29
|
+
type FlowScenario = string | {
|
|
30
|
+
name: string;
|
|
31
|
+
options?: unknown;
|
|
32
|
+
};
|
|
33
|
+
type FlowApi = {
|
|
34
|
+
bridge: AgentBridge;
|
|
35
|
+
call: AgentBridge['call'];
|
|
36
|
+
step: (label: string, tool: string, ...args: unknown[]) => Promise<unknown>;
|
|
37
|
+
/** What each declared scenario's apply returned, by name. */
|
|
38
|
+
scenarios: Record<string, unknown>;
|
|
39
|
+
};
|
|
40
|
+
/** A flow file: `export default async ({ step, call }) => {}`, plus what it needs. */
|
|
41
|
+
type FlowModule = {
|
|
42
|
+
default: (api: FlowApi) => Promise<void>;
|
|
43
|
+
/** Applied before the flow and undone after it, even when it fails. */
|
|
44
|
+
scenario?: FlowScenario;
|
|
45
|
+
scenarios?: FlowScenario[];
|
|
46
|
+
};
|
|
47
|
+
type FlowResult = {
|
|
48
|
+
steps: number;
|
|
49
|
+
/** Errors the app logged during the flow. */
|
|
50
|
+
errors: number;
|
|
51
|
+
/** Restorers that failed in the closing bridge.restore, with why. */
|
|
52
|
+
restoreErrors: string[];
|
|
53
|
+
};
|
|
54
|
+
type RunFlowOptions = {
|
|
55
|
+
/** Where step lines go. Default: console.log. */
|
|
56
|
+
print?: (line: string) => void;
|
|
57
|
+
/** More scenarios to apply, e.g. from `--scenario`; one of the same name replaces the flow's. */
|
|
58
|
+
scenarios?: Array<{
|
|
59
|
+
name: string;
|
|
60
|
+
options?: unknown;
|
|
61
|
+
}>;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Runs a flow module you imported: applies its scenarios, runs it, and when
|
|
65
|
+
* there were any, undoes everything with bridge.restore afterwards, even
|
|
66
|
+
* when it fails. Throws what the flow threw; otherwise returns how it went.
|
|
67
|
+
* It runs only the code you hand it, in this process.
|
|
68
|
+
*/
|
|
69
|
+
declare function runFlow(bridge: AgentBridge, flow: FlowModule, options?: RunFlowOptions): Promise<FlowResult>;
|
|
70
|
+
|
|
28
71
|
/** What a running session writes next to its socket. Readable only by the user. */
|
|
29
72
|
type SessionState = {
|
|
30
73
|
name: string;
|
|
@@ -109,4 +152,4 @@ type ListedDevice = {
|
|
|
109
152
|
/** Apps with the bridge on Expo's socket, plus every React Native page Metro knows. */
|
|
110
153
|
declare function listDevices(options?: Pick<ConnectOptions, 'metro'>): Promise<ListedDevice[]>;
|
|
111
154
|
|
|
112
|
-
export { type AgentBridge, AgentBridgeCallError, type ConnectOptions, type DeviceInfo, type ListedDevice, type LogEntry, type SessionConnectOptions, type SessionState, type Timed, type ToolInfo, type TransportName, connect, connectSession, listDevices, listSessions };
|
|
155
|
+
export { type AgentBridge, AgentBridgeCallError, type ConnectOptions, type DeviceInfo, type FlowApi, type FlowModule, type FlowResult, type FlowScenario, type ListedDevice, type LogEntry, type RunFlowOptions, type SessionConnectOptions, type SessionState, type Timed, type ToolInfo, type TransportName, connect, connectSession, listDevices, listSessions, runFlow };
|
package/dist/client/index.js
CHANGED
|
@@ -3,12 +3,14 @@ import {
|
|
|
3
3
|
connect,
|
|
4
4
|
connectSession,
|
|
5
5
|
listDevices,
|
|
6
|
-
listSessions
|
|
7
|
-
|
|
6
|
+
listSessions,
|
|
7
|
+
runFlow
|
|
8
|
+
} from "../chunk-S2FYN2R5.js";
|
|
8
9
|
export {
|
|
9
10
|
AgentBridgeCallError,
|
|
10
11
|
connect,
|
|
11
12
|
connectSession,
|
|
12
13
|
listDevices,
|
|
13
|
-
listSessions
|
|
14
|
+
listSessions,
|
|
15
|
+
runFlow
|
|
14
16
|
};
|
package/dist/expo/index.d.cts
CHANGED