peachsim 0.1.527

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.
Files changed (324) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +640 -0
  3. package/cli/app-config.ts +82 -0
  4. package/cli/app-fonts.ts +388 -0
  5. package/cli/app-project.ts +59 -0
  6. package/cli/app-splash.ts +171 -0
  7. package/cli/app-state-reset.ts +26 -0
  8. package/cli/auth.ts +184 -0
  9. package/cli/bin.ts +59 -0
  10. package/cli/bridge-diagnostics.ts +226 -0
  11. package/cli/bridge-flow-runner.ts +2806 -0
  12. package/cli/browser-backend.ts +44 -0
  13. package/cli/browser-evals.ts +96 -0
  14. package/cli/cli-update.ts +261 -0
  15. package/cli/cloud-client.ts +1530 -0
  16. package/cli/cloud-dispatch.ts +206 -0
  17. package/cli/cloud-session.ts +227 -0
  18. package/cli/command-contract.ts +221 -0
  19. package/cli/command-registry.ts +304 -0
  20. package/cli/commands/agent-wrapper.ts +1048 -0
  21. package/cli/commands/agent.ts +439 -0
  22. package/cli/commands/app-fonts.ts +99 -0
  23. package/cli/commands/assert.ts +542 -0
  24. package/cli/commands/auth.ts +71 -0
  25. package/cli/commands/box/bundle-plane.ts +71 -0
  26. package/cli/commands/box/checkout-plane.ts +256 -0
  27. package/cli/commands/box/plane-share.ts +208 -0
  28. package/cli/commands/box/rnx-command.ts +60 -0
  29. package/cli/commands/box/search-command.ts +157 -0
  30. package/cli/commands/box.ts +925 -0
  31. package/cli/commands/camera.ts +266 -0
  32. package/cli/commands/cleanup.ts +169 -0
  33. package/cli/commands/compat.ts +176 -0
  34. package/cli/commands/config.ts +34 -0
  35. package/cli/commands/control.ts +2154 -0
  36. package/cli/commands/cpu-profile.ts +941 -0
  37. package/cli/commands/daemon-mac-app.ts +165 -0
  38. package/cli/commands/daemon.ts +800 -0
  39. package/cli/commands/debug.ts +751 -0
  40. package/cli/commands/desktop.ts +39 -0
  41. package/cli/commands/detect.ts +201 -0
  42. package/cli/commands/detox.ts +481 -0
  43. package/cli/commands/device.ts +134 -0
  44. package/cli/commands/diagnose.ts +589 -0
  45. package/cli/commands/do-chain.ts +408 -0
  46. package/cli/commands/electron.ts +94 -0
  47. package/cli/commands/film.ts +379 -0
  48. package/cli/commands/flow.ts +1240 -0
  49. package/cli/commands/inspect/actions.ts +516 -0
  50. package/cli/commands/inspect/core.ts +2086 -0
  51. package/cli/commands/inspect/count.ts +17 -0
  52. package/cli/commands/inspect/describe.ts +119 -0
  53. package/cli/commands/inspect/env.ts +25 -0
  54. package/cli/commands/inspect/find.ts +169 -0
  55. package/cli/commands/inspect/get-layout.ts +53 -0
  56. package/cli/commands/inspect/keyboard.ts +54 -0
  57. package/cli/commands/inspect/list.ts +175 -0
  58. package/cli/commands/inspect/memory.ts +215 -0
  59. package/cli/commands/inspect/redaction.ts +39 -0
  60. package/cli/commands/inspect/resolve-target.ts +77 -0
  61. package/cli/commands/inspect/screens.ts +78 -0
  62. package/cli/commands/inspect/settle.ts +32 -0
  63. package/cli/commands/inspect/settling.ts +197 -0
  64. package/cli/commands/inspect/shared.ts +259 -0
  65. package/cli/commands/inspect/sleep.ts +15 -0
  66. package/cli/commands/inspect/tree.ts +23 -0
  67. package/cli/commands/inspect/url.ts +17 -0
  68. package/cli/commands/inspect/wait-event.ts +211 -0
  69. package/cli/commands/inspect/wait-idle.ts +25 -0
  70. package/cli/commands/inspect/wait-ready.ts +87 -0
  71. package/cli/commands/inspect/wait-selector.ts +59 -0
  72. package/cli/commands/inspect/wait.ts +32 -0
  73. package/cli/commands/inspect.ts +4738 -0
  74. package/cli/commands/install-cli.ts +275 -0
  75. package/cli/commands/install-desktop.ts +385 -0
  76. package/cli/commands/login.ts +357 -0
  77. package/cli/commands/logout.ts +24 -0
  78. package/cli/commands/maestro-generate.ts +375 -0
  79. package/cli/commands/maestro.ts +536 -0
  80. package/cli/commands/mode.ts +58 -0
  81. package/cli/commands/no-bridge-hint.ts +80 -0
  82. package/cli/commands/perf.ts +67 -0
  83. package/cli/commands/permissions.ts +203 -0
  84. package/cli/commands/platform.ts +410 -0
  85. package/cli/commands/preview.ts +103 -0
  86. package/cli/commands/profile.ts +106 -0
  87. package/cli/commands/react.ts +353 -0
  88. package/cli/commands/record.ts +1435 -0
  89. package/cli/commands/report-issue.ts +305 -0
  90. package/cli/commands/reset.ts +89 -0
  91. package/cli/commands/runtime.ts +348 -0
  92. package/cli/commands/screenshot-command.ts +106 -0
  93. package/cli/commands/screenshot-layers.ts +144 -0
  94. package/cli/commands/screenshot-mode.ts +38 -0
  95. package/cli/commands/screenshot.ts +492 -0
  96. package/cli/commands/screenshots-capture.ts +608 -0
  97. package/cli/commands/screenshots.ts +127 -0
  98. package/cli/commands/serve.ts +139 -0
  99. package/cli/commands/setup.ts +416 -0
  100. package/cli/commands/shell-boolean-mode.ts +82 -0
  101. package/cli/commands/skills.ts +468 -0
  102. package/cli/commands/slides.ts +357 -0
  103. package/cli/commands/state.ts +94 -0
  104. package/cli/commands/storage.ts +58 -0
  105. package/cli/commands/telemetry.ts +55 -0
  106. package/cli/commands/test.ts +535 -0
  107. package/cli/commands/three-mode.ts +771 -0
  108. package/cli/commands/timeline.ts +121 -0
  109. package/cli/commands/upgrade.ts +167 -0
  110. package/cli/commands/upload.ts +1203 -0
  111. package/cli/commands/version.ts +63 -0
  112. package/cli/commands/welcome.ts +17 -0
  113. package/cli/commands/what-happened.ts +328 -0
  114. package/cli/current-sim.ts +217 -0
  115. package/cli/desktop-companion.ts +315 -0
  116. package/cli/drivers/electron.ts +70 -0
  117. package/cli/drivers/index.ts +20 -0
  118. package/cli/drivers/playwright-provisioning.ts +184 -0
  119. package/cli/drivers/playwright-sim-host.ts +537 -0
  120. package/cli/drivers/playwright.ts +209 -0
  121. package/cli/drivers/registry.ts +65 -0
  122. package/cli/drivers/types.ts +102 -0
  123. package/cli/flow-export.ts +55 -0
  124. package/cli/flow-file.ts +142 -0
  125. package/cli/flow-live-status.ts +126 -0
  126. package/cli/flow-session.ts +140 -0
  127. package/cli/help-core.ts +143 -0
  128. package/cli/help.ts +44 -0
  129. package/cli/hidden-runtime-alias.ts +19 -0
  130. package/cli/hints.ts +214 -0
  131. package/cli/inspect-notice-state.ts +114 -0
  132. package/cli/internal-child.ts +176 -0
  133. package/cli/maestro-js.ts +323 -0
  134. package/cli/main.ts +704 -0
  135. package/cli/open-url.ts +8 -0
  136. package/cli/outbound-endpoints.ts +830 -0
  137. package/cli/parent-pid.ts +204 -0
  138. package/cli/parse-args.ts +189 -0
  139. package/cli/privacy.ts +53 -0
  140. package/cli/prompt.ts +56 -0
  141. package/cli/recording-access.ts +108 -0
  142. package/cli/registry.ts +2 -0
  143. package/cli/resolve-assets.ts +63 -0
  144. package/cli/rnx-in-process.ts +98 -0
  145. package/cli/run-registry.ts +226 -0
  146. package/cli/run-rnx-core.ts +148 -0
  147. package/cli/run-rnx.ts +137 -0
  148. package/cli/runtime-notes.ts +66 -0
  149. package/cli/runtime-summary.ts +25 -0
  150. package/cli/secure-file.ts +31 -0
  151. package/cli/self-invocation.ts +58 -0
  152. package/cli/send-to-box.ts +109 -0
  153. package/cli/setup-repository.ts +191 -0
  154. package/cli/shell-init.ts +98 -0
  155. package/cli/standalone.ts +4 -0
  156. package/cli/telemetry.ts +84 -0
  157. package/cli/ui.ts +68 -0
  158. package/cli/ws-bridge.ts +1020 -0
  159. package/detox/attempt-events.cjs +23 -0
  160. package/detox/colors.ts +54 -0
  161. package/detox/config-loader.ts +135 -0
  162. package/detox/element-types.ts +48 -0
  163. package/detox/expectations.ts +534 -0
  164. package/detox/gestures.ts +501 -0
  165. package/detox/index.ts +2694 -0
  166. package/detox/jest-preset.cjs +29 -0
  167. package/detox/jest-setup-after-env.cjs +22 -0
  168. package/detox/matchers.ts +30 -0
  169. package/detox/motion-change.cjs +109 -0
  170. package/detox/motion-change.d.cts +35 -0
  171. package/detox/navigation-lifecycle.ts +290 -0
  172. package/detox/navigation.ts +43 -0
  173. package/detox/proof-frame.cjs +114 -0
  174. package/detox/record-block-execution.cjs +78 -0
  175. package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
  176. package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
  177. package/detox/screenshots/color-test-rest-norngh.png +0 -0
  178. package/detox/screenshots/theme-blue-toggle.png +0 -0
  179. package/detox/screenshots/theme-blue.png +0 -0
  180. package/detox/screenshots/theme-red-toggle.png +0 -0
  181. package/detox/screenshots/theme-red.png +0 -0
  182. package/dist-lib/agent-daemon-client.cjs +502 -0
  183. package/dist-lib/agent-events.cjs +48 -0
  184. package/dist-lib/agent-identity.cjs +53 -0
  185. package/dist-lib/agent-sessions.cjs +759 -0
  186. package/dist-lib/attached-projects.cjs +526 -0
  187. package/dist-lib/auth/shared-session.cjs +208 -0
  188. package/dist-lib/backend-origin.cjs +88 -0
  189. package/dist-lib/beta.cjs +41 -0
  190. package/dist-lib/beta.mjs +13 -0
  191. package/dist-lib/bridge-constants.cjs +161 -0
  192. package/dist-lib/bridge-contract-input.cjs +451 -0
  193. package/dist-lib/bridge-contract-input.mjs +414 -0
  194. package/dist-lib/bridge-contract.cjs +130 -0
  195. package/dist-lib/bridge-contract.mjs +94 -0
  196. package/dist-lib/capture-contract.cjs +220 -0
  197. package/dist-lib/capture-contract.mjs +190 -0
  198. package/dist-lib/cli-constants.cjs +32 -0
  199. package/dist-lib/cloud-contract.cjs +194 -0
  200. package/dist-lib/cloud-contract.mjs +145 -0
  201. package/dist-lib/cloud.cjs +670 -0
  202. package/dist-lib/cloud.mjs +639 -0
  203. package/dist-lib/config.cjs +96 -0
  204. package/dist-lib/detox/index.cjs +3789 -0
  205. package/dist-lib/detox/jest-preset.cjs +29 -0
  206. package/dist-lib/dev-bundle-resolution.cjs +290 -0
  207. package/dist-lib/home-paths.cjs +638 -0
  208. package/dist-lib/host/bridge-host.cjs +10903 -0
  209. package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
  210. package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
  211. package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
  212. package/dist-lib/host/replacement-module-handler.cjs +469 -0
  213. package/dist-lib/host/websocket-proxy.cjs +298 -0
  214. package/dist-lib/index.cjs +4268 -0
  215. package/dist-lib/jump-to-source-babel.cjs +93 -0
  216. package/dist-lib/jump-to-source-native.cjs +112 -0
  217. package/dist-lib/menu.cjs +646 -0
  218. package/dist-lib/menu.mjs +604 -0
  219. package/dist-lib/metro-fingerprint-registry.cjs +186 -0
  220. package/dist-lib/metro-fingerprint-registry.mjs +156 -0
  221. package/dist-lib/metro-production-bundle.cjs +329 -0
  222. package/dist-lib/metro-production-bundle.mjs +295 -0
  223. package/dist-lib/metro.cjs +904 -0
  224. package/dist-lib/profiles.cjs +246 -0
  225. package/dist-lib/public-brand.cjs +39 -0
  226. package/dist-lib/react-native-host-modules.cjs +51 -0
  227. package/dist-lib/react-native-host-modules.mjs +25 -0
  228. package/dist-lib/render-mode.cjs +54 -0
  229. package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
  230. package/dist-lib/sdk.cjs +2433 -0
  231. package/dist-lib/sdk.mjs +2336 -0
  232. package/dist-lib/skills.cjs +39132 -0
  233. package/dist-lib/swift-submit.cjs +92 -0
  234. package/dist-lib/swift-submit.mjs +65 -0
  235. package/dist-lib/swift.cjs +1646 -0
  236. package/dist-lib/vite.cjs +1591 -0
  237. package/package.json +290 -0
  238. package/scripts/dev-server-scanner.ts +1291 -0
  239. package/scripts/optional-demo-registry.ts +113 -0
  240. package/skills/contrast/SKILL.md +165 -0
  241. package/skills/rnx-debug/SKILL.md +433 -0
  242. package/skills/rnx-perf/SKILL.md +399 -0
  243. package/skills/rnx-setup/SKILL.md +172 -0
  244. package/skills/rnx-test/SKILL.md +321 -0
  245. package/skills/rnx-visual/SKILL.md +126 -0
  246. package/src/agent-daemon-client.ts +410 -0
  247. package/src/agent-events.ts +71 -0
  248. package/src/agent-identity.ts +23 -0
  249. package/src/agent-prompt.ts +71 -0
  250. package/src/agent-sessions.ts +580 -0
  251. package/src/app-url.ts +39 -0
  252. package/src/attached-projects.ts +539 -0
  253. package/src/auth/shared-session.ts +235 -0
  254. package/src/backend-origin.ts +70 -0
  255. package/src/beta.ts +19 -0
  256. package/src/bridge-constants.ts +175 -0
  257. package/src/bridge-contract-input.ts +502 -0
  258. package/src/bridge-contract.ts +558 -0
  259. package/src/browser-cache.ts +16 -0
  260. package/src/capture-contract.ts +587 -0
  261. package/src/cli-constants.ts +1 -0
  262. package/src/cli-version.ts +40 -0
  263. package/src/cloud-contract.ts +274 -0
  264. package/src/cloud.ts +762 -0
  265. package/src/codex-client.ts +234 -0
  266. package/src/config.ts +132 -0
  267. package/src/connect.ts +101 -0
  268. package/src/dev-bundle-resolution.ts +346 -0
  269. package/src/dev-server-open.ts +198 -0
  270. package/src/disk-cleanup.ts +540 -0
  271. package/src/flow-timeout-scale.ts +15 -0
  272. package/src/home-paths.ts +806 -0
  273. package/src/host/agent-host.ts +666 -0
  274. package/src/host/bridge-host.ts +2788 -0
  275. package/src/host/fetch-proxy-handler.ts +449 -0
  276. package/src/host/fetch-proxy-overrides.ts +51 -0
  277. package/src/host/open-url.ts +89 -0
  278. package/src/host/plane-host.ts +203 -0
  279. package/src/host/replacement-module-handler.ts +158 -0
  280. package/src/host/websocket-proxy.ts +315 -0
  281. package/src/index.ts +71 -0
  282. package/src/jump-to-source-babel.ts +135 -0
  283. package/src/jump-to-source-native.ts +102 -0
  284. package/src/menu.ts +777 -0
  285. package/src/metro-fingerprint-registry.ts +302 -0
  286. package/src/metro-plugin.ts +341 -0
  287. package/src/metro-production-bundle.ts +471 -0
  288. package/src/native-dev-bundle-url.ts +118 -0
  289. package/src/native-seam-manifest.ts +341 -0
  290. package/src/playwright-host-log.ts +16 -0
  291. package/src/plist.ts +27 -0
  292. package/src/poll-until.ts +123 -0
  293. package/src/profiles.ts +179 -0
  294. package/src/public-brand.ts +9 -0
  295. package/src/react-native-host-modules.ts +26 -0
  296. package/src/render-mode.ts +27 -0
  297. package/src/rolldown-plugin-swift.ts +240 -0
  298. package/src/runtime-assets.ts +233 -0
  299. package/src/runtime-delivery.ts +54 -0
  300. package/src/screenshots/compose.ts +422 -0
  301. package/src/screenshots/frame-compose.ts +514 -0
  302. package/src/screenshots/orchestrate.ts +256 -0
  303. package/src/screenshots/org-deck.ts +91 -0
  304. package/src/screenshots/registry.ts +58 -0
  305. package/src/screenshots/schema.ts +364 -0
  306. package/src/sdk.ts +15 -0
  307. package/src/settings-bundle.ts +418 -0
  308. package/src/shell-bridge-identity.ts +15 -0
  309. package/src/sim-client.ts +697 -0
  310. package/src/skills/builtin/a11y-review.ts +132 -0
  311. package/src/skills/builtin/compat-check.ts +71 -0
  312. package/src/skills/builtin/maestro-test.ts +121 -0
  313. package/src/skills/builtin/perf-profile.ts +90 -0
  314. package/src/skills/builtin/screenshot-all.ts +52 -0
  315. package/src/skills/builtin/visual-diff.ts +100 -0
  316. package/src/skills/registry.ts +107 -0
  317. package/src/skills/types.ts +41 -0
  318. package/src/swift-submit.ts +185 -0
  319. package/src/swift.ts +7 -0
  320. package/src/team-machine-prompt.ts +73 -0
  321. package/src/vite-plugin-one.ts +264 -0
  322. package/src/vite-plugin-swift.ts +620 -0
  323. package/src/vite-plugin.ts +1946 -0
  324. package/src/worklets-babel.ts +250 -0
@@ -0,0 +1,1240 @@
1
+ // shared Maestro YAML playback and live-authoring implementation.
2
+ // discovers an rnx target when needed, then reuses the shared bridge action layer
3
+
4
+ import * as fs from 'fs'
5
+ import { createHash, randomUUID } from 'node:crypto'
6
+ import { tmpdir } from 'os'
7
+ import * as path from 'path'
8
+ import { scanDevServers } from '../../scripts/dev-server-scanner'
9
+ import { loadOptionalDemoApps } from '../../scripts/optional-demo-registry'
10
+ import { applyRNXConfigToUrl, type RNXConfig } from '../../src/config'
11
+ import { printBridgeFailureDiagnostics } from '../bridge-diagnostics'
12
+ import {
13
+ SootSimBridgeFlowRunner,
14
+ type SootSimFlowProfileResult,
15
+ type SootSimFlowTraceStep,
16
+ } from '../bridge-flow-runner'
17
+ import { readCurrentSimId, saveCurrentSimId } from '../current-sim'
18
+ import { findDesktopCompanion } from '../desktop-companion'
19
+ import { exportFlowSession } from '../flow-export'
20
+ import { parseFlowFile, validateFlowFile } from '../flow-file'
21
+ import { FlowLiveStatusReporter } from '../flow-live-status'
22
+ import { finalizeFlowSession, keepFlowCandidate, startFlowSession } from '../flow-session'
23
+ import { GLOBAL_FLAG_TAKES_VALUE } from '../parse-args'
24
+ import { rnxExit } from '../run-rnx'
25
+ import {
26
+ BRIDGE_VALUE_FLAGS,
27
+ createBridge,
28
+ createBridgeFromParsed,
29
+ parseBridgeCliArgs,
30
+ resolveBridgePortForPin,
31
+ } from '../ws-bridge'
32
+ import {
33
+ buildShellUrl,
34
+ closeSimsBulk,
35
+ resolveShellBaseUrlForBridgePort,
36
+ runOpenCommand,
37
+ terminatePlaywrightHostsForSims,
38
+ } from './control'
39
+ import { printShellPerfReport } from './inspect'
40
+ import { resolveDefaultUploadOrigin } from './upload'
41
+ import type { UploadResult } from './upload'
42
+
43
+ export { parseFlowFile, validateFlowFile } from '../flow-file'
44
+
45
+ // the complete flag vocabulary of `runFlowPlayback`. it is both the strip list
46
+ // handed to parseBridgeCliArgs and the vocabulary the unknown-argument check
47
+ // enforces, so a flag added to the runner and left out of here gets rejected
48
+ // rather than silently dropped.
49
+ const FLOW_BOOLEAN_FLAGS = [
50
+ '--record',
51
+ '--profile',
52
+ '--electron',
53
+ '--new',
54
+ '--preview',
55
+ '--preview-open',
56
+ '--headless',
57
+ '--headed',
58
+ '--proof',
59
+ '--no-shell',
60
+ '--shell-only',
61
+ ]
62
+
63
+ const FLOW_VALUE_FLAGS = [
64
+ '--app',
65
+ '--out',
66
+ '--base-url',
67
+ '--device',
68
+ '--theme',
69
+ '--slow',
70
+ '--tail-wait',
71
+ '--url',
72
+ '--replace',
73
+ '--remap',
74
+ '--screenshots',
75
+ '--screenshot-paths',
76
+ '--preview-origin',
77
+ '--preview-public-origin',
78
+ '--driver',
79
+ '--billing-kind',
80
+ '--owner',
81
+ '--repo',
82
+ ]
83
+
84
+ // the strip lists parseBridgeCliArgs needs to see a flow argv the way the
85
+ // runner does. exported so a command that pre-parses argv before handing it to
86
+ // runFlowPlayback reads the same vocabulary instead of keeping its own copy.
87
+ export const FLOW_BRIDGE_ARG_OPTIONS = {
88
+ stripBooleanFlags: FLOW_BOOLEAN_FLAGS,
89
+ stripValueFlags: FLOW_VALUE_FLAGS,
90
+ }
91
+
92
+ // every flag that swallows the token after it on a flow argv. a caller picking
93
+ // the positional flow path out of raw argv has to skip those tokens, or
94
+ // `maestro test --sim a2 login.yaml` resolves `a2` as the flow file.
95
+ export const FLOW_ARG_VALUE_FLAGS = [...FLOW_VALUE_FLAGS, ...BRIDGE_VALUE_FLAGS]
96
+
97
+ export interface UnexpectedFlowArg {
98
+ arg: string
99
+ message: string
100
+ }
101
+
102
+ // runFlowPlayback is the end of the line for argv: everything below it is built
103
+ // from named values, never forwarded raw. so an argument it does not read is
104
+ // read by nobody, and dropping one is how a run reports success while doing
105
+ // something other than what was asked — a stale build took --proof, discarded
106
+ // it, and produced non-proof screenshots under a proof-mode label.
107
+ //
108
+ // parseBridgeCliArgs already removes every flag this command understands and
109
+ // every flag the bridge owns, each with its value, so whatever it leaves behind
110
+ // past the flow path at args[0] is an argument nothing here consumes.
111
+ export function findUnexpectedFlowArgs(args: string[]): UnexpectedFlowArg[] {
112
+ const known = new Set([...FLOW_BOOLEAN_FLAGS, ...FLOW_VALUE_FLAGS])
113
+ const problems: UnexpectedFlowArg[] = parseBridgeCliArgs(args, FLOW_BRIDGE_ARG_OPTIONS)
114
+ .positional.slice(1)
115
+ .map((arg) => {
116
+ const eq = arg.indexOf('=')
117
+ const head = eq > 0 ? arg.slice(0, eq) : ''
118
+ if (known.has(head)) {
119
+ return {
120
+ arg,
121
+ message: `${head} takes its value as a separate argument: ${head} ${arg.slice(eq + 1)}`,
122
+ }
123
+ }
124
+ const globalTakesValue = GLOBAL_FLAG_TAKES_VALUE.get(arg)
125
+ if (globalTakesValue !== undefined) {
126
+ return {
127
+ arg,
128
+ message: `${arg} is a global rnx flag and goes before the command: rnx ${arg}${
129
+ globalTakesValue ? ' <value>' : ''
130
+ } <command>`,
131
+ }
132
+ }
133
+ if (arg.startsWith('-')) return { arg, message: `unknown flag: ${arg}` }
134
+ return { arg, message: `unexpected argument: ${arg}` }
135
+ })
136
+
137
+ // a value flag left without a value keeps the runner's default instead of
138
+ // what the caller meant to set, which is the same silent substitution.
139
+ for (let i = 0; i < args.length; i++) {
140
+ if (!FLOW_VALUE_FLAGS.includes(args[i])) continue
141
+ const value = args[i + 1]
142
+ if (value === undefined || value.startsWith('-')) {
143
+ problems.push({ arg: args[i], message: `${args[i]} expects a value` })
144
+ }
145
+ i++
146
+ }
147
+ return problems
148
+ }
149
+
150
+ let lastPreviewUploadResult: UploadResult | null = null
151
+
152
+ export function getLastFlowPreviewUploadResult(): UploadResult | null {
153
+ return lastPreviewUploadResult
154
+ }
155
+
156
+ // per-step trace of the most recent playback, preview or not — callers
157
+ // Maestro callers fold this into their run registration.
158
+ let lastFlowTraceSteps: SootSimFlowTraceStep[] = []
159
+
160
+ export function getLastFlowTraceSteps(): SootSimFlowTraceStep[] {
161
+ return lastFlowTraceSteps
162
+ }
163
+
164
+ // resolve a frontmatter `app:` value that may be a port, a URL, or a
165
+ // registered demo name (`bluesky`, `3pc`, `uniswap`, …). ports and URLs pass
166
+ // through untouched. a registered name also carries the launcher's preferred
167
+ // port and runtime config into the open command.
168
+ export async function resolveAppFrontmatterTarget(
169
+ raw: string,
170
+ ): Promise<{ target: string; runtimeConfig?: RNXConfig }> {
171
+ if (!raw) return { target: '' }
172
+ const trimmed = raw.trim()
173
+ if (!trimmed) return { target: '' }
174
+ if (/^\d+$/.test(trimmed)) return { target: trimmed }
175
+ if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
176
+ return { target: trimmed }
177
+ }
178
+ // try a case-insensitive name match against the OPTIONAL internal demo
179
+ // registry (in-repo only; empty on a published install). unknown strings
180
+ // fall through unchanged so other targets (host:port, raw bundle URLs
181
+ // without a scheme) still reach the legacy resolver.
182
+ const apps = await loadOptionalDemoApps()
183
+ const match = apps.find((app) => app.name.toLowerCase() === trimmed.toLowerCase())
184
+ if (match) {
185
+ return {
186
+ target: String(match.preferredPort),
187
+ runtimeConfig: match.runtimeConfig,
188
+ }
189
+ }
190
+ return { target: trimmed }
191
+ }
192
+
193
+ // discover an rnx shell URL — prefer patched servers, fall back to any match.
194
+ // exported so `detox.ts` and `maestro.ts` can reuse the same discovery logic.
195
+ export async function discoverSootsimUrl(): Promise<string | null> {
196
+ const servers = await scanDevServers()
197
+ const patched = servers.find((s) => s.patched)
198
+ if (patched) return `http://localhost:${patched.port}/__soot/`
199
+ const any = servers[0]
200
+ if (any) return `http://localhost:${any.port}/__soot/`
201
+ return null
202
+ }
203
+
204
+ export async function runFlowPlayback(args: string[]): Promise<number> {
205
+ lastPreviewUploadResult = null
206
+ lastFlowTraceSteps = []
207
+ if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
208
+ console.log(`
209
+ rnx maestro — author and run Maestro YAML flows
210
+
211
+ usage:
212
+ rnx maestro test <flow.yaml> [options]
213
+ rnx maestro start
214
+ rnx maestro keep
215
+ rnx maestro end [--output <path>] [--validate] [--video]
216
+ rnx maestro validate <path>
217
+
218
+ options:
219
+ --record record a webm artifact while the flow runs
220
+ --profile capture perf stats while the flow runs
221
+ --out <dir> output directory (default: /tmp/rnx-recordings)
222
+ --device <model> override the flow frontmatter device for this run
223
+ --theme <scheme> override the flow frontmatter theme (light or dark) for
224
+ this run
225
+ --slow <ms> delay between steps for natural pacing
226
+ --tail-wait <ms> hold the recording open after the last step so the
227
+ final UI state is captured (default: smart idle up to
228
+ 6000ms for --preview, fixed 2000ms for --record, 0 otherwise)
229
+ --url <url> load this target before running the flow
230
+ --base-url <url> run the flow against the shell at this URL instead of
231
+ the discovered one, and poll that shell's own bridge.
232
+ errors when no live rnx world serves it, so a pinned run
233
+ can never quietly measure the default shell instead
234
+ --screenshot-paths <mode>
235
+ screenshot path mode: dir (default) or flow
236
+ --no-shell capture every screenshot tenant-only (no status bar,
237
+ keyboard, notification center, or other shell overlays).
238
+ use for clean demo asset sets. per-step \`layers:\`
239
+ still wins over this default.
240
+ --shell-only inverse — capture shell chrome only
241
+ --replace <m>=<f> replace a Metro module with a local file for this run
242
+ --new open a fresh sim before running the flow
243
+ --driver <id> when paired with --new, use playwright or electron;
244
+ browser sims default to isolated playwright
245
+ --headless pass headless=true to the launch driver (useful
246
+ with --driver playwright)
247
+ --headed force a fresh, VISIBLE window for this run (implies
248
+ --new; overrides --headless). use when you want to
249
+ watch the flow run live even if the current sim is
250
+ headless or stale
251
+ --proof deterministic-capture mode: pin the TextInput caret
252
+ visible instead of blinking, stop notifications
253
+ auto-dismissing, and leave the device corners unclipped
254
+ to match a native simctl framebuffer. a screen holding a
255
+ focused text field cannot reach three identical idle
256
+ frames without this, so its screenshot fails after 12
257
+ tries. use it for any run whose screenshots get
258
+ compared to anything
259
+ --electron prefer opening the target in the desktop companion
260
+ --sim <id> target a specific current sim
261
+ --validate replay the drafted flow before clearing it
262
+ --video replay and record the drafted flow before clearing it
263
+ --preview record events+video, upload, and print /preview/<id>
264
+ --preview-open open the uploaded preview link after a successful run
265
+ --preview-origin <url>
266
+ upload target for --preview (default: auto)
267
+ --preview-public-origin <url>
268
+ public link origin for --preview (default: rnxsim.com
269
+ for prod uploads)
270
+ --billing-kind test_run
271
+ meter the chained preview upload as a test run
272
+ --owner <org> associate preview upload with a linked org repo
273
+ --repo <repo> repo name for --owner; user must belong to the team
274
+
275
+ frontmatter (top of flow.yaml between --- markers):
276
+ app: 8081 load this target before the flow starts
277
+ device: iphone-16 set the live device model for this flow run
278
+ electron: true prefer the desktop companion sim for this flow
279
+
280
+ default runtime:
281
+ bridge-backed flow playback against the current sim
282
+ if no current sim exists, flow opens the discovered target first
283
+ recording and profiling both run on the same bridge-backed path
284
+
285
+ examples:
286
+ rnx maestro test login.yaml
287
+ rnx maestro test demo.yaml --url 8081 --record --slow 500 --out ./recordings
288
+ rnx maestro test demo.yaml --sim a2
289
+ rnx maestro start
290
+ rnx maestro keep
291
+ rnx maestro end --output ./.maestro/draft.yaml
292
+ rnx maestro end --output ./.maestro/draft.yaml --validate
293
+ rnx maestro end --output ./.maestro/draft.yaml --video
294
+ rnx maestro validate ./.maestro/draft.yaml
295
+
296
+ flow extension:
297
+ takeScreenshot: hero
298
+ takeScreenshot:
299
+ path: marketing/hero
300
+ withFrame: true
301
+ `)
302
+ rnxExit(0)
303
+ }
304
+
305
+ const unexpected = findUnexpectedFlowArgs(args)
306
+ if (unexpected.length > 0) {
307
+ for (const problem of unexpected) {
308
+ console.error(` error: ${problem.message}`)
309
+ }
310
+ console.error('\n run `rnx maestro --help` for the flags this command accepts')
311
+ rnxExit(1)
312
+ }
313
+
314
+ const flowPath = args[0]
315
+ if (!fs.existsSync(flowPath)) {
316
+ console.error(` error: ${flowPath} not found`)
317
+ rnxExit(1)
318
+ }
319
+
320
+ const getFlag = (name: string) => args.find((_, i) => args[i - 1] === name)
321
+ const profile = args.includes('--profile')
322
+ // --headed forces a fresh visible window: implies --new and overrides
323
+ // --headless (you asked to watch it run).
324
+ const headed = args.includes('--headed')
325
+ // deterministic-capture mode. pins everything that would otherwise change
326
+ // between two frames of an idle screen, so `takeScreenshot` reaches its
327
+ // three identical frames on the first try instead of hunting for them.
328
+ const proofMode = args.includes('--proof')
329
+ const openInNewSim = args.includes('--new') || headed
330
+ const outDir = getFlag('--out') || '/tmp/rnx-recordings'
331
+ // when --screenshots isn't explicitly set, default to the --out directory.
332
+ // this matches what most users expect: one flag controls the whole output
333
+ // bundle (recording + screenshots) for a demo run.
334
+ // previously, screenshots silently landed in /tmp/rnx-flow even when
335
+ // --out was set, requiring a second --screenshots flag to redirect them.
336
+ const screenshotDir =
337
+ getFlag('--screenshots') || (getFlag('--out') ? outDir : '/tmp/rnx-flow')
338
+ const screenshotPathMode =
339
+ getFlag('--screenshot-paths') === 'flow' ? 'flow' : ('dir' as const)
340
+ // --no-shell makes every takeScreenshot in the flow capture tenant-only
341
+ // (no status bar, keyboard, notification center). useful for demo asset
342
+ // sets where shell chrome would obscure the app or stale overlays would
343
+ // pollute the result. per-step `layers:` still wins over this default.
344
+ const screenshotLayers: 'full' | 'tenant' | 'shell' | undefined = args.includes(
345
+ '--no-shell',
346
+ )
347
+ ? 'tenant'
348
+ : args.includes('--shell-only')
349
+ ? 'shell'
350
+ : undefined
351
+ const deviceOverride = getFlag('--device')?.trim() || ''
352
+ // --device and --theme are also global flags, but a global sets the settings
353
+ // store inside THIS process and the sim renders in the shell's browser page,
354
+ // so `rnx --theme light maestro test` reaches nothing. both have to be read
355
+ // here and pushed over the bridge, and they are read the same way so a caller
356
+ // that gets one right cannot get the other silently ignored.
357
+ const themeOverride = getFlag('--theme')?.trim() || ''
358
+ const slow = args.includes('--slow') ? +(getFlag('--slow') || '500') : 0
359
+ const cliElectron = args.includes('--electron')
360
+ const preview = args.includes('--preview')
361
+ // only --preview reads this (start event, entitlement origin, uploader).
362
+ // resolving it unconditionally probed https://contrast.localhost:3000 and
363
+ // charged every non-preview flow the probe's full 2s timeout whenever no
364
+ // local Contrast stack was listening.
365
+ const previewOrigin = preview
366
+ ? await resolveDefaultUploadOrigin(getFlag('--preview-origin'))
367
+ : ''
368
+ const previewPublicOrigin = getFlag('--preview-public-origin')
369
+ const previewOpen = args.includes('--preview-open')
370
+ const previewOwner = getFlag('--owner')?.trim() || ''
371
+ const previewRepo = getFlag('--repo')?.trim() || ''
372
+ const previewBillingKind = getFlag('--billing-kind')?.trim() || ''
373
+ const previewRunScope = preview
374
+ ? `maestro:${
375
+ process.env.GITHUB_RUN_ID
376
+ ? `github:${process.env.GITHUB_RUN_ID}:attempt:${process.env.GITHUB_RUN_ATTEMPT || '1'}:job:${process.env.GITHUB_JOB || 'preview'}`
377
+ : `local:${randomUUID()}`
378
+ }:flow:${createHash('sha256')
379
+ .update(path.relative(process.cwd(), path.resolve(flowPath)))
380
+ .digest('hex')
381
+ .slice(0, 16)}`
382
+ : ''
383
+ if (previewBillingKind && previewBillingKind !== 'test_run') {
384
+ console.error(` error: invalid --billing-kind: ${previewBillingKind}`)
385
+ rnxExit(1)
386
+ }
387
+ const replaceArgs = args.flatMap((arg, index) =>
388
+ args[index - 1] === '--replace' ? ['--replace', arg] : [],
389
+ )
390
+ // forward --remap <host:port>=<host:port> to the open command so the sim
391
+ // boots with the guest-fetch network remap (config.network.remap).
392
+ const remapArgs = args.flatMap((arg, index) =>
393
+ args[index - 1] === '--remap' ? ['--remap', arg] : [],
394
+ )
395
+ // --preview implies --record: the preview page layers the mp4 over
396
+ // the canvas during scrub so the viewer sees smooth motion while the
397
+ // real app is catching up. upload chains the video in automatically.
398
+ const record = args.includes('--record') || preview
399
+ // hold the final UI state in the recording so viewers see the result
400
+ // of the flow, not just a frame mid-transition. short flows otherwise
401
+ // cut off ~600ms in. caller can override with --tail-wait <ms>.
402
+ const tailWaitMs = args.includes('--tail-wait')
403
+ ? Math.max(0, +(getFlag('--tail-wait') || '2000'))
404
+ : record
405
+ ? preview
406
+ ? 6000
407
+ : 2000
408
+ : 0
409
+
410
+ // parse flow file (frontmatter + steps). `${...}` templates stay verbatim
411
+ // here — the runner interpolates per step at execution time.
412
+ const flowContent = fs.readFileSync(flowPath, 'utf8')
413
+ const { frontmatter, steps } = parseFlowFile(flowContent)
414
+ if (steps.length === 0) {
415
+ console.error(' error: flow file must contain a YAML array of steps')
416
+ rnxExit(1)
417
+ }
418
+ const targetDevice = deviceOverride || frontmatter.device || ''
419
+ const targetTheme = themeOverride || frontmatter.theme || ''
420
+
421
+ const wantElectron = cliElectron || frontmatter.electron === true
422
+ const rawTargetApp =
423
+ frontmatter.app === undefined || frontmatter.app === null
424
+ ? ''
425
+ : typeof frontmatter.app === 'number'
426
+ ? String(frontmatter.app)
427
+ : frontmatter.app
428
+ // accept `app: <demo-name>` (e.g. `app: 3pc`) in addition to ports/URLs.
429
+ // resolve via APPS — the launcher's preferredPort is the explicit target.
430
+ const targetAppResolution = await resolveAppFrontmatterTarget(rawTargetApp)
431
+ const targetApp = targetAppResolution.target
432
+
433
+ const parsedBridgeArgs = parseBridgeCliArgs(args, FLOW_BRIDGE_ARG_OPTIONS)
434
+ const url = getFlag('--app') || getFlag('--url') || ''
435
+
436
+ // --base-url pins the run to one shell, so a caller can build an engine from
437
+ // a known tree, serve it, and measure that build instead of whatever the
438
+ // discovered shell happens to hold. the page and the bridge have to stay in
439
+ // one world: without this retarget the flow would open the pinned shell and
440
+ // then poll the default world's bridge, waiting out its timeout with no sign
441
+ // of which shell it actually ran against.
442
+ const pinnedShellBaseUrl = getFlag('--base-url') || ''
443
+ if (pinnedShellBaseUrl) {
444
+ const pinned = resolveBridgePortForPin({
445
+ baseUrl: pinnedShellBaseUrl,
446
+ wsPort: parsedBridgeArgs.wsPort,
447
+ explicitPort: parsedBridgeArgs.explicitPort,
448
+ })
449
+ if (pinned.error !== undefined) {
450
+ console.error(` error: ${pinned.error}`)
451
+ rnxExit(1)
452
+ } else {
453
+ parsedBridgeArgs.wsPort = pinned.port
454
+ }
455
+ }
456
+ if (wantElectron && !findDesktopCompanion()) {
457
+ console.error(
458
+ ' error: desktop companion not found. install or build it first with `bun run build:electron`',
459
+ )
460
+ rnxExit(1)
461
+ }
462
+
463
+ const mode = wantElectron ? 'desktop companion' : 'bridge'
464
+ const appLabel = targetApp ? ` | target: ${targetApp}` : ''
465
+ console.log(`
466
+ rnx maestro — ${path.basename(flowPath)}
467
+ ${steps.length} steps | ${mode}${appLabel}${record ? ' | recording' : ''}${profile ? ' | profiling' : ''}${slow ? ` | ${slow}ms delay` : ''}
468
+ `)
469
+
470
+ let openTarget = url || targetApp || ''
471
+ const effectiveOpenInNewSim =
472
+ openInNewSim || (wantElectron && parsedBridgeArgs.simIdSource !== 'flag')
473
+ const driverFlag = getFlag('--driver') || ''
474
+ let closeFlowOwnedSimAfterRun = false
475
+ const ownedSimIds = new Set<string>()
476
+
477
+ // if we weren't told exactly where to go, and there's already a primary
478
+ // open tab, just reuse it. opening a fresh tab that auto-discovers some
479
+ // other dev server on the host is almost never what the user wants — they
480
+ // already pointed the current tab at the app they want to drive.
481
+ let reuseActivePrimary = false
482
+ if (!openTarget && !effectiveOpenInNewSim && parsedBridgeArgs.simIdSource === 'none') {
483
+ const probe = createBridgeFromParsed(parsedBridgeArgs)
484
+ try {
485
+ const sims = await probe.listSims()
486
+ const primary = sims.find((b) => b.isPrimary && b.readyState === 'open')
487
+ const firstOpen = sims.find((b) => b.readyState === 'open')
488
+ const active = primary ?? firstOpen
489
+ if (active) {
490
+ reuseActivePrimary = true
491
+ console.log(` reusing active sim: ${active.id}`)
492
+ }
493
+ } catch {
494
+ // bridge not reachable — fall through to discovery
495
+ } finally {
496
+ probe.close()
497
+ }
498
+ }
499
+
500
+ if (
501
+ !openTarget &&
502
+ !reuseActivePrimary &&
503
+ (effectiveOpenInNewSim || parsedBridgeArgs.simIdSource === 'none')
504
+ ) {
505
+ const discovered = await discoverSootsimUrl()
506
+ if (!discovered) {
507
+ console.error(' error: no current sim and no rnx target found')
508
+ rnxExit(1)
509
+ }
510
+ openTarget = discovered
511
+ }
512
+
513
+ if (openTarget && targetDevice) {
514
+ const shellBaseUrl = applyRNXConfigToUrl(
515
+ pinnedShellBaseUrl || resolveShellBaseUrlForBridgePort(parsedBridgeArgs.wsPort),
516
+ targetAppResolution.runtimeConfig,
517
+ )
518
+ const url = new URL(
519
+ await buildShellUrl(openTarget, shellBaseUrl, { proof: proofMode }),
520
+ )
521
+ url.searchParams.set('device', targetDevice)
522
+ openTarget = url.toString()
523
+ }
524
+
525
+ if (openTarget) {
526
+ const openArgs = [openTarget]
527
+ if (pinnedShellBaseUrl) openArgs.push('--base-url', pinnedShellBaseUrl)
528
+ openArgs.push(...replaceArgs)
529
+ openArgs.push(...remapArgs)
530
+ if (effectiveOpenInNewSim) {
531
+ openArgs.push('--new')
532
+ }
533
+ openArgs.push('--no-describe')
534
+ if (parsedBridgeArgs.simIdSource === 'flag' && parsedBridgeArgs.simId) {
535
+ openArgs.push('--sim', parsedBridgeArgs.simId)
536
+ }
537
+ if (driverFlag) {
538
+ openArgs.push('--driver', driverFlag)
539
+ }
540
+ if (args.includes('--headless') && !headed) {
541
+ openArgs.push('--headless')
542
+ }
543
+ if (proofMode) openArgs.push('--proof')
544
+ const opened = await runOpenCommand(openArgs, {
545
+ port: parsedBridgeArgs.wsPort,
546
+ timeoutMs: parsedBridgeArgs.commandTimeoutMs,
547
+ runtimeConfig: targetAppResolution.runtimeConfig,
548
+ })
549
+ // a flow that launched its own sim owns it, and owning it means closing it.
550
+ // the detached host's watchdog polls the *top* ancestor
551
+ // (getStableOwnerPid -> findTopAncestor), which is the shell, agent
552
+ // session, or runner daemon, not the run. that pid can live for days, so
553
+ // a launched sim nobody closes stays open and keeps its flow lock.
554
+ closeFlowOwnedSimAfterRun = opened?.launched === true || !!driverFlag
555
+ if (closeFlowOwnedSimAfterRun && opened) ownedSimIds.add(opened.simId)
556
+ }
557
+
558
+ const resolvedBridgeArgs = parseBridgeCliArgs(args, FLOW_BRIDGE_ARG_OPTIONS)
559
+ const pinnedSimId =
560
+ resolvedBridgeArgs.simIdSource === 'flag'
561
+ ? resolvedBridgeArgs.simId
562
+ : readCurrentSimId() || resolvedBridgeArgs.simId
563
+ // carry the source through so the target-sim notice reads `via --sim`
564
+ // (not the misleading `primary fallback — no sim pinned`) when the flow
565
+ // was actually pinned with --sim or a `rnx use` selection.
566
+ const pinnedSimIdSource: typeof resolvedBridgeArgs.simIdSource =
567
+ resolvedBridgeArgs.simIdSource === 'flag' ? 'flag' : pinnedSimId ? 'saved' : 'none'
568
+ const bridge = createBridge(resolvedBridgeArgs.wsPort, {
569
+ commandTimeoutMs: resolvedBridgeArgs.commandTimeoutMs,
570
+ simId: pinnedSimId,
571
+ simIdSource: pinnedSimIdSource,
572
+ // the shell prints `lockedBy` over the device while the flow drives it.
573
+ // without a label that falls back to the cli identity key, which is an
574
+ // internal `ENV_VAR:value` string rather than something a viewer reads.
575
+ cliLabel: 'maestro test',
576
+ })
577
+ let driver: SootSimBridgeFlowRunner
578
+ const applySimSettings = async () => {
579
+ const statusBarTime = frontmatter.env?.RNX_STATUS_BAR_TIME
580
+ if (statusBarTime) {
581
+ if (!/^(?:[1-9]|1[0-2]):[0-5][0-9]$/.test(statusBarTime)) {
582
+ throw new Error('RNX_STATUS_BAR_TIME must be a 12-hour time such as 9:41')
583
+ }
584
+ await bridge.send({
585
+ type: 'evaluate',
586
+ simId: driver.simId,
587
+ code: `window.dispatchEvent(new CustomEvent('sootsim:statusBarOverride', { detail: { time: ${JSON.stringify(statusBarTime)} } }))`,
588
+ })
589
+ }
590
+ if (targetTheme) {
591
+ await bridge.send({
592
+ type: 'call',
593
+ simId: driver.simId,
594
+ path: 'SootSim.bridges.settings.set',
595
+ args: ['colorScheme', targetTheme],
596
+ })
597
+ }
598
+ if (!targetDevice) return false
599
+ return Boolean(
600
+ await bridge.send({
601
+ type: 'call',
602
+ simId: driver.simId,
603
+ path: 'SootSim.bridges.settings.set',
604
+ args: ['deviceModel', targetDevice],
605
+ }),
606
+ )
607
+ }
608
+ driver = new SootSimBridgeFlowRunner(bridge, {
609
+ screenshotDir,
610
+ flowDir: path.dirname(path.resolve(flowPath)),
611
+ screenshotPathMode,
612
+ screenshotLayers,
613
+ simId: pinnedSimId,
614
+ recordingOutputDir: record ? outDir : undefined,
615
+ recordingFormat: preview ? 'mp4' : 'webm',
616
+ // preview mode uploads to previewOrigin; entitlement check must
617
+ // talk to the same origin, not whatever the shared desktop sim
618
+ // happens to have cached.
619
+ billingOriginOverride: preview ? previewOrigin : undefined,
620
+ // any recording reached via a github installation token is a ci /
621
+ // pr-preview run, so allow it for preview uploads. local flow recording
622
+ // writes a file and matches `rnx record --mode video`.
623
+ allowGitHubRecording: preview,
624
+ requireRecordingEntitlement: preview,
625
+ // re-arm the event recorder after every launchApp-with-reload. the
626
+ // recorder lives in-page state and gets wiped by window.location.reload.
627
+ onAfterLaunch: async (simId) => {
628
+ if (simId) saveCurrentSimId(simId)
629
+ const deviceChanged = await applySimSettings()
630
+ if (deviceChanged) await driver.waitForTree(120000)
631
+ if (!preview) return
632
+ await bridge.send({
633
+ type: 'evaluate',
634
+ simId: driver.simId,
635
+ code: `(() => {
636
+ const r = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
637
+ return r?.start?.() ?? false
638
+ })()`,
639
+ })
640
+ },
641
+ })
642
+ if (slow) driver.stepDelay = slow
643
+ // live step rail + pause gate in the sim devtools "test" tab, named by the
644
+ // flow file so the rail identifies what is running.
645
+ driver.liveStatus = new FlowLiveStatusReporter(
646
+ bridge,
647
+ path.basename(flowPath),
648
+ flowContent,
649
+ () => driver.simId,
650
+ )
651
+ // maestro default vars + flow-level `env:` frontmatter, defined into the
652
+ // flow's JS context before the first step (upstream withDefaultEnvVars +
653
+ // DefineVariablesCommand).
654
+ driver.js.putEnv('MAESTRO_FILENAME', path.basename(flowPath, path.extname(flowPath)))
655
+ if (frontmatter.env && typeof frontmatter.env === 'object') {
656
+ for (const [key, value] of Object.entries(frontmatter.env)) {
657
+ driver.js.putEnv(key, value)
658
+ }
659
+ }
660
+
661
+ let profileResult: SootSimFlowProfileResult | null = null
662
+ let videoPath: string | null = null
663
+ let videoDurationMs: number | null = null
664
+ let capturedEvents: Array<Record<string, any>> | null = null
665
+ let previewScreenshotPath: string | null = null
666
+ let failureBundleDir: string | null = null
667
+ let exitCode = 0
668
+ const startsWithLaunchApp = !!steps[0]?.launchApp
669
+ try {
670
+ if (!startsWithLaunchApp) {
671
+ await driver.waitForTree(120000)
672
+ }
673
+ const deviceChanged = await applySimSettings()
674
+ if (deviceChanged && !startsWithLaunchApp) await driver.waitForTree(120000)
675
+ // preview replay must reproduce the SAME initial conditions the agent
676
+ // explored from. the explore session runs on a fresh ephemeral profile
677
+ // (cold, logged-out), so its recorded steps assume that cold start —
678
+ // e.g. step 1 taps a dev/login button. but the replay tab reuses the
679
+ // explore tab's now-dirtied storage (persisted auth/session), so without
680
+ // a reset the app boots already-authenticated and the recorded login
681
+ // step targets an element that no longer exists — the flow hangs on
682
+ // step 1 ("made no progress … bridge or bundle probably hung"). flows
683
+ // that open with an explicit launchApp manage their own state; every
684
+ // other preview flow gets a clean cold launch here so the recording
685
+ // captures the same journey from the same starting point. app-agnostic,
686
+ // no per-repo configuration — the bot just figures it out.
687
+ // skip when the caller already established a clean cold state (e.g. the
688
+ // contrastbot run.sh pre-replay reset reopens an --ephemeral sim: fresh
689
+ // profile, logged-out, brought to readiness). a second clearState+reload
690
+ // here is redundant and triggers a sim-id rotation on the reload that
691
+ // routes the flow to a stale sim, hanging step 1 ("made no progress …
692
+ // bridge or bundle probably hung"). one reset is enough — the bot
693
+ // signals it did one via RNX_PREVIEW_RESET_DONE.
694
+ if (preview && !startsWithLaunchApp && !process.env.RNX_PREVIEW_RESET_DONE) {
695
+ await driver.launchApp({ clearState: true })
696
+ }
697
+ if (record) {
698
+ if (startsWithLaunchApp) {
699
+ driver.prepareRecording()
700
+ } else {
701
+ await driver.startRecording()
702
+ }
703
+ }
704
+ if (profile) {
705
+ await driver.startProfile()
706
+ }
707
+ if (preview) {
708
+ // start the preview event recorder on the tab so agent-driven taps
709
+ // (via sootsim:agentAction) land in the capture stream for replay.
710
+ // some shells expose the recorder via window.SootSim, others via
711
+ // the legacy alias only — try both.
712
+ const started = await bridge.send({
713
+ type: 'evaluate',
714
+ simId: driver.simId,
715
+ code: `(() => {
716
+ const r = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
717
+ return r?.start?.() ?? false
718
+ })()`,
719
+ })
720
+ if (!started) {
721
+ console.warn(
722
+ ' warn: eventRecorder.start() returned false — preview will upload bundle without replay events',
723
+ )
724
+ }
725
+ }
726
+ await driver.runFlow(steps)
727
+ if (profile) {
728
+ profileResult = await driver.stopProfile()
729
+ }
730
+ await driver.waitForRecordingTail({ maxMs: tailWaitMs, smart: preview })
731
+ if (record) {
732
+ videoPath = await driver.stopRecording()
733
+ videoDurationMs = driver.getLastRecordingDurationMs()
734
+ }
735
+ if (preview) {
736
+ capturedEvents = (await bridge.send({
737
+ type: 'evaluate',
738
+ simId: driver.simId,
739
+ code: `(() => {
740
+ const r = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
741
+ return r?.stop?.() ?? []
742
+ })()`,
743
+ })) as Array<Record<string, any>>
744
+ console.log(` captured ${capturedEvents.length} replay events`)
745
+ previewScreenshotPath = path.join(screenshotDir, 'preview-final.png')
746
+ await driver.captureScreenshot(previewScreenshotPath)
747
+ console.log(` final screenshot: ${previewScreenshotPath}`)
748
+ }
749
+
750
+ const screenshotSteps = steps.filter(
751
+ (s: any) => s && typeof s === 'object' && 'takeScreenshot' in s,
752
+ ).length
753
+ console.log(`\n + completed (${steps.length} steps)`)
754
+ if (screenshotSteps > 0) {
755
+ // surface where the assets actually landed so the user doesn't have to
756
+ // grep through verbose [flow] step output for paths
757
+ console.log(` screenshots: ${screenshotSteps} → ${screenshotDir}`)
758
+ }
759
+ if (profileResult) {
760
+ printFlowProfile(profileResult)
761
+ }
762
+ if (videoPath) {
763
+ console.log(` video: ${videoPath}`)
764
+ }
765
+ console.log()
766
+ } catch (err: any) {
767
+ if (profile && !profileResult) {
768
+ try {
769
+ profileResult = await driver.stopProfile()
770
+ } catch {
771
+ // ignore profiling teardown failures while already handling the main error
772
+ }
773
+ }
774
+ if (record && !videoPath) {
775
+ try {
776
+ videoPath = await driver.stopRecording()
777
+ videoDurationMs = driver.getLastRecordingDurationMs()
778
+ } catch (recordErr: any) {
779
+ console.warn(
780
+ ` recording stop failed: ${recordErr?.message || String(recordErr)}`,
781
+ )
782
+ }
783
+ }
784
+ if (preview && !capturedEvents) {
785
+ try {
786
+ capturedEvents = (await bridge.send({
787
+ type: 'evaluate',
788
+ simId: driver.simId,
789
+ code: `(() => {
790
+ const r = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
791
+ return r?.stop?.() ?? []
792
+ })()`,
793
+ })) as Array<Record<string, any>>
794
+ console.log(` captured ${capturedEvents.length} partial replay events`)
795
+ } catch (eventErr: any) {
796
+ console.warn(
797
+ ` event recorder stop failed: ${eventErr?.message || String(eventErr)}`,
798
+ )
799
+ }
800
+ }
801
+ console.error(`\n x failed: ${err.message}\n`)
802
+ if (profileResult) {
803
+ console.log(' partial profile:')
804
+ printFlowProfile(profileResult)
805
+ }
806
+ if (videoPath) {
807
+ console.log(` partial video: ${videoPath}`)
808
+ }
809
+ try {
810
+ // rich per-failure bundle: screenshot + describe.json + a11y +
811
+ // tree + console + failed-requests. dir is named by the failing
812
+ // step so consecutive failing runs don't overwrite each other.
813
+ const failedStep = driver.lastFailedStep
814
+ const bundleName = failedStep
815
+ ? `step-${String(failedStep.index + 1).padStart(2, '0')}-${failedStep.kind}`
816
+ : 'unstaged'
817
+ const bundleDir = path.join(screenshotDir, bundleName)
818
+ await driver.captureFailureBundle(bundleDir, {
819
+ error: err,
820
+ stepIndex: failedStep?.index,
821
+ stepKind: failedStep?.kind,
822
+ stepTarget: failedStep?.target,
823
+ })
824
+ failureBundleDir = bundleDir
825
+ console.log(` failure bundle: ${bundleDir}`)
826
+ console.log(
827
+ ` (contents: screenshot.png, describe.json, a11y.txt, tree.txt, console.json, error.json)`,
828
+ )
829
+ } catch (bundleErr) {
830
+ // fall back to the bare screenshot if the full bundle blew up —
831
+ // at least the viewer has one thing to look at.
832
+ try {
833
+ const errShot = path.join(screenshotDir, 'error.png')
834
+ await driver.captureScreenshot(errShot)
835
+ console.log(` error screenshot: ${errShot}`)
836
+ } catch {}
837
+ console.log(
838
+ ` (failure bundle capture failed: ${
839
+ bundleErr instanceof Error ? bundleErr.message : String(bundleErr)
840
+ })`,
841
+ )
842
+ }
843
+ await printBridgeFailureDiagnostics(bridge, {
844
+ errorsCommand: `rnx get errors 5${driver.simId ? ` --sim ${driver.simId}` : ''}`,
845
+ warningsCommand: `rnx get warnings 5${driver.simId ? ` --sim ${driver.simId}` : ''}`,
846
+ requestsCommand: `rnx get requests 5${driver.simId ? ` --sim ${driver.simId}` : ''}`,
847
+ })
848
+ exitCode = 1
849
+ }
850
+
851
+ await driver.liveStatus?.end(exitCode === 0 ? 'passed' : 'failed')
852
+
853
+ // under --preview, kick the captured events into the preview uploader so the viewer
854
+ // gets a real /preview/<id> with scrubbable replay. failed flows upload the
855
+ // partial recording when available so dashboard runs can link to the trace.
856
+ if (preview && (exitCode === 0 || videoPath || capturedEvents)) {
857
+ try {
858
+ const eventsJsonlGz = await prepareEventsFile(capturedEvents ?? [])
859
+ const uploadArgs = ['--origin', previewOrigin, '--events', eventsJsonlGz]
860
+ if (previewPublicOrigin) {
861
+ uploadArgs.push('--public-origin', previewPublicOrigin)
862
+ }
863
+ // chain the recorded mp4 so preview-page can layer it over the
864
+ // canvas during scrub for smooth motion while the real app
865
+ // catches up.
866
+ if (videoPath) uploadArgs.push('--video', videoPath)
867
+ if (previewScreenshotPath) {
868
+ uploadArgs.push('--screenshot', previewScreenshotPath)
869
+ }
870
+ if (videoPath && videoDurationMs && Number.isFinite(videoDurationMs)) {
871
+ uploadArgs.push('--video-duration-ms', String(Math.round(videoDurationMs)))
872
+ uploadArgs.push('--recorded-duration-ms', String(Math.round(videoDurationMs)))
873
+ }
874
+ const recordingStartedAtMs = driver.getLastRecordingStartedAtMs()
875
+ if (recordingStartedAtMs && Number.isFinite(recordingStartedAtMs)) {
876
+ uploadArgs.push(
877
+ '--recording-started-at-ms',
878
+ String(Math.round(recordingStartedAtMs)),
879
+ )
880
+ }
881
+ if (parsedBridgeArgs.wsPort) {
882
+ uploadArgs.push('--port', String(parsedBridgeArgs.wsPort))
883
+ }
884
+ if (driver.simId) {
885
+ uploadArgs.push('--sim', driver.simId)
886
+ }
887
+ if (previewOpen) uploadArgs.push('--open')
888
+ if (previewOwner) uploadArgs.push('--owner', previewOwner)
889
+ if (previewRepo) uploadArgs.push('--repo', previewRepo)
890
+ uploadArgs.push('--run-scope', previewRunScope)
891
+ if (previewBillingKind) {
892
+ uploadArgs.push('--billing-kind', previewBillingKind)
893
+ }
894
+ console.log(`\n preparing preview upload…`)
895
+ const flowTraceSteps = driver.getFlowTraceSteps()
896
+ if (flowTraceSteps.length > 0) {
897
+ uploadArgs.push(
898
+ '--timeline-events',
899
+ await prepareFlowStepTimelineFile(flowTraceSteps),
900
+ )
901
+ }
902
+ const snapshotManifest = await prepareFlowSnapshotManifest(
903
+ flowTraceSteps,
904
+ recordingStartedAtMs,
905
+ )
906
+ if (snapshotManifest) {
907
+ uploadArgs.push('--snapshot-manifest', snapshotManifest)
908
+ }
909
+ if (failureBundleDir) {
910
+ // failed runs ship the per-step debug bundle with the share so the
911
+ // dashboard's run row can show the failing screen + full context.
912
+ uploadArgs.push('--failure-bundle', failureBundleDir)
913
+ }
914
+ const frameStats = driver.getLastRecordingFrameStats()
915
+ if (frameStats) {
916
+ uploadArgs.push('--trace-frame-stats', await prepareFrameStatsFile(frameStats))
917
+ }
918
+ const { runUpload } = await import('./upload')
919
+ lastPreviewUploadResult = await runUpload(uploadArgs, {})
920
+ } catch (err: any) {
921
+ console.error(` preview upload failed: ${err?.message || err}`)
922
+ exitCode = 1
923
+ }
924
+ }
925
+
926
+ lastFlowTraceSteps = driver.getFlowTraceSteps()
927
+
928
+ bridge.close()
929
+
930
+ if (closeFlowOwnedSimAfterRun) {
931
+ if (driver.simId) ownedSimIds.add(driver.simId)
932
+ await closeFlowOwnedSims(parsedBridgeArgs.wsPort, parsedBridgeArgs.commandTimeoutMs, [
933
+ ...ownedSimIds,
934
+ ])
935
+ } else if (driver.simId && driver.simId !== pinnedSimId) {
936
+ // `launchApp clearState` rotates the sim id mid-flow (the shell reconnects
937
+ // under a fresh id). the runner tracks that in-memory, but a directory run
938
+ // (`rnx maestro test <dir>`) spawns the next flow fresh — it re-reads
939
+ // the saved sim id, which would still point at the pre-rotation sim and
940
+ // fail with "sim not responding / stale orphan". persist the rotated id so
941
+ // the next flow reuses the live sim. only when we're NOT closing it.
942
+ saveCurrentSimId(driver.simId)
943
+ }
944
+
945
+ return exitCode
946
+ }
947
+
948
+ async function closeFlowOwnedSims(
949
+ wsPort: number,
950
+ commandTimeoutMs: number,
951
+ simIds: string[],
952
+ ): Promise<void> {
953
+ const ids = [...new Set(simIds.filter(Boolean))]
954
+ if (ids.length === 0) return
955
+ const bridge = createBridge(wsPort, { commandTimeoutMs })
956
+ try {
957
+ const sims = await bridge.listSims()
958
+ const openIds = ids.filter((id) =>
959
+ sims.some((sim) => sim.id === id && sim.readyState === 'open'),
960
+ )
961
+ if (openIds.length === 0) return
962
+ const result = await closeSimsBulk(bridge, wsPort, commandTimeoutMs, openIds)
963
+ await terminatePlaywrightHostsForSims(sims, openIds)
964
+ if (result.closed.length > 0) {
965
+ console.log(` closed flow sim(s): ${result.closed.join(', ')}`)
966
+ }
967
+ if (result.remaining.length > 0) {
968
+ console.warn(
969
+ ` warn: flow sim(s) still connected after close: ${result.remaining.join(', ')}`,
970
+ )
971
+ }
972
+ } catch (err) {
973
+ console.warn(
974
+ ` warn: failed to close flow sim(s): ${
975
+ err instanceof Error ? err.message : String(err)
976
+ }`,
977
+ )
978
+ } finally {
979
+ bridge.close()
980
+ }
981
+ }
982
+
983
+ // write the captured event stream to a gzipped jsonl file in a temp dir.
984
+ // returns the absolute path for the upload command to pick up.
985
+ async function prepareEventsFile(events: Array<Record<string, any>>): Promise<string> {
986
+ const { gzipSync } = await import('zlib')
987
+ const lines =
988
+ events.map((e) => JSON.stringify(e)).join('\n') + (events.length ? '\n' : '')
989
+ const gz = gzipSync(Buffer.from(lines, 'utf8'))
990
+ const outPath = path.join(tmpdir(), `rnx-events-${Date.now()}.jsonl.gz`)
991
+ fs.writeFileSync(outPath, gz)
992
+ console.log(` events: ${events.length} written to ${outPath} (${gz.length} bytes gz)`)
993
+ return outPath
994
+ }
995
+
996
+ async function prepareFlowStepTimelineFile(
997
+ steps: SootSimFlowTraceStep[],
998
+ ): Promise<string> {
999
+ const { gzipSync } = await import('zlib')
1000
+ const events = steps.map((step, index) => ({
1001
+ schemaVersion: 1,
1002
+ t: step.endedAtMs,
1003
+ seq: 1_000_000 + index,
1004
+ context: 'host',
1005
+ kind: 'flow-step',
1006
+ id: `flow-step-${step.stepIndex}`,
1007
+ data: {
1008
+ stepIndex: step.stepIndex,
1009
+ stepNumber: step.stepIndex + 1,
1010
+ stepName: step.stepName,
1011
+ ...(step.targetLabel ? { targetLabel: step.targetLabel } : {}),
1012
+ status: step.status,
1013
+ durationMs: step.durationMs,
1014
+ startedAtMs: step.startedAtMs,
1015
+ endedAtMs: step.endedAtMs,
1016
+ ...(step.error ? { error: step.error } : {}),
1017
+ },
1018
+ }))
1019
+ const lines = events.map((event) => JSON.stringify(event)).join('\n') + '\n'
1020
+ const gz = gzipSync(Buffer.from(lines, 'utf8'))
1021
+ const outPath = path.join(tmpdir(), `rnx-flow-steps-${Date.now()}.jsonl.gz`)
1022
+ fs.writeFileSync(outPath, gz)
1023
+ console.log(
1024
+ ` flow trace: ${events.length} step events written to ${outPath} (${gz.length} bytes gz)`,
1025
+ )
1026
+ return outPath
1027
+ }
1028
+
1029
+ async function prepareFlowSnapshotManifest(
1030
+ steps: SootSimFlowTraceStep[],
1031
+ recordingStartedAtMs: number | null,
1032
+ ): Promise<string | null> {
1033
+ if (!recordingStartedAtMs) return null
1034
+ const snapshots = steps.flatMap((step) =>
1035
+ step.screenshotPath
1036
+ ? [
1037
+ {
1038
+ id: `flow-step-${step.stepIndex}`,
1039
+ label: step.targetLabel ?? `Step ${step.stepIndex + 1}`,
1040
+ kind: 'interaction',
1041
+ t: Math.max(0, step.endedAtMs - recordingStartedAtMs),
1042
+ path: step.screenshotPath,
1043
+ },
1044
+ ]
1045
+ : [],
1046
+ )
1047
+ if (snapshots.length === 0) return null
1048
+ const outPath = path.join(tmpdir(), `rnx-flow-snapshots-${Date.now()}.json`)
1049
+ fs.writeFileSync(outPath, JSON.stringify(snapshots))
1050
+ console.log(` flow snapshots: ${snapshots.length} written to ${outPath}`)
1051
+ return outPath
1052
+ }
1053
+
1054
+ async function prepareFrameStatsFile(frameStats: unknown): Promise<string> {
1055
+ const outPath = path.join(tmpdir(), `rnx-frame-stats-${Date.now()}.json`)
1056
+ fs.writeFileSync(outPath, JSON.stringify(frameStats))
1057
+ console.log(` frame stats: written to ${outPath}`)
1058
+ return outPath
1059
+ }
1060
+
1061
+ async function runFlowSession(args: string[]) {
1062
+ const subcommand = args[0]
1063
+ const getFlag = (name: string) => args.find((_, i) => args[i - 1] === name)
1064
+
1065
+ switch (subcommand) {
1066
+ case 'start': {
1067
+ const { path, state } = startFlowSession()
1068
+ console.log(` flow draft started`)
1069
+ console.log(` session: ${path}`)
1070
+ console.log(` steps: ${state.steps.length}`)
1071
+ return
1072
+ }
1073
+
1074
+ case 'keep': {
1075
+ const result = keepFlowCandidate()
1076
+ if (!result.active) {
1077
+ console.error(' no active flow draft — run `rnx maestro start` first')
1078
+ rnxExit(1)
1079
+ }
1080
+ if (!result.kept) {
1081
+ console.log(' no pending action to keep')
1082
+ return
1083
+ }
1084
+ console.log(` kept: ${result.candidate.summary}`)
1085
+ console.log(` steps: ${result.stepCount}`)
1086
+ return
1087
+ }
1088
+
1089
+ case 'end': {
1090
+ const outputPath =
1091
+ getFlag('--output') || (args[1] && !args[1].startsWith('-') ? args[1] : undefined)
1092
+ const validate = args.includes('--validate') || args.includes('--video')
1093
+ const video = args.includes('--video')
1094
+ // ending the draft commits the trailing pending action. the last
1095
+ // successful `do` is presumably one the user wants; without this,
1096
+ // `maestro start → do → maestro end` (no explicit keep) exported an empty
1097
+ // [] and silently dropped the work (F13-4).
1098
+ const autoKept = keepFlowCandidate()
1099
+ if (autoKept.active && autoKept.kept) {
1100
+ console.log(` auto-kept trailing action: ${autoKept.candidate.summary}`)
1101
+ }
1102
+ const result = exportFlowSession(
1103
+ outputPath || (validate ? createDraftValidationPath() : undefined),
1104
+ )
1105
+ if (!result.active) {
1106
+ console.error(' no active flow draft — run `rnx maestro start` first')
1107
+ rnxExit(1)
1108
+ }
1109
+ if (!result.valid) {
1110
+ console.error(' flow draft is not valid:')
1111
+ for (const issue of result.issues) console.error(` - ${issue}`)
1112
+ console.error(
1113
+ ' draft preserved — keep at least one real interaction or run `rnx maestro start` to reset',
1114
+ )
1115
+ rnxExit(1)
1116
+ }
1117
+
1118
+ if (validate) {
1119
+ const playbackArgs = buildFlowValidationArgs(args, result.outputPath)
1120
+ const exitCode = await runFlowPlayback(playbackArgs)
1121
+ if (exitCode !== 0) {
1122
+ console.error(
1123
+ '\n validation failed — draft preserved so you can keep iterating',
1124
+ )
1125
+ process.exitCode = exitCode
1126
+ return
1127
+ }
1128
+
1129
+ finalizeFlowSession()
1130
+ console.log(
1131
+ ` flow draft validated (${result.stepCount} step${result.stepCount === 1 ? '' : 's'})`,
1132
+ )
1133
+ if (result.outputPath) {
1134
+ console.log(` saved: ${result.outputPath}`)
1135
+ }
1136
+ if (video) {
1137
+ console.log(' video: recorded during validation run')
1138
+ }
1139
+ return
1140
+ }
1141
+
1142
+ finalizeFlowSession()
1143
+ console.log(
1144
+ ` flow draft ended (${result.stepCount} step${result.stepCount === 1 ? '' : 's'})`,
1145
+ )
1146
+ if (result.outputPath) {
1147
+ console.log(` saved: ${result.outputPath}`)
1148
+ console.log(` next: rnx maestro test ${result.outputPath} --record`)
1149
+ return
1150
+ }
1151
+ console.log('')
1152
+ process.stdout.write(result.yaml)
1153
+ return
1154
+ }
1155
+
1156
+ case 'validate': {
1157
+ const flowPath = args[1]
1158
+ if (!flowPath || flowPath.startsWith('-')) {
1159
+ console.error(' usage: rnx maestro validate <path>')
1160
+ rnxExit(1)
1161
+ }
1162
+ const issues = validateFlowFile(flowPath)
1163
+ if (issues.length > 0) {
1164
+ console.error(` x ${flowPath} failed validation:`)
1165
+ for (const issue of issues) console.error(` - ${issue}`)
1166
+ rnxExit(1)
1167
+ }
1168
+ console.log(` + ${flowPath} looks valid`)
1169
+ return
1170
+ }
1171
+ }
1172
+ }
1173
+
1174
+ // the subcommand / flow-file path is the first positional arg, but a
1175
+ // `--sim <id>` pair can sit in front of it: bin.ts prepends the global
1176
+ // `--sim` to every command's args, and `rnx maestro --sim a2 start` is a
1177
+ // documented form. hoist a leading `--sim <value>` to the end so `args[0]`
1178
+ // is the real subcommand again (downstream getFlag / parseBridgeCliArgs
1179
+ // still find `--sim` wherever it lands). without this, `--sim 6a5 flow
1180
+ // start` parsed `--sim` as the flow path and failed `--sim not found`.
1181
+ export function hoistLeadingSimFlag(args: string[]): string[] {
1182
+ if (args[0] === '--sim' && args.length >= 2) {
1183
+ return [...args.slice(2), '--sim', args[1]]
1184
+ }
1185
+ return args
1186
+ }
1187
+
1188
+ export async function runMaestroAuthoring(rawArgs: string[]): Promise<number> {
1189
+ const args = hoistLeadingSimFlag(rawArgs)
1190
+ const subcommand = args[0]
1191
+ if (
1192
+ subcommand === 'start' ||
1193
+ subcommand === 'keep' ||
1194
+ subcommand === 'good' ||
1195
+ subcommand === 'end' ||
1196
+ subcommand === 'validate'
1197
+ ) {
1198
+ await runFlowSession(args)
1199
+ return 0
1200
+ }
1201
+
1202
+ const exitCode = await runFlowPlayback(args)
1203
+ if (exitCode !== 0) {
1204
+ process.exitCode = exitCode
1205
+ }
1206
+ return exitCode
1207
+ }
1208
+
1209
+ function printFlowProfile(result: SootSimFlowProfileResult) {
1210
+ console.log('')
1211
+ printShellPerfReport(result)
1212
+ }
1213
+ function createDraftValidationPath() {
1214
+ return path.join(tmpdir(), `rnx-flow-draft-${Date.now()}.yaml`)
1215
+ }
1216
+
1217
+ function buildFlowValidationArgs(args: string[], outputPath: string | null) {
1218
+ if (!outputPath) {
1219
+ throw new Error('validated flow draft requires an output path')
1220
+ }
1221
+
1222
+ const nextArgs = [outputPath]
1223
+ for (let i = 1; i < args.length; i++) {
1224
+ const arg = args[i]
1225
+ if (arg === '--validate' || arg === '--video') continue
1226
+ if (arg === '--output') {
1227
+ i += 1
1228
+ continue
1229
+ }
1230
+ if (i === 1 && !arg.startsWith('-')) {
1231
+ continue
1232
+ }
1233
+ nextArgs.push(arg)
1234
+ }
1235
+
1236
+ if (!nextArgs.includes('--record') && args.includes('--video')) {
1237
+ nextArgs.push('--record')
1238
+ }
1239
+ return nextArgs
1240
+ }